@duro-app/ui 5.10.0 → 5.12.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 (179) hide show
  1. package/dist/{Table-gRiLI243.js → Table-ColfpgKV.js} +8 -4
  2. package/dist/Table-ColfpgKV.js.map +1 -0
  3. package/dist/compiler-runtime-BuugS4D0.js.map +1 -1
  4. package/dist/components/Alert/Alert.d.ts.map +1 -1
  5. package/dist/components/Button/Button.d.ts +2 -1
  6. package/dist/components/Button/Button.d.ts.map +1 -1
  7. package/dist/components/Button/styles.css.d.ts +1 -0
  8. package/dist/components/Button/styles.css.d.ts.map +1 -1
  9. package/dist/components/Callout/Callout.d.ts.map +1 -1
  10. package/dist/components/Callout/styles.css.d.ts +3 -0
  11. package/dist/components/Callout/styles.css.d.ts.map +1 -1
  12. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
  13. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
  14. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  15. package/dist/components/Dialog/Dialog.d.ts +2 -17
  16. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  17. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  18. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  19. package/dist/components/Dialog/native.css.d.ts +82 -0
  20. package/dist/components/Dialog/native.css.d.ts.map +1 -0
  21. package/dist/components/Dialog/parts.css.d.ts +82 -0
  22. package/dist/components/Dialog/parts.css.d.ts.map +1 -0
  23. package/dist/components/Dialog/parts.d.ts +35 -0
  24. package/dist/components/Dialog/parts.d.ts.map +1 -0
  25. package/dist/components/Dialog/styles.css.d.ts +1 -74
  26. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  27. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  28. package/dist/components/Drawer/styles.css.d.ts +2 -0
  29. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  30. package/dist/components/FileTrigger/FileTrigger.d.ts +23 -0
  31. package/dist/components/FileTrigger/FileTrigger.d.ts.map +1 -0
  32. package/dist/components/FileTrigger/FileTrigger.meta.d.ts +3 -0
  33. package/dist/components/FileTrigger/FileTrigger.meta.d.ts.map +1 -0
  34. package/dist/components/FileTrigger/FileTrigger.stories.d.ts +9 -0
  35. package/dist/components/FileTrigger/FileTrigger.stories.d.ts.map +1 -0
  36. package/dist/components/FileTrigger/index.d.ts +3 -0
  37. package/dist/components/FileTrigger/index.d.ts.map +1 -0
  38. package/dist/components/FileTrigger/pick.d.ts +14 -0
  39. package/dist/components/FileTrigger/pick.d.ts.map +1 -0
  40. package/dist/components/Grid/styles.css.d.ts +1 -0
  41. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  42. package/dist/components/Icon/Icon.d.ts +3 -9
  43. package/dist/components/Icon/Icon.d.ts.map +1 -1
  44. package/dist/components/Icon/Icon.stories.d.ts.map +1 -1
  45. package/dist/components/Icon/geometry.d.ts +60 -0
  46. package/dist/components/Icon/geometry.d.ts.map +1 -0
  47. package/dist/components/Icon/types.d.ts +15 -0
  48. package/dist/components/Icon/types.d.ts.map +1 -0
  49. package/dist/components/Input/Input.d.ts +5 -1
  50. package/dist/components/Input/Input.d.ts.map +1 -1
  51. package/dist/components/Input/styles.css.d.ts +1 -0
  52. package/dist/components/Input/styles.css.d.ts.map +1 -1
  53. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  54. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  55. package/dist/components/Meter/styles.css.d.ts +1 -0
  56. package/dist/components/Meter/styles.css.d.ts.map +1 -1
  57. package/dist/components/PageShell/styles.css.d.ts +2 -0
  58. package/dist/components/PageShell/styles.css.d.ts.map +1 -1
  59. package/dist/components/RadioGroup/RadioGroup.d.ts +1 -17
  60. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  61. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +3 -0
  62. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -1
  63. package/dist/components/RadioGroup/group.d.ts +31 -0
  64. package/dist/components/RadioGroup/group.d.ts.map +1 -0
  65. package/dist/components/RadioGroup/styles.css.d.ts +10 -0
  66. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -1
  67. package/dist/components/ScrollArea/styles.css.d.ts +1 -0
  68. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  69. package/dist/components/Spinner/Spinner.d.ts +2 -6
  70. package/dist/components/Spinner/Spinner.d.ts.map +1 -1
  71. package/dist/components/Spinner/Spinner.types.d.ts +6 -0
  72. package/dist/components/Spinner/Spinner.types.d.ts.map +1 -0
  73. package/dist/components/Spinner/ring.css.d.ts +39 -0
  74. package/dist/components/Spinner/ring.css.d.ts.map +1 -0
  75. package/dist/components/Spinner/styles.css.d.ts +1 -28
  76. package/dist/components/Spinner/styles.css.d.ts.map +1 -1
  77. package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
  78. package/dist/components/StatusIcon/styles.css.d.ts +3 -0
  79. package/dist/components/StatusIcon/styles.css.d.ts.map +1 -1
  80. package/dist/components/Switch/Switch.d.ts +2 -18
  81. package/dist/components/Switch/Switch.d.ts.map +1 -1
  82. package/dist/components/Switch/Switch.types.d.ts +18 -0
  83. package/dist/components/Switch/Switch.types.d.ts.map +1 -0
  84. package/dist/components/TableOfContents/styles.css.d.ts +1 -0
  85. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -1
  86. package/dist/components/Textarea/styles.css.d.ts +1 -0
  87. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  88. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  89. package/dist/components/Toggle/styles.css.d.ts +11 -2
  90. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  91. package/dist/components/VirtualTable/styles.css.d.ts +6 -0
  92. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  93. package/dist/index.css +1 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +4589 -3788
  97. package/dist/index.js.map +1 -1
  98. package/dist/shared/portal/hostRegistry.d.ts +33 -0
  99. package/dist/shared/portal/hostRegistry.d.ts.map +1 -0
  100. package/dist/shared/useOverlayOpenState.d.ts +40 -0
  101. package/dist/shared/useOverlayOpenState.d.ts.map +1 -0
  102. package/dist/table.js +141 -135
  103. package/dist/table.js.map +1 -1
  104. package/package.json +34 -4
  105. package/src/components/Alert/Alert.tsx +14 -2
  106. package/src/components/Button/Button.tsx +3 -2
  107. package/src/components/Button/styles.css.ts +2 -0
  108. package/src/components/Callout/Callout.tsx +17 -3
  109. package/src/components/Callout/styles.css.ts +5 -0
  110. package/src/components/Checkbox/Checkbox.native.tsx +67 -0
  111. package/src/components/Checkbox/Checkbox.tsx +1 -50
  112. package/src/components/ConfirmDialog/ConfirmDialog.tsx +3 -0
  113. package/src/components/DetailPanel/DetailPanel.tsx +9 -45
  114. package/src/components/Dialog/Dialog.native.tsx +296 -0
  115. package/src/components/Dialog/Dialog.stories.tsx +71 -0
  116. package/src/components/Dialog/Dialog.tsx +41 -130
  117. package/src/components/Dialog/native.css.ts +78 -0
  118. package/src/components/Dialog/parts.css.ts +101 -0
  119. package/src/components/Dialog/parts.tsx +61 -0
  120. package/src/components/Dialog/styles.css.ts +2 -89
  121. package/src/components/Drawer/Drawer.tsx +20 -49
  122. package/src/components/Drawer/styles.css.ts +4 -0
  123. package/src/components/FileTrigger/FileTrigger.meta.ts +24 -0
  124. package/src/components/FileTrigger/FileTrigger.stories.tsx +36 -0
  125. package/src/components/FileTrigger/FileTrigger.tsx +80 -0
  126. package/src/components/FileTrigger/index.ts +2 -0
  127. package/src/components/FileTrigger/pick.ts +21 -0
  128. package/src/components/Grid/styles.css.ts +2 -0
  129. package/src/components/Icon/Icon.native.tsx +54 -0
  130. package/src/components/Icon/Icon.stories.tsx +1 -0
  131. package/src/components/Icon/Icon.tsx +19 -400
  132. package/src/components/Icon/geometry.ts +456 -0
  133. package/src/components/Icon/types.ts +74 -0
  134. package/src/components/Input/Input.tsx +8 -0
  135. package/src/components/Input/styles.css.ts +2 -0
  136. package/src/components/LinkButton/styles.css.ts +2 -0
  137. package/src/components/Meter/styles.css.ts +2 -0
  138. package/src/components/PageShell/styles.css.ts +4 -0
  139. package/src/components/RadioGroup/RadioGroup.meta.ts +1 -1
  140. package/src/components/RadioGroup/RadioGroup.native.tsx +48 -0
  141. package/src/components/RadioGroup/RadioGroup.stories.tsx +64 -1
  142. package/src/components/RadioGroup/RadioGroup.tsx +8 -83
  143. package/src/components/RadioGroup/group.tsx +85 -0
  144. package/src/components/RadioGroup/styles.css.ts +13 -0
  145. package/src/components/ScrollArea/styles.css.ts +2 -0
  146. package/src/components/Spinner/Spinner.native.tsx +37 -0
  147. package/src/components/Spinner/Spinner.tsx +6 -15
  148. package/src/components/Spinner/Spinner.types.ts +6 -0
  149. package/src/components/Spinner/ring.css.ts +45 -0
  150. package/src/components/Spinner/styles.css.ts +2 -32
  151. package/src/components/StatusIcon/StatusIcon.tsx +13 -2
  152. package/src/components/StatusIcon/styles.css.ts +5 -0
  153. package/src/components/Switch/Switch.native.tsx +45 -0
  154. package/src/components/Switch/Switch.tsx +22 -44
  155. package/src/components/Switch/Switch.types.ts +18 -0
  156. package/src/components/TableOfContents/styles.css.ts +2 -0
  157. package/src/components/Textarea/styles.css.ts +2 -0
  158. package/src/components/ThemeProvider/ThemeProvider.native.tsx +70 -10
  159. package/src/components/Toggle/Toggle.tsx +2 -0
  160. package/src/components/Toggle/styles.css.ts +14 -2
  161. package/src/components/VirtualTable/styles.css.ts +12 -0
  162. package/src/index.native.ts +28 -1
  163. package/src/index.ts +5 -0
  164. package/src/native-guard/globals.d.ts +10 -0
  165. package/src/native-guard/react-dom.d.ts +3 -0
  166. package/src/shared/accessibilityFocus.native.ts +23 -0
  167. package/src/shared/portal/OverlayModal.native.tsx +51 -0
  168. package/src/shared/portal/PortalHost.native.tsx +102 -0
  169. package/src/shared/portal/hostRegistry.ts +76 -0
  170. package/src/shared/portal/themeScope.native.ts +18 -0
  171. package/src/shared/safeArea.native.ts +22 -0
  172. package/src/shared/useOverlayOpenState.ts +128 -0
  173. package/dist/Table-gRiLI243.js.map +0 -1
  174. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts +0 -10
  175. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts.map +0 -1
  176. package/dist/index.native.d.ts +0 -33
  177. package/dist/index.native.d.ts.map +0 -1
  178. package/dist/platform.native.d.ts +0 -2
  179. package/dist/platform.native.d.ts.map +0 -1
