@estiva-app/ui 0.55.0 → 0.56.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 (48) hide show
  1. package/dist/CollapsibleSection.d.ts +7 -1
  2. package/dist/CollapsibleSection.d.ts.map +1 -1
  3. package/dist/ContainerHeader.d.ts +7 -1
  4. package/dist/ContainerHeader.d.ts.map +1 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/ListColumn.d.ts +2 -0
  7. package/dist/ListColumn.d.ts.map +1 -1
  8. package/dist/Menu.d.ts.map +1 -1
  9. package/dist/NavItem.d.ts.map +1 -1
  10. package/dist/NavTree.d.ts +9 -1
  11. package/dist/NavTree.d.ts.map +1 -1
  12. package/dist/PersonTrigger.d.ts.map +1 -1
  13. package/dist/SectionHeader.d.ts +22 -3
  14. package/dist/SectionHeader.d.ts.map +1 -1
  15. package/dist/SectionLabel.d.ts.map +1 -1
  16. package/dist/gates/index.js +1 -1
  17. package/dist/gates/index.js.map +2 -2
  18. package/dist/index.js +136 -72
  19. package/dist/index.js.map +4 -4
  20. package/dist/looks.d.ts +4 -0
  21. package/dist/looks.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/registry.json +93 -22
  24. package/src/CollapsibleSection.stories.tsx +14 -1
  25. package/src/CollapsibleSection.tsx +10 -4
  26. package/src/ContainerHeader.mdx +4 -0
  27. package/src/ContainerHeader.stories.tsx +14 -0
  28. package/src/ContainerHeader.test.tsx +8 -0
  29. package/src/ContainerHeader.tsx +34 -6
  30. package/src/IconButton.tsx +2 -1
  31. package/src/ListColumn.tsx +4 -2
  32. package/src/Menu.tsx +5 -4
  33. package/src/NavItem.test.tsx +2 -2
  34. package/src/NavItem.tsx +21 -12
  35. package/src/NavTree.mdx +16 -1
  36. package/src/NavTree.stories.tsx +49 -2
  37. package/src/NavTree.test.tsx +7 -0
  38. package/src/NavTree.tsx +21 -4
  39. package/src/PersonTrigger.tsx +6 -5
  40. package/src/SectionHeader.mdx +6 -0
  41. package/src/SectionHeader.stories.tsx +18 -0
  42. package/src/SectionHeader.test.tsx +26 -0
  43. package/src/SectionHeader.tsx +99 -23
  44. package/src/SectionLabel.tsx +6 -3
  45. package/src/Sidebar.tsx +1 -1
  46. package/src/eslint/no-copied-look.test.ts +2 -2
  47. package/src/gates/app-checks.ts +1 -1
  48. package/src/looks.ts +6 -0
package/dist/looks.d.ts CHANGED
@@ -34,6 +34,10 @@ export declare const CHIP_TEXT_CLASSES = "text-chip signal:font-mono signal:text
34
34
  /** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
35
35
  export declare const BARE_INPUT_CLASSES = "bg-transparent text-text-primary placeholder:text-text-muted outline-none";
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
+ /** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
38
+ export declare const MUTED_CONTROL_CLASSES = "text-text-secondary hover:bg-bg-hover hover:text-text-primary";
39
+ /** 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. */
40
+ export declare const MENU_TRIGGER_CLASSES = "cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover";
37
41
  export declare const SIDEBAR_ROW_CLASSES = "rounded-lg transition-colors";
38
42
  export declare const SIDEBAR_ROW_TEXT_CLASSES = "text-text-secondary hover:text-text-primary";
