@duro-app/ui 0.35.3 → 0.36.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/components/ColorMode/ColorMode.d.ts +61 -0
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -0
- package/dist/components/ColorMode/ColorMode.meta.d.ts +3 -0
- package/dist/components/ColorMode/ColorMode.meta.d.ts.map +1 -0
- package/dist/components/ColorMode/ColorMode.stories.d.ts +12 -0
- package/dist/components/ColorMode/ColorMode.stories.d.ts.map +1 -0
- package/dist/components/ColorMode/index.d.ts +2 -0
- package/dist/components/ColorMode/index.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +24 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -0
- package/dist/components/Icon/Icon.d.ts +1 -1
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +1 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +10 -4
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2436 -2197
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ColorMode/ColorMode.meta.ts +40 -0
- package/src/components/ColorMode/ColorMode.stories.tsx +87 -0
- package/src/components/ColorMode/ColorMode.tsx +205 -0
- package/src/components/ColorMode/index.ts +6 -0
- package/src/components/ColorMode/styles.css.ts +32 -0
- package/src/components/Icon/Icon.tsx +110 -0
- package/src/components/Select/Select.stories.tsx +59 -7
- package/src/components/Select/Select.tsx +43 -5
- package/src/components/Select/styles.css.ts +16 -4
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@duro-app/tokens": "^0.
|
|
57
|
+
"@duro-app/tokens": "^0.36.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Color-mode controller + toggle. ColorModeProvider owns the preference (system/light/dark/high-contrast), resolves "system" against the OS scheme, persists it to localStorage, and applies the resolved theme via ThemeProvider. ColorModeToggle is an icon button that cycles the preference; useColorMode() reads it.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'App-level light/dark (and high-contrast) switching that persists across reloads',
|
|
8
|
+
'Respecting the OS prefers-color-scheme with a user override',
|
|
9
|
+
'Any place that needs the resolved theme name (useColorMode().theme)',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'A single static theme — use ThemeProvider directly with a fixed theme prop',
|
|
13
|
+
'On/off setting unrelated to theming — use Switch',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['ColorModeProvider'],
|
|
17
|
+
optional: ['ColorModeToggle', 'useColorMode'],
|
|
18
|
+
},
|
|
19
|
+
relatedTo: [
|
|
20
|
+
{
|
|
21
|
+
component: 'ThemeProvider',
|
|
22
|
+
relationship:
|
|
23
|
+
'ColorModeProvider renders ThemeProvider with the resolved theme; use ThemeProvider alone for a fixed theme',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
component: 'Toggle',
|
|
27
|
+
relationship: 'Toggle is a generic pressed button; ColorModeToggle cycles theme modes',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
example: `// At the app root — replaces a bare <ThemeProvider>:
|
|
31
|
+
<ColorModeProvider defaultPreference="system" modes={['light', 'dark']}>
|
|
32
|
+
<App />
|
|
33
|
+
</ColorModeProvider>
|
|
34
|
+
|
|
35
|
+
// Anywhere inside (e.g. the top bar):
|
|
36
|
+
<ColorModeToggle />
|
|
37
|
+
|
|
38
|
+
// Or read the resolved theme:
|
|
39
|
+
const {theme, preference, setPreference} = useColorMode()`,
|
|
40
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, fn} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
4
|
+
import {ColorModeProvider, ColorModeToggle, useColorMode} from './ColorMode'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ColorModeToggle> = {
|
|
7
|
+
title: 'Components/ColorMode',
|
|
8
|
+
component: ColorModeToggle,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof ColorModeToggle>
|
|
13
|
+
|
|
14
|
+
/** Default cycle: light ⇄ dark. Starts on `system`. */
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: (args) => (
|
|
17
|
+
// storageKey null keeps stories isolated (no cross-test localStorage leak).
|
|
18
|
+
<ColorModeProvider storageKey={null} onPreferenceChange={fn()}>
|
|
19
|
+
<ColorModeToggle {...args} />
|
|
20
|
+
</ColorModeProvider>
|
|
21
|
+
),
|
|
22
|
+
play: async ({canvas, userEvent}) => {
|
|
23
|
+
const btn = canvas.getByRole('button')
|
|
24
|
+
await expect(btn.getAttribute('aria-label')).toContain('Color mode: system')
|
|
25
|
+
|
|
26
|
+
await userEvent.click(btn)
|
|
27
|
+
await expect(btn.getAttribute('aria-label')).toContain('Color mode: light')
|
|
28
|
+
|
|
29
|
+
await userEvent.click(btn)
|
|
30
|
+
await expect(btn.getAttribute('aria-label')).toContain('Color mode: dark')
|
|
31
|
+
|
|
32
|
+
// Wraps back to the first mode.
|
|
33
|
+
await userEvent.click(btn)
|
|
34
|
+
await expect(btn.getAttribute('aria-label')).toContain('Color mode: light')
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A four-way cycle including `system` and `high-contrast`. */
|
|
39
|
+
export const AllModes: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<ColorModeProvider
|
|
42
|
+
storageKey={null}
|
|
43
|
+
defaultPreference="system"
|
|
44
|
+
modes={['system', 'light', 'dark', 'high-contrast']}
|
|
45
|
+
>
|
|
46
|
+
<ColorModeToggle />
|
|
47
|
+
</ColorModeProvider>
|
|
48
|
+
),
|
|
49
|
+
play: async ({canvas, userEvent}) => {
|
|
50
|
+
const btn = canvas.getByRole('button')
|
|
51
|
+
await expect(btn.getAttribute('aria-label')).toContain('Switch to light')
|
|
52
|
+
await userEvent.click(btn)
|
|
53
|
+
await expect(btn.getAttribute('aria-label')).toContain('Color mode: light')
|
|
54
|
+
await userEvent.click(btn)
|
|
55
|
+
await userEvent.click(btn)
|
|
56
|
+
await expect(btn.getAttribute('aria-label')).toContain('high contrast')
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readout = css.create({
|
|
61
|
+
row: {display: 'flex', alignItems: 'center', gap: 12},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
function Readout() {
|
|
65
|
+
const {preference, theme} = useColorMode()
|
|
66
|
+
return (
|
|
67
|
+
<html.div style={readout.row}>
|
|
68
|
+
<ColorModeToggle />
|
|
69
|
+
<html.span>
|
|
70
|
+
preference: {preference} → theme: {theme}
|
|
71
|
+
</html.span>
|
|
72
|
+
</html.div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Reading the resolved theme via `useColorMode()`. */
|
|
77
|
+
export const WithReadout: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<ColorModeProvider storageKey={null} defaultPreference="light">
|
|
80
|
+
<Readout />
|
|
81
|
+
</ColorModeProvider>
|
|
82
|
+
),
|
|
83
|
+
play: async ({canvas}) => {
|
|
84
|
+
await expect(canvas.getByText(/preference: light/)).toBeInTheDocument()
|
|
85
|
+
await expect(canvas.getByText(/theme: light/)).toBeInTheDocument()
|
|
86
|
+
},
|
|
87
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {html} from 'react-strict-dom'
|
|
11
|
+
import {isNative} from '../../platform'
|
|
12
|
+
import {Icon, type IconName} from '../Icon/Icon'
|
|
13
|
+
import {ThemeProvider, type ThemeName} from '../ThemeProvider/ThemeProvider'
|
|
14
|
+
import {styles} from './styles.css'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The user's *preference*. `'system'` follows the OS `prefers-color-scheme`;
|
|
18
|
+
* the others pin a concrete {@link ThemeName}. Resolves to a ThemeName via
|
|
19
|
+
* {@link useColorMode}().theme.
|
|
20
|
+
*/
|
|
21
|
+
export type ColorModePreference = 'system' | ThemeName
|
|
22
|
+
|
|
23
|
+
const PREFERENCES: readonly ColorModePreference[] = ['system', 'light', 'dark', 'high-contrast']
|
|
24
|
+
|
|
25
|
+
const ICON: Record<ColorModePreference, IconName> = {
|
|
26
|
+
system: 'monitor',
|
|
27
|
+
light: 'sun',
|
|
28
|
+
dark: 'moon',
|
|
29
|
+
'high-contrast': 'contrast',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LABEL: Record<ColorModePreference, string> = {
|
|
33
|
+
system: 'system',
|
|
34
|
+
light: 'light',
|
|
35
|
+
dark: 'dark',
|
|
36
|
+
'high-contrast': 'high contrast',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ColorModeContextValue {
|
|
40
|
+
/** The user's chosen preference. */
|
|
41
|
+
preference: ColorModePreference
|
|
42
|
+
/** The concrete theme the preference resolves to (system → OS scheme). */
|
|
43
|
+
theme: ThemeName
|
|
44
|
+
/** Set the preference explicitly. */
|
|
45
|
+
setPreference: (preference: ColorModePreference) => void
|
|
46
|
+
/** Advance to the next preference in {@link modes} (wraps). */
|
|
47
|
+
cycle: () => void
|
|
48
|
+
/** The cycle order used by `cycle()` and the toggle. */
|
|
49
|
+
modes: readonly ColorModePreference[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ColorModeContext = createContext<ColorModeContextValue | null>(null)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the active color mode. Throws if used outside a {@link ColorModeProvider}.
|
|
56
|
+
*/
|
|
57
|
+
export function useColorMode(): ColorModeContextValue {
|
|
58
|
+
const ctx = useContext(ColorModeContext)
|
|
59
|
+
if (!ctx) {
|
|
60
|
+
throw new Error('useColorMode must be used within a <ColorModeProvider>')
|
|
61
|
+
}
|
|
62
|
+
return ctx
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const canUseDom =
|
|
66
|
+
!isNative && typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
67
|
+
|
|
68
|
+
function getSystemTheme(): Extract<ThemeName, 'light' | 'dark'> {
|
|
69
|
+
// No DOM (SSR / native): default to dark, matching ThemeProvider's default.
|
|
70
|
+
if (!canUseDom) return 'dark'
|
|
71
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPreference(value: unknown): value is ColorModePreference {
|
|
75
|
+
return typeof value === 'string' && (PREFERENCES as readonly string[]).includes(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readStored(key: string | null): ColorModePreference | null {
|
|
79
|
+
if (!key || isNative || typeof window === 'undefined') return null
|
|
80
|
+
try {
|
|
81
|
+
const raw = window.localStorage.getItem(key)
|
|
82
|
+
return isPreference(raw) ? raw : null
|
|
83
|
+
} catch {
|
|
84
|
+
// Private mode / blocked storage — degrade to no persistence.
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeStored(key: string | null, value: ColorModePreference): void {
|
|
90
|
+
if (!key || isNative || typeof window === 'undefined') return
|
|
91
|
+
try {
|
|
92
|
+
window.localStorage.setItem(key, value)
|
|
93
|
+
} catch {
|
|
94
|
+
/* ignore */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ColorModeProviderProps {
|
|
99
|
+
children: ReactNode
|
|
100
|
+
/** Initial preference (uncontrolled). Default `'system'`. For SSR with no
|
|
101
|
+
* hydration flash, pass the value you persisted server-side (e.g. a cookie). */
|
|
102
|
+
defaultPreference?: ColorModePreference
|
|
103
|
+
/** Controlled preference — when set, the provider does not own the state. */
|
|
104
|
+
preference?: ColorModePreference
|
|
105
|
+
/** Fired whenever the preference changes (controlled or not). */
|
|
106
|
+
onPreferenceChange?: (preference: ColorModePreference) => void
|
|
107
|
+
/** Cycle order for `cycle()` / `<ColorModeToggle>`. Default `['light', 'dark']`. */
|
|
108
|
+
modes?: readonly ColorModePreference[]
|
|
109
|
+
/** localStorage key for persistence; `null` disables it. Default `'duro-color-mode'`. */
|
|
110
|
+
storageKey?: string | null
|
|
111
|
+
/** Wrap children in the DS `ThemeProvider` with the resolved theme. Default `true`.
|
|
112
|
+
* Set `false` to drive your own ThemeProvider from `useColorMode().theme`. */
|
|
113
|
+
applyTheme?: boolean
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Owns color-mode state, resolves `'system'` against the OS scheme, persists the
|
|
118
|
+
* choice, and (by default) applies the resolved theme via `ThemeProvider`.
|
|
119
|
+
* Pair with {@link ColorModeToggle} or read state via {@link useColorMode}.
|
|
120
|
+
*/
|
|
121
|
+
export function ColorModeProvider({
|
|
122
|
+
children,
|
|
123
|
+
defaultPreference = 'system',
|
|
124
|
+
preference: controlled,
|
|
125
|
+
onPreferenceChange,
|
|
126
|
+
modes = ['light', 'dark'],
|
|
127
|
+
storageKey = 'duro-color-mode',
|
|
128
|
+
applyTheme = true,
|
|
129
|
+
}: ColorModeProviderProps) {
|
|
130
|
+
const [internal, setInternal] = useState<ColorModePreference>(controlled ?? defaultPreference)
|
|
131
|
+
const preference = controlled ?? internal
|
|
132
|
+
|
|
133
|
+
// Hydrate from storage AFTER mount (not in the initializer) so the first
|
|
134
|
+
// client render matches the server and React doesn't warn about a mismatch.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (controlled !== undefined) return
|
|
137
|
+
const stored = readStored(storageKey)
|
|
138
|
+
if (stored) setInternal(stored)
|
|
139
|
+
// Only on mount / when the key changes.
|
|
140
|
+
}, [controlled, storageKey])
|
|
141
|
+
|
|
142
|
+
const setPreference = useCallback(
|
|
143
|
+
(next: ColorModePreference) => {
|
|
144
|
+
if (controlled === undefined) setInternal(next)
|
|
145
|
+
writeStored(storageKey, next)
|
|
146
|
+
onPreferenceChange?.(next)
|
|
147
|
+
},
|
|
148
|
+
[controlled, storageKey, onPreferenceChange],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
// Track the OS scheme so `'system'` stays live.
|
|
152
|
+
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!canUseDom) return
|
|
155
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
156
|
+
const onChange = () => setSystemTheme(mq.matches ? 'dark' : 'light')
|
|
157
|
+
onChange()
|
|
158
|
+
mq.addEventListener('change', onChange)
|
|
159
|
+
return () => mq.removeEventListener('change', onChange)
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
const theme: ThemeName = preference === 'system' ? systemTheme : preference
|
|
163
|
+
|
|
164
|
+
const cycle = useCallback(() => {
|
|
165
|
+
const i = modes.indexOf(preference)
|
|
166
|
+
const next = modes[(i + 1) % modes.length] ?? modes[0]
|
|
167
|
+
if (next) setPreference(next)
|
|
168
|
+
}, [modes, preference, setPreference])
|
|
169
|
+
|
|
170
|
+
const value = useMemo<ColorModeContextValue>(
|
|
171
|
+
() => ({preference, theme, setPreference, cycle, modes}),
|
|
172
|
+
[preference, theme, setPreference, cycle, modes],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<ColorModeContext.Provider value={value}>
|
|
177
|
+
{applyTheme ? <ThemeProvider theme={theme}>{children}</ThemeProvider> : children}
|
|
178
|
+
</ColorModeContext.Provider>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ColorModeToggleProps {
|
|
183
|
+
/** Icon size in px. Default `20`. */
|
|
184
|
+
size?: number
|
|
185
|
+
/** Override the announced label (otherwise auto-generated from current/next). */
|
|
186
|
+
'aria-label'?: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A compact icon button that cycles the color-mode preference through the
|
|
191
|
+
* provider's `modes`. The glyph reflects the current preference
|
|
192
|
+
* (sun/moon/monitor/contrast). Must be inside a {@link ColorModeProvider}.
|
|
193
|
+
*/
|
|
194
|
+
export function ColorModeToggle({size = 20, 'aria-label': ariaLabel}: ColorModeToggleProps) {
|
|
195
|
+
const {preference, cycle, modes} = useColorMode()
|
|
196
|
+
const i = modes.indexOf(preference)
|
|
197
|
+
const next = modes[(i + 1) % modes.length] ?? modes[0] ?? preference
|
|
198
|
+
const label = ariaLabel ?? `Color mode: ${LABEL[preference]}. Switch to ${LABEL[next]}.`
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<html.button type="button" aria-label={label} onClick={cycle} style={styles.button}>
|
|
202
|
+
<Icon name={ICON[preference]} size={size} />
|
|
203
|
+
</html.button>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
|
|
5
|
+
export const styles = css.create({
|
|
6
|
+
button: {
|
|
7
|
+
display: 'inline-flex',
|
|
8
|
+
alignItems: 'center',
|
|
9
|
+
justifyContent: 'center',
|
|
10
|
+
padding: spacing.sm,
|
|
11
|
+
borderWidth: 1,
|
|
12
|
+
borderStyle: 'solid',
|
|
13
|
+
borderColor: {
|
|
14
|
+
default: colors.border,
|
|
15
|
+
':hover': colors.textMuted,
|
|
16
|
+
},
|
|
17
|
+
borderRadius: radii.sm,
|
|
18
|
+
backgroundColor: {
|
|
19
|
+
default: 'transparent',
|
|
20
|
+
':hover': colors.bgCardHover,
|
|
21
|
+
},
|
|
22
|
+
color: colors.text,
|
|
23
|
+
cursor: 'pointer',
|
|
24
|
+
transitionProperty: 'background-color, border-color, color',
|
|
25
|
+
transitionDuration: '150ms',
|
|
26
|
+
transitionTimingFunction: 'ease',
|
|
27
|
+
outlineWidth: {default: 0, ':focus-visible': 2},
|
|
28
|
+
outlineStyle: 'solid',
|
|
29
|
+
outlineColor: colors.accent,
|
|
30
|
+
outlineOffset: 2,
|
|
31
|
+
},
|
|
32
|
+
})
|
|
@@ -11,6 +11,21 @@ export type IconName =
|
|
|
11
11
|
| 'shield'
|
|
12
12
|
| 'lock'
|
|
13
13
|
| 'key'
|
|
14
|
+
// Navigation / wayfinding glyphs
|
|
15
|
+
| 'map'
|
|
16
|
+
| 'layers'
|
|
17
|
+
| 'repeat'
|
|
18
|
+
| 'database'
|
|
19
|
+
| 'shield-check'
|
|
20
|
+
| 'route'
|
|
21
|
+
| 'git-branch'
|
|
22
|
+
| 'menu'
|
|
23
|
+
| 'pin'
|
|
24
|
+
// Color-mode glyphs
|
|
25
|
+
| 'sun'
|
|
26
|
+
| 'moon'
|
|
27
|
+
| 'monitor'
|
|
28
|
+
| 'contrast'
|
|
14
29
|
// Filled variants (solid shape with cutout symbol)
|
|
15
30
|
| 'info-circle-filled'
|
|
16
31
|
| 'alert-triangle-filled'
|
|
@@ -78,6 +93,101 @@ const strokeIcons: Partial<Record<IconName, ReactNode>> = {
|
|
|
78
93
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
|
79
94
|
</>
|
|
80
95
|
),
|
|
96
|
+
// ---- navigation / wayfinding ----
|
|
97
|
+
map: (
|
|
98
|
+
<>
|
|
99
|
+
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" />
|
|
100
|
+
<line x1="8" y1="2" x2="8" y2="18" />
|
|
101
|
+
<line x1="16" y1="6" x2="16" y2="22" />
|
|
102
|
+
</>
|
|
103
|
+
),
|
|
104
|
+
layers: (
|
|
105
|
+
<>
|
|
106
|
+
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
|
107
|
+
<polyline points="2 17 12 22 22 17" />
|
|
108
|
+
<polyline points="2 12 12 17 22 12" />
|
|
109
|
+
</>
|
|
110
|
+
),
|
|
111
|
+
// Two crossing arrows — exchange / interface flows.
|
|
112
|
+
repeat: (
|
|
113
|
+
<>
|
|
114
|
+
<polyline points="17 1 21 5 17 9" />
|
|
115
|
+
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
|
116
|
+
<polyline points="7 23 3 19 7 15" />
|
|
117
|
+
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
|
118
|
+
</>
|
|
119
|
+
),
|
|
120
|
+
database: (
|
|
121
|
+
<>
|
|
122
|
+
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
|
123
|
+
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
|
124
|
+
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
|
125
|
+
</>
|
|
126
|
+
),
|
|
127
|
+
'shield-check': (
|
|
128
|
+
<>
|
|
129
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
130
|
+
<polyline points="9 12 11 14 15 10" />
|
|
131
|
+
</>
|
|
132
|
+
),
|
|
133
|
+
// Milestone path — roadmap / itinerary.
|
|
134
|
+
route: (
|
|
135
|
+
<>
|
|
136
|
+
<circle cx="6" cy="19" r="3" />
|
|
137
|
+
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
|
|
138
|
+
<circle cx="18" cy="5" r="3" />
|
|
139
|
+
</>
|
|
140
|
+
),
|
|
141
|
+
'git-branch': (
|
|
142
|
+
<>
|
|
143
|
+
<line x1="6" y1="3" x2="6" y2="15" />
|
|
144
|
+
<circle cx="18" cy="6" r="3" />
|
|
145
|
+
<circle cx="6" cy="18" r="3" />
|
|
146
|
+
<path d="M18 9a9 9 0 0 1-9 9" />
|
|
147
|
+
</>
|
|
148
|
+
),
|
|
149
|
+
menu: (
|
|
150
|
+
<>
|
|
151
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
152
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
153
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
154
|
+
</>
|
|
155
|
+
),
|
|
156
|
+
pin: (
|
|
157
|
+
<>
|
|
158
|
+
<line x1="12" y1="17" x2="12" y2="22" />
|
|
159
|
+
<path d="M9 10.76V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6.76a2 2 0 0 0 .55 1.38l1.68 1.78A1 1 0 0 1 16.5 16h-9a1 1 0 0 1-.73-1.68l1.68-1.78A2 2 0 0 0 9 10.76z" />
|
|
160
|
+
</>
|
|
161
|
+
),
|
|
162
|
+
// ---- color mode ----
|
|
163
|
+
sun: (
|
|
164
|
+
<>
|
|
165
|
+
<circle cx="12" cy="12" r="5" />
|
|
166
|
+
<line x1="12" y1="1" x2="12" y2="3" />
|
|
167
|
+
<line x1="12" y1="21" x2="12" y2="23" />
|
|
168
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
169
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
170
|
+
<line x1="1" y1="12" x2="3" y2="12" />
|
|
171
|
+
<line x1="21" y1="12" x2="23" y2="12" />
|
|
172
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
173
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
174
|
+
</>
|
|
175
|
+
),
|
|
176
|
+
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
|
|
177
|
+
monitor: (
|
|
178
|
+
<>
|
|
179
|
+
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
|
180
|
+
<line x1="8" y1="21" x2="16" y2="21" />
|
|
181
|
+
<line x1="12" y1="17" x2="12" y2="21" />
|
|
182
|
+
</>
|
|
183
|
+
),
|
|
184
|
+
// Circle, half solid — high-contrast / mode indicator.
|
|
185
|
+
contrast: (
|
|
186
|
+
<>
|
|
187
|
+
<circle cx="12" cy="12" r="10" />
|
|
188
|
+
<path d="M12 2a10 10 0 0 1 0 20z" fill="currentColor" stroke="none" />
|
|
189
|
+
</>
|
|
190
|
+
),
|
|
81
191
|
}
|
|
82
192
|
|
|
83
193
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, fn} from 'storybook/test'
|
|
2
|
+
import {expect, fn, screen} from 'storybook/test'
|
|
3
|
+
import {Dialog} from '../Dialog/Dialog'
|
|
3
4
|
import {Select} from './Select'
|
|
4
5
|
|
|
5
6
|
const meta: Meta = {
|
|
@@ -62,13 +63,16 @@ export const OpenAndSelect: Story = {
|
|
|
62
63
|
|
|
63
64
|
await userEvent.click(trigger)
|
|
64
65
|
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
65
|
-
|
|
66
|
+
// The popup portals to the ThemeProvider mount (outside the story canvas),
|
|
67
|
+
// so query the whole document via `screen`.
|
|
68
|
+
const options = screen.getAllByRole('option')
|
|
66
69
|
await expect(options.length).toBe(3)
|
|
67
70
|
await expect(options[0]).toHaveAttribute('aria-selected', 'true')
|
|
68
71
|
|
|
69
72
|
await userEvent.click(options[1])
|
|
70
73
|
await expect(trigger).toHaveTextContent(/Francais/)
|
|
71
|
-
|
|
74
|
+
// Closed listbox is aria-hidden, so it's no longer exposed by role.
|
|
75
|
+
await expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
72
76
|
},
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -101,9 +105,9 @@ export const WithPlaceholder: Story = {
|
|
|
101
105
|
// Shows placeholder when nothing selected
|
|
102
106
|
await expect(trigger).toHaveTextContent(/Choose a language\.\.\./)
|
|
103
107
|
|
|
104
|
-
// Open and select
|
|
108
|
+
// Open and select (options portal to the ThemeProvider mount → use screen)
|
|
105
109
|
await userEvent.click(trigger)
|
|
106
|
-
const options =
|
|
110
|
+
const options = screen.getAllByRole('option')
|
|
107
111
|
await expect(options.length).toBe(4)
|
|
108
112
|
|
|
109
113
|
// None selected initially
|
|
@@ -111,7 +115,7 @@ export const WithPlaceholder: Story = {
|
|
|
111
115
|
await expect(opt).toHaveAttribute('aria-selected', 'false')
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
await userEvent.click(
|
|
118
|
+
await userEvent.click(screen.getByText('Vue'))
|
|
115
119
|
await expect(trigger).toHaveTextContent(/Vue/)
|
|
116
120
|
},
|
|
117
121
|
}
|
|
@@ -139,6 +143,54 @@ export const KeyboardNavigation: Story = {
|
|
|
139
143
|
play: async ({canvas, userEvent}) => {
|
|
140
144
|
const trigger = canvas.getByRole('combobox')
|
|
141
145
|
await userEvent.click(trigger)
|
|
142
|
-
await expect(
|
|
146
|
+
await expect(screen.getByRole('listbox')).toBeInTheDocument()
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Regression: a Select inside a Dialog. The Dialog clips its content
|
|
151
|
+
// (overflow: hidden + body overflowY: auto), so an in-tree absolutely
|
|
152
|
+
// positioned popup would be cropped. The popup must portal to the
|
|
153
|
+
// ThemeProvider mount (z-index above the Dialog) and render in full.
|
|
154
|
+
export const InsideDialog: Story = {
|
|
155
|
+
render: () => (
|
|
156
|
+
<Dialog.Root open onOpenChange={() => {}}>
|
|
157
|
+
<Dialog.Portal>
|
|
158
|
+
<Dialog.Header>
|
|
159
|
+
<Dialog.Title>Add a zone</Dialog.Title>
|
|
160
|
+
</Dialog.Header>
|
|
161
|
+
<Dialog.Body>
|
|
162
|
+
<Select.Root defaultValue="">
|
|
163
|
+
<Select.Trigger aria-label="Macro-zone">
|
|
164
|
+
<Select.Value placeholder="— No macro-zone —" />
|
|
165
|
+
<Select.Icon />
|
|
166
|
+
</Select.Trigger>
|
|
167
|
+
<Select.Popup>
|
|
168
|
+
<Select.Item value="">
|
|
169
|
+
<Select.ItemText>— No macro-zone —</Select.ItemText>
|
|
170
|
+
</Select.Item>
|
|
171
|
+
<Select.Item value="north">
|
|
172
|
+
<Select.ItemText>North</Select.ItemText>
|
|
173
|
+
</Select.Item>
|
|
174
|
+
<Select.Item value="south">
|
|
175
|
+
<Select.ItemText>South</Select.ItemText>
|
|
176
|
+
</Select.Item>
|
|
177
|
+
</Select.Popup>
|
|
178
|
+
</Select.Root>
|
|
179
|
+
</Dialog.Body>
|
|
180
|
+
</Dialog.Portal>
|
|
181
|
+
</Dialog.Root>
|
|
182
|
+
),
|
|
183
|
+
play: async ({userEvent}) => {
|
|
184
|
+
// Both the trigger and the (portaled) popup live outside the story canvas:
|
|
185
|
+
// the Dialog itself portals. Query the whole document.
|
|
186
|
+
const trigger = screen.getByRole('combobox', {name: 'Macro-zone'})
|
|
187
|
+
await userEvent.click(trigger)
|
|
188
|
+
const options = screen.getAllByRole('option')
|
|
189
|
+
await expect(options.length).toBe(3)
|
|
190
|
+
// The popup escapes the Dialog: it renders into the portal mount, which is
|
|
191
|
+
// a sibling of (and stacks above) the Dialog — not clipped by it.
|
|
192
|
+
const listbox = screen.getByRole('listbox')
|
|
193
|
+
await expect(listbox).toBeInTheDocument()
|
|
194
|
+
await expect(listbox.closest('[role="dialog"]')).toBeNull()
|
|
143
195
|
},
|
|
144
196
|
}
|