@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.
Files changed (33) hide show
  1. package/dist/components/ColorMode/ColorMode.d.ts +61 -0
  2. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -0
  3. package/dist/components/ColorMode/ColorMode.meta.d.ts +3 -0
  4. package/dist/components/ColorMode/ColorMode.meta.d.ts.map +1 -0
  5. package/dist/components/ColorMode/ColorMode.stories.d.ts +12 -0
  6. package/dist/components/ColorMode/ColorMode.stories.d.ts.map +1 -0
  7. package/dist/components/ColorMode/index.d.ts +2 -0
  8. package/dist/components/ColorMode/index.d.ts.map +1 -0
  9. package/dist/components/ColorMode/styles.css.d.ts +24 -0
  10. package/dist/components/ColorMode/styles.css.d.ts.map +1 -0
  11. package/dist/components/Icon/Icon.d.ts +1 -1
  12. package/dist/components/Icon/Icon.d.ts.map +1 -1
  13. package/dist/components/Select/Select.d.ts.map +1 -1
  14. package/dist/components/Select/Select.stories.d.ts +1 -0
  15. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  16. package/dist/components/Select/styles.css.d.ts +10 -4
  17. package/dist/components/Select/styles.css.d.ts.map +1 -1
  18. package/dist/index.css +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2436 -2197
  22. package/dist/index.js.map +1 -1
  23. package/package.json +2 -2
  24. package/src/components/ColorMode/ColorMode.meta.ts +40 -0
  25. package/src/components/ColorMode/ColorMode.stories.tsx +87 -0
  26. package/src/components/ColorMode/ColorMode.tsx +205 -0
  27. package/src/components/ColorMode/index.ts +6 -0
  28. package/src/components/ColorMode/styles.css.ts +32 -0
  29. package/src/components/Icon/Icon.tsx +110 -0
  30. package/src/components/Select/Select.stories.tsx +59 -7
  31. package/src/components/Select/Select.tsx +43 -5
  32. package/src/components/Select/styles.css.ts +16 -4
  33. 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.35.3",
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.35.3"
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,6 @@
1
+ export {
2
+ ColorModeProvider,
3
+ ColorModeToggle,
4
+ useColorMode,
5
+ type ColorModePreference,
6
+ } from './ColorMode'
@@ -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
- const options = canvas.getAllByRole('option')
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
- await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument()
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 = canvas.getAllByRole('option')
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(canvas.getByText('Vue'))
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(canvas.getByRole('listbox')).toBeInTheDocument()
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
  }