@carbonid1/design-system 0.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 +91 -0
- package/package.json +51 -0
- package/src/Button/Button.stories.tsx +121 -0
- package/src/Button/Button.tsx +67 -0
- package/src/Button/Button.types.ts +11 -0
- package/src/Kbd/Kbd.stories.tsx +112 -0
- package/src/Kbd/Kbd.tsx +56 -0
- package/src/Kbd/Kbd.types.ts +10 -0
- package/src/ProgressRing/ProgressRing.consts.ts +4 -0
- package/src/ProgressRing/ProgressRing.stories.tsx +66 -0
- package/src/ProgressRing/ProgressRing.tsx +68 -0
- package/src/ProgressRing/ProgressRing.vi.tsx +51 -0
- package/src/Select/Select.tsx +156 -0
- package/src/Select/Select.types.ts +30 -0
- package/src/Select/Select.vi.tsx +129 -0
- package/src/Slider/Slider.tsx +53 -0
- package/src/Slider/Slider.vi.tsx +29 -0
- package/src/Switch/Switch.tsx +41 -0
- package/src/Switch/Switch.vi.tsx +31 -0
- package/src/ThemeCycler/ThemeCycler.tsx +37 -0
- package/src/ThemeProvider/ThemeProvider.tsx +18 -0
- package/src/Toaster/Toaster.tsx +7 -0
- package/src/Tooltip/Tooltip.stories.tsx +161 -0
- package/src/Tooltip/Tooltip.tsx +107 -0
- package/src/helpers/cn/cn.ts +6 -0
- package/src/helpers/getModKey/getModKey.ts +2 -0
- package/src/helpers/getModKey/getModKey.vi.ts +23 -0
- package/src/index.ts +30 -0
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Kbd } from '../Kbd/Kbd'
|
|
4
|
+
import { type ReactElement, cloneElement, useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
type TooltipProps = {
|
|
7
|
+
label: string
|
|
8
|
+
shortcut?: string | string[]
|
|
9
|
+
position?: 'top' | 'bottom'
|
|
10
|
+
delay?: number
|
|
11
|
+
maxWidth?: number
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
className?: string
|
|
14
|
+
children: ReactElement<{ 'aria-label'?: string }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_DELAY = 200
|
|
18
|
+
|
|
19
|
+
export const Tooltip = ({
|
|
20
|
+
label,
|
|
21
|
+
shortcut,
|
|
22
|
+
position = 'top',
|
|
23
|
+
delay = DEFAULT_DELAY,
|
|
24
|
+
maxWidth,
|
|
25
|
+
disabled,
|
|
26
|
+
className,
|
|
27
|
+
children,
|
|
28
|
+
}: TooltipProps) => {
|
|
29
|
+
const [visible, setVisible] = useState(false)
|
|
30
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
31
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
|
|
33
|
+
const show = useCallback(() => {
|
|
34
|
+
if (disabled) return
|
|
35
|
+
timeoutRef.current = setTimeout(() => setVisible(true), delay)
|
|
36
|
+
}, [disabled, delay])
|
|
37
|
+
|
|
38
|
+
const hide = useCallback(() => {
|
|
39
|
+
if (timeoutRef.current) {
|
|
40
|
+
clearTimeout(timeoutRef.current)
|
|
41
|
+
timeoutRef.current = null
|
|
42
|
+
}
|
|
43
|
+
setVisible(false)
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (disabled && timeoutRef.current) {
|
|
48
|
+
clearTimeout(timeoutRef.current)
|
|
49
|
+
timeoutRef.current = null
|
|
50
|
+
}
|
|
51
|
+
}, [disabled])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
return () => {
|
|
55
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!visible) return
|
|
61
|
+
let lastCheck = 0
|
|
62
|
+
const handlePointerMove = (e: PointerEvent) => {
|
|
63
|
+
if (e.timeStamp - lastCheck < 100) return
|
|
64
|
+
lastCheck = e.timeStamp
|
|
65
|
+
const rect = wrapperRef.current?.getBoundingClientRect()
|
|
66
|
+
if (!rect) return
|
|
67
|
+
const inside =
|
|
68
|
+
e.clientX >= rect.left &&
|
|
69
|
+
e.clientX <= rect.right &&
|
|
70
|
+
e.clientY >= rect.top &&
|
|
71
|
+
e.clientY <= rect.bottom
|
|
72
|
+
if (!inside) hide()
|
|
73
|
+
}
|
|
74
|
+
document.addEventListener('pointermove', handlePointerMove, { passive: true })
|
|
75
|
+
return () => document.removeEventListener('pointermove', handlePointerMove)
|
|
76
|
+
}, [visible, hide])
|
|
77
|
+
|
|
78
|
+
const positionClasses = position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
ref={wrapperRef}
|
|
83
|
+
className={`relative inline-flex${className ? ` ${className}` : ''}`}
|
|
84
|
+
onMouseEnter={show}
|
|
85
|
+
onMouseLeave={hide}
|
|
86
|
+
onPointerDown={hide}
|
|
87
|
+
onFocus={show}
|
|
88
|
+
onBlur={hide}
|
|
89
|
+
>
|
|
90
|
+
{cloneElement(children, {
|
|
91
|
+
'aria-label': children.props['aria-label'] ?? label,
|
|
92
|
+
})}
|
|
93
|
+
{visible && !disabled && (
|
|
94
|
+
<div
|
|
95
|
+
role="tooltip"
|
|
96
|
+
style={maxWidth ? { maxWidth } : undefined}
|
|
97
|
+
className={`absolute left-1/2 z-50 -translate-x-1/2 ${maxWidth ? 'whitespace-normal' : 'whitespace-nowrap'} bg-foreground text-background pointer-events-none flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs shadow-lg ${positionClasses}`}
|
|
98
|
+
>
|
|
99
|
+
{label}
|
|
100
|
+
{shortcut && (
|
|
101
|
+
<Kbd keys={shortcut} size="sm" className="bg-background/15 border-transparent" />
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -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
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { Button, buttonVariants } from './Button/Button'
|
|
2
|
+
export type { ButtonProps } from './Button/Button.types'
|
|
3
|
+
|
|
4
|
+
export { Kbd, kbdVariants } from './Kbd/Kbd'
|
|
5
|
+
export type { KbdProps } from './Kbd/Kbd.types'
|
|
6
|
+
|
|
7
|
+
export { ProgressRing } from './ProgressRing/ProgressRing'
|
|
8
|
+
|
|
9
|
+
export { Slider } from './Slider/Slider'
|
|
10
|
+
|
|
11
|
+
export { Switch } from './Switch/Switch'
|
|
12
|
+
|
|
13
|
+
export { Select } from './Select/Select'
|
|
14
|
+
export type {
|
|
15
|
+
SelectOption,
|
|
16
|
+
SelectGroup,
|
|
17
|
+
SelectOptionState,
|
|
18
|
+
SelectProps,
|
|
19
|
+
} from './Select/Select.types'
|
|
20
|
+
|
|
21
|
+
export { Tooltip } from './Tooltip/Tooltip'
|
|
22
|
+
|
|
23
|
+
export { ThemeProvider } from './ThemeProvider/ThemeProvider'
|
|
24
|
+
|
|
25
|
+
export { ThemeCycler } from './ThemeCycler/ThemeCycler'
|
|
26
|
+
|
|
27
|
+
export { Toaster } from './Toaster/Toaster'
|
|
28
|
+
|
|
29
|
+
export { cn } from './helpers/cn/cn'
|
|
30
|
+
export { getModKey } from './helpers/getModKey/getModKey'
|