@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.
Files changed (139) hide show
  1. package/dist/Table-C70zdSkM.js +1419 -0
  2. package/dist/Table-C70zdSkM.js.map +1 -0
  3. package/dist/components/Button/Button.d.ts +11 -2
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/Button/Button.stories.d.ts +1 -0
  6. package/dist/components/Button/Button.stories.d.ts.map +1 -1
  7. package/dist/components/Card/Card.stories.d.ts.map +1 -1
  8. package/dist/components/Combobox/Combobox.d.ts +1 -1
  9. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  10. package/dist/components/Combobox/styles.css.d.ts +0 -64
  11. package/dist/components/Combobox/styles.css.d.ts.map +1 -1
  12. package/dist/components/Dialog/Dialog.d.ts +11 -3
  13. package/dist/components/Dialog/Dialog.d.ts.map +1 -1
  14. package/dist/components/Dialog/Dialog.stories.d.ts +4 -0
  15. package/dist/components/Dialog/Dialog.stories.d.ts.map +1 -1
  16. package/dist/components/Dialog/styles.css.d.ts +1 -0
  17. package/dist/components/Dialog/styles.css.d.ts.map +1 -1
  18. package/dist/components/DragDrop/styles.css.d.ts +10 -2
  19. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  20. package/dist/components/Grid/Grid.d.ts +11 -2
  21. package/dist/components/Grid/Grid.d.ts.map +1 -1
  22. package/dist/components/Grid/Grid.stories.d.ts +2 -0
  23. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  24. package/dist/components/Grid/styles.css.d.ts +2 -2
  25. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  26. package/dist/components/Input/Input.d.ts +6 -1
  27. package/dist/components/Input/Input.d.ts.map +1 -1
  28. package/dist/components/Input/Input.meta.d.ts.map +1 -1
  29. package/dist/components/Input/Input.stories.d.ts +1 -0
  30. package/dist/components/Input/Input.stories.d.ts.map +1 -1
  31. package/dist/components/Input/styles.css.d.ts +18 -0
  32. package/dist/components/Input/styles.css.d.ts.map +1 -1
  33. package/dist/components/InputGroup/InputGroup.d.ts +6 -1
  34. package/dist/components/InputGroup/InputGroup.d.ts.map +1 -1
  35. package/dist/components/InputGroup/InputGroup.stories.d.ts +1 -0
  36. package/dist/components/InputGroup/InputGroup.stories.d.ts.map +1 -1
  37. package/dist/components/Listbox/Listbox.d.ts +75 -0
  38. package/dist/components/Listbox/Listbox.d.ts.map +1 -0
  39. package/dist/components/Listbox/Listbox.meta.d.ts +3 -0
  40. package/dist/components/Listbox/Listbox.meta.d.ts.map +1 -0
  41. package/dist/components/Listbox/Listbox.stories.d.ts +7 -0
  42. package/dist/components/Listbox/Listbox.stories.d.ts.map +1 -0
  43. package/dist/components/Listbox/styles.css.d.ts +75 -0
  44. package/dist/components/Listbox/styles.css.d.ts.map +1 -0
  45. package/dist/components/Menu/Menu.d.ts +18 -5
  46. package/dist/components/Menu/Menu.d.ts.map +1 -1
  47. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  48. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  49. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  50. package/dist/components/Menu/MenuContext.d.ts +3 -0
  51. package/dist/components/Menu/MenuContext.d.ts.map +1 -1
  52. package/dist/components/Menu/styles.css.d.ts +24 -15
  53. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  54. package/dist/components/Menu/useMenuRoot.d.ts +14 -1
  55. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  56. package/dist/components/StatusIcon/StatusIcon.stories.d.ts.map +1 -1
  57. package/dist/components/Table/Table.stories.d.ts +1 -0
  58. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  59. package/dist/components/TextLink/TextLink.d.ts +24 -0
  60. package/dist/components/TextLink/TextLink.d.ts.map +1 -0
  61. package/dist/components/TextLink/TextLink.meta.d.ts +3 -0
  62. package/dist/components/TextLink/TextLink.meta.d.ts.map +1 -0
  63. package/dist/components/TextLink/TextLink.stories.d.ts +9 -0
  64. package/dist/components/TextLink/TextLink.stories.d.ts.map +1 -0
  65. package/dist/components/TextLink/styles.css.d.ts +28 -0
  66. package/dist/components/TextLink/styles.css.d.ts.map +1 -0
  67. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts +1 -0
  68. package/dist/components/ThemeProvider/ThemeProvider.stories.d.ts.map +1 -1
  69. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts +14 -0
  70. package/dist/components/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  71. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts +3 -0
  72. package/dist/components/VisuallyHidden/VisuallyHidden.meta.d.ts.map +1 -0
  73. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts +7 -0
  74. package/dist/components/VisuallyHidden/VisuallyHidden.stories.d.ts.map +1 -0
  75. package/dist/docs/FlexGrow.stories.d.ts +7 -0
  76. package/dist/docs/FlexGrow.stories.d.ts.map +1 -0
  77. package/dist/index.css +1 -1
  78. package/dist/index.d.ts +5 -0
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +2986 -2821
  81. package/dist/index.js.map +1 -1
  82. package/dist/index.native.d.ts +1 -0
  83. package/dist/index.native.d.ts.map +1 -1
  84. package/dist/shared/length.d.ts +14 -9
  85. package/dist/shared/length.d.ts.map +1 -1
  86. package/dist/shared/length.typecheck.d.ts +3 -0
  87. package/dist/shared/length.typecheck.d.ts.map +1 -1
  88. package/dist/styles/visually-hidden.css.d.ts +6 -0
  89. package/dist/styles/visually-hidden.css.d.ts.map +1 -1
  90. package/dist/table.js +1 -1
  91. package/package.json +2 -2
  92. package/src/components/Button/Button.stories.tsx +41 -0
  93. package/src/components/Button/Button.tsx +16 -1
  94. package/src/components/Card/Card.stories.tsx +2 -1
  95. package/src/components/Combobox/Combobox.tsx +25 -79
  96. package/src/components/Combobox/styles.css.ts +0 -74
  97. package/src/components/Dialog/Dialog.meta.ts +1 -1
  98. package/src/components/Dialog/Dialog.stories.tsx +109 -3
  99. package/src/components/Dialog/Dialog.tsx +63 -9
  100. package/src/components/Dialog/styles.css.ts +2 -0
  101. package/src/components/DragDrop/styles.css.ts +16 -3
  102. package/src/components/Grid/Grid.meta.ts +1 -1
  103. package/src/components/Grid/Grid.stories.tsx +53 -1
  104. package/src/components/Grid/Grid.tsx +23 -11
  105. package/src/components/Grid/styles.css.ts +3 -2
  106. package/src/components/Input/Input.meta.ts +10 -3
  107. package/src/components/Input/Input.stories.tsx +52 -2
  108. package/src/components/Input/Input.tsx +12 -3
  109. package/src/components/Input/styles.css.ts +29 -0
  110. package/src/components/InputGroup/InputGroup.stories.tsx +20 -0
  111. package/src/components/InputGroup/InputGroup.tsx +20 -2
  112. package/src/components/Listbox/Listbox.meta.ts +52 -0
  113. package/src/components/Listbox/Listbox.stories.tsx +170 -0
  114. package/src/components/Listbox/Listbox.tsx +278 -0
  115. package/src/components/Listbox/styles.css.ts +89 -0
  116. package/src/components/Menu/Menu.meta.ts +2 -1
  117. package/src/components/Menu/Menu.stories.tsx +289 -8
  118. package/src/components/Menu/Menu.tsx +100 -29
  119. package/src/components/Menu/MenuContext.ts +3 -0
  120. package/src/components/Menu/styles.css.ts +31 -15
  121. package/src/components/Menu/useMenuRoot.ts +105 -48
  122. package/src/components/StatusIcon/StatusIcon.stories.tsx +2 -2
  123. package/src/components/Table/Table.stories.tsx +39 -0
  124. package/src/components/TextLink/TextLink.meta.ts +33 -0
  125. package/src/components/TextLink/TextLink.stories.tsx +63 -0
  126. package/src/components/TextLink/TextLink.tsx +46 -0
  127. package/src/components/TextLink/styles.css.ts +58 -0
  128. package/src/components/ThemeProvider/ThemeProvider.stories.tsx +50 -0
  129. package/src/components/VisuallyHidden/VisuallyHidden.meta.ts +19 -0
  130. package/src/components/VisuallyHidden/VisuallyHidden.stories.tsx +30 -0
  131. package/src/components/VisuallyHidden/VisuallyHidden.tsx +22 -0
  132. package/src/docs/FlexGrow.stories.tsx +83 -0
  133. package/src/index.native.ts +1 -0
  134. package/src/index.ts +5 -0
  135. package/src/shared/length.ts +29 -10
  136. package/src/shared/length.typecheck.tsx +9 -0
  137. package/src/styles/visually-hidden.css.ts +8 -0
  138. package/dist/Table-BKN_d-mp.js +0 -1192
  139. 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 function useMenuRoot() {
5
- const [open, setOpen] = useState(false)
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 needsInitialHighlightRef = useRef(false)
37
+ const openRef = useRef(open)
38
+ openRef.current = open
39
+ const onOpenChangeRef = useRef(onOpenChange)
40
+ onOpenChangeRef.current = onOpenChange
13
41
 
14
- const close = useCallback(() => {
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
- needsInitialHighlightRef.current = false
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
- setOpen((prev) => {
23
- if (!prev) {
24
- needsInitialHighlightRef.current = true
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
- // Highlight the first item after items register on open.
34
- // Child effects (item registration) run before this parent effect,
35
- // so orderRef is populated by the time this runs.
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 && needsInitialHighlightRef.current) {
38
- needsInitialHighlightRef.current = false
39
- const order = orderRef.current
40
- if (order.length > 0) {
41
- setHighlightedId(order[0])
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 (preventDefault)
108
+ // Native keydown on the popup element, for full KeyboardEvent access.
64
109
  useEffect(() => {
65
- const root = rootRef.current
66
- if (!root || !open) return
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 items = itemsRef.current
103
- setHighlightedId((prev) => {
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
- root.addEventListener('keydown', handleKeyDown)
125
- return () => root.removeEventListener('keydown', handleKeyDown)
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: 'repeat(auto-fill, minmax(200px, 1fr))',
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
+ }