@duro-app/ui 5.32.0 → 5.34.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 (55) hide show
  1. package/dist/{Table-ypIvytAS.js → Table-IuD0tooi.js} +56 -49
  2. package/dist/Table-IuD0tooi.js.map +1 -0
  3. package/dist/{compiler-runtime-BuugS4D0.js → compiler-runtime-CV6vsf8h.js} +80 -73
  4. package/dist/{compiler-runtime-BuugS4D0.js.map → compiler-runtime-CV6vsf8h.js.map} +1 -1
  5. package/dist/components/Avatar/Avatar.d.ts +20 -0
  6. package/dist/components/Avatar/Avatar.d.ts.map +1 -0
  7. package/dist/components/Avatar/Avatar.meta.d.ts +3 -0
  8. package/dist/components/Avatar/Avatar.meta.d.ts.map +1 -0
  9. package/dist/components/Avatar/Avatar.stories.d.ts +13 -0
  10. package/dist/components/Avatar/Avatar.stories.d.ts.map +1 -0
  11. package/dist/components/Avatar/styles.css.d.ts +41 -0
  12. package/dist/components/Avatar/styles.css.d.ts.map +1 -0
  13. package/dist/components/Icon/Icon.d.ts.map +1 -1
  14. package/dist/components/Icon/geometry.d.ts +4 -0
  15. package/dist/components/Icon/geometry.d.ts.map +1 -1
  16. package/dist/components/Icon/phosphor-glyphs.d.ts +1028 -0
  17. package/dist/components/Icon/phosphor-glyphs.d.ts.map +1 -0
  18. package/dist/components/Icon/types.d.ts +3 -1
  19. package/dist/components/Icon/types.d.ts.map +1 -1
  20. package/dist/components/List/ItemLink.d.ts +32 -0
  21. package/dist/components/List/ItemLink.d.ts.map +1 -0
  22. package/dist/components/List/List.d.ts +36 -4
  23. package/dist/components/List/List.d.ts.map +1 -1
  24. package/dist/components/List/List.stories.d.ts +9 -0
  25. package/dist/components/List/List.stories.d.ts.map +1 -1
  26. package/dist/components/List/styles.css.d.ts +39 -0
  27. package/dist/components/List/styles.css.d.ts.map +1 -1
  28. package/dist/form.js +1 -1
  29. package/dist/index.css +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5578 -4353
  33. package/dist/index.js.map +1 -1
  34. package/dist/table.js +2 -2
  35. package/package.json +3 -2
  36. package/src/components/Avatar/Avatar.meta.ts +31 -0
  37. package/src/components/Avatar/Avatar.stories.tsx +79 -0
  38. package/src/components/Avatar/Avatar.tsx +42 -0
  39. package/src/components/Avatar/styles.css.ts +32 -0
  40. package/src/components/Icon/Icon.meta.ts +1 -1
  41. package/src/components/Icon/Icon.native.tsx +2 -2
  42. package/src/components/Icon/Icon.tsx +4 -3
  43. package/src/components/Icon/geometry.ts +7 -0
  44. package/src/components/Icon/phosphor-glyphs.ts +1285 -0
  45. package/src/components/Icon/phosphor-names.json +130 -0
  46. package/src/components/Icon/types.ts +4 -0
  47. package/src/components/List/ItemLink.native.tsx +60 -0
  48. package/src/components/List/ItemLink.tsx +80 -0
  49. package/src/components/List/List.meta.ts +3 -3
  50. package/src/components/List/List.stories.tsx +91 -1
  51. package/src/components/List/List.tsx +130 -22
  52. package/src/components/List/styles.css.ts +45 -0
  53. package/src/index.native.ts +6 -0
  54. package/src/index.ts +6 -0
  55. package/dist/Table-ypIvytAS.js.map +0 -1
