@carbonid1/design-system 5.0.3 → 5.2.0
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/dist/Badge/Badge.d.ts +6 -0
- package/dist/Badge/Badge.js +23 -0
- package/{src/Badge/Badge.types.ts → dist/Badge/Badge.types.d.ts} +8 -11
- package/dist/Badge/Badge.types.js +1 -0
- package/dist/Button/Button.d.ts +7 -0
- package/dist/Button/Button.js +34 -0
- package/dist/Button/Button.types.d.ts +8 -0
- package/dist/Button/Button.types.js +1 -0
- package/dist/ContextMenu/ContextMenu.d.ts +56 -0
- package/dist/ContextMenu/ContextMenu.js +40 -0
- package/dist/Kbd/Kbd.d.ts +6 -0
- package/dist/Kbd/Kbd.js +38 -0
- package/dist/Kbd/Kbd.types.d.ts +8 -0
- package/dist/Kbd/Kbd.types.js +1 -0
- package/dist/ProgressRing/ProgressRing.consts.d.ts +4 -0
- package/dist/ProgressRing/ProgressRing.consts.js +4 -0
- package/dist/ProgressRing/ProgressRing.d.ts +10 -0
- package/dist/ProgressRing/ProgressRing.js +12 -0
- package/dist/Select/Select.d.ts +2 -0
- package/dist/Select/Select.js +85 -0
- package/dist/Select/Select.types.d.ts +26 -0
- package/dist/Select/Select.types.js +1 -0
- package/dist/Slider/Slider.d.ts +13 -0
- package/dist/Slider/Slider.js +5 -0
- package/dist/ThemeCycler/ThemeCycler.d.ts +1 -0
- package/dist/ThemeCycler/ThemeCycler.js +30 -0
- package/dist/ThemeProvider/ThemeProvider.d.ts +5 -0
- package/dist/ThemeProvider/ThemeProvider.js +4 -0
- package/dist/Toaster/Toaster.d.ts +2 -0
- package/dist/Toaster/Toaster.js +4 -0
- package/dist/Tooltip/Tooltip.d.ts +15 -0
- package/dist/Tooltip/Tooltip.js +59 -0
- package/dist/helpers/cn/cn.d.ts +2 -0
- package/dist/helpers/cn/cn.js +5 -0
- package/dist/helpers/getModKey/getModKey.d.ts +1 -0
- package/dist/helpers/getModKey/getModKey.js +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +16 -0
- package/package.json +17 -35
- package/skills/design-system/SKILL.md +17 -0
- package/skills/design-system/references/theming.md +53 -14
- package/themes/dashboard.css +22 -4
- package/themes/reader.css +51 -6
- package/src/Badge/Badge.tsx +0 -53
- package/src/Button/Button.tsx +0 -67
- package/src/Button/Button.types.ts +0 -11
- package/src/ContextMenu/ContextMenu.tsx +0 -159
- package/src/Kbd/Kbd.tsx +0 -56
- package/src/Kbd/Kbd.types.ts +0 -10
- package/src/ProgressRing/ProgressRing.consts.ts +0 -4
- package/src/ProgressRing/ProgressRing.tsx +0 -68
- package/src/Select/Select.test.tsx +0 -129
- package/src/Select/Select.tsx +0 -156
- package/src/Select/Select.types.ts +0 -30
- package/src/Slider/Slider.test.tsx +0 -29
- package/src/Slider/Slider.tsx +0 -53
- package/src/ThemeCycler/ThemeCycler.tsx +0 -37
- package/src/ThemeProvider/ThemeProvider.tsx +0 -18
- package/src/Toaster/Toaster.tsx +0 -7
- package/src/Tooltip/Tooltip.tsx +0 -107
- package/src/helpers/cn/cn.ts +0 -6
- package/src/helpers/getModKey/getModKey.test.ts +0 -23
- package/src/helpers/getModKey/getModKey.ts +0 -2
- package/src/index.ts +0 -36
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
|
|
4
|
-
import { ChevronRight } from 'lucide-react'
|
|
5
|
-
import type { ComponentPropsWithoutRef, Ref } from 'react'
|
|
6
|
-
import { cn } from '../helpers/cn/cn'
|
|
7
|
-
|
|
8
|
-
type BasePopupProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Popup>
|
|
9
|
-
type BasePositionerProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Positioner>
|
|
10
|
-
type BaseItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Item>
|
|
11
|
-
type BaseSeparatorProps = ComponentPropsWithoutRef<typeof BaseContextMenu.Separator>
|
|
12
|
-
type BaseGroupLabelProps = ComponentPropsWithoutRef<typeof BaseContextMenu.GroupLabel>
|
|
13
|
-
type BaseSubmenuTriggerProps = ComponentPropsWithoutRef<typeof BaseContextMenu.SubmenuTrigger>
|
|
14
|
-
type BaseCheckboxItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.CheckboxItem>
|
|
15
|
-
type BaseRadioItemProps = ComponentPropsWithoutRef<typeof BaseContextMenu.RadioItem>
|
|
16
|
-
|
|
17
|
-
type ItemVariant = 'default' | 'destructive'
|
|
18
|
-
|
|
19
|
-
type PositionerProps = BasePositionerProps & { ref?: Ref<HTMLDivElement> }
|
|
20
|
-
type PopupProps = BasePopupProps & { ref?: Ref<HTMLDivElement> }
|
|
21
|
-
type SeparatorProps = BaseSeparatorProps & { ref?: Ref<HTMLDivElement> }
|
|
22
|
-
type GroupLabelProps = BaseGroupLabelProps & { ref?: Ref<HTMLDivElement> }
|
|
23
|
-
type ItemProps = BaseItemProps & { ref?: Ref<HTMLDivElement>; variant?: ItemVariant }
|
|
24
|
-
type CheckboxItemProps = BaseCheckboxItemProps & { ref?: Ref<HTMLDivElement> }
|
|
25
|
-
type RadioItemProps = BaseRadioItemProps & { ref?: Ref<HTMLDivElement> }
|
|
26
|
-
type SubmenuTriggerProps = BaseSubmenuTriggerProps & { ref?: Ref<HTMLDivElement> }
|
|
27
|
-
|
|
28
|
-
const popupClasses =
|
|
29
|
-
'bg-popover text-popover-foreground border-border z-50 min-w-45 origin-[var(--transform-origin)] rounded-lg border py-1 shadow-lg outline-hidden transition-[transform,opacity] data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0'
|
|
30
|
-
|
|
31
|
-
const itemBaseClasses =
|
|
32
|
-
'relative flex h-7 cursor-default items-center gap-1.5 px-2.5 text-[0.8rem] outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4 data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
|
|
33
|
-
|
|
34
|
-
const itemVariantClasses: Record<ItemVariant, string> = {
|
|
35
|
-
default:
|
|
36
|
-
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
|
|
37
|
-
destructive:
|
|
38
|
-
'text-destructive data-[highlighted]:bg-destructive/10 data-[highlighted]:text-destructive',
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const Positioner = ({ className, sideOffset = 4, ...props }: PositionerProps) => (
|
|
42
|
-
<BaseContextMenu.Positioner
|
|
43
|
-
sideOffset={sideOffset}
|
|
44
|
-
className={cn('z-50 outline-hidden', className)}
|
|
45
|
-
{...props}
|
|
46
|
-
/>
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
const Popup = ({ className, ...props }: PopupProps) => (
|
|
50
|
-
<BaseContextMenu.Popup className={cn(popupClasses, className)} {...props} />
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
const Item = ({ className, variant = 'default', ...props }: ItemProps) => (
|
|
54
|
-
<BaseContextMenu.Item
|
|
55
|
-
className={cn(itemBaseClasses, itemVariantClasses[variant], className)}
|
|
56
|
-
{...props}
|
|
57
|
-
/>
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
const Separator = ({ className, ...props }: SeparatorProps) => (
|
|
61
|
-
<BaseContextMenu.Separator
|
|
62
|
-
className={cn('bg-border my-1 h-px', className)}
|
|
63
|
-
{...props}
|
|
64
|
-
/>
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
const GroupLabel = ({ className, ...props }: GroupLabelProps) => (
|
|
68
|
-
<BaseContextMenu.GroupLabel
|
|
69
|
-
className={cn('text-muted-foreground px-2.5 py-1 text-xs', className)}
|
|
70
|
-
{...props}
|
|
71
|
-
/>
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
const CheckboxItem = ({ className, children, ...props }: CheckboxItemProps) => (
|
|
75
|
-
<BaseContextMenu.CheckboxItem
|
|
76
|
-
className={cn(itemBaseClasses, itemVariantClasses.default, 'pl-7', className)}
|
|
77
|
-
{...props}
|
|
78
|
-
>
|
|
79
|
-
<span
|
|
80
|
-
aria-hidden
|
|
81
|
-
className="absolute left-2 flex size-3.5 items-center justify-center"
|
|
82
|
-
>
|
|
83
|
-
<BaseContextMenu.CheckboxItemIndicator className="size-3.5">
|
|
84
|
-
<CheckIcon />
|
|
85
|
-
</BaseContextMenu.CheckboxItemIndicator>
|
|
86
|
-
</span>
|
|
87
|
-
{children}
|
|
88
|
-
</BaseContextMenu.CheckboxItem>
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
const RadioItem = ({ className, children, ...props }: RadioItemProps) => (
|
|
92
|
-
<BaseContextMenu.RadioItem
|
|
93
|
-
className={cn(itemBaseClasses, itemVariantClasses.default, 'pl-7', className)}
|
|
94
|
-
{...props}
|
|
95
|
-
>
|
|
96
|
-
<span
|
|
97
|
-
aria-hidden
|
|
98
|
-
className="absolute left-2 flex size-3.5 items-center justify-center"
|
|
99
|
-
>
|
|
100
|
-
<BaseContextMenu.RadioItemIndicator className="size-3.5">
|
|
101
|
-
<DotIcon />
|
|
102
|
-
</BaseContextMenu.RadioItemIndicator>
|
|
103
|
-
</span>
|
|
104
|
-
{children}
|
|
105
|
-
</BaseContextMenu.RadioItem>
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
const SubmenuTrigger = ({ className, children, ...props }: SubmenuTriggerProps) => (
|
|
109
|
-
<BaseContextMenu.SubmenuTrigger
|
|
110
|
-
className={cn(
|
|
111
|
-
itemBaseClasses,
|
|
112
|
-
itemVariantClasses.default,
|
|
113
|
-
'data-popup-open:bg-accent data-popup-open:text-accent-foreground pr-2',
|
|
114
|
-
className,
|
|
115
|
-
)}
|
|
116
|
-
{...props}
|
|
117
|
-
>
|
|
118
|
-
{children}
|
|
119
|
-
<ChevronRight className="ml-auto size-4 opacity-60" aria-hidden />
|
|
120
|
-
</BaseContextMenu.SubmenuTrigger>
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
const CheckIcon = () => (
|
|
124
|
-
<svg viewBox="0 0 14 14" fill="none" className="size-3.5">
|
|
125
|
-
<path
|
|
126
|
-
d="M3 7.5 6 10.5 11 4.5"
|
|
127
|
-
stroke="currentColor"
|
|
128
|
-
strokeWidth="1.75"
|
|
129
|
-
strokeLinecap="round"
|
|
130
|
-
strokeLinejoin="round"
|
|
131
|
-
/>
|
|
132
|
-
</svg>
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
const DotIcon = () => (
|
|
136
|
-
<svg viewBox="0 0 14 14" className="size-3.5">
|
|
137
|
-
<circle cx="7" cy="7" r="3" fill="currentColor" />
|
|
138
|
-
</svg>
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
export const ContextMenu = {
|
|
142
|
-
Root: BaseContextMenu.Root,
|
|
143
|
-
Trigger: BaseContextMenu.Trigger,
|
|
144
|
-
Portal: BaseContextMenu.Portal,
|
|
145
|
-
Backdrop: BaseContextMenu.Backdrop,
|
|
146
|
-
Positioner,
|
|
147
|
-
Popup,
|
|
148
|
-
Arrow: BaseContextMenu.Arrow,
|
|
149
|
-
Group: BaseContextMenu.Group,
|
|
150
|
-
GroupLabel,
|
|
151
|
-
Separator,
|
|
152
|
-
Item,
|
|
153
|
-
LinkItem: BaseContextMenu.LinkItem,
|
|
154
|
-
CheckboxItem,
|
|
155
|
-
RadioGroup: BaseContextMenu.RadioGroup,
|
|
156
|
-
RadioItem,
|
|
157
|
-
SubmenuRoot: BaseContextMenu.SubmenuRoot,
|
|
158
|
-
SubmenuTrigger,
|
|
159
|
-
}
|
package/src/Kbd/Kbd.tsx
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { getModKey } from '../helpers/getModKey/getModKey'
|
|
4
|
-
import { cn } from '../helpers/cn/cn'
|
|
5
|
-
import { cva } from 'class-variance-authority'
|
|
6
|
-
import type { KbdProps } from './Kbd.types'
|
|
7
|
-
|
|
8
|
-
const MOD_SYMBOLS: Record<string, string> = { Cmd: '⌘', Ctrl: 'Ctrl' }
|
|
9
|
-
|
|
10
|
-
const KEY_SYMBOLS: Record<string, string> = {
|
|
11
|
-
shift: '⇧',
|
|
12
|
-
alt: '⌥',
|
|
13
|
-
enter: '↵',
|
|
14
|
-
backspace: '⌫',
|
|
15
|
-
escape: 'Esc',
|
|
16
|
-
space: '␣',
|
|
17
|
-
left: '←',
|
|
18
|
-
right: '→',
|
|
19
|
-
up: '↑',
|
|
20
|
-
down: '↓',
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const resolveKey = (key: string): string => {
|
|
24
|
-
const lower = key.toLowerCase()
|
|
25
|
-
if (lower === 'mod') return MOD_SYMBOLS[getModKey()] ?? 'Ctrl'
|
|
26
|
-
return KEY_SYMBOLS[lower] ?? key
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const kbdVariants = cva(
|
|
30
|
-
'inline-flex items-center justify-center rounded-sm border font-mono leading-none select-none',
|
|
31
|
-
{
|
|
32
|
-
variants: {
|
|
33
|
-
size: {
|
|
34
|
-
sm: 'min-w-4 px-1 py-0.5 text-[10px] border-foreground/10 bg-foreground/10',
|
|
35
|
-
default: 'min-w-5 px-1.5 py-1 text-[11px] border-border bg-muted',
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
defaultVariants: { size: 'default' },
|
|
39
|
-
},
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
const Kbd = ({ keys, size, className }: KbdProps) => {
|
|
43
|
-
const keyList = Array.isArray(keys) ? keys : [keys]
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<span className="inline-flex items-center gap-0.5" role="presentation">
|
|
47
|
-
{keyList.map((key, i) => (
|
|
48
|
-
<kbd key={i} className={cn(kbdVariants({ size }), className)}>
|
|
49
|
-
{resolveKey(key)}
|
|
50
|
-
</kbd>
|
|
51
|
-
))}
|
|
52
|
-
</span>
|
|
53
|
-
)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export { Kbd, kbdVariants }
|
package/src/Kbd/Kbd.types.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { VariantProps } from 'class-variance-authority'
|
|
2
|
-
import type { kbdVariants } from './Kbd'
|
|
3
|
-
|
|
4
|
-
type KbdProps = {
|
|
5
|
-
/** Single key or array of keys for combos. 'mod' auto-resolves to Cmd/Ctrl. */
|
|
6
|
-
keys: string | string[]
|
|
7
|
-
className?: string
|
|
8
|
-
} & VariantProps<typeof kbdVariants>
|
|
9
|
-
|
|
10
|
-
export type { KbdProps }
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { CIRCUMFERENCE, RADIUS, RING_SIZE, STROKE_WIDTH } from './ProgressRing.consts'
|
|
2
|
-
|
|
3
|
-
type ProgressRingProps = {
|
|
4
|
-
progress: number
|
|
5
|
-
colorClass: string
|
|
6
|
-
label: string
|
|
7
|
-
animate?: boolean
|
|
8
|
-
pendingStyle?: 'none' | 'dashed'
|
|
9
|
-
testId?: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const DASH_SEGMENT = CIRCUMFERENCE / 8
|
|
13
|
-
|
|
14
|
-
export const ProgressRing = ({
|
|
15
|
-
progress,
|
|
16
|
-
colorClass,
|
|
17
|
-
label,
|
|
18
|
-
animate = false,
|
|
19
|
-
pendingStyle = 'none',
|
|
20
|
-
testId,
|
|
21
|
-
}: ProgressRingProps) => {
|
|
22
|
-
const MIN_VISIBLE = 0.08
|
|
23
|
-
const visibleProgress = animate ? Math.max(progress, MIN_VISIBLE) : progress
|
|
24
|
-
const dashoffset = CIRCUMFERENCE * (1 - visibleProgress)
|
|
25
|
-
|
|
26
|
-
const shouldSpin = animate && progress < 0.15
|
|
27
|
-
const isDashed = !animate && pendingStyle === 'dashed'
|
|
28
|
-
const center = RING_SIZE / 2
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<svg
|
|
32
|
-
width={RING_SIZE}
|
|
33
|
-
height={RING_SIZE}
|
|
34
|
-
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
|
|
35
|
-
role="img"
|
|
36
|
-
aria-label={label}
|
|
37
|
-
className={animate && !shouldSpin ? 'animate-buffer-pulse motion-reduce:animate-none' : ''}
|
|
38
|
-
>
|
|
39
|
-
{/* Track */}
|
|
40
|
-
<circle
|
|
41
|
-
cx={center}
|
|
42
|
-
cy={center}
|
|
43
|
-
r={RADIUS}
|
|
44
|
-
fill="none"
|
|
45
|
-
stroke="currentColor"
|
|
46
|
-
strokeWidth={STROKE_WIDTH}
|
|
47
|
-
className={`text-border ${isDashed ? 'animate-ring-drift motion-reduce:animate-none' : ''}`}
|
|
48
|
-
strokeDasharray={isDashed ? `${DASH_SEGMENT * 0.6} ${DASH_SEGMENT * 0.4}` : undefined}
|
|
49
|
-
style={isDashed ? { transformOrigin: `${center}px ${center}px` } : undefined}
|
|
50
|
-
/>
|
|
51
|
-
{/* Fill */}
|
|
52
|
-
<circle
|
|
53
|
-
data-testid={testId}
|
|
54
|
-
cx={center}
|
|
55
|
-
cy={center}
|
|
56
|
-
r={RADIUS}
|
|
57
|
-
fill="none"
|
|
58
|
-
stroke="currentColor"
|
|
59
|
-
strokeWidth={STROKE_WIDTH}
|
|
60
|
-
strokeDasharray={CIRCUMFERENCE}
|
|
61
|
-
strokeDashoffset={dashoffset}
|
|
62
|
-
strokeLinecap="round"
|
|
63
|
-
className={`${colorClass} transition-[stroke-dashoffset,color] duration-500 ${shouldSpin ? 'animate-ring-spin motion-reduce:animate-none' : ''}`}
|
|
64
|
-
style={{ transformOrigin: `${center}px ${center}px` }}
|
|
65
|
-
/>
|
|
66
|
-
</svg>
|
|
67
|
-
)
|
|
68
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
-
import { StrictMode } from 'react'
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { Select } from './Select'
|
|
5
|
-
import type { SelectGroup } from './Select.types'
|
|
6
|
-
|
|
7
|
-
const options = [
|
|
8
|
-
{ value: 'a', label: 'Alpha' },
|
|
9
|
-
{ value: 'b', label: 'Bravo' },
|
|
10
|
-
{ value: 'c', label: 'Charlie' },
|
|
11
|
-
]
|
|
12
|
-
|
|
13
|
-
const groups: SelectGroup[] = [
|
|
14
|
-
{
|
|
15
|
-
label: 'Letters',
|
|
16
|
-
options: [
|
|
17
|
-
{ value: 'a', label: 'Alpha' },
|
|
18
|
-
{ value: 'b', label: 'Bravo' },
|
|
19
|
-
],
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
label: 'Numbers',
|
|
23
|
-
options: [
|
|
24
|
-
{ value: '1', label: 'One' },
|
|
25
|
-
{ value: '2', label: 'Two' },
|
|
26
|
-
],
|
|
27
|
-
},
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
const renderWith = (ui: React.ReactElement) => render(<StrictMode>{ui}</StrictMode>)
|
|
31
|
-
|
|
32
|
-
describe('Select', () => {
|
|
33
|
-
it('renders selected label and chevron in button', () => {
|
|
34
|
-
renderWith(<Select options={options} value="b" onChange={vi.fn()} />)
|
|
35
|
-
|
|
36
|
-
const button = screen.getByRole('button', { name: /bravo/i })
|
|
37
|
-
expect(button).toBeInTheDocument()
|
|
38
|
-
expect(button.querySelector('svg')).toBeInTheDocument()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('opens menu on click showing all options', () => {
|
|
42
|
-
renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
|
|
43
|
-
|
|
44
|
-
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
45
|
-
|
|
46
|
-
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
47
|
-
expect(screen.getByRole('option', { name: /alpha/i })).toBeInTheDocument()
|
|
48
|
-
expect(screen.getByRole('option', { name: /bravo/i })).toBeInTheDocument()
|
|
49
|
-
expect(screen.getByRole('option', { name: /charlie/i })).toBeInTheDocument()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('calls onChange and closes menu when an option is selected', () => {
|
|
53
|
-
const onChange = vi.fn()
|
|
54
|
-
renderWith(<Select options={options} value="a" onChange={onChange} />)
|
|
55
|
-
|
|
56
|
-
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
57
|
-
fireEvent.click(screen.getByRole('option', { name: /charlie/i }))
|
|
58
|
-
|
|
59
|
-
expect(onChange).toHaveBeenCalledWith('c')
|
|
60
|
-
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('closes on Escape key', () => {
|
|
64
|
-
renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
|
|
65
|
-
|
|
66
|
-
const button = screen.getByRole('button', { name: /alpha/i })
|
|
67
|
-
fireEvent.click(button)
|
|
68
|
-
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
69
|
-
|
|
70
|
-
fireEvent.keyDown(button, { key: 'Escape' })
|
|
71
|
-
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('closes on click outside', () => {
|
|
75
|
-
renderWith(<Select options={options} value="a" onChange={vi.fn()} />)
|
|
76
|
-
|
|
77
|
-
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
78
|
-
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
79
|
-
|
|
80
|
-
fireEvent.mouseDown(document.body)
|
|
81
|
-
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('navigates with arrow keys and selects with Enter', () => {
|
|
85
|
-
const onChange = vi.fn()
|
|
86
|
-
renderWith(<Select options={options} value="a" onChange={onChange} />)
|
|
87
|
-
|
|
88
|
-
const button = screen.getByRole('button', { name: /alpha/i })
|
|
89
|
-
fireEvent.click(button)
|
|
90
|
-
|
|
91
|
-
fireEvent.keyDown(button, { key: 'ArrowDown' })
|
|
92
|
-
fireEvent.keyDown(button, { key: 'ArrowDown' })
|
|
93
|
-
fireEvent.keyDown(button, { key: 'Enter' })
|
|
94
|
-
|
|
95
|
-
expect(onChange).toHaveBeenCalledWith('b')
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('renders groups with headers', () => {
|
|
99
|
-
renderWith(<Select groups={groups} value="a" onChange={vi.fn()} />)
|
|
100
|
-
|
|
101
|
-
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
102
|
-
|
|
103
|
-
expect(screen.getByText('Letters')).toBeInTheDocument()
|
|
104
|
-
expect(screen.getByText('Numbers')).toBeInTheDocument()
|
|
105
|
-
expect(screen.getByRole('option', { name: /one/i })).toBeInTheDocument()
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('uses renderOption for custom item rendering', () => {
|
|
109
|
-
renderWith(
|
|
110
|
-
<Select
|
|
111
|
-
options={options}
|
|
112
|
-
value="a"
|
|
113
|
-
onChange={vi.fn()}
|
|
114
|
-
renderOption={option => <span data-testid="custom">{option.label.toUpperCase()}</span>}
|
|
115
|
-
/>,
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
fireEvent.click(screen.getByRole('button', { name: /alpha/i }))
|
|
119
|
-
|
|
120
|
-
expect(screen.getAllByTestId('custom')).toHaveLength(3)
|
|
121
|
-
expect(screen.getByText('BRAVO')).toBeInTheDocument()
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('shows placeholder when value does not match any option', () => {
|
|
125
|
-
renderWith(<Select options={options} value="x" onChange={vi.fn()} placeholder="Pick one" />)
|
|
126
|
-
|
|
127
|
-
expect(screen.getByRole('button', { name: /pick one/i })).toBeInTheDocument()
|
|
128
|
-
})
|
|
129
|
-
})
|
package/src/Select/Select.tsx
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { ChevronDown } from 'lucide-react'
|
|
4
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
-
import type { SelectOption, SelectProps } from './Select.types'
|
|
6
|
-
|
|
7
|
-
export const Select = ({
|
|
8
|
-
value,
|
|
9
|
-
onChange,
|
|
10
|
-
options,
|
|
11
|
-
groups,
|
|
12
|
-
placeholder,
|
|
13
|
-
id,
|
|
14
|
-
className,
|
|
15
|
-
menuClassName,
|
|
16
|
-
renderOption,
|
|
17
|
-
onOpenChange,
|
|
18
|
-
'aria-label': ariaLabel,
|
|
19
|
-
}: SelectProps) => {
|
|
20
|
-
const [open, setOpen] = useState(false)
|
|
21
|
-
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
22
|
-
const containerRef = useRef<HTMLDivElement>(null)
|
|
23
|
-
|
|
24
|
-
const changeOpen = useCallback(
|
|
25
|
-
(next: boolean) => {
|
|
26
|
-
setOpen(next)
|
|
27
|
-
onOpenChange?.(next)
|
|
28
|
-
},
|
|
29
|
-
[onOpenChange],
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
// Build flat items list with group metadata
|
|
33
|
-
const { items, groupStartIndices } = useMemo(() => {
|
|
34
|
-
const flat: SelectOption[] = [...(options ?? [])]
|
|
35
|
-
const starts: { index: number; label: string }[] = []
|
|
36
|
-
|
|
37
|
-
for (const group of groups ?? []) {
|
|
38
|
-
starts.push({ index: flat.length, label: group.label })
|
|
39
|
-
flat.push(...group.options)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { items: flat, groupStartIndices: starts }
|
|
43
|
-
}, [options, groups])
|
|
44
|
-
|
|
45
|
-
const selectedOption = items.find(o => o.value === value)
|
|
46
|
-
const displayText = selectedOption?.label ?? placeholder ?? value
|
|
47
|
-
|
|
48
|
-
// Click outside to close
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
51
|
-
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
52
|
-
changeOpen(false)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
document.addEventListener('mousedown', handleClickOutside)
|
|
56
|
-
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
57
|
-
}, [changeOpen])
|
|
58
|
-
|
|
59
|
-
const selectItem = (itemValue: string) => {
|
|
60
|
-
onChange(itemValue)
|
|
61
|
-
changeOpen(false)
|
|
62
|
-
setHighlightedIndex(-1)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
66
|
-
if (e.key === 'Escape') {
|
|
67
|
-
changeOpen(false)
|
|
68
|
-
setHighlightedIndex(-1)
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (!open) {
|
|
73
|
-
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
|
74
|
-
e.preventDefault()
|
|
75
|
-
changeOpen(true)
|
|
76
|
-
setHighlightedIndex(0)
|
|
77
|
-
}
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (e.key === 'ArrowDown') {
|
|
82
|
-
e.preventDefault()
|
|
83
|
-
setHighlightedIndex(prev => (prev + 1) % items.length)
|
|
84
|
-
} else if (e.key === 'ArrowUp') {
|
|
85
|
-
e.preventDefault()
|
|
86
|
-
setHighlightedIndex(prev => (prev - 1 + items.length) % items.length)
|
|
87
|
-
} else if (e.key === 'Enter') {
|
|
88
|
-
e.preventDefault()
|
|
89
|
-
const highlighted = items[highlightedIndex]
|
|
90
|
-
if (highlighted) {
|
|
91
|
-
selectItem(highlighted.value)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const itemClassName = (index: number, itemValue: string) => {
|
|
97
|
-
const base = 'px-3 py-2.5 cursor-pointer'
|
|
98
|
-
const highlight = index === highlightedIndex ? 'bg-primary-muted' : 'hover:bg-primary-muted'
|
|
99
|
-
const selected = itemValue === value ? 'font-medium' : ''
|
|
100
|
-
return `${base} ${highlight} ${selected}`
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return (
|
|
104
|
-
<div className="relative" ref={containerRef} onKeyDown={handleKeyDown}>
|
|
105
|
-
<button
|
|
106
|
-
type="button"
|
|
107
|
-
id={id}
|
|
108
|
-
className={`flex items-center justify-between gap-2 ${className ?? ''}`}
|
|
109
|
-
aria-expanded={open}
|
|
110
|
-
aria-haspopup="listbox"
|
|
111
|
-
aria-label={ariaLabel}
|
|
112
|
-
onClick={() => changeOpen(!open)}
|
|
113
|
-
>
|
|
114
|
-
<span className="truncate">{displayText}</span>
|
|
115
|
-
<ChevronDown
|
|
116
|
-
className={`size-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
|
|
117
|
-
/>
|
|
118
|
-
</button>
|
|
119
|
-
|
|
120
|
-
{open && (
|
|
121
|
-
<ul
|
|
122
|
-
role="listbox"
|
|
123
|
-
className={`border-border bg-background absolute z-50 mt-1 max-h-60 w-max min-w-full overflow-y-auto rounded-lg border shadow-lg ${menuClassName ?? ''}`}
|
|
124
|
-
>
|
|
125
|
-
{items.map((item, index) => {
|
|
126
|
-
const groupHeader = groupStartIndices.find(g => g.index === index)
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<li key={item.value} role="presentation">
|
|
130
|
-
{groupHeader && (
|
|
131
|
-
<div className="text-muted-foreground px-3 pt-2 pb-1 text-xs font-medium uppercase">
|
|
132
|
-
{groupHeader.label}
|
|
133
|
-
</div>
|
|
134
|
-
)}
|
|
135
|
-
<div
|
|
136
|
-
role="option"
|
|
137
|
-
aria-selected={value === item.value}
|
|
138
|
-
className={itemClassName(index, item.value)}
|
|
139
|
-
onClick={() => selectItem(item.value)}
|
|
140
|
-
onMouseEnter={() => setHighlightedIndex(index)}
|
|
141
|
-
>
|
|
142
|
-
{renderOption
|
|
143
|
-
? renderOption(item, {
|
|
144
|
-
highlighted: index === highlightedIndex,
|
|
145
|
-
selected: value === item.value,
|
|
146
|
-
})
|
|
147
|
-
: item.label}
|
|
148
|
-
</div>
|
|
149
|
-
</li>
|
|
150
|
-
)
|
|
151
|
-
})}
|
|
152
|
-
</ul>
|
|
153
|
-
)}
|
|
154
|
-
</div>
|
|
155
|
-
)
|
|
156
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from 'react'
|
|
2
|
-
|
|
3
|
-
export type SelectOption = {
|
|
4
|
-
value: string
|
|
5
|
-
label: string
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type SelectGroup = {
|
|
9
|
-
label: string
|
|
10
|
-
options: SelectOption[]
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export type SelectOptionState = {
|
|
14
|
-
highlighted: boolean
|
|
15
|
-
selected: boolean
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type SelectProps = {
|
|
19
|
-
value: string
|
|
20
|
-
onChange: (value: string) => void
|
|
21
|
-
options?: SelectOption[]
|
|
22
|
-
groups?: SelectGroup[]
|
|
23
|
-
placeholder?: string
|
|
24
|
-
id?: string
|
|
25
|
-
className?: string
|
|
26
|
-
menuClassName?: string
|
|
27
|
-
renderOption?: (option: SelectOption, state: SelectOptionState) => ReactNode
|
|
28
|
-
onOpenChange?: (open: boolean) => void
|
|
29
|
-
'aria-label'?: string
|
|
30
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { cleanup, render, screen } from '@testing-library/react'
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { Slider } from './Slider'
|
|
4
|
-
|
|
5
|
-
afterEach(cleanup)
|
|
6
|
-
|
|
7
|
-
describe('Slider', () => {
|
|
8
|
-
it('renders a slider with the correct value', () => {
|
|
9
|
-
render(<Slider value={50} onChange={() => {}} min={0} max={100} aria-label="Volume" />)
|
|
10
|
-
const slider = screen.getByRole('slider')
|
|
11
|
-
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
it('calls onCommit when interaction ends', () => {
|
|
15
|
-
const onCommit = vi.fn()
|
|
16
|
-
render(
|
|
17
|
-
<Slider
|
|
18
|
-
value={50}
|
|
19
|
-
onChange={() => {}}
|
|
20
|
-
onCommit={onCommit}
|
|
21
|
-
min={0}
|
|
22
|
-
max={100}
|
|
23
|
-
aria-label="Volume"
|
|
24
|
-
/>,
|
|
25
|
-
)
|
|
26
|
-
// Just verify it renders without error — slider drag testing is unreliable in jsdom
|
|
27
|
-
expect(screen.getByRole('slider')).toBeInTheDocument()
|
|
28
|
-
})
|
|
29
|
-
})
|