@estiva-app/ui 0.56.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +1 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +2 -2
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +4 -4
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/index.js +33 -36
- package/dist/index.js.map +3 -3
- package/dist/looks.d.ts +2 -2
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +8 -7
- package/src/ChipInput.test.tsx +8 -0
- package/src/ChipInput.tsx +4 -2
- package/src/CollapsibleSection.mdx +1 -1
- package/src/CollapsibleSection.test.tsx +24 -0
- package/src/CollapsibleSection.tsx +1 -1
- package/src/CommandPalette.test.tsx +6 -0
- package/src/CommandPalette.tsx +3 -2
- package/src/ListColumn.test.tsx +8 -0
- package/src/NavItem.mdx +3 -1
- package/src/NavItem.test.tsx +30 -0
- package/src/NavItem.tsx +10 -3
- package/src/NavTree.test.tsx +21 -0
- package/src/NavTree.tsx +7 -3
- package/src/SectionHeader.mdx +4 -3
- package/src/SectionHeader.test.tsx +68 -5
- package/src/SectionHeader.tsx +37 -25
- package/src/Select.test.tsx +8 -0
- package/src/Select.tsx +5 -2
- package/src/SuggestionMenu.stories.tsx +15 -0
- package/src/SuggestionMenu.test.tsx +8 -0
- package/src/SuggestionMenu.tsx +4 -2
- package/src/looks.ts +3 -3
package/dist/looks.d.ts
CHANGED
|
@@ -34,12 +34,12 @@ export declare const CHIP_TEXT_CLASSES = "text-chip signal:font-mono signal:text
|
|
|
34
34
|
/** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
|
|
35
35
|
export declare const BARE_INPUT_CLASSES = "bg-transparent text-text-primary placeholder:text-text-muted outline-none";
|
|
36
36
|
/** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
|
|
37
|
+
export declare const SIDEBAR_ROW_CLASSES = "rounded-lg transition-colors";
|
|
38
|
+
export declare const SIDEBAR_ROW_TEXT_CLASSES = "text-text-secondary hover:text-text-primary";
|
|
37
39
|
/** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
|
|
38
40
|
export declare const MUTED_CONTROL_CLASSES = "text-text-secondary hover:bg-bg-hover hover:text-text-primary";
|
|
39
41
|
/** A control that opens a menu: a hover fill, held while its menu is up — PersonTrigger's row, and a ContainerHeader title with a menu. */
|
|
40
42
|
export declare const MENU_TRIGGER_CLASSES = "cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover";
|
|
41
|
-
export declare const SIDEBAR_ROW_CLASSES = "rounded-lg transition-colors";
|
|
42
|
-
export declare const SIDEBAR_ROW_TEXT_CLASSES = "text-text-secondary hover:text-text-primary";
|
|
43
43
|
/**
|
|
44
44
|
* A panel that slides open and shut on Base UI's Collapsible: Base UI measures
|
|
45
45
|
* it and writes its height to a variable, `auto` again once the slide ends, and
|
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;AAE5I,
|
|
1
|
+
{"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA;AAE7G,4IAA4I;AAE5I,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,kEAAkE,CAAA;AAEpG,2IAA2I;AAC3I,eAAO,MAAM,oBAAoB,gGAAgG,CAAA;AAEjI;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,kLAAkL,CAAA"}
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.
|
|
7
|
+
"packageVersion": "0.57.0",
|
|
8
8
|
"exports": 130,
|
|
9
9
|
"typeExports": 134,
|
|
10
10
|
"files": null
|
|
@@ -1201,7 +1201,7 @@
|
|
|
1201
1201
|
"name": "actions",
|
|
1202
1202
|
"takes": "SectionAction[]",
|
|
1203
1203
|
"required": false,
|
|
1204
|
-
"note": "Beside the title, revealed
|
|
1204
|
+
"note": "Beside the title, revealed while the row is hovered or keyboard-focused — `SectionHeader`'s."
|
|
1205
1205
|
},
|
|
1206
1206
|
{
|
|
1207
1207
|
"name": "menu",
|
|
@@ -3952,7 +3952,7 @@
|
|
|
3952
3952
|
"text-body-2 rounded-lg transition-colors text-text-primary hover:bg-bg-hover",
|
|
3953
3953
|
"text-body-2 rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover",
|
|
3954
3954
|
"text-center font-mono text-caption tabular-nums text-text-muted",
|
|
3955
|
-
"text-center font-mono text-caption tabular-nums text-text-muted transition-opacity",
|
|
3955
|
+
"text-center font-mono text-caption tabular-nums text-text-muted transition-opacity [@media(hover:none)]:opacity-0",
|
|
3956
3956
|
"overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none"
|
|
3957
3957
|
]
|
|
3958
3958
|
},
|
|
@@ -4061,7 +4061,7 @@
|
|
|
4061
4061
|
"name": "actions",
|
|
4062
4062
|
"takes": "SectionAction[]",
|
|
4063
4063
|
"required": false,
|
|
4064
|
-
"note": "Beside the title, shown
|
|
4064
|
+
"note": "Beside the title, shown while the row is hovered or keyboard-focused: \"Open\", typically."
|
|
4065
4065
|
},
|
|
4066
4066
|
{
|
|
4067
4067
|
"name": "menu",
|
|
@@ -5239,7 +5239,7 @@
|
|
|
5239
5239
|
"kind": "component",
|
|
5240
5240
|
"importPath": "@estiva-app/ui",
|
|
5241
5241
|
"sourceFile": "src/SectionHeader.tsx",
|
|
5242
|
-
"purpose": "The 32px row a section starts with: a SectionLabel, and actions that appear
|
|
5242
|
+
"purpose": "The 32px row a section starts with: a SectionLabel, and actions that appear while the row is hovered or keyboard-focused, as IconButtons with tooltips — always, on a screen with no hover.",
|
|
5243
5243
|
"purposeFrom": "page",
|
|
5244
5244
|
"props": [
|
|
5245
5245
|
{
|
|
@@ -5300,7 +5300,7 @@
|
|
|
5300
5300
|
"name": "showActions",
|
|
5301
5301
|
"takes": "hover | always",
|
|
5302
5302
|
"required": false,
|
|
5303
|
-
"note": "`hover` reveals the actions while the row is hovered or focused; `always` keeps them."
|
|
5303
|
+
"note": "`hover` reveals the actions while the row is hovered or keyboard-focused (always on a screen with no hover); `always` keeps them."
|
|
5304
5304
|
},
|
|
5305
5305
|
{
|
|
5306
5306
|
"name": "hover",
|
|
@@ -5363,7 +5363,8 @@
|
|
|
5363
5363
|
"opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100 rotate-90",
|
|
5364
5364
|
"transition-transform duration-150 text-text-secondary rotate-90",
|
|
5365
5365
|
"rounded-lg transition-colors text-text-secondary hover:text-text-primary",
|
|
5366
|
-
"rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover"
|
|
5366
|
+
"rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover",
|
|
5367
|
+
"opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
|
5367
5368
|
]
|
|
5368
5369
|
},
|
|
5369
5370
|
{
|
package/src/ChipInput.test.tsx
CHANGED
|
@@ -70,6 +70,14 @@ describe('ChipInput', () => {
|
|
|
70
70
|
expect(input.getAttribute('aria-expanded')).toBe('true')
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
it('puts 2px between its rows, as a menu does', async () => {
|
|
74
|
+
const user = userEvent.setup()
|
|
75
|
+
render(<Harness />)
|
|
76
|
+
await user.type(screen.getByRole('combobox'), 'a')
|
|
77
|
+
const [first] = await screen.findAllByRole('option')
|
|
78
|
+
expect(first.parentElement!.className.split(' ')).toContain('gap-0.5')
|
|
79
|
+
})
|
|
80
|
+
|
|
73
81
|
it('names the highlighted row through aria-activedescendant', async () => {
|
|
74
82
|
const user = userEvent.setup()
|
|
75
83
|
render(<Harness />)
|
package/src/ChipInput.tsx
CHANGED
|
@@ -277,10 +277,12 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
|
|
|
277
277
|
for; `--anchor-width` is the positioner's own answer, and the
|
|
278
278
|
anchor is the box (see `box` above), not the input. The padding
|
|
279
279
|
is on the scrolling content so the bar hugs the panel (D63), and
|
|
280
|
-
240px is the cap this list has always had.
|
|
280
|
+
240px is the cap this list has always had. The rows are the
|
|
281
|
+
list's own children, so the 2px between them, a menu's, is on
|
|
282
|
+
the list. */}
|
|
281
283
|
<Combobox.Popup className="w-[var(--anchor-width)] p-0" render={<MenuPanel />}>
|
|
282
284
|
<ScrollArea viewportClassName="max-h-[240px]" contentClassName="flex flex-col p-2">
|
|
283
|
-
<Combobox.List>
|
|
285
|
+
<Combobox.List className="flex flex-col gap-0.5">
|
|
284
286
|
{(option: T) => (
|
|
285
287
|
<Combobox.Item
|
|
286
288
|
key={option.id}
|
|
@@ -21,7 +21,7 @@ toggle, and the rows under it, sliding shut and open.
|
|
|
21
21
|
key (`app.sidebar.starred`).
|
|
22
22
|
- `open` + `onOpenChange` when the app owns the state.
|
|
23
23
|
- `actions` for add, sort, filter beside the title — SectionHeader's,
|
|
24
|
-
revealed
|
|
24
|
+
revealed while the row is hovered or keyboard-focused.
|
|
25
25
|
- `look="row"` when the section is a row among rows — a folder in a
|
|
26
26
|
sidebar, under a quiet label: the title at a row's weight, and its rows
|
|
27
27
|
indented under it, past the chevron.
|
|
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
9
9
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
10
|
import userEvent from '@testing-library/user-event'
|
|
11
11
|
import { CollapsibleSection } from './CollapsibleSection'
|
|
12
|
+
import { MenuItem } from './Menu'
|
|
12
13
|
|
|
13
14
|
afterEach(cleanup)
|
|
14
15
|
beforeEach(() => window.localStorage.clear())
|
|
@@ -79,3 +80,26 @@ describe('CollapsibleSection', () => {
|
|
|
79
80
|
expect(title().getAttribute('aria-expanded')).toBe('false')
|
|
80
81
|
})
|
|
81
82
|
})
|
|
83
|
+
|
|
84
|
+
/** What it hands its header (0.56.0): the folder's ⋮, the ⋮'s name, and the icon before the title. */
|
|
85
|
+
describe('CollapsibleSection, its header', () => {
|
|
86
|
+
it('passes menu and menuLabel on: the ⋮ is named by menuLabel and opens the menu', async () => {
|
|
87
|
+
render(<CollapsibleSection title="Section" menu={<MenuItem label="Rename" />} menuLabel="Section options">{rows}</CollapsibleSection>)
|
|
88
|
+
await userEvent.click(screen.getByRole('button', { name: 'Section options' }))
|
|
89
|
+
expect(await screen.findByRole('menuitem', { name: 'Rename' })).not.toBeNull()
|
|
90
|
+
// The ⋮ acts; it does not fold the section.
|
|
91
|
+
expect(title().getAttribute('aria-expanded')).toBe('true')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('with menu and no menuLabel, the ⋮ is named for the title', () => {
|
|
95
|
+
render(<CollapsibleSection title="Section" menu={<MenuItem label="Rename" />}>{rows}</CollapsibleSection>)
|
|
96
|
+
expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('passes icon on: it is drawn inside the title’s toggle, before the words', () => {
|
|
100
|
+
render(<CollapsibleSection title="Section" icon={<span data-testid="icon" />}>{rows}</CollapsibleSection>)
|
|
101
|
+
const icon = screen.getByTestId('icon')
|
|
102
|
+
expect(title().contains(icon)).toBe(true)
|
|
103
|
+
expect(icon.compareDocumentPosition(screen.getByText('Section')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -36,7 +36,7 @@ export interface CollapsibleSectionProps {
|
|
|
36
36
|
icon?: ReactNode
|
|
37
37
|
/** Beside the title and always visible, before the actions — a count. `SectionHeader`'s. */
|
|
38
38
|
trailing?: ReactNode
|
|
39
|
-
/** Beside the title, revealed
|
|
39
|
+
/** Beside the title, revealed while the row is hovered or keyboard-focused — `SectionHeader`'s. */
|
|
40
40
|
actions?: SectionAction[]
|
|
41
41
|
/** Beside the actions, revealed with them: the section's "More options" menu, `MenuItem`s — `SectionHeader`'s. */
|
|
42
42
|
menu?: ReactNode
|
|
@@ -94,6 +94,12 @@ describe('CommandPaletteSearch — rows', () => {
|
|
|
94
94
|
expect(groups.map((g) => g.firstElementChild?.textContent)).toEqual(['First', 'Second'])
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
+
it('puts 2px between its rows and under each heading, as a menu does', () => {
|
|
98
|
+
render(<Search groups={[{ label: 'Group', rows: [row('One'), row('Two')] }]} />)
|
|
99
|
+
const [first] = screen.getAllByRole('option')
|
|
100
|
+
expect(first.parentElement!.className.split(' ')).toContain('gap-0.5')
|
|
101
|
+
})
|
|
102
|
+
|
|
97
103
|
it('lights the first row, and Enter does what it does', async () => {
|
|
98
104
|
const onSelect = vi.fn()
|
|
99
105
|
const user = userEvent.setup()
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -397,9 +397,10 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
|
|
|
397
397
|
|
|
398
398
|
<Autocomplete.List className="flex flex-col">
|
|
399
399
|
{(group: ListGroup) => (
|
|
400
|
-
<Autocomplete.Group key={group.value} items={group.items} className="flex flex-col">
|
|
400
|
+
<Autocomplete.Group key={group.value} items={group.items} className="flex flex-col gap-0.5">
|
|
401
401
|
{/* A heading labels the rows, it is not one of them: read
|
|
402
|
-
secondary, as in a menu (MenuSection).
|
|
402
|
+
secondary, as in a menu (MenuSection). 2px under it and
|
|
403
|
+
between its rows, as every list has (Katerina, 5 October). */}
|
|
403
404
|
<Autocomplete.GroupLabel className="flex h-7 shrink-0 items-center px-3">
|
|
404
405
|
<SectionLabel tone="secondary">{group.value}</SectionLabel>
|
|
405
406
|
</Autocomplete.GroupLabel>
|
package/src/ListColumn.test.tsx
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
/** What the ListColumn page claims, pinned. Sizes are measured in the browser; these pin the parts. */
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
5
|
+
import userEvent from '@testing-library/user-event'
|
|
6
|
+
import { MenuItem } from './Menu'
|
|
5
7
|
import { ListColumn } from './ListColumn'
|
|
6
8
|
|
|
7
9
|
afterEach(cleanup)
|
|
@@ -12,6 +14,12 @@ describe('ListColumn', () => {
|
|
|
12
14
|
expect(screen.getByText('Items')).toBeTruthy()
|
|
13
15
|
})
|
|
14
16
|
|
|
17
|
+
it('titleMenu: the title is the button that opens the column’s views', async () => {
|
|
18
|
+
render(<ListColumn title="View one" titleMenu={<MenuItem label="View two" />}>{null}</ListColumn>)
|
|
19
|
+
await userEvent.click(screen.getByRole('button', { name: 'View one' }))
|
|
20
|
+
expect(await screen.findByRole('menuitem', { name: 'View two' })).not.toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
15
23
|
it('is 290px wide with a line on its right, and keeps its width', () => {
|
|
16
24
|
const { container } = render(<ListColumn title="Items">{null}</ListColumn>)
|
|
17
25
|
const classes = (container.firstElementChild as HTMLElement).className.split(' ')
|
package/src/NavItem.mdx
CHANGED
|
@@ -24,7 +24,9 @@ count — a muted mono number whose tooltip says what it counts.
|
|
|
24
24
|
|
|
25
25
|
- A row with things to do to it — move it, rename it — takes them as
|
|
26
26
|
`menu`: pass `MenuItem`s, and the row draws the ⋮, shows it while the row
|
|
27
|
-
is pointed at or focused, and moves the count aside for it.
|
|
27
|
+
is pointed at or keyboard-focused, and moves the count aside for it. On a
|
|
28
|
+
screen with no hover the ⋮ always shows, and the count or dot under it
|
|
29
|
+
stays aside: the row reads as it does under a pointer.
|
|
28
30
|
|
|
29
31
|
<Canvas of={NavItemStories.WithMenu} />
|
|
30
32
|
|
package/src/NavItem.test.tsx
CHANGED
|
@@ -94,6 +94,17 @@ describe('NavItem unread', () => {
|
|
|
94
94
|
expect(container.querySelector('[data-unread]')).toBeNull()
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
+
it('the dot and the badge are pulled 4px right (-mr-1), onto the axis of a count and of the ⋮', () => {
|
|
98
|
+
const { container } = render(
|
|
99
|
+
<>
|
|
100
|
+
<NavItem href="#" label="Item one" unread />
|
|
101
|
+
<NavItem href="#" label="Item two" unread urgent />
|
|
102
|
+
</>,
|
|
103
|
+
)
|
|
104
|
+
expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('-mr-1')
|
|
105
|
+
expect(container.querySelector('[data-urgent]')!.parentElement!.className.split(' ')).toContain('-mr-1')
|
|
106
|
+
})
|
|
107
|
+
|
|
97
108
|
it('urgent without unread draws nothing', () => {
|
|
98
109
|
const { container } = render(<NavItem href="#" label="Item one" urgent />)
|
|
99
110
|
expect(container.querySelector('[data-urgent], [data-unread]')).toBeNull()
|
|
@@ -105,6 +116,25 @@ describe('NavItem unread', () => {
|
|
|
105
116
|
expect(menu?.className).toContain('right-1')
|
|
106
117
|
})
|
|
107
118
|
|
|
119
|
+
it('on a screen with no hover the ⋮ always shows, and the count steps aside for it', () => {
|
|
120
|
+
render(<NavItem href="#" label="Item one" count={3} menu={<span>Action</span>} />)
|
|
121
|
+
const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')!
|
|
122
|
+
expect(menu.className.split(' ')).toContain('[@media(hover:none)]:opacity-100')
|
|
123
|
+
expect(screen.getByText('3').className.split(' ')).toContain('[@media(hover:none)]:opacity-0')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('on a screen with no hover, with nothing at the right, the ⋮’s place is kept so the label stops before it', () => {
|
|
127
|
+
const { container } = render(<NavItem href="#" label="Item one" menu={<span>Action</span>} />)
|
|
128
|
+
expect(container.querySelector('a > [aria-hidden="true"]')!.className.split(' ')).toContain('[@media(hover:none)]:block')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('with nothing at the right, the ⋮’s kept place is the ⋮’s own, at right-1, so the label loses no more than it covers', () => {
|
|
132
|
+
const { container } = render(<NavItem href="#" label="Item one" menu={<span>Action</span>} />)
|
|
133
|
+
// The slot is pulled 4px into the padding, as the ⋮ is (`right-1`): measured, the label then stops 8px before the button, not 12.
|
|
134
|
+
expect(container.querySelector('a > [aria-hidden="true"]')!.className.split(' ')).toEqual(expect.arrayContaining(['-mr-1', 'w-6']))
|
|
135
|
+
expect(screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')!.className).toContain('right-1')
|
|
136
|
+
})
|
|
137
|
+
|
|
108
138
|
it('with a count and a menu, the ⋮ lands on the count', () => {
|
|
109
139
|
render(<NavItem href="#" label="Item one" count={3} menu={<span>Action</span>} />)
|
|
110
140
|
const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
|
package/src/NavItem.tsx
CHANGED
|
@@ -112,7 +112,9 @@ export function NavItem({
|
|
|
112
112
|
// (`:focus-visible`), never any focus: a clicked row keeps its focus, and
|
|
113
113
|
// with `focus-within` its icon and dot went while the arrow and the ⋮ stayed
|
|
114
114
|
// hidden (Katerina, 5 October).
|
|
115
|
-
|
|
115
|
+
// A screen with no hover shows the ⋮ always (below), so what it lands on
|
|
116
|
+
// steps aside always: the row reads as it does under a pointer.
|
|
117
|
+
const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0 [@media(hover:none)]:opacity-0'
|
|
116
118
|
|
|
117
119
|
const row = (
|
|
118
120
|
<a
|
|
@@ -176,7 +178,10 @@ export function NavItem({
|
|
|
176
178
|
// Nothing at the right, but a menu: no room is kept at rest, so the
|
|
177
179
|
// label has the row's whole width; while the ⋮ shows, its 24px slot
|
|
178
180
|
// is kept, so a long label ends before it instead of running under it.
|
|
179
|
-
|
|
181
|
+
// `-mr-1`: the ⋮ sits at `right-1`, 4px into the row's padding, so the
|
|
182
|
+
// slot does too — at the padding's edge it kept 4px the ⋮ never
|
|
183
|
+
// covers, and the label stopped 12px short of the button, not 8.
|
|
184
|
+
<span aria-hidden="true" className="-mr-1 hidden w-6 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block [@media(hover:none)]:block" />
|
|
180
185
|
) : null}
|
|
181
186
|
</a>
|
|
182
187
|
)
|
|
@@ -207,7 +212,9 @@ export function NavItem({
|
|
|
207
212
|
// (Katerina, 5 October: the rows move to the heading, not the
|
|
208
213
|
// heading to the rows). Over a dot or the empty slot it sits 4px
|
|
209
214
|
// right of the slot's centre.
|
|
210
|
-
|
|
215
|
+
// On a screen with no hover it always shows: nothing there can point
|
|
216
|
+
// at the row, and the menu has no other way in.
|
|
217
|
+
<div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100')}>
|
|
211
218
|
<Menu
|
|
212
219
|
align="right"
|
|
213
220
|
trigger={
|
package/src/NavTree.test.tsx
CHANGED
|
@@ -130,6 +130,27 @@ describe('NavTree marks its groups', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 4px between groups and none above the first, title or no title. The margin
|
|
135
|
+
* was `mt-1 first:mt-0`, and the quiet title is drawn before the groups, so
|
|
136
|
+
* the first group under a title was never the first child and took the 4px
|
|
137
|
+
* (measured in Chrome: 6px under the title, 2px after).
|
|
138
|
+
*/
|
|
139
|
+
describe('NavTree spaces its groups', () => {
|
|
140
|
+
it('only a group after a group takes the 4px, so the first sits at the column’s 2px under the title', () => {
|
|
141
|
+
const { container } = render(<NavTree title="Groups" groups={GROUPS} emptyMessage="Nothing here yet." />)
|
|
142
|
+
const boxes = [...container.querySelectorAll<HTMLElement>('[data-nav-group]')]
|
|
143
|
+
for (const box of boxes) {
|
|
144
|
+
const classes = box.className.split(' ')
|
|
145
|
+
expect(classes).toContain('[&+&]:mt-1')
|
|
146
|
+
expect(classes).not.toContain('mt-1')
|
|
147
|
+
}
|
|
148
|
+
// The selector's own terms: the first group follows the title, which is no group.
|
|
149
|
+
expect(boxes[0].previousElementSibling!.className.split(' ')).not.toContain('[&+&]:mt-1')
|
|
150
|
+
expect(boxes[1].previousElementSibling).toBe(boxes[0])
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
133
154
|
describe('NavTree with no groups and no words', () => {
|
|
134
155
|
it('draws no line at all', () => {
|
|
135
156
|
const { container } = render(<NavTree groups={[]} />)
|
package/src/NavTree.tsx
CHANGED
|
@@ -50,7 +50,7 @@ export interface NavTreeGroup {
|
|
|
50
50
|
message?: string
|
|
51
51
|
/** Beside the title, always shown: an `UnreadDot`, typically. */
|
|
52
52
|
trailing?: ReactNode
|
|
53
|
-
/** Beside the title, shown
|
|
53
|
+
/** Beside the title, shown while the row is hovered or keyboard-focused: "Open", typically. */
|
|
54
54
|
actions?: SectionAction[]
|
|
55
55
|
/** The group's "More options" menu, after its actions: `MenuItem`s — a folder's Rename, Archive. */
|
|
56
56
|
menu?: ReactNode
|
|
@@ -171,7 +171,7 @@ export interface NavTreeSectionProps {
|
|
|
171
171
|
message?: string
|
|
172
172
|
/** Beside the title, always shown: an `UnreadDot`, typically. */
|
|
173
173
|
trailing?: ReactNode
|
|
174
|
-
/** Beside the title, shown
|
|
174
|
+
/** Beside the title, shown while the row is hovered or keyboard-focused: "Open", typically. */
|
|
175
175
|
actions?: SectionAction[]
|
|
176
176
|
/** The group's "More options" menu, after its actions: `MenuItem`s. */
|
|
177
177
|
menu?: ReactNode
|
|
@@ -209,7 +209,11 @@ export function NavTreeSection({ id, title, message, loading = false, trailing,
|
|
|
209
209
|
// sits 38px from the next (Katerina, 5 October: 42 was too far from rows
|
|
210
210
|
// 33 apart; halved), and the list starts 16px under its header, as every
|
|
211
211
|
// list column does (on the first group it read 20, Topics against People).
|
|
212
|
-
|
|
212
|
+
// The margin is on a group that follows a group (`[&+&]`), not "all but
|
|
213
|
+
// the first child": NavTree draws its quiet title before the groups, so
|
|
214
|
+
// with `first:mt-0` the first group under a title still took the 4px
|
|
215
|
+
// (measured 6px under the title, 2px once the margin went).
|
|
216
|
+
className="shrink-0 [&+&]:mt-1"
|
|
213
217
|
data-nav-group={id}
|
|
214
218
|
onPointerEnter={intent ? () => { clearTimeout(timer.current); timer.current = setTimeout(intent, NAV_TREE_INTENT_MS) } : undefined}
|
|
215
219
|
onPointerLeave={intent ? () => clearTimeout(timer.current) : undefined}
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -6,7 +6,8 @@ import * as SectionHeaderStories from './SectionHeader.stories'
|
|
|
6
6
|
# SectionHeader
|
|
7
7
|
|
|
8
8
|
The 32px row a section starts with: a SectionLabel, and actions that
|
|
9
|
-
appear
|
|
9
|
+
appear while the row is hovered or keyboard-focused, as IconButtons with
|
|
10
|
+
tooltips — always, on a screen with no hover. It is also the
|
|
10
11
|
header of a **CollapsibleSection**, which draws the chevron and makes the
|
|
11
12
|
title the toggle — that is where a section folds.
|
|
12
13
|
|
|
@@ -86,7 +87,7 @@ It does these by itself. Write none of them around it.
|
|
|
86
87
|
| It does | So you never write | Refused when written by hand |
|
|
87
88
|
|---|---|---|
|
|
88
89
|
| With `chevron`, its title is a button that calls `onToggle` on a click, Enter or Space, and says whether it is open | a button of your own, or `aria-expanded` | |
|
|
89
|
-
| Shows its actions while one of them
|
|
90
|
+
| Shows its actions while the keyboard is in the row — on the title or on one of them — and always on a screen with no hover | a focus style of your own | |
|
|
90
91
|
| Keeps its 32px in a scrolling column | `shrink-0` | |
|
|
91
92
|
|
|
92
93
|
## Keys
|
|
@@ -94,7 +95,7 @@ It does these by itself. Write none of them around it.
|
|
|
94
95
|
| Key | Does |
|
|
95
96
|
|---|---|
|
|
96
97
|
| Enter, Space | on the title, with `chevron`: calls `onToggle` |
|
|
97
|
-
| Tab | the title, then each action — the actions show
|
|
98
|
+
| Tab | the title, then each action — the actions show from the title on |
|
|
98
99
|
|
|
99
100
|
## Props
|
|
100
101
|
|
|
@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
8
8
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
9
|
import userEvent from '@testing-library/user-event'
|
|
10
10
|
import { MenuItem } from './Menu'
|
|
11
|
+
import { NavItem } from './NavItem'
|
|
11
12
|
import { SectionHeader } from './SectionHeader'
|
|
12
13
|
import { UnreadDot } from './UnreadDot'
|
|
13
14
|
|
|
@@ -50,6 +51,55 @@ describe('SectionHeader', () => {
|
|
|
50
51
|
})
|
|
51
52
|
})
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* When the buttons show. The pointer and the menu are Chrome's to prove (the
|
|
56
|
+
* stories); what is pinned here is which element each reveal asks.
|
|
57
|
+
*/
|
|
58
|
+
describe('SectionHeader, the reveal', () => {
|
|
59
|
+
const menu = <MenuItem label="Rename" />
|
|
60
|
+
const actions = [{ icon: <span />, tooltip: 'Add', onClick: () => {} }]
|
|
61
|
+
|
|
62
|
+
it('shows the buttons once the title’s toggle has keyboard focus, as a NavItem shows its ⋮ for its link', () => {
|
|
63
|
+
const { container } = render(<SectionHeader title="Section" chevron onToggle={() => {}} actions={actions} menu={menu} />)
|
|
64
|
+
const row = container.firstElementChild as HTMLElement
|
|
65
|
+
const toggle = screen.getByRole('button', { name: 'Section' })
|
|
66
|
+
const buttons = screen.getByRole('button', { name: 'Add' }).parentElement!
|
|
67
|
+
// The row is the group, and it holds the toggle: the reveal asks the row, not the buttons' own box.
|
|
68
|
+
expect(row.className.split(' ')).toContain('group')
|
|
69
|
+
expect(row.contains(toggle)).toBe(true)
|
|
70
|
+
expect(buttons.className.split(' ')).toContain('group-has-[:focus-visible]:opacity-100')
|
|
71
|
+
expect(buttons.className.split(' ')).not.toContain('has-[:focus-visible]:opacity-100')
|
|
72
|
+
// An open menu holds them, asked of their own box: the toggle's aria-expanded must not.
|
|
73
|
+
expect(buttons.className.split(' ')).toContain('has-[[aria-expanded=true]]:opacity-100')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('on a screen with no hover the buttons always show, and a dot under them steps aside', () => {
|
|
77
|
+
const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} actions={actions} menu={menu} />)
|
|
78
|
+
expect(screen.getByRole('button', { name: 'Add' }).parentElement!.className.split(' ')).toContain('[@media(hover:none)]:opacity-100')
|
|
79
|
+
expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('[@media(hover:none)]:has-[[data-unread]]:opacity-0')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('no reveal answers to any focus (`focus-within`): a clicked row kept its focus and lost its icon and dot (0.56.0)', () => {
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<>
|
|
85
|
+
<SectionHeader title="Section" chevron icon={<span />} onToggle={() => {}} trailing={<UnreadDot />} actions={actions} menu={menu} />
|
|
86
|
+
<NavItem href="#" label="Item one" icon={<span />} count={2} hint={<span>Label</span>} menu={menu}>
|
|
87
|
+
<NavItem href="#" label="Item two" unread menu={menu} />
|
|
88
|
+
</NavItem>
|
|
89
|
+
</>,
|
|
90
|
+
)
|
|
91
|
+
const classes = [...container.querySelectorAll('*')].map((e) => e.getAttribute('class') ?? '').join(' ')
|
|
92
|
+
expect(classes).not.toContain('focus-within')
|
|
93
|
+
// The keyboard's reveal is what replaced it.
|
|
94
|
+
expect(classes).toContain('group-has-[:focus-visible]')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('with showActions="always" there is nothing to reveal', () => {
|
|
98
|
+
render(<SectionHeader title="Section" actions={actions} showActions="always" />)
|
|
99
|
+
expect(screen.getByRole('button', { name: 'Add' }).parentElement!.className).not.toContain('opacity-0')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
53
103
|
/**
|
|
54
104
|
* The trailing slot (0.12.2, ADOPTION B23). Peek's Screener header carries its
|
|
55
105
|
* count as a Chip there, and was the last hand-drawn folding header in the app
|
|
@@ -78,13 +128,26 @@ describe('SectionHeader, the trailing slot', () => {
|
|
|
78
128
|
const { container } = render(
|
|
79
129
|
<SectionHeader title="Section" trailing={<UnreadDot />} actions={[{ icon: <span />, tooltip: 'Open', onClick: () => {} }]} menu={<MenuItem label="Rename" />} />,
|
|
80
130
|
)
|
|
81
|
-
const
|
|
82
|
-
//
|
|
83
|
-
expect(
|
|
84
|
-
expect(
|
|
131
|
+
const slot = container.querySelector('[data-unread]')!.parentElement!
|
|
132
|
+
// Over the last button, out of the row's flow — not a slot of its own before them — and gone while they show.
|
|
133
|
+
expect(slot.className.split(' ')).toEqual(expect.arrayContaining(['has-[[data-unread]]:absolute', 'has-[[data-unread]]:right-1', 'has-[[data-unread]]:w-6', 'group-hover:has-[[data-unread]]:opacity-0']))
|
|
134
|
+
expect(slot.matches(':has([data-unread])')).toBe(true)
|
|
85
135
|
expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull()
|
|
86
136
|
})
|
|
87
137
|
|
|
138
|
+
it('a dot is found by what it draws, not by its element: one in a wrapper of the app’s own steps aside too', () => {
|
|
139
|
+
render(
|
|
140
|
+
<SectionHeader title="Section" trailing={<span data-testid="wrapped"><UnreadDot /></span>} menu={<MenuItem label="Rename" />} />,
|
|
141
|
+
)
|
|
142
|
+
const slot = screen.getByTestId('wrapped').parentElement!
|
|
143
|
+
expect(slot.matches(':has([data-unread])')).toBe(true)
|
|
144
|
+
expect(slot.className.split(' ')).toContain('has-[[data-unread]]:absolute')
|
|
145
|
+
// A count holds no dot, so the same slot keeps it beside the buttons.
|
|
146
|
+
cleanup()
|
|
147
|
+
render(<SectionHeader title="Section" trailing={<span data-testid="count">2</span>} menu={<MenuItem label="Rename" />} />)
|
|
148
|
+
expect(screen.getByTestId('count').parentElement!.matches(':has([data-unread])')).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
88
151
|
it('an icon shows at rest, and folding, the arrow is in its place', () => {
|
|
89
152
|
const { container } = render(<SectionHeader title="Section" chevron icon={<span data-icon />} onToggle={() => {}} />)
|
|
90
153
|
const icon = container.querySelector('[data-icon]')!
|
|
@@ -95,6 +158,6 @@ describe('SectionHeader, the trailing slot', () => {
|
|
|
95
158
|
|
|
96
159
|
it('a dot with no buttons sits on their axis all the same', () => {
|
|
97
160
|
const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} />)
|
|
98
|
-
expect(container.querySelector('[data-unread]')!.parentElement!.className).toContain('-mr-1')
|
|
161
|
+
expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('has-[[data-unread]]:-mr-1')
|
|
99
162
|
})
|
|
100
163
|
})
|