@duro-app/ui 5.28.0 → 5.29.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/components/List/List.d.ts.map +1 -1
- package/dist/components/List/List.stories.d.ts +1 -0
- package/dist/components/List/List.stories.d.ts.map +1 -1
- package/dist/components/List/styles.css.d.ts +19 -0
- package/dist/components/List/styles.css.d.ts.map +1 -1
- package/dist/index.js +2335 -2295
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/List/List.meta.ts +1 -1
- package/src/components/List/List.stories.tsx +54 -0
- package/src/components/List/List.tsx +49 -11
- package/src/components/List/styles.css.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"dependencies": {
|
|
93
93
|
"@tanstack/react-virtual": "^3.14.13",
|
|
94
94
|
"qrcode": "^1.5.4",
|
|
95
|
-
"@duro-app/tokens": "^5.
|
|
95
|
+
"@duro-app/tokens": "^5.29.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -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.',
|
|
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.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Displaying items in a narrow container (< 480px)',
|
|
8
8
|
'Detail panels, drawers, and sidebars',
|
|
@@ -221,3 +221,57 @@ function DetailPanelDemo() {
|
|
|
221
221
|
export const SingleSelection: Story = {
|
|
222
222
|
render: () => <DetailPanelDemo />,
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
// --- Clickable items ---
|
|
226
|
+
|
|
227
|
+
function ClickableDemo() {
|
|
228
|
+
const [picked, setPicked] = useState<string[]>([])
|
|
229
|
+
const turns = [
|
|
230
|
+
{
|
|
231
|
+
id: 't1',
|
|
232
|
+
text: 'Fix the login bug where a stale token logs the user out mid-session',
|
|
233
|
+
reply: 'Done',
|
|
234
|
+
},
|
|
235
|
+
{id: 't2', text: 'Add a test for it', reply: 'Added auth.test.ts'},
|
|
236
|
+
{id: 't3', text: 'Still sending', reply: null},
|
|
237
|
+
]
|
|
238
|
+
return (
|
|
239
|
+
<html.div style={narrowContainer.box}>
|
|
240
|
+
<List.Root aria-label="Turns">
|
|
241
|
+
{turns.map((turn) => (
|
|
242
|
+
<List.Item
|
|
243
|
+
key={turn.id}
|
|
244
|
+
disabled={turn.reply === null}
|
|
245
|
+
onClick={() => setPicked((all) => [...all, turn.id])}
|
|
246
|
+
>
|
|
247
|
+
<List.Content>
|
|
248
|
+
<List.Text>{turn.text}</List.Text>
|
|
249
|
+
{turn.reply ? <List.Description>{turn.reply}</List.Description> : null}
|
|
250
|
+
</List.Content>
|
|
251
|
+
<List.Actions>
|
|
252
|
+
<Badge size="sm">Turn</Badge>
|
|
253
|
+
</List.Actions>
|
|
254
|
+
</List.Item>
|
|
255
|
+
))}
|
|
256
|
+
</List.Root>
|
|
257
|
+
<html.p>Picked: {picked.join(', ')}</html.p>
|
|
258
|
+
</html.div>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const ClickableItems: Story = {
|
|
263
|
+
render: () => <ClickableDemo />,
|
|
264
|
+
play: async ({canvas, userEvent}) => {
|
|
265
|
+
const buttons = canvas.getAllByRole('button')
|
|
266
|
+
await expect(buttons).toHaveLength(3)
|
|
267
|
+
// The keyboard reaches the item and presses it, once.
|
|
268
|
+
await userEvent.tab()
|
|
269
|
+
await expect(buttons[0]).toHaveFocus()
|
|
270
|
+
await userEvent.keyboard('{Enter}')
|
|
271
|
+
await expect(canvas.getByText('Picked: t1')).toBeInTheDocument()
|
|
272
|
+
// A press on the row outside the text is one press too.
|
|
273
|
+
await userEvent.click(canvas.getAllByText('Turn')[1])
|
|
274
|
+
await expect(canvas.getByText('Picked: t1, t2')).toBeInTheDocument()
|
|
275
|
+
await expect(buttons[2]).toBeDisabled()
|
|
276
|
+
},
|
|
277
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, createContext, useContext} from 'react'
|
|
1
|
+
import {type ReactNode, createContext, useContext, useMemo} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {styles} from './styles.css'
|
|
4
4
|
|
|
@@ -50,27 +50,65 @@ interface ItemProps {
|
|
|
50
50
|
onClick?: () => void
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// What a clickable Item hands its Content, which becomes the item's button.
|
|
54
|
+
interface ItemActionValue {
|
|
55
|
+
onClick: () => void
|
|
56
|
+
disabled: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ItemActionContext = createContext<ItemActionValue | null>(null)
|
|
60
|
+
|
|
53
61
|
function Item({children, selected, disabled, onClick}: ItemProps) {
|
|
54
62
|
const {selectionMode} = useList()
|
|
55
63
|
const isSelectable = selectionMode !== 'none'
|
|
64
|
+
const action = useMemo(
|
|
65
|
+
() => (onClick ? {onClick, disabled: disabled === true} : null),
|
|
66
|
+
[onClick, disabled],
|
|
67
|
+
)
|
|
56
68
|
|
|
57
69
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
<ItemActionContext.Provider value={action}>
|
|
71
|
+
<html.div
|
|
72
|
+
role="listitem"
|
|
73
|
+
aria-selected={isSelectable ? selected : undefined}
|
|
74
|
+
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}
|
|
78
|
+
style={[
|
|
79
|
+
styles.item,
|
|
80
|
+
onClick != null && !disabled && styles.itemClickable,
|
|
81
|
+
selected && styles.itemSelected,
|
|
82
|
+
disabled && styles.itemDisabled,
|
|
83
|
+
]}
|
|
84
|
+
>
|
|
85
|
+
{children}
|
|
86
|
+
</html.div>
|
|
87
|
+
</ItemActionContext.Provider>
|
|
67
88
|
)
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
// --- Content: wraps primary text + description ---
|
|
71
92
|
|
|
72
93
|
function Content({children}: {children: ReactNode}) {
|
|
73
|
-
|
|
94
|
+
const action = useContext(ItemActionContext)
|
|
95
|
+
if (!action) return <html.div style={styles.content}>{children}</html.div>
|
|
96
|
+
// In a clickable Item the Content is a real button: focusable, pressed with Enter or Space,
|
|
97
|
+
// announced with its text. The Actions stay beside it, never nested inside.
|
|
98
|
+
return (
|
|
99
|
+
<html.button
|
|
100
|
+
type="button"
|
|
101
|
+
disabled={action.disabled}
|
|
102
|
+
onClick={(event) => {
|
|
103
|
+
// The row handles a press too; this one has been answered.
|
|
104
|
+
event.stopPropagation()
|
|
105
|
+
action.onClick()
|
|
106
|
+
}}
|
|
107
|
+
style={[styles.content, styles.contentButton]}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</html.button>
|
|
111
|
+
)
|
|
74
112
|
}
|
|
75
113
|
|
|
76
114
|
// --- Text: primary label ---
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {css} from 'react-strict-dom'
|
|
2
2
|
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
-
import {microSpacing, spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
3
|
+
import {microSpacing, radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
4
|
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
5
|
import {duration} from '@duro-app/tokens/tokens/motion.css'
|
|
6
6
|
import {borders} from '@duro-app/tokens/tokens/borders.css'
|
|
@@ -40,6 +40,9 @@ export const styles = css.create({
|
|
|
40
40
|
':hover': colors.bgCardHover,
|
|
41
41
|
},
|
|
42
42
|
},
|
|
43
|
+
itemClickable: {
|
|
44
|
+
cursor: 'pointer',
|
|
45
|
+
},
|
|
43
46
|
itemDisabled: {
|
|
44
47
|
opacity: 0.5,
|
|
45
48
|
pointerEvents: 'none',
|
|
@@ -57,6 +60,24 @@ export const styles = css.create({
|
|
|
57
60
|
minWidth: 0,
|
|
58
61
|
},
|
|
59
62
|
|
|
63
|
+
// The Content of a clickable Item: a button that reads as the row's text.
|
|
64
|
+
contentButton: {
|
|
65
|
+
alignItems: 'stretch',
|
|
66
|
+
textAlign: 'start',
|
|
67
|
+
backgroundColor: 'transparent',
|
|
68
|
+
borderWidth: 0,
|
|
69
|
+
padding: 0,
|
|
70
|
+
margin: 0,
|
|
71
|
+
font: 'inherit',
|
|
72
|
+
color: 'inherit',
|
|
73
|
+
cursor: 'inherit',
|
|
74
|
+
outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
|
|
75
|
+
outlineStyle: {default: 'none', ':focus-visible': 'solid'},
|
|
76
|
+
outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
|
|
77
|
+
outlineOffset: {default: 0, ':focus-visible': borders.focusOffset},
|
|
78
|
+
borderRadius: radii.xs,
|
|
79
|
+
},
|
|
80
|
+
|
|
60
81
|
// --- Primary text ---
|
|
61
82
|
text: {
|
|
62
83
|
fontSize: typography.fontSizeSm,
|