@estiva-app/ui 0.50.3 → 0.51.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 (45) hide show
  1. package/dist/BlockNodes.d.ts.map +1 -1
  2. package/dist/CollapsibleSection.d.ts +7 -1
  3. package/dist/CollapsibleSection.d.ts.map +1 -1
  4. package/dist/NavItem.d.ts +23 -3
  5. package/dist/NavItem.d.ts.map +1 -1
  6. package/dist/NavTree.d.ts +49 -0
  7. package/dist/NavTree.d.ts.map +1 -0
  8. package/dist/SectionLabel.d.ts +7 -1
  9. package/dist/SectionLabel.d.ts.map +1 -1
  10. package/dist/editor.js +1 -1
  11. package/dist/editor.js.map +2 -2
  12. package/dist/eslint/index.js +1 -1
  13. package/dist/eslint/index.js.map +2 -2
  14. package/dist/eslint/no-native-title.d.ts +1 -1
  15. package/dist/eslint/no-native-title.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +332 -212
  19. package/dist/index.js.map +4 -4
  20. package/dist/looks.d.ts +7 -0
  21. package/dist/looks.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/registry.json +115 -5
  24. package/src/BlockNodes.test.tsx +43 -0
  25. package/src/BlockNodes.tsx +5 -2
  26. package/src/CollapsibleSection.mdx +6 -0
  27. package/src/CollapsibleSection.stories.tsx +5 -0
  28. package/src/CollapsibleSection.tsx +12 -4
  29. package/src/NavItem.mdx +30 -0
  30. package/src/NavItem.stories.tsx +34 -0
  31. package/src/NavItem.test.tsx +82 -0
  32. package/src/NavItem.tsx +123 -25
  33. package/src/NavTree.mdx +83 -0
  34. package/src/NavTree.stories.tsx +84 -0
  35. package/src/NavTree.test.tsx +54 -0
  36. package/src/NavTree.tsx +112 -0
  37. package/src/SectionHeader.tsx +1 -1
  38. package/src/SectionLabel.mdx +4 -1
  39. package/src/SectionLabel.stories.tsx +3 -0
  40. package/src/SectionLabel.tsx +10 -2
  41. package/src/eslint/no-native-title.ts +1 -1
  42. package/src/index.ts +1 -0
  43. package/src/looks.ts +8 -0
  44. package/src/registry/registry.test.ts +4 -4
  45. package/stories/Choosing.mdx +1 -0
package/dist/looks.d.ts CHANGED
@@ -36,4 +36,11 @@ 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-md transition-colors";
38
38
  export declare const SIDEBAR_ROW_TEXT_CLASSES = "text-text-secondary hover:text-text-primary";
39
+ /**
40
+ * A panel that slides open and shut on Base UI's Collapsible: Base UI measures
41
+ * it and writes its height to a variable, `auto` again once the slide ends, and
42
+ * the panel is 0 high on its opening and closing frames. None under
43
+ * prefers-reduced-motion. CollapsibleSection's rows, and the rows under a NavItem.
44
+ */
45
+ export declare const COLLAPSIBLE_PANEL_CLASSES = "h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none data-[starting-style]:h-0 data-[ending-style]:h-0";
39
46
  //# sourceMappingURL=looks.d.ts.map
@@ -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;AAC5I,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,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;AAC5I,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.50.3",
3
+ "version": "0.51.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,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.50.3",
8
- "exports": 104,
9
- "typeExports": 113,
7
+ "packageVersion": "0.51.0",
8
+ "exports": 105,
9
+ "typeExports": 116,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -1089,6 +1089,12 @@
1089
1089
  "required": false,
1090
1090
  "note": "`heading` (default): the title is a section heading."
1091
1091
  },
1092
+ {
1093
+ "name": "indent",
1094
+ "takes": "true/false",
1095
+ "required": false,
1096
+ "note": "With the `heading` look: the rows start under the title, past the chevron — a folder in a sidebar, its files lightly indented under its name (Katerina, 2 October)."
1097
+ },
1092
1098
  {
1093
1099
  "name": "children",
1094
1100
  "takes": "anything",
@@ -3512,6 +3518,36 @@
3512
3518
  "required": false,
3513
3519
  "note": "The ⋮'s accessible name and tooltip. Default \"More options for {label}\"."
3514
3520
  },
