@estiva-app/ui 0.55.0 → 0.57.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 (65) hide show
  1. package/dist/ChipInput.d.ts.map +1 -1
  2. package/dist/CollapsibleSection.d.ts +8 -2
  3. package/dist/CollapsibleSection.d.ts.map +1 -1
  4. package/dist/CommandPalette.d.ts.map +1 -1
  5. package/dist/ContainerHeader.d.ts +7 -1
  6. package/dist/ContainerHeader.d.ts.map +1 -1
  7. package/dist/IconButton.d.ts.map +1 -1
  8. package/dist/ListColumn.d.ts +2 -0
  9. package/dist/ListColumn.d.ts.map +1 -1
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/NavTree.d.ts +11 -3
  13. package/dist/NavTree.d.ts.map +1 -1
  14. package/dist/PersonTrigger.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +24 -5
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/SectionLabel.d.ts.map +1 -1
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/SuggestionMenu.d.ts.map +1 -1
  20. package/dist/gates/index.js +1 -1
  21. package/dist/gates/index.js.map +2 -2
  22. package/dist/index.js +138 -77
  23. package/dist/index.js.map +4 -4
  24. package/dist/looks.d.ts +4 -0
  25. package/dist/looks.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/registry.json +98 -26
  28. package/src/ChipInput.test.tsx +8 -0
  29. package/src/ChipInput.tsx +4 -2
  30. package/src/CollapsibleSection.mdx +1 -1
  31. package/src/CollapsibleSection.stories.tsx +14 -1
  32. package/src/CollapsibleSection.test.tsx +24 -0
  33. package/src/CollapsibleSection.tsx +11 -5
  34. package/src/CommandPalette.test.tsx +6 -0
  35. package/src/CommandPalette.tsx +3 -2
  36. package/src/ContainerHeader.mdx +4 -0
  37. package/src/ContainerHeader.stories.tsx +14 -0
  38. package/src/ContainerHeader.test.tsx +8 -0
  39. package/src/ContainerHeader.tsx +34 -6
  40. package/src/IconButton.tsx +2 -1
  41. package/src/ListColumn.test.tsx +8 -0
  42. package/src/ListColumn.tsx +4 -2
  43. package/src/Menu.tsx +5 -4
  44. package/src/NavItem.mdx +3 -1
  45. package/src/NavItem.test.tsx +32 -2
  46. package/src/NavItem.tsx +28 -12
  47. package/src/NavTree.mdx +16 -1
  48. package/src/NavTree.stories.tsx +49 -2
  49. package/src/NavTree.test.tsx +28 -0
  50. package/src/NavTree.tsx +27 -6
  51. package/src/PersonTrigger.tsx +6 -5
  52. package/src/SectionHeader.mdx +10 -3
  53. package/src/SectionHeader.stories.tsx +18 -0
  54. package/src/SectionHeader.test.tsx +89 -0
  55. package/src/SectionHeader.tsx +114 -26
  56. package/src/SectionLabel.tsx +6 -3
  57. package/src/Select.test.tsx +8 -0
  58. package/src/Select.tsx +5 -2
  59. package/src/Sidebar.tsx +1 -1
  60. package/src/SuggestionMenu.stories.tsx +15 -0
  61. package/src/SuggestionMenu.test.tsx +8 -0
  62. package/src/SuggestionMenu.tsx +4 -2
  63. package/src/eslint/no-copied-look.test.ts +2 -2
  64. package/src/gates/app-checks.ts +1 -1
  65. package/src/looks.ts +6 -0
