@estiva-app/ui 0.57.3 → 0.58.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.
Files changed (43) hide show
  1. package/dist/ContainerHeader.d.ts.map +1 -1
  2. package/dist/DialogShell.d.ts.map +1 -1
  3. package/dist/NavItem.d.ts.map +1 -1
  4. package/dist/Popover.d.ts +3 -2
  5. package/dist/Popover.d.ts.map +1 -1
  6. package/dist/SectionHeader.d.ts +7 -1
  7. package/dist/SectionHeader.d.ts.map +1 -1
  8. package/dist/ShowMore.d.ts +27 -0
  9. package/dist/ShowMore.d.ts.map +1 -0
  10. package/dist/SuggestionMenu.d.ts.map +1 -1
  11. package/dist/eslint/index.js +1 -0
  12. package/dist/eslint/index.js.map +2 -2
  13. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +127 -89
  17. package/dist/index.js.map +4 -4
  18. package/dist/looks.d.ts +2 -0
  19. package/dist/looks.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/registry.json +66 -4
  22. package/src/ComposerTriggers.test.tsx +3 -0
  23. package/src/ContainerHeader.mdx +1 -1
  24. package/src/ContainerHeader.tsx +3 -2
  25. package/src/DialogShell.tsx +2 -0
  26. package/src/ListColumn.mdx +1 -1
  27. package/src/NavItem.tsx +3 -2
  28. package/src/Popover.mdx +3 -1
  29. package/src/Popover.tsx +3 -2
  30. package/src/SectionHeader.mdx +6 -4
  31. package/src/SectionHeader.stories.tsx +7 -2
  32. package/src/SectionHeader.test.tsx +6 -0
  33. package/src/SectionHeader.tsx +10 -2
  34. package/src/ShowMore.mdx +62 -0
  35. package/src/ShowMore.stories.tsx +39 -0
  36. package/src/ShowMore.test.tsx +44 -0
  37. package/src/ShowMore.tsx +63 -0
  38. package/src/SuggestionMenu.tsx +2 -1
  39. package/src/eslint/no-restyled-part.ts +1 -0
  40. package/src/index.ts +1 -0
  41. package/src/looks.ts +3 -0
  42. package/src/registry/registry.test.ts +4 -4
  43. package/stories/Choosing.mdx +2 -1
package/dist/looks.d.ts CHANGED
@@ -29,6 +29,8 @@ export declare const FIELD_SIZE_CLASSES: {
29
29
  export declare const FIELD_SHELL_CLASSES = "bg-bg-field border border-border-field shadow-field hover:border-border-strong group-hover/field:border-border-strong rounded-lg transition-colors";
30
30
  /** A panel that floats over the page: Menu's panel and Tooltip. */
31
31
  export declare const FLOATING_SURFACE_CLASSES = "rounded-lg border border-border-default bg-bg-elevated shadow-lg";
32
+ /** A count beside a label, in the muted colour: NavItem's number and SectionHeader's `count`. */
33
+ export declare const COUNT_TEXT_CLASSES = "font-mono text-caption tabular-nums text-text-muted";
32
34
  /** A chip's words: Chip's label and Reaction's count. */
33
35
  export declare const CHIP_TEXT_CLASSES = "text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums";
34
36
  /** Words typed straight into an input with no box of its own: SearchInput and ChipInput. */
@@ -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,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"}
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,iGAAiG;AACjG,eAAO,MAAM,kBAAkB,wDAAwD,CAAA;AAEvF,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.57.3",
3
+ "version": "0.58.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,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.57.3",
8
- "exports": 130,
9
- "typeExports": 134,
7
+ "packageVersion": "0.58.0",
8
+ "exports": 131,
9
+ "typeExports": 135,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -4428,7 +4428,7 @@
4428
4428
  },
4429
4429
  {
4430
4430
  "name": "finalFocus",
4431
- "takes": "RefObject<HTMLElement | null>",
4431
+ "takes": "RefObject<HTMLElement | null> | false",
4432
4432
  "required": false,
4433
4433
  "note": "Where focus goes when the panel closes. With a `trigger` it goes back to the trigger and this is not needed."
4434
4434
  },
