@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
|
@@ -1,48 +1,93 @@
|
|
|
1
|
-
import {useState, useCallback, useRef, useId, useEffect} from 'react'
|
|
1
|
+
import {useState, useCallback, useRef, useId, useEffect, useLayoutEffect} from 'react'
|
|
2
2
|
import type {MenuContextValue} from './MenuContext'
|
|
3
|
+
import {isOutsidePress} from '../Popover/outside'
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
|
|
5
|
+
export interface MenuRootOptions {
|
|
6
|
+
open?: boolean
|
|
7
|
+
defaultOpen?: boolean
|
|
8
|
+
onOpenChange?: (open: boolean) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The menu-button pattern (WAI-ARIA APG): on open, focus moves to the
|
|
13
|
+
* `role="menu"` popup, which carries `aria-activedescendant`; arrows move the
|
|
14
|
+
* highlight; Escape closes and focus returns to the trigger. The keydown
|
|
15
|
+
* handler sits on the popup itself, never on `document`, so keys typed
|
|
16
|
+
* elsewhere (an editor) are never intercepted, and Escape stops there so an
|
|
17
|
+
* enclosing Popover or Dialog stays open.
|
|
18
|
+
*/
|
|
19
|
+
export function useMenuRoot({open: openProp, defaultOpen = false, onOpenChange}: MenuRootOptions) {
|
|
20
|
+
const [inner, setInner] = useState(defaultOpen)
|
|
21
|
+
const controlled = openProp !== undefined
|
|
22
|
+
const open = controlled ? openProp : inner
|
|
6
23
|
const [highlightedId, setHighlightedId] = useState<string | null>(null)
|
|
24
|
+
// Read by the keydown handler, so activating an item is not a side effect
|
|
25
|
+
// inside a state updater (React may run updaters twice).
|
|
26
|
+
const highlightedRef = useRef(highlightedId)
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
highlightedRef.current = highlightedId
|
|
29
|
+
}, [highlightedId])
|
|
7
30
|
const menuId = useId()
|
|
31
|
+
const triggerId = useId()
|
|
8
32
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
9
33
|
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
34
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
10
35
|
const itemsRef = useRef(new Map<string, HTMLElement>())
|
|
11
36
|
const orderRef = useRef<string[]>([])
|
|
12
|
-
const
|
|
37
|
+
const openRef = useRef(open)
|
|
38
|
+
openRef.current = open
|
|
39
|
+
const onOpenChangeRef = useRef(onOpenChange)
|
|
40
|
+
onOpenChangeRef.current = onOpenChange
|
|
13
41
|
|
|
14
|
-
const
|
|
42
|
+
const setOpen = useCallback(
|
|
43
|
+
(next: boolean) => {
|
|
44
|
+
if (next === openRef.current) return
|
|
45
|
+
if (!controlled) setInner(next)
|
|
46
|
+
onOpenChangeRef.current?.(next)
|
|
47
|
+
},
|
|
48
|
+
[controlled],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Closes and leaves focus where it is (a press elsewhere put it there).
|
|
52
|
+
const dismiss = useCallback(() => {
|
|
53
|
+
if (!openRef.current) return
|
|
15
54
|
setOpen(false)
|
|
16
55
|
setHighlightedId(null)
|
|
17
|
-
|
|
56
|
+
}, [setOpen])
|
|
57
|
+
|
|
58
|
+
// Closes and puts focus back on the trigger.
|
|
59
|
+
const close = useCallback(() => {
|
|
60
|
+
if (!openRef.current) return
|
|
61
|
+
dismiss()
|
|
18
62
|
triggerRef.current?.focus()
|
|
19
|
-
}, [])
|
|
63
|
+
}, [dismiss])
|
|
20
64
|
|
|
21
65
|
const toggle = useCallback(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} else {
|
|
26
|
-
setHighlightedId(null)
|
|
27
|
-
needsInitialHighlightRef.current = false
|
|
28
|
-
}
|
|
29
|
-
return !prev
|
|
30
|
-
})
|
|
31
|
-
}, [])
|
|
66
|
+
if (openRef.current) close()
|
|
67
|
+
else setOpen(true)
|
|
68
|
+
}, [close, setOpen])
|
|
32
69
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
70
|
+
// On open: focus the menu and highlight the first item. Child effects (item
|
|
71
|
+
// registration) run before this parent effect, so orderRef is populated.
|
|
72
|
+
const wasOpenRef = useRef(false)
|
|
36
73
|
useEffect(() => {
|
|
37
|
-
if (open &&
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
74
|
+
if (open && !wasOpenRef.current) {
|
|
75
|
+
popupRef.current?.focus({preventScroll: true})
|
|
76
|
+
const first = orderRef.current[0]
|
|
77
|
+
if (first) setHighlightedId(first)
|
|
78
|
+
} else if (!open && wasOpenRef.current) {
|
|
79
|
+
setHighlightedId(null)
|
|
43
80
|
}
|
|
81
|
+
wasOpenRef.current = open
|
|
44
82
|
}, [open])
|
|
45
83
|
|
|
84
|
+
// Keep the highlighted item in view when the popup scrolls.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!highlightedId) return
|
|
87
|
+
const el = itemsRef.current.get(highlightedId)
|
|
88
|
+
el?.scrollIntoView?.({block: 'nearest'})
|
|
89
|
+
}, [highlightedId])
|
|
90
|
+
|
|
46
91
|
const registerItem = useCallback((id: string, element: HTMLElement) => {
|
|
47
92
|
itemsRef.current.set(id, element)
|
|
48
93
|
const map = itemsRef.current
|
|
@@ -60,12 +105,24 @@ export function useMenuRoot() {
|
|
|
60
105
|
}
|
|
61
106
|
}, [])
|
|
62
107
|
|
|
63
|
-
// Native keydown for full KeyboardEvent access
|
|
108
|
+
// Native keydown on the popup element, for full KeyboardEvent access.
|
|
64
109
|
useEffect(() => {
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
110
|
+
const popup = popupRef.current
|
|
111
|
+
if (!popup || !open) return
|
|
67
112
|
|
|
68
113
|
function handleKeyDown(e: KeyboardEvent) {
|
|
114
|
+
if (e.key === 'Escape') {
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
e.stopPropagation()
|
|
117
|
+
close()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (e.key === 'Tab') {
|
|
121
|
+
// Back on the trigger before the browser moves focus, so Tab and
|
|
122
|
+
// Shift+Tab continue from where the menu was opened.
|
|
123
|
+
close()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
69
126
|
const order = orderRef.current
|
|
70
127
|
if (order.length === 0) return
|
|
71
128
|
|
|
@@ -99,41 +156,41 @@ export function useMenuRoot() {
|
|
|
99
156
|
case 'Enter':
|
|
100
157
|
case ' ': {
|
|
101
158
|
e.preventDefault()
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
if (prev) {
|
|
105
|
-
const el = items.get(prev)
|
|
106
|
-
el?.click()
|
|
107
|
-
}
|
|
108
|
-
return prev
|
|
109
|
-
})
|
|
110
|
-
break
|
|
111
|
-
}
|
|
112
|
-
case 'Escape': {
|
|
113
|
-
e.preventDefault()
|
|
114
|
-
close()
|
|
115
|
-
break
|
|
116
|
-
}
|
|
117
|
-
case 'Tab': {
|
|
118
|
-
close()
|
|
159
|
+
const current = highlightedRef.current
|
|
160
|
+
if (current) itemsRef.current.get(current)?.click()
|
|
119
161
|
break
|
|
120
162
|
}
|
|
121
163
|
}
|
|
122
164
|
}
|
|
123
165
|
|
|
124
|
-
|
|
125
|
-
return () =>
|
|
166
|
+
popup.addEventListener('keydown', handleKeyDown)
|
|
167
|
+
return () => popup.removeEventListener('keydown', handleKeyDown)
|
|
126
168
|
}, [open, close])
|
|
127
169
|
|
|
170
|
+
// A press outside the trigger and the popup closes the menu, and leaves
|
|
171
|
+
// focus where the press put it.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!open) return
|
|
174
|
+
function onPointerDown(e: PointerEvent) {
|
|
175
|
+
if (isOutsidePress(e.target as Node | null, [popupRef.current, triggerRef.current])) {
|
|
176
|
+
dismiss()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
180
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
181
|
+
}, [open, dismiss])
|
|
182
|
+
|
|
128
183
|
const ctx: MenuContextValue = {
|
|
129
184
|
open,
|
|
130
185
|
toggle,
|
|
131
186
|
close,
|
|
132
187
|
menuId,
|
|
188
|
+
triggerId,
|
|
133
189
|
highlightedId,
|
|
134
190
|
setHighlightedId,
|
|
135
191
|
registerItem,
|
|
136
192
|
triggerRef,
|
|
193
|
+
popupRef,
|
|
137
194
|
}
|
|
138
195
|
|
|
139
196
|
return {ctx, rootRef}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect} from 'storybook/test'
|
|
3
2
|
import {css, html} from 'react-strict-dom'
|
|
4
3
|
import {StatusIcon} from './StatusIcon'
|
|
5
4
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
6
5
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
7
6
|
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
7
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
8
8
|
|
|
9
9
|
const meta: Meta<typeof StatusIcon> = {
|
|
10
10
|
title: 'Components/StatusIcon',
|
|
@@ -52,7 +52,7 @@ export const CustomSize: Story = {
|
|
|
52
52
|
const gridStyles = css.create({
|
|
53
53
|
grid: {
|
|
54
54
|
display: 'grid',
|
|
55
|
-
gridTemplateColumns:
|
|
55
|
+
gridTemplateColumns: `repeat(auto-fill, minmax(${sizes.gridColSm}, 1fr))`,
|
|
56
56
|
gap: spacing.lg,
|
|
57
57
|
},
|
|
58
58
|
cell: {display: 'flex', flexDirection: 'column', alignItems: 'center', gap: spacing.sm},
|
|
@@ -1293,3 +1293,42 @@ export const CardsBelowMobileBreakpoint: Story = {
|
|
|
1293
1293
|
await expect(getComputedStyle(ownerLabel).display).toBe('block')
|
|
1294
1294
|
},
|
|
1295
1295
|
}
|
|
1296
|
+
|
|
1297
|
+
// A column width can carry a size token inside a track function: resolveTrack
|
|
1298
|
+
// turns `minmax(gridColSm, 1fr)` into `minmax(var(--…), 1fr)` (ADR-0027).
|
|
1299
|
+
export const TokenColumnWidths: Story = {
|
|
1300
|
+
name: 'Token column widths',
|
|
1301
|
+
render: () => (
|
|
1302
|
+
<html.div style={respStyles.scrollDense}>
|
|
1303
|
+
<Table.Root>
|
|
1304
|
+
<Table.Header>
|
|
1305
|
+
<Table.Row>
|
|
1306
|
+
<Table.HeaderCell width="minmax(gridColSm, 1fr)">Name</Table.HeaderCell>
|
|
1307
|
+
<Table.HeaderCell width="fit-content(labelMinW)">Owner</Table.HeaderCell>
|
|
1308
|
+
<Table.HeaderCell width="iconButton">Act</Table.HeaderCell>
|
|
1309
|
+
</Table.Row>
|
|
1310
|
+
</Table.Header>
|
|
1311
|
+
<Table.Body>
|
|
1312
|
+
<Table.Row>
|
|
1313
|
+
<Table.Cell>SMTP submission service account</Table.Cell>
|
|
1314
|
+
<Table.Cell>{LONG_EMAIL}</Table.Cell>
|
|
1315
|
+
<Table.Cell>x</Table.Cell>
|
|
1316
|
+
</Table.Row>
|
|
1317
|
+
</Table.Body>
|
|
1318
|
+
</Table.Root>
|
|
1319
|
+
</html.div>
|
|
1320
|
+
),
|
|
1321
|
+
play: async ({canvasElement}) => {
|
|
1322
|
+
await waitFor(() =>
|
|
1323
|
+
expect(canvasElement.querySelectorAll('[role="columnheader"]')).toHaveLength(3),
|
|
1324
|
+
)
|
|
1325
|
+
const headers = [...canvasElement.querySelectorAll('[role="columnheader"]')]
|
|
1326
|
+
const [name, owner, act] = headers.map((h) => h.getBoundingClientRect().width)
|
|
1327
|
+
// minmax(gridColSm, 1fr): at least 240px and takes the slack.
|
|
1328
|
+
await expect(name).toBeGreaterThanOrEqual(240)
|
|
1329
|
+
// fit-content(labelMinW): capped at 120px even with a long email inside.
|
|
1330
|
+
await expect(owner).toBeLessThanOrEqual(120.5)
|
|
1331
|
+
// iconButton: exactly 32px.
|
|
1332
|
+
await expect(Math.abs(act! - 32)).toBeLessThanOrEqual(0.5)
|
|
1333
|
+
},
|
|
1334
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Inline hyperlink for running text and standalone text links ("View all", "Edit profile"). Always underlined, so colour is never the only cue: default is accent text with an accent underline; subtle keeps the surrounding text colour with a muted underline and turns accent on hover. target="_blank" defaults rel to noopener noreferrer.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A link inside a sentence or paragraph',
|
|
8
|
+
'A standalone text link under a list or card ("View all requests")',
|
|
9
|
+
'A quiet link in dense UI (variant="subtle")',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'A link that should look like a button — use LinkButton',
|
|
13
|
+
'An action that does not navigate — use Button (variant="link" for a text-styled action)',
|
|
14
|
+
'A link inside a dropdown — use Menu.LinkItem',
|
|
15
|
+
],
|
|
16
|
+
relatedTo: [
|
|
17
|
+
{
|
|
18
|
+
component: 'LinkButton',
|
|
19
|
+
kind: 'contrast',
|
|
20
|
+
relationship:
|
|
21
|
+
'LinkButton is a hyperlink styled as a button; TextLink is a hyperlink styled as text',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
component: 'Button',
|
|
25
|
+
kind: 'contrast',
|
|
26
|
+
relationship: 'Button runs an action; TextLink navigates',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
example: `<Text>
|
|
30
|
+
Read the <TextLink href="/docs/migrate">migration guide</TextLink> before you change seats, or{' '}
|
|
31
|
+
<TextLink href="/plans" variant="subtle">compare plans</TextLink> first.
|
|
32
|
+
</Text>`,
|
|
33
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect} from 'storybook/test'
|
|
3
|
+
import {TextLink} from './TextLink'
|
|
4
|
+
import {Text} from '../Text/Text'
|
|
5
|
+
import {Inline} from '../Inline/Inline'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof TextLink> = {
|
|
8
|
+
title: 'Components/TextLink',
|
|
9
|
+
component: TextLink,
|
|
10
|
+
args: {href: '#guide', children: 'migration guide'},
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof TextLink>
|
|
15
|
+
|
|
16
|
+
export const InRunningText: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<Text>
|
|
19
|
+
Your workspace moved to the new plan. Read the{' '}
|
|
20
|
+
<TextLink href="#guide">migration guide</TextLink> before you change seats, or{' '}
|
|
21
|
+
<TextLink href="#plans" variant="subtle">
|
|
22
|
+
compare plans
|
|
23
|
+
</TextLink>{' '}
|
|
24
|
+
first.
|
|
25
|
+
</Text>
|
|
26
|
+
),
|
|
27
|
+
play: async ({canvas}) => {
|
|
28
|
+
for (const name of ['migration guide', 'compare plans']) {
|
|
29
|
+
const link = canvas.getByRole('link', {name})
|
|
30
|
+
// Underlined in both variants: colour is never the only cue.
|
|
31
|
+
await expect(getComputedStyle(link).textDecorationLine).toContain('underline')
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Standalone: Story = {
|
|
37
|
+
render: () => (
|
|
38
|
+
<Inline gap="lg">
|
|
39
|
+
<TextLink href="#all">View all requests</TextLink>
|
|
40
|
+
<TextLink href="#profile" variant="subtle">
|
|
41
|
+
Edit profile
|
|
42
|
+
</TextLink>
|
|
43
|
+
<TextLink href="https://example.com/docs" target="_blank">
|
|
44
|
+
Documentation
|
|
45
|
+
</TextLink>
|
|
46
|
+
</Inline>
|
|
47
|
+
),
|
|
48
|
+
play: async ({canvas}) => {
|
|
49
|
+
const external = canvas.getByRole('link', {name: 'Documentation'})
|
|
50
|
+
await expect(external).toHaveAttribute('target', '_blank')
|
|
51
|
+
await expect(external).toHaveAttribute('rel', 'noopener noreferrer')
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const KeyboardFocus: Story = {
|
|
56
|
+
render: () => <TextLink href="#billing">Billing settings</TextLink>,
|
|
57
|
+
play: async ({canvas, userEvent}) => {
|
|
58
|
+
await userEvent.tab()
|
|
59
|
+
const link = canvas.getByRole('link', {name: 'Billing settings'})
|
|
60
|
+
await expect(link).toHaveFocus()
|
|
61
|
+
await expect(getComputedStyle(link).outlineStyle).toBe('solid')
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {styles} from './styles.css'
|
|
4
|
+
|
|
5
|
+
export type TextLinkVariant = 'default' | 'subtle'
|
|
6
|
+
|
|
7
|
+
interface TextLinkProps {
|
|
8
|
+
href: string
|
|
9
|
+
/** `_blank` opens a new tab; `rel` then defaults to `noopener noreferrer`. */
|
|
10
|
+
target?: '_blank' | '_self'
|
|
11
|
+
rel?: string
|
|
12
|
+
/**
|
|
13
|
+
* `default`: accent text with an accent underline. `subtle`: the
|
|
14
|
+
* surrounding text colour with a muted underline, accent on hover. Both
|
|
15
|
+
* stay underlined, so colour is never the only cue in running text.
|
|
16
|
+
*/
|
|
17
|
+
variant?: TextLinkVariant
|
|
18
|
+
/** Accessible name, when the visible text alone does not say where it goes. */
|
|
19
|
+
'aria-label'?: string
|
|
20
|
+
children: ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An inline hyperlink in running text or a standalone "View all" link. For a
|
|
25
|
+
* link that looks like a button, use LinkButton.
|
|
26
|
+
*/
|
|
27
|
+
export function TextLink({
|
|
28
|
+
href,
|
|
29
|
+
target,
|
|
30
|
+
rel,
|
|
31
|
+
variant = 'default',
|
|
32
|
+
'aria-label': ariaLabel,
|
|
33
|
+
children,
|
|
34
|
+
}: TextLinkProps) {
|
|
35
|
+
return (
|
|
36
|
+
<html.a
|
|
37
|
+
href={href}
|
|
38
|
+
target={target}
|
|
39
|
+
rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
|
|
40
|
+
aria-label={ariaLabel}
|
|
41
|
+
style={[styles.base, styles[variant]]}
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</html.a>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
5
|
+
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
6
|
+
|
|
7
|
+
// Direction "Underlined" (docs/mockups/text-link/Underlined.dc.html): the
|
|
8
|
+
// underline is always there, so colour is never the only cue (WCAG F73).
|
|
9
|
+
export const styles = css.create({
|
|
10
|
+
base: {
|
|
11
|
+
fontFamily: 'inherit',
|
|
12
|
+
fontSize: 'inherit',
|
|
13
|
+
lineHeight: 'inherit',
|
|
14
|
+
cursor: 'pointer',
|
|
15
|
+
textDecorationLine: 'underline',
|
|
16
|
+
textDecorationStyle: 'solid',
|
|
17
|
+
borderRadius: radii.xs,
|
|
18
|
+
transitionProperty: 'color, text-decoration-color',
|
|
19
|
+
transitionDuration: duration.fast,
|
|
20
|
+
transitionTimingFunction: easing.standard,
|
|
21
|
+
outlineWidth: {
|
|
22
|
+
default: 0,
|
|
23
|
+
':focus-visible': borders.focusRing,
|
|
24
|
+
},
|
|
25
|
+
outlineStyle: {
|
|
26
|
+
default: 'none',
|
|
27
|
+
':focus-visible': 'solid',
|
|
28
|
+
},
|
|
29
|
+
outlineColor: {
|
|
30
|
+
default: 'transparent',
|
|
31
|
+
':focus-visible': colors.accent,
|
|
32
|
+
},
|
|
33
|
+
outlineOffset: {
|
|
34
|
+
default: 0,
|
|
35
|
+
':focus-visible': borders.focusOffset,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
default: {
|
|
39
|
+
color: {
|
|
40
|
+
default: colors.accent,
|
|
41
|
+
':hover': colors.accentHover,
|
|
42
|
+
},
|
|
43
|
+
textDecorationColor: {
|
|
44
|
+
default: colors.accent,
|
|
45
|
+
':hover': colors.accentHover,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
subtle: {
|
|
49
|
+
color: {
|
|
50
|
+
default: 'inherit',
|
|
51
|
+
':hover': colors.accent,
|
|
52
|
+
},
|
|
53
|
+
textDecorationColor: {
|
|
54
|
+
default: colors.textMuted,
|
|
55
|
+
':hover': colors.accent,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
})
|
|
@@ -163,3 +163,53 @@ export const AllThemes: Story = {
|
|
|
163
163
|
await expect(canvas.getByText('high-contrast')).toBeInTheDocument()
|
|
164
164
|
},
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
const contrastStyles = css.create({
|
|
168
|
+
surface: {
|
|
169
|
+
padding: spacing.md,
|
|
170
|
+
borderRadius: radii.md,
|
|
171
|
+
backgroundColor: colors.contrastSurface,
|
|
172
|
+
color: colors.onContrastSurface,
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
/** Parse a computed `rgb(r, g, b)` / `rgba(r, g, b, a)` colour. */
|
|
177
|
+
function rgbOf(value: string): [number, number, number] {
|
|
178
|
+
const parts = value.match(/\d+(\.\d+)?/g)?.map(Number) ?? []
|
|
179
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** WCAG 2.x relative luminance contrast ratio. */
|
|
183
|
+
function contrastRatio(a: string, b: string): number {
|
|
184
|
+
const lum = (rgb: [number, number, number]) => {
|
|
185
|
+
const [r, g, bl] = rgb.map((c) => {
|
|
186
|
+
const s = c / 255
|
|
187
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
188
|
+
}) as [number, number, number]
|
|
189
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * bl
|
|
190
|
+
}
|
|
191
|
+
const [hi, lo] = [lum(rgbOf(a)), lum(rgbOf(b))].sort((x, y) => y - x) as [number, number]
|
|
192
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// contrastSurface takes the opposite tone of the theme; its text must still
|
|
196
|
+
// read at 4.5:1 or better in each one.
|
|
197
|
+
export const ContrastSurfaceInEveryTheme: Story = {
|
|
198
|
+
render: () => (
|
|
199
|
+
<html.div style={sideBySideStyles.wrapper}>
|
|
200
|
+
{(['dark', 'light', 'high-contrast'] as const).map((theme) => (
|
|
201
|
+
<ThemeProvider key={theme} theme={theme}>
|
|
202
|
+
<html.div style={contrastStyles.surface}>{`Contrast surface (${theme})`}</html.div>
|
|
203
|
+
</ThemeProvider>
|
|
204
|
+
))}
|
|
205
|
+
</html.div>
|
|
206
|
+
),
|
|
207
|
+
play: async ({canvas}) => {
|
|
208
|
+
for (const theme of ['dark', 'light', 'high-contrast']) {
|
|
209
|
+
const el = canvas.getByText(`Contrast surface (${theme})`)
|
|
210
|
+
const style = getComputedStyle(el)
|
|
211
|
+
const ratio = contrastRatio(style.color, style.backgroundColor)
|
|
212
|
+
await expect(ratio).toBeGreaterThanOrEqual(4.5)
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Text for assistive tech only: kept in the accessibility tree, clipped off screen. Gives words to an icon-only control or a visual-only cue.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A label for an icon inside a control that has no aria-label prop',
|
|
8
|
+
'Spelling out what a colour or icon shows (a status dot, "(opens in a new tab)")',
|
|
9
|
+
'A heading that structures a page for screen readers without a visible title',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Naming a control that takes aria-label — pass aria-label instead',
|
|
13
|
+
'Hiding content from everyone — render nothing, or use display: none',
|
|
14
|
+
],
|
|
15
|
+
example: `<Button variant="secondary">
|
|
16
|
+
<Icon name="search" size="sm" />
|
|
17
|
+
<VisuallyHidden>Search</VisuallyHidden>
|
|
18
|
+
</Button>`,
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect} from 'storybook/test'
|
|
3
|
+
import {VisuallyHidden} from './VisuallyHidden'
|
|
4
|
+
import {Button} from '../Button/Button'
|
|
5
|
+
import {Icon} from '../Icon/Icon'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof VisuallyHidden> = {
|
|
8
|
+
title: 'Components/VisuallyHidden',
|
|
9
|
+
component: VisuallyHidden,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof VisuallyHidden>
|
|
14
|
+
|
|
15
|
+
export const NamesAnIconButton: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<Button variant="secondary">
|
|
18
|
+
<Icon name="search" size="sm" />
|
|
19
|
+
<VisuallyHidden>Search</VisuallyHidden>
|
|
20
|
+
</Button>
|
|
21
|
+
),
|
|
22
|
+
play: async ({canvas}) => {
|
|
23
|
+
const button = canvas.getByRole('button', {name: 'Search'})
|
|
24
|
+
await expect(button).toBeInTheDocument()
|
|
25
|
+
const text = canvas.getByText('Search')
|
|
26
|
+
const box = text.getBoundingClientRect()
|
|
27
|
+
await expect(box.width).toBeLessThanOrEqual(1)
|
|
28
|
+
await expect(box.height).toBeLessThanOrEqual(1)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {visuallyHidden} from '../../styles/visually-hidden.css'
|
|
4
|
+
|
|
5
|
+
interface VisuallyHiddenProps {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
/** For aria-describedby / aria-labelledby to point at. */
|
|
8
|
+
id?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Text for assistive tech only: in the accessibility tree, not on screen. Use
|
|
13
|
+
* it to give an icon-only control or a visual-only cue words — a status a
|
|
14
|
+
* colour shows, "(opens in a new tab)" after a link.
|
|
15
|
+
*/
|
|
16
|
+
export function VisuallyHidden({children, id}: VisuallyHiddenProps) {
|
|
17
|
+
return (
|
|
18
|
+
<html.span id={id} style={[visuallyHidden.base, visuallyHidden.text]}>
|
|
19
|
+
{children}
|
|
20
|
+
</html.span>
|
|
21
|
+
)
|
|
22
|
+
}
|