@estiva-app/ui 0.24.1 → 0.25.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/src/Menu.mdx CHANGED
@@ -140,8 +140,11 @@ Four surfaces used to claim menu items of nothing.
140
140
 
141
141
  A menu whose rows are `px-3` (tall picker rows) aligns its headings by passing
142
142
  `className="px-3"` to `MenuSection` — the heading row is `px-2` by default,
143
- matching the default rows. A `Divider` inside a menu needs nothing: the panel
144
- already runs it the width of the rows it separates.
143
+ matching the default rows.
144
+
145
+ **Between groups, a `MenuSeparator`**: the line, the width of the rows it
146
+ separates, with its own 4px above and below. Not a `Divider`, whose room is
147
+ its caller's.
145
148
 
146
149
  ## Props
147
150
 
@@ -1,9 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconCopy, IconDots, IconPencil, IconSquareRounded, IconTrash } from '@tabler/icons-react'
3
3
  import { Button } from './Button'
4
- import { Divider } from './Divider'
5
4
  import { IconButton } from './IconButton'
6
- import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub } from './Menu'
5
+ import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub } from './Menu'
7
6
  import { SectionLabel } from './SectionLabel'
8
7
 
9
8
  /**
@@ -41,7 +40,7 @@ export const Items: Story = {
41
40
  <MenuPanel className="min-w-[180px]">
42
41
  <MenuItem label="Rename" leading={icon(IconPencil)} onClick={() => {}} />
43
42
  <MenuItem label="Duplicate" leading={icon(IconCopy)} shortcut="Ctrl+D" onClick={() => {}} />
44
- <Divider className="my-1" />
43
+ <MenuSeparator />
45
44
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
46
45
  </MenuPanel>
47
46
  ),
@@ -57,7 +56,7 @@ export const Sections: Story = {
57
56
  <MenuItem label="Item one" selected onClick={() => {}} />
58
57
  <MenuItem label="Item two" onClick={() => {}} />
59
58
  </MenuSection>
60
- <Divider className="my-1" />
59
+ <MenuSeparator />
61
60
  <MenuSection label="Another section">
62
61
  <MenuItem label="Option one" onClick={() => {}} />
63
62
  <MenuItem label="Option two" onClick={() => {}} />
@@ -75,7 +74,7 @@ export const WithARow: Story = {
75
74
  <span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">Item one</span>
76
75
  <SectionLabel tone="secondary">Label</SectionLabel>
77
76
  </MenuRow>
78
- <Divider className="my-1" />
77
+ <MenuSeparator />
79
78
  <MenuItem label="An action" onClick={() => {}} />
80
79
  </MenuPanel>
81
80
  ),
@@ -99,7 +98,7 @@ export const FromATrigger: Story = {
99
98
  <MenuItem label="Item two" onClick={() => {}} />
100
99
  <MenuItem label="Item three" onClick={() => {}} />
101
100
  </MenuSub>
102
- <Divider className="my-1" />
101
+ <MenuSeparator />
103
102
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
104
103
  </Menu>
105
104
  ),
@@ -149,7 +148,7 @@ export const OnACard: Story = {
149
148
  <MenuItem label="Item two" onClick={() => {}} />
150
149
  </MenuSub>
151
150
  </MenuSection>
152
- <Divider className="my-1" />
151
+ <MenuSeparator />
153
152
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
154
153
  </Menu>
155
154
  </div>
package/src/Menu.test.tsx CHANGED
@@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
11
11
  import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
12
  import userEvent from '@testing-library/user-event'
13
13
  import { Button } from './Button'
14
- import { Menu, MenuItem, MenuPanel, MenuSection, MenuSub } from './Menu'
14
+ import { Menu, MenuItem, MenuPanel, MenuSection, MenuSeparator, MenuSub } from './Menu'
15
15
 
16
16
  afterEach(cleanup)
17
17
 
@@ -269,6 +269,24 @@ describe('Menu', () => {
269
269
  * plugin already owns the keyboard — so `MenuItem` and `MenuSection` have to
270
270
  * work with no `Menu` above them.
271
271
  */
