@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/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/NavItem.d.ts +23 -3
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +49 -0
- package/dist/NavTree.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +7 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +332 -212
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +7 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +115 -5
- package/src/CollapsibleSection.mdx +6 -0
- package/src/CollapsibleSection.stories.tsx +5 -0
- package/src/CollapsibleSection.tsx +12 -4
- package/src/NavItem.mdx +30 -0
- package/src/NavItem.stories.tsx +34 -0
- package/src/NavItem.test.tsx +82 -0
- package/src/NavItem.tsx +123 -25
- package/src/NavTree.mdx +83 -0
- package/src/NavTree.stories.tsx +84 -0
- package/src/NavTree.test.tsx +54 -0
- package/src/NavTree.tsx +112 -0
- package/src/SectionHeader.tsx +1 -1
- package/src/SectionLabel.mdx +4 -1
- package/src/SectionLabel.stories.tsx +3 -0
- package/src/SectionLabel.tsx +10 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/index.ts +1 -0
- package/src/looks.ts +8 -0
- package/src/registry/registry.test.ts +4 -4
- 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
|
package/dist/looks.d.ts.map
CHANGED
|
@@ -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
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
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=
|
|
101
|
+
className={COLLAPSIBLE_PANEL_CLASSES}
|
|
95
102
|
>
|
|
96
|
-
{/* A row's rows start under its title: the 16px chevron and its 8px gap
|
|
97
|
-
|
|
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
|
|
package/src/NavItem.stories.tsx
CHANGED
|
@@ -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
|
|
2
|
-
import {
|
|
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({
|
|
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
|
-
|
|
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
|
|
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 (
|
|
152
|
+
if (!besides && hint == null) return row
|
|
88
153
|
const name = menuLabel ?? `More options for ${label}`
|
|
89
|
-
|
|
154
|
+
const line = (
|
|
90
155
|
<div className="group/nav relative shrink-0">
|
|
91
156
|
{row}
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|