@@ -0,0 +1,130 @@
1
+ [
2
+ "arrow-circle-down",
3
+ "arrow-circle-right",
4
+ "arrow-circle-up",
5
+ "arrow-clockwise",
6
+ "arrow-down",
7
+ "arrow-elbow-down-right",
8
+ "arrow-left",
9
+ "arrow-right",
10
+ "arrow-square-out",
11
+ "arrows-clockwise",
12
+ "arrows-down-up",
13
+ "arrows-left-right",
14
+ "arrows-out",
15
+ "article",
16
+ "at",
17
+ "bell",
18
+ "bell-slash",
19
+ "bookmark",
20
+ "books",
21
+ "browsers",
22
+ "bug",
23
+ "calendar",
24
+ "caret-down",
25
+ "caret-left",
26
+ "chart-line",
27
+ "chat",
28
+ "chat-circle-dots",
29
+ "chat-dots",
30
+ "chats-circle",
31
+ "check",
32
+ "checks",
33
+ "circle",
34
+ "circle-half",
35
+ "clipboard",
36
+ "clock-counter-clockwise",
37
+ "cloud",
38
+ "cloud-arrow-down",
39
+ "cloud-arrow-up",
40
+ "cloud-check",
41
+ "cloud-slash",
42
+ "code",
43
+ "copy",
44
+ "cpu",
45
+ "crosshair",
46
+ "cube",
47
+ "desktop",
48
+ "device-mobile",
49
+ "download",
50
+ "envelope",
51
+ "envelope-simple-open",
52
+ "eye",
53
+ "eye-slash",
54
+ "file-arrow-down",
55
+ "file-arrow-up",
56
+ "files",
57
+ "first-aid-kit",
58
+ "flask",
59
+ "floppy-disk",
60
+ "folder",
61
+ "folder-open",
62
+ "funnel-simple",
63
+ "git-diff",
64
+ "github-logo",
65
+ "globe",
66
+ "graph",
67
+ "grid-four",
68
+ "hammer",
69
+ "hand",
70
+ "hard-drive-download",
71
+ "hard-drives",
72
+ "heart",
73
+ "hourglass",
74
+ "info",
75
+ "laptop",
76
+ "leaf",
77
+ "lightbulb",
78
+ "lightning",
79
+ "link",
80
+ "list",
81
+ "magnifying-glass",
82
+ "microphone",
83
+ "minus-circle",
84
+ "navigation-arrow",
85
+ "palette",
86
+ "paper-plane",
87
+ "paper-plane-tilt",
88
+ "paperclip",
89
+ "paw-print",
90
+ "pencil",
91
+ "pencil-simple",
92
+ "person",
93
+ "play-circle",
94
+ "plus",
95
+ "plus-circle",
96
+ "pulse",
97
+ "push-pin",
98
+ "puzzle-piece",
99
+ "qr-code",
100
+ "question",
101
+ "radio-button",
102
+ "resize",
103
+ "robot",
104
+ "rocket",
105
+ "share",
106
+ "sign-in",
107
+ "sign-out",
108
+ "sliders-horizontal",
109
+ "sparkle",
110
+ "speaker-high",
111
+ "speaker-slash",
112
+ "speedometer",
113
+ "squares-four",
114
+ "stack",
115
+ "stack-simple",
116
+ "star",
117
+ "terminal",
118
+ "text-aa",
119
+ "timer",
120
+ "toggle-right",
121
+ "translate",
122
+ "trash",
123
+ "tray",
124
+ "user-circle",
125
+ "warning",
126
+ "warning-circle",
127
+ "wifi-high",
128
+ "windows-logo",
129
+ "wrench"
130
+ ]
@@ -1,8 +1,12 @@
1
1
  import type {ColorToken, IconSize} from '@duro-app/tokens/keys'
2
2
 
3
+ import type {PhosphorIconName} from './phosphor-glyphs'
4
+
3
5
  export type {IconSize}
4
6
 
7
+ /** Duro's own glyphs, then the Phosphor-generated ones Happier's React Native icon names use. */
5
8
  export type IconName =