@@ -5278,6 +5278,12 @@
5278
5278
  "required": false,
5279
5279
  "note": "Beside the title and always visible, before the actions — a count."
5280
5280
  },
5281
+ {
5282
+ "name": "count",
5283
+ "takes": "number",
5284
+ "required": false,
5285
+ "note": "How many there are, 8px after the title — not at the row's end, where `trailing` goes (Katerina, 7 October: a heading over a list says its count beside its name, as a tab does)."
5286
+ },
5281
5287
  {
5282
5288
  "name": "actions",
5283
5289
  "takes": "SectionAction[]",
@@ -5362,6 +5368,7 @@
5362
5368
  "opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100",
5363
5369
  "opacity-0 transition-[opacity,transform] duration-150 group-hover:opacity-100 rotate-90",
5364
5370
  "transition-transform duration-150 text-text-secondary rotate-90",
5371
+ "font-mono text-caption tabular-nums text-text-muted",
5365
5372
  "rounded-lg transition-colors text-text-secondary hover:text-text-primary",
5366
5373
  "rounded-lg transition-colors text-text-secondary hover:text-text-primary hover:bg-bg-hover",
5367
5374
  "opacity-0 transition-opacity group-hover:opacity-100 [@media(hover:none)]:opacity-100"
@@ -5646,6 +5653,61 @@
5646
5653
  "app": null,
5647
5654
  "looks": []
5648
5655
  },
