@carbonid1/design-system 0.1.0 → 2.0.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/package.json +25 -3
- package/src/Button/Button.stories.tsx +0 -121
- package/src/Kbd/Kbd.stories.tsx +0 -112
- package/src/ProgressRing/ProgressRing.stories.tsx +0 -66
- package/src/ProgressRing/ProgressRing.vi.tsx +0 -51
- package/src/Select/Select.vi.tsx +0 -129
- package/src/Slider/Slider.vi.tsx +0 -29
- package/src/Switch/Switch.vi.tsx +0 -31
- package/src/Tooltip/Tooltip.stories.tsx +0 -161
- package/src/helpers/getModKey/getModKey.vi.ts +0 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carbonid1/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Shared React UI primitives themed via @carbonid1/tailwind-config",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"@base-ui/react": "^1.0.0-alpha",
|
|
21
|
-
"@carbonid1/tailwind-config": "^0.
|
|
21
|
+
"@carbonid1/tailwind-config": "^0.3.0",
|
|
22
22
|
"class-variance-authority": "^0.7.0",
|
|
23
23
|
"clsx": "^2.0.0",
|
|
24
24
|
"lucide-react": ">=0.400.0",
|
|
@@ -35,17 +35,39 @@
|
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@base-ui/react": "^1.4.0",
|
|
38
|
+
"@storybook/addon-docs": "^10.3.5",
|
|
39
|
+
"@storybook/addon-themes": "^10.3.5",
|
|
40
|
+
"@storybook/addon-vitest": "^10.3.5",
|
|
41
|
+
"@storybook/react-vite": "^10.3.5",
|
|
42
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
43
|
+
"@testing-library/react": "^16.3.2",
|
|
44
|
+
"@testing-library/user-event": "^14.6.1",
|
|
38
45
|
"@types/react": "^19.2.14",
|
|
39
46
|
"@types/react-dom": "^19.2.3",
|
|
47
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
40
48
|
"class-variance-authority": "^0.7.1",
|
|
41
49
|
"clsx": "^2.1.1",
|
|
50
|
+
"jsdom": "^29.0.2",
|
|
42
51
|
"lucide-react": "^1.8.0",
|
|
43
52
|
"next-themes": "^0.4.6",
|
|
44
53
|
"react": "^19.2.5",
|
|
45
54
|
"react-dom": "^19.2.5",
|
|
46
55
|
"react-hotkeys-hook": "^5.2.4",
|
|
47
56
|
"sonner": "^2.0.7",
|
|
57
|
+
"storybook": "^10.3.5",
|
|
48
58
|
"tailwind-merge": "^3.5.0",
|
|
49
|
-
"
|
|
59
|
+
"tailwindcss": "^4.2.2",
|
|
60
|
+
"vite": "^8.0.8",
|
|
61
|
+
"vitest": "^4.1.4",
|
|
62
|
+
"@carbonid1/storybook-config": "0.1.0",
|
|
63
|
+
"@carbonid1/tailwind-config": "0.3.0",
|
|
64
|
+
"@carbonid1/tsconfig": "0.1.0",
|
|
65
|
+
"@carbonid1/vitest-config": "0.1.0"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"storybook": "storybook dev -p 6006",
|
|
69
|
+
"build-storybook": "storybook build",
|
|
70
|
+
"test": "vitest --run",
|
|
71
|
+
"test:watch": "vitest"
|
|
50
72
|
}
|
|
51
73
|
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import type { ComponentProps } from 'react'
|
|
2
|
-
import { expect, fn } from 'storybook/test'
|
|
3
|
-
import preview from '../../../../.storybook/preview'
|
|
4
|
-
import { Button } from './Button'
|
|
5
|
-
|
|
6
|
-
type ButtonProps = ComponentProps<typeof Button>
|
|
7
|
-
|
|
8
|
-
// TODO: Remove `.type<>()` workaround after upgrading to Storybook 11.
|
|
9
|
-
// SB11 adds `const` modifier to factory generics, fixing args inference for intersection types.
|
|
10
|
-
// See: https://github.com/storybookjs/storybook/issues/32829
|
|
11
|
-
const meta = preview.type<{ args: ButtonProps }>().meta({
|
|
12
|
-
component: Button,
|
|
13
|
-
args: {
|
|
14
|
-
onClick: fn(),
|
|
15
|
-
},
|
|
16
|
-
argTypes: {
|
|
17
|
-
variant: {
|
|
18
|
-
control: 'select',
|
|
19
|
-
options: ['ghost', 'primary', 'outline', 'destructive', 'subtle', 'danger', 'link'],
|
|
20
|
-
},
|
|
21
|
-
size: {
|
|
22
|
-
control: 'select',
|
|
23
|
-
options: ['default', 'small', 'large', 'icon', 'smallIcon', 'largeIcon'],
|
|
24
|
-
},
|
|
25
|
-
fullWidth: { control: 'boolean' },
|
|
26
|
-
loading: { control: 'boolean' },
|
|
27
|
-
disabled: { control: 'boolean' },
|
|
28
|
-
},
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
// --- Variants ---
|
|
32
|
-
|
|
33
|
-
export const Ghost = meta.story({
|
|
34
|
-
args: { children: 'Ghost', variant: 'ghost' },
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
export const Primary = meta.story({
|
|
38
|
-
args: { children: 'Primary', variant: 'primary' },
|
|
39
|
-
})
|
|
40
|
-
Primary.test('fires onClick when clicked', async ({ canvas, userEvent, args }) => {
|
|
41
|
-
const button = await canvas.findByRole('button')
|
|
42
|
-
await userEvent.click(button)
|
|
43
|
-
await expect(args.onClick).toHaveBeenCalledOnce()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
export const Outline = meta.story({
|
|
47
|
-
args: { children: 'Outline', variant: 'outline' },
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
export const Destructive = meta.story({
|
|
51
|
-
args: { children: 'Delete Account', variant: 'destructive' },
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
export const Subtle = meta.story({
|
|
55
|
-
args: { children: '▶', size: 'icon', variant: 'subtle', 'aria-label': 'Play source' },
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
export const Danger = meta.story({
|
|
59
|
-
args: { children: '✕', size: 'icon', variant: 'danger', 'aria-label': 'Delete' },
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
export const Link = meta.story({
|
|
63
|
-
args: { children: 'Learn more', variant: 'link' },
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
// --- Sizes ---
|
|
67
|
-
|
|
68
|
-
export const IconSize = meta.story({
|
|
69
|
-
args: { children: '✕', size: 'icon', 'aria-label': 'Close' },
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
export const SmallIconSize = meta.story({
|
|
73
|
-
args: { children: '✕', size: 'smallIcon', 'aria-label': 'Dismiss' },
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
export const LargeIconSize = meta.story({
|
|
77
|
-
args: { children: '▶', size: 'largeIcon', variant: 'primary', 'aria-label': 'Play' },
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
export const SmallSize = meta.story({
|
|
81
|
-
args: { children: 'Copy Text', size: 'small' },
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
export const LargeSize = meta.story({
|
|
85
|
-
args: { children: 'Continue', size: 'large', variant: 'primary' },
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
// --- FullWidth ---
|
|
89
|
-
|
|
90
|
-
export const FullWidthPrimary = meta.story({
|
|
91
|
-
args: {
|
|
92
|
-
children: 'Continue to Next Chapter',
|
|
93
|
-
variant: 'primary',
|
|
94
|
-
size: 'large',
|
|
95
|
-
fullWidth: true,
|
|
96
|
-
},
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
export const FullWidthMenuItem = meta.story({
|
|
100
|
-
args: { children: 'Copy Text', size: 'small', fullWidth: true },
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
// --- States ---
|
|
104
|
-
|
|
105
|
-
export const Loading = meta.story({
|
|
106
|
-
args: { children: 'Generating...', variant: 'primary', loading: true },
|
|
107
|
-
})
|
|
108
|
-
Loading.test('shows spinner and disables button', async ({ canvas }) => {
|
|
109
|
-
const button = await canvas.findByRole('button')
|
|
110
|
-
await expect(button).toBeDisabled()
|
|
111
|
-
await expect(button).toHaveAttribute('aria-busy', 'true')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
export const Disabled = meta.story({
|
|
115
|
-
args: { children: 'Disabled', variant: 'primary', disabled: true },
|
|
116
|
-
})
|
|
117
|
-
Disabled.test('is disabled and does not respond to clicks', async ({ canvas }) => {
|
|
118
|
-
const button = await canvas.findByRole('button')
|
|
119
|
-
await expect(button).toBeDisabled()
|
|
120
|
-
await expect(button).toHaveStyle({ pointerEvents: 'none' })
|
|
121
|
-
})
|
package/src/Kbd/Kbd.stories.tsx
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import type { ComponentProps } from 'react'
|
|
2
|
-
import { expect } from 'storybook/test'
|
|
3
|
-
import preview from '../../../../.storybook/preview'
|
|
4
|
-
import { Kbd } from './Kbd'
|
|
5
|
-
|
|
6
|
-
type KbdProps = ComponentProps<typeof Kbd>
|
|
7
|
-
|
|
8
|
-
const meta = preview.type<{ args: KbdProps }>().meta({
|
|
9
|
-
component: Kbd,
|
|
10
|
-
argTypes: {
|
|
11
|
-
size: {
|
|
12
|
-
control: 'select',
|
|
13
|
-
options: ['default', 'sm'],
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
decorators: [
|
|
17
|
-
Story => (
|
|
18
|
-
<div style={{ padding: 24, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
19
|
-
<Story />
|
|
20
|
-
</div>
|
|
21
|
-
),
|
|
22
|
-
],
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
// --- Single keys ---
|
|
26
|
-
|
|
27
|
-
/** Plain letter key — the simplest case. */
|
|
28
|
-
export const SingleKey = meta.story({
|
|
29
|
-
args: { keys: 'T' },
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
/** Token name resolved to its standard symbol. */
|
|
33
|
-
export const EscapeKey = meta.story({
|
|
34
|
-
name: 'Symbolic: Escape',
|
|
35
|
-
args: { keys: 'escape' },
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
/** Spacebar rendered as the ␣ symbol. */
|
|
39
|
-
export const SpaceKey = meta.story({
|
|
40
|
-
name: 'Symbolic: Space',
|
|
41
|
-
args: { keys: 'space' },
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
/** Arrow key rendered as a directional symbol. */
|
|
45
|
-
export const ArrowKey = meta.story({
|
|
46
|
-
name: 'Symbolic: Arrow',
|
|
47
|
-
args: { keys: 'left' },
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
// --- Combos ---
|
|
51
|
-
|
|
52
|
-
/** Two-key combo — each key renders as a separate styled element. */
|
|
53
|
-
export const ShiftCombo = meta.story({
|
|
54
|
-
name: 'Combo: Shift + T',
|
|
55
|
-
args: { keys: ['shift', 'T'] },
|
|
56
|
-
})
|
|
57
|
-
ShiftCombo.test('renders separate kbd per key in combo', async ({ canvas }) => {
|
|
58
|
-
const wrapper = canvas.getAllByRole('presentation')[0]
|
|
59
|
-
if (!wrapper) throw new Error('No presentation wrapper found')
|
|
60
|
-
const kbdElements = wrapper.querySelectorAll('kbd')
|
|
61
|
-
await expect(kbdElements.length).toBe(2)
|
|
62
|
-
await expect(kbdElements[0]?.textContent).toBe('⇧')
|
|
63
|
-
await expect(kbdElements[1]?.textContent).toBe('T')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
/** Mod token resolved to ⌘ on Mac or Ctrl on other platforms. */
|
|
67
|
-
export const ModCombo = meta.story({
|
|
68
|
-
name: 'Combo: Mod + F (OS-aware)',
|
|
69
|
-
args: { keys: ['mod', 'F'] },
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
/** Three-key combo showing modifier stacking. */
|
|
73
|
-
export const TripleCombo = meta.story({
|
|
74
|
-
name: 'Combo: Mod + Shift + K',
|
|
75
|
-
args: { keys: ['mod', 'shift', 'K'] },
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
// --- Sizes ---
|
|
79
|
-
|
|
80
|
-
/** Small size used inline within tooltip overlays. */
|
|
81
|
-
export const Small = meta.story({
|
|
82
|
-
name: 'Size: Small',
|
|
83
|
-
args: { keys: ['shift', 'B'], size: 'sm' },
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
// --- In context ---
|
|
87
|
-
|
|
88
|
-
/** Shortcut hint next to a settings label, matching the AppearanceCard layout. */
|
|
89
|
-
export const InlineWithText = meta.story({
|
|
90
|
-
name: 'Context: Inline with text',
|
|
91
|
-
args: { keys: ['shift', 'T'], size: 'sm' },
|
|
92
|
-
decorators: [
|
|
93
|
-
Story => (
|
|
94
|
-
<span className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
95
|
-
Theme <Story />
|
|
96
|
-
</span>
|
|
97
|
-
),
|
|
98
|
-
],
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
/** Small Kbd on an inverted tooltip background — needs color overrides to stay visible. */
|
|
102
|
-
export const TooltipStyle = meta.story({
|
|
103
|
-
name: 'Context: Tooltip background',
|
|
104
|
-
args: { keys: ['mod', 'F'], size: 'sm', className: 'border-transparent bg-background/15' },
|
|
105
|
-
decorators: [
|
|
106
|
-
Story => (
|
|
107
|
-
<div className="bg-foreground text-background flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs">
|
|
108
|
-
Search <Story />
|
|
109
|
-
</div>
|
|
110
|
-
),
|
|
111
|
-
],
|
|
112
|
-
})
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import type { ComponentProps } from 'react'
|
|
2
|
-
import preview from '../../../../.storybook/preview'
|
|
3
|
-
import { ProgressRing } from './ProgressRing'
|
|
4
|
-
|
|
5
|
-
type ProgressRingProps = ComponentProps<typeof ProgressRing>
|
|
6
|
-
|
|
7
|
-
const meta = preview.type<{ args: ProgressRingProps }>().meta({
|
|
8
|
-
component: ProgressRing,
|
|
9
|
-
argTypes: {
|
|
10
|
-
progress: { control: { type: 'range', min: 0, max: 1, step: 0.01 } },
|
|
11
|
-
colorClass: {
|
|
12
|
-
control: 'select',
|
|
13
|
-
options: ['text-primary', 'text-success', 'text-muted-foreground'],
|
|
14
|
-
},
|
|
15
|
-
animate: { control: 'boolean' },
|
|
16
|
-
pendingStyle: {
|
|
17
|
-
control: 'select',
|
|
18
|
-
options: ['none', 'dashed'],
|
|
19
|
-
},
|
|
20
|
-
},
|
|
21
|
-
args: {
|
|
22
|
-
label: 'Generation progress',
|
|
23
|
-
colorClass: 'text-primary',
|
|
24
|
-
},
|
|
25
|
-
decorators: [
|
|
26
|
-
Story => (
|
|
27
|
-
<div style={{ padding: 24 }}>
|
|
28
|
-
<Story />
|
|
29
|
-
</div>
|
|
30
|
-
),
|
|
31
|
-
],
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
// --- Active generation ---
|
|
35
|
-
|
|
36
|
-
export const Spinning = meta.story({
|
|
37
|
-
name: 'Active: Spinning (low progress)',
|
|
38
|
-
args: { progress: 0, animate: true, label: 'Starting generation' },
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
export const InProgress = meta.story({
|
|
42
|
-
name: 'Active: In Progress',
|
|
43
|
-
args: { progress: 0.35, animate: true, label: '35 of 100 paragraphs' },
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
export const AlmostDone = meta.story({
|
|
47
|
-
name: 'Active: Almost Done',
|
|
48
|
-
args: { progress: 0.92, animate: true, label: '92 of 100 paragraphs' },
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
export const Completed = meta.story({
|
|
52
|
-
name: 'Completed',
|
|
53
|
-
args: { progress: 1, animate: false, colorClass: 'text-success', label: '100 paragraphs' },
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
// --- Pending ---
|
|
57
|
-
|
|
58
|
-
export const PendingQueued = meta.story({
|
|
59
|
-
name: 'Pending: Queued',
|
|
60
|
-
args: { progress: 0, animate: false, pendingStyle: 'dashed', label: 'Queued' },
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
export const PendingPaused = meta.story({
|
|
64
|
-
name: 'Pending: Paused (mid-generation)',
|
|
65
|
-
args: { progress: 0.4, animate: false, pendingStyle: 'dashed', label: 'Paused at 40%' },
|
|
66
|
-
})
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
})
|
package/src/Select/Select.vi.tsx
DELETED
|
@@ -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/Slider/Slider.vi.tsx
DELETED
|
@@ -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
|
-
})
|
package/src/Switch/Switch.vi.tsx
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import type { ComponentProps } from 'react'
|
|
2
|
-
import { expect, waitFor } from 'storybook/test'
|
|
3
|
-
import preview from '../../../.storybook/preview'
|
|
4
|
-
import { Tooltip } from './Tooltip'
|
|
5
|
-
|
|
6
|
-
type TooltipProps = ComponentProps<typeof Tooltip>
|
|
7
|
-
|
|
8
|
-
const meta = preview.type<{ args: TooltipProps }>().meta({
|
|
9
|
-
component: Tooltip,
|
|
10
|
-
argTypes: {
|
|
11
|
-
position: {
|
|
12
|
-
control: 'select',
|
|
13
|
-
options: ['top', 'bottom'],
|
|
14
|
-
},
|
|
15
|
-
disabled: { control: 'boolean' },
|
|
16
|
-
delay: { control: 'number' },
|
|
17
|
-
maxWidth: { control: 'number' },
|
|
18
|
-
},
|
|
19
|
-
args: {
|
|
20
|
-
label: 'Tooltip label',
|
|
21
|
-
children: <button style={{ padding: '8px 16px' }}>Hover me</button>,
|
|
22
|
-
},
|
|
23
|
-
decorators: [
|
|
24
|
-
Story => (
|
|
25
|
-
<div style={{ padding: 80, display: 'flex', justifyContent: 'center' }}>
|
|
26
|
-
<Story />
|
|
27
|
-
</div>
|
|
28
|
-
),
|
|
29
|
-
],
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
// --- Variants ---
|
|
33
|
-
|
|
34
|
-
/** Label-only tooltip with no shortcut — the most common usage. */
|
|
35
|
-
export const Default = meta.story({
|
|
36
|
-
args: { label: 'Default tooltip' },
|
|
37
|
-
})
|
|
38
|
-
Default.test('is hidden by default', async ({ canvas }) => {
|
|
39
|
-
await expect(canvas.queryByRole('tooltip')).toBeNull()
|
|
40
|
-
})
|
|
41
|
-
Default.test('shows on hover and hides on leave', async ({ canvas, userEvent }) => {
|
|
42
|
-
const button = await canvas.findByRole('button')
|
|
43
|
-
await userEvent.hover(button)
|
|
44
|
-
await waitFor(() => expect(canvas.getByRole('tooltip')).toHaveTextContent('Default tooltip'))
|
|
45
|
-
await userEvent.unhover(button)
|
|
46
|
-
await waitFor(() => expect(canvas.queryByRole('tooltip')).toBeNull())
|
|
47
|
-
})
|
|
48
|
-
Default.test('shows on focus and hides on blur', async ({ canvas }) => {
|
|
49
|
-
const button = await canvas.findByRole('button')
|
|
50
|
-
button.focus()
|
|
51
|
-
await waitFor(() => expect(canvas.getByRole('tooltip')).toHaveTextContent('Default tooltip'))
|
|
52
|
-
button.blur()
|
|
53
|
-
await waitFor(() => expect(canvas.queryByRole('tooltip')).toBeNull())
|
|
54
|
-
})
|
|
55
|
-
Default.test('does not render kbd when shortcut omitted', async ({ canvas, userEvent }) => {
|
|
56
|
-
const button = await canvas.findByRole('button')
|
|
57
|
-
await userEvent.hover(button)
|
|
58
|
-
await waitFor(() => {
|
|
59
|
-
expect(canvas.getByRole('tooltip').querySelector('kbd')).toBeNull()
|
|
60
|
-
})
|
|
61
|
-
})
|
|
62
|
-
Default.test('hides on pointerdown', async ({ canvas, userEvent }) => {
|
|
63
|
-
const button = await canvas.findByRole('button')
|
|
64
|
-
await userEvent.hover(button)
|
|
65
|
-
await waitFor(() => expect(canvas.getByRole('tooltip')).toBeInTheDocument())
|
|
66
|
-
await userEvent.pointer({ keys: '[MouseLeft>]', target: button })
|
|
67
|
-
await waitFor(() => expect(canvas.queryByRole('tooltip')).toBeNull())
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
/** Single-key shortcut badge shown alongside the label. */
|
|
71
|
-
export const WithShortcut = meta.story({
|
|
72
|
-
args: { label: 'Play', shortcut: 'Space' },
|
|
73
|
-
})
|
|
74
|
-
WithShortcut.test('renders shortcut kbd on hover', async ({ canvas, userEvent }) => {
|
|
75
|
-
const button = await canvas.findByRole('button')
|
|
76
|
-
await userEvent.hover(button)
|
|
77
|
-
await waitFor(() => {
|
|
78
|
-
const tooltip = canvas.getByRole('tooltip')
|
|
79
|
-
expect(tooltip).toHaveTextContent('Play')
|
|
80
|
-
expect(tooltip.querySelector('kbd')).not.toBeNull()
|
|
81
|
-
})
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
/** Multi-key combo shortcut — each key rendered as a separate Kbd element. */
|
|
85
|
-
export const WithComboShortcut = meta.story({
|
|
86
|
-
args: { label: 'Search', shortcut: ['mod', 'F'] },
|
|
87
|
-
})
|
|
88
|
-
WithComboShortcut.test('renders multiple kbd elements for combo', async ({ canvas, userEvent }) => {
|
|
89
|
-
const button = await canvas.findByRole('button')
|
|
90
|
-
await userEvent.hover(button)
|
|
91
|
-
await waitFor(() => {
|
|
92
|
-
const kbdElements = canvas.getByRole('tooltip').querySelectorAll('kbd')
|
|
93
|
-
expect(kbdElements.length).toBe(2)
|
|
94
|
-
})
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
/** Tooltip anchored below the trigger — used in headers and top-of-page areas. */
|
|
98
|
-
export const BottomPosition = meta.story({
|
|
99
|
-
args: { label: 'Bottom tooltip', position: 'bottom' },
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
/** Long text constrained to a max width, wrapping across multiple lines. */
|
|
103
|
-
export const WithMaxWidth = meta.story({
|
|
104
|
-
args: {
|
|
105
|
-
label: 'This is a very long tooltip that should wrap to multiple lines when maxWidth is set',
|
|
106
|
-
maxWidth: 200,
|
|
107
|
-
},
|
|
108
|
-
})
|
|
109
|
-
WithMaxWidth.test('applies max-width style and wraps text', async ({ canvas, userEvent }) => {
|
|
110
|
-
const button = await canvas.findByRole('button')
|
|
111
|
-
await userEvent.hover(button)
|
|
112
|
-
await waitFor(() => {
|
|
113
|
-
const tooltip = canvas.getByRole('tooltip')
|
|
114
|
-
expect(tooltip).toHaveStyle({ maxWidth: '200px' })
|
|
115
|
-
expect(tooltip.className).toContain('whitespace-normal')
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
/** Tooltip suppressed while a popover or menu is open. */
|
|
120
|
-
export const Disabled = meta.story({
|
|
121
|
-
args: { label: 'Disabled tooltip', disabled: true },
|
|
122
|
-
})
|
|
123
|
-
Disabled.test('does not show when disabled', async ({ canvas, userEvent }) => {
|
|
124
|
-
const button = await canvas.findByRole('button')
|
|
125
|
-
await userEvent.hover(button)
|
|
126
|
-
await new Promise(r => setTimeout(r, 300))
|
|
127
|
-
await expect(canvas.queryByRole('tooltip')).toBeNull()
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
/** Longer delay for progressive-disclosure tooltips on data visualizations. */
|
|
131
|
-
export const CustomDelay = meta.story({
|
|
132
|
-
args: { label: 'Slow tooltip', delay: 500 },
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
/** Icon-only button that gets its accessible name from the tooltip label. */
|
|
136
|
-
export const IconOnlyTrigger = meta.story({
|
|
137
|
-
args: {
|
|
138
|
-
label: 'Previous Sentence',
|
|
139
|
-
children: (
|
|
140
|
-
<button>
|
|
141
|
-
<span>icon</span>
|
|
142
|
-
</button>
|
|
143
|
-
),
|
|
144
|
-
},
|
|
145
|
-
})
|
|
146
|
-
IconOnlyTrigger.test('sets aria-label from label prop', async ({ canvas }) => {
|
|
147
|
-
const button = await canvas.findByRole('button')
|
|
148
|
-
await expect(button).toHaveAttribute('aria-label', 'Previous Sentence')
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
/** Trigger with a pre-existing aria-label that should not be overwritten. */
|
|
152
|
-
export const CustomAriaLabel = meta.story({
|
|
153
|
-
args: {
|
|
154
|
-
label: 'Previous Sentence',
|
|
155
|
-
children: <button aria-label="Custom label">icon</button>,
|
|
156
|
-
},
|
|
157
|
-
})
|
|
158
|
-
CustomAriaLabel.test('preserves existing aria-label on trigger', async ({ canvas }) => {
|
|
159
|
-
const button = await canvas.findByRole('button')
|
|
160
|
-
await expect(button).toHaveAttribute('aria-label', 'Custom label')
|
|
161
|
-
})
|
|
@@ -1,23 +0,0 @@
|
|
|
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
|
-
})
|