39
43
  /**
@@ -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,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,kEAAkE,CAAA;AAEpG,2IAA2I;AAC3I,eAAO,MAAM,oBAAoB,gGAAgG,CAAA;AAEjI,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF;;;;;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.56.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.56.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",
@@ -1197,6 +1203,18 @@
1197
1203
  "required": false,
1198
1204
  "note": "Beside the title, revealed on hover or focus — `SectionHeader`'s."
1199
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."
1217
+ },
1200
1218
  {
1201
1219
  "name": "showActions",
1202
1220
  "takes": "hover | always",
@@ -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",
@@ -4018,6 +4063,18 @@
4018
4063
  "required": false,
4019
4064
  "note": "Beside the title, shown on hover or focus: \"Open\", typically."
4020
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."
4077
+ },
4021
4078
  {
4022
4079
  "name": "defaultOpen",
4023
4080
  "takes": "true/false",
@@ -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
  {
@@ -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,6 +5284,18 @@
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",
@@ -5284,11 +5359,11 @@
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
- "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"
5366
+ "rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover"
5292
5367
  ]
5293
5368
  },
5294
5369
  {
@@ -5349,19 +5424,15 @@
5349
5424
  "docPage": "src/SectionLabel.mdx",
5350
5425
  "app": null,
5351
5426
  "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",
5427
+ "text-h5 leading-3 text-text-secondary truncate",
5428
+ "text-h5 leading-3 text-text-muted truncate",
5429
+ "text-h5 leading-3 text-text-primary truncate",
5430
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary",
5431
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-secondary truncate",
5432
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-muted",
5433
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-muted truncate",
5434
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-primary",
5435
+ "text-h5 leading-3 leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest text-text-primary truncate",
5365
5436
  "leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest"
5366
5437
  ]
5367
5438
  },
@@ -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 },
@@ -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
39
  /** Beside the title, revealed on hover or focus — `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
  )
@@ -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',
@@ -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).
package/src/Menu.tsx CHANGED
@@ -95,7 +95,8 @@ export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
95
95
  return (
96
96
  <div
97
97
  className={cn(
98
- 'flex flex-col p-2',
98
+ // 2px between rows, as a list column's and a sidebar's (Katerina, 5 October).
99
+ 'flex flex-col gap-0.5 p-2',
99
100
  FLOATING_SURFACE_CLASSES,
100
101
  /*
101
102
  * A divider in a menu runs the width of the rows it separates.
@@ -248,7 +249,7 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
248
249
  * sentence was written at 0.12.6 and the prop was not: a `p-1` on
249
250
  * `className` added 4px to these 8px instead of replacing them
250
251
  * (PLAN Finding 60). The prop exists now. */}
251
- <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2', contentClassName)}>
252
+ <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col gap-0.5 p-2', contentClassName)}>
252
253
  <MenuContext.Provider value={{ openOnHover }}>
253
254
  <DividerInPanel.Provider value={true}>{children}</DividerInPanel.Provider>
254
255
  </MenuContext.Provider>