package/dist/looks.d.ts CHANGED
@@ -36,6 +36,10 @@ export declare const BARE_INPUT_CLASSES = "bg-transparent text-text-primary plac
36
36
  /** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
37
37
  export declare const SIDEBAR_ROW_CLASSES = "rounded-lg transition-colors";
38
38
  export declare const SIDEBAR_ROW_TEXT_CLASSES = "text-text-secondary hover:text-text-primary";
39
+ /** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
40
+ export declare const MUTED_CONTROL_CLASSES = "text-text-secondary hover:bg-bg-hover hover:text-text-primary";
41
+ /** A control that opens a menu: a hover fill, held while its menu is up — PersonTrigger's row, and a ContainerHeader title with a menu. */
42
+ export declare const MENU_TRIGGER_CLASSES = "cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover";
39
43
  /**
40
44
  * A panel that slides open and shut on Base UI's Collapsible: Base UI measures
41
45
  * it and writes its height to a variable, `auto` again once the slide ends, and
@@ -1 +1 @@
1
- {"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA;AAE7G,4IAA4I;AAE5I,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,kLAAkL,CAAA"}
1
+ {"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA;AAE7G,4IAA4I;AAE5I,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,kEAAkE,CAAA;AAEpG,2IAA2I;AAC3I,eAAO,MAAM,oBAAoB,gGAAgG,CAAA;AAEjI;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,kLAAkL,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.55.0",
7
+ "packageVersion": "0.57.0",
8
8
  "exports": 130,
9
9
  "typeExports": 134,
10
10
  "files": null
@@ -1185,6 +1185,12 @@
1185
1185
  "required": false,
1186
1186
  "note": "Remember open or closed in this browser, under this key."
1187
1187
  },
1188
+ {
1189
+ "name": "icon",
1190
+ "takes": "anything",
1191
+ "required": false,
1192
+ "note": "16px, before the title; the arrow takes its place on hover — `SectionHeader`'s."
1193
+ },
1188
1194
  {
1189
1195
  "name": "trailing",
1190
1196
  "takes": "anything",
@@ -1195,7 +1201,19 @@
1195
1201
  "name": "actions",
1196
1202
  "takes": "SectionAction[]",
1197
1203
  "required": false,
1198
- "note": "Beside the title, revealed on hover or focus — `SectionHeader`'s."
1204
+ "note": "Beside the title, revealed while the row is hovered or keyboard-focused — `SectionHeader`'s."
1205
+ },
1206
+ {
1207
+ "name": "menu",
1208
+ "takes": "anything",
1209
+ "required": false,
1210
+ "note": "Beside the actions, revealed with them: the section's \"More options\" menu, `MenuItem`s — `SectionHeader`'s."
1211
+ },
1212
+ {
1213
+ "name": "menuLabel",
1214
+ "takes": "text",
1215
+ "required": false,
1216
+ "note": "The ⋮'s accessible name — `SectionHeader`'s."
1199
1217
  },
1200
1218
  {
1201
1219
  "name": "showActions",
@@ -1750,6 +1768,12 @@
1750
1768
  "required": false,
1751
1769
  "note": "A chevron after the title, for a title that opens something."
1752
1770
  },
1771
+ {
1772
+ "name": "titleMenu",
1773
+ "takes": "anything",
1774
+ "required": false,
1775
+ "note": "The menu the title opens: `MenuItem`s — the column's views (Katerina, 5 October: Peek's Topics column, Topics or Folders)."
1776
+ },
1753
1777
  {
1754
1778
  "name": "actions",
1755
1779
  "takes": "anything",
@@ -1764,14 +1788,29 @@
1764
1788
  }
1765
1789
  ],
1766
1790
  "variants": [],
1767
- "ownsBehaviours": [],
1791
+ "ownsBehaviours": [
1792
+ {
1793
+ "id": "base-ui",
1794
+ "behaviour": "Is built on Base UI"
1795
+ },
1796
+ {
1797
+ "id": "role",
1798
+ "behaviour": "Says what it is to assistive technology"
1799
+ },
1800
+ {
1801
+ "id": "tab-stop",
1802
+ "behaviour": "Is reachable with Tab"
1803
+ }
1804
+ ],
1768
1805
  "status": "stable",
1769
1806
  "migrationStage": null,
1770
1807
  "docsId": "navigation-containerheader--docs",
1771
- "storyId": "navigation-containerheader--default",
1808
+ "storyId": "navigation-containerheader--with-title-menu",
1772
1809
  "docPage": "src/ContainerHeader.mdx",
1773
1810
  "app": null,
1774
- "looks": []
1811
+ "looks": [
1812
+ "cursor-pointer rounded-md transition-colors hover:bg-bg-hover"
1813
+ ]
1775
1814
  },
1776
1815
  {
1777
1816
  "name": "deleteBlock",
@@ -3320,6 +3359,12 @@
3320
3359
  "required": false,
3321
3360
  "note": "A chevron after the title, as `ContainerHeader` draws it."
3322
3361
  },
3362
+ {
3363
+ "name": "titleMenu",
3364
+ "takes": "anything",
3365
+ "required": false,
3366
+ "note": "The menu the title opens — `ContainerHeader`'s: the column's views."
3367
+ },
3323
3368
  {
3324
3369
  "name": "actions",
3325
3370
  "takes": "anything",
@@ -3907,7 +3952,7 @@
3907
3952
  "text-body-2 rounded-lg transition-colors text-text-primary hover:bg-bg-hover",
3908
3953
  "text-body-2 rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover",
3909
3954
  "text-center font-mono text-caption tabular-nums text-text-muted",
3910
- "text-center font-mono text-caption tabular-nums text-text-muted transition-opacity",
3955
+ "text-center font-mono text-caption tabular-nums text-text-muted transition-opacity [@media(hover:none)]:opacity-0",
3911
3956
  "overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none"
3912
3957
  ]
3913
3958
  },
@@ -4016,7 +4061,19 @@
4016
4061
  "name": "actions",
4017
4062
  "takes": "SectionAction[]",
4018
4063
  "required": false,
4019
- "note": "Beside the title, shown on hover or focus: \"Open\", typically."
4064
+ "note": "Beside the title, shown while the row is hovered or keyboard-focused: \"Open\", typically."
4065
+ },
4066
+ {
4067
+ "name": "menu",
4068
+ "takes": "anything",
4069
+ "required": false,
4070
+ "note": "The group's \"More options\" menu, after its actions: `MenuItem`s."
4071
+ },
4072
+ {
4073
+ "name": "icon",
4074
+ "takes": "anything",
4075
+ "required": false,
4076
+ "note": "16px, before the title; the arrow takes its place on hover."
4020
4077
  },
4021
4078
  {
4022
4079
  "name": "defaultOpen",
@@ -4320,8 +4377,8 @@
4320
4377
  "docPage": "src/PersonTrigger.mdx",
4321
4378
  "app": null,
4322
4379
  "looks": [
4323
- "cursor-pointer rounded-md text-body-2 text-text-primary transition-colors hover:bg-bg-hover",
4324
- "cursor-pointer rounded-md text-body-2 text-text-primary transition-colors hover:bg-bg-hover bg-bg-hover"
4380
+ "text-body-2 text-text-primary cursor-pointer rounded-md transition-colors hover:bg-bg-hover",
4381
+ "text-body-2 text-text-primary cursor-pointer rounded-md transition-colors hover:bg-bg-hover bg-bg-hover"
4325
4382
  ]
4326
4383
  },
4327
4384
  {
@@ -5182,7 +5239,7 @@
5182
5239
  "kind": "component",
5183
5240
  "importPath": "@estiva-app/ui",
5184
5241
  "sourceFile": "src/SectionHeader.tsx",
5185
- "purpose": "The 32px row a section starts with: a SectionLabel, and actions that appear on hover or focus as IconButtons with tooltips.",
5242
+ "purpose": "The 32px row a section starts with: a SectionLabel, and actions that appear while the row is hovered or keyboard-focused, as IconButtons with tooltips — always, on a screen with no hover.",
5186
5243
  "purposeFrom": "page",
5187
5244
  "props": [
5188
5245
  {
@@ -5197,6 +5254,12 @@
5197
5254
  "required": false,
5198
5255
  "note": "Collapsible: draws the chevron and makes the title a button that toggles."
5199
5256
  },
5257
+ {
5258
+ "name": "icon",
5259
+ "takes": "anything",
5260
+ "required": false,
5261
+ "note": "16px, stroke 1.5, before the title — a folder's (Katerina, 5 October: Peek's Folders, the standard heading with a folder icon)."
5262
+ },
5200
5263
  {
5201
5264
  "name": "isExpanded",
5202
5265
  "takes": "true/false",
@@ -5221,11 +5284,23 @@
5221
5284
  "required": false,
5222
5285
  "note": "Right-aligned, in the order given."
5223
5286
  },
5287
+ {
5288
+ "name": "menu",
5289
+ "takes": "anything",
5290
+ "required": false,
5291
+ "note": "The heading's \"More options\" menu: `MenuItem`s — what acts on the whole section, a folder's Rename or Archive (Katerina, 5 October: Peek's Folders page goes, and its folder actions move onto the folder's heading)."
5292
+ },
5293
+ {
5294
+ "name": "menuLabel",
5295
+ "takes": "text",
5296
+ "required": false,
5297
+ "note": "The ⋮'s accessible name. Default \"More options for\" the title."
5298
+ },
5224
5299
  {
5225
5300
  "name": "showActions",
5226
5301
  "takes": "hover | always",
5227
5302
  "required": false,
5228
- "note": "`hover` reveals the actions while the row is hovered or focused; `always` keeps them."
5303
+ "note": "`hover` reveals the actions while the row is hovered or keyboard-focused (always on a screen with no hover); `always` keeps them."
5229
5304
  },
5230
5305
  {
5231
5306
  "name": "hover",
@@ -5284,11 +5359,12 @@
5284
5359
  "docPage": "src/SectionHeader.mdx",
5285
5360
  "app": null,
5286
5361
  "looks": [
5362
+ "opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100",
5363
+ "opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100 rotate-90",
5287
5364
  "transition-transform duration-150 text-text-secondary rotate-90",
5288
5365
  "rounded-lg transition-colors text-text-secondary hover:text-text-primary",
5289
5366
  "rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover",
5290
- "-mr-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
5291
- "opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
5367
+ "opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100"
5292
5368
  ]
5293
5369
  },
5294
5370
  {
@@ -5349,19 +5425,15 @@
5349
5425
  "docPage": "src/SectionLabel.mdx",
5350
5426
  "app": null,
5351
5427
  "looks": [
5352
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary",
5353
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary truncate",
5354
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-muted",
5355
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-muted truncate",
5356
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-primary",
5357
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-primary truncate",
5358
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary",
5359
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary truncate",
5360
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-muted",
5361
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-muted truncate",
5362
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-primary",
5363
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-primary truncate",
5364
- "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest",
5428
+ "text-h5 leading-3 text-text-secondary truncate",
5429
+ "text-h5 leading-3 text-text-muted truncate",
5430
+ "text-h5 leading-3 text-text-primary truncate",
5431
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary",
5432
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary truncate",
5433
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-muted",
5434
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-muted truncate",
5435
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-primary",
5436
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-primary truncate",
5365
5437
  "leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest"
5366
5438
  ]
5367
5439
  },
@@ -70,6 +70,14 @@ describe('ChipInput', () => {
70
70
  expect(input.getAttribute('aria-expanded')).toBe('true')
71
71
  })
72
72
 
73
+ it('puts 2px between its rows, as a menu does', async () => {
74
+ const user = userEvent.setup()
75
+ render(<Harness />)
76
+ await user.type(screen.getByRole('combobox'), 'a')
77
+ const [first] = await screen.findAllByRole('option')
78
+ expect(first.parentElement!.className.split(' ')).toContain('gap-0.5')
79
+ })
80
+
73
81
  it('names the highlighted row through aria-activedescendant', async () => {
74
82
  const user = userEvent.setup()
75
83
  render(<Harness />)
package/src/ChipInput.tsx CHANGED
@@ -277,10 +277,12 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
277
277
  for; `--anchor-width` is the positioner's own answer, and the
278
278
  anchor is the box (see `box` above), not the input. The padding
279
279
  is on the scrolling content so the bar hugs the panel (D63), and
280
- 240px is the cap this list has always had. */}
280
+ 240px is the cap this list has always had. The rows are the
281
+ list's own children, so the 2px between them, a menu's, is on
282
+ the list. */}
281
283
  <Combobox.Popup className="w-[var(--anchor-width)] p-0" render={<MenuPanel />}>
