@duro-app/ui 5.13.0 → 5.14.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 (109) hide show
  1. package/README.md +49 -0
  2. package/dist/{Table-Bms2ftt3.js → Table-DnaoH-h5.js} +747 -635
  3. package/dist/Table-DnaoH-h5.js.map +1 -0
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/ColorMode/ColorMode.d.ts +2 -1
  6. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
  7. package/dist/components/ColorMode/storage.d.ts +4 -0
  8. package/dist/components/ColorMode/storage.d.ts.map +1 -0
  9. package/dist/components/ColorMode/styles.css.d.ts +4 -0
  10. package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
  11. package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
  12. package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
  13. package/dist/components/Combobox/Combobox.d.ts +7 -2
  14. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  15. package/dist/components/Combobox/comboboxState.d.ts +29 -0
  16. package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
  17. package/dist/components/Combobox/native.css.d.ts +27 -0
  18. package/dist/components/Combobox/native.css.d.ts.map +1 -0
  19. package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
  20. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
  21. package/dist/components/Drawer/Drawer.d.ts +5 -2
  22. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  23. package/dist/components/Drawer/DrawerSnap.stories.d.ts +2 -0
  24. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -1
  25. package/dist/components/Drawer/native.css.d.ts +4 -0
  26. package/dist/components/Drawer/native.css.d.ts.map +1 -1
  27. package/dist/components/Drawer/styles.css.d.ts +4 -0
  28. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  29. package/dist/components/Form/FormSubmitContext.d.ts +8 -0
  30. package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
  31. package/dist/components/Listbox/native.css.d.ts +84 -0
  32. package/dist/components/Listbox/native.css.d.ts.map +1 -0
  33. package/dist/components/Select/Select.d.ts +9 -2
  34. package/dist/components/Select/Select.d.ts.map +1 -1
  35. package/dist/components/Select/Select.stories.d.ts +11 -0
  36. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  37. package/dist/components/Select/native.css.d.ts +11 -0
  38. package/dist/components/Select/native.css.d.ts.map +1 -0
  39. package/dist/components/Select/selectState.d.ts +21 -0
  40. package/dist/components/Select/selectState.d.ts.map +1 -0
  41. package/dist/components/Select/styles.css.d.ts +10 -0
  42. package/dist/components/Select/styles.css.d.ts.map +1 -1
  43. package/dist/components/Select/useSelectRoot.d.ts +6 -8
  44. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  45. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
  46. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
  47. package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
  48. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  49. package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
  50. package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
  51. package/dist/components/ThemeProvider/overrides.d.ts +16 -0
  52. package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
  53. package/dist/index.css +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +3302 -3268
  57. package/dist/index.js.map +1 -1
  58. package/dist/shared/textOf.d.ts +7 -0
  59. package/dist/shared/textOf.d.ts.map +1 -0
  60. package/dist/table.js +1 -1
  61. package/package.json +3 -2
  62. package/src/components/Button/Button.tsx +12 -1
  63. package/src/components/ColorMode/ColorMode.meta.ts +1 -1
  64. package/src/components/ColorMode/ColorMode.tsx +16 -37
  65. package/src/components/ColorMode/storage.native.ts +8 -0
  66. package/src/components/ColorMode/storage.ts +19 -0
  67. package/src/components/ColorMode/styles.css.ts +6 -0
  68. package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
  69. package/src/components/ColorMode/useSystemScheme.ts +19 -0
  70. package/src/components/Combobox/Combobox.meta.ts +1 -1
  71. package/src/components/Combobox/Combobox.native.tsx +295 -0
  72. package/src/components/Combobox/Combobox.tsx +14 -8
  73. package/src/components/Combobox/comboboxState.ts +107 -0
  74. package/src/components/Combobox/native.css.ts +33 -0
  75. package/src/components/Combobox/useComboboxRoot.ts +17 -91
  76. package/src/components/Drawer/Drawer.meta.ts +1 -1
  77. package/src/components/Drawer/Drawer.native.tsx +29 -7
  78. package/src/components/Drawer/Drawer.tsx +11 -2
  79. package/src/components/Drawer/DrawerSnap.stories.tsx +22 -2
  80. package/src/components/Drawer/native.css.ts +4 -0
  81. package/src/components/Drawer/styles.css.ts +4 -0
  82. package/src/components/Form/Form.meta.ts +1 -1
  83. package/src/components/Form/Form.native.tsx +120 -0
  84. package/src/components/Form/FormSubmitContext.ts +12 -0
  85. package/src/components/Listbox/Listbox.meta.ts +1 -1
  86. package/src/components/Listbox/Listbox.native.tsx +153 -0
  87. package/src/components/Listbox/native.css.ts +91 -0
  88. package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
  89. package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
  90. package/src/components/Select/Select.meta.ts +1 -1
  91. package/src/components/Select/Select.native.tsx +224 -0
  92. package/src/components/Select/Select.stories.tsx +49 -1
  93. package/src/components/Select/Select.tsx +20 -3
  94. package/src/components/Select/native.css.ts +13 -0
  95. package/src/components/Select/selectState.ts +36 -0
  96. package/src/components/Select/styles.css.ts +11 -0
  97. package/src/components/Select/useSelectRoot.ts +9 -30
  98. package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
  99. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
  100. package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
  101. package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
  102. package/src/components/ThemeProvider/overrideRule.ts +39 -0
  103. package/src/components/ThemeProvider/overrides.ts +27 -0
  104. package/src/form.native.ts +7 -0
  105. package/src/index.native.ts +15 -0
  106. package/src/index.ts +1 -0
  107. package/src/shared/focusInput.native.ts +18 -0
  108. package/src/shared/textOf.ts +13 -0
  109. package/dist/Table-Bms2ftt3.js.map +0 -1