3521
+ {
3522
+ "name": "hint",
3523
+ "takes": "anything",
3524
+ "required": false,
3525
+ "note": "Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically."
3526
+ },
3527
+ {
3528
+ "name": "children",
3529
+ "takes": "anything",
3530
+ "required": false,
3531
+ "note": "Rows under this one, folding under it: `NavItem`s."
3532
+ },
3533
+ {
3534
+ "name": "defaultOpen",
3535
+ "takes": "true/false",
3536
+ "required": false,
3537
+ "note": "With `children`: open unless told otherwise."
3538
+ },
3539
+ {
3540
+ "name": "open",
3541
+ "takes": "true/false",
3542
+ "required": false,
3543
+ "note": "With `children`: the caller owns the state."
3544
+ },
3545
+ {
3546
+ "name": "onOpenChange",
3547
+ "takes": "a handler",
3548
+ "required": false,
3549
+ "note": null
3550
+ },
3515
3551
  {
3516
3552
  "name": "className",
3517
3553
  "takes": "text",
@@ -3520,7 +3556,12 @@
3520
3556
  }
3521
3557
  ],
3522
3558
  "variants": [],
3523
- "ownsBehaviours": [],
3559
+ "ownsBehaviours": [
3560
+ {
3561
+ "id": "base-ui",
3562
+ "behaviour": "Is built on Base UI"
3563
+ }
3564
+ ],
3524
3565
  "status": "stable",
3525
3566
  "migrationStage": null,
3526
3567
  "docsId": "frame-navitem--docs",
@@ -3531,9 +3572,66 @@
3531
3572
  "text-body-2 rounded-md transition-colors bg-bg-active text-text-primary",
3532
3573
  "text-body-2 rounded-md transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover",
3533
3574
  "text-center font-mono text-caption tabular-nums text-text-muted",
3534
- "text-center font-mono text-caption tabular-nums text-text-muted transition-opacity"
3575
+ "text-center font-mono text-caption tabular-nums text-text-muted transition-opacity",
3576
+ "overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none"
3535
3577
  ]
3536
3578
  },
3579
+ {
3580
+ "name": "NavTree",
3581
+ "repo": "estiva-ui",
3582
+ "kind": "component",
3583
+ "importPath": "@estiva-app/ui",
3584
+ "sourceFile": "src/NavTree.tsx",
3585
+ "purpose": "A sidebar's tree from one nested list: a quiet label, groups that fold under the standard heading with their rows lightly indented, and rows under rows to any depth.",
3586
+ "purposeFrom": "page",
3587
+ "props": [
3588
+ {
3589
+ "name": "title",
3590
+ "takes": "text",
3591
+ "required": false,
3592
+ "note": "The quiet label over the groups. Absent, none is drawn."
3593
+ },
3594
+ {
3595
+ "name": "groups",
3596
+ "takes": "NavTreeGroup[] | null",
3597
+ "required": true,
3598
+ "note": "`null` while the first read is on its way: a skeleton in the rows' place."
3599
+ },
3600
+ {
3601
+ "name": "selected",
3602
+ "takes": "text",
3603
+ "required": false,
3604
+ "note": "The row that is current: `active`, and its group starts open."
3605
+ },
3606
+ {
3607
+ "name": "onSelect",
3608
+ "takes": "a handler",
3609
+ "required": false,
3610
+ "note": "A row was clicked. A router app prevents the default and navigates."
3611
+ },
3612
+ {
3613
+ "name": "emptyMessage",
3614
+ "takes": "text",
3615
+ "required": true,
3616
+ "note": "What to say when there are no groups. The words are the caller's."
3617
+ },
3618
+ {
3619
+ "name": "storageKey",
3620
+ "takes": "text",
3621
+ "required": false,
3622
+ "note": "Remember each group open or closed in this browser, under this key and the group's id."
3623
+ }
3624
+ ],
3625
+ "variants": [],
3626
+ "ownsBehaviours": [],
3627
+ "status": "stable",
3628
+ "migrationStage": null,
3629
+ "docsId": "frame-navtree--docs",
3630
+ "storyId": "frame-navtree--default",
3631
+ "docPage": "src/NavTree.mdx",
3632
+ "app": null,
3633
+ "looks": []
3634
+ },
3537
3635
  {
3538
3636
  "name": "normalizeHref",
3539
3637
  "repo": "estiva-ui",
@@ -4686,6 +4784,12 @@
4686
4784
  "required": false,
4687
4785
  "note": "Cut a long label short with an ellipsis instead of letting it wrap or spill."
4688
4786
  },