282
284
  <ScrollArea viewportClassName="max-h-[240px]" contentClassName="flex flex-col p-2">
283
- <Combobox.List>
285
+ <Combobox.List className="flex flex-col gap-0.5">
284
286
  {(option: T) => (
285
287
  <Combobox.Item
286
288
  key={option.id}
@@ -21,7 +21,7 @@ toggle, and the rows under it, sliding shut and open.
21
21
  key (`app.sidebar.starred`).
22
22
  - `open` + `onOpenChange` when the app owns the state.
23
23
  - `actions` for add, sort, filter beside the title — SectionHeader's,
24
- revealed on hover or focus.
24
+ revealed while the row is hovered or keyboard-focused.
25
25
  - `look="row"` when the section is a row among rows — a folder in a
26
26
  sidebar, under a quiet label: the title at a row's weight, and its rows
27
27
  indented under it, past the chevron.
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconPlus } from '@tabler/icons-react'
3
3
  import { Chip } from './Chip'
4
4
  import { CollapsibleSection } from './CollapsibleSection'
5
+ import { MenuItem } from './Menu'
5
6
  import { NavItem } from './NavItem'
6
7
 
7
8
  /** A section that opens and closes: the header's title is the toggle, and the rows slide. */
@@ -9,7 +10,7 @@ const meta = {
9
10
  title: 'Navigation/CollapsibleSection',
10
11
  component: CollapsibleSection,
11
12
  decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
12
- args: { title: 'Section', contentClassName: 'gap-px', children: null },
13
+ args: { title: 'Section', contentClassName: 'gap-0.5', children: null },
13
14
  argTypes: { children: { control: false }, actions: { control: false } },
14
15
  render: (args) => (
15
16
  <CollapsibleSection {...args}>
@@ -48,6 +49,18 @@ export const AsARow: Story = {
48
49
  args: { title: 'Folder', look: 'row' },
49
50
  }
50
51
 
52
+ /** `menu`: the section's "More options" ⋮, after its actions — a folder's Rename, Archive. Hover the title. */
53
+ export const WithMenu: Story = {
54
+ args: {
55
+ menu: (
56
+ <>
57
+ <MenuItem label="Rename" />
58
+ <MenuItem label="Delete" destructive />
59
+ </>
60
+ ),
61
+ },
62
+ }
63
+
51
64
  /** `indent`: the heading look, its rows starting under its title, past the chevron — a folder in a sidebar. */
52
65
  export const Indented: Story = {
53
66
  args: { title: 'Group', indent: true },
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9
9
  import { cleanup, render, screen } from '@testing-library/react'
10
10
  import userEvent from '@testing-library/user-event'
11
11
  import { CollapsibleSection } from './CollapsibleSection'
12
+ import { MenuItem } from './Menu'
12
13
 
13
14
  afterEach(cleanup)
14
15
  beforeEach(() => window.localStorage.clear())
@@ -79,3 +80,26 @@ describe('CollapsibleSection', () => {
79
80
  expect(title().getAttribute('aria-expanded')).toBe('false')
80
81
  })
81
82
  })
83
+
84
+ /** What it hands its header (0.56.0): the folder's ⋮, the ⋮'s name, and the icon before the title. */
85
+ describe('CollapsibleSection, its header', () => {
86
+ it('passes menu and menuLabel on: the ⋮ is named by menuLabel and opens the menu', async () => {
87
+ render(<CollapsibleSection title="Section" menu={<MenuItem label="Rename" />} menuLabel="Section options">{rows}</CollapsibleSection>)
88
+ await userEvent.click(screen.getByRole('button', { name: 'Section options' }))
89
+ expect(await screen.findByRole('menuitem', { name: 'Rename' })).not.toBeNull()
90
+ // The ⋮ acts; it does not fold the section.
91
+ expect(title().getAttribute('aria-expanded')).toBe('true')
92
+ })
93
+
94
+ it('with menu and no menuLabel, the ⋮ is named for the title', () => {
95
+ render(<CollapsibleSection title="Section" menu={<MenuItem label="Rename" />}>{rows}</CollapsibleSection>)
96
+ expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull()
97
+ })
98
+
99
+ it('passes icon on: it is drawn inside the title’s toggle, before the words', () => {
100
+ render(<CollapsibleSection title="Section" icon={<span data-testid="icon" />}>{rows}</CollapsibleSection>)
101
+ const icon = screen.getByTestId('icon')
102
+ expect(title().contains(icon)).toBe(true)
103
+ expect(icon.compareDocumentPosition(screen.getByText('Section')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
104
+ })
105
+ })
@@ -32,10 +32,16 @@ export interface CollapsibleSectionProps {
32
32
  onOpenChange?: (open: boolean) => void
33
33
  /** Remember open or closed in this browser, under this key. The app prefixes it. */
34
34
  storageKey?: string
35
+ /** 16px, before the title; the arrow takes its place on hover — `SectionHeader`'s. */
36
+ icon?: ReactNode
35
37
  /** Beside the title and always visible, before the actions — a count. `SectionHeader`'s. */
36
38
  trailing?: ReactNode
37
- /** Beside the title, revealed on hover or focus — `SectionHeader`'s. */
39
+ /** Beside the title, revealed while the row is hovered or keyboard-focused — `SectionHeader`'s. */
38
40
  actions?: SectionAction[]
41
+ /** Beside the actions, revealed with them: the section's "More options" menu, `MenuItem`s — `SectionHeader`'s. */
42
+ menu?: ReactNode
43
+ /** The ⋮'s accessible name — `SectionHeader`'s. */
44
+ menuLabel?: string
39
45
  showActions?: 'hover' | 'always'
40
46
  /**
41
47
  * `heading` (default): the title is a section heading. `row`: the fold is a
@@ -79,7 +85,7 @@ function writeStored(key: string | undefined, open: boolean) {
79
85
  }
80
86
  }
81
87
 
82
- export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, trailing, actions, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
88
+ export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, icon, trailing, actions, menu, menuLabel, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
83
89
  const [openState, setOpenState] = useState(() => readStored(storageKey) ?? defaultOpen)
84
90
  const open = openProp ?? openState
85
91
  const setOpen = (next: boolean) => {
@@ -89,7 +95,7 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
89
95
  }
90
96
  return (
91
97
  <Collapsible.Root open={open} onOpenChange={setOpen} className={cn('flex flex-col', className)}>
92
- <SectionHeader title={title} chevron isExpanded={open} trailing={trailing} actions={actions} showActions={showActions} look={look} className="shrink-0" render={<Collapsible.Trigger />} />
98
+ <SectionHeader title={title} chevron icon={icon} isExpanded={open} trailing={trailing} actions={actions} menu={menu} menuLabel={menuLabel} showActions={showActions} look={look} className="shrink-0" render={<Collapsible.Trigger />} />
93
99
  {/* The slide: Base UI measures the panel and writes its height to a
94
100
  variable — `auto` again once the slide ends, so rows that arrive
95
101
  later are not clipped — and the panel is 0 high on its opening frame
@@ -101,8 +107,8 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
101
107
  className={COLLAPSIBLE_PANEL_CLASSES}
102
108
  >
103
109
  {/* A row's rows start under its title: the 16px chevron and its 8px gap
104
- (pl-6). An indented heading's: its 12px chevron and 4px gap (pl-4). */}
105
- <div className={cn('flex flex-col', look === 'row' ? 'pl-6' : indent && 'pl-4', contentClassName)}>{children}</div>
110
+ (pl-6). An indented heading's: the same — its arrow is a row's now (5 October). */}
111
+ <div className={cn('flex flex-col pt-0.5', (look === 'row' || indent) && 'pl-6', contentClassName)}>{children}</div>
106
112
  </Collapsible.Panel>
107
113
  </Collapsible.Root>
108
114
  )
@@ -94,6 +94,12 @@ describe('CommandPaletteSearch — rows', () => {
94
94
  expect(groups.map((g) => g.firstElementChild?.textContent)).toEqual(['First', 'Second'])
95
95
  })
96
96
 
97
+ it('puts 2px between its rows and under each heading, as a menu does', () => {
98
+ render(<Search groups={[{ label: 'Group', rows: [row('One'), row('Two')] }]} />)
99
+ const [first] = screen.getAllByRole('option')
100
+ expect(first.parentElement!.className.split(' ')).toContain('gap-0.5')
101
+ })
102
+
97
103
  it('lights the first row, and Enter does what it does', async () => {
98
104
  const onSelect = vi.fn()
99
105
  const user = userEvent.setup()
@@ -397,9 +397,10 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
397
397
 
398
398
  <Autocomplete.List className="flex flex-col">
399
399
  {(group: ListGroup) => (
400
- <Autocomplete.Group key={group.value} items={group.items} className="flex flex-col">
400
+ <Autocomplete.Group key={group.value} items={group.items} className="flex flex-col gap-0.5">
401
401
  {/* A heading labels the rows, it is not one of them: read
402
- secondary, as in a menu (MenuSection). */}
402
+ secondary, as in a menu (MenuSection). 2px under it and
403
+ between its rows, as every list has (Katerina, 5 October). */}
403
404
  <Autocomplete.GroupLabel className="flex h-7 shrink-0 items-center px-3">
404
405
  <SectionLabel tone="secondary">{group.value}</SectionLabel>
405
406
  </Autocomplete.GroupLabel>
@@ -17,6 +17,10 @@ with a hairline under it.
17
17
  thread, a side panel. One per column.
18
18
  - The column's own actions — new, sort, close — as IconButtons with tooltips
19
19
  in `actions`.
20
+ - `titleMenu`: the title opens a menu of the column's views, its chevron
21
+ drawn; the title and the chevron are one button.
22
+
23
+ <Canvas of={ContainerHeaderStories.WithTitleMenu} />
20
24
 
21
25
  ## When not
22
26
 
@@ -2,6 +2,7 @@ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconEdit, IconSortDescending, IconX } from '@tabler/icons-react'
3
3
  import { ContainerHeader } from './ContainerHeader'
4
4
  import { IconButton } from './IconButton'
5
+ import { MenuItem } from './Menu'
5
6
  import { Card } from './Card'
6
7
 
7
8
  // The top of a column: a surface with a hairline, so the bar's own hairline and height can be seen.
@@ -18,6 +19,19 @@ const meta = {
18
19
  export default meta
19
20
  type Story = StoryObj<typeof meta>
20
21
 
22
+ /** A title that opens a menu: the title and its chevron are one button. Click it. */
23
+ export const WithTitleMenu: Story = {
24
+ args: {
25
+ title: 'View one',
26
+ titleMenu: (
27
+ <>
28
+ <MenuItem label="View one" selected />
29
+ <MenuItem label="View two" />
30
+ </>
31
+ ),
32
+ },
33
+ }
34
+
21
35
  /** A string title, one line, alone. */
22
36
  export const Default: Story = {}
23
37
 
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import { afterEach, describe, expect, it } from 'vitest'
9
9
  import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { MenuItem } from './Menu'
10
12
  import { ContainerHeader } from './ContainerHeader'
11
13
 
12
14
  afterEach(cleanup)
@@ -44,4 +46,10 @@ describe('ContainerHeader', () => {
44
46
  expect(container.firstElementChild!.children).toHaveLength(2)
45
47
  expect(screen.getByRole('button', { name: 'New' }).closest('div')!.className).toContain('gap-1')
46
48
  })
49
+
50
+ it('a title with a menu is one button, and opens it', async () => {
51
+ render(<ContainerHeader title="View one" titleMenu={<MenuItem label="View two" />} />)
52
+ await userEvent.click(screen.getByRole('button', { name: 'View one' }))
53
+ expect(await screen.findByRole('menuitem', { name: 'View two' })).not.toBeNull()
54
+ })
47
55
  })
@@ -1,6 +1,9 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { IconChevronDown } from '@tabler/icons-react'
3
+ import { Button as BaseButton } from '@base-ui/react/button'
3
4
  import { cn } from './cn'
5
+ import { MENU_TRIGGER_CLASSES } from './looks'
6
+ import { Menu } from './Menu'
4
7
 
5
8
  /**
6
9
  * The bar across the top of a column — a list, a thread, a side panel: its
@@ -20,20 +23,45 @@ export interface ContainerHeaderProps {
20
23
  title: ReactNode
21
24
  /** A chevron after the title, for a title that opens something. */
22
25
  chevron?: boolean
26
+ /**
27
+ * The menu the title opens: `MenuItem`s — the column's views (Katerina,
28
+ * 5 October: Peek's Topics column, Topics or Folders). The title and its
29
+ * chevron are then one button, lit under the pointer; the chevron is drawn.
30
+ */
31
+ titleMenu?: ReactNode
23
32
  /** The column's own buttons, at the right edge: IconButtons with tooltips, 4px apart. */
24
33
  actions?: ReactNode
25
34
  className?: string
26
35
  }
27
36
 
28
- export function ContainerHeader({ title, chevron = false, actions, className }: ContainerHeaderProps) {
37
+ export function ContainerHeader({ title, chevron = false, titleMenu, actions, className }: ContainerHeaderProps) {
29
38
  const text = typeof title === 'string'
39
+ const name = (
40
+ <div className={cn('flex items-center gap-1', text ? 'shrink-0' : 'min-w-0 flex-1')}>
41
+ {text ? <span className="whitespace-nowrap text-body-2-strong text-text-primary">{title}</span> : <div className="min-w-0 flex-1">{title}</div>}
42
+ {(chevron || titleMenu != null) && <IconChevronDown size={12} stroke={1.5} className="shrink-0 text-text-secondary" />}
43
+ </div>
44
+ )
30
45
  return (
31
- <div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4 pl-5', className)}>
46
+ // With a title menu the bar gives the button 6px of its left padding
47
+ // (pl-3.5 + the button's px-1.5 = pl-5): the words stay where a title's
48
+ // are, and the fill is inside the bar — a negative margin had it clipped
49
+ // (Katerina, 5 October).
50
+ <div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4', titleMenu != null ? 'pl-3.5' : 'pl-5', className)}>
32
51
  <div className={cn('flex items-center gap-2 overflow-hidden', text ? 'shrink-0' : 'min-w-0 flex-1')}>
33
- <div className={cn('flex items-center gap-1', text ? 'shrink-0' : 'min-w-0 flex-1')}>
34
- {text ? <span className="whitespace-nowrap text-body-2-strong text-text-primary">{title}</span> : <div className="min-w-0 flex-1">{title}</div>}
35
- {chevron && <IconChevronDown size={12} stroke={1.5} className="shrink-0 text-text-secondary" />}
36
- </div>
52
+ {titleMenu != null ? (
53
+ <Menu
54
+ trigger={
55
+ <BaseButton type="button" className={cn('flex min-w-0 items-center px-1.5 py-1', MENU_TRIGGER_CLASSES)}>
56
+ {name}
57
+ </BaseButton>
58
+ }
59
+ >
60
+ {titleMenu}
61
+ </Menu>
62
+ ) : (
63
+ name
64
+ )}
37
65
  </div>
38
66
  {actions && (
39
67
  <div className="flex shrink-0 items-center justify-end gap-3">
@@ -1,6 +1,7 @@
1
1
  import type { ComponentPropsWithRef, ReactNode } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { cn } from './cn'
4
+ import { MUTED_CONTROL_CLASSES } from './looks'
4
5
  import { useFormBusy } from './formBusy'
5
6
  import { TooltipTrigger } from './Tooltip'
6
7
 
@@ -82,7 +83,7 @@ export function IconButton({
82
83
  // stretched and carries no `self-center` — see the note there.
83
84
  'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
84
85
  !off && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
85
- !off && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
86
+ !off && (variant === 'muted' || variant === 'resolve') && MUTED_CONTROL_CLASSES,
86
87
  !off && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
87
88
  !off && variant === 'current' && 'hover:bg-bg-hover',
88
89
  off && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
@@ -2,6 +2,8 @@
2
2
  /** What the ListColumn page claims, pinned. Sizes are measured in the browser; these pin the parts. */
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
4
  import { cleanup, render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { MenuItem } from './Menu'
5
7
  import { ListColumn } from './ListColumn'
6
8
 
7
9
  afterEach(cleanup)
@@ -12,6 +14,12 @@ describe('ListColumn', () => {
12
14
  expect(screen.getByText('Items')).toBeTruthy()
13
15
  })
14
16
 
17
+ it('titleMenu: the title is the button that opens the column’s views', async () => {
18
+ render(<ListColumn title="View one" titleMenu={<MenuItem label="View two" />}>{null}</ListColumn>)
19
+ await userEvent.click(screen.getByRole('button', { name: 'View one' }))
20
+ expect(await screen.findByRole('menuitem', { name: 'View two' })).not.toBeNull()
21
+ })
22
+
15
23
  it('is 290px wide with a line on its right, and keeps its width', () => {
16
24
  const { container } = render(<ListColumn title="Items">{null}</ListColumn>)
17
25
  const classes = (container.firstElementChild as HTMLElement).className.split(' ')
@@ -32,6 +32,8 @@ export interface ListColumnProps {
32
32
  title: ReactNode
33
33
  /** A chevron after the title, as `ContainerHeader` draws it. */
34
34
  chevron?: boolean
35
+ /** The menu the title opens — `ContainerHeader`'s: the column's views. */
36
+ titleMenu?: ReactNode
35
37
  /** The column's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
36
38
  actions?: ReactNode
37
39
  /** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
@@ -44,7 +46,7 @@ export interface ListColumnProps {
44
46
  className?: string
45
47
  }
46
48
 
47
- function Column({ title, chevron = false, actions, above, collapsed = false, children, className, broken = false }: ListColumnProps & { broken?: boolean }) {
49
+ function Column({ title, chevron = false, titleMenu, actions, above, collapsed = false, children, className, broken = false }: ListColumnProps & { broken?: boolean }) {
48
50
  return (
49
51
  <div
50
52
  className={cn(
@@ -55,7 +57,7 @@ function Column({ title, chevron = false, actions, above, collapsed = false, chi
55
57
  )}
56
58
  data-collapsed={collapsed || undefined}
57
59
  >
58
- <ContainerHeader title={title} chevron={chevron} actions={actions} />
60
+ <ContainerHeader title={title} chevron={chevron} titleMenu={titleMenu} actions={actions} />
59
61
  {above}
60
62
  {broken ? (
61
63
  // The message takes the room the list had and centres itself in it (Katerina, 19 September).