@@ -0,0 +1,36 @@
1
+ import {useCallback, useState} from 'react'
2
+ import {useControllableValue} from '../../hooks/useControllableValue'
3
+
4
+ export interface SelectStateOptions {
5
+ defaultValue?: string
6
+ value?: string
7
+ onValueChange?: (value: string | null) => void
8
+ initialLabels?: Record<string, string>
9
+ }
10
+
11
+ /**
12
+ * The Select's state both platforms share (plan N0, Decision 10): the value
13
+ * (controlled or not), whether it is open, and each option's label. Focus,
14
+ * highlight, document order and keys are the web adapter's (useSelectRoot);
15
+ * native presses and the sheet are Select.native's.
16
+ */
17
+ export function useSelectState({
18
+ defaultValue,
19
+ value: controlledValue,
20
+ onValueChange,
21
+ initialLabels,
22
+ }: SelectStateOptions) {
23
+ const [value, setValue] = useControllableValue<string | null>(
24
+ controlledValue,
25
+ defaultValue ?? null,
26
+ onValueChange,
27
+ )
28
+ const [open, setOpen] = useState(false)
29
+ const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
30
+
31
+ const registerLabel = useCallback((v: string, label: string) => {
32
+ setLabels((prev) => (prev[v] === label ? prev : {...prev, [v]: label}))
33
+ }, [])
34
+
35
+ return {value, setValue, open, setOpen, labels, registerLabel}
36
+ }
@@ -124,4 +124,15 @@ export const styles = css.create({
124
124
  hidden: {
125
125
  display: 'none',
126
126
  },
127
+ // Popup's `empty`: no items to choose from.
128
+ empty: {
129
+ paddingTop: spacing.md,
130
+ paddingBottom: spacing.md,
131
+ paddingLeft: spacing.md,
132
+ paddingRight: spacing.md,
133
+ fontFamily: typography.fontFamily,
134
+ fontSize: typography.fontSizeSm,
135
+ color: colors.textMuted,
136
+ textAlign: 'center',
137
+ },
127
138
  })
@@ -1,27 +1,13 @@
1
1
  import {useState, useCallback, useMemo, useRef, useId, useEffect} from 'react'
