@duro-app/ui 5.5.0 → 5.6.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 (66) hide show
  1. package/dist/{Table-DYN-bp0e.js → Table-B1sWL3GA.js} +404 -387
  2. package/dist/Table-B1sWL3GA.js.map +1 -0
  3. package/dist/components/Button/Button.d.ts +2 -1
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/Button/styles.css.d.ts +1 -0
  6. package/dist/components/Button/styles.css.d.ts.map +1 -1
  7. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
  8. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  9. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  10. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  11. package/dist/components/Menu/Menu.d.ts +4 -1
  12. package/dist/components/Menu/Menu.d.ts.map +1 -1
  13. package/dist/components/Menu/styles.css.d.ts +14 -0
  14. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  15. package/dist/components/Popover/Popover.d.ts +14 -2
  16. package/dist/components/Popover/Popover.d.ts.map +1 -1
  17. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  18. package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
  19. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
  20. package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
  21. package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
  22. package/dist/components/Popover/styles.css.d.ts +15 -0
  23. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  24. package/dist/components/Select/Select.d.ts +4 -1
  25. package/dist/components/Select/Select.d.ts.map +1 -1
  26. package/dist/components/Select/styles.css.d.ts +9 -0
  27. package/dist/components/Select/styles.css.d.ts.map +1 -1
  28. package/dist/components/Toggle/Toggle.d.ts +2 -1
  29. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  30. package/dist/components/Toggle/styles.css.d.ts +1 -0
  31. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  32. package/dist/index.css +1 -1
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1603 -1558
  36. package/dist/index.js.map +1 -1
  37. package/dist/shared/ModalContext.d.ts +7 -0
  38. package/dist/shared/ModalContext.d.ts.map +1 -1
  39. package/dist/shared/exports.typecheck.d.ts +9 -0
  40. package/dist/shared/exports.typecheck.d.ts.map +1 -0
  41. package/dist/shared/types.d.ts +2 -0
  42. package/dist/shared/types.d.ts.map +1 -1
  43. package/dist/table.js +1 -1
  44. package/package.json +2 -2
  45. package/src/components/Button/Button.tsx +2 -1
  46. package/src/components/Button/styles.css.ts +3 -0
  47. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
  48. package/src/components/LinkButton/styles.css.ts +4 -0
  49. package/src/components/Menu/Menu.meta.ts +1 -1
  50. package/src/components/Menu/Menu.tsx +17 -2
  51. package/src/components/Menu/styles.css.ts +18 -0
  52. package/src/components/Popover/Popover.meta.ts +2 -1
  53. package/src/components/Popover/Popover.tsx +29 -4
  54. package/src/components/Popover/PopoverLayerContext.ts +3 -0
  55. package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
  56. package/src/components/Popover/styles.css.ts +21 -0
  57. package/src/components/Select/Select.meta.ts +1 -1
  58. package/src/components/Select/Select.tsx +5 -1
  59. package/src/components/Select/styles.css.ts +12 -0
  60. package/src/components/Toggle/Toggle.tsx +2 -1
  61. package/src/components/Toggle/styles.css.ts +2 -0
  62. package/src/index.ts +2 -0
  63. package/src/shared/ModalContext.ts +8 -1
  64. package/src/shared/exports.typecheck.ts +16 -0
  65. package/src/shared/types.ts +3 -0
  66. package/dist/Table-DYN-bp0e.js.map +0 -1
@@ -10,5 +10,12 @@
10
10
  * closes on a nested modal opening; no consumer needs it yet.
11
11
  */
12
12
  export declare const ModalContext: import('react').Context<boolean>;
13
+ /**
14
+ * True inside a Dialog or Drawer, false outside. A floating surface that is not
15
+ * a Duro Popover picks its layer with it, the way a Popover does:
16
+ * `useInModal() ? layers.popover : layers.floating`, and for one that must
17
+ * cover another floating surface (a link editor over a format bar)
18
+ * `useInModal() ? layers.popoverRaised : layers.floatingRaised`.
19
+ */
13
20
  export declare function useInModal(): boolean;