9
+ | PhosphorIconName
6
10
  | 'x-circle'
7
11
  | 'check-circle'
8
12
  | 'check-done'
@@ -0,0 +1,60 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ // Explicit suffix: a native-only module has no web file for moduleSuffixes to pair.
4
+ import {followLink} from '../../shared/followLink.native'
5
+ import type {OnNavigate} from '../../shared/navigate'
6
+ import {styles} from './styles.css'
7
+
8
+ /** Nothing to share on native: the link answers its own press and stops it there. */
9
+ export type ItemLinkState = Record<string, never>
10
+
11
+ export function createItemLinkState(): ItemLinkState {
12
+ return {}
13
+ }
14
+
15
+ /** The modifier keys of the row's press (unused on native). */
16
+ export interface RowPress {
17
+ altKey: boolean
18
+ ctrlKey: boolean
19
+ metaKey: boolean
20
+ shiftKey: boolean
21
+ button: number
22
+ }
23
+
24
+ interface ItemLinkProps {
25
+ state: ItemLinkState
26
+ href: string
27
+ onNavigate: OnNavigate | undefined
28
+ children: ReactNode
29
+ }
30
+
31
+ /**
32
+ * The Content of a List.Item with an href, on native (ADR-0002, native addendum): a
33
+ * `role="link"` that renders no `href` and follows the press through `followLink`.
34
+ */
35
+ export function ItemLink({href, onNavigate, children}: ItemLinkProps) {
36
+ return (
37
+ <html.div
38
+ role="link"
39
+ onClick={(event) => {
40
+ // Stopped, so the row never follows it a second time (native has no window listener
41
+ // a stopped press could hide).
42
+ event.stopPropagation()
43
+ followLink(href, onNavigate, 'List.Item')
44
+ }}
45
+ style={[styles.content, styles.contentLink]}
46
+ >
47
+ {children}
48
+ </html.div>
49
+ )
50
+ }
51
+
52
+ /** A press on the row reaches here only from outside the link: it follows the link. */
53
+ export function pressItemLink(
54
+ _state: ItemLinkState,
55
+ href: string,
56
+ onNavigate: OnNavigate | undefined,
57
+ _event: RowPress,
58
+ ) {
59
+ followLink(href, onNavigate, 'List.Item')
60
+ }
@@ -0,0 +1,80 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {linkClickHandler, type OnNavigate} from '../../shared/navigate'
4
+ import {styles} from './styles.css'
5
+
6
+ /** What a link row shares with its link: the anchor, and whether the press was the link's. */
7
+ export interface ItemLinkState {
8
+ anchor: HTMLAnchorElement | null
9
+ linkPressed: boolean
10
+ }
11
+
12
+ export function createItemLinkState(): ItemLinkState {
13
+ return {anchor: null, linkPressed: false}
14
+ }
15
+
16
+ /** The modifier keys and button of the row's press, replayed on the link. */
17
+ export interface RowPress {
18
+ altKey: boolean
19
+ ctrlKey: boolean
20
+ metaKey: boolean
21
+ shiftKey: boolean
22
+ button: number
23
+ }
24
+
25
+ interface ItemLinkProps {
26
+ state: ItemLinkState
27
+ href: string
28
+ onNavigate: OnNavigate | undefined
29
+ children: ReactNode
30
+ }
31
+
32
+ /** The Content of a List.Item with an href: a real `<a href>` (ADR-0002). */
33
+ export function ItemLink({state, href, onNavigate, children}: ItemLinkProps) {
34
+ const handler = linkClickHandler(href, onNavigate)
35
+ return (
36
+ <html.a
37
+ ref={(anchor: HTMLAnchorElement | null) => {
38
+ state.anchor = anchor
39
+ }}
40
+ href={href}
41
+ onClick={(event) => {
42
+ // Marked rather than stopped: stopping it would hide the click from window listeners.
43
+ state.linkPressed = true
44
+ handler?.(event)
45
+ }}
46
+ style={[styles.content, styles.contentLink]}
47
+ >
48
+ {children}
49
+ </html.a>
50
+ )
51
+ }
52
+
53
+ /**
54
+ * A press on the row: the link's own press bubbling up is left alone; any other (the Leading
55
+ * icon, the padding) is replayed on the anchor with the same button and modifier keys, so the
56
+ * browser and `onNavigate` see one link click (a cmd-click still opens a tab, ADR-0002).
57
+ */
58
+ export function pressItemLink(
59
+ state: ItemLinkState,
60
+ _href: string,
61
+ _onNavigate: OnNavigate | undefined,
62
+ press: RowPress,
63
+ ) {
64
+ if (state.linkPressed) {
65
+ state.linkPressed = false
66
+ return
67
+ }
68
+ const {altKey, ctrlKey, metaKey, shiftKey, button} = press
69
+ state.anchor?.dispatchEvent(
70
+ new MouseEvent('click', {
71
+ bubbles: true,
72
+ cancelable: true,
73
+ altKey,
74
+ ctrlKey,
75
+ metaKey,
76
+ shiftKey,
77
+ button,
78
+ }),
79
+ )
80
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Vertical list of interactive items. Ideal for narrow spaces (detail panels, sidebars) where Table would truncate. Supports selection, descriptions, and action slots. An Item with onClick is pressable anywhere on its row, and its Content becomes a button: focusable, pressed with Enter or Space, announced with its text, disabled with the Item; the Actions stay beside it, so a Button there never nests in it.',
5
+ 'Vertical list of interactive items. Ideal for narrow spaces (detail panels, sidebars) where Table would truncate. Supports selection, descriptions, and action slots. An Item with onClick is pressable anywhere on its row, and its Content becomes a button: focusable, pressed with Enter or Space, announced with its text, disabled with the Item; the Actions stay beside it, so a Button there never nests in it. An Item with href (and onNavigate for client-side routing, ADR-0002) makes its Content a real link instead: a settings-style row that goes somewhere; a disabled link Item is plain text. List.Leading holds an icon or an Avatar before the Content (decorative unless given an aria-label). List.Root takes a title, which names the list, and a footer of muted text under it.',
6
6
  whenToUse: [
7
7
  'Displaying items in a narrow container (< 480px)',
8
8
  'Detail panels, drawers, and sidebars',
@@ -10,12 +10,12 @@ export const meta: ComponentMeta = {
10
10
  ],
11
11
  whenNotToUse: [
12
12
  'Tabular data with many columns — use Table',
13
- 'Navigation menus — use SideNav or Menu',
13
+ "An app's main navigation — use SideNav or AppShell (a List of link Items is for rows that each open a page, like a settings index)",
14
14
  'Simple text lists without interaction — use Stack with Text',
15
15
  ],
16
16
  anatomy: {
17
17
  required: ['Root', 'Item', 'Content', 'Text'],
18
- optional: ['Description', 'Actions', 'Empty'],
18
+ optional: ['Leading', 'Description', 'Actions', 'Empty'],
19
19
  },
20
20
  relatedTo: [
21
21
  {
@@ -1,8 +1,12 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {useState} from 'react'
3
- import {expect} from 'storybook/test'
3
+ import {expect, fn} from 'storybook/test'
4
4
  import {css, html} from 'react-strict-dom'
5
5
  import {List} from './List'
6
+ import {clickLink} from '../../docs/clickLink'
7
+ import {onThemeSurface} from '../../docs/themedSurface'
8
+ import type {OnNavigate} from '../../shared/navigate'
9
+ import {Icon} from '../Icon/Icon'
6
10
  import {Badge} from '../Badge/Badge'
7
11
  import {Button} from '../Button/Button'
8
12
  import {Checkbox} from '../Checkbox/Checkbox'
@@ -275,3 +279,89 @@ export const ClickableItems: Story = {
275
279
  await expect(buttons[2]).toBeDisabled()
276
280
  },
277
281
  }
282
+
283
+ /**
284
+ * Settings-style rows: each Item with an `href` is a real link (its Content), with a
285
+ * category-coloured icon in the Leading slot, a title naming the list and a footer under it.
286
+ * `onNavigate` gets a plain primary click; a modified click keeps the browser default.
287
+ */
288
+ export const LinkItems: StoryObj<{onNavigate: OnNavigate}> = {
289
+ parameters: {a11y: {test: 'error'}},
290
+ decorators: [onThemeSurface],
291
+ args: {onNavigate: fn()},
292
+ render: (args) => (
293
+ <List.Root title="Account" footer="Signed in on this device.">
294
+ <List.Item href="#account" onNavigate={args.onNavigate}>
295
+ <List.Leading>
296
+ <Icon name="user-circle" size="md" color="categoryBlue" />
297
+ </List.Leading>
298
+ <List.Content>
299
+ <List.Text>Account</List.Text>
300
+ <List.Description>Profile, sign-in, devices</List.Description>
301
+ </List.Content>
302
+ </List.Item>
303
+ <List.Item href="#appearance" onNavigate={args.onNavigate}>
304
+ <List.Leading>
305
+ <Icon name="palette" size="md" color="categoryIndigo" />
306
+ </List.Leading>
307
+ <List.Content>
308
+ <List.Text>Appearance</List.Text>
309
+ </List.Content>
310
+ </List.Item>
311
+ <List.Item href="#usage" disabled>
312
+ <List.Leading>
313
+ <Icon name="chart-line" size="md" color="categoryOrange" />
314
+ </List.Leading>
315
+ <List.Content>
316
+ <List.Text>Usage</List.Text>
317
+ </List.Content>
318
+ </List.Item>
319
+ </List.Root>
320
+ ),
321
+ play: async ({args, canvas}) => {
322
+ const onNavigate = args.onNavigate as unknown as ReturnType<typeof fn>
323
+ onNavigate.mockImplementation((_href: string, event: {preventDefault(): void}) =>
324
+ event.preventDefault(),
325
+ )
326
+ // The title names the list; the footer follows it.
327
+ await expect(canvas.getByRole('list', {name: 'Account'})).toBeInTheDocument()
328
+ await expect(canvas.getByText('Signed in on this device.')).toBeInTheDocument()
329
+
330
+ const account = canvas.getByRole('link', {name: /Account/})
331
+ await expect(account).toHaveAttribute('href', '#account')
332
+ await expect(clickLink(account).defaultPrevented).toBe(true)
333
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
334
+ await expect(onNavigate.mock.calls[0][0]).toBe('#account')
335
+ for (const init of [{metaKey: true}, {ctrlKey: true}, {shiftKey: true}]) {
336
+ await expect(clickLink(account, init).defaultPrevented).toBe(false)
337
+ }
338
+ await expect(onNavigate).toHaveBeenCalledTimes(1)
339
+
340
+ // A press on the row outside the text (its Leading icon) follows the link once.
341
+ const appearanceRow = canvas
342
+ .getByRole('link', {name: 'Appearance'})
343
+ .closest('[role="listitem"]')
344
+ ;(appearanceRow?.firstElementChild as HTMLElement).click()
345
+ await expect(onNavigate).toHaveBeenCalledTimes(2)
346
+ await expect(onNavigate.mock.calls[1][0]).toBe('#appearance')
347
+ // A cmd-click there keeps the browser default (a new tab), as on the link itself.
348
+ const leading = appearanceRow?.firstElementChild as HTMLElement
349
+ let replayed = false
350
+ const onWindowClick = (event: MouseEvent) => {
351
+ // The replayed anchor click, then the outer click on the icon: keep the first answer.
352
+ if (event.target instanceof HTMLAnchorElement) {
353
+ replayed = event.metaKey && !event.defaultPrevented
354
+ }
355
+ event.preventDefault()
356
+ }
357
+ window.addEventListener('click', onWindowClick)
358
+ leading.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, metaKey: true}))
359
+ window.removeEventListener('click', onWindowClick)
360
+ await expect(replayed).toBe(true)
361
+ await expect(onNavigate).toHaveBeenCalledTimes(2)
362
+
363
+ // A disabled link item is plain text, not a link; the Leading icon is decorative.
364
+ await expect(canvas.getAllByRole('link')).toHaveLength(2)
365
+ await expect(canvas.queryByRole('link', {name: 'Usage'})).toBeNull()
366
+ },
367
+ }
@@ -1,5 +1,13 @@
1
- import {type ReactNode, createContext, useContext, useMemo} from 'react'
1
+ import {type ReactNode, createContext, useContext, useId, useMemo, useState} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
+ import type {OnNavigate} from '../../shared/navigate'
4
+ import {
5
+ ItemLink,
6
+ createItemLinkState,
7
+ pressItemLink,
8
+ type ItemLinkState,
9
+ type RowPress,
10
+ } from './ItemLink'
3
11
  import {styles} from './styles.css'
