@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.
@@ -1,4 +1,4 @@
1
- import { isValidElement, type ReactNode } from 'react'
1
+ import type { ReactNode } from 'react'
2
2
  import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
3
3
  import { useRender } from '@base-ui/react/use-render'
4
4
  import { cn } from './cn'
@@ -6,13 +6,12 @@ import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
6
6
  import { IconButton } from './IconButton'
7
7
  import { Menu } from './Menu'
8
8
  import { SectionLabel } from './SectionLabel'
9
- import { UnreadDot } from './UnreadDot'
10
9
 
11
10
  /**
12
11
  * The 32px row a section starts with (Peek's SectionHeader, 2026-09-01):
13
12
  * a SectionLabel, an optional collapse chevron that makes the title the
14
- * toggle, and actions that appear while the row is hovered or focused, as
15
- * IconButtons with tooltips.
13
+ * toggle, and actions that appear while the row is hovered or keyboard-focused,
14
+ * as IconButtons with tooltips.
16
15
  *
17
16
  * Three things changed on 2026-09-09, for `CollapsibleSection`:
18
17
  *
@@ -79,7 +78,7 @@ export interface SectionHeaderProps {
79
78
  menu?: ReactNode
80
79
  /** The ⋮'s accessible name. Default "More options for" the title. */
81
80
  menuLabel?: string
82
- /** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
81
+ /** `hover` reveals the actions while the row is hovered or keyboard-focused (always on a screen with no hover); `always` keeps them. */
83
82
  showActions?: 'hover' | 'always'
84
83
  /**
85
84
  * `fill` lights the row under the pointer when it does something (a toggle,
@@ -113,12 +112,35 @@ export function SectionHeader({ title, chevron = false, icon, isExpanded = true,
113
112
  fills the icon's place exactly.
114
113
  */
115
114
  const acts = (actions && actions.length > 0) || menu != null