@@ -11,8 +11,9 @@ import {
11
11
  useRef,
12
12
  } from 'react'
13
13
  import {html} from 'react-strict-dom'
14
- import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
14
+ import type {DurationToken} from '@duro-app/tokens/keys'
15
15
  import {styles} from './styles.css'
16
+ import {useOverlayOpenState} from '../../shared/useOverlayOpenState'
16
17
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
17
18
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
18
19
  import {ModalContext} from '../../shared/ModalContext'
@@ -89,57 +90,27 @@ function Root({
89
90
  swipeDismiss = true,
90
91
  closeAnimationDuration = 'fast',
91
92
  }: RootProps) {
92
- const isControlled = controlledOpen !== undefined
93
- const [internalOpen, setInternalOpen] = useState(defaultOpen)
94
- const [closing, setClosing] = useState(false)
95
- const closingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
93
+ const {
94
+ present,
95
+ closing,
96
+ requestOpen,
97
+ requestClose,
98
+ closeNow: requestCloseImmediate,
99
+ } = useOverlayOpenState({
100
+ open: controlledOpen,
101
+ defaultOpen,
102
+ onOpenChange,
103
+ exitDuration: closeAnimationDuration,
104
+ closeNotice: 'after-exit',
105
+ })
96
106
  const panelRef = useRef<HTMLDivElement>(null)
97
107
 
98
- const isOpen = isControlled ? controlledOpen : internalOpen
99
-
100
108
  const titleId = useId()
101
109
  const descriptionId = useId()
102
110
 
103
- const requestOpen = useCallback(() => {
104
- if (closingTimerRef.current) {
105
- clearTimeout(closingTimerRef.current)
106
- closingTimerRef.current = null
107
- }
108
- setClosing(false)
109
- if (!isControlled) setInternalOpen(true)
110
- onOpenChange?.(true)
111
- }, [isControlled, onOpenChange])
112
-
113
- const requestClose = useCallback(() => {
114
- setClosing(true)
115
- closingTimerRef.current = setTimeout(() => {
116
- setClosing(false)
117
- closingTimerRef.current = null
118
- if (!isControlled) setInternalOpen(false)
119
- onOpenChange?.(false)
120
- }, DURATION_MS[closeAnimationDuration])
121
- }, [isControlled, onOpenChange, closeAnimationDuration])
122
-
123
- const requestCloseImmediate = useCallback(() => {
124
- if (closingTimerRef.current) {
125
- clearTimeout(closingTimerRef.current)
126
- closingTimerRef.current = null
127
- }
128
- setClosing(false)
129
- if (!isControlled) setInternalOpen(false)
130
- onOpenChange?.(false)
131
- }, [isControlled, onOpenChange])
132
-
133
- // Clean up timer on unmount
134
- useEffect(() => {
135
- return () => {
136
- if (closingTimerRef.current) clearTimeout(closingTimerRef.current)
137
- }
138
- }, [])
139
-
140
111
  // Handle Escape key
141
112
  useEffect(() => {
142
- if (!isOpen && !closing) return
113
+ if (!present) return
143
114
  if (!dismissable) return
144
115
 
145
116
  function onKeyDown(e: KeyboardEvent) {
@@ -150,23 +121,23 @@ function Root({
150
121
  }
151
122
  document.addEventListener('keydown', onKeyDown)
152
123
  return () => document.removeEventListener('keydown', onKeyDown)
153
- }, [isOpen, closing, dismissable, requestClose])
124
+ }, [present, dismissable, requestClose])
154
125
 
155
126
  // Lock body scroll when open
156
127
  useEffect(() => {
157
- if (isOpen || closing) {
128
+ if (present) {
158
129
  const prev = document.body.style.overflow
159
130
  document.body.style.overflow = 'hidden'
160
131
  return () => {
161
132
  document.body.style.overflow = prev
162
133
  }
163
134
  }
164
- }, [isOpen, closing])
135
+ }, [present])
165
136
 
