@estiva-app/ui 0.50.4 → 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.
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.4",
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.4",
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",
@@ -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
+ })
package/src/NavItem.tsx CHANGED
@@ -1,7 +1,8 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from 'react'
2
- import { IconDotsVertical } from '@tabler/icons-react'
1
+ import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
2
+ import { Collapsible } from '@base-ui/react/collapsible'
3
+ import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
3
4
  import { cn } from './cn'
4
- import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
5
+ import { COLLAPSIBLE_PANEL_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
5
6
  import { IconButton } from './IconButton'
6
7
  import { Menu } from './Menu'
7
8
  import { WithTooltip } from './Tooltip'
@@ -22,8 +23,19 @@ import { WithTooltip } from './Tooltip'
22
23
  * menu is open, in the count's place — the count steps aside rather than
23
24
  * sitting under it. The ⋮ is beside the link, not inside it (a button inside
24
25
  * an anchor is not a button), and follows it in the Tab order.
26
+ *
27
+ * `children` are rows under this one (Katerina, 2 October: Leaf's folder
28
+ * tree). The row stays a link; while it is pointed at or focused its icon
29
+ * gives way to an arrow that folds the rows, in the icon's own place — so a
30
+ * row with rows under it is as wide as one without. The arrow is beside the
31
+ * link, like the ⋮. The rows slide as `CollapsibleSection`'s do, indented
32
+ * past the icon so their icons start under this row's label.
33
+ *
34
+ * `hint` is shown only while the row is pointed at or focused, left of the
35
+ * count — a neutral `Chip`, typically. It takes its own room while it shows,
36
+ * so the label ends with "…" before it instead of running under it.
25
37
  */
26
- export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'> {
38
+ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'children'> {
27
39
  label: string
28
40
  href: string
29
41
  /** `0` or absent renders no counter. */
@@ -37,10 +49,47 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
37
49
  menu?: ReactNode
38
50
  /** The ⋮'s accessible name and tooltip. Default "More options for {label}". */
39
51
  menuLabel?: string
52
+ /** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
53
+ hint?: ReactNode
54
+ /** Rows under this one, folding under it: `NavItem`s. While the row is pointed at or focused, its icon becomes the arrow. */
55
+ children?: ReactNode
56
+ /** With `children`: open unless told otherwise. */
57
+ defaultOpen?: boolean
58
+ /** With `children`: the caller owns the state. */
59
+ open?: boolean
60
+ onOpenChange?: (open: boolean) => void
40
61
  className?: string
41
62
  }
42
63
 
43
- export function NavItem({ label, href, count, countLabel, active = false, icon, menu, menuLabel, className, ...props }: NavItemProps) {
64
+ export function NavItem({
65
+ label,
66
+ href,
67
+ count,
68
+ countLabel,
69
+ active = false,
70
+ icon,
71
+ menu,
72
+ menuLabel,
73
+ hint,
74
+ children,
75
+ defaultOpen = true,
76
+ open: openProp,
77
+ onOpenChange,
78
+ className,
79
+ ...props
80
+ }: NavItemProps) {
81
+ const [openState, setOpenState] = useState(defaultOpen)
82
+ const panelId = useId()
83
+ const hasRows = children != null && children !== false
84
+ const open = openProp ?? openState
85
+ const setOpen = (next: boolean) => {
86
+ setOpenState(next)
87
+ onOpenChange?.(next)
88
+ }
89
+ // The row draws beside it — the ⋮, the arrow — so the pointer can leave the
90
+ // link for them while the row still reads as the one it is on.
91
+ const besides = menu != null || hasRows
92
+
44
93
  const row = (
45
94
  <a
46
95
  href={href}
@@ -53,14 +102,30 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
53
102
  SIDEBAR_ROW_CLASSES,
54
103
  active ? 'bg-bg-active text-text-primary' : cn(SIDEBAR_ROW_TEXT_CLASSES, 'hover:bg-bg-hover'),
55
104
  // With a menu the ⋮ sits beside the link, so the row keeps its hover
56
- // look while the ⋮ is pointed at and while its menu is open.
57
- menu != null && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary group-has-[[aria-expanded=true]]/nav:bg-bg-hover group-has-[[aria-expanded=true]]/nav:text-text-primary',
105
+ // look while the ⋮ is pointed at and while its menu is open. The same
106
+ // for the arrow.
107
+ besides && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary',
108
+ menu != null && !active && 'group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:bg-bg-hover group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:text-text-primary',
58
109
  className,
59
110
  )}
60
111
  {...props}
61
112
  >
62
- {icon && <span className="flex shrink-0 items-center">{icon}</span>}
113
+ {(icon || hasRows) && (
114
+ <span
115
+ className={cn(
116
+ // With rows under it the slot is kept even with no icon: the arrow lands in it.
117
+ 'flex min-w-4 shrink-0 items-center',
118
+ hasRows && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0',
119
+ )}
120
+ >
121
+ {icon}
122
+ </span>
123
+ )}
63
124
  <span className="flex-1 truncate">{label}</span>
125
+ {hint != null && (
126
+ // Takes room only while it shows, so the label gives way to it.
127
+ <span className="hidden shrink-0 items-center group-hover/nav:flex group-focus-within/nav:flex">{hint}</span>
128
+ )}
64
129
  {count ? (
65
130
  <WithTooltip label={countLabel ?? `${count} open`}>
66
131
  {/* A 16px box, centred: the slot is an icon's width, so a number
@@ -71,7 +136,7 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
71
136
  className={cn(
72
137
  'min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted',
73
138
  // With a menu, the ⋮ takes this place while it shows.
74
- menu != null && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0 group-has-[[aria-expanded=true]]/nav:opacity-0',
139
+ 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',
75
140
  )}
76
141
  >
77
142
  {count}
@@ -84,25 +149,58 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
84
149
  ) : null}
85
150
  </a>
86
151
  )
87
- if (menu == null) return row
152
+ if (!besides && hint == null) return row
88
153
  const name = menuLabel ?? `More options for ${label}`
89
- return (
154
+ const line = (
90
155
  <div className="group/nav relative shrink-0">
91
156
  {row}
92
- {/* `right-1`: the 24px button's 16px icon then ends 8px from the row's
93
- edge, where the count ends (px-2), so the ⋮ lands on the number. */}
94
- <div className="absolute inset-y-0 right-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100">
95
- <Menu
96
- align="right"
97
- trigger={
98
- <IconButton tooltip="More options" aria-label={name}>
99
- <IconDotsVertical size={16} stroke={1.5} />
100
- </IconButton>
101
- }
102
- >
103
- {menu}
104
- </Menu>
105
- </div>
157
+ {hasRows && (
158
+ // `left-1`: the 24px button's 16px arrow then sits where the icon
159
+ // sits (the row's px-2), so the icon turns into the arrow in place.
160
+ <div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100">
161
+ <IconButton
162
+ tooltip={open ? 'Collapse' : 'Expand'}
163
+ aria-label={open ? `Hide what is under ${label}` : `Show what is under ${label}`}
164
+ aria-expanded={open}
165
+ aria-controls={panelId}
166
+ onClick={() => setOpen(!open)}
167
+ >
168
+ <IconChevronRight size={16} stroke={1.5} className={cn('transition-transform duration-150', open && 'rotate-90')} />
169
+ </IconButton>
170
+ </div>
171
+ )}
172
+ {menu != null && (
173
+ // `right-1`: the 24px button's 16px icon then ends 8px from the row's
174
+ // edge, where the count ends (px-2), so the ⋮ lands on the number.
175
+ <div data-nav-menu className="absolute inset-y-0 right-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100">
176
+ <Menu
177
+ align="right"
178
+ trigger={
179
+ <IconButton tooltip="More options" aria-label={name}>
180
+ <IconDotsVertical size={16} stroke={1.5} />
181
+ </IconButton>
182
+ }
183
+ >
184
+ {menu}
185
+ </Menu>
186
+ </div>
187
+ )}
106
188
  </div>
107
189
  )
190
+ if (!hasRows) return line
191
+ return (
192
+ <Collapsible.Root open={open} onOpenChange={setOpen} className="flex shrink-0 flex-col">
193
+ {line}
194
+ {/* CollapsibleSection's slide, the same way: Base UI measures the panel
195
+ and the height transitions, none under prefers-reduced-motion. */}
196
+ <Collapsible.Panel
197
+ id={panelId}
198
+ hiddenUntilFound
199
+ className={COLLAPSIBLE_PANEL_CLASSES}
200
+ >
201
+ {/* `pl-6`: past the 16px icon and its 8px gap, so the rows' icons start under this row's label. */}
202
+ <div className="flex flex-col gap-px pl-6">{children}</div>
203
+ </Collapsible.Panel>
204
+ </Collapsible.Root>
205
+ )
108
206
  }