4
12
 
5
13
  // --- Types ---
@@ -24,47 +32,115 @@ interface RootProps {
24
32
  children: ReactNode
25
33
  /** Selection mode: 'none' (default), 'single', or 'multiple' */
26
34
  selectionMode?: ListSelectionMode
27
- /** Accessible label for the list */
35
+ /** Accessible label for the list; a `title` names the list instead. */
28
36
  'aria-label'?: string
37
+ /** A heading-styled label above the list, which also names the list (aria-labelledby). */
38
+ title?: ReactNode
39
+ /** Muted text after the list: a count, a hint, a note. */
40
+ footer?: ReactNode
29
41
  }
30
42
 
31
- function Root({children, selectionMode = 'none', 'aria-label': ariaLabel}: RootProps) {
43
+ function Root({
44
+ children,
45
+ selectionMode = 'none',
46
+ 'aria-label': ariaLabel,
47
+ title,
48
+ footer,
49
+ }: RootProps) {
50
+ const titleId = useId()
51
+ const hasTitle = title != null
52
+ const list = (
53
+ <html.div
54
+ role="list"
55
+ aria-label={hasTitle ? undefined : ariaLabel}
56
+ aria-labelledby={hasTitle ? titleId : undefined}
57
+ style={styles.root}
58
+ >
59
+ {children}
60
+ </html.div>
61
+ )
32
62
  return (
33
63
  <ListContext.Provider value={{selectionMode}}>
34
- <html.div role="list" aria-label={ariaLabel} style={styles.root}>
35
- {children}
36
- </html.div>
64
+ {hasTitle || footer != null ? (
65
+ <html.div style={styles.frame}>
66
+ {hasTitle ? (
67
+ <html.p id={titleId} style={styles.title}>
68
+ {title}
69
+ </html.p>
70
+ ) : null}
71
+ {list}
72
+ {footer != null ? <html.p style={styles.footer}>{footer}</html.p> : null}
73
+ </html.div>
74
+ ) : (
75
+ list
76
+ )}
37
77
  </ListContext.Provider>
38
78
  )
39
79
  }
40
80
 
41
81
  // --- Item ---
42
82
 
43
- interface ItemProps {
83
+ interface ItemBaseProps {
44
84
  children: ReactNode
45
85
  /** Whether this item is selected */
46
86
  selected?: boolean
47
87
  /** Whether this item is disabled */
48
88
  disabled?: boolean
89
+ }
90
+
91
+ interface PressItemProps extends ItemBaseProps {
49
92
  /** Click handler for the item */
50
93
  onClick?: () => void
94
+ href?: undefined
95
+ onNavigate?: undefined
51
96
  }
52
97
 
53
- // What a clickable Item hands its Content, which becomes the item's button.
54
- interface ItemActionValue {
55
- onClick: () => void
56
- disabled: boolean
98
+ interface LinkItemProps extends ItemBaseProps {
99
+ /**
100
+ * Makes the item's Content a link: a real `<a href>` on the web (a press through
101
+ * `followLink` on native, ADR-0002). An item is a link or pressable, never both: `href`
102
+ * excludes `onClick`.
103
+ */
104
+ href: string
105
+ /**
106
+ * Client-side navigation: called for a plain primary click (no modifier key); the browser
107
+ * default runs for every other click. Call `event.preventDefault()`, then your router's
108
+ * navigate. On native, without it a relative href goes to the system.
109
+ */
110
+ onNavigate?: OnNavigate
111
+ onClick?: undefined
57
112
  }
58
113
 
59
- const ItemActionContext = createContext<ItemActionValue | null>(null)
114
+ type ItemProps = PressItemProps | LinkItemProps
115
+
116
+ // What a pressable or linked Item hands its Content, which becomes the item's button or link.
117
+ type ItemAction =
118
+ | {kind: 'press'; onClick: () => void; disabled: boolean}
119
+ | {
120
+ kind: 'link'
121
+ href: string
122
+ onNavigate: OnNavigate | undefined
123
+ disabled: boolean
124
+ linkState: ItemLinkState
125
+ }
60
126
 
61
- function Item({children, selected, disabled, onClick}: ItemProps) {
127
+ const ItemActionContext = createContext<ItemAction | null>(null)
128
+
129
+ function Item({children, selected, disabled, onClick, href, onNavigate}: ItemProps) {
62
130
  const {selectionMode} = useList()
63
131
  const isSelectable = selectionMode !== 'none'
64
- const action = useMemo(
65
- () => (onClick ? {onClick, disabled: disabled === true} : null),
66
- [onClick, disabled],
67
- )
132
+ const isDisabled = disabled === true
133
+ const [linkState] = useState(createItemLinkState)
134
+ const action = useMemo<ItemAction | null>(() => {
135
+ if (href !== undefined) return {kind: 'link', href, onNavigate, disabled: isDisabled, linkState}
136
+ return onClick ? {kind: 'press', onClick, disabled: isDisabled} : null
137
+ }, [href, onNavigate, onClick, isDisabled, linkState])
138
+ // A press anywhere on a link row (its Leading icon, its padding) follows the link, as a
139
+ // settings row does; a press on the Content's link is the link's own.
140
+ const onRowClick =
141
+ href !== undefined
142
+ ? (press: RowPress) => pressItemLink(linkState, href, onNavigate, press)
143
+ : onClick
68
144
 
69
145
  return (
70
146
  <ItemActionContext.Provider value={action}>
@@ -72,12 +148,12 @@ function Item({children, selected, disabled, onClick}: ItemProps) {
72
148
  role="listitem"
73
149
  aria-selected={isSelectable ? selected : undefined}
74
150
  aria-disabled={disabled || undefined}
75
- // A press anywhere on the row; the Content's button is what keyboards and screen
76
- // readers reach.
77
- onClick={disabled ? undefined : onClick}
151
+ // A press anywhere on the row; the Content's button or link is what keyboards and
152
+ // screen readers reach.
153
+ onClick={disabled ? undefined : onRowClick}
78
154
  style={[
79
155
  styles.item,
80
- onClick != null && !disabled && styles.itemClickable,
156
+ (onClick != null || href !== undefined) && !disabled && styles.itemClickable,
81
157
  selected && styles.itemSelected,
82
158
  disabled && styles.itemDisabled,
83
159
  ]}
@@ -92,7 +168,17 @@ function Item({children, selected, disabled, onClick}: ItemProps) {
92
168
 
93
169
  function Content({children}: {children: ReactNode}) {
94
170
  const action = useContext(ItemActionContext)
95
- if (!action) return <html.div style={styles.content}>{children}</html.div>
171
+ // A disabled link is plain text: an anchor without an href is not a link.
172
+ if (!action || (action.kind === 'link' && action.disabled)) {
173
+ return <html.div style={styles.content}>{children}</html.div>
174
+ }
175
+ if (action.kind === 'link') {
176
+ return (
177
+ <ItemLink state={action.linkState} href={action.href} onNavigate={action.onNavigate}>
178
+ {children}
179
+ </ItemLink>
180
+ )
181
+ }
96
182
  // In a clickable Item the Content is a real button: focusable, pressed with Enter or Space,
97
183
  // announced with its text. The Actions stay beside it, never nested inside.
98
184
  return (
@@ -111,6 +197,27 @@ function Content({children}: {children: ReactNode}) {
111
197
  )
112
198
  }
113
199
 
200
+ // --- Leading: icon or avatar before the Content ---
201
+
202
+ interface LeadingProps {
203
+ children: ReactNode
204
+ /** Names the slot for assistive tech; without it the slot is decorative and hidden from it. */
205
+ 'aria-label'?: string
206
+ }
207
+
208
+ function Leading({children, 'aria-label': ariaLabel}: LeadingProps) {
209
+ return (
210
+ <html.div
211
+ role={ariaLabel ? 'img' : undefined}
212
+ aria-label={ariaLabel}
213
+ aria-hidden={ariaLabel ? undefined : true}
214
+ style={styles.leading}
215
+ >
216
+ {children}
217
+ </html.div>
218
+ )
219
+ }
220
+
114
221
  // --- Text: primary label ---
115
222
 
116
223
  function ItemText({children}: {children: ReactNode}) {
@@ -144,6 +251,7 @@ function Empty({children}: {children: ReactNode}) {
144
251
  export const List = {
145
252
  Root,
146
253
  Item,
254
+ Leading,
147
255
  Content,
148
256
  Text: ItemText,
149
257
  Description,
@@ -14,6 +14,30 @@ export const styles = css.create({
14
14
  padding: 0,
15
15
  },
16
16
 
17
+ // Wraps the Root when it has a title or a footer.
18
+ frame: {
19
+ display: 'flex',
20
+ flexDirection: 'column',
21
+ gap: spacing.xs,
22
+ },
23
+ title: {
24
+ fontSize: typography.fontSizeSm,
25
+ fontWeight: typography.fontWeightSemibold,
26
+ lineHeight: typography.lineHeight,
27
+ color: colors.text,
28
+ margin: 0,
29
+ paddingLeft: spacing.ms,
30
+ paddingRight: spacing.ms,
31
+ },
32
+ footer: {
33
+ fontSize: typography.fontSizeXs,
34
+ lineHeight: typography.lineHeight,
35
+ color: colors.textMuted,
36
+ margin: 0,
37
+ paddingLeft: spacing.ms,
38
+ paddingRight: spacing.ms,
39
+ },
40
+
17
41
  // --- Item ---
18
42
  item: {
19
43
  display: 'flex',
@@ -78,6 +102,27 @@ export const styles = css.create({
78
102
  borderRadius: radii.xs,
79
103
  },
80
104
 
105
+ // The Content of an Item with an href: an anchor that reads as the row's text.
106
+ contentLink: {
107
+ textDecorationLine: 'none',
108
+ color: 'inherit',
109
+ cursor: 'pointer',
110
+ outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
111
+ outlineStyle: {default: 'none', ':focus-visible': 'solid'},
112
+ outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
113
+ outlineOffset: {default: 0, ':focus-visible': borders.focusOffset},
114
+ borderRadius: radii.xs,
115
+ },
116
+
117
+ // --- Leading slot (icon or avatar) ---
118
+ leading: {
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ flexShrink: 0,
123
+ color: colors.textMuted,
124
+ },
125
+
81
126
  // --- Primary text ---
82
127
  text: {
83
128
  fontSize: typography.fontSizeSm,