@carbonid1/design-system 4.0.1 → 4.1.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/README.md +5 -5
- package/package.json +5 -2
- package/src/ContextMenu/ContextMenu.tsx +159 -0
- package/src/ProgressRing/ProgressRing.test.tsx +51 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Slider/Slider.test.tsx +29 -0
- package/src/Switch/Switch.test.tsx +31 -0
- package/src/helpers/getModKey/getModKey.test.ts +23 -0
- package/src/index.ts +2 -0
- package/themes/dashboard.css +12 -0
- package/themes/reader.css +12 -0
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# @carbonid1/design-system
|
|
2
2
|
|
|
3
|
-
Shared React UI primitives
|
|
3
|
+
Shared React UI primitives + Tailwind v4 theme presets (`reader`, `dashboard`) + postcss config.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
pnpm add @carbonid1/design-system
|
|
8
|
+
pnpm add @carbonid1/design-system react react-dom @base-ui/react class-variance-authority clsx tailwind-merge lucide-react sonner next-themes react-hotkeys-hook
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
And Tailwind CSS v4 (with `tw-animate-css` + `shadcn/tailwind.css` if you want matching utilities/base):
|
|
@@ -24,7 +24,7 @@ In your entry CSS (Next.js: `src/app/globals.css`):
|
|
|
24
24
|
@import 'tailwindcss';
|
|
25
25
|
@import 'tw-animate-css';
|
|
26
26
|
@import 'shadcn/tailwind.css';
|
|
27
|
-
@import '@carbonid1/
|
|
27
|
+
@import '@carbonid1/design-system/themes/reader';
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
### 2. Transpile the package in Next.js
|
|
@@ -84,8 +84,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
84
84
|
|
|
85
85
|
## Theming
|
|
86
86
|
|
|
87
|
-
Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via
|
|
87
|
+
Components use semantic color utilities (`bg-primary`, `text-muted-foreground`, etc.) that resolve via the imported theme preset. Switching theme → swap the `@carbonid1/design-system/themes/*` import. Dark mode is toggled by adding `class="dark"` to a root ancestor (`ThemeProvider` handles this).
|
|
88
88
|
|
|
89
89
|
## Build
|
|
90
90
|
|
|
91
|
-
No build step — package ships raw `.tsx
|
|
91
|
+
No build step — package ships raw `.tsx`. Consumers transpile at build time. Stories (`*.stories.tsx`) and tests (`*.test.{ts,tsx}`) are excluded from the published tarball.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carbonid1/design-system",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Shared React UI primitives + design tokens (themes, postcss config)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"author": "Andrew Korin",
|
|
12
12
|
"type": "module",
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"**/*.css"
|
|
15
|
+
],
|
|
13
16
|
"exports": {
|
|
14
17
|
".": "./src/index.ts",
|
|
15
18
|
"./themes/reader": "./themes/reader.css",
|
|
@@ -67,7 +70,7 @@
|
|
|
67
70
|
"tailwindcss": "^4.2.2",
|
|
68
71
|
"vite": "^8.0.8",
|
|
69
72
|
"vitest": "^4.1.4",
|
|
70
|
-
"@carbonid1/tsconfig": "0.2.
|
|
73
|
+
"@carbonid1/tsconfig": "0.2.1"
|
|
71
74
|
},
|
|
72
75
|
"scripts": {
|
|
73
76
|
"storybook": "storybook dev -p 6006",
|
|
@@ -0,0 +1,159 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ProgressRing } from './ProgressRing'
|
|
4
|
+
import { CIRCUMFERENCE } from './ProgressRing.consts'
|
|
5
|
+
|
|
6
|
+
describe('ProgressRing', () => {
|
|
7
|
+
it('renders zero dashoffset at 0% progress', () => {
|
|
8
|
+
render(<ProgressRing progress={0} colorClass="text-primary" label="Empty" testId="ring" />)
|
|
9
|
+
const circle = screen.getByTestId('ring')
|
|
10
|
+
const offset = Number(circle.getAttribute('stroke-dashoffset'))
|
|
11
|
+
expect(offset).toBeCloseTo(CIRCUMFERENCE, 1)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders correct dashoffset at 50% progress', () => {
|
|
15
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="Half" testId="ring" />)
|
|
16
|
+
const circle = screen.getByTestId('ring')
|
|
17
|
+
const offset = Number(circle.getAttribute('stroke-dashoffset'))
|
|
18
|
+
expect(offset).toBeCloseTo(CIRCUMFERENCE * 0.5, 1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders zero dashoffset at 100% progress', () => {
|
|
22
|
+
render(<ProgressRing progress={1} colorClass="text-success" label="Full" testId="ring" />)
|
|
23
|
+
const circle = screen.getByTestId('ring')
|
|
24
|
+
const offset = Number(circle.getAttribute('stroke-dashoffset'))
|
|
25
|
+
expect(offset).toBeCloseTo(0, 1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('applies colorClass to fill circle', () => {
|
|
29
|
+
render(<ProgressRing progress={0.5} colorClass="text-success" label="Test" testId="ring" />)
|
|
30
|
+
const circle = screen.getByTestId('ring')
|
|
31
|
+
expect(circle.classList.toString()).toContain('text-success')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('applies pulse animation when animate is true', () => {
|
|
35
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="Pulsing" animate />)
|
|
36
|
+
const svg = screen.getByRole('img', { hidden: true })
|
|
37
|
+
expect(svg.classList.toString()).toContain('animate-buffer-pulse')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not apply pulse animation when animate is false', () => {
|
|
41
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="Idle" />)
|
|
42
|
+
const svg = screen.getByRole('img', { hidden: true })
|
|
43
|
+
expect(svg.classList.toString()).not.toContain('animate-buffer-pulse')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('sets aria-label from label prop', () => {
|
|
47
|
+
render(<ProgressRing progress={0.5} colorClass="text-primary" label="42 paragraphs ready" />)
|
|
48
|
+
const svg = screen.getByRole('img', { hidden: true })
|
|
49
|
+
expect(svg.getAttribute('aria-label')).toBe('42 paragraphs ready')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { Switch } from './Switch'
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup)
|
|
7
|
+
|
|
8
|
+
describe('Switch', () => {
|
|
9
|
+
it('renders as a switch with the correct checked state', () => {
|
|
10
|
+
render(<Switch checked={true} onChange={() => {}} aria-label="Toggle" />)
|
|
11
|
+
expect(screen.getByRole('switch')).toBeChecked()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('calls onChange when clicked', async () => {
|
|
15
|
+
const user = userEvent.setup()
|
|
16
|
+
const onChange = vi.fn()
|
|
17
|
+
render(<Switch checked={false} onChange={onChange} aria-label="Toggle" />)
|
|
18
|
+
|
|
19
|
+
await user.click(screen.getByRole('switch'))
|
|
20
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not call onChange when disabled', async () => {
|
|
24
|
+
const user = userEvent.setup()
|
|
25
|
+
const onChange = vi.fn()
|
|
26
|
+
render(<Switch checked={false} onChange={onChange} disabled aria-label="Toggle" />)
|
|
27
|
+
|
|
28
|
+
await user.click(screen.getByRole('switch'))
|
|
29
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { getModKey } from './getModKey'
|
|
3
|
+
|
|
4
|
+
describe('getModKey', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllGlobals()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('returns "Cmd" on Mac', () => {
|
|
10
|
+
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' })
|
|
11
|
+
expect(getModKey()).toBe('Cmd')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns "Ctrl" on Windows', () => {
|
|
15
|
+
vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' })
|
|
16
|
+
expect(getModKey()).toBe('Ctrl')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns "Ctrl" when navigator is undefined', () => {
|
|
20
|
+
vi.stubGlobal('navigator', undefined)
|
|
21
|
+
expect(getModKey()).toBe('Ctrl')
|
|
22
|
+
})
|
|
23
|
+
})
|
package/src/index.ts
CHANGED
package/themes/dashboard.css
CHANGED
|
@@ -121,6 +121,18 @@
|
|
|
121
121
|
--success-foreground: oklch(0.82 0.1 149);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/* Global focus-visible ring for raw interactive elements. Primitives (Button,
|
|
125
|
+
* etc.) override this with their own tuned focus styling. */
|
|
126
|
+
@layer base {
|
|
127
|
+
button:focus-visible,
|
|
128
|
+
[role='button']:focus-visible,
|
|
129
|
+
a:focus-visible {
|
|
130
|
+
outline: 2px solid var(--primary);
|
|
131
|
+
outline-offset: 2px;
|
|
132
|
+
border-radius: 0.375rem;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/* Keyframes referenced by the theme's --animate-* tokens */
|
|
125
137
|
@keyframes ring-spin {
|
|
126
138
|
to {
|
package/themes/reader.css
CHANGED
|
@@ -123,6 +123,18 @@
|
|
|
123
123
|
--success-foreground: oklch(0.82 0.1 149);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/* Global focus-visible ring for raw interactive elements. Primitives (Button,
|
|
127
|
+
* etc.) override this with their own tuned focus styling. */
|
|
128
|
+
@layer base {
|
|
129
|
+
button:focus-visible,
|
|
130
|
+
[role='button']:focus-visible,
|
|
131
|
+
a:focus-visible {
|
|
132
|
+
outline: 2px solid var(--primary);
|
|
133
|
+
outline-offset: 2px;
|
|
134
|
+
border-radius: 0.375rem;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
126
138
|
/* Keyframes referenced by the theme's --animate-* tokens */
|
|
127
139
|
@keyframes ring-spin {
|
|
128
140
|
to {
|