4787
+ {
4788
+ "name": "folds",
4789
+ "takes": "true/false",
4790
+ "required": false,
4791
+ "note": "The title of a group that opens and closes."
4792
+ },
4689
4793
  {
4690
4794
  "name": "className",
4691
4795
  "takes": "text",
@@ -4711,6 +4815,12 @@
4711
4815
  "docPage": "src/SectionLabel.mdx",
4712
4816
  "app": null,
4713
4817
  "looks": [
4818
+ "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary",
4819
+ "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-secondary truncate",
4820
+ "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-muted",
4821
+ "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-muted truncate",
4822
+ "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-primary",
4823
+ "text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest text-text-primary truncate",
4714
4824
  "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",
4715
4825
  "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",
4716
4826
  "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",
@@ -374,6 +374,49 @@ describe('a commented paragraph turned into a quote or a list — MAN-12', () =>
374
374
  })
375
375
  }
376
376
 
377
+ // Ship's `/` menu at the start of the text: type the command, then delete it and wrap in one change.
378
+ for (const [name, typed, run, type] of [
379
+ ['quote', '/quo', (c: Chain) => c.setBlockquote(), 'blockquote'],
380
+ ['bullet list', '/bul', (c: Chain) => c.toggleBulletList(), 'bulletList'],
381
+ ['numbered list', '/num', (c: Chain) => c.toggleOrderedList(), 'orderedList'],
382
+ ] as const) {
383
+ it(`keeps the id when the / menu at the start of the text makes a ${name}`, () => {
384
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
385
+ editor.chain().setTextSelection(1).insertContent(typed).run()
386
+ run(editor.chain().focus().deleteRange({ from: 1, to: 1 + typed.length })).run()
387
+ const [turned, outer, item] = first()
388
+ expect(turned).toBe(type)
389
+ expect(type === 'blockquote' ? outer : item).toBe('p0')
390
+ })
391
+ }
392
+
393
+ // Peek's and Ship's typed shortcuts: the input rule deletes what was typed and wraps.
394
+ for (const [typed, type] of [['> ', 'blockquote'], ['- ', 'bulletList'], ['1. ', 'orderedList']] as const) {
395
+ it(`keeps the id when "${typed}" is typed at the start of the text`, () => {
396
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
397
+ const { view } = editor
398
+ editor.commands.setTextSelection(1)
399
+ for (const ch of typed) {
400
+ const { from, to } = view.state.selection
401
+ const handled = view.someProp('handleTextInput', (f) => f(view, from, to, ch, () => view.state.tr.insertText(ch, from, to)))
402
+ if (!handled) view.dispatch(view.state.tr.insertText(ch, from, to))
403
+ }
404
+ const [turned, outer, item] = first()
405
+ expect(turned).toBe(type)
406
+ expect(type === 'blockquote' ? outer : item).toBe('p0')
407
+ expect(editor.state.doc.textContent.startsWith('intro')).toBe(true)
408
+ })
409
+ }
410
+
411
+ it('gives a block pasted over a whole paragraph its own id, not the paragraph’s', () => {
412
+ editor = new Editor({ extensions: EXTENSIONS, content: TWO })
413
+ editor.commands.setNodeSelection(0)
414
+ editor.view.pasteHTML('<blockquote><p>new</p></blockquote>')
415
+ const ids = JSON.stringify(editor.getJSON())
416
+ expect(editor.state.doc.child(0).type.name).toBe('blockquote')
417
+ expect(ids).not.toContain('"p0"')
418
+ })
419
+
377
420
  it('hands the id back to the paragraph when the quote or the list is turned off', () => {
378
421
  for (const run of [(c: Chain) => c.toggleBlockquote(), (c: Chain) => c.toggleBulletList(), (c: Chain) => c.toggleOrderedList()]) {
379
422
  editor = new Editor({ extensions: EXTENSIONS, content: TWO })
@@ -192,7 +192,10 @@ function hiddenIds(doc: ProseMirrorNode): number[] {
192
192
  * {@link BlockId}. An id the save addressed before the change and does not
193
193
  * now goes to the innermost new block its text was carried into: the quote or
194
194
  * the list item around a paragraph, or the paragraph lifted out of one.
195
- * Text the change deleted hands nothing on.
195
+ * A block the change deleted hands nothing on. What it asks is whether the
196
+ * block's own opening survived, the token left of its text, not its first
197
+ * character: the `/` menu and a typed `> ` or `- ` delete what was typed at
198
+ * the very start of the text in the same change as the wrap.
196
199
  */
197
200
  function handedOver(doc: ProseMirrorNode, before: ProseMirrorNode, mapping: Mapping, unnamed: number[]): Map<number, string> {
198
201
  const out = new Map<number, string>()
@@ -205,7 +208,7 @@ function handedOver(doc: ProseMirrorNode, before: ProseMirrorNode, mapping: Mapp
205
208
  .sort((a, b) => a.start - b.start || b.pos - a.pos)
206
209
  for (const old of gone) {
207
210
  const id = idOf(old.node)!
208
- const { pos: at, deleted } = mapping.mapResult(old.start, 1)
211
+ const { pos: at, deleted } = mapping.mapResult(old.start, -1)
209
212
  if (deleted) continue
210
213
  let inner: number | undefined
211
214
  for (const pos of unnamed) {
@@ -28,6 +28,12 @@ toggle, and the rows under it, sliding shut and open.
28
28
 
29
29
  <Canvas of={CollapsibleSectionStories.AsARow} />
30
30
 
31
+ - `indent` with the standard heading: its rows start under its title, past
32
+ the chevron — a group in a sidebar whose rows read as inside it. **NavTree**
33
+ draws its groups this way.
34
+
35
+ <Canvas of={CollapsibleSectionStories.Indented} />
36
+
31
37
  In a sidebar — the Sidebar's own Composed canvas, which is where that
32
38
  composition lives:
33
39
 
@@ -47,3 +47,8 @@ export const Remembered: Story = { args: { storageKey: 'estiva-ui.stories.collap
47
47
  export const AsARow: Story = {
48
48
  args: { title: 'Folder', look: 'row' },
49
49
  }
50
+
51
+ /** `indent`: the heading look, its rows starting under its title, past the chevron — a folder in a sidebar. */
52
+ export const Indented: Story = {
53
+ args: { title: 'Group', indent: true },
54
+ }
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react'
2
2
  import { Collapsible } from '@base-ui/react/collapsible'
3
3
  import { cn } from './cn'
4
4
  import { SectionHeader, type SectionAction } from './SectionHeader'
5
+ import { COLLAPSIBLE_PANEL_CLASSES } from './looks'
5
6
 
6
7
  /**
7
8
  * A section that opens and closes: a `SectionHeader` whose title is the
@@ -42,6 +43,12 @@ export interface CollapsibleSectionProps {
42
43
  * its title, past the chevron (`SectionHeader`'s `look`).
43
44
  */
44
45
  look?: 'heading' | 'row'
46
+ /**
47
+ * With the `heading` look: the rows start under the title, past the
48
+ * chevron — a folder in a sidebar, its files lightly indented under its
49
+ * name (Katerina, 2 October). The `row` look always indents.
50
+ */
51
+ indent?: boolean
45
52
  /** The rows. */
46
53
  children: ReactNode
47
54
  /** On the section's box: `mt-2` between groups, `shrink-0` in a scrolling column. */
@@ -72,7 +79,7 @@ function writeStored(key: string | undefined, open: boolean) {
72
79
  }
73
80
  }
74
81
 
75
- export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, trailing, actions, showActions, look = 'heading', children, className, contentClassName }: CollapsibleSectionProps) {
82
+ export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, trailing, actions, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
76
83
  const [openState, setOpenState] = useState(() => readStored(storageKey) ?? defaultOpen)
77
84
  const open = openProp ?? openState
78
85
  const setOpen = (next: boolean) => {
@@ -91,10 +98,11 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
91
98
  prefers-reduced-motion. */}
92
99
  <Collapsible.Panel
93
100
  hiddenUntilFound
94
- className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none data-[starting-style]:h-0 data-[ending-style]:h-0"
101
+ className={COLLAPSIBLE_PANEL_CLASSES}
95
102
  >
96
- {/* A row's rows start under its title: the 16px chevron and its 8px gap (pl-6). */}
97
- <div className={cn('flex flex-col', look === 'row' && 'pl-6', contentClassName)}>{children}</div>
103
+ {/* 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>
98
106
  </Collapsible.Panel>
99
107
  </Collapsible.Root>
100
108
  )
package/src/NavItem.mdx CHANGED
@@ -28,6 +28,19 @@ count — a muted mono number whose tooltip says what it counts.
28
28
 
29
29
  <Canvas of={NavItemStories.WithMenu} />
30
30
 
31
+ - A row with rows under it takes them as `children`: more `NavItem`s. The
32
+ row stays a link; while it is pointed at or focused, its icon becomes the
33
+ arrow that folds them, in the icon's own place, and they slide shut. For a
34
+ whole tree from one list, use **NavTree**.
35
+
36
+ <Canvas of={NavItemStories.WithRowsUnder} />
37
+
38
+ - A word that helps only while the row is pointed at — where the item comes
39
+ from — is `hint`: pass a neutral **Chip**. It shows left of the count, and
40
+ the label ends with "…" before it instead of running under it.
41
+
42
+ <Canvas of={NavItemStories.WithHintLongLabel} />
43
+
31
44
  ## When not
32
45
 
33
46
  - An icon rail's tile → **RailItem**.
@@ -55,6 +68,11 @@ import { NavItem } from '@estiva-app/ui'
55
68
  - The label truncates; the count never gives up its width.
56
69
  - `menu` is the items only: `menu={<MenuItem label="Move to Folder…" onClick={move} />}`.
57
70
  The ⋮'s name is `More options for {label}` unless `menuLabel` says otherwise.
71
+ - `children` start open; `defaultOpen={false}` starts them folded, and
72
+ `open` with `onOpenChange` hands the state to the app. The arrow's name is
73
+ `Hide what is under {label}` or `Show what is under {label}`.
74
+ - `hint` takes room only while it shows, so a label at rest keeps its full
75
+ width.
58
76
 
59
77
  - A row is 32px tall, 8px in on each side, 8px between the icon and the label,
60
78
  the label 14px regular, 6px corners.
@@ -68,6 +86,18 @@ It does these by itself. Write none of them around it.
68
86
  | Marks the current place for assistive technology when `active` | `aria-current` | |
69
87
  | Keeps its 32px in a scrolling column | `shrink-0` | |
70
88
  | Shows a ⋮ over the count while pointed at, focused or open, and keeps the row's hover look under it | a positioned `IconButton` + `Menu` over the row, `group-hover` reveals | |
89
+ | Turns its icon into the arrow that folds `children` while pointed at or focused, and slides them | a positioned `IconButton` over the icon, open state, `group-hover` reveals, an indented box | |
90
+ | Tells assistive technology whether its rows are open | `aria-expanded`, `aria-controls` | |
91
+ | Shows `hint` only while pointed at or focused, and makes the label give way | a positioned chip over the row, `group-hover` reveals | |
92
+
93
+ ## Keys
94
+
95
+ | Key | Does |
96
+ |---|---|
97
+ | Tab | the link, then its arrow (with `children`), then its ⋮ (with `menu`) |
98
+ | Enter, Space | on the arrow: folds or unfolds the rows under it |
99
+
100
+ The rows are not an ARIA tree: there are no arrow keys between rows yet.
71
101
 
72
102
  ## Props
73
103
 
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { Chip } from './Chip'
3
4
  import { MenuItem } from './Menu'
4
5
  import { NavItem } from './NavItem'
5
6
 
@@ -60,3 +61,36 @@ export const WithMenuActive: Story = {
60
61
  export const WithMenuNoCount: Story = {
61
62
  args: { label: 'An item whose label runs much longer than the column has room for', icon: placeholder, menu: MENU },
62
63
  }
64
+
65
+ /** A hint shows only while the row is pointed at or focused, left of the count: here a neutral Chip. */
66
+ export const WithHint: Story = {
67
+ args: { icon: placeholder, count: 3, countLabel: '3 open', hint: <Chip type="neutral" label="Label" /> },
68
+ }
69
+
70
+ /** A long label gives way to the hint while it shows: it ends with "…" before the chip instead of running under it. */
71
+ export const WithHintLongLabel: Story = {
72
+ args: { label: 'An item whose label runs much longer than the column has room for', icon: placeholder, count: 12, countLabel: '12 open', hint: <Chip type="neutral" label="Label" /> },
73
+ }
74
+
75
+ /** Rows under a row: point at it and its icon becomes the arrow that folds them. The row itself stays a link. */
76
+ export const WithRowsUnder: Story = {
77
+ args: { icon: placeholder },
78
+ render: (args) => (
79
+ <NavItem {...args}>
80
+ <NavItem href="#" label="Item two" icon={placeholder} />
81
+ <NavItem href="#" label="Item three" icon={placeholder}>
82
+ <NavItem href="#" label="Item four" icon={placeholder} />
83
+ </NavItem>
84
+ </NavItem>
85
+ ),
86
+ }
87
+
88
+ /** Starts folded: point at it, or Tab to its arrow, to open it. */
89
+ export const WithRowsUnderClosed: Story = {
90
+ args: { icon: placeholder, defaultOpen: false },
91
+ render: (args) => (
92
+ <NavItem {...args}>
93
+ <NavItem href="#" label="Item two" icon={placeholder} />
94
+ </NavItem>
95
+ ),
96
+ }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the page claims for the rows under a row and the hint: the arrow folds
4
+ * the rows and says so, the rows are hidden rather than gone, the row itself
5
+ * stays a link a click does not fold, and the hint is there to be revealed.
6
+ * The reveal on hover and the slide are Chrome's to show.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { NavItem } from './NavItem'
12
+
13
+ afterEach(cleanup)
14
+
15
+ const arrow = (name: RegExp) => screen.getByRole('button', { name })
16
+
17
+ describe('NavItem with rows under it', () => {
18
+ const tree = (props: Partial<Parameters<typeof NavItem>[0]> = {}) => (
19
+ <NavItem href="#one" label="Item one" {...props}>
20
+ <NavItem href="#two" label="Item two" />
21
+ </NavItem>
22
+ )
23
+
24
+ it('starts open: the arrow says so and names what it folds, and the rows are there', () => {
25
+ render(tree())
26
+ const button = arrow(/Hide what is under Item one/)
27
+ expect(button.getAttribute('aria-expanded')).toBe('true')
28
+ expect(document.getElementById(button.getAttribute('aria-controls') ?? '')).not.toBeNull()
29
+ expect(screen.getByText('Item two').closest('[hidden]')).toBeNull()
30
+ })
31
+
32
+ it('the arrow folds the rows, hidden rather than gone, and unfolds them', async () => {
33
+ render(tree())
34
+ await userEvent.click(arrow(/Hide what is under Item one/))
35
+ expect(arrow(/Show what is under Item one/).getAttribute('aria-expanded')).toBe('false')
36
+ expect(screen.getByText('Item two').closest('[hidden]')).not.toBeNull()
37
+ await userEvent.click(arrow(/Show what is under Item one/))
38
+ expect(screen.getByText('Item two').closest('[hidden]')).toBeNull()
39
+ })
40
+
41
+ it('a click on the row is the link’s, and does not fold it', async () => {
42
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
43
+ render(tree({ onClick }))
44
+ await userEvent.click(screen.getByRole('link', { name: 'Item one' }))
45
+ expect(onClick).toHaveBeenCalledTimes(1)
46
+ expect(arrow(/Hide what is under Item one/).getAttribute('aria-expanded')).toBe('true')
47
+ })
48
+
49
+ it('starts folded with defaultOpen false, and reports a change through onOpenChange', async () => {
50
+ const onOpenChange = vi.fn()
51
+ render(tree({ defaultOpen: false, onOpenChange }))
52
+ expect(screen.getByText('Item two').closest('[hidden]')).not.toBeNull()
53
+ await userEvent.click(arrow(/Show what is under Item one/))
54
+ expect(onOpenChange).toHaveBeenCalledWith(true)
55
+ })
56
+
57
+ it('Enter on the arrow folds the rows', async () => {
58
+ render(tree())
59
+ arrow(/Hide what is under Item one/).focus()
60
+ await userEvent.keyboard('{Enter}')
61
+ expect(arrow(/Show what is under Item one/).getAttribute('aria-expanded')).toBe('false')
62
+ })
63
+
64
+ it('a row with a menu and rows under it: the open rows do not hold the ⋮ open', () => {
65
+ render(tree({ menu: <span>Action</span> }))
66
+ const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
67
+ expect(menu).not.toBeNull()
68
+ expect(menu?.querySelector('[aria-expanded=true]')).toBeNull()
69
+ })
70
+ })
71
+
72
+ describe('NavItem with a hint', () => {
73
+ it('draws the hint inside the row, beside the label', () => {
74
+ render(<NavItem href="#" label="Item one" hint={<span>Label</span>} />)
75
+ expect(screen.getByRole('link', { name: /Item one/ }).textContent).toContain('Label')
76
+ })
77
+
78
+ it('without children, a menu or a hint, the row is the bare link', () => {
79
+ const { container } = render(<NavItem href="#" label="Item one" />)
80
+ expect(container.firstElementChild?.tagName).toBe('A')
81
+ })
82
+ })