@umituz/web-design-system 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/domain/tokens/animation.tokens.ts +87 -0
- package/src/domain/tokens/index.ts +11 -0
- package/src/presentation/atoms/Checkbox.tsx +50 -0
- package/src/presentation/atoms/Divider.tsx +34 -0
- package/src/presentation/atoms/Link.tsx +45 -0
- package/src/presentation/atoms/Progress.tsx +48 -0
- package/src/presentation/atoms/Radio.tsx +42 -0
- package/src/presentation/atoms/Skeleton.tsx +38 -0
- package/src/presentation/atoms/Slider.tsx +49 -0
- package/src/presentation/atoms/Tooltip.tsx +76 -0
- package/src/presentation/atoms/index.ts +24 -0
- package/src/presentation/hooks/index.ts +13 -0
- package/src/presentation/hooks/useClickOutside.ts +33 -0
- package/src/presentation/hooks/useClipboard.ts +38 -0
- package/src/presentation/hooks/useDebounce.ts +22 -0
- package/src/presentation/hooks/useKeyboard.ts +89 -0
- package/src/presentation/hooks/useScrollLock.ts +26 -0
- package/src/presentation/hooks/useToggle.ts +16 -0
- package/src/presentation/molecules/CheckboxGroup.tsx +68 -0
- package/src/presentation/molecules/InputGroup.tsx +65 -0
- package/src/presentation/molecules/RadioGroup.tsx +63 -0
- package/src/presentation/molecules/Select.tsx +41 -0
- package/src/presentation/molecules/Textarea.tsx +43 -0
- package/src/presentation/molecules/index.ts +15 -0
- package/src/presentation/organisms/Accordion.tsx +117 -0
- package/src/presentation/organisms/Breadcrumb.tsx +83 -0
- package/src/presentation/organisms/Table.tsx +120 -0
- package/src/presentation/organisms/Tabs.tsx +99 -0
- package/src/presentation/organisms/index.ts +21 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useKeyboard Hook
|
|
3
|
+
* @description Keyboard event handling
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect } from 'react';
|
|
7
|
+
|
|
8
|
+
export type KeyboardKey = string;
|
|
9
|
+
export type KeyboardModifier = 'ctrl' | 'shift' | 'alt' | 'meta';
|
|
10
|
+
|
|
11
|
+
export interface KeyboardOptions {
|
|
12
|
+
key: KeyboardKey;
|
|
13
|
+
modifiers?: KeyboardModifier[];
|
|
14
|
+
onKeyDown?: (event: KeyboardEvent) => void;
|
|
15
|
+
onKeyUp?: (event: KeyboardEvent) => void;
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useKeyboard({
|
|
20
|
+
key,
|
|
21
|
+
modifiers = [],
|
|
22
|
+
onKeyDown,
|
|
23
|
+
onKeyUp,
|
|
24
|
+
enabled = true,
|
|
25
|
+
}: KeyboardOptions) {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled) return;
|
|
28
|
+
|
|
29
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
30
|
+
const keyMatches = event.key.toLowerCase() === key.toLowerCase();
|
|
31
|
+
const modifiersMatch = modifiers.every((mod) => {
|
|
32
|
+
switch (mod) {
|
|
33
|
+
case 'ctrl':
|
|
34
|
+
return event.ctrlKey;
|
|
35
|
+
case 'shift':
|
|
36
|
+
return event.shiftKey;
|
|
37
|
+
case 'alt':
|
|
38
|
+
return event.altKey;
|
|
39
|
+
case 'meta':
|
|
40
|
+
return event.metaKey;
|
|
41
|
+
default:
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (keyMatches && modifiersMatch) {
|
|
47
|
+
onKeyDown?.(event);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleKeyUp = (event: KeyboardEvent) => {
|
|
52
|
+
const keyMatches = event.key.toLowerCase() === key.toLowerCase();
|
|
53
|
+
const modifiersMatch = modifiers.every((mod) => {
|
|
54
|
+
switch (mod) {
|
|
55
|
+
case 'ctrl':
|
|
56
|
+
return event.ctrlKey;
|
|
57
|
+
case 'shift':
|
|
58
|
+
return event.shiftKey;
|
|
59
|
+
case 'alt':
|
|
60
|
+
return event.altKey;
|
|
61
|
+
case 'meta':
|
|
62
|
+
return event.metaKey;
|
|
63
|
+
default:
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (keyMatches && modifiersMatch) {
|
|
69
|
+
onKeyUp?.(event);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
74
|
+
document.addEventListener('keyup', handleKeyUp);
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
78
|
+
document.removeEventListener('keyup', handleKeyUp);
|
|
79
|
+
};
|
|
80
|
+
}, [key, modifiers, onKeyDown, onKeyUp, enabled]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useEscape(callback: () => void, enabled = true) {
|
|
84
|
+
useKeyboard({
|
|
85
|
+
key: 'Escape',
|
|
86
|
+
onKeyDown: callback,
|
|
87
|
+
enabled,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useScrollLock Hook
|
|
3
|
+
* @description Lock/unlock body scroll
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect } from 'react';
|
|
7
|
+
|
|
8
|
+
export function useScrollLock(enabled: boolean = true) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!enabled) return;
|
|
11
|
+
|
|
12
|
+
const originalStyle = window.getComputedStyle(document.body).overflow;
|
|
13
|
+
const originalPaddingRight = window.getComputedStyle(document.body).paddingRight;
|
|
14
|
+
|
|
15
|
+
// Calculate scrollbar width
|
|
16
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
17
|
+
|
|
18
|
+
document.body.style.overflow = 'hidden';
|
|
19
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
20
|
+
|
|
21
|
+
return () => {
|
|
22
|
+
document.body.style.overflow = originalStyle;
|
|
23
|
+
document.body.style.paddingRight = originalPaddingRight;
|
|
24
|
+
};
|
|
25
|
+
}, [enabled]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useToggle Hook
|
|
3
|
+
* @description Toggle boolean state
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
export function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
|
|
9
|
+
const [value, setValue] = useState(initialValue);
|
|
10
|
+
|
|
11
|
+
const toggle = useCallback(() => {
|
|
12
|
+
setValue((v) => !v);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
return [value, toggle, setValue];
|
|
16
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckboxGroup Component (Molecule)
|
|
3
|
+
* @description Group of checkboxes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps } from '../../domain/types';
|
|
9
|
+
import { Checkbox } from '../atoms/Checkbox';
|
|
10
|
+
import { Text } from '../atoms/Text';
|
|
11
|
+
|
|
12
|
+
export interface CheckboxOption {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CheckboxGroupProps extends HTMLAttributes<HTMLDivElement>, BaseProps {
|
|
19
|
+
options: CheckboxOption[];
|
|
20
|
+
value?: string[];
|
|
21
|
+
onChange?: (values: string[]) => void;
|
|
22
|
+
orientation?: 'vertical' | 'horizontal';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const orientationStyles: Record<'vertical' | 'horizontal', string> = {
|
|
26
|
+
vertical: 'flex-col gap-3',
|
|
27
|
+
horizontal: 'flex-row gap-6',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
|
|
31
|
+
({ className, options, value = [], onChange, orientation = 'vertical', ...props }, ref) => {
|
|
32
|
+
const handleCheckedChange = (optionValue: string, checked: boolean) => {
|
|
33
|
+
if (checked) {
|
|
34
|
+
onChange?.([...value, optionValue]);
|
|
35
|
+
} else {
|
|
36
|
+
onChange?.(value.filter((v) => v !== optionValue));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
ref={ref}
|
|
43
|
+
role="group"
|
|
44
|
+
className={cn('flex', orientationStyles[orientation], className)}
|
|
45
|
+
{...props}
|
|
46
|
+
>
|
|
47
|
+
{options.map((option) => (
|
|
48
|
+
<label
|
|
49
|
+
key={option.value}
|
|
50
|
+
className={cn(
|
|
51
|
+
'flex items-center gap-2 cursor-pointer',
|
|
52
|
+
option.disabled && 'opacity-50 cursor-not-allowed'
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<Checkbox
|
|
56
|
+
checked={value.includes(option.value)}
|
|
57
|
+
onCheckedChange={(checked) => handleCheckedChange(option.value, checked)}
|
|
58
|
+
disabled={option.disabled}
|
|
59
|
+
/>
|
|
60
|
+
<Text size="sm">{option.label}</Text>
|
|
61
|
+
</label>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CheckboxGroup.displayName = 'CheckboxGroup';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputGroup Component (Molecule)
|
|
3
|
+
* @description Input with prefix/suffix elements
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type ReactNode, type HTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps, ChildrenProps } from '../../domain/types';
|
|
9
|
+
import { Input } from '../atoms/Input';
|
|
10
|
+
|
|
11
|
+
export interface InputGroupProps extends HTMLAttributes<HTMLDivElement>, BaseProps, ChildrenProps {
|
|
12
|
+
leftElement?: ReactNode;
|
|
13
|
+
rightElement?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
|
|
17
|
+
({ className, leftElement, rightElement, children, ...props }, ref) => {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
ref={ref}
|
|
21
|
+
className="relative"
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
{leftElement && (
|
|
25
|
+
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
26
|
+
{leftElement}
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
{children}
|
|
30
|
+
{rightElement && (
|
|
31
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
32
|
+
{rightElement}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
InputGroup.displayName = 'InputGroup';
|
|
41
|
+
|
|
42
|
+
export const GroupedInput = forwardRef<HTMLInputElement, React.ComponentProps<typeof Input> & {
|
|
43
|
+
leftElement?: ReactNode;
|
|
44
|
+
rightElement?: ReactNode;
|
|
45
|
+
}>(
|
|
46
|
+
({ className, leftElement, rightElement, ...props }, ref) => {
|
|
47
|
+
return (
|
|
48
|
+
<InputGroup>
|
|
49
|
+
{leftElement}
|
|
50
|
+
<Input
|
|
51
|
+
ref={ref}
|
|
52
|
+
className={cn(
|
|
53
|
+
leftElement && 'pl-10',
|
|
54
|
+
rightElement && 'pr-10',
|
|
55
|
+
className
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
{rightElement}
|
|
60
|
+
</InputGroup>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
GroupedInput.displayName = 'GroupedInput';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RadioGroup Component (Molecule)
|
|
3
|
+
* @description Group of radio buttons
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps } from '../../domain/types';
|
|
9
|
+
import { Radio } from '../atoms/Radio';
|
|
10
|
+
import { Text } from '../atoms/Text';
|
|
11
|
+
|
|
12
|
+
export interface RadioOption {
|
|
13
|
+
value: string;
|
|
14
|
+
label: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RadioGroupProps extends HTMLAttributes<HTMLDivElement>, BaseProps {
|
|
19
|
+
name: string;
|
|
20
|
+
options: RadioOption[];
|
|
21
|
+
value?: string;
|
|
22
|
+
onChange?: (value: string) => void;
|
|
23
|
+
orientation?: 'vertical' | 'horizontal';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const orientationStyles: Record<'vertical' | 'horizontal', string> = {
|
|
27
|
+
vertical: 'flex-col gap-3',
|
|
28
|
+
horizontal: 'flex-row gap-6',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
32
|
+
({ className, name, options, value, onChange, orientation = 'vertical', ...props }, ref) => {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
ref={ref}
|
|
36
|
+
role="radiogroup"
|
|
37
|
+
className={cn('flex', orientationStyles[orientation], className)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{options.map((option) => (
|
|
41
|
+
<label
|
|
42
|
+
key={option.value}
|
|
43
|
+
className={cn(
|
|
44
|
+
'flex items-center gap-2 cursor-pointer',
|
|
45
|
+
option.disabled && 'opacity-50 cursor-not-allowed'
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<Radio
|
|
49
|
+
name={name}
|
|
50
|
+
value={option.value}
|
|
51
|
+
checked={value === option.value}
|
|
52
|
+
onChange={() => !option.disabled && onChange?.(option.value)}
|
|
53
|
+
disabled={option.disabled}
|
|
54
|
+
/>
|
|
55
|
+
<Text size="sm">{option.label}</Text>
|
|
56
|
+
</label>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
RadioGroup.displayName = 'RadioGroup';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select Component (Molecule)
|
|
3
|
+
* @description Dropdown select input
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type SelectHTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps } from '../../domain/types';
|
|
9
|
+
|
|
10
|
+
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement>, BaseProps {
|
|
11
|
+
error?: boolean;
|
|
12
|
+
options: Array<{ value: string; label: string; disabled?: boolean }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
|
16
|
+
({ className, error, options, disabled, ...props }, ref) => {
|
|
17
|
+
return (
|
|
18
|
+
<select
|
|
19
|
+
ref={ref}
|
|
20
|
+
disabled={disabled}
|
|
21
|
+
className={cn(
|
|
22
|
+
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-2',
|
|
23
|
+
'text-sm ring-offset-background',
|
|
24
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
25
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
26
|
+
error && 'border-destructive',
|
|
27
|
+
className
|
|
28
|
+
)}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
{options.map((option) => (
|
|
32
|
+
<option key={option.value} value={option.value} disabled={option.disabled}>
|
|
33
|
+
{option.label}
|
|
34
|
+
</option>
|
|
35
|
+
))}
|
|
36
|
+
</select>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
Select.displayName = 'Select';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Textarea Component (Molecule)
|
|
3
|
+
* @description Multi-line text input
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps } from '../../domain/types';
|
|
9
|
+
|
|
10
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement>, BaseProps {
|
|
11
|
+
error?: boolean;
|
|
12
|
+
resize?: 'none' | 'both' | 'horizontal' | 'vertical';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const resizeStyles: Record<'none' | 'both' | 'horizontal' | 'vertical', string> = {
|
|
16
|
+
none: 'resize-none',
|
|
17
|
+
both: 'resize',
|
|
18
|
+
horizontal: 'resize-x',
|
|
19
|
+
vertical: 'resize-y',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
23
|
+
({ className, error, resize = 'vertical', ...props }, ref) => {
|
|
24
|
+
return (
|
|
25
|
+
<textarea
|
|
26
|
+
ref={ref}
|
|
27
|
+
className={cn(
|
|
28
|
+
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2',
|
|
29
|
+
'text-sm ring-offset-background',
|
|
30
|
+
'placeholder:text-muted-foreground',
|
|
31
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
32
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
33
|
+
error && 'border-destructive',
|
|
34
|
+
resizeStyles[resize],
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
Textarea.displayName = 'Textarea';
|
|
@@ -18,3 +18,18 @@ export type { ChipProps } from './Chip';
|
|
|
18
18
|
|
|
19
19
|
export { Toggle } from './Toggle';
|
|
20
20
|
export type { ToggleProps } from './Toggle';
|
|
21
|
+
|
|
22
|
+
export { Select } from './Select';
|
|
23
|
+
export type { SelectProps } from './Select';
|
|
24
|
+
|
|
25
|
+
export { Textarea } from './Textarea';
|
|
26
|
+
export type { TextareaProps } from './Textarea';
|
|
27
|
+
|
|
28
|
+
export { RadioGroup } from './RadioGroup';
|
|
29
|
+
export type { RadioGroupProps, RadioOption } from './RadioGroup';
|
|
30
|
+
|
|
31
|
+
export { CheckboxGroup } from './CheckboxGroup';
|
|
32
|
+
export type { CheckboxGroupProps, CheckboxOption } from './CheckboxGroup';
|
|
33
|
+
|
|
34
|
+
export { InputGroup, GroupedInput } from './InputGroup';
|
|
35
|
+
export type { InputGroupProps } from './InputGroup';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accordion Component (Organism)
|
|
3
|
+
* @description Collapsible content sections
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, type ReactNode, type HTMLAttributes } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps } from '../../domain/types';
|
|
9
|
+
import { Icon } from '../atoms/Icon';
|
|
10
|
+
|
|
11
|
+
export interface AccordionItem {
|
|
12
|
+
value: string;
|
|
13
|
+
title: string;
|
|
14
|
+
content: ReactNode;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AccordionProps extends HTMLAttributes<HTMLDivElement>, BaseProps {
|
|
19
|
+
items: AccordionItem[];
|
|
20
|
+
allowMultiple?: boolean;
|
|
21
|
+
defaultValue?: string[];
|
|
22
|
+
variant?: 'default' | 'bordered' | 'ghost';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const variantStyles: Record<'default' | 'bordered' | 'ghost', string> = {
|
|
26
|
+
default: 'border-b',
|
|
27
|
+
bordered: 'border rounded-lg mb-2',
|
|
28
|
+
ghost: 'border-0',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function Accordion({
|
|
32
|
+
items,
|
|
33
|
+
allowMultiple = false,
|
|
34
|
+
defaultValue = [],
|
|
35
|
+
variant = 'default',
|
|
36
|
+
className,
|
|
37
|
+
...props
|
|
38
|
+
}: AccordionProps) {
|
|
39
|
+
const [openItems, setOpenItems] = useState<string[]>(defaultValue);
|
|
40
|
+
|
|
41
|
+
const toggleItem = useCallback((value: string) => {
|
|
42
|
+
setOpenItems((prev) => {
|
|
43
|
+
const isOpen = prev.includes(value);
|
|
44
|
+
|
|
45
|
+
if (allowMultiple) {
|
|
46
|
+
return isOpen
|
|
47
|
+
? prev.filter((v) => v !== value)
|
|
48
|
+
: [...prev, value];
|
|
49
|
+
} else {
|
|
50
|
+
return isOpen ? [] : [value];
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}, [allowMultiple]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn('w-full', className)} {...props}>
|
|
57
|
+
{items.map((item, index) => {
|
|
58
|
+
const isOpen = openItems.includes(item.value);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
key={item.value}
|
|
63
|
+
className={cn(
|
|
64
|
+
'group',
|
|
65
|
+
variantStyles[variant],
|
|
66
|
+
variant === 'bordered' && isOpen && 'ring-1 ring-ring'
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{/* Header */}
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => toggleItem(item.value)}
|
|
72
|
+
disabled={item.disabled}
|
|
73
|
+
className={cn(
|
|
74
|
+
'flex w-full items-center justify-between py-4 font-medium transition-all',
|
|
75
|
+
'hover:text-foreground',
|
|
76
|
+
item.disabled && 'opacity-50 cursor-not-allowed',
|
|
77
|
+
variant === 'bordered' && 'px-4'
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span>{item.title}</span>
|
|
81
|
+
<Icon
|
|
82
|
+
className={cn(
|
|
83
|
+
'transition-transform duration-200',
|
|
84
|
+
isOpen && 'rotate-180'
|
|
85
|
+
)}
|
|
86
|
+
size="sm"
|
|
87
|
+
>
|
|
88
|
+
<path
|
|
89
|
+
strokeLinecap="round"
|
|
90
|
+
strokeLinejoin="round"
|
|
91
|
+
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
|
92
|
+
/>
|
|
93
|
+
</Icon>
|
|
94
|
+
</button>
|
|
95
|
+
|
|
96
|
+
{/* Content */}
|
|
97
|
+
{isOpen && (
|
|
98
|
+
<div
|
|
99
|
+
className={cn(
|
|
100
|
+
'overflow-hidden',
|
|
101
|
+
'animate-accordion-down',
|
|
102
|
+
variant === 'bordered' && 'px-4 pb-4'
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<div className="pb-4 text-sm text-muted-foreground">
|
|
106
|
+
{item.content}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
Accordion.displayName = 'Accordion';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb Component (Organism)
|
|
3
|
+
* @description Navigation breadcrumb trail
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
|
7
|
+
import { cn } from '../../infrastructure/utils';
|
|
8
|
+
import type { BaseProps } from '../../domain/types';
|
|
9
|
+
import { Icon } from '../atoms/Icon';
|
|
10
|
+
import { Link } from '../atoms/Link';
|
|
11
|
+
|
|
12
|
+
export interface BreadcrumbItem {
|
|
13
|
+
label: string;
|
|
14
|
+
href?: string;
|
|
15
|
+
icon?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BreadcrumbsProps extends HTMLAttributes<HTMLElement>, BaseProps {
|
|
19
|
+
items: BreadcrumbItem[];
|
|
20
|
+
separator?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Breadcrumbs = forwardRef<HTMLElement, BreadcrumbsProps>(
|
|
24
|
+
({ className, items, separator, ...props }, ref) => {
|
|
25
|
+
const defaultSeparator = (
|
|
26
|
+
<Icon size="xs" className="text-muted-foreground">
|
|
27
|
+
<path
|
|
28
|
+
strokeLinecap="round"
|
|
29
|
+
strokeLinejoin="round"
|
|
30
|
+
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
|
31
|
+
/>
|
|
32
|
+
</Icon>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<nav
|
|
37
|
+
ref={ref}
|
|
38
|
+
aria-label="Breadcrumb"
|
|
39
|
+
className={cn('flex items-center gap-2 text-sm', className)}
|
|
40
|
+
{...props}
|
|
41
|
+
>
|
|
42
|
+
{items.map((item, index) => {
|
|
43
|
+
const isLast = index === items.length - 1;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div key={index} className="flex items-center gap-2">
|
|
47
|
+
{index > 0 && (separator || defaultSeparator)}
|
|
48
|
+
|
|
49
|
+
{item.href ? (
|
|
50
|
+
<Link
|
|
51
|
+
href={item.href}
|
|
52
|
+
className={cn(
|
|
53
|
+
'flex items-center gap-1.5',
|
|
54
|
+
isLast
|
|
55
|
+
? 'text-foreground font-medium'
|
|
56
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{item.icon}
|
|
60
|
+
{item.label}
|
|
61
|
+
</Link>
|
|
62
|
+
) : (
|
|
63
|
+
<span
|
|
64
|
+
className={cn(
|
|
65
|
+
'flex items-center gap-1.5',
|
|
66
|
+
isLast
|
|
67
|
+
? 'text-foreground font-medium'
|
|
68
|
+
: 'text-muted-foreground'
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{item.icon}
|
|
72
|
+
{item.label}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</nav>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
Breadcrumbs.displayName = 'Breadcrumbs';
|