@duro-app/ui 0.34.8 → 0.34.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.34.8",
3
+ "version": "0.34.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  }
55
55
  },
56
56
  "dependencies": {
57
- "@duro-app/tokens": "^0.34.8"
57
+ "@duro-app/tokens": "^0.34.10"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@babel/preset-typescript": "^7.28.0",
@@ -13,6 +13,8 @@ interface ButtonProps {
13
13
  disabled?: boolean
14
14
  type?: 'button' | 'submit'
15
15
  onClick?: () => void
16
+ /** Accessible name override — e.g. for buttons whose visible text isn't unique. */
17
+ 'aria-label'?: string
16
18
  children: ReactNode
17
19
  }
18
20
 
@@ -28,6 +30,7 @@ export function Button({
28
30
  disabled = false,
29
31
  type = 'button',
30
32
  onClick,
33
+ 'aria-label': ariaLabel,
31
34
  children,
32
35
  }: ButtonProps) {
33
36
  return (
@@ -35,6 +38,7 @@ export function Button({
35
38
  type={type}
36
39
  disabled={disabled}
37
40
  onClick={onClick}
41
+ aria-label={ariaLabel}
38
42
  style={[
39
43
  styles.base,
40
44
  isNative && styles.nativeFlex,
@@ -29,6 +29,7 @@ export function Checkbox({
29
29
  const [internalChecked, setInternalChecked] = useState(defaultChecked)
30
30
  const isChecked = isControlled ? controlledChecked : internalChecked
31
31
 
32
+ // Web: the real <input type=checkbox> drives toggling, a11y and form submit.
32
33
  const handleChange = useCallback(
33
34
  (e: React.ChangeEvent<HTMLInputElement>) => {
34
35
  if (!isControlled) {
@@ -39,10 +40,56 @@ export function Checkbox({
39
40
  [isControlled, onChange],
40
41
  )
41
42
 
43
+ // Native: RSD only makes <button> pressable and has no <input type=checkbox>,
44
+ // so the whole row is a button and we synthesize the change payload that
45
+ // consumers read (e.target.checked).
46
+ const handlePress = useCallback(() => {
47
+ if (disabled) return
48
+ const next = !isChecked
49
+ if (!isControlled) {
50
+ setInternalChecked(next)
51
+ }
52
+ onChange?.({
53
+ target: {checked: next, name, value},
54
+ } as unknown as React.ChangeEvent<HTMLInputElement>)
55
+ }, [disabled, isChecked, isControlled, onChange, name, value])
56
+
57
+ if (isNative) {
58
+ return (
59
+ <html.button
60
+ type="button"
61
+ onClick={handlePress}
62
+ disabled={disabled}
63
+ role="checkbox"
64
+ aria-checked={isChecked}
65
+ aria-label={!children ? ariaLabel : undefined}
66
+ style={[
67
+ styles.root,
68
+ styles.nativeFlex,
69
+ styles.nativeButton,
70
+ disabled && styles.rootDisabled,
71
+ ]}
72
+ >
73
+ <html.div
74
+ style={[
75
+ styles.box,
76
+ styles.nativeFlex,
77
+ isChecked ? styles.boxChecked : styles.boxUnchecked,
78
+ ]}
79
+ >
80
+ {/* CSS-border checkmark (rotated right+bottom borders) — no SVG, no
81
+ react-native-svg dep, so nothing is added to the web bundle. */}
82
+ <html.div
83
+ style={[styles.checkmark, isChecked ? styles.checkmarkVisible : styles.checkmarkHidden]}
84
+ />
85
+ </html.div>
86
+ {children && <html.span style={styles.labelText}>{children}</html.span>}
87
+ </html.button>
88
+ )
89
+ }
90
+
42
91
  return (
43
- <html.label
44
- style={[styles.root, isNative && styles.nativeFlex, disabled && styles.rootDisabled]}
45
- >
92
+ <html.label style={[styles.root, disabled && styles.rootDisabled]}>
46
93
  <html.input
47
94
  type="checkbox"
48
95
  name={name}
@@ -55,11 +102,7 @@ export function Checkbox({
55
102
  style={styles.input}
56
103
  />
57
104
  <html.span
58
- style={[
59
- styles.box,
60
- isNative && styles.nativeFlex,
61
- isChecked ? styles.boxChecked : styles.boxUnchecked,
62
- ]}
105
+ style={[styles.box, isChecked ? styles.boxChecked : styles.boxUnchecked]}
63
106
  aria-hidden
64
107
  >
65
108
  <svg
@@ -42,10 +42,39 @@ export const styles = css.create({
42
42
  backgroundColor: colors.accent,
43
43
  borderColor: colors.accent,
44
44
  },
45
- check: {
46
- width: 12,
47
- height: 12,
48
- color: colors.accentContrast,
45
+ // Native-only checkmark: a small box showing just its right + bottom border,
46
+ // rotated 45° into a tick. Pure CSS (RSD translates the transform string to
47
+ // an RN transform), so no SVG / react-native-svg weight reaches web.
48
+ checkmark: {
49
+ width: 5,
50
+ height: 9,
51
+ borderRightWidth: 2,
52
+ borderRightStyle: 'solid',
53
+ borderRightColor: colors.accentContrast,
54
+ borderBottomWidth: 2,
55
+ borderBottomStyle: 'solid',
56
+ borderBottomColor: colors.accentContrast,
57
+ transform: 'rotate(45deg)',
58
+ marginTop: -2,
59
+ },
60
+ checkmarkVisible: {
61
+ opacity: 1,
62
+ },
63
+ checkmarkHidden: {
64
+ opacity: 0,
65
+ },
66
+ // Native label text — RN <Text> doesn't inherit color/size from the button
67
+ // ancestor the way web inherits from <label>, so set it explicitly.
68
+ labelText: {
69
+ fontSize: typography.fontSizeSm,
70
+ color: colors.text,
71
+ },
72
+ // Strip the default button chrome RSD gives <button> on native (border/bg/
73
+ // padding mimic the browser default) so the row matches the web checkbox.
74
+ nativeButton: {
75
+ borderWidth: 0,
76
+ backgroundColor: 'transparent',
77
+ padding: 0,
49
78
  },
50
79
  input: {
51
80
  position: 'absolute',
@@ -24,7 +24,13 @@ export function useComboboxRoot({
24
24
  defaultValue ?? null,
25
25
  onValueChange,
26
26
  )
27
- const [internalInputValue, setInternalInputValue] = useState('')
27
+ // Seed the visible text from the initially-selected value's label, so a
28
+ // defaultValue/value shows its label on mount — and so the reconcile effect
29
+ // below doesn't see "value set but input empty" and wipe the selection.
30
+ const [internalInputValue, setInternalInputValue] = useState<string>(() => {
31
+ const initial = controlledValue ?? defaultValue
32
+ return initial ? (initialLabels?.[initial] ?? '') : ''
33
+ })
28
34
  const inputValue = controlledInputValue !== undefined ? controlledInputValue : internalInputValue
29
35
  const setInputValue = useCallback(
30
36
  (v: string) => {
@@ -201,7 +207,11 @@ export function useComboboxRoot({
201
207
  // - inputValue equals the selected label: nothing to do.
202
208
  useEffect(() => {
203
209
  if (open) return
204
- if (value === null) return
210
+ // Treat an empty value (null OR '') as "no selection" — nothing to
211
+ // reconcile. Without this, a controlled value="" (e.g. a table column
212
+ // filter) fires setValue(null) every render → inline onValueChange →
213
+ // parent state change → re-render → infinite "Maximum update depth" loop.
214
+ if (!value) return
205
215
  if (inputValue === '') {
206
216
  setValue(null)
207
217
  return
@@ -50,25 +50,32 @@ export function Switch({
50
50
  <html.label
51
51
  style={[styles.root, isNative && styles.nativeFlex, disabled && styles.rootDisabled]}
52
52
  >
53
- <html.input
54
- ref={inputRef}
55
- type="checkbox"
56
- role="switch"
57
- name={name}
58
- value={value}
59
- checked={controlledChecked !== undefined ? controlledChecked : undefined}
60
- defaultChecked={controlledChecked === undefined ? defaultChecked : undefined}
61
- disabled={disabled}
62
- aria-checked={checked}
63
- onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
64
- onCheckedChange?.(e.target.checked)
65
- }}
66
- style={styles.input}
67
- />
53
+ {!isNative && (
54
+ // Web: hidden real checkbox carries a11y + form submission. RSD has no
55
+ // <input type=checkbox> on native, so it's omitted there and the
56
+ // button below carries the switch role instead.
57
+ <html.input
58
+ ref={inputRef}
59
+ type="checkbox"
60
+ role="switch"
61
+ name={name}
62
+ value={value}
63
+ checked={controlledChecked !== undefined ? controlledChecked : undefined}
64
+ defaultChecked={controlledChecked === undefined ? defaultChecked : undefined}
65
+ disabled={disabled}
66
+ aria-checked={checked}
67
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
68
+ onCheckedChange?.(e.target.checked)
69
+ }}
70
+ style={styles.input}
71
+ />
72
+ )}
68
73
  <html.button
69
74
  type="button"
70
75
  tabIndex={-1}
71
- aria-hidden
76
+ aria-hidden={isNative ? undefined : true}
77
+ role={isNative ? 'switch' : undefined}
78
+ aria-checked={isNative ? checked : undefined}
72
79
  disabled={disabled}
73
80
  onClick={handleClick}
74
81
  style={[styles.track, checked ? styles.trackChecked : styles.trackUnchecked]}
@@ -1128,7 +1128,9 @@ export const SlotPropsOnRoot: Story = {
1128
1128
  play: async ({canvas}) => {
1129
1129
  await expect(canvas.getByRole('table')).toBeInTheDocument()
1130
1130
  // Note no Table.HeaderCell label — children are plain strings, so the
1131
- // label fallback uses extractText. Verify the rendered text appears.
1132
- await expect(canvas.getByText('Name')).toBeInTheDocument()
1131
+ // label fallback uses extractText. "Name" appears both in the header and
1132
+ // the SortChip option list, so assert at least one rendered (not getByText,
1133
+ // which throws on multiple matches).
1134
+ await expect(canvas.getAllByText('Name').length).toBeGreaterThan(0)
1133
1135
  },
1134
1136
  }