@duro-app/ui 5.30.0 → 5.31.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 (31) hide show
  1. package/dist/components/Select/Select.d.ts +3 -1
  2. package/dist/components/Select/Select.d.ts.map +1 -1
  3. package/dist/components/Select/Select.stories.d.ts +5 -0
  4. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  5. package/dist/components/Select/SelectContext.d.ts +2 -0
  6. package/dist/components/Select/SelectContext.d.ts.map +1 -1
  7. package/dist/components/Select/styles.css.d.ts +5 -0
  8. package/dist/components/Select/styles.css.d.ts.map +1 -1
  9. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  10. package/dist/components/Video/Video.d.ts +22 -0
  11. package/dist/components/Video/Video.d.ts.map +1 -0
  12. package/dist/components/Video/Video.meta.d.ts +3 -0
  13. package/dist/components/Video/Video.meta.d.ts.map +1 -0
  14. package/dist/components/Video/Video.stories.d.ts +8 -0
  15. package/dist/components/Video/Video.stories.d.ts.map +1 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1608 -1573
  19. package/dist/index.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/components/Select/Select.meta.ts +1 -1
  22. package/src/components/Select/Select.native.tsx +14 -3
  23. package/src/components/Select/Select.stories.tsx +31 -0
  24. package/src/components/Select/Select.tsx +23 -4
  25. package/src/components/Select/SelectContext.ts +2 -0
  26. package/src/components/Select/styles.css.ts +6 -0
  27. package/src/components/Select/useSelectRoot.ts +1 -0
  28. package/src/components/Video/Video.meta.ts +15 -0
  29. package/src/components/Video/Video.stories.tsx +28 -0
  30. package/src/components/Video/Video.tsx +31 -0
  31. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.30.0",
