@duro-app/ui 5.12.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.
- package/README.md +49 -0
- package/dist/Table-DnaoH-h5.js +1806 -0
- package/dist/Table-DnaoH-h5.js.map +1 -0
- package/dist/components/AppShell/native.css.d.ts +53 -0
- package/dist/components/AppShell/native.css.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.d.ts +2 -1
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
- package/dist/components/ColorMode/storage.d.ts +4 -0
- package/dist/components/ColorMode/storage.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +4 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
- package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
- package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +7 -2
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/comboboxState.d.ts +29 -0
- package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
- package/dist/components/Combobox/native.css.d.ts +27 -0
- package/dist/components/Combobox/native.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts +12 -37
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +22 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
- package/dist/components/Drawer/native.css.d.ts +59 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.css.d.ts +96 -0
- package/dist/components/Drawer/parts.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.d.ts +56 -0
- package/dist/components/Drawer/parts.d.ts.map +1 -0
- package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
- package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
- package/dist/components/Drawer/styles.css.d.ts +16 -57
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/touchInput.d.ts +35 -0
- package/dist/components/Drawer/touchInput.d.ts.map +1 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
- package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
- package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Form/FormSubmitContext.d.ts +8 -0
- package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
- package/dist/components/Listbox/native.css.d.ts +84 -0
- package/dist/components/Listbox/native.css.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +7 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/native.css.d.ts +14 -0
- package/dist/components/Menu/native.css.d.ts.map +1 -0
- package/dist/components/Menu/useMenuOpen.d.ts +16 -0
- package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
- package/dist/components/Menu/useMenuRoot.d.ts +3 -6
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/native.css.d.ts +11 -0
- package/dist/components/Popover/native.css.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +9 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +11 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/native.css.d.ts +11 -0
- package/dist/components/Select/native.css.d.ts.map +1 -0
- package/dist/components/Select/selectState.d.ts +21 -0
- package/dist/components/Select/selectState.d.ts.map +1 -0
- package/dist/components/Select/styles.css.d.ts +10 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts +6 -8
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/SideNav/Chevron.d.ts +4 -0
- package/dist/components/SideNav/Chevron.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/styles.css.d.ts +6 -0
- package/dist/components/SideNav/styles.css.d.ts.map +1 -1
- package/dist/components/SideNav/types.d.ts +6 -0
- package/dist/components/SideNav/types.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.d.ts +38 -0
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
- package/dist/components/SplitPane/styles.css.d.ts +78 -0
- package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
- package/dist/components/Tabs/TabLayout.d.ts +7 -0
- package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -1
- package/dist/components/Tabs/indicator.d.ts +28 -0
- package/dist/components/Tabs/indicator.d.ts.map +1 -0
- package/dist/components/Tabs/styles.css.d.ts +4 -0
- package/dist/components/Tabs/styles.css.d.ts.map +1 -1
- package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
- package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
- package/dist/components/Tabs/useTabKeys.d.ts +13 -0
- package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
- package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
- package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +2 -24
- package/dist/components/Tag/Tag.d.ts.map +1 -1
- package/dist/components/Tag/styles.css.d.ts +8 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -1
- package/dist/components/Tag/types.d.ts +39 -0
- package/dist/components/Tag/types.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
- package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
- package/dist/components/ThemeProvider/overrides.d.ts +16 -0
- package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.d.ts +2 -21
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/native.css.d.ts +81 -0
- package/dist/components/Toast/native.css.d.ts.map +1 -0
- package/dist/components/Toast/store.d.ts +47 -0
- package/dist/components/Toast/store.d.ts.map +1 -0
- package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
- package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/styles.css.d.ts +6 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
- package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.d.ts +33 -0
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
- package/dist/components/VirtualList/styles.css.d.ts +28 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5090 -4381
- package/dist/index.js.map +1 -1
- package/dist/shared/ButtonHost.d.ts +11 -0
- package/dist/shared/ButtonHost.d.ts.map +1 -0
- package/dist/shared/pressableChild.d.ts +14 -0
- package/dist/shared/pressableChild.d.ts.map +1 -0
- package/dist/shared/textOf.d.ts +7 -0
- package/dist/shared/textOf.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +3 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +174 -0
- package/src/components/AppShell/native.css.ts +56 -0
- package/src/components/Button/Button.tsx +15 -4
- package/src/components/ColorMode/ColorMode.meta.ts +1 -1
- package/src/components/ColorMode/ColorMode.tsx +16 -37
- package/src/components/ColorMode/storage.native.ts +8 -0
- package/src/components/ColorMode/storage.ts +19 -0
- package/src/components/ColorMode/styles.css.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.ts +19 -0
- package/src/components/Combobox/Combobox.meta.ts +1 -1
- package/src/components/Combobox/Combobox.native.tsx +295 -0
- package/src/components/Combobox/Combobox.tsx +14 -8
- package/src/components/Combobox/comboboxState.ts +107 -0
- package/src/components/Combobox/native.css.ts +33 -0
- package/src/components/Combobox/useComboboxRoot.ts +17 -91
- package/src/components/Drawer/Drawer.meta.ts +2 -1
- package/src/components/Drawer/Drawer.native.tsx +491 -0
- package/src/components/Drawer/Drawer.tsx +65 -143
- package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
- package/src/components/Drawer/DrawerSnap.stories.tsx +225 -0
- package/src/components/Drawer/native.css.ts +62 -0
- package/src/components/Drawer/parts.css.ts +125 -0
- package/src/components/Drawer/parts.tsx +110 -0
- package/src/components/Drawer/sheetPhysics.ts +103 -0
- package/src/components/Drawer/styles.css.ts +18 -73
- package/src/components/Drawer/touchInput.ts +81 -0
- package/src/components/Drawer/useDrawerRoot.ts +90 -0
- package/src/components/Drawer/useSheetHeights.ts +42 -0
- package/src/components/Drawer/useSwipeDismiss.ts +146 -23
- package/src/components/Form/Form.meta.ts +1 -1
- package/src/components/Form/Form.native.tsx +120 -0
- package/src/components/Form/FormSubmitContext.ts +12 -0
- package/src/components/Listbox/Listbox.meta.ts +1 -1
- package/src/components/Listbox/Listbox.native.tsx +153 -0
- package/src/components/Listbox/native.css.ts +91 -0
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.native.tsx +268 -0
- package/src/components/Menu/Menu.tsx +11 -2
- package/src/components/Menu/native.css.ts +18 -0
- package/src/components/Menu/useMenuOpen.ts +33 -0
- package/src/components/Menu/useMenuRoot.ts +7 -26
- package/src/components/Popover/Popover.meta.ts +1 -1
- package/src/components/Popover/Popover.native.tsx +205 -0
- package/src/components/Popover/native.css.ts +13 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +224 -0
- package/src/components/Select/Select.stories.tsx +49 -1
- package/src/components/Select/Select.tsx +20 -3
- package/src/components/Select/native.css.ts +13 -0
- package/src/components/Select/selectState.ts +36 -0
- package/src/components/Select/styles.css.ts +11 -0
- package/src/components/Select/useSelectRoot.ts +9 -30
- package/src/components/SideNav/Chevron.native.tsx +20 -0
- package/src/components/SideNav/Chevron.tsx +20 -0
- package/src/components/SideNav/SideNav.meta.ts +1 -1
- package/src/components/SideNav/SideNav.tsx +57 -41
- package/src/components/SideNav/styles.css.ts +8 -0
- package/src/components/SideNav/types.ts +6 -0
- package/src/components/SplitPane/SplitPane.meta.ts +40 -0
- package/src/components/SplitPane/SplitPane.native.tsx +47 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
- package/src/components/SplitPane/SplitPane.tsx +218 -0
- package/src/components/SplitPane/styles.css.ts +81 -0
- package/src/components/Tabs/TabLayout.native.tsx +9 -0
- package/src/components/Tabs/TabLayout.tsx +6 -0
- package/src/components/Tabs/Tabs.meta.ts +1 -1
- package/src/components/Tabs/Tabs.tsx +38 -136
- package/src/components/Tabs/indicator.ts +36 -0
- package/src/components/Tabs/styles.css.ts +7 -0
- package/src/components/Tabs/useTabIndicator.native.ts +35 -0
- package/src/components/Tabs/useTabIndicator.ts +49 -0
- package/src/components/Tabs/useTabKeys.native.ts +2 -0
- package/src/components/Tabs/useTabKeys.ts +99 -0
- package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
- package/src/components/Tag/RemoveGlyph.tsx +10 -0
- package/src/components/Tag/Tag.meta.ts +1 -1
- package/src/components/Tag/Tag.native.tsx +70 -0
- package/src/components/Tag/Tag.tsx +9 -37
- package/src/components/Tag/styles.css.ts +9 -0
- package/src/components/Tag/types.ts +43 -0
- package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
- package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
- package/src/components/ThemeProvider/overrideRule.ts +39 -0
- package/src/components/ThemeProvider/overrides.ts +27 -0
- package/src/components/Toast/Toast.meta.ts +1 -1
- package/src/components/Toast/ToastProvider.native.tsx +226 -0
- package/src/components/Toast/ToastProvider.tsx +22 -84
- package/src/components/Toast/native.css.ts +78 -0
- package/src/components/Toast/store.ts +96 -0
- package/src/components/Toggle/Toggle.tsx +3 -3
- package/src/components/Tooltip/Tooltip.meta.ts +1 -1
- package/src/components/Tooltip/Tooltip.native.tsx +36 -0
- package/src/components/Tooltip/TooltipHint.ts +13 -0
- package/src/components/Tree/Tree.meta.ts +1 -1
- package/src/components/Tree/Tree.tsx +23 -135
- package/src/components/Tree/styles.css.ts +6 -0
- package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
- package/src/components/Tree/useTreeKeyboard.ts +152 -0
- package/src/components/VirtualList/VirtualList.meta.ts +39 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
- package/src/components/VirtualList/VirtualList.tsx +103 -0
- package/src/components/VirtualList/styles.css.ts +31 -0
- package/src/form.native.ts +7 -0
- package/src/hooks/useContainerBelow.native.ts +30 -0
- package/src/index.native.ts +48 -0
- package/src/index.ts +9 -1
- package/src/shared/ButtonHost.native.tsx +70 -0
- package/src/shared/ButtonHost.tsx +12 -0
- package/src/shared/focusInput.native.ts +18 -0
- package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
- package/src/shared/pressableChild.ts +33 -0
- package/src/shared/textOf.ts +13 -0
- package/src/shared/useKeyboardHeight.native.ts +18 -0
- package/src/shared/useScreenReaderEnabled.native.ts +19 -0
- package/dist/Table-ColfpgKV.js +0 -1552
- package/dist/Table-ColfpgKV.js.map +0 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {useEffect} from 'react'
|
|
2
|
+
import type {Orientation} from '../../shared/types'
|
|
3
|
+
|
|
4
|
+
interface TabKeysInput {
|
|
5
|
+
listRef: React.RefObject<HTMLDivElement | null>
|
|
6
|
+
orientation: Orientation
|
|
7
|
+
activeValue: string | null
|
|
8
|
+
onSelect: (value: string) => void
|
|
9
|
+
tabsRef: React.RefObject<Map<string, boolean>>
|
|
10
|
+
orderRef: React.RefObject<string[]>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Web: arrow keys, Home and End move the selection and focus along the list. */
|
|
14
|
+
export function useTabKeys({
|
|
15
|
+
listRef,
|
|
16
|
+
orientation,
|
|
17
|
+
activeValue,
|
|
18
|
+
onSelect,
|
|
19
|
+
tabsRef,
|
|
20
|
+
orderRef,
|
|
21
|
+
}: TabKeysInput) {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const el = listRef.current
|
|
24
|
+
if (!el) return
|
|
25
|
+
const listEl = el
|
|
26
|
+
|
|
27
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
28
|
+
const order = orderRef.current
|
|
29
|
+
const tabs = tabsRef.current
|
|
30
|
+
if (order.length === 0) return
|
|
31
|
+
|
|
32
|
+
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
|
|
33
|
+
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
|
|
34
|
+
|
|
35
|
+
let targetValue: string | null = null
|
|
36
|
+
|
|
37
|
+
switch (e.key) {
|
|
38
|
+
case nextKey: {
|
|
39
|
+
e.preventDefault()
|
|
40
|
+
const currentIdx = activeValue ? order.indexOf(activeValue) : -1
|
|
41
|
+
for (let i = 1; i <= order.length; i++) {
|
|
42
|
+
const idx = (currentIdx + i) % order.length
|
|
43
|
+
const val = order[idx]
|
|
44
|
+
if (!tabs.get(val)) {
|
|
45
|
+
targetValue = val
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
case prevKey: {
|
|
52
|
+
e.preventDefault()
|
|
53
|
+
const currentIdx = activeValue ? order.indexOf(activeValue) : 0
|
|
54
|
+
for (let i = 1; i <= order.length; i++) {
|
|
55
|
+
const idx = (currentIdx - i + order.length) % order.length
|
|
56
|
+
const val = order[idx]
|
|
57
|
+
if (!tabs.get(val)) {
|
|
58
|
+
targetValue = val
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
break
|
|
63
|
+
}
|
|
64
|
+
case 'Home': {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
for (const val of order) {
|
|
67
|
+
if (!tabs.get(val)) {
|
|
68
|
+
targetValue = val
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
case 'End': {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
77
|
+
if (!tabs.get(order[i])) {
|
|
78
|
+
targetValue = order[i]
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (targetValue) {
|
|
87
|
+
onSelect(targetValue)
|
|
88
|
+
// Focus the newly activated tab button
|
|
89
|
+
const tabEl = listEl.querySelector(
|
|
90
|
+
`[data-tab-value="${targetValue}"]`,
|
|
91
|
+
) as HTMLElement | null
|
|
92
|
+
tabEl?.focus()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
el.addEventListener('keydown', handleKeyDown)
|
|
97
|
+
return () => el.removeEventListener('keydown', handleKeyDown)
|
|
98
|
+
}, [listRef, orientation, activeValue, onSelect, tabsRef, orderRef])
|
|
99
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Svg, {Path} from 'react-native-svg'
|
|
2
|
+
import {useResolvedColors} from '../ThemeProvider/ThemeProvider'
|
|
3
|
+
import type {RemoveGlyphProps} from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The same cross as the web's, drawn with react-native-svg (the native icon
|
|
7
|
+
* renderer, plan N0 Decision 9) in the tag's resolved text colour: a raw
|
|
8
|
+
* host has no CSS `currentColor` to inherit.
|
|
9
|
+
*/
|
|
10
|
+
export function RemoveGlyph({size, color}: RemoveGlyphProps) {
|
|
11
|
+
const stroke = useResolvedColors()[color]
|
|
12
|
+
return (
|
|
13
|
+
<Svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
14
|
+
<Path d="M3 3l6 6M9 3l-6 6" stroke={stroke} strokeWidth={1.5} strokeLinecap="round" />
|
|
15
|
+
</Svg>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {RemoveGlyphProps} from './types'
|
|
2
|
+
|
|
3
|
+
/** The tag's remove cross: 1.5 strokes on a 12 box, drawn in the tag's text colour. */
|
|
4
|
+
export function RemoveGlyph({size}: RemoveGlyphProps) {
|
|
5
|
+
return (
|
|
6
|
+
<svg width={size} height={size} viewBox="0 0 12 12" fill="none">
|
|
7
|
+
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
|
|
8
|
+
</svg>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Interactive tag/chip with optional remove button. Works standalone or inside TagGroup for collection management. Visually similar to Badge but supports interaction.',
|
|
5
|
+
'Interactive tag/chip with optional remove button. Works standalone or inside TagGroup for collection management. Visually similar to Badge but supports interaction. On React Native a Tag stands alone (TagGroup is web only): the label and, when removable, a remove button named from a string label.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Removable items in a list (emails, categories)',
|
|
8
8
|
'Inside TagGroup for editable tag collections',
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {html} from 'react-strict-dom'
|
|
2
|
+
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
3
|
+
import {styles} from './styles.css'
|
|
4
|
+
import {RemoveGlyph} from './RemoveGlyph'
|
|
5
|
+
import {variantText, type TagProps} from './types'
|
|
6
|
+
|
|
7
|
+
export type {TagProps, TagSize, TagVariant} from './types'
|
|
8
|
+
|
|
9
|
+
const sizeMap = {
|
|
10
|
+
sm: styles.sizeSm,
|
|
11
|
+
md: styles.sizeMd,
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
const removableSizeMap = {
|
|
15
|
+
sm: styles.removableSm,
|
|
16
|
+
md: styles.removableMd,
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Native Tag (plan N0, Slice B): the label and, when removable, the remove
|
|
21
|
+
* button. TagGroup is not ported (its grid keyboard model is web only,
|
|
22
|
+
* Decision 6), so `value` is unused here; the remove button's name comes from
|
|
23
|
+
* a string label.
|
|
24
|
+
*/
|
|
25
|
+
export function Tag({
|
|
26
|
+
variant = 'default',
|
|
27
|
+
size = 'md',
|
|
28
|
+
removable = false,
|
|
29
|
+
onRemove,
|
|
30
|
+
disabled = false,
|
|
31
|
+
wrap = false,
|
|
32
|
+
children,
|
|
33
|
+
}: TagProps) {
|
|
34
|
+
const removeLabel = typeof children === 'string' ? `Remove ${children}` : 'Remove'
|
|
35
|
+
// A div, not the web's span: a native span is a Text, which would lay the
|
|
36
|
+
// remove button out inline, raised off the label's centre line.
|
|
37
|
+
return (
|
|
38
|
+
<html.div
|
|
39
|
+
style={[
|
|
40
|
+
styles.base,
|
|
41
|
+
styles.nativeFlex,
|
|
42
|
+
sizeMap[size],
|
|
43
|
+
removable && removableSizeMap[size],
|
|
44
|
+
styles[variant],
|
|
45
|
+
disabled && styles.disabled,
|
|
46
|
+
wrap && styles.wrap,
|
|
47
|
+
]}
|
|
48
|
+
>
|
|
49
|
+
<html.span>{children}</html.span>
|
|
50
|
+
{removable && (
|
|
51
|
+
<html.button
|
|
52
|
+
type="button"
|
|
53
|
+
aria-label={removeLabel}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
onClick={disabled ? undefined : onRemove}
|
|
56
|
+
style={[
|
|
57
|
+
styles.removeButton,
|
|
58
|
+
styles.nativeButtonFlex,
|
|
59
|
+
disabled && styles.removeButtonDisabled,
|
|
60
|
+
]}
|
|
61
|
+
>
|
|
62
|
+
<RemoveGlyph
|
|
63
|
+
size={size === 'sm' ? SIZES_PX.glyphXs : SIZES_PX.glyphSm}
|
|
64
|
+
color={variantText[variant]}
|
|
65
|
+
/>
|
|
66
|
+
</html.button>
|
|
67
|
+
)}
|
|
68
|
+
</html.div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -1,31 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {useCallback, useRef} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
|
+
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
3
4
|
import {useTagGroup} from '../TagGroup/TagGroupContext'
|
|
4
5
|
import {styles} from './styles.css'
|
|
6
|
+
import {RemoveGlyph} from './RemoveGlyph'
|
|
7
|
+
import {variantText, type TagProps} from './types'
|
|
5
8
|
|
|
6
|
-
export type
|
|
7
|
-
export type TagSize = 'sm' | 'md'
|
|
8
|
-
|
|
9
|
-
interface TagProps {
|
|
10
|
-
/** Value identifier — required when used inside TagGroup */
|
|
11
|
-
value?: string
|
|
12
|
-
variant?: TagVariant
|
|
13
|
-
size?: TagSize
|
|
14
|
-
/** Show remove button. Auto-derived from TagGroup context when inside one. */
|
|
15
|
-
removable?: boolean
|
|
16
|
-
/** Called when remove button is clicked. Auto-wired to TagGroup when inside one. */
|
|
17
|
-
onRemove?: () => void
|
|
18
|
-
disabled?: boolean
|
|
19
|
-
/**
|
|
20
|
-
* Allow the label to wrap / break instead of staying on one line. Tags are
|
|
21
|
-
* `white-space: nowrap` by default (they read as pills); set this for long
|
|
22
|
-
* identifier-style values (e.g. `lldap.group.member.remove`) sitting in a
|
|
23
|
-
* constrained column, so they break within their cell instead of leaking
|
|
24
|
-
* into the next one.
|
|
25
|
-
*/
|
|
26
|
-
wrap?: boolean
|
|
27
|
-
children: ReactNode
|
|
28
|
-
}
|
|
9
|
+
export type {TagProps, TagSize, TagVariant} from './types'
|
|
29
10
|
|
|
30
11
|
const sizeMap = {
|
|
31
12
|
sm: styles.sizeSm,
|
|
@@ -112,19 +93,10 @@ export function Tag({
|
|
|
112
93
|
tabIndex={-1}
|
|
113
94
|
style={[styles.removeButton, disabled && styles.removeButtonDisabled]}
|
|
114
95
|
>
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
fill="none"
|
|
120
|
-
>
|
|
121
|
-
<path
|
|
122
|
-
d="M3 3l6 6M9 3l-6 6"
|
|
123
|
-
stroke="currentColor"
|
|
124
|
-
strokeWidth={1.5}
|
|
125
|
-
strokeLinecap="round"
|
|
126
|
-
/>
|
|
127
|
-
</svg>
|
|
96
|
+
<RemoveGlyph
|
|
97
|
+
size={size === 'sm' ? SIZES_PX.glyphXs : SIZES_PX.glyphSm}
|
|
98
|
+
color={variantText[variant]}
|
|
99
|
+
/>
|
|
128
100
|
</html.button>
|
|
129
101
|
</html.span>
|
|
130
102
|
)}
|
|
@@ -110,6 +110,15 @@ export const styles = css.create({
|
|
|
110
110
|
},
|
|
111
111
|
outlineWidth: 0,
|
|
112
112
|
},
|
|
113
|
+
// RSD on native lays out only display: flex, not the web's inline-flex.
|
|
114
|
+
nativeFlex: {
|
|
115
|
+
display: 'flex',
|
|
116
|
+
flexDirection: 'row',
|
|
117
|
+
alignSelf: 'flex-start',
|
|
118
|
+
},
|
|
119
|
+
nativeButtonFlex: {
|
|
120
|
+
display: 'flex',
|
|
121
|
+
},
|
|
113
122
|
removeButtonDisabled: {
|
|
114
123
|
cursor: 'not-allowed',
|
|
115
124
|
opacity: 0.4,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import type {ColorToken} from '@duro-app/tokens/keys'
|
|
3
|
+
|
|
4
|
+
export type TagVariant = 'default' | 'success' | 'warning' | 'error' | 'info'
|
|
5
|
+
|
|
6
|
+
/** Each variant's text colour, which the remove cross draws in. */
|
|
7
|
+
export const variantText = {
|
|
8
|
+
default: 'text',
|
|
9
|
+
success: 'successText',
|
|
10
|
+
warning: 'warningText',
|
|
11
|
+
error: 'errorText',
|
|
12
|
+
info: 'infoText',
|
|
13
|
+
} as const satisfies Record<TagVariant, ColorToken>
|
|
14
|
+
|
|
15
|
+
export type TagSize = 'sm' | 'md'
|
|
16
|
+
|
|
17
|
+
export interface TagProps {
|
|
18
|
+
/** Value identifier — required when used inside TagGroup */
|
|
19
|
+
value?: string
|
|
20
|
+
variant?: TagVariant
|
|
21
|
+
size?: TagSize
|
|
22
|
+
/** Show remove button. Auto-derived from TagGroup context when inside one. */
|
|
23
|
+
removable?: boolean
|
|
24
|
+
/** Called when remove button is clicked. Auto-wired to TagGroup when inside one. */
|
|
25
|
+
onRemove?: () => void
|
|
26
|
+
disabled?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Allow the label to wrap / break instead of staying on one line. Tags are
|
|
29
|
+
* `white-space: nowrap` by default (they read as pills); set this for long
|
|
30
|
+
* identifier-style values (e.g. `lldap.group.member.remove`) sitting in a
|
|
31
|
+
* constrained column, so they break within their cell instead of leaking
|
|
32
|
+
* into the next one.
|
|
33
|
+
*/
|
|
34
|
+
wrap?: boolean
|
|
35
|
+
children: ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RemoveGlyphProps {
|
|
39
|
+
/** px, from the glyph size tokens */
|
|
40
|
+
size: number
|
|
41
|
+
/** Native only: the web inherits the tag's text colour. */
|
|
42
|
+
color: ColorToken
|
|
43
|
+
}
|
|
@@ -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
|
|
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={
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
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={
|
|
59
|
-
<
|
|
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
|
+
}
|