@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.
- package/dist/{Table-ypIvytAS.js → Table-IuD0tooi.js} +56 -49
- package/dist/Table-IuD0tooi.js.map +1 -0
- package/dist/{compiler-runtime-BuugS4D0.js → compiler-runtime-CV6vsf8h.js} +80 -73
- package/dist/{compiler-runtime-BuugS4D0.js.map → compiler-runtime-CV6vsf8h.js.map} +1 -1
- package/dist/components/Avatar/Avatar.d.ts +20 -0
- package/dist/components/Avatar/Avatar.d.ts.map +1 -0
- package/dist/components/Avatar/Avatar.meta.d.ts +3 -0
- package/dist/components/Avatar/Avatar.meta.d.ts.map +1 -0
- package/dist/components/Avatar/Avatar.stories.d.ts +13 -0
- package/dist/components/Avatar/Avatar.stories.d.ts.map +1 -0
- package/dist/components/Avatar/styles.css.d.ts +41 -0
- package/dist/components/Avatar/styles.css.d.ts.map +1 -0
- package/dist/components/Icon/Icon.d.ts.map +1 -1
- package/dist/components/Icon/geometry.d.ts +4 -0
- package/dist/components/Icon/geometry.d.ts.map +1 -1
- package/dist/components/Icon/phosphor-glyphs.d.ts +1028 -0
- package/dist/components/Icon/phosphor-glyphs.d.ts.map +1 -0
- package/dist/components/Icon/types.d.ts +3 -1
- package/dist/components/Icon/types.d.ts.map +1 -1
- package/dist/components/List/ItemLink.d.ts +32 -0
- package/dist/components/List/ItemLink.d.ts.map +1 -0
- package/dist/components/List/List.d.ts +36 -4
- package/dist/components/List/List.d.ts.map +1 -1
- package/dist/components/List/List.stories.d.ts +9 -0
- package/dist/components/List/List.stories.d.ts.map +1 -1
- package/dist/components/List/styles.css.d.ts +39 -0
- package/dist/components/List/styles.css.d.ts.map +1 -1
- package/dist/form.js +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5578 -4353
- package/dist/index.js.map +1 -1
- package/dist/table.js +2 -2
- package/package.json +3 -2
- package/src/components/Avatar/Avatar.meta.ts +31 -0
- package/src/components/Avatar/Avatar.stories.tsx +79 -0
- package/src/components/Avatar/Avatar.tsx +42 -0
- package/src/components/Avatar/styles.css.ts +32 -0
- package/src/components/Icon/Icon.meta.ts +1 -1
- package/src/components/Icon/Icon.native.tsx +2 -2
- package/src/components/Icon/Icon.tsx +4 -3
- package/src/components/Icon/geometry.ts +7 -0
- package/src/components/Icon/phosphor-glyphs.ts +1285 -0
- package/src/components/Icon/phosphor-names.json +130 -0
- package/src/components/Icon/types.ts +4 -0
- package/src/components/List/ItemLink.native.tsx +60 -0
- package/src/components/List/ItemLink.tsx +80 -0
- package/src/components/List/List.meta.ts +3 -3
- package/src/components/List/List.stories.tsx +91 -1
- package/src/components/List/List.tsx +130 -22
- package/src/components/List/styles.css.ts +45 -0
- package/src/index.native.ts +6 -0
- package/src/index.ts +6 -0
- 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
|
-
'
|
|
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({
|
|
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
|
-
|
|
35
|
-
{
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
76
|
-
// readers reach.
|
|
77
|
-
onClick={disabled ? undefined :
|
|
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
|
-
|
|
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,
|