5656
+ {
5657
+ "name": "ShowMore",
5658
+ "repo": "estiva-ui",
5659
+ "kind": "component",
5660
+ "importPath": "@estiva-app/ui",
5661
+ "sourceFile": "src/ShowMore.tsx",
5662
+ "purpose": "Long content cut at a height behind a fade, with \"Show more\" under it.",
5663
+ "purposeFrom": "page",
5664
+ "props": [
5665
+ {
5666
+ "name": "children",
5667
+ "takes": "anything",
5668
+ "required": true,
5669
+ "note": null
5670
+ },
5671
+ {
5672
+ "name": "maxHeight",
5673
+ "takes": "number",
5674
+ "required": false,
5675
+ "note": "The height to cut at, in px. Default 96: about seven lines of caption text."
5676
+ },
5677
+ {
5678
+ "name": "surface",
5679
+ "takes": "surface | inset",
5680
+ "required": false,
5681
+ "note": "What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`."
5682
+ },
5683
+ {
5684
+ "name": "className",
5685
+ "takes": "text",
5686
+ "required": false,
5687
+ "note": null
5688
+ }
5689
+ ],
5690
+ "variants": [
5691
+ {
5692
+ "prop": "surface",
5693
+ "values": [
5694
+ "surface",
5695
+ "inset"
5696
+ ]
5697
+ }
5698
+ ],
5699
+ "ownsBehaviours": [],
5700
+ "status": "stable",
5701
+ "migrationStage": null,
5702
+ "docsId": "layout-showmore--docs",
5703
+ "storyId": "layout-showmore--long",
5704
+ "docPage": "src/ShowMore.mdx",
5705
+ "app": null,
5706
+ "looks": [
5707
+ "pointer-events-none bg-gradient-to-t to-transparent from-bg-surface",
5708
+ "pointer-events-none bg-gradient-to-t to-transparent from-bg-inset"
5709
+ ]
5710
+ },
5649
5711
  {
5650
5712
  "name": "Sidebar",
5651
5713
  "repo": "estiva-ui",
@@ -367,6 +367,9 @@ describe('leaving the box', () => {
367
367
  expect(isSuggestionOpen()).toBe(true)
368
368
  await act(async () => other().focus())
369
369
  expect(screen.queryByRole('listbox')).toBeNull()
370
+ // The closing list does not pull focus back into the box that was left.
371
+ await act(async () => new Promise((resolve) => setTimeout(resolve, 50)))
372
+ expect(document.activeElement).toBe(other())
370
373
  // So the other box's own Escape and Enter are its own — no grace either.
371
374
  expect(isSuggestionOpen()).toBe(false)
372
375
  expect(isSuggestionActive()).toBe(false)
@@ -66,7 +66,7 @@ It does these by itself. Write none of them around it.
66
66
 
67
67
  | It does | So you never write | Refused when written by hand |
68
68
  |---|---|---|
69
- | Draws the column's bar: 48px tall, the title 20px in at 14px medium, the actions 16px from the right, a hairline under it | a row of your own with padding and a border | |
69
+ | Draws the column's bar: 48px tall, the title 16px in at 14px medium, the actions 16px from the right, a hairline under it | a row of your own with padding and a border | |
70
70
  | Keeps its 48px in a scrolling column | `shrink-0` | |
71
71
 
72
72
  ## Props
@@ -43,11 +43,12 @@ export function ContainerHeader({ title, chevron = false, titleMenu, actions, cl
43
43
  </div>
44
44
  )
45
45
  return (
46
+ // The title 16px in, where the column's content starts (Katerina, 7 October; it was 20px).
46
47
  // With a title menu the bar gives the button 6px of its left padding
47
- // (pl-3.5 + the button's px-1.5 = pl-5): the words stay where a title's
48
+ // (pl-2.5 + the button's px-1.5 = pl-4): the words stay where a title's
48
49
  // are, and the fill is inside the bar — a negative margin had it clipped
49
50
  // (Katerina, 5 October).
50
- <div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4', titleMenu != null ? 'pl-3.5' : 'pl-5', className)}>
51
+ <div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4', titleMenu != null ? 'pl-2.5' : 'pl-4', className)}>
51
52
  <div className={cn('flex items-center gap-2 overflow-hidden', text ? 'shrink-0' : 'min-w-0 flex-1')}>
52
53
  {titleMenu != null ? (
53
54
  <Menu
@@ -118,7 +118,9 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
118
118
  {/* Header: the package's one header bar, with the dialog's own title in it (UIG-22).
119
119
  The title keeps to its one line and ends in "…": a long name wrapped to
120
120
  two lines in the 48px bar and lost its top (Katerina, 1 October). */}
121
+ {/* `pl-5`: a dialog's title stays 20px in, over its body and footer, which are. */}
121
122
  <ContainerHeader
123
+ className="pl-5"
122
124
  title={
123
125
  headerContent ?? (
124
126
  <Parts.Title className="block truncate text-h4 text-text-primary" render={<span />}>
@@ -60,7 +60,7 @@ import { ListColumn, Toolbar, ToolbarButton } from '@estiva-app/ui'
60
60
  - It sits beside the page's main area, inside AppShell's card: put both in a
61
61
  flex row, the main area `flex-1`.
62
62
  - The numbers: 290px wide, a 1px line on the right. The header is
63
- ContainerHeader's: 48px, the title 20px in. The list starts 16px under the
63
+ ContainerHeader's: 48px, the title 16px in. The list starts 16px under the
64
64
  header and ends 12px above the bottom, and its rows sit 12px in from each
65
65
  side.
66
66
  - The rows are yours. It scrolls them, so a list that grows with its data is
package/src/NavItem.tsx CHANGED
@@ -2,7 +2,7 @@ import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from '
2
2
  import { Collapsible } from '@base-ui/react/collapsible'
3
3
  import { IconAlertSquareRounded, IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
4
4
  import { cn } from './cn'
5
- import { COLLAPSIBLE_PANEL_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
5
+ import { COLLAPSIBLE_PANEL_CLASSES, COUNT_TEXT_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
6
6
  import { IconButton } from './IconButton'
7
7
  import { Menu } from './Menu'
8
8
  import { UnreadDot } from './UnreadDot'
@@ -160,7 +160,8 @@ export function NavItem({
160
160
  2026-09-09). A three-digit count grows the box leftwards. */}
161
161
  <span
162
162
  className={cn(
163
- 'min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted',
163
+ 'min-w-4 shrink-0 text-center',
164
+ COUNT_TEXT_CLASSES,
164
165
  // With a menu and no mark after it, the ⋮ takes this place while it shows.
165
166
  !mark && stepsAside,
166
167
  )}
package/src/Popover.mdx CHANGED
@@ -99,7 +99,9 @@ so it is the one case the caller drives:
99
99
  - **It does not take focus**, on purpose: the person is still in the text, and
100
100
  a toolbar that moved the caret out of it would end the edit it exists to
101
101
  serve. `finalFocus` is therefore only for a panel something inside it *did*
102
- take focus into — a field the person clicked.
102
+ take focus into — a field the person clicked. `finalFocus={false}` moves
103
+ nothing on close: a panel that closes because focus left for somewhere else
104
+ (a type-ahead list whose box was clicked out of) must not pull it back.
103
105
  - **Render it always and toggle `open`.** Not `{rect && <Popover…>}`: mounting
104
106
  the panel only while it is open takes Base UI's state machine with it, and
105
107
  its exits go too — measured, Escape and re-opening both became intermittent.
package/src/Popover.tsx CHANGED
@@ -82,9 +82,10 @@ export interface PopoverProps {
82
82
  * the trigger and this is not needed. An anchored panel never took focus, so
83
83
  * this only matters when something inside it did — a field the person tabbed
84
84
  * or clicked into: point it at what they came from, or focus is left on the
85
- * document body.
85
+ * document body. `false` moves nothing: a panel that closes because focus
86
+ * left for somewhere else must not pull it back (`SuggestionMenu`).
86
87
  */
87
- finalFocus?: RefObject<HTMLElement | null>
88
+ finalFocus?: RefObject<HTMLElement | null> | false
88
89
  /** Base UI's imperative handle. `actions.current?.close()` shuts the panel —
89
90
  * for the Cancel and Save buttons a form panel ends with. */
90
91
  actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
@@ -66,10 +66,12 @@ import { IconPlus } from '@tabler/icons-react'
66
66
  - An action is `{ icon, tooltip, onClick }` — the tooltip doubles as the
67
67
  action's accessible name. The actions sit beside the title button, not
68
68
  inside it, so a click on one never also toggles the section.
69
- - `trailing` is a count beside the title, held on screen while the actions
70
- come and go: a count is information, not an affordance. It sits outside
71
- the title button too, so it is neither part of the toggle's name nor part
72
- of its hit target.
69
+ - `count` is how many there are, 8px after the title, in a tab's count
70
+ style: a heading over a list says its number beside its name.
71
+ - `trailing` sits at the row's end, before the actions, and stays on screen
72
+ while they come and go: a chip, or an unread dot. It is outside the title
73
+ button, so it is neither part of the toggle's name nor part of its hit
74
+ target.
73
75
  - `render` swaps the title's element in Base UI's manner; it is how
74
76
  CollapsibleSection makes the title a `Collapsible.Trigger`.
75
77
 
@@ -10,7 +10,7 @@ const meta = {
10
10
  component: SectionHeader,
11
11
  decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
12
12
  args: { title: 'Section', showActions: 'hover' },
13
- argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false }, trailing: { control: false } },
13
+ argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false }, trailing: { control: false }, count: { control: 'number' } },
14
14
  } satisfies Meta<typeof SectionHeader>
15
15
 
16
16
  export default meta
@@ -67,7 +67,12 @@ export const WithMenu: Story = {
67
67
  },
68
68
  }
69
69
 
70
- /** A count beside the title, held on screen while the actions come and go. */
70
+ /** `count`: how many, right after the title. */
71
+ export const WithCount: Story = {
72
+ args: { count: 2, hover: 'none' },
73
+ }
74
+
75
+ /** `trailing`: a chip at the row's end, held on screen while the actions come and go. */
71
76
  export const WithTrailing: Story = {
72
77
  args: {
73
78
  trailing: <Chip type="brand" label="2" />,
@@ -160,4 +160,10 @@ describe('SectionHeader, the trailing slot', () => {
160
160
  const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} />)
161
161
  expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('has-[[data-unread]]:-mr-1')
162
162
  })
163
+
164
+ it('a count sits right after the title, inside the title, not at the row end', () => {
165
+ render(<SectionHeader title="Section" count={2} />)
166
+ const count = screen.getByText('2')
167
+ expect(count.previousElementSibling?.textContent).toBe('Section')
168
+ })
163
169
  })
@@ -2,7 +2,7 @@ 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'
5
- import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
5
+ import { COUNT_TEXT_CLASSES, 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'
@@ -66,6 +66,12 @@ export interface SectionHeaderProps {
66
66
  * app for want of the slot (ADOPTION B23).
67
67
  */
68
68
  trailing?: ReactNode
69
+ /**
70
+ * How many there are, 8px after the title — not at the row's end, where
71
+ * `trailing` goes (Katerina, 7 October: a heading over a list says its
72
+ * count beside its name, as a tab does).
73
+ */
74
+ count?: number
69
75
  /** Right-aligned, in the order given. */
70
76
  actions?: SectionAction[]
71
77
  /**
@@ -102,7 +108,7 @@ export interface SectionHeaderProps {
102
108
  className?: string
103
109
  }
104
110
 
105
- export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
111
+ export function SectionHeader({ title, chevron = false, icon, isExpanded = true, onToggle, trailing, count, actions, menu, menuLabel, showActions = 'hover', hover = 'fill', look = 'heading', render, className }: SectionHeaderProps) {
106
112
  const row = look === 'row'
107
113
  /*
108
114
  One arrow for everything that folds, a NavItem's 16px, and the title 8px
@@ -179,6 +185,8 @@ export function SectionHeader({ title, chevron = false, icon, isExpanded = true,
179
185
  ) : (
180
186
  <SectionLabel folds={chevron}>{title}</SectionLabel>
181
187
  )}
188
+ {/* 8px after the title with the 4px gap, in a row's count style. */}
189
+ {count != null && <span className={cn('ml-1 shrink-0', COUNT_TEXT_CLASSES)}>{count}</span>}
182
190
  </>
183
191
  ),
184
192
  },
@@ -0,0 +1,62 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ShowMoreStories from './ShowMore.stories'
3
+
4
+ <Meta of={ShowMoreStories} />
5
+
6
+ # ShowMore
7
+
8
+ Long content cut at a height behind a fade, with "Show more" under it.
9
+
10
+ <Canvas of={ShowMoreStories.Long} />
11
+
12
+ ## When
13
+
14
+ - A block of text that can run long beside other things the reader needs:
15
+ a description above controls, a summary above a list. Cut, it keeps what
16
+ is under it in view.
17
+ - Content whose length you do not know. It measures: what fits is drawn
18
+ whole, with no fade and no button.
19
+
20
+ <Canvas of={ShowMoreStories.Short} />
21
+
22
+ ## When not
23
+
24
+ - A whole section that opens and closes under its heading → **CollapsibleSection**.
25
+ - A message or reply in a conversation: read it whole.
26
+ - Content the reader came for, the page's own subject: show it all.
27
+ - A preview of something that lives elsewhere, on hover → **PreviewCard**.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { ShowMore } from '@estiva-app/ui'
33
+
34
+ <ShowMore surface="inset">
35
+ <p className="text-caption text-text-secondary">{description}</p>
36
+ </ShowMore>
37
+ ```
38
+
39
+ - Wrap only the part that runs long. Keep titles and controls outside it, so
40
+ they are never the part that is cut.
41
+ - `surface` is what the content sits on, for the fade to end in: `surface`
42
+ (a panel, the default) or `inset` (inside an inset card). A fade into the
43
+ wrong colour shows as a band.
44
+ - `maxHeight` is in px, 96 by default.
45
+
46
+ <Canvas of={ShowMoreStories.InACard} />
47
+
48
+ ## What it owns
49
+
50
+ It does these by itself. Write none of them around it.
51
+
52
+ | It does | So you never write | Refused when written by hand |
53
+ |---|---|---|
54
+ | Measures the content, and again whenever it changes size, to decide whether to cut | a length check on the text, or a `line-clamp` | |
55
+ | Draws the fade and the "Show more" / "Show less" button, which says whether it is open (`aria-expanded`) | a gradient and a toggle of your own | |
56
+ | Opens when focus arrives inside the cut part: a field or link there is never used out of sight | a focus handler that opens it | |
57
+
58
+ The cut content stays in the page, so assistive technology reads all of it.
59
+
60
+ ## Props
61
+
62
+ <Controls of={ShowMoreStories.Long} />
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Card } from './Card'
3
+ import { ShowMore } from './ShowMore'
4
+
5
+ const LONG = Array.from(
6
+ { length: 6 },
7
+ (_, i) => `Paragraph ${i + 1}. A long description written out in full, the way a real brief is, so that it runs past the height the box allows and needs cutting.`,
8
+ ).join(' ')
9
+
10
+ /** Long content cut behind a fade, with "Show more" under it. Short content is drawn whole. */
11
+ const meta = {
12
+ title: 'Layout/ShowMore',
13
+ component: ShowMore,
14
+ decorators: [(Story) => <div className="w-[320px]"><Story /></div>],
15
+ args: { children: <p className="text-caption text-text-secondary">{LONG}</p> },
16
+ argTypes: { children: { control: false } },
17
+ } satisfies Meta<typeof ShowMore>
18
+
19
+ export default meta
20
+ type Story = StoryObj<typeof meta>
21
+
22
+ /** Taller than 96px: cut, faded into the surface, "Show more" under it. */
23
+ export const Long: Story = {}
24
+
25
+ /** No taller than the limit: drawn whole, no fade, no button. */
26
+ export const Short: Story = {
27
+ args: { children: <p className="text-caption text-text-secondary">One line of description.</p> },
28
+ }
29
+
30
+ /** Inside an inset card: `surface="inset"`, so the fade ends in the card's fill. */
31
+ export const InACard: Story = {
32
+ args: { surface: 'inset' },
33
+ render: (args) => (
34
+ <Card fill="inset" className="p-3">
35
+ <p className="text-body-2-strong text-text-primary">Title</p>
36
+ <ShowMore {...args} className="mt-1" />
37
+ </Card>
38
+ ),
39
+ }
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The page's claims: content taller than the limit is cut with "Show more",
4
+ * which opens it and says so; content that fits has no button at all.
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { cleanup, render, screen } from '@testing-library/react'
8
+ import userEvent from '@testing-library/user-event'
9
+ import { ShowMore } from './ShowMore'
10
+
11
+ afterEach(() => {
12
+ cleanup()
13
+ vi.restoreAllMocks()
14
+ })
15
+
16
+ /** jsdom lays nothing out: every box says it is this tall. */
17
+ const contentHeight = (px: number) => vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(px)
18
+
19
+ describe('ShowMore', () => {
20
+ it('cuts content taller than the limit, and opens it', async () => {
21
+ contentHeight(300)
22
+ render(<ShowMore maxHeight={96}><p>Long</p></ShowMore>)
23
+ const button = screen.getByRole('button', { name: 'Show more' })
24
+ expect(button.getAttribute('aria-expanded')).toBe('false')
25
+ expect(screen.getByText('Long').parentElement!.style.maxHeight).toBe('96px')
26
+ await userEvent.click(button)
27
+ expect(screen.getByRole('button', { name: 'Show less' }).getAttribute('aria-expanded')).toBe('true')
28
+ expect(screen.getByText('Long').parentElement!.style.maxHeight).toBe('')
29
+ })
30
+
31
+ it('opens when focus arrives inside the cut content', async () => {
32
+ contentHeight(300)
33
+ render(<ShowMore maxHeight={96}><input aria-label="Field" /></ShowMore>)
34
+ await userEvent.click(screen.getByRole('textbox', { name: 'Field' }))
35
+ expect(screen.getByRole('button', { name: 'Show less' })).toBeTruthy()
36
+ })
37
+
38
+ it('draws content that fits whole, with no button', () => {
39
+ contentHeight(40)
40
+ render(<ShowMore maxHeight={96}><p>Short</p></ShowMore>)
41
+ expect(screen.queryByRole('button')).toBeNull()
42
+ expect(screen.getByText('Short').parentElement!.style.maxHeight).toBe('')
43
+ })
44
+ })
@@ -0,0 +1,63 @@
1
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { Button } from './Button'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * Long content cut at a height behind a fade, with "Show more" under it
7
+ * (Katerina, 7 October: a long description on a side panel pushed what was
8
+ * under it out of sight).
9
+ *
10
+ * Content no taller than `maxHeight` is drawn whole, with no fade and no
11
+ * button: the cut is for what would not fit, never a look. The height is
12
+ * measured, and measured again whenever the content changes size, so an
13
+ * edit that makes it long or short brings the button or takes it away.
14
+ *
15
+ * The fade ends in the surface the content sits on, which only the caller
16
+ * knows: `surface`, by name, so no app writes a colour into the part.
17
+ *
18
+ * Focus arriving inside the cut content opens it: a field or a link there,
19
+ * reached by Tab or a click, is never worked on out of sight.
20
+ */
21
+ export interface ShowMoreProps {
22
+ children: ReactNode
23
+ /** The height to cut at, in px. Default 96: about seven lines of caption text. */
24
+ maxHeight?: number
25
+ /** What the content sits on, for the fade to end in: a panel's `surface` (default), or an inset card's `inset`. */
26
+ surface?: 'surface' | 'inset'
27
+ className?: string
28
+ }
29
+
30
+ const FADE_CLASSES = { surface: 'from-bg-surface', inset: 'from-bg-inset' } as const
31
+
32
+ export function ShowMore({ children, maxHeight = 96, surface = 'surface', className }: ShowMoreProps) {
33
+ const box = useRef<HTMLDivElement>(null)
34
+ const [open, setOpen] = useState(false)
35
+ const [overflows, setOverflows] = useState(false)
36
+
37
+ useLayoutEffect(() => {
38
+ const el = box.current
39
+ if (!el) return
40
+ const measure = () => setOverflows(el.scrollHeight > maxHeight + 1)
41
+ measure()
42
+ if (typeof ResizeObserver === 'undefined') return
43
+ const observer = new ResizeObserver(measure)
44
+ observer.observe(el)
45
+ return () => observer.disconnect()
46
+ }, [maxHeight])
47
+
48
+ const cut = overflows && !open
49
+ return (
50
+ <div className={cn('flex flex-col items-start', className)}>
51
+ <div ref={box} className={cn('relative w-full', cut && 'overflow-hidden')} style={cut ? { maxHeight } : undefined} onFocus={cut ? () => setOpen(true) : undefined}>
52
+ {children}
53
+ {cut && <div aria-hidden className={cn('pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t to-transparent', FADE_CLASSES[surface])} />}
54
+ </div>
55
+ {overflows && (
56
+ // `-ml-1.5`: the small button's own 6px inset, so its words start where the content's do.
57
+ <Button variant="muted" size="small" className="-ml-1.5 mt-1" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
58
+ {open ? 'Show less' : 'Show more'}
59
+ </Button>
60
+ )}
61
+ </div>
62
+ )
63
+ }
@@ -186,7 +186,8 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
186
186
  }
187
187
 
188
188
  return (
189
- <Popover anchor={rect} open={rect !== null} side="top" align="left" className={width} maxHeight={maxHeight} ariaLabel={ariaLabel}>
189
+ // finalFocus={false}: focus never left the text, and a list that closes because focus moved elsewhere must not pull it back (71a4577b).
190
+ <Popover anchor={rect} open={rect !== null} side="top" align="left" className={width} maxHeight={maxHeight} ariaLabel={ariaLabel} finalFocus={false}>
190
191
  {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own.
191
192
  `gap-0.5`: 2px between rows, as a menu's — a section with a heading
192
193
  had them from `MenuSection`, and rows without one touched. */}
@@ -151,6 +151,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
151
151
  RichText: ['size'],
152
152
  SectionHeader: ['hover', 'look'],
153
153
  SectionLabel: ['tone', 'truncate'],
154
+ ShowMore: ['surface'],
154
155
  Select: ['size'],
155
156
  Tabs: ['size'],
156
157
  TextInput: ['size'],
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export { NavTree, NavTreeSection, type NavTreeGroup, type NavTreeNode, type NavT
76
76
  export { Popover, type PopoverProps } from './Popover'
77
77
  export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
78
78
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
79
+ export { ShowMore, type ShowMoreProps } from './ShowMore'
79
80
  export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
80
81
  export { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps, type ToolbarLinkProps } from './Toolbar'
81
82
  export { PreviewCard, type PreviewCardProps } from './PreviewCard'
package/src/looks.ts CHANGED
@@ -37,6 +37,9 @@ export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shado
37
37
  /** A panel that floats over the page: Menu's panel and Tooltip. */
38
38
  export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'
39
39
 
40
+ /** A count beside a label, in the muted colour: NavItem's number and SectionHeader's `count`. */
41
+ export const COUNT_TEXT_CLASSES = 'font-mono text-caption tabular-nums text-text-muted'
42
+
40
43
  /** A chip's words: Chip's label and Reaction's count. */
41
44
  export const CHIP_TEXT_CLASSES = 'text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums'
42
45
 
@@ -48,19 +48,19 @@ describe('the registry builds', () => {
48
48
  expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
49
49
  })
50
50
 
51
- it('counts 130 names over 69 files: 100 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, and moveBlock and blockDropCursor, the drop in the gutter, on 5 October)', () => {
51
+ it('counts 131 names over 70 files: 101 components, 29 helpers and 1 hook (NavTree, NavTreeSection, the composer triggers and UnreadDot joined on 2 October; BlockHandle and EditorBlockHandle (RIC-18) on 2 October, moveBlock and blockDropCursor, the drop in the gutter, on 5 October, and ShowMore on 7 October)', () => {
52
52
  // The reconciliation GATES.md §24 explains. If this changes, that changes.
53
53
  // The ten `EDITOR_EXTENSIONS` count as components by their names; they are
54
54
  // editor extensions, documented on SelectionToolbar's, BlockNodes' and
55
55
  // ComposerTriggers' pages.
56
56
  const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
57
- expect(kinds).toEqual({ component: 100, helper: 29, hook: 1 })
58
- expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(69)
57
+ expect(kinds).toEqual({ component: 101, helper: 29, hook: 1 })
58
+ expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(70)
59
59
  })
60
60
 
61
61
  it('gives every entry a purpose, from its own page or from the comment above it', () => {
62
62
  expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
63
- expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(65)
63
+ expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(66)
64
64
  expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(65)
65
65
  })
66
66
 
@@ -83,10 +83,11 @@ fork freely, owe nothing back, mention it on the package's ticket.
83
83
  | Label–value rows (a details rail) | **Property** — `row` and `stacked` |
84
84
  | The bar across the top of a column: its title, and the buttons that act on the whole column | **ContainerHeader** — inside the package's columns; in an app, the header comes with **Panel** or **ListColumn** |
85
85
  | A box that can hold more than fits — a list that grows with its data counts, even when today's data fits | **ScrollArea** — its bar is drawn over the content and takes no width |
86
+ | Text that can run long above things the reader needs, cut behind a fade | **ShowMore** — measures; what fits has no button |
86
87
  | A status, a count or a category, in a small pill | **Chip** — six colour types; the colour is the message, the label makes it legible |
87
88
  | Something new on a row or a card, no count | **UnreadDot** — the 6px dot in a 24px slot; in a sidebar row, **NavItem** `unread` draws it |
88
89
  | One thing — a conversation, a project, an object — as a box you can pick | **Card** — the fill by what it sits on; `href` for a link, `hover="fill"` in a feed |
89
- | A section heading, chevron, hover actions | **SectionHeader** |
90
+ | A section heading, chevron, hover actions, a count beside the title | **SectionHeader** |
90
91
  | A group of rows that opens and closes, and stays how you left it | **CollapsibleSection** — `storageKey` remembers |
91
92
  | The uppercase micro-label | **SectionLabel** |
92
93
  | A hairline | **Divider** |