@duro-app/ui 5.0.0 → 5.2.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-C70zdSkM.js +1419 -0
- package/dist/Table-C70zdSkM.js.map +1 -0
- package/dist/components/Button/Button.d.ts +11 -2
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/Button.stories.d.ts +1 -0
- package/dist/components/Button/Button.stories.d.ts.map +1 -1
- package/dist/components/Card/Card.stories.d.ts.map +1 -1
- package/dist/components/Combobox/Combobox.d.ts +1 -1
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/styles.css.d.ts +0 -64
- package/dist/components/Combobox/styles.css.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.d.ts +11 -3
- package/dist/components/Dialog/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
- package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
- package/dist/components/Dialog/styles.css.d.ts +1 -0
- package/dist/components/Dialog/styles.css.d.ts.map +1 -1
- package/dist/components/DragDrop/styles.css.d.ts +10 -2
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Grid/Grid.d.ts +11 -2
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +2 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/styles.css.d.ts +2 -2
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Input/Input.d.ts +6 -1
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Input/Input.meta.d.ts.map +1 -1
- package/dist/components/Input/Input.stories.d.ts +1 -0
- package/dist/components/Input/Input.stories.d.ts.map +1 -1
- package/dist/components/Input/styles.css.d.ts +18 -0
- package/dist/components/Input/styles.css.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.d.ts +6 -1
- package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
- package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
- package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
- package/dist/components/Listbox/Listbox.d.ts +75 -0
- package/dist/components/Listbox/Listbox.d.ts.map +1 -0
- package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
- package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
- package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
- package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
- package/dist/components/Listbox/styles.css.d.ts +75 -0
- package/dist/components/Listbox/styles.css.d.ts.map +1 -0
- package/dist/components/Menu/Menu.d.ts +18 -5
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/MenuContext.d.ts +3 -0
- package/dist/components/Menu/MenuContext.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +24 -15
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/useMenuRoot.d.ts +14 -1
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/StatusIcon/StatusIcon.stories.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +1 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/TextLink/TextLink.d.ts +24 -0
- package/dist/components/TextLink/TextLink.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
- package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
- package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
- package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
- package/dist/components/TextLink/styles.css.d.ts +28 -0
- package/dist/components/TextLink/styles.css.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
- package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
- package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
- package/dist/docs/FlexGrow.stories.d.ts +7 -0
- package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2986 -2821
- package/dist/index.js.map +1 -1
- package/dist/index.native.d.ts +1 -0
- package/dist/index.native.d.ts.map +1 -1
- package/dist/shared/length.d.ts +14 -9
- package/dist/shared/length.d.ts.map +1 -1
- package/dist/shared/length.typecheck.d.ts +3 -0
- package/dist/shared/length.typecheck.d.ts.map +1 -1
- package/dist/styles/visually-hidden.css.d.ts +6 -0
- package/dist/styles/visually-hidden.css.d.ts.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Button/Button.stories.tsx +41 -0
- package/src/components/Button/Button.tsx +16 -1
- package/src/components/Card/Card.stories.tsx +2 -1
- package/src/components/Combobox/Combobox.tsx +25 -79
- package/src/components/Combobox/styles.css.ts +0 -74
- package/src/components/Dialog/Dialog.meta.ts +1 -1
- package/src/components/Dialog/Dialog.stories.tsx +109 -3
- package/src/components/Dialog/Dialog.tsx +63 -9
- package/src/components/Dialog/styles.css.ts +2 -0
- package/src/components/DragDrop/styles.css.ts +16 -3
- package/src/components/Grid/Grid.meta.ts +1 -1
- package/src/components/Grid/Grid.stories.tsx +53 -1
- package/src/components/Grid/Grid.tsx +23 -11
- package/src/components/Grid/styles.css.ts +3 -2
- package/src/components/Input/Input.meta.ts +10 -3
- package/src/components/Input/Input.stories.tsx +52 -2
- package/src/components/Input/Input.tsx +12 -3
- package/src/components/Input/styles.css.ts +29 -0
- package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
- package/src/components/InputGroup/InputGroup.tsx +20 -2
- package/src/components/Listbox/Listbox.meta.ts +52 -0
- package/src/components/Listbox/Listbox.stories.tsx +170 -0
- package/src/components/Listbox/Listbox.tsx +278 -0
- package/src/components/Listbox/styles.css.ts +89 -0
- package/src/components/Menu/Menu.meta.ts +2 -1
- package/src/components/Menu/Menu.stories.tsx +289 -8
- package/src/components/Menu/Menu.tsx +100 -29
- package/src/components/Menu/MenuContext.ts +3 -0
- package/src/components/Menu/styles.css.ts +31 -15
- package/src/components/Menu/useMenuRoot.ts +105 -48
- package/src/components/StatusIcon/StatusIcon.stories.tsx +2 -2
- package/src/components/Table/Table.stories.tsx +39 -0
- package/src/components/TextLink/TextLink.meta.ts +33 -0
- package/src/components/TextLink/TextLink.stories.tsx +63 -0
- package/src/components/TextLink/TextLink.tsx +46 -0
- package/src/components/TextLink/styles.css.ts +58 -0
- package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
- package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
- package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
- package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
- package/src/docs/FlexGrow.stories.tsx +83 -0
- package/src/index.native.ts +1 -0
- package/src/index.ts +5 -0
- package/src/shared/length.ts +29 -10
- package/src/shared/length.typecheck.tsx +9 -0
- package/src/styles/visually-hidden.css.ts +8 -0
- package/dist/Table-BKN_d-mp.js +0 -1192
- package/dist/Table-BKN_d-mp.js.map +0 -1
|
@@ -14,9 +14,13 @@ import {html} from 'react-strict-dom'
|
|
|
14
14
|
import {DURATION_MS, type DurationToken} from '@duro-app/tokens/keys'
|
|
15
15
|
import {styles} from './styles.css'
|
|
16
16
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
17
|
+
import {devWarnOnce} from '../../shared/devWarnOnce'
|
|
17
18
|
|
|
18
19
|
// --- Types ---
|
|
19
20
|
|
|
21
|
+
const FOCUSABLE =
|
|
22
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
23
|
+
|
|
20
24
|
export type DialogSize = 'sm' | 'md' | 'lg'
|
|
21
25
|
|
|
22
26
|
const closeDurationMap = {
|
|
@@ -41,6 +45,8 @@ interface DialogContextValue {
|
|
|
41
45
|
titleId: string
|
|
42
46
|
descriptionId: string
|
|
43
47
|
popupRef: RefObject<HTMLDivElement | null>
|
|
48
|
+
/** Dialog.Close parts currently rendered (for the closeOnEscape dev warning). */
|
|
49
|
+
registerClose: () => () => void
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
const DialogContext = createContext<DialogContextValue | null>(null)
|
|
@@ -61,8 +67,16 @@ interface RootProps {
|
|
|
61
67
|
defaultOpen?: boolean
|
|
62
68
|
/** Called when dialog open state changes */
|
|
63
69
|
onOpenChange?: (open: boolean) => void
|
|
64
|
-
/** Whether clicking backdrop closes the dialog. Default: true */
|
|
70
|
+
/** Whether clicking the backdrop closes the dialog. Default: true */
|
|
65
71
|
dismissable?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* Whether Escape closes the dialog. Default: true, also when `dismissable`
|
|
74
|
+
* is false. Turning it off leaves the keyboard no way out but a
|
|
75
|
+
* Dialog.Close, so render one.
|
|
76
|
+
*/
|
|
77
|
+
closeOnEscape?: boolean
|
|
78
|
+
/** Element to focus on open. Default: the first focusable element, else the dialog itself. */
|
|
79
|
+
initialFocus?: RefObject<HTMLElement | null>
|
|
66
80
|
/** Motion token for the close animation. Default: 'fast' (150ms) */
|
|
67
81
|
closeAnimationDuration?: DurationToken
|
|
68
82
|
}
|
|
@@ -73,6 +87,8 @@ function Root({
|
|
|
73
87
|
defaultOpen = false,
|
|
74
88
|
onOpenChange,
|
|
75
89
|
dismissable = true,
|
|
90
|
+
closeOnEscape = true,
|
|
91
|
+
initialFocus,
|
|
76
92
|
closeAnimationDuration = 'fast',
|
|
77
93
|
}: RootProps) {
|
|
78
94
|
const isControlled = controlledOpen !== undefined
|
|
@@ -120,20 +136,55 @@ function Root({
|
|
|
120
136
|
}
|
|
121
137
|
}, [])
|
|
122
138
|
|
|
123
|
-
// Handle Escape key
|
|
139
|
+
// Handle Escape key. A layer inside the dialog (a Menu, a Select) that
|
|
140
|
+
// already handled it marks the event, and the dialog stays open.
|
|
124
141
|
useEffect(() => {
|
|
125
142
|
if (!isOpen && !closing) return
|
|
126
|
-
if (!
|
|
143
|
+
if (!closeOnEscape) return
|
|
127
144
|
|
|
128
145
|
function onKeyDown(e: KeyboardEvent) {
|
|
129
|
-
if (e.key
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
146
|
+
if (e.key !== 'Escape' || e.defaultPrevented) return
|
|
147
|
+
e.stopPropagation()
|
|
148
|
+
requestClose()
|
|
133
149
|
}
|
|
134
150
|
document.addEventListener('keydown', onKeyDown)
|
|
135
151
|
return () => document.removeEventListener('keydown', onKeyDown)
|
|
136
|
-
}, [isOpen, closing,
|
|
152
|
+
}, [isOpen, closing, closeOnEscape, requestClose])
|
|
153
|
+
|
|
154
|
+
// Focus: into the dialog on open (initialFocus, else the first focusable
|
|
155
|
+
// element, else the dialog itself), back to what had it on close. The
|
|
156
|
+
// Portal is a descendant, so its popup is mounted when this effect runs.
|
|
157
|
+
const closeCountRef = useRef(0)
|
|
158
|
+
const registerClose = useCallback(() => {
|
|
159
|
+
closeCountRef.current += 1
|
|
160
|
+
return () => {
|
|
161
|
+
closeCountRef.current -= 1
|
|
162
|
+
}
|
|
163
|
+
}, [])
|
|
164
|
+
const previousFocusRef = useRef<HTMLElement | null>(null)
|
|
165
|
+
const wasOpenRef = useRef(false)
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (isOpen && !wasOpenRef.current) {
|
|
168
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null
|
|
169
|
+
const popup = popupRef.current
|
|
170
|
+
if (popup) {
|
|
171
|
+
const target = initialFocus?.current ?? popup.querySelector<HTMLElement>(FOCUSABLE) ?? popup
|
|
172
|
+
target.focus({preventScroll: true})
|
|
173
|
+
}
|
|
174
|
+
if (!closeOnEscape && closeCountRef.current === 0) {
|
|
175
|
+
devWarnOnce(
|
|
176
|
+
'Dialog',
|
|
177
|
+
'no-escape-no-close',
|
|
178
|
+
'Dialog.Root has closeOnEscape={false} and no Dialog.Close: a keyboard user cannot leave this dialog. Render a Dialog.Close, or keep Escape.',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
} else if (!isOpen && !closing && wasOpenRef.current) {
|
|
182
|
+
const target = previousFocusRef.current
|
|
183
|
+
previousFocusRef.current = null
|
|
184
|
+
if (target?.isConnected) target.focus({preventScroll: true})
|
|
185
|
+
}
|
|
186
|
+
wasOpenRef.current = isOpen || closing
|
|
187
|
+
}, [isOpen, closing, initialFocus, closeOnEscape])
|
|
137
188
|
|
|
138
189
|
// Lock body scroll when open
|
|
139
190
|
useEffect(() => {
|
|
@@ -158,6 +209,7 @@ function Root({
|
|
|
158
209
|
titleId,
|
|
159
210
|
descriptionId,
|
|
160
211
|
popupRef,
|
|
212
|
+
registerClose,
|
|
161
213
|
}}
|
|
162
214
|
>
|
|
163
215
|
{children}
|
|
@@ -229,6 +281,7 @@ function Portal({children, size = 'md'}: PortalProps) {
|
|
|
229
281
|
ref={popupRef}
|
|
230
282
|
role="dialog"
|
|
231
283
|
aria-modal={true}
|
|
284
|
+
tabIndex={-1}
|
|
232
285
|
aria-labelledby={titleId}
|
|
233
286
|
aria-describedby={descriptionId}
|
|
234
287
|
style={[
|
|
@@ -301,7 +354,8 @@ interface CloseProps {
|
|
|
301
354
|
}
|
|
302
355
|
|
|
303
356
|
function Close({children, 'aria-label': ariaLabel = 'Close'}: CloseProps) {
|
|
304
|
-
const {requestClose} = useDialog()
|
|
357
|
+
const {requestClose, registerClose} = useDialog()
|
|
358
|
+
useEffect(() => registerClose(), [registerClose])
|
|
305
359
|
|
|
306
360
|
if (children) {
|
|
307
361
|
return (
|
|
@@ -64,6 +64,8 @@ export const styles = css.create({
|
|
|
64
64
|
flexDirection: 'column',
|
|
65
65
|
overflow: 'hidden',
|
|
66
66
|
pointerEvents: 'auto',
|
|
67
|
+
// Focused only as the fallback when it holds nothing focusable.
|
|
68
|
+
outlineStyle: 'none',
|
|
67
69
|
borderWidth: borders.hairline,
|
|
68
70
|
borderStyle: 'solid',
|
|
69
71
|
borderColor: colors.border,
|
|
@@ -2,6 +2,8 @@ import {css} from 'react-strict-dom'
|
|
|
2
2
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
3
|
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
4
|
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
5
|
+
import {shadows} from '@duro-app/tokens/tokens/shadows.css'
|
|
6
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
5
7
|
|
|
6
8
|
export const styles = css.create({
|
|
7
9
|
// The item is the drag handle: no browser panning starts on it, so a touch
|
|
@@ -30,13 +32,24 @@ export const styles = css.create({
|
|
|
30
32
|
transitionTimingFunction: easing.standard,
|
|
31
33
|
},
|
|
32
34
|
// Every zone shows it can receive while something is in the air; the one
|
|
33
|
-
// under the pointer lights up.
|
|
35
|
+
// under the pointer lights up. Drop-target rings, not focus: keyboard focus
|
|
36
|
+
// stays on outlines, which survive forced-colors mode.
|
|
37
|
+
// Forced-colors mode drops box-shadows and fills; the transparent outline
|
|
38
|
+
// is what it paints instead (in a system colour), so the rings survive.
|
|
34
39
|
zoneReady: {
|
|
35
|
-
boxShadow:
|
|
40
|
+
boxShadow: shadows.dropReady,
|
|
41
|
+
outlineStyle: 'solid',
|
|
42
|
+
outlineWidth: borders.hairline,
|
|
43
|
+
outlineColor: 'transparent',
|
|
44
|
+
outlineOffset: `calc(-1 * ${borders.hairline})`,
|
|
36
45
|
},
|
|
37
46
|
zoneOver: {
|
|
38
|
-
boxShadow:
|
|
47
|
+
boxShadow: shadows.dropOver,
|
|
39
48
|
backgroundColor: colors.infoBg,
|
|
49
|
+
outlineStyle: 'solid',
|
|
50
|
+
outlineWidth: borders.strong,
|
|
51
|
+
outlineColor: 'transparent',
|
|
52
|
+
outlineOffset: `calc(-1 * ${borders.strong})`,
|
|
40
53
|
},
|
|
41
54
|
ghost: {
|
|
42
55
|
position: 'fixed',
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
|
|
5
|
+
"Grid layout. Columns are a count (1-6), a list of weights ([1, 2] for one-third / two-thirds), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses to one column on the width of its own container, so it is safe to nest and to place beside a DetailPanel. Weights rather than CSS template strings, so the same props render as CSS grid on web and as a wrapping flex row on native. For a fixed track beside fractions (content + a 320px aside), tracks takes a token track list — tracks={['1fr', 'asideW']} — web only, equal columns on native.",
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Card grids, dashboard layouts, multi-column forms',
|
|
8
8
|
'Responsive layouts that should auto-adjust column count',
|
|
@@ -5,7 +5,8 @@ import {Grid} from './Grid'
|
|
|
5
5
|
import {Stack} from '../Stack/Stack'
|
|
6
6
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
7
7
|
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
8
|
-
import {SPACING_KEYS} from '@duro-app/tokens/keys'
|
|
8
|
+
import {SIZES_PX, SPACING_KEYS} from '@duro-app/tokens/keys'
|
|
9
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
9
10
|
import {useContainerQuery} from '../../hooks/useContainerQuery'
|
|
10
11
|
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
11
12
|
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
@@ -209,3 +210,54 @@ export const WithContainerQuery: Story = {
|
|
|
209
210
|
)
|
|
210
211
|
},
|
|
211
212
|
}
|
|
213
|
+
|
|
214
|
+
// A track list with a fixed aside: the content column takes the rest, the
|
|
215
|
+
// aside is sizes.asideW.
|
|
216
|
+
export const TracksWithAside: Story = {
|
|
217
|
+
render: () => (
|
|
218
|
+
<html.div style={trackStyles.frame}>
|
|
219
|
+
<Grid tracks={['1fr', 'asideW']} gap="md">
|
|
220
|
+
<html.div role="region" aria-label="Content" style={trackStyles.cell}>
|
|
221
|
+
Content
|
|
222
|
+
</html.div>
|
|
223
|
+
<html.div role="region" aria-label="Aside" style={trackStyles.cell}>
|
|
224
|
+
Aside
|
|
225
|
+
</html.div>
|
|
226
|
+
</Grid>
|
|
227
|
+
</html.div>
|
|
228
|
+
),
|
|
229
|
+
play: async ({canvas}) => {
|
|
230
|
+
const aside = canvas.getByRole('region', {name: 'Aside'})
|
|
231
|
+
await expect(aside.getBoundingClientRect().width).toBe(SIZES_PX.asideW)
|
|
232
|
+
const content = canvas.getByRole('region', {name: 'Content'})
|
|
233
|
+
await expect(content.getBoundingClientRect().width).toBeGreaterThan(SIZES_PX.asideW)
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const TracksWithMinmax: Story = {
|
|
238
|
+
render: () => (
|
|
239
|
+
<html.div style={trackStyles.frame}>
|
|
240
|
+
<Grid tracks={['minmax(gridColXs, 1fr)', 'minmax(gridColXs, 1fr)', 'minmax(gridColXs, 1fr)']}>
|
|
241
|
+
<html.div style={trackStyles.cell}>One</html.div>
|
|
242
|
+
<html.div style={trackStyles.cell}>Two</html.div>
|
|
243
|
+
<html.div role="region" aria-label="Three" style={trackStyles.cell}>
|
|
244
|
+
Three
|
|
245
|
+
</html.div>
|
|
246
|
+
</Grid>
|
|
247
|
+
</html.div>
|
|
248
|
+
),
|
|
249
|
+
play: async ({canvas}) => {
|
|
250
|
+
const third = canvas.getByRole('region', {name: 'Three'})
|
|
251
|
+
await expect(third.getBoundingClientRect().width).toBeGreaterThanOrEqual(SIZES_PX.gridColXs)
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const trackStyles = css.create({
|
|
256
|
+
frame: {
|
|
257
|
+
width: sizes.pageMd,
|
|
258
|
+
},
|
|
259
|
+
cell: {
|
|
260
|
+
padding: spacing.sm,
|
|
261
|
+
backgroundColor: colors.bgCard,
|
|
262
|
+
},
|
|
263
|
+
})
|
|
@@ -4,7 +4,7 @@ import {styles} from './styles.css'
|
|
|
4
4
|
import {isNative} from '../../platform'
|
|
5
5
|
import {cellBasis, cellPosition, columnWeights, gridTemplate, type GridColumns} from './columns'
|
|
6
6
|
import type {SpacingToken} from '@duro-app/tokens/keys'
|
|
7
|
-
import {resolveLength, type Length} from '../../shared/length'
|
|
7
|
+
import {resolveLength, resolveTrack, type GridTrack, type Length} from '../../shared/length'
|
|
8
8
|
|
|
9
9
|
export type GridLayout = 'split' | 'split-wide'
|
|
10
10
|
|
|
@@ -28,6 +28,15 @@ interface GridProps {
|
|
|
28
28
|
* rather than when the window does. Wins over `columns` / `minColumnWidth`.
|
|
29
29
|
*/
|
|
30
30
|
layout?: GridLayout
|
|
31
|
+
/**
|
|
32
|
+
* An explicit track list, for columns that are not all fractions: a size
|
|
33
|
+
* token for a fixed track (`['1fr', 'asideW']`), or `minmax()` /
|
|
34
|
+
* `fit-content()` of tokens and fractions (`'minmax(gridColXs, 1fr)'`).
|
|
35
|
+
* A px is a type error. Web only — on native it falls back to equal
|
|
36
|
+
* columns, one per track. Wins over `columns` / `minColumnWidth`; `layout`
|
|
37
|
+
* wins over it.
|
|
38
|
+
*/
|
|
39
|
+
tracks?: readonly GridTrack[]
|
|
31
40
|
children: ReactNode
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -87,13 +96,14 @@ const cellGapTopMap = {
|
|
|
87
96
|
xxxl: styles.cellGapTopXxxl,
|
|
88
97
|
} as const satisfies Record<SpacingToken, unknown>
|
|
89
98
|
|
|
90
|
-
export function Grid({gap = 'md', columns, minColumnWidth, layout, children}: GridProps) {
|
|
99
|
+
export function Grid({gap = 'md', columns, minColumnWidth, layout, tracks, children}: GridProps) {
|
|
100
|
+
const hasTracks = tracks !== undefined && tracks.length > 0
|
|
91
101
|
if (isNative) {
|
|
92
102
|
return (
|
|
93
103
|
<NativeGrid
|
|
94
104
|
gap={gap}
|
|
95
|
-
columns={layout ? layoutWeights[layout] : columns}
|
|
96
|
-
minColumnWidth={layout ? undefined : minColumnWidth}
|
|
105
|
+
columns={layout ? layoutWeights[layout] : hasTracks ? tracks.map(() => 1) : columns}
|
|
106
|
+
minColumnWidth={layout || hasTracks ? undefined : minColumnWidth}
|
|
97
107
|
>
|
|
98
108
|
{children}
|
|
99
109
|
</NativeGrid>
|
|
@@ -102,13 +112,15 @@ export function Grid({gap = 'md', columns, minColumnWidth, layout, children}: Gr
|
|
|
102
112
|
|
|
103
113
|
const columnStyle = layout
|
|
104
114
|
? layoutMap[layout]
|
|
105
|
-
:
|
|
106
|
-
? styles.
|
|
107
|
-
:
|
|
108
|
-
?
|
|
109
|
-
: columns
|
|
110
|
-
?
|
|
111
|
-
:
|
|
115
|
+
: hasTracks
|
|
116
|
+
? styles.template(tracks.map((track) => String(resolveTrack(track))).join(' '))
|
|
117
|
+
: minColumnWidth
|
|
118
|
+
? styles.autoFit(resolveLength(minColumnWidth))
|
|
119
|
+
: typeof columns === 'number'
|
|
120
|
+
? columnsMap[columns]
|
|
121
|
+
: columns
|
|
122
|
+
? styles.template(gridTemplate(columnWeights(columns) ?? [1]))
|
|
123
|
+
: undefined
|
|
112
124
|
|
|
113
125
|
const grid = <html.div style={[styles.base, gapMap[gap], columnStyle]}>{children}</html.div>
|
|
114
126
|
// A named layout answers its container query from a wrapper: an element
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {css} from 'react-strict-dom'
|
|
2
2
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
3
3
|
import {breakpoints} from '@duro-app/tokens/tokens/breakpoints.css'
|
|
4
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
4
5
|
|
|
5
6
|
// Where a split collapses to one column — measured on the split's OWN
|
|
6
7
|
// container, not the viewport. A split nested in another split (a list/detail
|
|
@@ -40,13 +41,13 @@ export const styles = css.create({
|
|
|
40
41
|
split: {
|
|
41
42
|
gridTemplateColumns: {
|
|
42
43
|
default: '1fr',
|
|
43
|
-
[`@container (min-width: ${SPLIT_BP})`]:
|
|
44
|
+
[`@container (min-width: ${SPLIT_BP})`]: `minmax(${sizes.gridColSm}, 1fr) minmax(0, 2fr)`,
|
|
44
45
|
},
|
|
45
46
|
},
|
|
46
47
|
splitWide: {
|
|
47
48
|
gridTemplateColumns: {
|
|
48
49
|
default: '1fr',
|
|
49
|
-
[`@container (min-width: ${SPLIT_WIDE_BP})`]:
|
|
50
|
+
[`@container (min-width: ${SPLIT_WIDE_BP})`]: `minmax(${sizes.gridColMd}, 1fr) minmax(0, 3fr)`,
|
|
50
51
|
},
|
|
51
52
|
},
|
|
52
53
|
// Weighted columns: `[1, 2]` → `1fr 2fr` (see columns.ts).
|
|
@@ -2,9 +2,16 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Text input with automatic Field/Form integration. Variant auto-switches to error when inside an invalid Field.',
|
|
6
|
-
whenToUse: [
|
|
7
|
-
|
|
5
|
+
'Text input with automatic Field/Form integration. Variant auto-switches to error when inside an invalid Field. variant="ghost" reads as text until hovered (hairline border) or focused (accent border and ring): only for inline edit where a visible Field.Label names it; it shows the error state when the Field is invalid.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Single-line text entry (text, email, password, url, tel, number, search)',
|
|
8
|
+
'Inline edit of a labelled value in place (a ticket title) — variant="ghost"',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Multi-line text — use Textarea',
|
|
12
|
+
'Picking from predefined options — use Select',
|
|
13
|
+
'A ghost field without a visible label — it reads as plain text, so the label is what says it is editable',
|
|
14
|
+
],
|
|
8
15
|
relatedTo: [
|
|
9
16
|
{component: 'Field', kind: 'composition', relationship: 'Wrap in Field.Root for label + error'},
|
|
10
17
|
{
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, fn} from 'storybook/test'
|
|
2
|
+
import {expect, fn, waitFor} from 'storybook/test'
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {Input} from './Input'
|
|
5
|
+
import {Field} from '../Field/Field'
|
|
6
|
+
import {Stack} from '../Stack/Stack'
|
|
5
7
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
6
8
|
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
7
9
|
|
|
@@ -14,7 +16,7 @@ const meta: Meta<typeof Input> = {
|
|
|
14
16
|
argTypes: {
|
|
15
17
|
variant: {
|
|
16
18
|
control: 'select',
|
|
17
|
-
options: ['default', 'error'],
|
|
19
|
+
options: ['default', 'error', 'ghost'],
|
|
18
20
|
},
|
|
19
21
|
type: {
|
|
20
22
|
control: 'select',
|
|
@@ -105,3 +107,51 @@ export const AllVariants: Story = {
|
|
|
105
107
|
await expect(disabled).toBeDisabled()
|
|
106
108
|
},
|
|
107
109
|
}
|
|
110
|
+
|
|
111
|
+
// --- Ghost (5.2, direction OutlineOnHover) ---
|
|
112
|
+
|
|
113
|
+
export const GhostInlineEdit: Story = {
|
|
114
|
+
render: () => (
|
|
115
|
+
<html.div style={layoutStyles.narrow}>
|
|
116
|
+
<Stack gap="md">
|
|
117
|
+
<Field.Root>
|
|
118
|
+
<Field.Label>Title</Field.Label>
|
|
119
|
+
<Input variant="ghost" defaultValue="Printer on floor 3 is jammed" />
|
|
120
|
+
</Field.Root>
|
|
121
|
+
<Field.Root invalid>
|
|
122
|
+
<Field.Label>Assignee</Field.Label>
|
|
123
|
+
<Input variant="ghost" defaultValue="" />
|
|
124
|
+
<Field.Error>Assignee is required</Field.Error>
|
|
125
|
+
</Field.Root>
|
|
126
|
+
<Field.Root disabled>
|
|
127
|
+
<Field.Label>Due</Field.Label>
|
|
128
|
+
<Input variant="ghost" defaultValue="Friday" />
|
|
129
|
+
</Field.Root>
|
|
130
|
+
</Stack>
|
|
131
|
+
</html.div>
|
|
132
|
+
),
|
|
133
|
+
play: async ({canvas, userEvent}) => {
|
|
134
|
+
const title = canvas.getByRole('textbox', {name: 'Title'})
|
|
135
|
+
// Reads as text at rest: no border colour, no fill.
|
|
136
|
+
await expect(getComputedStyle(title).borderTopColor).toBe('rgba(0, 0, 0, 0)')
|
|
137
|
+
await userEvent.click(title)
|
|
138
|
+
await userEvent.keyboard('{Shift>}{Tab}{/Shift}{Tab}')
|
|
139
|
+
await expect(title).toHaveFocus()
|
|
140
|
+
await expect(getComputedStyle(title).outlineStyle).toBe('solid')
|
|
141
|
+
// The border colour transitions in (duration.fast).
|
|
142
|
+
await waitFor(() => expect(getComputedStyle(title).borderTopColor).not.toBe('rgba(0, 0, 0, 0)'))
|
|
143
|
+
|
|
144
|
+
const assignee = canvas.getByRole('textbox', {name: 'Assignee'})
|
|
145
|
+
await expect(assignee).toHaveAttribute('aria-invalid', 'true')
|
|
146
|
+
await expect(getComputedStyle(assignee).backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
147
|
+
|
|
148
|
+
await expect(canvas.getByRole('textbox', {name: 'Due'})).toBeDisabled()
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const layoutStyles = css.create({
|
|
153
|
+
narrow: {
|
|
154
|
+
maxWidth: sizes.panelSm,
|
|
155
|
+
padding: spacing.md,
|
|
156
|
+
},
|
|
157
|
+
})
|
|
@@ -6,9 +6,14 @@ import {styles} from './styles.css'
|
|
|
6
6
|
type StrictInputProps = React.ComponentProps<typeof html.input>
|
|
7
7
|
export type InputType = NonNullable<StrictInputProps['type']>
|
|
8
8
|
|
|
9
|
-
export type InputVariant = 'default' | 'error'
|
|
9
|
+
export type InputVariant = 'default' | 'error' | 'ghost'
|
|
10
10
|
|
|
11
11
|
interface InputProps {
|
|
12
|
+
/**
|
|
13
|
+
* `ghost` reads as text until hovered or focused — only for inline edit
|
|
14
|
+
* where a visible label names the field (Field.Label). It shows the error
|
|
15
|
+
* state when the Field is invalid.
|
|
16
|
+
*/
|
|
12
17
|
variant?: InputVariant
|
|
13
18
|
/** Orthogonal to variant — `mono` keeps the error border but uses the
|
|
14
19
|
* monospace family (slugs, identifiers, hashes). */
|
|
@@ -100,7 +105,9 @@ export function Input({
|
|
|
100
105
|
const effectiveOnBlur = onBlur ?? fieldCtx?.onBlur
|
|
101
106
|
const effectiveRef = ref ?? fieldCtx?.ref
|
|
102
107
|
const effectiveDisabled = disabled ?? ctx?.disabled
|
|
103
|
-
const
|
|
108
|
+
const invalid = Boolean(ctx?.invalid) || variant === 'error'
|
|
109
|
+
const ghost = variant === 'ghost'
|
|
110
|
+
const effectiveVariant = ghost ? 'ghost' : ctx?.invalid ? 'error' : variant
|
|
104
111
|
// Field context owns describedby (description + error); fall back to the
|
|
105
112
|
// explicit prop only when standalone.
|
|
106
113
|
const describedBy = ctx
|
|
@@ -134,7 +141,7 @@ export function Input({
|
|
|
134
141
|
defaultValue={defaultValue}
|
|
135
142
|
disabled={effectiveDisabled}
|
|
136
143
|
aria-describedby={describedBy || undefined}
|
|
137
|
-
aria-invalid={
|
|
144
|
+
aria-invalid={invalid || undefined}
|
|
138
145
|
onChange={effectiveOnChange as StrictInputProps['onChange']}
|
|
139
146
|
onBlur={effectiveOnBlur as StrictInputProps['onBlur']}
|
|
140
147
|
onFocus={onFocus as StrictInputProps['onFocus']}
|
|
@@ -144,6 +151,8 @@ export function Input({
|
|
|
144
151
|
style={[
|
|
145
152
|
styles.base,
|
|
146
153
|
styles[effectiveVariant],
|
|
154
|
+
ghost && invalid && styles.ghostError,
|
|
155
|
+
ghost && effectiveDisabled && styles.ghostDisabled,
|
|
147
156
|
font === 'mono' && styles.mono,
|
|
148
157
|
groupCtx?.inGroup && styles.inGroup,
|
|
149
158
|
]}
|
|
@@ -53,6 +53,35 @@ export const styles = css.create({
|
|
|
53
53
|
':focus': colors.error,
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
|
+
// Direction OutlineOnHover (docs/mockups/input-ghost/OutlineOnHover.dc.html):
|
|
57
|
+
// reads as text at rest; hover draws the field's hairline border, focus
|
|
58
|
+
// turns it accent and adds the ring. For inline edit with a visible label.
|
|
59
|
+
ghost: {
|
|
60
|
+
backgroundColor: 'transparent',
|
|
61
|
+
paddingTop: spacing.xs,
|
|
62
|
+
paddingBottom: spacing.xs,
|
|
63
|
+
paddingLeft: spacing.sm,
|
|
64
|
+
paddingRight: spacing.sm,
|
|
65
|
+
// The text lines up with its label above.
|
|
66
|
+
marginLeft: `calc(-1 * ${spacing.sm})`,
|
|
67
|
+
borderColor: {
|
|
68
|
+
default: 'transparent',
|
|
69
|
+
':hover': colors.border,
|
|
70
|
+
':focus': colors.accent,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
ghostError: {
|
|
74
|
+
backgroundColor: colors.errorBg,
|
|
75
|
+
borderColor: {
|
|
76
|
+
default: colors.errorBorder,
|
|
77
|
+
':focus': colors.error,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
ghostDisabled: {
|
|
81
|
+
color: colors.textMuted,
|
|
82
|
+
cursor: 'not-allowed',
|
|
83
|
+
borderColor: 'transparent',
|
|
84
|
+
},
|
|
56
85
|
inGroup: {
|
|
57
86
|
borderWidth: 0,
|
|
58
87
|
borderRadius: 0,
|
|
@@ -140,3 +140,23 @@ export const AllVariants: Story = {
|
|
|
140
140
|
</html.div>
|
|
141
141
|
),
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
const onSearch = fn()
|
|
145
|
+
|
|
146
|
+
// An icon-only clickable addon is a button: aria-label gives it its name.
|
|
147
|
+
export const IconAddonNamed: Story = {
|
|
148
|
+
render: () => (
|
|
149
|
+
<html.div style={layoutStyles.container}>
|
|
150
|
+
<InputGroup.Root>
|
|
151
|
+
<Input placeholder="Search tickets" aria-label="Search tickets" />
|
|
152
|
+
<InputGroup.Addon onClick={onSearch} aria-label="Run search">
|
|
153
|
+
<Icon name="search" size="sm" />
|
|
154
|
+
</InputGroup.Addon>
|
|
155
|
+
</InputGroup.Root>
|
|
156
|
+
</html.div>
|
|
157
|
+
),
|
|
158
|
+
play: async ({canvas, userEvent}) => {
|
|
159
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Run search'}))
|
|
160
|
+
await expect(onSearch).toHaveBeenCalled()
|
|
161
|
+
},
|
|
162
|
+
}
|
|
@@ -33,10 +33,22 @@ interface AddonProps {
|
|
|
33
33
|
/** Optional minimum width to prevent layout shift (e.g. Copy → Copied!): a
|
|
34
34
|
* size token key or a relative length. */
|
|
35
35
|
minWidth?: Length
|
|
36
|
+
/**
|
|
37
|
+
* Accessible name of a clickable addon (it renders a button). Required when
|
|
38
|
+
* its content is an icon (copy, reveal): without it the button has no name.
|
|
39
|
+
*/
|
|
40
|
+
'aria-label'?: string
|
|
36
41
|
children: ReactNode
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
function Addon({
|
|
44
|
+
function Addon({
|
|
45
|
+
position = 'end',
|
|
46
|
+
onClick,
|
|
47
|
+
disabled,
|
|
48
|
+
minWidth,
|
|
49
|
+
'aria-label': ariaLabel,
|
|
50
|
+
children,
|
|
51
|
+
}: AddonProps) {
|
|
40
52
|
const positionStyle = position === 'start' ? styles.addonStart : styles.addonEnd
|
|
41
53
|
const style = [
|
|
42
54
|
styles.addon,
|
|
@@ -49,7 +61,13 @@ function Addon({position = 'end', onClick, disabled, minWidth, children}: AddonP
|
|
|
49
61
|
|
|
50
62
|
if (onClick) {
|
|
51
63
|
return (
|
|
52
|
-
<html.button
|
|
64
|
+
<html.button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={onClick}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
aria-label={ariaLabel}
|
|
69
|
+
style={style}
|
|
70
|
+
>
|
|
53
71
|
{children}
|
|
54
72
|
</html.button>
|
|
55
73
|
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH).',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Typeahead inside a rich-text editor, where focus must stay in the editor',
|
|
8
|
+
'A custom combobox whose input is not Combobox.Input',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Picking a value from a filterable list in a form — use Combobox (built on Listbox)',
|
|
12
|
+
'A short fixed list — use Select',
|
|
13
|
+
'Actions — use Menu',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['Root', 'Option'],
|
|
17
|
+
optional: ['Empty'],
|
|
18
|
+
},
|
|
19
|
+
relatedTo: [
|
|
20
|
+
{
|
|
21
|
+
component: 'Combobox',
|
|
22
|
+
kind: 'contrast',
|
|
23
|
+
relationship:
|
|
24
|
+
'Combobox is a ready-made input + Listbox; Listbox alone serves an anchor you own (an editor)',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
component: 'Menu',
|
|
28
|
+
kind: 'contrast',
|
|
29
|
+
relationship:
|
|
30
|
+
'Menu takes focus and triggers actions; Listbox leaves focus on its anchor and offers values',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
example: `const id = useId()
|
|
34
|
+
const [highlightedId, setHighlightedId] = useState<string | null>(null)
|
|
35
|
+
const optionId = (value: string) => \`\${id}-\${value}\`
|
|
36
|
+
// On the editor root: Object.entries(Listbox.getAnchorProps({id, open, highlightedId}))
|
|
37
|
+
<Listbox.Root
|
|
38
|
+
id={id}
|
|
39
|
+
open={open}
|
|
40
|
+
anchor={() => caretRect()}
|
|
41
|
+
highlightedId={highlightedId}
|
|
42
|
+
onHighlight={setHighlightedId}
|
|
43
|
+
onSelect={(value) => insertMention(value)}
|
|
44
|
+
getOptionId={optionId}
|
|
45
|
+
aria-label="Mentions"
|
|
46
|
+
>
|
|
47
|
+
{people.map((p) => (
|
|
48
|
+
<Listbox.Option key={p.id} value={p.id}>{p.name}</Listbox.Option>
|
|
49
|
+
))}
|
|
50
|
+
{people.length === 0 && <Listbox.Empty>No match</Listbox.Empty>}
|
|
51
|
+
</Listbox.Root>`,
|
|
52
|
+
}
|