@@ -578,14 +579,14 @@ export function MenuSection({ label, children, className }: { label: string; chi
578
579
  )
579
580
  if (!inMenu) {
580
581
  return (
581
- <div className="flex flex-col">
582
+ <div className="flex flex-col gap-0.5">
582
583
  {heading}
583
584
  {children}
584
585
  </div>
585
586
  )
586
587
  }
587
588
  return (
588
- <BaseMenu.Group className="flex flex-col">
589
+ <BaseMenu.Group className="flex flex-col gap-0.5">
589
590
  <BaseMenu.GroupLabel render={heading} />
590
591
  {children}
591
592
  </BaseMenu.Group>
@@ -99,10 +99,10 @@ describe('NavItem unread', () => {
99
99
  expect(container.querySelector('[data-urgent], [data-unread]')).toBeNull()
100
100
  })
101
101
 
102
- it('with a menu, the ⋮ sits in the 24px slot over the dot', () => {
102
+ it('with a menu over the dot, the ⋮ ends where a heading’s ⋮ ends', () => {
103
103
  render(<NavItem href="#" label="Item one" unread menu={<span>Action</span>} />)
104
104
  const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
105
- expect(menu?.className).toContain('right-2')
105
+ expect(menu?.className).toContain('right-1')
106
106
  })
107
107
 
108
108
  it('with a count and a menu, the ⋮ lands on the count', () => {
package/src/NavItem.tsx CHANGED
@@ -108,7 +108,11 @@ export function NavItem({
108
108
  // What sits at the right at rest, after the count: the dot, or the badge.
109
109
  const mark = unread ? (urgent ? <UrgentMark /> : <UnreadDot />) : null
110
110
  // Under the ⋮ while it shows: the count and the mark step aside for it.
111
- const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0'
111
+ // "While it shows" is the pointer on the row or the keyboard in it
112
+ // (`:focus-visible`), never any focus: a clicked row keeps its focus, and
113
+ // with `focus-within` its icon and dot went while the arrow and the ⋮ stayed
114
+ // hidden (Katerina, 5 October).
115
+ const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0'
112
116
 
113
117
  const row = (
114
118
  <a
@@ -135,7 +139,7 @@ export function NavItem({
135
139
  className={cn(
136
140
  // With rows under it the slot is kept even with no icon: the arrow lands in it.
137
141
  'flex min-w-4 shrink-0 items-center',
138
- hasRows && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0',
142
+ hasRows && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0',
139
143
  )}
140
144
  >
141
145
  {icon}
@@ -144,7 +148,7 @@ export function NavItem({
144
148
  <span className={cn('flex-1 truncate', unread && 'font-medium')}>{label}</span>
145
149
  {hint != null && (
146
150
  // Takes room only while it shows, so the label gives way to it.
147
- <span className="hidden shrink-0 items-center group-hover/nav:flex group-focus-within/nav:flex">{hint}</span>
151
+ <span className="hidden shrink-0 items-center group-hover/nav:flex group-has-[:focus-visible]/nav:flex">{hint}</span>
148
152
  )}
149
153
  {count ? (
150
154
  <WithTooltip label={countLabel ?? `${count} open`}>
@@ -164,12 +168,15 @@ export function NavItem({
164
168
  </WithTooltip>
165
169
  ) : null}
166
170
  {mark ? (
167
- <span className={cn('flex shrink-0', stepsAside)}>{mark}</span>
171
+ // `-mr-1`: the 24px slot's dot then sits on the axis of a count and
172
+ // of the ⋮ (8px from the edge to the 16px box), one column with a
173
+ // folder heading's dot and ⋮ (Katerina, 5 October).
174
+ <span className={cn('-mr-1 flex shrink-0', stepsAside)}>{mark}</span>
168
175
  ) : menu != null && !count ? (
169
176
  // Nothing at the right, but a menu: no room is kept at rest, so the
170
177
  // label has the row's whole width; while the ⋮ shows, its 24px slot
171
178
  // is kept, so a long label ends before it instead of running under it.
172
- <span aria-hidden="true" className="hidden w-6 shrink-0 group-hover/nav:block group-focus-within/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block" />
179
+ <span aria-hidden="true" className="hidden w-6 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block" />
173
180
  ) : null}
174
181
  </a>
175
182
  )
@@ -181,7 +188,7 @@ export function NavItem({
181
188
  {hasRows && (
182
189
  // `left-1`: the 24px button's 16px arrow then sits where the icon
183
190
  // sits (the row's px-2), so the icon turns into the arrow in place.
184
- <div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100">
191
+ <div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100">
185
192
  <IconButton
186
193
  tooltip={open ? 'Collapse' : 'Expand'}
187
194
  aria-label={open ? `Hide what is under ${label}` : `Show what is under ${label}`}
@@ -194,11 +201,13 @@ export function NavItem({
194
201
  </div>
195
202
  )}
196
203
  {menu != null && (
197
- // On a count, `right-1`: the 24px button's 16px icon then ends 8px
198
- // from the row's edge, where the count ends (px-2), so the ⋮ lands on
199
- // the number. On the mark or the empty slot, `right-2`: the button
200
- // sits in the 24px slot itself.
201
- <div data-nav-menu className={cn('absolute inset-y-0 flex items-center', count && !mark ? 'right-1' : 'right-2', 'opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100')}>
204
+ // `right-1`: the 24px button's 16px icon ends 8px from the row's
205
+ // edge, where a count ends (px-2) — and where a SectionHeader's
206
+ // actions and ⋮ end, so a folder's ⋮ and its rows' ⋮ are one column
207
+ // (Katerina, 5 October: the rows move to the heading, not the
208
+ // heading to the rows). Over a dot or the empty slot it sits 4px
209
+ // right of the slot's centre.
210
+ <div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100')}>
202
211
  <Menu
203
212
  align="right"
204
213
  trigger={
@@ -225,7 +234,7 @@ export function NavItem({
225
234
  className={COLLAPSIBLE_PANEL_CLASSES}
226
235
  >
227
236
  {/* `pl-6`: past the 16px icon and its 8px gap, so the rows' icons start under this row's label. */}
228
- <div className="flex flex-col gap-px pl-6">{children}</div>
237
+ <div className="flex flex-col gap-0.5 pt-0.5 pl-6">{children}</div>
229
238
  </Collapsible.Panel>
230
239
  </Collapsible.Root>
231
240
  )