@duro-app/ui 5.11.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 (157) hide show
  1. package/dist/Table-ColfpgKV.js.map +1 -1
  2. package/dist/compiler-runtime-BuugS4D0.js.map +1 -1
  3. package/dist/components/Alert/Alert.d.ts.map +1 -1
  4. package/dist/components/Button/Button.d.ts +2 -1
  5. package/dist/components/Button/Button.d.ts.map +1 -1
  6. package/dist/components/Button/styles.css.d.ts +1 -0
  7. package/dist/components/Button/styles.css.d.ts.map +1 -1
  8. package/dist/components/Callout/Callout.d.ts.map +1 -1
  9. package/dist/components/Callout/styles.css.d.ts +3 -0
  10. package/dist/components/Callout/styles.css.d.ts.map +1 -1
  11. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -1
  12. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -1
  13. package/dist/components/DetailPanel/DetailPanel.d.ts.map +1 -1
  14. package/dist/components/Dialog/Dialog.d.ts +2 -17
  15. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  16. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  17. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  18. package/dist/components/Dialog/native.css.d.ts +82 -0
  19. package/dist/components/Dialog/native.css.d.ts.map +1 -0
  20. package/dist/components/Dialog/parts.css.d.ts +82 -0
  21. package/dist/components/Dialog/parts.css.d.ts.map +1 -0
  22. package/dist/components/Dialog/parts.d.ts +35 -0
  23. package/dist/components/Dialog/parts.d.ts.map +1 -0
  24. package/dist/components/Dialog/styles.css.d.ts +1 -74
  25. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  26. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  27. package/dist/components/Drawer/styles.css.d.ts +2 -0
  28. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  29. package/dist/components/Grid/styles.css.d.ts +1 -0
  30. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  31. package/dist/components/Icon/Icon.d.ts +3 -9
  32. package/dist/components/Icon/Icon.d.ts.map +1 -1
  33. package/dist/components/Icon/geometry.d.ts +60 -0
  34. package/dist/components/Icon/geometry.d.ts.map +1 -0
  35. package/dist/components/Icon/types.d.ts +15 -0
  36. package/dist/components/Icon/types.d.ts.map +1 -0
  37. package/dist/components/Input/Input.d.ts +5 -1
  38. package/dist/components/Input/Input.d.ts.map +1 -1
  39. package/dist/components/Input/styles.css.d.ts +1 -0
  40. package/dist/components/Input/styles.css.d.ts.map +1 -1
  41. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  42. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  43. package/dist/components/Meter/styles.css.d.ts +1 -0
  44. package/dist/components/Meter/styles.css.d.ts.map +1 -1
  45. package/dist/components/PageShell/styles.css.d.ts +2 -0
  46. package/dist/components/PageShell/styles.css.d.ts.map +1 -1
  47. package/dist/components/RadioGroup/RadioGroup.d.ts +1 -17
  48. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  49. package/dist/components/RadioGroup/RadioGroup.stories.d.ts +3 -0
  50. package/dist/components/RadioGroup/RadioGroup.stories.d.ts.map +1 -1
  51. package/dist/components/RadioGroup/group.d.ts +31 -0
  52. package/dist/components/RadioGroup/group.d.ts.map +1 -0
  53. package/dist/components/RadioGroup/styles.css.d.ts +10 -0
  54. package/dist/components/RadioGroup/styles.css.d.ts.map +1 -1
  55. package/dist/components/ScrollArea/styles.css.d.ts +1 -0
  56. package/dist/components/ScrollArea/styles.css.d.ts.map +1 -1
  57. package/dist/components/Spinner/Spinner.d.ts +2 -6
  58. package/dist/components/Spinner/Spinner.d.ts.map +1 -1
  59. package/dist/components/Spinner/Spinner.types.d.ts +6 -0
  60. package/dist/components/Spinner/Spinner.types.d.ts.map +1 -0
  61. package/dist/components/Spinner/ring.css.d.ts +39 -0
  62. package/dist/components/Spinner/ring.css.d.ts.map +1 -0
  63. package/dist/components/Spinner/styles.css.d.ts +1 -28
  64. package/dist/components/Spinner/styles.css.d.ts.map +1 -1
  65. package/dist/components/StatusIcon/StatusIcon.d.ts.map +1 -1
  66. package/dist/components/StatusIcon/styles.css.d.ts +3 -0
  67. package/dist/components/StatusIcon/styles.css.d.ts.map +1 -1
  68. package/dist/components/Switch/Switch.d.ts +2 -18
  69. package/dist/components/Switch/Switch.d.ts.map +1 -1
  70. package/dist/components/Switch/Switch.types.d.ts +18 -0
  71. package/dist/components/Switch/Switch.types.d.ts.map +1 -0
  72. package/dist/components/TableOfContents/styles.css.d.ts +1 -0
  73. package/dist/components/TableOfContents/styles.css.d.ts.map +1 -1
  74. package/dist/components/Textarea/styles.css.d.ts +1 -0
  75. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  76. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  77. package/dist/components/Toggle/styles.css.d.ts +11 -2
  78. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  79. package/dist/components/VirtualTable/styles.css.d.ts +6 -0
  80. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  81. package/dist/index.css +1 -1
  82. package/dist/index.js +4299 -3566
  83. package/dist/index.js.map +1 -1
  84. package/dist/shared/portal/hostRegistry.d.ts +33 -0
  85. package/dist/shared/portal/hostRegistry.d.ts.map +1 -0
  86. package/dist/shared/useOverlayOpenState.d.ts +40 -0
  87. package/dist/shared/useOverlayOpenState.d.ts.map +1 -0
  88. package/dist/table.js +141 -135
  89. package/dist/table.js.map +1 -1
  90. package/package.json +34 -4
  91. package/src/components/Alert/Alert.tsx +14 -2
  92. package/src/components/Button/Button.tsx +3 -2
  93. package/src/components/Button/styles.css.ts +2 -0
  94. package/src/components/Callout/Callout.tsx +17 -3
  95. package/src/components/Callout/styles.css.ts +5 -0
  96. package/src/components/Checkbox/Checkbox.native.tsx +67 -0
  97. package/src/components/Checkbox/Checkbox.tsx +1 -50
  98. package/src/components/ConfirmDialog/ConfirmDialog.tsx +3 -0
  99. package/src/components/DetailPanel/DetailPanel.tsx +9 -45
  100. package/src/components/Dialog/Dialog.native.tsx +296 -0
  101. package/src/components/Dialog/Dialog.stories.tsx +71 -0
  102. package/src/components/Dialog/Dialog.tsx +41 -130
  103. package/src/components/Dialog/native.css.ts +78 -0
  104. package/src/components/Dialog/parts.css.ts +101 -0
  105. package/src/components/Dialog/parts.tsx +61 -0
  106. package/src/components/Dialog/styles.css.ts +2 -89
  107. package/src/components/Drawer/Drawer.tsx +20 -49
  108. package/src/components/Drawer/styles.css.ts +4 -0
  109. package/src/components/Grid/styles.css.ts +2 -0
  110. package/src/components/Icon/Icon.native.tsx +54 -0
  111. package/src/components/Icon/Icon.tsx +19 -430
  112. package/src/components/Icon/geometry.ts +456 -0
  113. package/src/components/Icon/types.ts +74 -0
  114. package/src/components/Input/Input.tsx +8 -0
  115. package/src/components/Input/styles.css.ts +2 -0
  116. package/src/components/LinkButton/styles.css.ts +2 -0
  117. package/src/components/Meter/styles.css.ts +2 -0
  118. package/src/components/PageShell/styles.css.ts +4 -0
  119. package/src/components/RadioGroup/RadioGroup.meta.ts +1 -1
  120. package/src/components/RadioGroup/RadioGroup.native.tsx +48 -0
  121. package/src/components/RadioGroup/RadioGroup.stories.tsx +64 -1
  122. package/src/components/RadioGroup/RadioGroup.tsx +8 -83
  123. package/src/components/RadioGroup/group.tsx +85 -0
  124. package/src/components/RadioGroup/styles.css.ts +13 -0
  125. package/src/components/ScrollArea/styles.css.ts +2 -0
  126. package/src/components/Spinner/Spinner.native.tsx +37 -0
  127. package/src/components/Spinner/Spinner.tsx +6 -15
  128. package/src/components/Spinner/Spinner.types.ts +6 -0
  129. package/src/components/Spinner/ring.css.ts +45 -0
  130. package/src/components/Spinner/styles.css.ts +2 -32
  131. package/src/components/StatusIcon/StatusIcon.tsx +13 -2
  132. package/src/components/StatusIcon/styles.css.ts +5 -0
  133. package/src/components/Switch/Switch.native.tsx +45 -0
  134. package/src/components/Switch/Switch.tsx +22 -44
  135. package/src/components/Switch/Switch.types.ts +18 -0
  136. package/src/components/TableOfContents/styles.css.ts +2 -0
  137. package/src/components/Textarea/styles.css.ts +2 -0
  138. package/src/components/ThemeProvider/ThemeProvider.native.tsx +70 -10
  139. package/src/components/Toggle/Toggle.tsx +2 -0
  140. package/src/components/Toggle/styles.css.ts +14 -2
  141. package/src/components/VirtualTable/styles.css.ts +12 -0
  142. package/src/index.native.ts +28 -1
  143. package/src/native-guard/globals.d.ts +10 -0
  144. package/src/native-guard/react-dom.d.ts +3 -0
  145. package/src/shared/accessibilityFocus.native.ts +23 -0
  146. package/src/shared/portal/OverlayModal.native.tsx +51 -0
  147. package/src/shared/portal/PortalHost.native.tsx +102 -0
  148. package/src/shared/portal/hostRegistry.ts +76 -0
  149. package/src/shared/portal/themeScope.native.ts +18 -0
  150. package/src/shared/safeArea.native.ts +22 -0
  151. package/src/shared/useOverlayOpenState.ts +128 -0
  152. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts +0 -10
  153. package/dist/components/ThemeProvider/ThemeProvider.native.d.ts.map +0 -1
  154. package/dist/index.native.d.ts +0 -33
  155. package/dist/index.native.d.ts.map +0 -1
  156. package/dist/platform.native.d.ts +0 -2
  157. package/dist/platform.native.d.ts.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import {useState} from 'react'
