@duro-app/ui 5.29.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.
- package/dist/{Table-Cw1B14R3.js → Table-ypIvytAS.js} +677 -664
- package/dist/Table-ypIvytAS.js.map +1 -0
- package/dist/components/Menu/Menu.d.ts +3 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/Menu.stories.d.ts +1 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/MenuContext.d.ts +2 -1
- package/dist/components/Menu/MenuContext.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +4 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +3 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +5 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/SelectContext.d.ts +2 -0
- package/dist/components/Select/SelectContext.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +5 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Video/Video.d.ts +22 -0
- package/dist/components/Video/Video.d.ts.map +1 -0
- package/dist/components/Video/Video.meta.d.ts +3 -0
- package/dist/components/Video/Video.meta.d.ts.map +1 -0
- package/dist/components/Video/Video.stories.d.ts +8 -0
- package/dist/components/Video/Video.stories.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1609 -1574
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.native.tsx +18 -3
- package/src/components/Menu/Menu.stories.tsx +42 -0
- package/src/components/Menu/Menu.tsx +13 -5
- package/src/components/Menu/MenuContext.ts +2 -1
- package/src/components/Menu/styles.css.ts +4 -0
- package/src/components/Menu/useMenuRoot.ts +15 -6
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +14 -3
- package/src/components/Select/Select.stories.tsx +31 -0
- package/src/components/Select/Select.tsx +23 -4
- package/src/components/Select/SelectContext.ts +2 -0
- package/src/components/Select/styles.css.ts +6 -0
- package/src/components/Select/useSelectRoot.ts +1 -0
- package/src/components/Video/Video.meta.ts +15 -0
- package/src/components/Video/Video.stories.tsx +28 -0
- package/src/components/Video/Video.tsx +31 -0
- package/src/index.ts +1 -0
- package/dist/Table-Cw1B14R3.js.map +0 -1
package/dist/table.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { M as F, q as gt, R as it, H as kt, t as pt, v as bt, w as ft, x as mt, b as ht, g as yt, r as St, y as Tt } from "./Table-
|
|
1
|
+
import { M as F, q as gt, R as it, H as kt, t as pt, v as bt, w as ft, x as mt, b as ht, g as yt, r as St, y as Tt } from "./Table-ypIvytAS.js";
|
|
2
2
|
import { jsx as u, jsxs as q } from "react/jsx-runtime";
|
|
3
3
|
import { c as H, m as p, d as b } from "./compiler-runtime-BuugS4D0.js";
|
|
4
4
|
import { flexRender as E, useReactTable as rt, getPaginationRowModel as Ct, getFilteredRowModel as vt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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 action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars; size="small" matches small Toggles and Buttons (a small ghost trigger is iconButtonSm). The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog. On React Native the popup is placed against the trigger in a modal overlay; a press outside or the Android back button closes it, and an item runs its onClick after the menu has gone (so an item can open a Dialog). Menu.LinkItem takes onNavigate(href, event) on both platforms (ADR-0002); on native, without it or without preventDefault, the link opens with the system. Native has no arrow keys: each item is its own screen-reader stop.',
|
|
5
|
+
'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars; size="small" matches small Toggles and Buttons (a small ghost trigger is iconButtonSm). The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog. On React Native the popup is placed against the trigger in a modal overlay; a press outside or the Android back button closes it, and an item runs its onClick after the menu has gone (so an item can open a Dialog). Menu.LinkItem takes onNavigate(href, event) on both platforms (ADR-0002); on native, without it or without preventDefault, the link opens with the system. Native has no arrow keys: each item is its own screen-reader stop. A Menu.Item can be disabled: it stays listed and is announced as disabled, but the highlight and the arrow keys skip it and a press or Enter does nothing.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Context menu or "more actions" dropdown',
|
|
8
8
|
'Navigation links in a dropdown',
|
|
@@ -197,15 +197,30 @@ const layout = StyleSheet.create({scroll: {maxHeight: SIZES_PX.listMaxH, flexGro
|
|
|
197
197
|
|
|
198
198
|
interface ItemProps {
|
|
199
199
|
onClick?: () => void
|
|
200
|
+
/** Shown but not available: inert to a press and announced as disabled. */
|
|
201
|
+
disabled?: boolean
|
|
200
202
|
children: ReactNode
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
function Item({onClick, children}: ItemProps) {
|
|
205
|
+
function Item({onClick, disabled = false, children}: ItemProps) {
|
|
204
206
|
const {closeThen} = useMenu()
|
|
205
207
|
return (
|
|
206
|
-
<Pressable
|
|
208
|
+
<Pressable
|
|
209
|
+
accessibilityRole="menuitem"
|
|
210
|
+
accessibilityState={{disabled}}
|
|
211
|
+
disabled={disabled}
|
|
212
|
+
onPress={() => closeThen(onClick)}
|
|
213
|
+
>
|
|
207
214
|
{({pressed}) => (
|
|
208
|
-
<html.div
|
|
215
|
+
<html.div
|
|
216
|
+
style={[
|
|
217
|
+
styles.item,
|
|
218
|
+
pressed && nativeStyles.itemPressed,
|
|
219
|
+
disabled && styles.itemDisabled,
|
|
220
|
+
]}
|
|
221
|
+
>
|
|
222
|
+
{children}
|
|
223
|
+
</html.div>
|
|
209
224
|
)}
|
|
210
225
|
</Pressable>
|
|
211
226
|
)
|
|
@@ -539,3 +539,45 @@ export const TouchTargets: Story = {
|
|
|
539
539
|
const touchStyles = css.create({
|
|
540
540
|
row: {display: 'flex', gap: spacing.md, alignItems: 'center'},
|
|
541
541
|
})
|
|
542
|
+
|
|
543
|
+
// A disabled item stays listed and is announced as disabled, but the highlight and the arrow keys
|
|
544
|
+
// skip it and a press does nothing (a Sync menu whose Pull is blocked while a push is running).
|
|
545
|
+
const onPull = fn()
|
|
546
|
+
const onPush = fn()
|
|
547
|
+
export const DisabledItem: Story = {
|
|
548
|
+
render: () => (
|
|
549
|
+
<Menu.Root>
|
|
550
|
+
<Menu.Trigger>Sync</Menu.Trigger>
|
|
551
|
+
<Menu.Popup>
|
|
552
|
+
<Menu.Item disabled onClick={onPull}>
|
|
553
|
+
Pull
|
|
554
|
+
</Menu.Item>
|
|
555
|
+
<Menu.Item onClick={onPush}>Push</Menu.Item>
|
|
556
|
+
<Menu.Item>Fetch</Menu.Item>
|
|
557
|
+
</Menu.Popup>
|
|
558
|
+
</Menu.Root>
|
|
559
|
+
),
|
|
560
|
+
play: async ({canvas, userEvent}) => {
|
|
561
|
+
onPull.mockClear()
|
|
562
|
+
onPush.mockClear()
|
|
563
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Sync'}))
|
|
564
|
+
const menu = await page().findByRole('menu', {name: 'Sync'})
|
|
565
|
+
const pull = page().getByRole('menuitem', {name: 'Pull'})
|
|
566
|
+
const push = page().getByRole('menuitem', {name: 'Push'})
|
|
567
|
+
await expect(pull).toHaveAttribute('aria-disabled', 'true')
|
|
568
|
+
// The highlight opens on the first item that can be chosen.
|
|
569
|
+
await waitFor(() => expect(menu).toHaveAttribute('aria-activedescendant', push.id))
|
|
570
|
+
await userEvent.click(pull)
|
|
571
|
+
await expect(onPull).not.toHaveBeenCalled()
|
|
572
|
+
await expect(menu).toBeInTheDocument()
|
|
573
|
+
// Up from Push wraps to Fetch, never landing on Pull.
|
|
574
|
+
await userEvent.keyboard('{ArrowUp}')
|
|
575
|
+
await expect(menu).toHaveAttribute(
|
|
576
|
+
'aria-activedescendant',
|
|
577
|
+
page().getByRole('menuitem', {name: 'Fetch'}).id,
|
|
578
|
+
)
|
|
579
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
580
|
+
await userEvent.keyboard('{Enter}')
|
|
581
|
+
await expect(onPush).toHaveBeenCalledOnce()
|
|
582
|
+
},
|
|
583
|
+
}
|
|
@@ -186,10 +186,12 @@ function Popup({children, align = 'start'}: PopupProps) {
|
|
|
186
186
|
// --- Item ---
|
|
187
187
|
interface ItemProps {
|
|
188
188
|
onClick?: () => void
|
|
189
|
+
/** Shown but not available: skipped by the arrow keys, inert to a press, announced as disabled. */
|
|
190
|
+
disabled?: boolean
|
|
189
191
|
children: ReactNode
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
function Item({onClick, children}: ItemProps) {
|
|
194
|
+
function Item({onClick, disabled = false, children}: ItemProps) {
|
|
193
195
|
const {close, highlightedId, setHighlightedId, registerItem} = useMenu()
|
|
194
196
|
const id = useId()
|
|
195
197
|
const ref = useRef<HTMLDivElement>(null)
|
|
@@ -198,10 +200,11 @@ function Item({onClick, children}: ItemProps) {
|
|
|
198
200
|
useEffect(() => {
|
|
199
201
|
const el = ref.current
|
|
200
202
|
if (!el) return
|
|
201
|
-
return registerItem(id, el)
|
|
202
|
-
}, [id, registerItem])
|
|
203
|
+
return registerItem(id, el, disabled)
|
|
204
|
+
}, [disabled, id, registerItem])
|
|
203
205
|
|
|
204
206
|
const handleClick = () => {
|
|
207
|
+
if (disabled) return
|
|
205
208
|
onClick?.()
|
|
206
209
|
close()
|
|
207
210
|
}
|
|
@@ -211,9 +214,14 @@ function Item({onClick, children}: ItemProps) {
|
|
|
211
214
|
ref={ref}
|
|
212
215
|
id={id}
|
|
213
216
|
role="menuitem"
|
|
217
|
+
aria-disabled={disabled || undefined}
|
|
214
218
|
onClick={handleClick}
|
|
215
|
-
onPointerEnter={() => setHighlightedId(id)}
|
|
216
|
-
style={[
|
|
219
|
+
onPointerEnter={disabled ? undefined : () => setHighlightedId(id)}
|
|
220
|
+
style={[
|
|
221
|
+
styles.item,
|
|
222
|
+
isHighlighted && styles.itemHighlighted,
|
|
223
|
+
disabled && styles.itemDisabled,
|
|
224
|
+
]}
|
|
217
225
|
>
|
|
218
226
|
{children}
|
|
219
227
|
</html.div>
|
|
@@ -9,7 +9,8 @@ export interface MenuContextValue {
|
|
|
9
9
|
triggerId: string
|
|
10
10
|
highlightedId: string | null
|
|
11
11
|
setHighlightedId: (id: string | null) => void
|
|
12
|
-
|
|
12
|
+
/** A disabled item stays listed but the keys skip it and Enter does nothing. */
|
|
13
|
+
registerItem: (id: string, element: HTMLElement, disabled?: boolean) => () => void
|
|
13
14
|
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
14
15
|
popupRef: React.RefObject<HTMLDivElement | null>
|
|
15
16
|
}
|
|
@@ -111,6 +111,10 @@ export const styles = css.create({
|
|
|
111
111
|
transitionProperty: 'background-color',
|
|
112
112
|
transitionDuration: duration.fast,
|
|
113
113
|
},
|
|
114
|
+
itemDisabled: {
|
|
115
|
+
color: colors.textDisabled,
|
|
116
|
+
cursor: 'not-allowed',
|
|
117
|
+
},
|
|
114
118
|
// The fill is the highlight; the transparent outline is what forced-colors
|
|
115
119
|
// mode paints, where fills are dropped.
|
|
116
120
|
itemHighlighted: {
|
|
@@ -29,6 +29,12 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
29
29
|
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
30
30
|
const itemsRef = useRef(new Map<string, HTMLElement>())
|
|
31
31
|
const orderRef = useRef<string[]>([])
|
|
32
|
+
const disabledRef = useRef(new Set<string>())
|
|
33
|
+
// What the keys move through: the items in document order, without the disabled ones.
|
|
34
|
+
const enabledOrder = useCallback(
|
|
35
|
+
() => orderRef.current.filter((id) => !disabledRef.current.has(id)),
|
|
36
|
+
[],
|
|
37
|
+
)
|
|
32
38
|
// Closes and leaves focus where it is (a press elsewhere put it there).
|
|
33
39
|
const dismiss = useCallback(() => {
|
|
34
40
|
if (!openRef.current) return
|
|
@@ -54,13 +60,13 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
54
60
|
useEffect(() => {
|
|
55
61
|
if (open && !wasOpenRef.current) {
|
|
56
62
|
popupRef.current?.focus({preventScroll: true})
|
|
57
|
-
const first =
|
|
63
|
+
const first = enabledOrder()[0]
|
|
58
64
|
if (first) setHighlightedId(first)
|
|
59
65
|
} else if (!open && wasOpenRef.current) {
|
|
60
66
|
setHighlightedId(null)
|
|
61
67
|
}
|
|
62
68
|
wasOpenRef.current = open
|
|
63
|
-
}, [open])
|
|
69
|
+
}, [open, enabledOrder])
|
|
64
70
|
|
|
65
71
|
// Keep the highlighted item in view when the popup scrolls.
|
|
66
72
|
useEffect(() => {
|
|
@@ -69,8 +75,10 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
69
75
|
el?.scrollIntoView?.({block: 'nearest'})
|
|
70
76
|
}, [highlightedId])
|
|
71
77
|
|
|
72
|
-
const registerItem = useCallback((id: string, element: HTMLElement) => {
|
|
78
|
+
const registerItem = useCallback((id: string, element: HTMLElement, disabled = false) => {
|
|
73
79
|
itemsRef.current.set(id, element)
|
|
80
|
+
if (disabled) disabledRef.current.add(id)
|
|
81
|
+
else disabledRef.current.delete(id)
|
|
74
82
|
const map = itemsRef.current
|
|
75
83
|
const ids = [...map.keys()]
|
|
76
84
|
ids.sort((a, b) => {
|
|
@@ -82,6 +90,7 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
82
90
|
orderRef.current = ids
|
|
83
91
|
return () => {
|
|
84
92
|
itemsRef.current.delete(id)
|
|
93
|
+
disabledRef.current.delete(id)
|
|
85
94
|
orderRef.current = orderRef.current.filter((i) => i !== id)
|
|
86
95
|
}
|
|
87
96
|
}, [])
|
|
@@ -104,7 +113,7 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
104
113
|
close()
|
|
105
114
|
return
|
|
106
115
|
}
|
|
107
|
-
const order =
|
|
116
|
+
const order = enabledOrder()
|
|
108
117
|
if (order.length === 0) return
|
|
109
118
|
|
|
110
119
|
switch (e.key) {
|
|
@@ -138,7 +147,7 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
138
147
|
case ' ': {
|
|
139
148
|
e.preventDefault()
|
|
140
149
|
const current = highlightedRef.current
|
|
141
|
-
if (current) itemsRef.current.get(current)?.click()
|
|
150
|
+
if (current && !disabledRef.current.has(current)) itemsRef.current.get(current)?.click()
|
|
142
151
|
break
|
|
143
152
|
}
|
|
144
153
|
}
|
|
@@ -146,7 +155,7 @@ export function useMenuRoot(options: MenuRootOptions) {
|
|
|
146
155
|
|
|
147
156
|
popup.addEventListener('keydown', handleKeyDown)
|
|
148
157
|
return () => popup.removeEventListener('keydown', handleKeyDown)
|
|
149
|
-
}, [open, close])
|
|
158
|
+
}, [open, close, enabledOrder])
|
|
150
159
|
|
|
151
160
|
// A press outside the trigger and the popup closes the menu, and leaves
|
|
152
161
|
// focus where the press put it.
|
|
@@ -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={[
|
|
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 {
|
|
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={[
|
|
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>
|
|
@@ -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.
|
|
@@ -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'
|