@duro-app/ui 0.35.4 → 0.36.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.35.4",
3
+ "version": "0.36.1",
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.4"
57
+ "@duro-app/tokens": "^0.36.1"
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,203 @@
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 isPreference(value: unknown): value is ColorModePreference {
69
+ return typeof value === 'string' && (PREFERENCES as readonly string[]).includes(value)
70
+ }
71
+
72
+ function readStored(key: string | null): ColorModePreference | null {
73
+ if (!key || isNative || typeof window === 'undefined') return null
74
+ try {
75
+ const raw = window.localStorage.getItem(key)
76
+ return isPreference(raw) ? raw : null
77
+ } catch {
78
+ // Private mode / blocked storage — degrade to no persistence.
79
+ return null
80
+ }
81
+ }
82
+
83
+ function writeStored(key: string | null, value: ColorModePreference): void {
84
+ if (!key || isNative || typeof window === 'undefined') return
85
+ try {
86
+ window.localStorage.setItem(key, value)
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ }
91
+
92
+ interface ColorModeProviderProps {
93
+ children: ReactNode
94
+ /** Initial preference (uncontrolled). Default `'system'`. For SSR with no
95
+ * hydration flash, pass the value you persisted server-side (e.g. a cookie). */
96
+ defaultPreference?: ColorModePreference
97
+ /** Controlled preference — when set, the provider does not own the state. */
98
+ preference?: ColorModePreference
99
+ /** Fired whenever the preference changes (controlled or not). */
100
+ onPreferenceChange?: (preference: ColorModePreference) => void
101
+ /** Cycle order for `cycle()` / `<ColorModeToggle>`. Default `['light', 'dark']`. */
102
+ modes?: readonly ColorModePreference[]
103
+ /** localStorage key for persistence; `null` disables it. Default `'duro-color-mode'`. */
104
+ storageKey?: string | null
105
+ /** Wrap children in the DS `ThemeProvider` with the resolved theme. Default `true`.
106
+ * Set `false` to drive your own ThemeProvider from `useColorMode().theme`. */
107
+ applyTheme?: boolean
108
+ }
109
+
110
+ /**
111
+ * Owns color-mode state, resolves `'system'` against the OS scheme, persists the
112
+ * choice, and (by default) applies the resolved theme via `ThemeProvider`.
113
+ * Pair with {@link ColorModeToggle} or read state via {@link useColorMode}.
114
+ */
115
+ export function ColorModeProvider({
116
+ children,
117
+ defaultPreference = 'system',
118
+ preference: controlled,
119
+ onPreferenceChange,
120
+ modes = ['light', 'dark'],
121
+ storageKey = 'duro-color-mode',
122
+ applyTheme = true,
123
+ }: ColorModeProviderProps) {
124
+ const [internal, setInternal] = useState<ColorModePreference>(controlled ?? defaultPreference)
125
+ const preference = controlled ?? internal
126
+
127
+ // Hydrate from storage AFTER mount (not in the initializer) so the first
128
+ // client render matches the server and React doesn't warn about a mismatch.
129
+ useEffect(() => {
130
+ if (controlled !== undefined) return
131
+ const stored = readStored(storageKey)
132
+ if (stored) setInternal(stored)
133
+ // Only on mount / when the key changes.
134
+ }, [controlled, storageKey])
135
+
136
+ const setPreference = useCallback(
137
+ (next: ColorModePreference) => {
138
+ if (controlled === undefined) setInternal(next)
139
+ writeStored(storageKey, next)
140
+ onPreferenceChange?.(next)
141
+ },
142
+ [controlled, storageKey, onPreferenceChange],
143
+ )
144
+
145
+ // Track the OS scheme so `'system'` stays live. The initial value MUST be the
146
+ // SSR-stable default ('dark', what the server renders without a DOM) so the
147
+ // first client render matches the server — reading matchMedia in the
148
+ // initializer would diverge (server 'dark' vs client's real scheme) and trip
149
+ // a hydration mismatch. The effect reconciles to the real scheme after mount.
150
+ const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark')
151
+ useEffect(() => {
152
+ if (!canUseDom) return
153
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
154
+ const onChange = () => setSystemTheme(mq.matches ? 'dark' : 'light')
155
+ onChange()
156
+ mq.addEventListener('change', onChange)
157
+ return () => mq.removeEventListener('change', onChange)
158
+ }, [])
159
+
160
+ const theme: ThemeName = preference === 'system' ? systemTheme : preference
161
+
162
+ const cycle = useCallback(() => {
163
+ const i = modes.indexOf(preference)
164
+ const next = modes[(i + 1) % modes.length] ?? modes[0]
165
+ if (next) setPreference(next)
166
+ }, [modes, preference, setPreference])
167
+
168
+ const value = useMemo<ColorModeContextValue>(
169
+ () => ({preference, theme, setPreference, cycle, modes}),
170
+ [preference, theme, setPreference, cycle, modes],
171
+ )
172
+
173
+ return (
174
+ <ColorModeContext.Provider value={value}>
175
+ {applyTheme ? <ThemeProvider theme={theme}>{children}</ThemeProvider> : children}
176
+ </ColorModeContext.Provider>
177
+ )
178
+ }
179
+
180
+ interface ColorModeToggleProps {
181
+ /** Icon size in px. Default `20`. */
182
+ size?: number
183
+ /** Override the announced label (otherwise auto-generated from current/next). */
184
+ 'aria-label'?: string
185
+ }
186
+
187
+ /**
188
+ * A compact icon button that cycles the color-mode preference through the
189
+ * provider's `modes`. The glyph reflects the current preference
190
+ * (sun/moon/monitor/contrast). Must be inside a {@link ColorModeProvider}.
191
+ */
192
+ export function ColorModeToggle({size = 20, 'aria-label': ariaLabel}: ColorModeToggleProps) {
193
+ const {preference, cycle, modes} = useColorMode()
194
+ const i = modes.indexOf(preference)
195
+ const next = modes[(i + 1) % modes.length] ?? modes[0] ?? preference
196
+ const label = ariaLabel ?? `Color mode: ${LABEL[preference]}. Switch to ${LABEL[next]}.`
197
+
198
+ return (
199
+ <html.button type="button" aria-label={label} onClick={cycle} style={styles.button}>
200
+ <Icon name={ICON[preference]} size={size} />
201
+ </html.button>
202
+ )
203
+ }
@@ -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
  /**
package/src/index.ts CHANGED
@@ -62,6 +62,12 @@ export {
62
62
  usePortalMount,
63
63
  type ThemeName,
64
64
  } from './components/ThemeProvider/ThemeProvider'
65
+ export {
66
+ ColorModeProvider,
67
+ ColorModeToggle,
68
+ useColorMode,
69
+ type ColorModePreference,
70
+ } from './components/ColorMode'
65
71
  export {Text, type TextVariant, type TextColor} from './components/Text/Text'
66
72
  export {Toggle, type ToggleSize} from './components/Toggle/Toggle'
67
73
  export {ToggleGroup} from './components/ToggleGroup/ToggleGroup'