2
2
  import type {Meta, StoryObj} from '@storybook/react'
3
3
  import {expect, fn} from 'storybook/test'
4
- import {css, html} from 'react-strict-dom'
4
+ import {html} from 'react-strict-dom'
5
5
  import {RadioGroup} from './RadioGroup'
6
6
 
7
7
  const meta: Meta = {
@@ -81,3 +81,66 @@ export const Controlled: Story = {
81
81
  await expect(radios[0]).not.toBeChecked()
82
82
  },
83
83
  }
84
+
85
+ const onPick = fn()
86
+
87
+ export const OneChangePerPick: Story = {
88
+ render: () => (
89
+ <RadioGroup.Root defaultValue="apple" onValueChange={onPick} aria-label="Fruit">
90
+ <RadioGroup.Item value="apple">Apple</RadioGroup.Item>
91
+ <RadioGroup.Item value="banana">Banana</RadioGroup.Item>
92
+ <RadioGroup.Item value="cherry">Cherry</RadioGroup.Item>
93
+ </RadioGroup.Root>
94
+ ),
95
+ play: async ({canvas, userEvent}) => {
96
+ onPick.mockClear()
97
+ // Clicking the visible label text…
98
+ await userEvent.click(canvas.getByText('Banana'))
99
+ await expect(onPick).toHaveBeenCalledTimes(1)
100
+ await expect(onPick).toHaveBeenLastCalledWith('banana')
101
+ // …and clicking the radio itself each report the pick exactly once.
102
+ await userEvent.click(canvas.getByRole('radio', {name: 'Cherry'}))
103
+ await expect(onPick).toHaveBeenCalledTimes(2)
104
+ await expect(onPick).toHaveBeenLastCalledWith('cherry')
105
+ // Re-picking the selected item is not a change.
106
+ await userEvent.click(canvas.getByText('Cherry'))
107
+ await expect(onPick).toHaveBeenCalledTimes(2)
108
+ },
109
+ }
110
+
111
+ export const SubmitsWithForm: Story = {
112
+ render: () => (
113
+ <html.form aria-label="Order">
114
+ <RadioGroup.Root name="fruit" defaultValue="apple" aria-label="Fruit">
115
+ <RadioGroup.Item value="apple">Apple</RadioGroup.Item>
116
+ <RadioGroup.Item value="banana">Banana</RadioGroup.Item>
117
+ </RadioGroup.Root>
118
+ </html.form>
119
+ ),
120
+ play: async ({canvas, userEvent}) => {
121
+ const form = canvas.getByRole('form', {name: 'Order'}) as HTMLFormElement
122
+ await expect(new FormData(form).get('fruit')).toBe('apple')
123
+ await userEvent.click(canvas.getByText('Banana'))
124
+ await expect(new FormData(form).get('fruit')).toBe('banana')
125
+ },
126
+ }
127
+
128
+ export const ArrowKeysMoveSelection: Story = {
129
+ render: () => (
130
+ <RadioGroup.Root defaultValue="sm" aria-label="Size">
131
+ <RadioGroup.Item value="sm">Small</RadioGroup.Item>
132
+ <RadioGroup.Item value="md">Medium</RadioGroup.Item>
133
+ <RadioGroup.Item value="lg">Large</RadioGroup.Item>
134
+ </RadioGroup.Root>
135
+ ),
136
+ play: async ({canvas, userEvent}) => {
137
+ const group = canvas.getByRole('radiogroup', {name: 'Size'})
138
+ await expect(group).toBeInTheDocument()
139
+ // Arrow keys move the selection within the group, as native radios do.
140
+ const [sm, md] = canvas.getAllByRole('radio')
141
+ sm.focus()
142
+ await userEvent.keyboard('{ArrowDown}')
143
+ await expect(md).toBeChecked()
144
+ await expect(md).toHaveFocus()
145
+ },
146
+ }
@@ -1,78 +1,8 @@
1
- import {type ReactNode, createContext, useContext, useCallback} from 'react'
1
+ import type {ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
- import {useControllableValue} from '../../hooks/useControllableValue'
4
3
  import {styles} from './styles.css'
5
- import {useFieldGroupLabelling} from '../Field/FieldContext'
6
4
  import {visuallyHidden} from '../../styles/visually-hidden.css'
7
-
8
- // --- Context ---
9
-
10
- interface RadioGroupContextValue {
11
- value: string
12
- onSelect: (value: string) => void
13
- disabled: boolean
14
- }
15
-
16
- const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
17
-
18
- function useRadioGroup() {
19
- const ctx = useContext(RadioGroupContext)
20
- if (!ctx) {
21
- throw new Error('RadioGroup.Item must be used within RadioGroup.Root')
22
- }
23
- return ctx
24
- }
25
-
26
- // --- Root ---
27
-
28
- interface RootProps {
29
- value?: string
30
- defaultValue?: string
31
- onValueChange?: (value: string) => void
32
- orientation?: 'horizontal' | 'vertical'
33
- disabled?: boolean
34
- /** Accessible name when the group is not inside a Field.Root (inside one,
35
- * the Field.Label names it). */
36
- 'aria-label'?: string
37
- /** id of the element that names the group, when not inside a Field.Root. */
38
- 'aria-labelledby'?: string
39
- children: ReactNode
40
- }
41
-
42
- function Root({
43
- value: controlledValue,
44
- defaultValue = '',
45
- onValueChange,
46
- orientation = 'vertical',
47
- disabled = false,
48
- children,
49
- ...labelling
50
- }: RootProps) {
51
- const a11y = useFieldGroupLabelling(labelling)
52
- const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
53
-
54
- const onSelect = useCallback(
55
- (itemValue: string) => {
56
- if (!disabled) {
57
- setValue(itemValue)
58
- }
59
- },
60
- [disabled, setValue],
61
- )
62
-
63
- return (
64
- <RadioGroupContext.Provider value={{value, onSelect, disabled}}>
65
- <html.div
66
- role="radiogroup"
67
- aria-orientation={orientation}
68
- {...a11y}
69
- style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
70
- >
71
- {children}
72
- </html.div>
73
- </RadioGroupContext.Provider>
74
- )
75
- }
5
+ import {Root, useRadioGroup} from './group'
76
6
 
77
7
  // --- Item ---
78
8
 
@@ -83,20 +13,18 @@ interface ItemProps {
83
13
  }
84
14
 
85
15
  function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
86
- const {value: groupValue, onSelect, disabled: groupDisabled} = useRadioGroup()
16
+ const {value: groupValue, name, onSelect, disabled: groupDisabled} = useRadioGroup()
87
17
  const isChecked = groupValue === value
88
18
  const isDisabled = groupDisabled || itemDisabled
89
19
 
90
- const handleClick = () => {
91
- if (!isDisabled) {
92
- onSelect(value)
93
- }
94
- }
95
-
20
+ // The native radio is the only source of a pick: a click on the label
21
+ // activates it, and onChange fires once, only when the selection changes.
22
+ // (A label onClick as well would report every pick twice.)
96
23
  return (
97
- <html.label style={[styles.item, isDisabled && styles.itemDisabled]} onClick={handleClick}>
24
+ <html.label style={[styles.item, isDisabled && styles.itemDisabled]}>
98
25
  <html.input
99
26
  type="radio"
27
+ name={name}
100
28
  value={value}
101
29
  checked={isChecked}
102
30
  disabled={isDisabled}
@@ -114,9 +42,6 @@ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
114
42
  )
115
43
  }