2
- import {useControllableValue} from '../../hooks/useControllableValue'
3
2
  import type {SelectContextValue} from './SelectContext'
3
+ import {useSelectState, type SelectStateOptions} from './selectState'
4
4
 
5
- interface UseSelectRootOptions {
6
- defaultValue?: string
7
- value?: string
8
- onValueChange?: (value: string | null) => void
9
- initialLabels?: Record<string, string>
10
- }
11
-
12
- export function useSelectRoot({
13
- defaultValue,
14
- value: controlledValue,
15
- onValueChange,
16
- initialLabels,
17
- }: UseSelectRootOptions) {
18
- const [value, setValue] = useControllableValue<string | null>(
19
- controlledValue,
20
- defaultValue ?? null,
21
- onValueChange,
22
- )
23
- const [open, setOpen] = useState(false)
24
- const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
5
+ /**
6
+ * The web adapter over the shared Select state: the highlighted option,
7
+ * document order, keys and DOM focus (the WAI-ARIA select-only combobox).
8
+ */
9
+ export function useSelectRoot(options: SelectStateOptions) {
10
+ const {value, setValue, open, setOpen, labels, registerLabel} = useSelectState(options)
25
11
  const [highlightedId, setHighlightedId] = useState<string | null>(null)
26
12
  const listboxId = useId()
27
13
  const rootRef = useRef<HTMLDivElement>(null)
@@ -33,7 +19,7 @@ export function useSelectRoot({
33
19
  setOpen(false)
34
20
  setHighlightedId(null)
35
21
  triggerRef.current?.focus()
36
- }, [])
22
+ }, [setOpen])
37
23
 
38
24
  const toggle = useCallback(() => {
39
25
  setOpen((prev) => {
@@ -54,14 +40,7 @@ export function useSelectRoot({
54
40
  }
55
41
  return !prev
56
42
  })
57
- }, [value])
58
-
59
- const registerLabel = useCallback((v: string, label: string) => {
60
- setLabels((prev) => {
61
- if (prev[v] === label) return prev
62
- return {...prev, [v]: label}
63
- })
64
- }, [])
43
+ }, [value, setOpen])
65
44
 
66
45
  const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
67
46
  itemsRef.current.set(id, {value: itemValue, element})