14
21
  //# sourceMappingURL=ModalContext.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ModalContext.d.ts","sourceRoot":"","sources":["../../src/shared/ModalContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,kCAAuB,CAAA;AAEhD,wBAAgB,UAAU,YAEzB"}
1
+ {"version":3,"file":"ModalContext.d.ts","sourceRoot":"","sources":["../../src/shared/ModalContext.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,kCAAuB,CAAA;AAEhD;;;;;;GAMG;AACH,wBAAgB,UAAU,IAAI,OAAO,CAEpC"}
@@ -0,0 +1,9 @@
1
+ import { ButtonSize, ControlSize, ToggleSize, useInModal } from '../index';
2
+ type Same<T, U> = [T] extends [U] ? ([U] extends [T] ? true : false) : false;
3
+ export declare const useInModalIsBoolean: Same<ReturnType<typeof useInModal>, boolean>;
4
+ export declare const useInModalTakesNothing: Same<Parameters<typeof useInModal>, []>;
5
+ export declare const controlSizes: Same<ControlSize, 'default' | 'small'>;
6
+ export declare const buttonSizeIsControlSize: Same<ButtonSize, ControlSize>;
7
+ export declare const toggleSizeIsControlSize: Same<ToggleSize, ControlSize>;
8
+ export {};
9
+ //# sourceMappingURL=exports.typecheck.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exports.typecheck.d.ts","sourceRoot":"","sources":["../../src/shared/exports.typecheck.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAC,UAAU,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAC,MAAM,UAAU,CAAA;AAE7E,KAAK,IAAI,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,KAAK,CAAC,GAAG,KAAK,CAAA;AAG5E,eAAO,MAAM,mBAAmB,EAAE,IAAI,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,EAAE,OAAO,CAAQ,CAAA;AACrF,eAAO,MAAM,sBAAsB,EAAE,IAAI,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,EAAE,EAAE,CAAQ,CAAA;AAGnF,eAAO,MAAM,YAAY,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,OAAO,CAAQ,CAAA;AACxE,eAAO,MAAM,uBAAuB,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,CAAQ,CAAA;AAC1E,eAAO,MAAM,uBAAuB,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,CAAQ,CAAA"}
@@ -1,2 +1,4 @@
1
1
  export type Orientation = 'horizontal' | 'vertical';
2
+ /** The two control sizes shared by Button, Toggle and the Select, Menu and Popover triggers. */
3
+ export type ControlSize = 'default' | 'small';
2
4
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,UAAU,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/shared/types.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,UAAU,CAAA;AAEnD,gGAAgG;AAChG,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,OAAO,CAAA"}
package/dist/table.js CHANGED
@@ -1,4 +1,4 @@
1
- import { M as F, m as xt, R as rt, H as kt, n as pt, o as bt, p as ft, q as mt, k as ht, t as yt, r as St, v as Tt } from "./Table-DYN-bp0e.js";
1
+ import { M as F, m as xt, R as rt, H as kt, n as pt, o as bt, p as ft, q as mt, k as ht, t as yt, r as St, v as Tt } from "./Table-B1sWL3GA.js";
2
2
  import { jsx as u, jsxs as q } from "react/jsx-runtime";
3
3
  import { c as H, m as p, d as b } from "./compiler-runtime-B2IcI8_O.js";
4
4
  import { flexRender as E, useReactTable as it, getPaginationRowModel as Ct, getFilteredRowModel as vt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.5.0",
3
+ "version": "5.6.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": "^5.5.0"
69
+ "@duro-app/tokens": "^5.6.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@babel/preset-typescript": "^7.28.0",
@@ -2,11 +2,12 @@ import type {ReactNode, Ref} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {isNative} from '../../platform'
4
4
  import {mergeRefs} from '../../shared/mergeRefs'
5
+ import type {ControlSize} from '../../shared/types'
5
6
  import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
6
7
  import {styles} from './styles.css'
7
8
 
8
9
  export type ButtonVariant = 'primary' | 'secondary' | 'inverseSecondary' | 'link' | 'danger'
9
- export type ButtonSize = 'default' | 'small'
10
+ export type ButtonSize = ControlSize
10
11
 
11
12
  interface ButtonProps {
12
13
  variant?: ButtonVariant
@@ -4,6 +4,7 @@ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
6
  import {borders} from '@duro-app/tokens/tokens/borders.css'
7
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
7
8
 
8
9
  export const styles = css.create({
9
10
  base: {
@@ -62,6 +63,8 @@ export const styles = css.create({
62
63
  paddingLeft: spacing.sm,
63
64
  paddingRight: spacing.sm,
64
65
  fontSize: typography.fontSizeXs,
66
+ // Every small control is at least controlSm tall, whatever it holds (5.6).
67
+ minHeight: sizes.controlSm,
65
68
  },
66
69
  primary: {
67
70
  backgroundColor: {
@@ -1,9 +1,21 @@
1
1
  import {useState} from 'react'
2
2
  import type {Meta, StoryObj} from '@storybook/react'
3
- import {expect, userEvent, within} from 'storybook/test'
4
- import {BORDERS_PX, LAYERS, RADII_PX} from '@duro-app/tokens/keys'
3
+ import {expect, userEvent, waitFor, within} from 'storybook/test'
4
+ import {
5
+ BORDERS_PX,
6
+ FONT_SIZE_REM,
7
+ LAYERS,
8
+ RADII_PX,
9
+ SIZES_PX,
10
+ SPACING_PX,
11
+ } from '@duro-app/tokens/keys'
12
+ import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
5
13
  import {ButtonGroup} from './ButtonGroup'
14
+ import type {ControlSize} from '../../shared/types'
6
15
  import {Button} from '../Button/Button'
16
+ import {Icon} from '../Icon/Icon'
17
+ import {Inline} from '../Inline/Inline'
18
+ import {Menu} from '../Menu/Menu'
7
19
  import {Popover} from '../Popover/Popover'
8
20
  import {Select} from '../Select/Select'
9
21
  import {Stack} from '../Stack/Stack'
@@ -146,11 +158,11 @@ async function expectJoined(controls: Element[]) {
146
158
  }
147
159
  }
148
160
 
149
- function TextStyleSelects({named = false}: {named?: boolean}) {
161
+ function TextStyleSelects({named = false, size}: {named?: boolean; size?: ControlSize}) {
150
162
  return (
151
163
  <ButtonGroup attached aria-label="Text style">
152
164
  <Select.Root name={named ? 'block' : undefined} defaultValue="normal">
153
- <Select.Trigger aria-label="Block type">
165
+ <Select.Trigger aria-label="Block type" size={size}>
154
166
  <Select.Value />
155
167
  <Select.Icon />
156
168
  </Select.Trigger>
@@ -160,7 +172,7 @@ function TextStyleSelects({named = false}: {named?: boolean}) {
160
172
  </Select.Popup>
161
173
  </Select.Root>
162
174
  <Select.Root name={named ? 'font' : undefined} defaultValue="arial">
163
- <Select.Trigger aria-label="Font">
175
+ <Select.Trigger aria-label="Font" size={size}>
164
176
  <Select.Value />
165
177
  <Select.Icon />
166
178
  </Select.Trigger>
@@ -170,7 +182,7 @@ function TextStyleSelects({named = false}: {named?: boolean}) {
170
182
  </Select.Popup>
171
183
  </Select.Root>
172
184
  <Select.Root name={named ? 'size' : undefined} defaultValue="15">
173
- <Select.Trigger aria-label="Font size">
185
+ <Select.Trigger aria-label="Font size" size={size}>
174
186
  <Select.Value />
175
187
  <Select.Icon />
176
188
  </Select.Trigger>
@@ -242,25 +254,37 @@ export const AttachedFocusMiddle: Story = {
242
254
  },
243
255
  }
244
256
 
245
- function FormatGroup() {
257
+ function FormatGroup({size}: {size?: ControlSize}) {
246
258
  const [pressed, setPressed] = useState<Record<string, boolean>>({u: true})
247
259
  const toggle = (key: string) => (next: boolean) => setPressed((p) => ({...p, [key]: next}))
248
260
  return (
249
261
  <ButtonGroup attached aria-label="Format">
250
- <Toggle aria-label="Bold" pressed={!!pressed.b} onPressedChange={toggle('b')}>
262
+ <Toggle aria-label="Bold" size={size} pressed={!!pressed.b} onPressedChange={toggle('b')}>
251
263
  B
252
264
  </Toggle>
253
- <Toggle aria-label="Italic" pressed={!!pressed.i} onPressedChange={toggle('i')}>
265
+ <Toggle aria-label="Italic" size={size} pressed={!!pressed.i} onPressedChange={toggle('i')}>
254
266
  I
255
267
  </Toggle>
256
- <Toggle aria-label="Underline" pressed={!!pressed.u} onPressedChange={toggle('u')}>
268
+ <Toggle
269
+ aria-label="Underline"
270
+ size={size}
271
+ pressed={!!pressed.u}
272
+ onPressedChange={toggle('u')}
273
+ >
257
274
  U
258
275
  </Toggle>
259
- <Toggle aria-label="Code" pressed={!!pressed.code} onPressedChange={toggle('code')}>
276
+ <Toggle
277
+ aria-label="Code"
278
+ size={size}
279
+ pressed={!!pressed.code}
280
+ onPressedChange={toggle('code')}
281
+ >
260
282
  {'<>'}
261
283
  </Toggle>
262
284
  <Popover.Root>
263
- <Popover.Trigger aria-label="Link">↗</Popover.Trigger>
285
+ <Popover.Trigger aria-label="Link" size={size}>
286
+ ↗
287
+ </Popover.Trigger>
264
288
  <Popover.Popup label="Link">
265
289
  <Stack gap="sm">
266
290
  <Text>Paste a URL.</Text>
@@ -268,10 +292,10 @@ function FormatGroup() {
268
292
  </Stack>
269
293
  </Popover.Popup>
270
294
  </Popover.Root>
271
- <Button variant="secondary" aria-label="Clear formatting">
295
+ <Button variant="secondary" size={size} aria-label="Clear formatting">
272
296
  T
273
297
  </Button>
274
- <Button variant="secondary" aria-label="Highlight">
298
+ <Button variant="secondary" size={size} aria-label="Highlight">
275
299
  H
276
300
  </Button>
277
301
  </ButtonGroup>
@@ -372,3 +396,137 @@ export const BothPressedFocusSecond: Story = {
372
396
  await expect(drawnAt(bold, b.left + b.width / 2, ringPixels(b).bottom)).toBe(true)
373
397
  },
374
398
  }
399
+
400
+ // --- Small triggers (5.6) ---
401
+
402
+ /** fontSizeXs in px, from the root font size. */
403
+ const smallFontPx = () =>
404
+ `${parseFloat(getComputedStyle(document.documentElement).fontSize) * FONT_SIZE_REM.fontSizeXs}px`
405
+
406
+ /** [Normal | Arial | 15px] at size="small": joined like the default size. */
407
+ export const AttachedSelectsSmall: Story = {
408
+ render: () => <TextStyleSelects size="small" />,
409
+ play: async ({canvas}) => {
410
+ const triggers = canvas.getAllByRole('combobox')
411
+ await expectJoined(triggers)
412
+ for (const el of triggers) {
413
+ await expect(getComputedStyle(el).fontSize).toBe(smallFontPx())
414
+ }
415
+ },
416
+ }
417
+
418
+ /** The mixed format group at size="small": Toggles, the Popover trigger and
419
+ * the Buttons stay joined. */
420
+ export const AttachedMixedSmall: Story = {
421
+ render: () => <FormatGroup size="small" />,
422
+ play: async ({canvas}) => {
423
+ const group = canvas.getByRole('group', {name: 'Format'})
424
+ await expectJoined(within(group).getAllByRole('button'))
425
+ },
426
+ }
427
+
428
+ function SmallControlsRow() {
429
+ return (
430
+ <Inline gap="sm" align="center">
431
+ <Toggle size="small" aria-label="Bold">
432
+ B
433
+ </Toggle>
434
+ <Toggle size="small" aria-label="Search">
435
+ <Icon name="search" size="md" />
436
+ </Toggle>
437
+ <Button size="small" variant="secondary">
438
+ Clear
439
+ </Button>
440
+ <Select.Root defaultValue="normal">
441
+ <Select.Trigger size="small" aria-label="Block type">
442
+ <Select.Value />
443
+ <Select.Icon />
444
+ </Select.Trigger>
445
+ <Select.Popup>
446
+ <Select.Item value="normal">Normal</Select.Item>
447
+ <Select.Item value="h1">Heading 1</Select.Item>
448
+ </Select.Popup>
449
+ </Select.Root>
450
+ <Menu.Root>
451
+ <Menu.Trigger size="small">Insert</Menu.Trigger>
452
+ <Menu.Popup>
453
+ <Menu.Item onClick={() => undefined}>Image</Menu.Item>
454
+ </Menu.Popup>
455
+ </Menu.Root>
456
+ <Popover.Root>
457
+ <Popover.Trigger size="small" aria-label="Text colour">
458
+ A
459
+ </Popover.Trigger>
460
+ <Popover.Popup label="Text colour">
461
+ <Text>Pick a colour.</Text>
462
+ </Popover.Popup>
463
+ </Popover.Root>
464
+ <Menu.Root>
465
+ <Menu.Trigger size="small" variant="ghost" aria-label="More">
466
+ <Icon name="menu" size="sm" />
467
+ </Menu.Trigger>
468
+ <Menu.Popup>
469
+ <Menu.Item onClick={() => undefined}>Settings</Menu.Item>
470
+ </Menu.Popup>
471
+ </Menu.Root>
472
+ </Inline>
473
+ )
474
+ }
475
+
476
+ /** Small Toggles (text and an 18px icon), a small Button and small Select,
477
+ * Menu and Popover triggers in a plain Inline (no attached group to stretch
478
+ * them): every one is controlSm tall whatever it holds, with Toggle's
479
+ * padding and font. The ghost Menu trigger is the small icon-button size. */
480
+ export const SmallTriggersRow: Story = {
481
+ render: () => <SmallControlsRow />,
482
+ play: async ({canvas}) => {
483
+ await withFinePointer()
484
+ const controls = [
485
+ canvas.getByRole('button', {name: 'Bold'}),
486
+ canvas.getByRole('button', {name: 'Search'}),
487
+ canvas.getByRole('button', {name: 'Clear'}),
488
+ ]
489
+ const triggers = [
490
+ canvas.getByRole('combobox', {name: 'Block type'}),
491
+ canvas.getByRole('button', {name: 'Insert'}),
492
+ canvas.getByRole('button', {name: 'Text colour'}),
493
+ ]
494
+ for (const el of [...controls, ...triggers]) {
495
+ await expect(el.offsetHeight).toBe(SIZES_PX.controlSm)
496
+ }
497
+ for (const el of triggers) {
498
+ const cs = getComputedStyle(el)
499
+ await expect(cs.paddingTop).toBe(`${SPACING_PX.xs}px`)
500
+ await expect(cs.paddingLeft).toBe(`${SPACING_PX.sm}px`)
501
+ await expect(cs.fontSize).toBe(smallFontPx())
502
+ }
503
+ const ghost = canvas.getByRole('button', {name: 'More'})
504
+ await expect(ghost.offsetHeight).toBe(SIZES_PX.iconButtonSm)
505
+ await expect(ghost.offsetWidth).toBe(SIZES_PX.iconButtonSm)
506
+ await expect(getComputedStyle(ghost).fontSize).toBe(smallFontPx())
507
+ },
508
+ }
509
+
510
+ /** The same row under a coarse pointer: Toggle and the three triggers reach
511
+ * touchTarget, the ghost Menu too. Button has no touch size at any size, so the small Button stays
512
+ * controlSm (the 5.5 plan's addendum). */
513
+ export const SmallTriggersRowCoarsePointer: Story = {
514
+ render: () => <SmallControlsRow />,
515
+ play: async ({canvas}) => {
516
+ const touch = [
517
+ canvas.getByRole('button', {name: 'Bold'}),
518
+ canvas.getByRole('button', {name: 'Search'}),
519
+ canvas.getByRole('combobox', {name: 'Block type'}),
520
+ canvas.getByRole('button', {name: 'Insert'}),
521
+ canvas.getByRole('button', {name: 'Text colour'}),
522
+ canvas.getByRole('button', {name: 'More'}),
523
+ ]
524
+ const button = canvas.getByRole('button', {name: 'Clear'})
525
+ await withCoarsePointer(async () => {
526
+ for (const el of touch) {
527
+ await waitFor(() => expect(el.offsetHeight).toBe(SIZES_PX.touchTarget))
528
+ }
529
+ await expect(button.offsetHeight).toBe(SIZES_PX.controlSm)
530
+ })
531
+ },
532
+ }
@@ -4,6 +4,7 @@ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
6
  import {borders} from '@duro-app/tokens/tokens/borders.css'
7
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
7
8
 
8
9
  export const styles = css.create({
9
10
  base: {
@@ -34,6 +35,9 @@ export const styles = css.create({
34
35
  paddingLeft: spacing.sm,
35
36
  paddingRight: spacing.sm,
36
37
  fontSize: typography.fontSizeXs,
38
+
39
+ // Matches Button: every small control is at least controlSm tall (5.6).
40
+ minHeight: sizes.controlSm,
37
41
  },
38
42
  primary: {
39
43
  backgroundColor: {
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars. The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog.',
5
+ 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars; size="small" matches small Toggles and Buttons (a small ghost trigger is iconButtonSm). The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog.',
6
6
  whenToUse: [
7
7
  'Context menu or "more actions" dropdown',
8
8
  'Navigation links in a dropdown',
@@ -15,6 +15,7 @@ import {styles} from './styles.css'
15
15
  import {MenuContext, useMenu} from './MenuContext'
16
16
  import {useMenuRoot} from './useMenuRoot'
17
17
  import {devWarnOnce} from '../../shared/devWarnOnce'
18
+ import type {ControlSize} from '../../shared/types'
18
19
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
19
20
  import {computePopoverPosition} from '../Popover/position'
20
21
  import {usePopoverLayer} from '../Popover/PopoverLayerContext'
@@ -58,10 +59,18 @@ interface TriggerProps {
58
59
  'aria-label'?: string
59
60
  /** `ghost`: no border or fill until hover, for toolbars. Default: `default`. */
60
61
  variant?: MenuTriggerVariant
62
+ /** `small` matches a small Toggle or Button beside it; a small ghost trigger is `iconButtonSm`. Default: `default`. */
63
+ size?: ControlSize
61
64
  ref?: Ref<HTMLButtonElement>
62
65
  }
63
66
 
64
- function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}: TriggerProps) {
67
+ function Trigger({
68
+ children,
69
+ 'aria-label': ariaLabel,
70
+ variant = 'default',
71
+ size = 'default',
72
+ ref,
73
+ }: TriggerProps) {
65
74
  const {open, toggle, menuId, triggerId, triggerRef} = useMenu()
66
75
  const grouped = useGroupedControl<HTMLButtonElement>()
67
76
  const groupedRef = grouped.ref
@@ -99,7 +108,13 @@ function Trigger({children, 'aria-label': ariaLabel, variant = 'default', ref}:
99
108
  aria-expanded={open}
100
109
  aria-haspopup="menu"
101
110
  aria-controls={open ? menuId : undefined}
102
- style={[styles.trigger, variant === 'ghost' && styles.triggerGhost, grouped.style]}
111
+ style={[
112
+ styles.trigger,
113
+ variant === 'ghost'
114
+ ? [styles.triggerGhost, size === 'small' && styles.triggerGhostSmall]
115
+ : size === 'small' && styles.triggerSmall,
116
+ grouped.style,
117
+ ]}
103
118
  >
104
119
  {children}
105
120
  </html.button>
@@ -47,6 +47,24 @@ export const styles = css.create({
47
47
  paddingRight: spacing.xs,
48
48
  borderColor: 'transparent',
49
49
  },
50
+ // size="small": Toggle sizeSmall's values, as longhands so they win over the
51
+ // trigger's own longhands. Every small control is at least controlSm tall,
52
+ // whatever it holds, and the touch target under a coarse pointer.
53
+ triggerSmall: {
54
+ gap: spacing.xs,
55
+ paddingTop: spacing.xs,
56
+ paddingBottom: spacing.xs,
57
+ paddingLeft: spacing.sm,
58
+ paddingRight: spacing.sm,
59
+ fontSize: typography.fontSizeXs,
60
+ minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
61
+ },
62
+ // A small ghost trigger keeps the ghost's xs padding at the small icon-button size.
63
+ triggerGhostSmall: {
64
+ fontSize: typography.fontSizeXs,
65
+ minWidth: sizes.iconButtonSm,
66
+ minHeight: {default: sizes.iconButtonSm, '@media (pointer: coarse)': sizes.touchTarget},
67
+ },
50
68
  popup: {
51
69
  position: 'fixed',
52
70
  top: 0,
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves.',
5
+ 'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves. Popover.Trigger takes size="small" beside small Toggles. Popover.Popup raised draws it over another floating surface (floatingRaised, popoverRaised in a modal), never over a modal.',
6
6
  whenToUse: [
7
7
  'A small form or controls tied to one element that stay open while you work in them',
8
8
  'Anchoring to a spot that is not a button (a canvas point, a text selection) with a virtual anchor',
9
+ 'One floating surface over another (a link editor over a selection format bar) — Popover.Popup raised',
9
10
  'Holding a Select or Combobox: their portalled list counts as inside the popover, so choosing an item keeps it open; Escape closes an open Select, Combobox or Menu before the popover',
10
11
  ],
11
12
  whenNotToUse: [
@@ -22,6 +22,7 @@ import {PopoverLayerContext, type PopoverLayerContextValue} from './PopoverLayer
22
22
  import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
23
23
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
24
24
  import {mergeRefs} from '../../shared/mergeRefs'
25
+ import type {ControlSize} from '../../shared/types'
25
26
  import {useInModal} from '../../shared/ModalContext'
26
27
 
27
28
  // --- Context ---
@@ -166,6 +167,8 @@ function Root({
166
167
  unregisterParent?.()
167
168
  }
168
169
  },
170
+ // Inside a raised Popup, this Popover is raised too.
171
+ raised: parentLayer?.raised,
169
172
  }),
170
173
  [parentLayer],
171
174
  )
@@ -201,11 +204,13 @@ interface TriggerProps {
201
204
  children: ReactNode
202
205
  /** Accessible name: required when the label is an icon only. */
203
206
  'aria-label'?: string
207
+ /** `small` matches a small Toggle or Button beside it. Default: `default`. */
208
+ size?: ControlSize
204
209
  /** The trigger <button>, e.g. to measure it or return focus to it. */
205
210
  ref?: Ref<HTMLButtonElement>
206
211
  }
207
212
 
208
- function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
213
+ function Trigger({children, 'aria-label': ariaLabel, size = 'default', ref}: TriggerProps) {
209
214
  const {open, setOpen, popoverId, triggerRef} = usePopover('Trigger')
210
215
  const grouped = useGroupedControl<HTMLButtonElement>()
211
216
 
@@ -220,7 +225,7 @@ function Trigger({children, 'aria-label': ariaLabel, ref}: TriggerProps) {
220
225
  aria-haspopup="dialog"
221
226
  aria-expanded={open}
222
227
  aria-controls={open ? popoverId : undefined}
223
- style={[styles.trigger, grouped.style]}
228
+ style={[styles.trigger, size === 'small' && styles.triggerSmall, grouped.style]}
224
229
  >
225
230
  {children}
226
231
  </html.button>
@@ -233,9 +238,18 @@ interface PopupProps {
233
238
  children: ReactNode
234
239
  /** Accessible name for the dialog. */
235
240
  label: string
241
+ /**
242
+ * Draw over another floating surface: `layers.floatingRaised` outside a
243
+ * modal, `layers.popoverRaised` inside a Dialog or Drawer, instead of
244
+ * `floating` / `popover`. For one floating thing that must cover a sibling
245
+ * one (a link editor over a format bar), never for covering a modal: a
246
+ * Dialog opened later still covers it. A Popover nested inside a raised one
247
+ * is raised too. Default: inherited, else `false`.
248
+ */
249
+ raised?: boolean
236
250
  }
237
251
 
238
- function Popup({children, label}: PopupProps) {
252
+ function Popup({children, label, raised}: PopupProps) {
239
253
  const {open, popoverId, side, align, anchor, triggerRef, popupRef, repositionRef} =
240
254
  usePopover('Popup')
241
255
  const [coords, setCoords] = useState({top: 0, left: 0})
@@ -243,6 +257,14 @@ function Popup({children, label}: PopupProps) {
243
257
  // tokens cascade into the popup and it stacks above dialogs.
244
258
  const mount = usePortalMount()
245
259
  const inModal = useInModal()
260
+ const layer = useContext(PopoverLayerContext)
261
+ const isRaised = raised ?? layer?.raised ?? false
262
+ // Re-provided around the children: the Root can't see this prop, and a
263
+ // Popover nested in here reads it to stay above this one.
264
+ const raisedLayer = useMemo<PopoverLayerContextValue | null>(
265
+ () => (layer ? {...layer, raised: isRaised} : null),
266
+ [layer, isRaised],
267
+ )
246
268
 
247
269
  // Measure before paint on open; re-measure on scroll/resize (capture phase
248
270
  // catches any scrolling ancestor) and when Root.reposition() is called.
@@ -291,10 +313,13 @@ function Popup({children, label}: PopupProps) {
291
313
  style={[
292
314
  styles.popup,
293
315
  inModal && styles.popupInModal,
316
+ isRaised && (inModal ? styles.popupInModalRaised : styles.popupRaised),
294
317
  styles.popupPosition(coords.top, coords.left),
295
318
  ]}
296
319
  >
297
- <ControlContextBoundary>{children}</ControlContextBoundary>
320
+ <PopoverLayerContext.Provider value={raisedLayer}>
321
+ <ControlContextBoundary>{children}</ControlContextBoundary>
322
+ </PopoverLayerContext.Provider>
298
323
  </html.div>,
299
324
  mount ?? document.body,
300
325
  )
@@ -5,6 +5,9 @@ import {type RefObject, createContext, useContext, useLayoutEffect} from 'react'
5
5
  // on them counts as inside the Popover.
6
6
  export interface PopoverLayerContextValue {
7
7
  register: (el: Element) => () => void
8
+ /** Set by a `raised` Popup for what it holds, so a Popover nested in it is
9
+ * raised too and is not drawn under its parent. */
10
+ raised?: boolean
8
11
  }
9
12
 
10
13
  export const PopoverLayerContext = createContext<PopoverLayerContextValue | null>(null)