272
+ describe('MenuSeparator', () => {
273
+ // UIG-14: the line between groups brings its own 4px above and below; a plain
274
+ // Divider left that to each caller, and Peek's main menus had none.
275
+ it('is a separator with its own room, the width of the rows', async () => {
276
+ render(
277
+ <Menu open trigger={<Button>More</Button>}>
278
+ <MenuItem label="One" />
279
+ <MenuSeparator />
280
+ <MenuItem label="Two" />
281
+ </Menu>,
282
+ )
283
+ const line = await screen.findByRole('separator')
284
+ const classes = (line.getAttribute('class') ?? '').split(' ')
285
+ expect(classes).toContain('my-1')
286
+ expect(classes).toContain('mx-0')
287
+ })
288
+ })
289
+
272
290
  describe('rows on a bare MenuPanel', () => {
273
291
  /**
274
292
  * It is a button, and it does NOT claim to be a menu item. ARIA requires a
package/src/Menu.tsx CHANGED
@@ -2,7 +2,7 @@ import { createContext, useContext, type ComponentPropsWithRef, type ReactElemen
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { Menu as BaseMenu } from '@base-ui/react/menu'
4
4
  import { cn } from './cn'
5
- import { DividerInPanel } from './Divider'
5
+ import { Divider, DividerInPanel } from './Divider'
6
6
  import { triggerDisabled } from './triggerDisabled'
7
7
  import { ScrollArea } from './ScrollArea'
8
8
  import { Kbd } from './Kbd'
@@ -590,6 +590,16 @@ export function MenuSection({ label, children, className }: { label: string; chi
590
590
  )
591
591
  }
592
592
 
593
+ /**
594
+ * The line between two groups of a menu, with its own 4px above and below
595
+ * (UIG-14, Katerina 19 September). A plain `Divider` left that room to each
596
+ * caller, and Peek's menus drew it three ways — none, 4px, 4px and inset — so
597
+ * the lines in its main menus touched the rows.
598
+ */
599
+ export function MenuSeparator() {
600
+ return <Divider className="my-1" />
601
+ }
602
+
593
603
  /** A non-interactive row inside a menu, at the item's own geometry. */
594
604
  export function MenuRow({ children, className }: { children: ReactNode; className?: string }) {
595
605
  return <div className={cn('flex items-center gap-2 px-2 py-1.5', className)}>{children}</div>
@@ -16,6 +16,9 @@ title the toggle — that is where a section folds.
16
16
 
17
17
  - A sidebar or panel section that carries its own actions — add, sort,
18
18
  filter.
19
+ - **A heading over a group of rows, even with nothing to act on**: no
20
+ `actions`, and `hover="none"` so the row stays still under the pointer.
21
+ Never a row drawn by hand with a SectionLabel in it.
19
22
  - `showActions="always"` keeps the actions visible — for touch, or for a
20
23
  section whose actions are the point.
21
24
 
@@ -25,7 +28,8 @@ title the toggle — that is where a section folds.
25
28
 
26
29
  - A section that opens and closes → **CollapsibleSection**: the header,
27
30
  the rows, the slide, and a `storageKey` that remembers.
28
- - A title with no chevron and no actions → **SectionLabel** alone.
31
+ - A label inside a row of another shape — a dialog's section, a panel's
32
+ title, a card's line → **SectionLabel**.
29
33
  - A heading inside a menu → **MenuSection**.
30
34
  - Page-level headings → the `h*` type tokens.
31
35
 
@@ -15,13 +15,15 @@ how).
15
15
  ## When
16
16
 
17
17
  - **It is a bare label**: the row it sits in, and the room around it, are yours.
18
- - Titling a group of rows in a sidebar, a panel, a rail.
18
+ - A label inside a row of another shape: a dialog's section, a panel's
19
+ title, a card's line.
19
20
  - Inside a menu it arrives via **MenuSection** — don't place it there by
20
21
  hand.
21
22
 
22
23
  ## When not
23
24
 
24
- - A heading that also collapses or carries actions → **SectionHeader**.
25
+ - A heading over a group of rows → **SectionHeader**, even with nothing to
26
+ act on; a group that folds → **CollapsibleSection**.
25
27
  - The label above a stacked control → **Property** `stacked` — a field
26
28
  label is a different voice, at a different size, on purpose.
27
29
  - Page-level headings → the `h*` type tokens.
package/src/index.ts CHANGED
@@ -60,10 +60,11 @@ export { DialogShell, type DialogShellProps } from './DialogShell'
60
60
  export { Divider, type DividerProps } from './Divider'
61
61
  export { EditableText, type EditableTextProps } from './EditableText'
62
62
  export { EmptyState, type EmptyStateProps } from './EmptyState'
63
+ export { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary'
63
64
  export { ProgressBar, type ProgressBarProps, type ProgressBarVariant } from './ProgressBar'
64
65
  export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
65
66
  export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
66
- export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
67
+ export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
67
68
  export { NavItem, type NavItemProps } from './NavItem'
68
69
  export { Popover, type PopoverProps } from './Popover'
69
70
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
@@ -45,17 +45,17 @@ describe('the registry builds', () => {
45
45
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
46
46
  })
47
47
 
48
- it('counts 84 names over 55 files: 77 components, 6 helpers and 1 hook (ListColumn and ToolbarLink joined in UIG-14)', () => {
48
+ it('counts 86 names over 56 files: 79 components, 6 helpers and 1 hook (ErrorBoundary and MenuSeparator joined in UIG-14)', () => {
49
49
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
50
50
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
51
- expect(kinds).toEqual({ component: 77, helper: 6, hook: 1 })
52
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(55)
51
+ expect(kinds).toEqual({ component: 79, helper: 6, hook: 1 })
52
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(56)
53
53
  })
54
54
 
55
55
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
56
56
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
57
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(55)
58
- expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(29)
57
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(56)
58
+ expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(30)
59
59
  })
60
60
 
61
61
  it('takes a name with a page of its own from the page, and a name without one from the code', () => {
@@ -278,7 +278,7 @@ describe('the ids point at something', () => {
278
278
  describe('a second parser agrees', async () => {
279
279
  const docgen = await import('react-docgen').catch(() => null)
280
280
 
281
- it.skipIf(!docgen)('finds the same props on all 77 components', async () => {
281
+ it.skipIf(!docgen)('finds the same props on all 79 components', async () => {
282
282
  if (!docgen) return
283
283
  const resolver = new docgen.builtinResolvers.FindExportedDefinitionsResolver({ limit: 0 })
284
284
  const byName = new Map(registry.entries.map((one) => [one.name, one]))