Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions src/components/ui/card/card.styles.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { cva, type VariantProps } from 'class-variance-authority';

export const cardFrame = cva('rounded-xl border border-gray-200 bg-white');
const cardFrame = cva('rounded-xl border border-gray-200 bg-white');

export const cardImageWrapper = cva('relative rounded-xl overflow-hidden');
const cardImageWrapper = cva('relative rounded-xl overflow-hidden');

export const cardHeading = cva('font-bold', {
const cardHeading = cva('font-bold', {
variants: {
size: {
sm: 'text-heading-s',
Expand All @@ -19,9 +19,9 @@ export const cardHeading = cva('font-bold', {
defaultVariants: { size: 'md', status: 'open' },
});

export const cardInfoLayout = cva('flex flex-nowrap items-start tablet:items-center gap-1.5');
const cardInfoLayout = cva('flex flex-nowrap items-start tablet:items-center gap-1.5');

export const cardInfoText = cva('text-caption tablet:text-body-s', {
const cardInfoText = cva('text-caption tablet:text-body-s', {
variants: {
status: {
open: 'text-gray-500',
Expand All @@ -31,7 +31,7 @@ export const cardInfoText = cva('text-caption tablet:text-body-s', {
defaultVariants: { status: 'open' },
});

export const cardInfoIcon = cva('', {
const cardInfoIcon = cva('', {
variants: {
status: {
open: 'bg-red-300',
Expand All @@ -41,11 +41,11 @@ export const cardInfoIcon = cva('', {
defaultVariants: { status: 'open' },
});

export const cardPayLayout = cva('flex items-center gap-x-3');
const cardPayLayout = cva('flex items-center gap-x-3');

export const cardBadge = cva('flex items-center gap-x-0.5 rounded-full');
const cardBadge = cva('flex items-center gap-x-0.5 rounded-full');

export const cardBadgeText = cva('whitespace-nowrap text-caption tablet:text-body-s');
const cardBadgeText = cva('whitespace-nowrap text-caption tablet:text-body-s');

export const cardLayout = {
frame: cardFrame,
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const Dropdown = <T extends string>({
className,
onChange,
}: DropdownProps<T>) => {
const { value: isOpen, toggle, setClose } = useToggle();
const { isOpen, toggle, setClose } = useToggle();
const [attachDropdownRef, dropdownRef] = useSafeRef<HTMLDivElement>();
const [attachTriggerRef, triggerRef] = useSafeRef<HTMLButtonElement>();
const [attachListRef, listRef] = useSafeRef<HTMLDivElement>();
Expand Down
105 changes: 105 additions & 0 deletions src/components/ui/filter/components/filterBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { filterLayout } from '@/components/ui/filter/filter.styles';
import { Icon } from '@/components/ui/icon';
import { DateInput, Input } from '@/components/ui/input';
import { ADDRESS_CODE } from '@/constants/dropdown';
import { parseRFC3339 } from '@/lib/utils/dateFormatter';
import { formatNumber } from '@/lib/utils/formatNumber';
import { FilterQueryParams } from '@/types/api';
import { ChangeEvent, useMemo } from 'react';

interface FilterBodyProps {
formData: FilterQueryParams;
onChange: (updater: (prev: FilterQueryParams) => FilterQueryParams) => void;
}

const FilterBody = ({ formData, onChange }: FilterBodyProps) => {
const startAt = parseRFC3339(formData.startsAtGte);
const pay = useMemo(
() => (formData.hourlyPayGte ? formatNumber(formData.hourlyPayGte) : ''),
[formData.hourlyPayGte]
);
const locations = formData.address ?? [];
const locationList = ADDRESS_CODE;

const addLocation = (loc: string) => {
if (locations.includes(loc)) return;
onChange(prev => ({ ...prev, address: [...locations, loc] }));
};

const removeLocation = (loc: string) => {
const next = locations.filter(v => v !== loc);
onChange(prev => ({ ...prev, address: next }));
};

const handleDateChange = (date: Date | null) => {
const rfc3339String = date?.toISOString();
onChange(prev => ({ ...prev, startsAtGte: rfc3339String }));
};

const handlePayChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const digits = target.value.replace(/[^0-9]/g, '');
onChange(prev => ({ ...prev, hourlyPayGte: Number(digits) }));
};

return (
<ul className={filterLayout.body()}>
<li>
<p className='mb-2 text-base'>위치</p>
<ul className={filterLayout.locationWrapper()}>
{locationList.map(value => (
<li key={value} className='w-full mobile:w-[calc(50%-6px)]'>
<button
type='button'
className={filterLayout.location()}
onClick={() => addLocation(value)}
>
{value}
</button>
</li>
))}
</ul>
{locations.length !== 0 && (
<div className='mt-3 flex flex-wrap gap-2'>
{locations.map(value => (
<button
key={value}
type='button'
className={filterLayout.locationSelected()}
onClick={() => removeLocation(value)}
>
<span className='text-inherit'>{value}</span>
<Icon iconName='close' iconSize='sm' className='bg-red-500' />
</button>
))}
</div>
)}
</li>
{/* @TODO DateInput 기능 완성 시 작업 */}
{/* <li>
<DateInput
id='filterStartAt'
label='시작일'
className='gap-2'
value={startAt}
onChange={handleDateChange}
/>
</li> */}
<li className='flex items-end gap-3'>
<Input
id='filterPay'
type='text'
inputMode='numeric'
pattern='[0-9]*'
label='금액'
placeholder='0'
suffix='원'
className='gap-2'
value={pay}
onChange={handlePayChange}
/>
<p className='whitespace-nowrap pb-4'>이상부터</p>
</li>
</ul>
);
};
export default FilterBody;
21 changes: 21 additions & 0 deletions src/components/ui/filter/components/filterFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Button } from '@/components/ui/button';
import { filterLayout } from '@/components/ui/filter/filter.styles';

interface FilterFooterProps {
onReset: () => void;
onSubmit: () => void;
}
const FilterFooter = ({ onReset, onSubmit }: FilterFooterProps) => {
return (
<div className={filterLayout.footer()}>
<Button type='button' variant='secondary' className='shrink-0' onClick={onReset}>
초기화
</Button>
<Button type='button' full onClick={onSubmit}>
적용하기
</Button>
</div>
);
};

export default FilterFooter;
15 changes: 15 additions & 0 deletions src/components/ui/filter/components/filterHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { filterLayout } from '@/components/ui/filter/filter.styles';
import { Icon } from '@/components/ui/icon';

const FilterHeader = ({ onClose }: { onClose?: () => void }) => {
return (
<div className={filterLayout.header()}>
<div className='text-heading-s font-bold'>상세 필터</div>
<button type='button' className='icon-btn' onClick={onClose} aria-label='상세 필터 닫기'>
<Icon iconName='close' decorative />
</button>
</div>
);
};

export default FilterHeader;
67 changes: 67 additions & 0 deletions src/components/ui/filter/filter.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils/cn';
import { cva } from 'class-variance-authority';

const filterPosition = cva('bg-background w-full');

const filterStickyContent = cva('sticky left-0 flex border-gray-200');

const filterWrapper = cva(
cn(
filterPosition(),
'fixed z-10 h-dvh overflow-hidden rounded-xl border border-gray-200 min-[480px]:absolute min-[480px]:h-fit min-[480px]:w-[390px]'
)
);

const filterPadding = cva('px-3 tablet:px-5');

const filterGap = cva('flex flex-col gap-6');

const filterHeader = cva(
cn(
filterPosition(),
filterStickyContent(),
filterPadding(),
'top-0 items-center justify-between border-b pb-3 pt-6'
)
);

const filterBody = cva(
cn(
filterGap(),
filterPadding(),
'scroll-bar py-3 h-[calc(100dvh-94px-61px)] min-[480px]:h-fit min-[480px]:max-h-[calc(600px)]'
)
);

const filterFooter = cva(
cn(filterPosition(), filterStickyContent(), filterPadding(), 'bottom-0 gap-2 border-t pb-6 pt-3')
);

const filterLocationWrapper = cva(
'scroll-bar flex h-64 flex-wrap gap-3 rounded-md border border-gray-200 bg-white p-5'
);

const filterLocationText = cva(
'shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-center text-body-m'
);

const filterLocation = cva(cn(filterLocationText(), 'hover:bg-red-100 hover:text-red-500'));

const filterLocationSelected = cva(
cn(filterLocationText(), 'flex items-center gap-2 bg-red-100 font-bold text-red-500')
);

export const filterLayout = {
position: filterPosition,
stickyContent: filterStickyContent,
wrapper: filterWrapper,
padding: filterPadding,
header: filterHeader,
body: filterBody,
footer: filterFooter,
filterGap,
locationWrapper: filterLocationWrapper,
locationText: filterLocationText,
location: filterLocation,
locationSelected: filterLocationSelected,
};
57 changes: 57 additions & 0 deletions src/components/ui/filter/filter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { cn } from '@/lib/utils/cn';
import { FilterQueryParams } from '@/types/api';
import { useCallback, useEffect, useState } from 'react';
import FilterBody from './components/filterBody';
import FilterFooter from './components/filterFooter';
import FilterHeader from './components/filterHeader';
import { filterLayout } from './filter.styles';

interface FilterProps {
value: FilterQueryParams;
onSubmit: (next: FilterQueryParams) => void;
onClose?: () => void;
className: string;
}

const INIT_DATA: FilterQueryParams = {
address: undefined,
startsAtGte: undefined,
hourlyPayGte: undefined,
};

export function normalizeFilter(q: FilterQueryParams): FilterQueryParams {
return {
address: q.address && q.address.length > 0 ? q.address : undefined,
startsAtGte: q.startsAtGte ?? undefined,
hourlyPayGte: typeof q.hourlyPayGte === 'number' ? q.hourlyPayGte : undefined,
};
}

const Filter = ({ value, onSubmit, onClose, className }: FilterProps) => {
const [draft, setDraft] = useState<FilterQueryParams>(value);

// const handleSubmit = () => onSubmit(draft);

const handleSubmit = useCallback(() => {
onSubmit(normalizeFilter(draft));
onClose?.();
}, [draft, onSubmit, onClose]);

const handleReset = useCallback(() => {
setDraft(INIT_DATA);
onSubmit(INIT_DATA);
}, [onSubmit]);

useEffect(() => {
setDraft(value);
}, [value]);

return (
<div className={cn(filterLayout.wrapper(), className)}>
<FilterHeader onClose={onClose} />
<FilterBody formData={draft} onChange={setDraft} />
<FilterFooter onReset={handleReset} onSubmit={handleSubmit} />
</div>
);
};
export default Filter;
1 change: 1 addition & 0 deletions src/components/ui/filter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Filter } from './filter';
1 change: 1 addition & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export { Table } from '@/components/ui/table';
export { Button } from './button';
export { Notice, Post } from './card';
export { Dropdown } from './dropdown';
export { Filter } from './filter';
export { Icon } from './icon';
export { Modal, Notification } from './modal';
6 changes: 3 additions & 3 deletions src/components/ui/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export default function Input({
<label
htmlFor={id}
className={cn(
'text-sm text-black',
'text-base text-black',
// 팀 토큰의 line-height 반영 (Body-S)
'leading-[var(--lh-body-s)]'
'leading-[var(--lh-body-l)]'
)}
>
{label}
Expand Down Expand Up @@ -65,7 +65,7 @@ export default function Input({
<span
className={cn(
'pointer-events-none absolute right-5 top-1/2 -translate-y-1/2',
'text-sm text-gray-500'
'text-base text-block'
)}
>
{suffix}
Expand Down
Loading