3
+ "version": "5.31.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -92,7 +92,7 @@
92
92
  "dependencies": {
93
93
  "@tanstack/react-virtual": "^3.14.13",
94
94
  "qrcode": "^1.5.4",
95
- "@duro-app/tokens": "^5.30.0"
95
+ "@duro-app/tokens": "^5.31.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@babel/preset-typescript": "^7.28.0",
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop. An Item can be disabled (shown, skipped by the keys, inert to a press, announced as disabled), and onOpenChange on Root reports the list opening and closing, so work such as checking which options are available starts only once someone is choosing.',
5
+ 'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop. An Item can be disabled (shown, skipped by the keys, inert to a press, announced as disabled), and onOpenChange on Root reports the list opening and closing, so work such as checking which options are available starts only once someone is choosing. Root takes disabled for the whole control: the trigger is a disabled button (announced so, on native too) and does not open.',
6
6
  whenToUse: [
7
7
  'User must pick one option from a predefined list',
8
8
  'Form field for country, category, role selection',
@@ -28,6 +28,7 @@ const SNAP_POINTS = ['medium', 'large'] as const
28
28
  interface NativeSelectContextValue {
29
29
  value: string | null
30
30
  open: boolean
31
+ disabled: boolean
31
32
  setOpen: (open: boolean) => void
32
33
  choose: (value: string) => void
33
34
  labels: Record<string, string>
@@ -59,6 +60,8 @@ interface RootProps {
59
60
  initialLabels?: Record<string, string>
60
61
  /** Called when the sheet opens or closes. */
61
62
  onOpenChange?: (open: boolean) => void
63
+ /** The whole control is disabled: the trigger does not open the sheet. */
64
+ disabled?: boolean
62
65
  children: ReactNode
63
66
  }
64
67
 
@@ -74,6 +77,7 @@ function Root({
74
77
  onValueChange,
75
78
  initialLabels,
76
79
  onOpenChange,
80
+ disabled = false,
77
81
  children,
78
82
  }: RootProps) {
79
83
  const state = useSelectState({defaultValue, value, onValueChange, initialLabels, onOpenChange})
@@ -90,6 +94,7 @@ function Root({
90
94
  const ctx: NativeSelectContextValue = {
91
95
  value: state.value,
92
96
  open,
97
+ disabled,
93
98
  setOpen,
94
99
  choose: (v) => {
95
100
  setValue(v)
@@ -123,19 +128,25 @@ function Trigger({
123
128
  'aria-label'?: string
124
129
  size?: ControlSize
125
130
  }) {
126
- const {open, setOpen, triggerRef} = useSelect()
131
+ const {open, disabled, setOpen, triggerRef} = useSelect()
127
132
  const hint = useTooltipHint()
128
133
  return (
129
134
  <Pressable
130
135
  ref={triggerRef as RefObject<never>}
136
+ disabled={disabled}
131
137
  onPress={() => setOpen(true)}
132
138
  accessibilityRole="combobox"
133
139
  accessibilityLabel={ariaLabel}
134
140
  accessibilityHint={hint}
135
- accessibilityState={{expanded: open}}
141
+ accessibilityState={{expanded: open, disabled}}
136
142
  >
137
143
  <html.div
138
- style={[styles.trigger, nativeStyles.nativeFlex, size === 'small' && styles.triggerSmall]}
144
+ style={[
145
+ styles.trigger,
146
+ nativeStyles.nativeFlex,
147
+ size === 'small' && styles.triggerSmall,
148
+ disabled && styles.triggerDisabled,
149
+ ]}
139
150
  >
140
151
  {children}
141
152
  </html.div>
@@ -349,3 +349,34 @@ export const DisabledOptionAndOpenChange: Story = {
349
349
  await waitFor(() => expect(onAgentOpenChange).toHaveBeenLastCalledWith(false))
350
350
  },
351
351
  }
352
+
353
+ const onDisabledOpenChange = fn()
354
+
355
+ /**
356
+ * The whole control can be `disabled` (a choice that does not apply right now: a write
357
+ * that is not allowed): the trigger is a disabled button, announced so, and does not open.
358
+ */
359
+ export const DisabledControl: Story = {
360
+ render: () => (
361
+ <Select.Root defaultValue="private" disabled onOpenChange={onDisabledOpenChange}>
362
+ <Select.Trigger aria-label="Visibility">
363
+ <Select.Value />
364
+ <Select.Icon />
365
+ </Select.Trigger>
366
+ <Select.Popup label="Visibility">
367
+ <Select.Item value="private">Private</Select.Item>
368
+ <Select.Item value="public">Public</Select.Item>
369
+ </Select.Popup>
370
+ </Select.Root>
371
+ ),
372
+ play: async ({canvas, userEvent}) => {
373
+ onDisabledOpenChange.mockClear()
374
+ const trigger = canvas.getByRole('combobox', {name: 'Visibility'})
375
+ await expect(trigger).toBeDisabled()
376
+ await expect(trigger).toHaveTextContent(/Private/)
377
+ await userEvent.click(trigger)
378
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false')
379
+ await expect(screen.queryByRole('listbox')).toBeNull()
380
+ await expect(onDisabledOpenChange).not.toHaveBeenCalled()
381
+ },
382
+ }
@@ -1,4 +1,13 @@
1
- import {Children, type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
1
+ import {
2
+ Children,
3
+ type ReactNode,
4
+ useRef,
5
+ useId,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useState,
10
+ } from 'react'
2
11
  import {createPortal} from 'react-dom'
3
12
  import {html} from 'react-strict-dom'
4
13
  import {SPACING_PX} from '@duro-app/tokens/keys'
@@ -23,6 +32,8 @@ interface RootProps {
23
32
  initialLabels?: Record<string, string>
24
33
  /** Called when the list opens or closes (to start work only once someone is choosing). */
25
34
  onOpenChange?: (open: boolean) => void
35
+ /** The whole control is disabled: the trigger is a disabled button and does not open. */
36
+ disabled?: boolean
26
37
  children: ReactNode
27
38
  }
28
39
 
@@ -33,15 +44,17 @@ function Root({
33
44
  onValueChange,
34
45
  initialLabels,
35
46
  onOpenChange,
47
+ disabled = false,
36
48
  children,
37
49
  }: RootProps) {
38
- const {ctx, rootRef} = useSelectRoot({
50
+ const {ctx: rootCtx, rootRef} = useSelectRoot({
39
51
  defaultValue,
40
52
  value,
41
53
  onValueChange,
42
54
  initialLabels,
43
55
  onOpenChange,
44
56
  })
57
+ const ctx = useMemo(() => ({...rootCtx, disabled}), [rootCtx, disabled])
45
58
 
46
59
  return (
47
60
  <SelectContext.Provider value={ctx}>
@@ -64,7 +77,7 @@ function Trigger({
64
77
  /** `small` matches a small Toggle or Button beside it. Default: `default`. */
65
78
  size?: ControlSize
66
79
  }) {
67
- const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
80
+ const {open, disabled, toggle, listboxId, highlightedId, triggerRef} = useSelect()
68
81
  const localRef = useRef<HTMLButtonElement>(null)
69
82
  const grouped = useGroupedControl<HTMLButtonElement>()
70
83
  // Inside a <Field.Root>, adopt its controlId so <Field.Label for={controlId}>
@@ -88,6 +101,7 @@ function Trigger({
88
101
  id={fieldCtx?.controlId}
89
102
  type="button"
90
103
  role={'combobox' as 'listbox'}
104
+ disabled={disabled}
91
105
  onClick={toggle}
92
106
  aria-label={ariaLabel}
93
107
  aria-expanded={open}
@@ -96,7 +110,12 @@ function Trigger({
96
110
  aria-activedescendant={highlightedId ?? undefined}
97
111
  aria-describedby={describedBy || undefined}
98
112
  aria-invalid={fieldCtx?.invalid || undefined}
99
- style={[styles.trigger, size === 'small' && styles.triggerSmall, grouped.style]}
113
+ style={[
114
+ styles.trigger,
115
+ size === 'small' && styles.triggerSmall,
116
+ disabled && styles.triggerDisabled,
117
+ grouped.style,
118
+ ]}
100
119
  >
101
120
  {children}
102
121
  </html.button>
@@ -2,6 +2,8 @@ import {createContext, useContext} from 'react'
2
2
 
3
3
  export interface SelectContextValue {
4
4
  open: boolean
5
+ /** The whole control is disabled: the trigger does not open. */
6
+ disabled: boolean
5
7
  toggle: () => void
6
8
  close: () => void
7
9
  value: string | null
@@ -37,6 +37,12 @@ export const styles = css.create({
37
37
  transitionProperty: 'border-color',
38
38
  transitionDuration: duration.fast,
39
39
  },
40
+ // The whole control disabled: Button's disabled look, and no hover accent.
41
+ triggerDisabled: {
42
+ opacity: 0.5,
43
+ cursor: 'not-allowed',
44
+ borderColor: colors.border,
45
+ },
40
46
  // size="small": Toggle sizeSmall's values, as longhands so they win over the
41
47
  // trigger's own longhands. Every small control is at least controlSm tall,
42
48
  // whatever it holds, and the touch target under a coarse pointer.
@@ -157,6 +157,7 @@ export function useSelectRoot(options: SelectStateOptions) {
157
157
  const ctx: SelectContextValue = useMemo(
158
158
  () => ({
159
159
  open,
160
+ disabled: false,
160
161
  toggle,
161
162
  close,
162
163
  value,
@@ -0,0 +1,15 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ "A video file with the browser's own controls: a <video> as wide as its container (never wider, at its own ratio) with one source of the given MIME type, named for assistive tech by label. preload is metadata by default (duration and first frame, not the whole file). Web only: on React Native, host your own player (expo-video) behind a .native.tsx leaf.",
6
+ whenToUse: [
7
+ 'A video file the person opened: a screen recording, a clip in a file viewer',
8
+ 'A video an app turned into an object URL from bytes it fetched',
9
+ ],
10
+ whenNotToUse: [
11
+ 'A decorative or autoplaying background — Duro has none',
12
+ 'An image — use an img or Prose',
13
+ ],
14
+ example: `<Video src={objectUrl} type="video/mp4" label={fileName} />`,
15
+ }
@@ -0,0 +1,28 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {Video} from './Video'
4
+
5
+ const meta: Meta<typeof Video> = {
6
+ title: 'Components/Video',
7
+ component: Video,
8
+ }
9
+ export default meta
10
+ type Story = StoryObj<typeof Video>
11
+
12
+ // A tiny inline WebM is not needed to check the element: the source and its type are what a test reads.
13
+ const SRC = 'https://example.invalid/clip.mp4'
14
+
15
+ /** A video file shown with the browser's own controls, named for assistive tech by `label`. */
16
+ export const Default: Story = {
17
+ args: {src: SRC, type: 'video/mp4', label: 'Screen recording of the failing test'},
18
+ play: async ({canvasElement}) => {
19
+ const video = canvasElement.querySelector('video')
20
+ await expect(video).not.toBeNull()
21
+ await expect(video).toHaveAttribute('aria-label', 'Screen recording of the failing test')
22
+ await expect(video).toHaveAttribute('controls')
23
+ await expect(video).toHaveAttribute('preload', 'metadata')
24
+ const source = video!.querySelector('source')
25
+ await expect(source).toHaveAttribute('src', SRC)
26
+ await expect(source).toHaveAttribute('type', 'video/mp4')
27
+ },
28
+ }
@@ -0,0 +1,31 @@
1
+ import type {Ref} from 'react'
2
+
3
+ interface VideoProps {
4
+ /** The video's URL: a file URL, or an object URL an app made from bytes it fetched. */
5
+ src: string
6
+ /** Its MIME type (`video/mp4`, `video/webm`), so the browser picks a playable source. */
7
+ type: string
8
+ /** The accessible name: what the video shows (its file name, a caption). */
9
+ label: string
10
+ /** The browser's own play, seek and volume controls. Default: true. */
11
+ controls?: boolean
12
+ /** How much to fetch before play: metadata (duration, first frame) by default. */
13
+ preload?: 'none' | 'metadata' | 'auto'
14
+ /** The video element, e.g. to pause it from elsewhere. */
15
+ ref?: Ref<HTMLVideoElement>
16
+ }
17
+
18
+ // Relative sizes only: the video is as wide as its container, never wider, at its own ratio.
19
+ const VIDEO_STYLE = {display: 'block', maxWidth: '100%', height: 'auto'} as const
20
+
21
+ /**
22
+ * Video — a video file with the browser's controls. react-strict-dom has no video element, so
23
+ * this is a raw <video> (as Icon is a raw <svg>). Web only: on native, host your own player.
24
+ */
25
+ export function Video({src, type, label, controls = true, preload = 'metadata', ref}: VideoProps) {
26
+ return (
27
+ <video ref={ref} controls={controls} preload={preload} aria-label={label} style={VIDEO_STYLE}>
28
+ <source src={src} type={type} />
29
+ </video>
30
+ )
31
+ }
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export {Meter, type MeterProps, type MeterTone} from './components/Meter/Meter'
76
76
  export {PageNav} from './components/PageNav/PageNav'
77
77
  export {Panel} from './components/Panel/Panel'
78
78
  export {Prose} from './components/Prose/Prose'
79
+ export {Video} from './components/Video/Video'
79
80
  export {RadioGroup} from './components/RadioGroup/RadioGroup'
80
81
  export {ScrollArea} from './components/ScrollArea/ScrollArea'
81
82
  export {Combobox} from './components/Combobox/Combobox'