@@ -0,0 +1,113 @@
1
+ import {useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, waitFor} from 'storybook/test'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
6
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
7
+ import {ThemeProvider, type ColorOverrides, type ThemeName} from './ThemeProvider'
8
+ import {Button} from '../Button/Button'
9
+ import {Text} from '../Text/Text'
10
+
11
+ const meta: Meta = {
12
+ title: 'Theme/ThemeProvider/Overrides',
13
+ }
14
+
15
+ export default meta
16
+
17
+ const styles = css.create({
18
+ surface: {
19
+ display: 'flex',
20
+ flexDirection: 'column',
21
+ gap: spacing.sm,
22
+ padding: spacing.md,
23
+ backgroundColor: colors.bg,
24
+ },
25
+ })
26
+
27
+ interface OverrideArgs {
28
+ theme: ThemeName
29
+ bg: string
30
+ accent: string
31
+ }
32
+
33
+ /** Colours replaced by token key; the controls change them in place. */
34
+ export const Playground: StoryObj<OverrideArgs> = {
35
+ args: {theme: 'dark', bg: '#1b2433', accent: '#ff5a1f'},
36
+ argTypes: {
37
+ theme: {control: 'inline-radio', options: ['dark', 'light', 'high-contrast']},
38
+ bg: {control: 'color', description: 'overrides.bg'},
39
+ accent: {control: 'color', description: 'overrides.accent'},
40
+ },
41
+ render: ({theme, bg, accent}) => (
42
+ <ThemeProvider theme={theme} overrides={{bg, accent}}>
43
+ <html.div style={styles.surface}>
44
+ <Text>The page background and the primary fill come from overrides.</Text>
45
+ <Button>Primary</Button>
46
+ </html.div>
47
+ </ThemeProvider>
48
+ ),
49
+ }
50
+
51
+ const A: ColorOverrides = {bg: 'rgb(27, 36, 51)', accent: 'rgb(255, 90, 31)'}
52
+ const B: ColorOverrides = {bg: 'rgb(10, 60, 30)', accent: 'rgb(30, 144, 255)'}
53
+ const STEPS: readonly (ColorOverrides | undefined)[] = [A, B, A, undefined]
54
+
55
+ /** Steps through A, B, A and none, with a nested provider under each. */
56
+ function Stepper() {
57
+ const [step, setStep] = useState(0)
58
+ const overrides = STEPS[step]
59
+ return (
60
+ <ThemeProvider overrides={overrides}>
61
+ <html.div data-testid="outer" style={styles.surface}>
62
+ <Button onClick={() => setStep((s) => Math.min(s + 1, STEPS.length - 1))}>Next</Button>
63
+ <ThemeProvider>
64
+ <html.div data-testid="inherits" style={styles.surface} />
65
+ </ThemeProvider>
66
+ <ThemeProvider overrides={{bg: 'rgb(60, 10, 60)', accent: 'red} body{background:red'}}>
67
+ <html.div data-testid="inner" style={styles.surface} />
68
+ </ThemeProvider>
69
+ </html.div>
70
+ </ThemeProvider>
71
+ )
72
+ }
73
+
74
+ const bgOf = (el: Element) => getComputedStyle(el).backgroundColor
75
+ const aliasOf = (el: Element) => getComputedStyle(el).getPropertyValue('--duro-color-bg').trim()
76
+
77
+ export const OverridesUpdateInPlace: StoryObj = {
78
+ render: () => <Stepper />,
79
+ play: async ({canvas, userEvent}) => {
80
+ const outer = canvas.getByTestId('outer')
81
+ const inherits = canvas.getByTestId('inherits')
82
+ const inner = canvas.getByTestId('inner')
83
+ const next = canvas.getByRole('button', {name: 'Next'})
84
+
85
+ // The outer provider's scope while it has overrides.
86
+ const provider = outer.parentElement!
87
+ await waitFor(() => expect(provider.getAttribute('data-duro-theme')).toBeTruthy())
88
+ const scope = provider.getAttribute('data-duro-theme')!
89
+
90
+ for (const step of [A, B, A]) {
91
+ await waitFor(() => expect(bgOf(outer)).toBe(step.bg))
92
+ // Button's fill eases between colours: wait for it to settle.
93
+ await waitFor(() => expect(getComputedStyle(next).backgroundColor).toBe(step.accent))
94
+ // The --duro-* alias follows the override.
95
+ await expect(aliasOf(outer)).toBe(step.bg)
96
+ // A nested provider with no overrides, same theme: the outer's colours.
97
+ await expect(bgOf(inherits)).toBe(step.bg)
98
+ await expect(aliasOf(inherits)).toBe(step.bg)
99
+ // A nested provider with its own: its colours inside, the outer's outside.
100
+ await expect(bgOf(inner)).toBe('rgb(60, 10, 60)')
101
+ await userEvent.click(next)
102
+ }
103
+
104
+ // No overrides: the theme's own colours, and no outer rule left.
105
+ await waitFor(() => expect(bgOf(outer)).toBe('rgb(15, 15, 15)'))
106
+ const rules = [...document.querySelectorAll('style')].map((s) => s.textContent ?? '')
107
+ await expect(provider.hasAttribute('data-duro-theme')).toBe(false)
108
+ await expect(rules.filter((r) => r.includes(`"${scope}"`))).toHaveLength(0)
109
+ // The invalid value never reaches a rule, and nothing leaked onto body.
110
+ await expect(rules.some((r) => r.includes('body{'))).toBe(false)
111
+ await expect(getComputedStyle(document.body).backgroundColor).not.toBe('rgb(255, 0, 0)')
112
+ },
113
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Theme context provider. Must wrap the entire app at the root level. Sets dark, light, or high-contrast theme for all child components via CSS custom properties.',
5
+ 'Theme context provider. Must wrap the entire app at the root level. Sets dark, light, or high-contrast theme for all child components via CSS custom properties. overrides replaces colours by token key ({accent: "#ff5a1f"}) and updates in place, without a re-mount: a scoped rule on the web (values that are not a colour are dropped), a runtime theme on React Native, where useResolvedColors() follows too. Overrides replace a colour whatever the theme, so pass per-mode values when the mode can change. A nested provider on the same theme keeps the outer overrides and lays its own over them; on another theme it starts from that theme\'s colours.',
6
6
  whenToUse: [
7
7
  'App root — required for all Duro components to render correctly',
8
8
  'Theme switching (dark/light mode toggle)',
@@ -18,11 +18,19 @@ import {
18
18
  ThemeScopeContext,
19
19
  type ThemeScope,
20
20
  } from '../../shared/portal/themeScope'
21
+ import {mergeOverrides, OverridesContext, type ColorOverrides} from './overrides'
21
22
 
22
23
  export type ThemeName = 'dark' | 'light' | 'high-contrast'
24
+ export type {ColorOverrides}
23
25
 
24
26
  interface ThemeProviderProps {
25
27
  theme?: ThemeName
28
+ /**
29
+ * Colours that replace the theme's own, by token key (`{accent: '#ff5a1f'}`).
30
+ * They update in place, without a re-mount. A nested provider on the same
31
+ * theme keeps these and lays its own over them.
32
+ */
33
+ overrides?: ColorOverrides
26
34
  children: ReactNode
27
35
  }
28
36
 
@@ -80,7 +88,7 @@ export function useResolvedColors(): Record<ColorToken, string> {
80
88
  return useContext(ResolvedColorsContext)
81
89
  }
82
90
 
83
- export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
91
+ export function ThemeProvider({theme = 'dark', overrides, children}: ThemeProviderProps) {
84
92
  const outerRegistry = useContext(HostRegistryContext)
85
93
  const outerScope = useContext(ThemeScopeContext)
86
94
  // The outermost provider owns the registry and draws the root host.
@@ -88,7 +96,24 @@ export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
88
96
  outerRegistry ? null : createHostRegistry(),
89
97
  )
90
98
  const registry = outerRegistry ?? ownRegistry
91
- const themeStyles = themeMap[theme]
99
+ const outerOverrides = useContext(OverridesContext)
100
+ const effective = useMemo(
101
+ () => mergeOverrides(outerOverrides, theme, overrides),
102
+ [outerOverrides, theme, overrides],
103
+ )
104
+ // A runtime theme on native, applied after the base one so RSD styles
105
+ // cascade it; rebuilt only when the colours change.
106
+ const overrideKey = JSON.stringify(effective.colors)
107
+ const themeStyles = useMemo(() => {
108
+ const base = themeMap[theme]
109
+ return overrideKey === '{}'
110
+ ? base
111
+ : [...base, css.createTheme(colors, JSON.parse(overrideKey) as ColorOverrides)]
112
+ }, [theme, overrideKey])
113
+ const resolved = useMemo(
114
+ () => ({...palettes[theme], ...(JSON.parse(overrideKey) as ColorOverrides)}),
115
+ [theme, overrideKey],
116
+ )
92
117
  const scope = useMemo<ThemeScope>(
93
118
  () => ({styles: [...outerScope.styles, ...themeStyles]}),
94
119
  [outerScope, themeStyles],
@@ -97,11 +122,15 @@ export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
97
122
  return (
98
123
  <HostRegistryContext.Provider value={registry}>
99
124
  <ThemeScopeContext.Provider value={scope}>
100
- <ResolvedColorsContext.Provider value={palettes[theme]}>
101
- <html.div style={[...themeStyles, styles.root, ownRegistry && styles.canvas] as DivStyle}>
102
- {children}
103
- {ownRegistry ? <PortalHost placement="base" /> : null}
104
- </html.div>
125
+ <ResolvedColorsContext.Provider value={resolved}>
126
+ <OverridesContext.Provider value={effective}>
127
+ <html.div
128
+ style={[...themeStyles, styles.root, ownRegistry && styles.canvas] as DivStyle}
129
+ >
130
+ {children}
131
+ {ownRegistry ? <PortalHost placement="base" /> : null}
132
+ </html.div>
133
+ </OverridesContext.Provider>
105
134
  </ResolvedColorsContext.Provider>
106
135
  </ThemeScopeContext.Provider>
107
136
  </HostRegistryContext.Provider>
@@ -1,14 +1,24 @@
1
- import {createContext, type ReactNode, useContext, useState} from 'react'
1
+ import {createContext, type ReactNode, useContext, useId, useMemo, useState} from 'react'
2
2
  import {css, html} from 'react-strict-dom'
3
+ import type {ColorToken} from '@duro-app/tokens/keys'
3
4
  import {colors} from '@duro-app/tokens/tokens/colors.css'
4
5
  import {lightTheme, lightShadows} from '@duro-app/tokens/themes/light.css'
5
6
  import {highContrastTheme, highContrastShadows} from '@duro-app/tokens/themes/high-contrast.css'
6
7
  import {layers} from '@duro-app/tokens/tokens/layers.css'
8
+ import {mergeOverrides, OverridesContext, type ColorOverrides} from './overrides'
9
+ import {overrideRule} from './overrideRule'
7
10
 
8
11
  export type ThemeName = 'dark' | 'light' | 'high-contrast'
12
+ export type {ColorOverrides}
9
13
 
10
14
  interface ThemeProviderProps {
11
15
  theme?: ThemeName
16
+ /**
17
+ * Colours that replace the theme's own, by token key (`{accent: '#ff5a1f'}`).
18
+ * They update in place, without a re-mount. A nested provider on the same
19
+ * theme keeps these and lays its own over them.
20
+ */
21
+ overrides?: ColorOverrides
12
22
  children: ReactNode
13
23
  }
14
24
 
@@ -49,14 +59,40 @@ export function usePortalMount(): HTMLElement | null {
49
59
  return useContext(PortalMountContext)
50
60
  }
51
61
 
52
- export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
53
- const overrides = themeMap[theme]
54
- const themeStyles = [overrides?.[0], overrides?.[1], styles.root] as DivStyle
62
+ /** `var(--bg-x1y2)` → `--bg-x1y2`: the compiled variable a token key names. */
63
+ function variableOf(key: ColorToken): string | null {
64
+ const ref = String(colors[key] ?? '')
65
+ const match = /^var\((--[\w-]+)\)$/.exec(ref)
66
+ return match ? match[1] : null
67
+ }
68
+
69
+ export function ThemeProvider({theme = 'dark', overrides, children}: ThemeProviderProps) {
70
+ const themeStyles = themeMap[theme]
71
+ const style = [themeStyles?.[0], themeStyles?.[1], styles.root] as DivStyle
55
72
  const [mount, setMount] = useState<HTMLElement | null>(null)
73
+ const scope = useId()
74
+ const outer = useContext(OverridesContext)
75
+ const effective = useMemo(
76
+ () => mergeOverrides(outer, theme, overrides),
77
+ [outer, theme, overrides],
78
+ )
79
+ const rule = useMemo(
80
+ () =>
81
+ Object.keys(effective.colors).length > 0
82
+ ? overrideRule(scope, effective.colors, variableOf)
83
+ : null,
84
+ [scope, effective],
85
+ )
56
86
 
57
87
  return (
58
- <html.div data-theme={theme} style={themeStyles}>
59
- <PortalMountContext.Provider value={mount}>{children}</PortalMountContext.Provider>
88
+ <html.div data-theme={theme} data-duro-theme={rule ? scope : undefined} style={style}>
89
+ {/* A plain, updating <style> (react-strict-dom has no html.style): no
90
+ href or precedence, which would make React hoist it once and ignore
91
+ later changes. */}
92
+ {rule ? <style>{rule}</style> : null}
93
+ <OverridesContext.Provider value={effective}>
94
+ <PortalMountContext.Provider value={mount}>{children}</PortalMountContext.Provider>
95
+ </OverridesContext.Provider>
60
96
  <html.div ref={setMount} data-portal-mount style={styles.portalMount} />
61
97
  </html.div>
62
98
  )
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The web ThemeProvider's override rule, pure: it runs the same on the server
3
+ * and in the browser (no `CSS` global), so a server-rendered rule hydrates.
4
+ */
5
+ import type {ColorToken} from '@duro-app/tokens/keys'
6
+ import type {ColorOverrides} from './overrides'
7
+
8
+ /** A value that is a colour and nothing else, so it cannot leave its rule. */
9
+ export function isSafeColor(value: string): boolean {
10
+ // The same answer on the server and in the browser (no CSS.supports), so a
11
+ // server-rendered rule hydrates unchanged: a hex, a colour function, or a
12
+ // keyword, with nothing that could close the declaration or the rule.
13
+ if (/[;{}<>"'\\]/.test(value)) return false
14
+ return (
15
+ /^#[0-9a-f]{3,8}$/i.test(value) ||
16
+ /^(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^()]*\)$/i.test(value) ||
17
+ /^[a-z]+$/i.test(value)
18
+ )
19
+ }
20
+
21
+ /** The scoped rule for a provider's overrides, or null when it has none. */
22
+ export function overrideRule(
23
+ scope: string,
24
+ overrides: ColorOverrides,
25
+ variableOf: (key: ColorToken) => string | null,
26
+ ): string | null {
27
+ const declarations = Object.entries(overrides).flatMap(([key, value]) => {
28
+ const variable = variableOf(key as ColorToken)
29
+ return variable && typeof value === 'string' && isSafeColor(value)
30
+ ? [`${variable}: ${value};`]
31
+ : []
32
+ })
33
+ if (declarations.length === 0) return null
34
+ // Doubled, so it outranks the theme classes (one class each) wherever the
35
+ // stylesheets land.
36
+ // useId output holds neither a quote nor a backslash: safe inside quotes.
37
+ const selector = `[data-duro-theme="${scope}"]`
38
+ return `${selector}${selector} { ${declarations.join(' ')} }`
39
+ }
@@ -0,0 +1,27 @@
1
+ import {createContext} from 'react'
2
+ import type {ColorToken} from '@duro-app/tokens/keys'
3
+
4
+ /** Colours that replace a theme's own, by token key. */
5
+ export type ColorOverrides = Partial<Record<ColorToken, string>>
6
+
7
+ /** The overrides in force at a provider, and the theme they were made for. */
8
+ export interface EffectiveOverrides {
9
+ theme: string | null
10
+ colors: ColorOverrides
11
+ }
12
+
13
+ export const OverridesContext = createContext<EffectiveOverrides>({theme: null, colors: {}})
14
+
15
+ /**
16
+ * One rule on both platforms (plan N0, slice C): a provider on the same theme
17
+ * as the one above it keeps that one's overrides and lays its own over them;
18
+ * a provider on another theme starts from its own theme's colours.
19
+ */
20
+ export function mergeOverrides(
21
+ outer: EffectiveOverrides,
22
+ theme: string,
23
+ own: ColorOverrides | undefined,
24
+ ): EffectiveOverrides {
25
+ const inherited = outer.theme === theme ? outer.colors : {}
26
+ return {theme, colors: own ? {...inherited, ...own} : inherited}
27
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@duro-app/ui/form` on React Native — resolved via the `react-native` export
3
+ * condition. Form validates the same Effect Schema; a `type="submit"` Button
4
+ * inside it submits through FormSubmitContext, since native has no DOM
5
+ * submit event. See `form.ts` for why Form is a subpath.
6
+ */
7
+ export {Form, type FormProps} from './components/Form/Form'
@@ -51,6 +51,13 @@ export {Tag, type TagVariant} from './components/Tag/Tag'
51
51
  export {SideNav} from './components/SideNav/SideNav'
52
52
  export {Tabs} from './components/Tabs/Tabs'
53
53
  export {Tree} from './components/Tree/Tree'
54
+ // 5.14: pickers open a bottom sheet (a Drawer at the medium and large detents)
55
+ export {Select} from './components/Select/Select'
56
+ export {Combobox} from './components/Combobox/Combobox'
57
+ export {Listbox} from './components/Listbox/Listbox'
58
+ // The platform's own scroll indicator: Scrollbar and Thumb draw nothing.
59
+ export {ScrollArea} from './components/ScrollArea/ScrollArea'
60
+ export type {ListboxAnchorProps, ListboxMaxHeight} from './components/Listbox/Listbox'
54
61
  // Always stacked on native: a phone is below every collapseBelow.
55
62
  export {SplitPane} from './components/SplitPane/SplitPane'
56
63
  // Always the phone shape on native: the bar on top, the rail in a left Drawer.
@@ -97,9 +104,17 @@ export type {GridTrack} from './shared/length'
97
104
  // no display:contents / portal mount; applies the theme to a flex container).
98
105
  export {
99
106
  ThemeProvider,
107
+ type ColorOverrides,
100
108
  usePortalMount,
101
109
  useResolvedColors,
102
110
  type ThemeName,
103
111
  } from './components/ThemeProvider/ThemeProvider'
112
+ // 'system' follows RN Appearance; persistence is the app's (preference + onPreferenceChange).
113
+ export {
114
+ ColorModeProvider,
115
+ ColorModeToggle,
116
+ useColorMode,
117
+ type ColorModePreference,
118
+ } from './components/ColorMode'
104
119
  // Where toasts draw on native: mount one inside a natively presented route.
105
120
  export {PortalHost} from './shared/portal/PortalHost'
package/src/index.ts CHANGED
@@ -125,6 +125,7 @@ export {VirtualList, type VirtualListProps} from './components/VirtualList/Virtu
125
125
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
126
126
  export {
127
127
  ThemeProvider,
128
+ type ColorOverrides,
128
129
  usePortalMount,
129
130
  type ThemeName,
130
131
  } from './components/ThemeProvider/ThemeProvider'
@@ -0,0 +1,18 @@
1
+ interface StrictInputRef {
2
+ getNativeRef?: () => {focus?: () => void} | null
3
+ focus?: () => void
4
+ }
5
+
6
+ /**
7
+ * Focus an html.input on native. react-strict-dom 0.0.55 hands out a copy of
8
+ * the TextInput instance, whose `focus()` does nothing (#506); the copy keeps
9
+ * TextInput's own `getNativeRef()`, which returns the real host.
10
+ *
11
+ * holds-until: react-strict-dom releases #515 (focus() on the ref itself).
12
+ */
13
+ export function focusInput(input: unknown): void {
14
+ const ref = input as StrictInputRef | null
15
+ const host = ref?.getNativeRef?.()
16
+ if (host?.focus) host.focus()
17
+ else ref?.focus?.()
18
+ }
@@ -0,0 +1,13 @@
1
+ import {isValidElement, type ReactNode} from 'react'
2
+
3
+ /**
4
+ * The text a React node would render, read from its props: a native picker's
5
+ * option label, where there is no DOM `textContent` to read it from.
6
+ */
7
+ export function textOf(node: ReactNode): string {
8
+ if (node == null || typeof node === 'boolean') return ''
9
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
10
+ if (Array.isArray(node)) return node.map(textOf).join('')
11
+ if (isValidElement<{children?: ReactNode}>(node)) return textOf(node.props.children)
12
+ return ''
13
+ }