116
44
 
117
- // inside a Field.Root, the label names this group (see Field's `group`)
118
- Root.isFieldGroup = true as const
119
-
120
45
  export const RadioGroup = {
121
46
  Root,
122
47
  Item,
@@ -0,0 +1,85 @@
1
+ import {type ReactNode, createContext, useContext, useCallback, useId} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import {styles} from './styles.css'
5
+ import {useFieldGroupLabelling} from '../Field/FieldContext'
6
+
7
+ // --- Context ---
8
+
9
+ interface RadioGroupContextValue {
10
+ value: string
11
+ name: string
12
+ onSelect: (value: string) => void
13
+ disabled: boolean
14
+ }
15
+
16
+ const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
17
+
18
+ export function useRadioGroup() {
19
+ const ctx = useContext(RadioGroupContext)
20
+ if (!ctx) {
21
+ throw new Error('RadioGroup.Item must be used within RadioGroup.Root')
22
+ }
23
+ return ctx
24
+ }
25
+
26
+ // --- Root ---
27
+
28
+ interface RootProps {
29
+ value?: string
30
+ defaultValue?: string
31
+ onValueChange?: (value: string) => void
32
+ orientation?: 'horizontal' | 'vertical'
33
+ disabled?: boolean
34
+ /** Form field name: every item's radio carries it, so the selection is
35
+ * submitted with a native form. Generated when omitted (the radios still
36
+ * form one group). */
37
+ name?: string
38
+ /** Accessible name when the group is not inside a Field.Root (inside one,
39
+ * the Field.Label names it). */
40
+ 'aria-label'?: string
41
+ /** id of the element that names the group, when not inside a Field.Root. */
42
+ 'aria-labelledby'?: string
43
+ children: ReactNode
44
+ }
45
+
46
+ export function Root({
47
+ value: controlledValue,
48
+ defaultValue = '',
49
+ onValueChange,
50
+ orientation = 'vertical',
51
+ disabled = false,
52
+ name: nameProp,
53
+ children,
54
+ ...labelling
55
+ }: RootProps) {
56
+ const a11y = useFieldGroupLabelling(labelling)
57
+ const generatedName = useId()
58
+ const name = nameProp ?? generatedName
59
+ const [value, setValue] = useControllableValue(controlledValue, defaultValue, onValueChange)
60
+
61
+ const onSelect = useCallback(
62
+ (itemValue: string) => {
63
+ if (!disabled) {
64
+ setValue(itemValue)
65
+ }
66
+ },
67
+ [disabled, setValue],
68
+ )
69
+
70
+ return (
71
+ <RadioGroupContext.Provider value={{value, name, onSelect, disabled}}>
72
+ <html.div
73
+ role="radiogroup"
74
+ aria-orientation={orientation}
75
+ {...a11y}
76
+ style={[styles.root, orientation === 'horizontal' && styles.rootHorizontal]}
77
+ >
78
+ {children}
79
+ </html.div>
80
+ </RadioGroupContext.Provider>
81
+ )
82
+ }
83
+
84
+ // inside a Field.Root, the label names this group (see Field's `group`)
85
+ Root.isFieldGroup = true as const
@@ -24,6 +24,14 @@ export const styles = css.create({
24
24
  color: colors.text,
25
25
  lineHeight: typography.lineHeight,
26
26
  },
27
+ // Native draws the item as a button: no button chrome, a flex row.
28
+ nativeItem: {
29
+ display: 'flex',
30
+ flexDirection: 'row',
31
+ borderWidth: 0,
32
+ backgroundColor: 'transparent',
33
+ padding: 0,
34
+ },
27
35
  itemDisabled: {
28
36
  opacity: 0.5,
29
37
  cursor: 'not-allowed',
@@ -63,4 +71,9 @@ export const styles = css.create({
63
71
  input: {
64
72
  opacity: 0,
65
73
  },
74
+ // RSD-native only honors display:'flex' (not 'inline-flex'). Layered on
75
+ // native only.
76
+ nativeFlex: {
77
+ display: 'flex',
78
+ },
66
79
  })
@@ -12,6 +12,8 @@ export const styles = css.create({
12
12
  },
13
13
  viewport: {
14
14
  width: '100%',
15
+ // Native has no border-box reset (the web's global-reset.css); declare it.
16
+ boxSizing: 'border-box',
15
17
  height: '100%',
16
18
  overflowX: 'auto',
17
19
  overflowY: 'auto',
@@ -0,0 +1,37 @@
1
+ import {useEffect} from 'react'
2
+ import Animated, {
3
+ Easing,
4
+ cancelAnimation,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withRepeat,
8
+ withTiming,
9
+ } from 'react-native-reanimated'
10
+ import {html} from 'react-strict-dom'
11
+ import {ring, TURN_MS} from './ring.css'
12
+ import {visuallyHidden} from '../../styles/visually-hidden.css'
13
+ import type {SpinnerProps} from './Spinner.types'
14
+
15
+ export type {SpinnerProps, SpinnerSize} from './Spinner.types'
16
+
17
+ /**
18
+ * Native Spinner: the web's ring, turned by Reanimated on the UI thread (RSD
19
+ * has no CSS animations on native), so a busy JS thread does not stall it.
20
+ */
21
+ export function Spinner({size = 'md', label = 'Loading'}: SpinnerProps) {
22
+ const turn = useSharedValue(0)
23
+ useEffect(() => {
24
+ turn.value = withRepeat(withTiming(360, {duration: TURN_MS, easing: Easing.linear}), -1)
25
+ return () => cancelAnimation(turn)
26
+ }, [turn])
27
+ const turning = useAnimatedStyle(() => ({transform: [{rotate: `${turn.value}deg`}]}))
28
+
29
+ return (
30
+ <html.div role="status" style={[ring.root, ring.nativeRoot]}>
31
+ <Animated.View style={turning}>
32
+ <html.div style={[ring.ring, ring[size]]} aria-hidden />
33
+ </Animated.View>
34
+ <html.span style={[visuallyHidden.base, ring.srOnly]}>{label}</html.span>
35
+ </html.div>
36
+ )
37
+ }
@@ -1,25 +1,16 @@
1
1
  import {html} from 'react-strict-dom'
2
+ import {ring} from './ring.css'
2
3
  import {styles} from './styles.css'
3
4
  import {visuallyHidden} from '../../styles/visually-hidden.css'
5
+ import type {SpinnerProps} from './Spinner.types'
4
6
 
5
- export type SpinnerSize = 'sm' | 'md' | 'lg'
6
-
7
- interface SpinnerProps {
8
- size?: SpinnerSize
9
- label?: string
10
- }
11
-
12
- const sizeMap = {
13
- sm: styles.sm,
14
- md: styles.md,
15
- lg: styles.lg,
16
- } as const
7
+ export type {SpinnerProps, SpinnerSize} from './Spinner.types'
17
8
 
18
9
  export function Spinner({size = 'md', label = 'Loading'}: SpinnerProps) {
19
10
  return (
20
- <html.div role="status" style={styles.root}>
21
- <html.div style={[styles.spinner, sizeMap[size]]} aria-hidden />
22
- <html.span style={[visuallyHidden.base, styles.srOnly]}>{label}</html.span>
11
+ <html.div role="status" style={ring.root}>
12
+ <html.div style={[ring.ring, ring[size], styles.spinning]} aria-hidden />
13
+ <html.span style={[visuallyHidden.base, ring.srOnly]}>{label}</html.span>
23
14
  </html.div>
24
15
  )
25
16
  }
@@ -0,0 +1,6 @@
1
+ export type SpinnerSize = 'sm' | 'md' | 'lg'
2
+
3
+ export interface SpinnerProps {
4
+ size?: SpinnerSize
5
+ label?: string
6
+ }
@@ -0,0 +1,45 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
4
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
5
+
6
+ /** The ring both platforms draw; each turns it its own way. */
7
+ export const ring = css.create({
8
+ root: {
9
+ display: 'inline-flex',
10
+ alignItems: 'center',
11
+ justifyContent: 'center',
12
+ },
13
+ // RSD-native only honors display:'flex' (not 'inline-flex').
14
+ nativeRoot: {
15
+ display: 'flex',
16
+ },
17
+ ring: {
18
+ borderRadius: '50%',
19
+ borderStyle: 'solid',
20
+ borderColor: colors.border,
21
+ borderTopColor: colors.accent,
22
+ },
23
+ sm: {
24
+ width: sizes.spinnerSm,
25
+ height: sizes.spinnerSm,
26
+ borderWidth: borders.strong,
27
+ },
28
+ md: {
29
+ width: sizes.spinnerMd,
30
+ height: sizes.spinnerMd,
31
+ borderWidth: borders.strong,
32
+ },
33
+ lg: {
34
+ width: sizes.spinnerLg,
35
+ height: sizes.spinnerLg,
36
+ borderWidth: borders.accent,
37
+ },
38
+ // Layered on visuallyHidden.base.
39
+ srOnly: {
40
+ clip: 'rect(0, 0, 0, 0)',
41
+ },
42
+ })
43
+
44
+ /** One turn, in ms: the web animation's 0.6s. */
45
+ export const TURN_MS = 600
@@ -1,8 +1,5 @@
1
1
  import {css} from 'react-strict-dom'
2
- import {colors} from '@duro-app/tokens/tokens/colors.css'
3
- import {sizes} from '@duro-app/tokens/tokens/sizes.css'
4
2
  import {easing} from '@duro-app/tokens/tokens/motion.css'
5
- import {borders} from '@duro-app/tokens/tokens/borders.css'
6
3
 
7
4
  const spin = css.keyframes({
8
5
  '0%': {transform: 'rotate(0deg)'},
@@ -10,38 +7,11 @@ const spin = css.keyframes({
10
7
  })
11
8
 
12
9
  export const styles = css.create({
13
- root: {
14
- display: 'inline-flex',
15
- alignItems: 'center',
16
- justifyContent: 'center',
17
- },
18
- spinner: {
19
- borderRadius: '50%',
20
- borderStyle: 'solid',
21
- borderColor: colors.border,
22
- borderTopColor: colors.accent,
10
+ // Web turns the ring with a CSS animation (one turn per TURN_MS).
11
+ spinning: {
23
12
  animationName: spin,
24
13
  animationDuration: '0.6s',
25
14
  animationTimingFunction: easing.linear,
26
15
  animationIterationCount: 'infinite',
27
16
  },
28
- sm: {
29
- width: sizes.spinnerSm,
30
- height: sizes.spinnerSm,
31
- borderWidth: borders.strong,
32
- },
33
- md: {
34
- width: sizes.spinnerMd,
35
- height: sizes.spinnerMd,
36
- borderWidth: borders.strong,
37
- },
38
- lg: {
39
- width: sizes.spinnerLg,
40
- height: sizes.spinnerLg,
41
- borderWidth: borders.accent,
42
- },
43
- // Layered on visuallyHidden.base.
44
- srOnly: {
45
- clip: 'rect(0, 0, 0, 0)',
46
- },
47
17
  })
@@ -1,6 +1,8 @@
1
1
  import {html} from 'react-strict-dom'
2
2
  import {Icon} from '../Icon'
3
3
  import type {IconName, IconSize} from '../Icon'
4
+ import type {ColorToken} from '@duro-app/tokens/keys'
5
+ import {isNative} from '../../platform'
4
6
  import {styles} from './styles.css'
5
7
 
6
8
  export type StatusIconName = Extract<
@@ -9,6 +11,15 @@ export type StatusIconName = Extract<
9
11
  >
10
12
  export type StatusIconVariant = 'error' | 'success' | 'warning' | 'info' | 'muted'
11
13
 
14
+ // On native an Icon cannot inherit the variant's colour, so it is passed.
15
+ const iconColor = {
16
+ error: 'error',
17
+ success: 'success',
18
+ warning: 'warning',
19
+ info: 'info',
20
+ muted: 'textMuted',
21
+ } as const satisfies Record<StatusIconVariant, ColorToken>
22
+
12
23
  interface StatusIconProps {
13
24
  name: StatusIconName
14
25
  size?: IconSize
@@ -17,8 +28,8 @@ interface StatusIconProps {
17
28
 
18
29
  export function StatusIcon({name, size = 'xxl', variant = 'muted'}: StatusIconProps) {
19
30
  return (
20
- <html.div style={[styles.root, styles[variant]]}>
21
- <Icon name={name} size={size} />
31
+ <html.div style={[styles.root, isNative && styles.nativeFlex, styles[variant]]}>
32
+ <Icon name={name} size={size} color={isNative ? iconColor[variant] : undefined} />
22
33
  </html.div>
23
34
  )
24
35
  }
@@ -24,4 +24,9 @@ export const styles = css.create({
24
24
  muted: {
25
25
  color: colors.textMuted,
26
26
  },
27
+ // RSD-native only honors display:'flex' (not 'inline-flex'). Layered on
28
+ // native only.
29
+ nativeFlex: {
30
+ display: 'flex',
31
+ },
27
32
  })
@@ -0,0 +1,45 @@
1
+ import {useCallback} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {useControllableValue} from '../../hooks/useControllableValue'
4
+ import {styles} from './styles.css'
5
+ import type {SwitchProps} from './Switch.types'
6
+
7
+ export type {SwitchProps} from './Switch.types'
8
+
9
+ /**
10
+ * Native Switch: RSD has no `<input type="checkbox">` on native, so the track
11
+ * button carries the switch role. `name` and `value` have no form to submit
12
+ * to here.
13
+ */
14
+ export function Switch({
15
+ checked: controlledChecked,
16
+ defaultChecked = false,
17
+ onCheckedChange,
18
+ disabled = false,
19
+ children,
20
+ }: SwitchProps) {
21
+ const [checked, setChecked] = useControllableValue(
22
+ controlledChecked,
23
+ defaultChecked,
24
+ onCheckedChange,
25
+ )
26
+ const handleClick = useCallback(() => {
27
+ if (!disabled) setChecked(!checked)
28
+ }, [disabled, checked, setChecked])
29
+
30
+ return (
31
+ <html.label style={[styles.root, styles.nativeFlex, disabled && styles.rootDisabled]}>
32
+ <html.button
33
+ type="button"
34
+ role="switch"
35
+ aria-checked={checked}
36
+ disabled={disabled}
37
+ onClick={handleClick}
38
+ style={[styles.track, checked ? styles.trackChecked : styles.trackUnchecked]}
39
+ >
40
+ <html.span style={[styles.thumb, checked && styles.thumbChecked]} />
41
+ </html.button>
42
+ {children && <html.span>{children}</html.span>}
43
+ </html.label>
44
+ )
45
+ }
@@ -1,26 +1,11 @@
1
- import {type ReactNode, useCallback, useRef} from 'react'
1
+ import {useCallback, useRef} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
- import {isNative} from '../../platform'
4
3
  import {useControllableValue} from '../../hooks/useControllableValue'
5
4
  import {styles} from './styles.css'
6
5
  import {visuallyHidden} from '../../styles/visually-hidden.css'
6
+ import type {SwitchProps} from './Switch.types'
7
7
 
8
- interface SwitchProps {
9
- /** Controlled checked state. */
10
- checked?: boolean
11
- /** Initial checked state (uncontrolled). */
12
- defaultChecked?: boolean
13
- /** Callback fired when the checked state changes. */
14
- onCheckedChange?: (checked: boolean) => void
15
- /** Prevents user interaction. */
16
- disabled?: boolean
17
- /** Form field name for the hidden input. */
18
- name?: string
19
- /** Value submitted when checked. */
20
- value?: string
21
- /** Label content rendered beside the switch. */
22
- children?: ReactNode
23
- }
8
+ export type {SwitchProps} from './Switch.types'
24
9
 
25
10
  export function Switch({
26
11
  checked: controlledChecked,
@@ -48,35 +33,28 @@ export function Switch({
48
33
  }, [disabled, checked, setChecked])
49
34
 
50
35
  return (
51
- <html.label
52
- style={[styles.root, isNative && styles.nativeFlex, disabled && styles.rootDisabled]}
53
- >
54
- {!isNative && (
55
- // Web: hidden real checkbox carries a11y + form submission. RSD has no
56
- // <input type=checkbox> on native, so it's omitted there and the
57
- // button below carries the switch role instead.
58
- <html.input
59
- ref={inputRef}
60
- type="checkbox"
61
- role="switch"
62
- name={name}
63
- value={value}
64
- checked={controlledChecked !== undefined ? controlledChecked : undefined}
65
- defaultChecked={controlledChecked === undefined ? defaultChecked : undefined}
66
- disabled={disabled}
67
- aria-checked={checked}
68
- onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
69
- onCheckedChange?.(e.target.checked)
70
- }}
71
- style={[visuallyHidden.base, styles.input]}
72
- />
73
- )}
36
+ <html.label style={[styles.root, disabled && styles.rootDisabled]}>
37
+ {/* The hidden real checkbox carries a11y and form submission (native:
38
+ Switch.native.tsx, whose button carries the switch role). */}
39
+ <html.input
40
+ ref={inputRef}
41
+ type="checkbox"
42
+ role="switch"
43
+ name={name}
44
+ value={value}
45
+ checked={controlledChecked !== undefined ? controlledChecked : undefined}
46
+ defaultChecked={controlledChecked === undefined ? defaultChecked : undefined}
47
+ disabled={disabled}
48
+ aria-checked={checked}
49
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
50
+ onCheckedChange?.(e.target.checked)
51
+ }}
52
+ style={[visuallyHidden.base, styles.input]}
53
+ />
74
54
  <html.button
75
55
  type="button"
76
56
  tabIndex={-1}
77
- aria-hidden={isNative ? undefined : true}
78
- role={isNative ? 'switch' : undefined}
79
- aria-checked={isNative ? checked : undefined}
57
+ aria-hidden
80
58
  disabled={disabled}
81
59
  onClick={handleClick}
82
60
  style={[styles.track, checked ? styles.trackChecked : styles.trackUnchecked]}