@duro-app/ui 4.3.0 → 4.4.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/ActionBar/ActionBar.d.ts +7 -0
- package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.meta.d.ts.map +1 -1
- package/dist/components/ActionBar/ActionBar.stories.d.ts +3 -0
- package/dist/components/ActionBar/ActionBar.stories.d.ts.map +1 -1
- package/dist/components/ActionBar/styles.css.d.ts +25 -2
- package/dist/components/ActionBar/styles.css.d.ts.map +1 -1
- package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1044 -1007
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/ActionBar/ActionBar.meta.ts +7 -0
- package/src/components/ActionBar/ActionBar.stories.tsx +95 -3
- package/src/components/ActionBar/ActionBar.tsx +12 -1
- package/src/components/ActionBar/styles.css.ts +34 -2
- package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@tanstack/react-virtual": "^3.14.6",
|
|
69
|
-
"@duro-app/tokens": "^4.
|
|
69
|
+
"@duro-app/tokens": "^4.4.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -7,6 +7,7 @@ export const meta: ComponentMeta = {
|
|
|
7
7
|
'Bulk actions on table selections (delete, revoke, export)',
|
|
8
8
|
'Multi-select workflows where actions apply to all selected items',
|
|
9
9
|
'Selection-scoped verbs on a canvas/editor surface (set `bottomOffset` to clear any bottom chrome such as zoom controls)',
|
|
10
|
+
'A docked side panel (DetailPanel) is open: pass its width as `insetInlineEnd` so the bar centres beside it, not under it (follows the writing direction: the left edge in right-to-left)',
|
|
10
11
|
],
|
|
11
12
|
whenNotToUse: [
|
|
12
13
|
'Single-item actions that have an inline home — use inline buttons or a Menu',
|
|
@@ -18,6 +19,12 @@ export const meta: ComponentMeta = {
|
|
|
18
19
|
kind: 'composition',
|
|
19
20
|
relationship: 'Common pattern: Table checkboxes + ActionBar for bulk ops',
|
|
20
21
|
},
|
|
22
|
+
{
|
|
23
|
+
component: 'DetailPanel',
|
|
24
|
+
kind: 'composition',
|
|
25
|
+
relationship:
|
|
26
|
+
"With a DetailPanel docked at the end edge, pass the panel's width as insetInlineEnd",
|
|
27
|
+
},
|
|
21
28
|
],
|
|
22
29
|
example: `<ActionBar selectedItemCount={selected.size} onClearSelection={() => setSelected(new Set())}>
|
|
23
30
|
<Button variant="danger" size="small" onClick={handleDelete}>Delete</Button>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {useState} from 'react'
|
|
2
2
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
3
|
-
import {expect, fn,
|
|
3
|
+
import {expect, fn, within} from 'storybook/test'
|
|
4
4
|
import {css, html} from 'react-strict-dom'
|
|
5
5
|
import {ActionBar} from './ActionBar'
|
|
6
6
|
import {Button} from '../Button/Button'
|
|
@@ -155,8 +155,7 @@ export const Default: Story = {
|
|
|
155
155
|
onClearSelection={args.onClearSelection as () => void}
|
|
156
156
|
/>
|
|
157
157
|
),
|
|
158
|
-
play: async (
|
|
159
|
-
const canvas = within(canvasElement)
|
|
158
|
+
play: async () => {
|
|
160
159
|
// The ActionBar is portalled to document.body, so query from there
|
|
161
160
|
const toolbar = document.querySelector('[role="toolbar"]')
|
|
162
161
|
await expect(toolbar).toBeInTheDocument()
|
|
@@ -310,3 +309,96 @@ export const AllVariants: Story = {
|
|
|
310
309
|
</html.div>
|
|
311
310
|
),
|
|
312
311
|
}
|
|
312
|
+
|
|
313
|
+
// A panel docked at the window's end edge, standing in for an open DetailPanel.
|
|
314
|
+
const docked = css.create({
|
|
315
|
+
panel: {
|
|
316
|
+
position: 'fixed',
|
|
317
|
+
top: 0,
|
|
318
|
+
bottom: 0,
|
|
319
|
+
insetInlineEnd: 0,
|
|
320
|
+
width: 360,
|
|
321
|
+
borderInlineStartWidth: 1,
|
|
322
|
+
borderInlineStartStyle: 'solid',
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Real buttons: they don't shrink below their labels, so together they are
|
|
327
|
+
// wider than the free area beside the panel.
|
|
328
|
+
const WIDE_ACTIONS = [
|
|
329
|
+
'Duplicate',
|
|
330
|
+
'Move to section',
|
|
331
|
+
'Copy style',
|
|
332
|
+
'Paste style',
|
|
333
|
+
'Group',
|
|
334
|
+
'Ungroup',
|
|
335
|
+
'Bring forward',
|
|
336
|
+
'Send backward',
|
|
337
|
+
'Delete',
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
const barRect = () =>
|
|
341
|
+
(document.querySelector('[role="toolbar"]') as Element).getBoundingClientRect()
|
|
342
|
+
const panelRect = () =>
|
|
343
|
+
(document.querySelector('[data-testid="docked-panel"]') as Element).getBoundingClientRect()
|
|
344
|
+
|
|
345
|
+
export const NoInset: Story = {
|
|
346
|
+
render: () => (
|
|
347
|
+
<ActionBar selectedItemCount={2} onClearSelection={() => {}}>
|
|
348
|
+
<Button variant="secondary" size="small">
|
|
349
|
+
Delete
|
|
350
|
+
</Button>
|
|
351
|
+
</ActionBar>
|
|
352
|
+
),
|
|
353
|
+
play: async () => {
|
|
354
|
+
const bar = barRect()
|
|
355
|
+
const centre = document.documentElement.clientWidth / 2
|
|
356
|
+
// Today's placement: centred on the window.
|
|
357
|
+
await expect(Math.abs(bar.left + bar.width / 2 - centre)).toBeLessThanOrEqual(1)
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export const DockedPanel: Story = {
|
|
362
|
+
render: () => (
|
|
363
|
+
<>
|
|
364
|
+
<html.div style={docked.panel} data-testid="docked-panel" />
|
|
365
|
+
<ActionBar selectedItemCount={2} insetInlineEnd={360} onClearSelection={() => {}}>
|
|
366
|
+
<Button variant="secondary" size="small">
|
|
367
|
+
Delete
|
|
368
|
+
</Button>
|
|
369
|
+
</ActionBar>
|
|
370
|
+
</>
|
|
371
|
+
),
|
|
372
|
+
play: async () => {
|
|
373
|
+
const bar = barRect()
|
|
374
|
+
const panel = panelRect()
|
|
375
|
+
// Clear of the panel, and centred in what is left of the window.
|
|
376
|
+
await expect(bar.right).toBeLessThanOrEqual(panel.left - 24)
|
|
377
|
+
await expect(Math.abs(bar.left + bar.width / 2 - panel.left / 2)).toBeLessThanOrEqual(1)
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export const DockedPanelWideBar: Story = {
|
|
382
|
+
render: () => (
|
|
383
|
+
<>
|
|
384
|
+
<html.div style={docked.panel} data-testid="docked-panel" />
|
|
385
|
+
<ActionBar selectedItemCount={2} insetInlineEnd={360} onClearSelection={() => {}}>
|
|
386
|
+
{WIDE_ACTIONS.map((a) => (
|
|
387
|
+
<Button key={a} variant="secondary" size="small">
|
|
388
|
+
{a}
|
|
389
|
+
</Button>
|
|
390
|
+
))}
|
|
391
|
+
</ActionBar>
|
|
392
|
+
</>
|
|
393
|
+
),
|
|
394
|
+
play: async () => {
|
|
395
|
+
// A bar wider than the free area still keeps 24px clear of the panel,
|
|
396
|
+
// and so does what it holds: the actions scroll, they don't spill.
|
|
397
|
+
const limit = panelRect().left - 24
|
|
398
|
+
await expect(barRect().right).toBeLessThanOrEqual(limit)
|
|
399
|
+
const last = await within(document.body).findByRole('button', {name: 'Delete'})
|
|
400
|
+
const actions = last.parentElement!
|
|
401
|
+
await expect(actions.getBoundingClientRect().right).toBeLessThanOrEqual(limit)
|
|
402
|
+
await expect(actions.scrollWidth).toBeGreaterThan(actions.clientWidth)
|
|
403
|
+
},
|
|
404
|
+
}
|
|
@@ -22,6 +22,13 @@ export interface ActionBarProps {
|
|
|
22
22
|
* height. Defaults to the spacing.lg token.
|
|
23
23
|
*/
|
|
24
24
|
bottomOffset?: SpacingToken | number
|
|
25
|
+
/**
|
|
26
|
+
* Width in px of chrome docked at the window's inline-end edge (right in
|
|
27
|
+
* left-to-right, left in right-to-left), such as an open DetailPanel: the
|
|
28
|
+
* bar centres in what is left and keeps 24px clear of it. 0 or omitted
|
|
29
|
+
* centres on the window.
|
|
30
|
+
*/
|
|
31
|
+
insetInlineEnd?: number
|
|
25
32
|
/** Action buttons to display. */
|
|
26
33
|
children: ReactNode
|
|
27
34
|
}
|
|
@@ -33,6 +40,7 @@ function ActionBarContent({
|
|
|
33
40
|
onClearSelection,
|
|
34
41
|
dismissible = true,
|
|
35
42
|
bottomOffset,
|
|
43
|
+
insetInlineEnd = 0,
|
|
36
44
|
children,
|
|
37
45
|
}: ActionBarProps) {
|
|
38
46
|
const countStr = selectedItemCount === 'all' ? 'All' : `${selectedItemCount}`
|
|
@@ -44,6 +52,7 @@ function ActionBarContent({
|
|
|
44
52
|
aria-label={label}
|
|
45
53
|
style={[
|
|
46
54
|
styles.overlay,
|
|
55
|
+
insetInlineEnd > 0 ? styles.inset(insetInlineEnd) : styles.centered,
|
|
47
56
|
bottomOffset != null &&
|
|
48
57
|
styles.overlayOffset(
|
|
49
58
|
typeof bottomOffset === 'number' ? bottomOffset : SPACING_PX[bottomOffset],
|
|
@@ -55,7 +64,9 @@ function ActionBarContent({
|
|
|
55
64
|
{label}
|
|
56
65
|
</html.span>
|
|
57
66
|
<html.div style={[styles.separator, isEmphasized && styles.separatorEmphasized]} />
|
|
58
|
-
<html.div style={styles.actions
|
|
67
|
+
<html.div style={[styles.actions, insetInlineEnd > 0 && styles.actionsScroll]}>
|
|
68
|
+
{children}
|
|
69
|
+
</html.div>
|
|
59
70
|
{dismissible && (
|
|
60
71
|
<html.button
|
|
61
72
|
type="button"
|
|
@@ -9,8 +9,6 @@ export const styles = css.create({
|
|
|
9
9
|
overlay: {
|
|
10
10
|
position: 'fixed',
|
|
11
11
|
bottom: spacing.lg,
|
|
12
|
-
left: '50%',
|
|
13
|
-
transform: 'translateX(-50%)',
|
|
14
12
|
display: 'flex',
|
|
15
13
|
flexDirection: 'row',
|
|
16
14
|
alignItems: 'center',
|
|
@@ -26,8 +24,25 @@ export const styles = css.create({
|
|
|
26
24
|
borderRadius: radii.md,
|
|
27
25
|
boxShadow: shadows.lg,
|
|
28
26
|
zIndex: 50,
|
|
27
|
+
},
|
|
28
|
+
// Centred on the window: the default, with nothing docked at its edge.
|
|
29
|
+
centered: {
|
|
30
|
+
left: '50%',
|
|
31
|
+
transform: 'translateX(-50%)',
|
|
29
32
|
maxWidth: 'calc(100vw - 48px)',
|
|
30
33
|
},
|
|
34
|
+
// Centred in the window minus chrome docked at the inline-end edge (a
|
|
35
|
+
// DetailPanel). Both insets and auto margins centre a fixed box of
|
|
36
|
+
// fit-content width in what is left, measured on the containing block
|
|
37
|
+
// (the window without a classic scrollbar), not on 100vw.
|
|
38
|
+
inset: (end: number) => ({
|
|
39
|
+
insetInlineStart: 0,
|
|
40
|
+
insetInlineEnd: end,
|
|
41
|
+
marginInlineStart: 'auto',
|
|
42
|
+
marginInlineEnd: 'auto',
|
|
43
|
+
width: 'fit-content',
|
|
44
|
+
maxWidth: `calc(100% - ${end}px - 48px)`,
|
|
45
|
+
}),
|
|
31
46
|
overlayOffset: (bottom: number) => ({
|
|
32
47
|
bottom,
|
|
33
48
|
}),
|
|
@@ -51,6 +66,23 @@ export const styles = css.create({
|
|
|
51
66
|
alignItems: 'center',
|
|
52
67
|
gap: spacing.sm,
|
|
53
68
|
},
|
|
69
|
+
// Beside a docked panel the room is narrower: more actions than fit scroll
|
|
70
|
+
// instead of spilling past the bar. A scroll box clips vertically too, so
|
|
71
|
+
// the row gets room on every side for the buttons' focus rings, taken back
|
|
72
|
+
// by its margin so the bar keeps its size.
|
|
73
|
+
actionsScroll: {
|
|
74
|
+
minWidth: 0,
|
|
75
|
+
overflowX: 'auto',
|
|
76
|
+
overflowY: 'hidden',
|
|
77
|
+
paddingTop: spacing.xs,
|
|
78
|
+
paddingBottom: spacing.xs,
|
|
79
|
+
paddingLeft: spacing.xs,
|
|
80
|
+
paddingRight: spacing.xs,
|
|
81
|
+
marginTop: `calc(-1 * ${spacing.xs})`,
|
|
82
|
+
marginBottom: `calc(-1 * ${spacing.xs})`,
|
|
83
|
+
marginLeft: `calc(-1 * ${spacing.xs})`,
|
|
84
|
+
marginRight: `calc(-1 * ${spacing.xs})`,
|
|
85
|
+
},
|
|
54
86
|
closeButton: {
|
|
55
87
|
display: 'inline-flex',
|
|
56
88
|
alignItems: 'center',
|
|
@@ -18,6 +18,12 @@ export const meta: ComponentMeta = {
|
|
|
18
18
|
optional: ['Title', 'Footer', 'Close'],
|
|
19
19
|
},
|
|
20
20
|
relatedTo: [
|
|
21
|
+
{
|
|
22
|
+
component: 'ActionBar',
|
|
23
|
+
kind: 'composition',
|
|
24
|
+
relationship:
|
|
25
|
+
"An ActionBar shown while the panel is open takes the panel's width as insetInlineEnd",
|
|
26
|
+
},
|
|
21
27
|
{
|
|
22
28
|
component: 'Drawer',
|
|
23
29
|
kind: 'contrast',
|