166
137
  return (
167
138
  <DrawerContext.Provider
168
139
  value={{
169
- open: isOpen || closing,
140
+ open: present,
170
141
  closing,
171
142
  closeDuration: closeAnimationDuration,
172
143
  anchor,
@@ -79,6 +79,8 @@ export const styles = css.create({
79
79
  // Panel sizing per anchor
80
80
  panelHorizontal: {
81
81
  width: '100%',
82
+ // Native has no border-box reset (the web's global-reset.css); declare it.
83
+ boxSizing: 'border-box',
82
84
  height: '100%',
83
85
  borderLeftWidth: borders.hairline,
84
86
  borderLeftStyle: 'solid',
@@ -86,6 +88,8 @@ export const styles = css.create({
86
88
  },
87
89
  panelVertical: {
88
90
  width: '100%',
91
+ // Native has no border-box reset (the web's global-reset.css); declare it.
92
+ boxSizing: 'border-box',
89
93
  borderTopWidth: borders.hairline,
90
94
  borderTopStyle: 'solid',
91
95
  borderTopColor: colors.border,
@@ -0,0 +1,24 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A Button that opens the OS file picker. Pass the chosen files to onSelect; call open() on its ref to open the picker from elsewhere.',
6
+ whenToUse: [
7
+ 'Uploading or attaching one or more files',
8
+ 'Opening the file picker from a custom control via the ref',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Drag-and-drop zones — build on a drop target instead',
12
+ 'Triggering a non-file action — use Button',
13
+ ],
14
+ relatedTo: [
15
+ {
16
+ component: 'Button',
17
+ kind: 'composition',
18
+ relationship: 'FileTrigger renders a Button and forwards variant, size and disabled',
19
+ },
20
+ ],
21
+ example: `<FileTrigger accept="image/*" multiple onSelect={(files) => upload(files)}>
22
+ Choose images
23
+ </FileTrigger>`,
24
+ }
@@ -0,0 +1,36 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {FileTrigger} from './FileTrigger'
4
+
5
+ const meta: Meta<typeof FileTrigger> = {
6
+ title: 'Components/FileTrigger',
7
+ component: FileTrigger,
8
+ args: {onSelect: fn()},
9
+ argTypes: {
10
+ variant: {control: 'select', options: ['primary', 'secondary', 'link', 'danger']},
11
+ size: {control: 'select', options: ['default', 'small']},
12
+ multiple: {control: 'boolean'},
13
+ disabled: {control: 'boolean'},
14
+ },
15
+ }
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof FileTrigger>
19
+
20
+ export const Default: Story = {
21
+ args: {children: 'Choose file', variant: 'secondary'},
22
+ play: async ({args, canvas, userEvent, canvasElement}) => {
23
+ await expect(canvas.getByRole('button', {name: 'Choose file'})).toBeEnabled()
24
+ const input = canvasElement.querySelector<HTMLInputElement>('input[type=file]')!
25
+ await userEvent.upload(input, new File(['hi'], 'hi.txt', {type: 'text/plain'}))
26
+ await expect(args.onSelect).toHaveBeenCalledTimes(1)
27
+ },
28
+ }
29
+
30
+ export const Images: Story = {
31
+ args: {children: 'Choose images', accept: 'image/*', multiple: true},
32
+ }
33
+
34
+ export const Disabled: Story = {
35
+ args: {children: 'Choose file', disabled: true},
36
+ }
@@ -0,0 +1,80 @@
1
+ import {useEffect, useImperativeHandle, useRef} from 'react'
2
+ import type {ReactNode, Ref} from 'react'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Button, type ButtonSize, type ButtonVariant} from '../Button/Button'
5
+ import {openPicker, takeFiles} from './pick'
6
+
7
+ export interface FileTriggerHandle {
8
+ /** Open the OS file picker programmatically. */
9
+ open: () => void
10
+ }
11
+
12
+ export interface FileTriggerProps {
13
+ /** Accepted types, as the native `accept` attribute (e.g. `image/*,.pdf`). */
14
+ accept?: string
15
+ multiple?: boolean
16
+ /** Called with the chosen files. */
17
+ onSelect: (files: File[]) => void
18
+ variant?: ButtonVariant
19
+ size?: ButtonSize
20
+ disabled?: boolean
21
+ 'aria-label'?: string
22
+ /** Exposes `open()` so a caller can open the picker without the button. */
23
+ ref?: Ref<FileTriggerHandle>
24
+ children: ReactNode
25
+ }
26
+
27
+ // display:none, not visually hidden: the input is only ever opened by click(),
28
+ // which a hidden input still honours, and this keeps its "No file chosen" text
29
+ // out of the accessibility tree (axe flagged its contrast).
30
+ const styles = css.create({
31
+ input: {display: 'none'},
32
+ })
33
+
34
+ /** A Button that opens the OS file picker. Web only: native has no file input. */
35
+ export function FileTrigger({
36
+ accept,
37
+ multiple = false,
38
+ onSelect,
39
+ variant,
40
+ size,
41
+ disabled,
42
+ 'aria-label': ariaLabel,
43
+ ref,
44
+ children,
45
+ }: FileTriggerProps) {
46
+ const inputRef = useRef<HTMLInputElement>(null)
47
+ useImperativeHandle(ref, () => ({open: () => openPicker(inputRef.current)}), [])
48
+
49
+ // RSD's input props omit `accept`, so set the DOM attribute directly.
50
+ useEffect(() => {
51
+ const input = inputRef.current
52
+ if (!input) return
53
+ if (accept) input.setAttribute('accept', accept)
54
+ else input.removeAttribute('accept')
55
+ }, [accept])
56
+
57
+ return (
58
+ <>
59
+ <Button
60
+ variant={variant}
61
+ size={size}
62
+ disabled={disabled}
63
+ aria-label={ariaLabel}
64
+ onClick={() => openPicker(inputRef.current)}
65
+ >
66
+ {children}
67
+ </Button>
68
+ <html.input
69
+ ref={inputRef}
70
+ type="file"
71
+ multiple={multiple}
72
+ disabled={disabled}
73
+ tabIndex={-1}
74
+ aria-hidden={true}
75
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => takeFiles(e.target, onSelect)}
76
+ style={styles.input}
77
+ />
78
+ </>
79
+ )
80
+ }
@@ -0,0 +1,2 @@
1
+ export {FileTrigger} from './FileTrigger'
2
+ export type {FileTriggerProps, FileTriggerHandle} from './FileTrigger'
@@ -0,0 +1,21 @@
1
+ /** The slice of an <input type=file> the picker logic touches. */
2
+ export interface FileInputLike {
3
+ files: ArrayLike<File> | null
4
+ value: string
5
+ click: () => void
6
+ }
7
+
8
+ /** Open the native file picker. */
9
+ export function openPicker(input: Pick<FileInputLike, 'click'> | null) {
10
+ input?.click()
11
+ }
12
+
13
+ /**
14
+ * Hand the chosen files to `onSelect`, then reset the input so picking the
15
+ * same file again still fires a change event.
16
+ */
17
+ export function takeFiles(input: FileInputLike, onSelect: (files: File[]) => void) {
18
+ const files = input.files ? Array.from(input.files) : []
19
+ input.value = ''
20
+ if (files.length > 0) onSelect(files)
21
+ }
@@ -86,6 +86,8 @@ export const styles = css.create({
86
86
  // against an auto width the line breaks come out wrong and the container
87
87
  // under-reports its height (verified on the iOS simulator).
88
88
  width: '100%',
89
+ // Native has no border-box reset (the web's global-reset.css); declare it.
90
+ boxSizing: 'border-box',
89
91
  },
90
92
  // border-box is explicit: react-strict-dom mirrors the web default
91
93
  // (content-box) on native, which adds the gap padding on top of the basis
@@ -0,0 +1,54 @@
1
+ import {createElement} from 'react'
2
+ import Svg, {Circle, Ellipse, Line, Path, Polygon, Polyline, Rect} from 'react-native-svg'
3
+ import {ICON_SIZES, type ColorToken} from '@duro-app/tokens/keys'
4
+ import {useResolvedColors} from '../ThemeProvider/ThemeProvider'
5
+ import {GLYPHS, type Paint, type Shape} from './geometry'
6
+ import type {IconProps} from './types'
7
+
8
+ export type {IconName, IconProps, IconSize} from './types'
9
+
10
+ const elements = {
11
+ path: Path,
12
+ circle: Circle,
13
+ ellipse: Ellipse,
14
+ line: Line,
15
+ rect: Rect,
16
+ polyline: Polyline,
17
+ polygon: Polygon,
18
+ } as const
19
+
20
+ /** react-native-svg reads `currentColor` from the Svg's `color`, which is a resolved colour here. */
21
+ function nativeShape(shape: Shape, key: number) {
22
+ const {tag, ...attrs} = shape
23
+ return createElement(elements[tag] as never, {key, ...attrs})
24
+ }
25
+
26
+ /**
27
+ * Native Icon: the same geometry as the web, drawn with react-native-svg
28
+ * (plan N0, Decision 9; RSD has no `svg`). It cannot inherit a text colour,
29
+ * so its colour is a token resolved through the nearest ThemeProvider. It is
30
+ * decorative: the control that hosts it carries the label.
31
+ */
32
+ export function Icon({name, size: sizeToken = 'lg', color = 'text'}: IconProps) {
33
+ const size = ICON_SIZES[sizeToken]
34
+ const glyph = GLYPHS[name]
35
+ const resolved = useResolvedColors()[color as ColorToken]
36
+ const paint: {fill: Paint; stroke?: Paint} =
37
+ glyph.style === 'fill' ? {fill: 'currentColor'} : {fill: 'none', stroke: 'currentColor'}
38
+ return (
39
+ <Svg
40
+ viewBox="0 0 24 24"
41
+ width={size}
42
+ height={size}
43
+ color={resolved}
44
+ {...paint}
45
+ strokeWidth={glyph.style === 'stroke' ? 2 : undefined}
46
+ strokeLinecap="round"
47
+ strokeLinejoin="round"
48
+ accessibilityElementsHidden
49
+ importantForAccessibility="no-hide-descendants"
50
+ >
51
+ {glyph.shapes.map(nativeShape)}
52
+ </Svg>
53
+ )
54
+ }
@@ -57,6 +57,7 @@ const groups: ReadonlyArray<{title: string; names: readonly IconName[]}> = [
57
57
  {title: 'Input / action', names: ['search', 'mic']},
58
58
  {title: 'Color mode', names: ['sun', 'moon', 'monitor', 'contrast']},
59
59
  {title: 'Device status', names: ['signal', 'battery']},
60
+ {title: 'Media', names: ['video', 'music', 'play', 'pause']},
60
61
  {
61
62
  title: 'Filled variants',
62
63
  names: [