116
- // A dot sits in the place of the last button and steps aside while the
117
- // buttons show — the rows' dot and ⋮ share a place the same way (Katerina,
118
- // 5 October). A count stays beside them: it is information (09-09).
119
- const dot = isValidElement(trailing) && trailing.type === UnreadDot
120
- const shared = acts && showActions === 'hover' && dot
121
- const reveal = showActions === 'hover' && 'opacity-0 transition-opacity group-hover:opacity-100 has-[:focus-visible]:opacity-100 has-[[aria-expanded=true]]:opacity-100'
115
+ /*
116
+ A dot sits in the place of the last button and steps aside while the
117
+ buttons show — the rows' dot and ⋮ share a place the same way (Katerina,
118
+ 5 October). A count stays beside them: it is information (09-09).
119
+
120
+ "A dot" is whatever holds an `UnreadDot`, found by the `data-unread` it
121
+ draws, so the CSS decides (`has-[[data-unread]]`) rather than a check of
122
+ the element's type: that missed a dot in a tooltip or in an app's own
123
+ wrapper, which then sat beside the buttons as a count does. With no
124
+ buttons it sits on their axis all the same (`-mr-1`, as they are pulled).
125
+ In the last button's place it is the 24px slot at `right-1`, where the
126
+ buttons' box ends, gone while they show — on a screen with no hover, then,
127
+ always.
128
+ */
129
+ const trailingClasses = !acts
130
+ ? 'has-[[data-unread]]:-mr-1'
131
+ : showActions === 'hover' &&
132
+ 'has-[[data-unread]]:pointer-events-none has-[[data-unread]]:absolute has-[[data-unread]]:inset-y-0 has-[[data-unread]]:right-1 has-[[data-unread]]:w-6 has-[[data-unread]]:justify-center has-[[data-unread]]:transition-opacity group-hover:has-[[data-unread]]:opacity-0 group-has-[:focus-visible]:has-[[data-unread]]:opacity-0 group-has-[[data-section-menu]_[aria-expanded=true]]:has-[[data-unread]]:opacity-0 [@media(hover:none)]:has-[[data-unread]]:opacity-0'
133
+ /*
134
+ The buttons show under the pointer, while the keyboard is anywhere in the
135
+ row — the title's toggle included, as a NavItem's ⋮ shows once its link
136
+ has keyboard focus; it was only one of the buttons themselves — and while
137
+ the menu is open. `aria-expanded` is asked of the buttons' own box: the
138
+ toggle says it too, and an open section would keep them for good.
139
+
140
+ A screen with no hover (a phone, a tablet) shows them always: nothing
141
+ there can point at the row, so hidden they could never be reached.
142
+ */
143
+ const reveal = showActions === 'hover' && 'opacity-0 transition-opacity group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100'
122
144
  const titleElement = useRender({
123
145
  render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
124
146
  props: {
@@ -165,7 +187,8 @@ export function SectionHeader({ title, chevron = false, icon, isExpanded = true,
165
187
  return (
166
188
  <div
167
189
  className={cn(
168
- 'group flex h-[32px] shrink-0 items-center gap-1 px-2',
190
+ // `relative`: a dot in the last button's place is placed against the row.
191
+ 'group relative flex h-[32px] shrink-0 items-center gap-1 px-2',
169
192
  !row && 'transition-colors',
170
193
  // A row is NavItem's shape and colour: 6px corners, secondary words that brighten on hover.
171
194
  row ? cn(SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES) : 'rounded-lg',
@@ -184,18 +207,14 @@ export function SectionHeader({ title, chevron = false, icon, isExpanded = true,
184
207
  {/* Outside the title button, like the actions: a chip inside a button
185
208
  would be part of the button's accessible name and part of its hit
186
209
  target, and the count is neither. */}
187
- {trailing != null && !shared && (
188
- // A dot with no buttons to share a place with still sits on their
189
- // axis — the rows' dots and ⋮ (`-mr-1`, as the buttons are pulled).
190
- <div className={cn('flex shrink-0 items-center', dot && !acts && '-mr-1')}>{trailing}</div>
191
- )}
210
+ {trailing != null && <div className={cn('flex shrink-0 items-center', trailingClasses)}>{trailing}</div>}
192
211
  {acts && (
193
212
  <div
194
213
  // `-mr-1`: an IconButton is 24px around a 16px icon, so at the row's
195
214
  // `px-2` its icon ended 12px from the edge while a NavItem's number
196
215
  // ends 8px from it; pulled 4px, the icon and the number share a
197
216
  // right edge (Katerina, 2026-09-09; measured 4px off, then 0).
198
- className="group/acts relative -mr-1 flex shrink-0 items-center"
217
+ className="-mr-1 flex shrink-0 items-center"
199
218
  >
200
219
  <div className={cn('flex items-center gap-1', reveal)}>
201
220
  {actions?.map((action) => (
@@ -218,13 +237,6 @@ export function SectionHeader({ title, chevron = false, icon, isExpanded = true,
218
237
  </div>
219
238
  )}
220
239
  </div>
221
- {shared && (
222
- // The dot in the last button's 24px place, on the axis of its icon;
223
- // gone while the buttons show.
224
- <div className="pointer-events-none absolute inset-y-0 right-0 flex w-6 items-center justify-center transition-opacity group-hover:opacity-0 group-has-[:focus-visible]/acts:opacity-0 group-has-[[aria-expanded=true]]/acts:opacity-0">
225
- {trailing}
226
- </div>
227
- )}
228
240
  </div>
229
241
  )}
230
242
  </div>
@@ -104,6 +104,14 @@ describe('Select', () => {
104
104
  expect(screen.getByRole('combobox', { name: 'Status' }).getAttribute('aria-expanded')).toBe('true')
105
105
  })
106
106
 
107
+ it('puts 2px between options, as between a menu’s rows', async () => {
108
+ const user = userEvent.setup()
109
+ render(<Controlled initial="todo" />)
110
+ await user.click(screen.getByRole('combobox', { name: 'Status' }))
111
+ const [first] = await screen.findAllByRole('option')
112
+ expect(first.parentElement!.className.split(' ')).toContain('gap-0.5')
113
+ })
114
+
107
115
  it('opens from the keyboard, moves with arrows, picks with Enter, and gives focus back', async () => {
108
116
  const user = userEvent.setup()
109
117
  const onChange = vi.fn()
package/src/Select.tsx CHANGED
@@ -174,8 +174,11 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
174
174
  * to the right side". The rows keep their 4px inset, because the
175
175
  * padding that was the panel's is the content's; and the cap loses
176
176
  * its `- 0.5rem`, because that padding is inside the box that
177
- * scrolls now, so 288px stays 288px. */}
178
- <ScrollArea viewportClassName="max-h-[min(288px,var(--available-height))]" contentClassName="flex flex-col p-2">
177
+ * scrolls now, so 288px stays 288px.
178
+ *
179
+ * `gap-0.5`: 2px between the options, as between a menu's rows —
180
+ * they touched, while every menu had moved to 2px (0.56.0). */}
181
+ <ScrollArea viewportClassName="max-h-[min(288px,var(--available-height))]" contentClassName="flex flex-col gap-0.5 p-2">
179
182
  {options.map((option) => (
180
183
  <BaseSelect.Item
181
184
  key={option.value}
@@ -83,6 +83,21 @@ export const Default: Story = {
83
83
  ),
84
84
  }
85
85
 
86
+ /** No heading: rows the editor ranks itself, 2px apart as under one. */
87
+ export const NoHeading: Story = {
88
+ render: () => (
89
+ <AtACaret<Command>
90
+ ariaLabel="Commands"
91
+ width="w-[300px]"
92
+ maxHeight="max-h-[400px]"
93
+ sections={[{ items: FORMAT }]}
94
+ itemKey={(c) => c.id}
95
+ row={commandRow}
96
+ onSelect={() => {}}
97
+ />
98
+ ),
99
+ }
100
+
86
101
  /** Tall rows: a face, a second line, and Enter's hint on the highlighted one. */
87
102
  export const TallRows: Story = {
88
103
  render: () => (
@@ -48,6 +48,14 @@ describe('SuggestionMenu', () => {
48
48
  expect(screen.getByRole('group', { name: 'Second' })).toBeTruthy()
49
49
  })
50
50
 
51
+ it('puts 2px between rows, with a heading or without one', async () => {
52
+ draw({ sections: [{ items: ['One', 'Two'] }, { label: 'Second', items: ['Three'] }] })
53
+ const list = await screen.findByRole('listbox')
54
+ // The rows with no heading are the listbox's own; under a heading, MenuSection's.
55
+ expect(list.className.split(' ')).toContain('gap-0.5')
56
+ expect(screen.getByRole('group', { name: 'Second' }).firstElementChild!.className.split(' ')).toContain('gap-0.5')
57
+ })
58
+
51
59
  it('draws nothing when there is nothing to show', () => {
52
60
  draw({ sections: [{ label: 'First', items: [] }] })
53
61
  expect(screen.queryByRole('listbox')).toBeNull()
@@ -177,8 +177,10 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
177
177
 
178
178
  return (
179
179
  <Popover anchor={rect} open={rect !== null} side="top" align="left" className={width} maxHeight={maxHeight} ariaLabel={ariaLabel}>
180
- {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own. */}
181
- <div id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col">
180
+ {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own.
181
+ `gap-0.5`: 2px between rows, as a menu's — a section with a heading
182
+ had them from `MenuSection`, and rows without one touched. */}
183
+ <div id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
182
184
  {shown.map((section, i) => (
183
185
  <div key={section.label ?? i} className="contents">
184
186
  {i > 0 && <MenuSeparator />}
package/src/looks.ts CHANGED
@@ -45,15 +45,15 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
45
45
 
46
46
  /** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
47
47
  // 8px corners, a MenuItem row's (Katerina, 2 October).
48
+ export const SIDEBAR_ROW_CLASSES = 'rounded-lg transition-colors'
49
+ export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
50
+
48
51
  /** A quiet icon control: secondary, brightening on a hover fill — IconButton's `muted`. */
49
52
  export const MUTED_CONTROL_CLASSES = 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
50
53
 
51
54
  /** 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. */
52
55
  export const MENU_TRIGGER_CLASSES = 'cursor-pointer rounded-md transition-colors hover:bg-bg-hover data-[popup-open]:bg-bg-hover'
53
56
 
54
- export const SIDEBAR_ROW_CLASSES = 'rounded-lg transition-colors'
55
- export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
56
-
57
57
  /**
58
58
  * A panel that slides open and shut on Base UI's Collapsible: Base UI measures
59
59
  * it and writes its height to a variable, `auto` again once the slide ends, and