@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "4.3.0",
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.3.0"
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, userEvent, within} from 'storybook/test'
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 ({canvasElement}) => {
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}>{children}</html.div>
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',