@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.
- package/dist/ContainerHeader.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/Popover.d.ts +3 -2
- package/dist/Popover.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/ShowMore.d.ts +27 -0
- package/dist/ShowMore.d.ts.map +1 -0
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/eslint/index.js +1 -0
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -89
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +2 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +66 -4
- package/src/ComposerTriggers.test.tsx +3 -0
- package/src/ContainerHeader.mdx +1 -1
- package/src/ContainerHeader.tsx +3 -2
- package/src/DialogShell.tsx +2 -0
- package/src/ListColumn.mdx +1 -1
- package/src/NavItem.tsx +3 -2
- package/src/Popover.mdx +3 -1
- package/src/Popover.tsx +3 -2
- package/src/SectionHeader.mdx +6 -4
- package/src/SectionHeader.stories.tsx +7 -2
- package/src/SectionHeader.test.tsx +6 -0
- package/src/SectionHeader.tsx +10 -2
- package/src/ShowMore.mdx +62 -0
- package/src/ShowMore.stories.tsx +39 -0
- package/src/ShowMore.test.tsx +44 -0
- package/src/ShowMore.tsx +63 -0
- package/src/SuggestionMenu.tsx +2 -1
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/index.ts +1 -0
- package/src/looks.ts +3 -0
- package/src/registry/registry.test.ts +4 -4
- 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. */
|
package/dist/looks.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA;AAE7G,4IAA4I;AAE5I,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
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
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)
|
package/src/ContainerHeader.mdx
CHANGED
|
@@ -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
|
|
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
|
package/src/ContainerHeader.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
package/src/DialogShell.tsx
CHANGED
|
@@ -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 />}>
|
package/src/ListColumn.mdx
CHANGED
|
@@ -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
|
|
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
|
|
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>
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -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
|
-
- `
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
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
|
})
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -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
|
},
|
package/src/ShowMore.mdx
ADDED
|
@@ -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
|
+
})
|
package/src/ShowMore.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/SuggestionMenu.tsx
CHANGED
|
@@ -186,7 +186,8 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
return (
|
|
189
|
-
|
|
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
|
|
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:
|
|
58
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
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(
|
|
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
|
|
package/stories/Choosing.mdx
CHANGED
|
@@ -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** |
|