@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/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
@@ -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,2FAA2F;AAC3F,eAAO,MAAM,qBAAqB,kEAAkE,CAAA;AAEpG,2IAA2I;AAC3I,eAAO,MAAM,oBAAoB,gGAAgG,CAAA;AAEjI,eAAO,MAAM,mBAAmB,iCAAiC,CAAA;AACjE,eAAO,MAAM,wBAAwB,gDAAgD,CAAA;AAErF;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,kLAAkL,CAAA"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.56.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 on hover or focus — `SectionHeader`'s."
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 on hover or focus: \"Open\", typically."
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 on hover or focus as IconButtons with tooltips.",
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
  {
@@ -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 on hover or focus.
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 on hover or focus — `SectionHeader`'s. */
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()
@@ -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>
@@ -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
 
@@ -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
- 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'
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
- <span aria-hidden="true" className="hidden w-6 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block" />
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
- <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')}>
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={
@@ -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 on hover or focus: "Open", typically. */
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 on hover or focus: "Open", typically. */
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
- className="mt-1 shrink-0 first:mt-0"
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}
@@ -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 on hover or focus as IconButtons with tooltips. It is also the
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 has focus | a focus style of your own | |
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 while one has focus |
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 dot = container.querySelector('[data-unread]')!
82
- // Inside the buttons' box, over the last one — not a slot of its own before them.
83
- expect(dot.closest('[class~="group/acts"]')).not.toBe(null)
84
- expect(dot.parentElement!.className).toContain('group-hover:opacity-0')
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
  })