@estiva-app/ui 0.23.0 → 0.24.1
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/AppShell.d.ts.map +1 -1
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Form.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/ListColumn.d.ts +41 -0
- package/dist/ListColumn.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +9 -2
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +7 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts +12 -3
- package/dist/Select.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +18 -0
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +13 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/eslint/index.js +2 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +189 -143
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +214 -11
- package/src/AppShell.mdx +18 -1
- package/src/AppShell.test.tsx +16 -0
- package/src/AppShell.tsx +4 -1
- package/src/AttachmentCard.mdx +12 -1
- package/src/AttachmentCard.test.tsx +10 -0
- package/src/AttachmentCard.tsx +8 -5
- package/src/Avatar.mdx +20 -5
- package/src/AvatarGroup.mdx +4 -0
- package/src/Banner.mdx +17 -8
- package/src/Breadcrumb.mdx +12 -0
- package/src/Button.mdx +17 -5
- package/src/Button.tsx +2 -2
- package/src/Card.mdx +14 -1
- package/src/Checkbox.mdx +11 -0
- package/src/Chip.mdx +4 -0
- package/src/ChipInput.mdx +13 -1
- package/src/CollapsibleSection.mdx +12 -3
- package/src/CommandPalette.mdx +14 -2
- package/src/ConfirmDialog.mdx +13 -2
- package/src/ContainerHeader.mdx +15 -2
- package/src/DialogShell.mdx +15 -1
- package/src/Divider.mdx +12 -1
- package/src/EditableText.mdx +17 -1
- package/src/EmptyState.mdx +17 -6
- package/src/Field.mdx +13 -5
- package/src/FieldLine.mdx +9 -1
- package/src/FilePicker.mdx +8 -0
- package/src/Form.mdx +11 -1
- package/src/Form.test.tsx +26 -0
- package/src/Form.tsx +7 -0
- package/src/IconButton.mdx +17 -3
- package/src/IconButton.stories.tsx +3 -0
- package/src/IconButton.test.tsx +25 -0
- package/src/IconButton.tsx +61 -46
- package/src/IdentityMenu.mdx +10 -1
- package/src/InlineChip.mdx +11 -0
- package/src/Kbd.mdx +6 -2
- package/src/Link.mdx +13 -1
- package/src/ListColumn.mdx +87 -0
- package/src/ListColumn.stories.tsx +136 -0
- package/src/ListColumn.test.tsx +50 -0
- package/src/ListColumn.tsx +63 -0
- package/src/Menu.mdx +18 -1
- package/src/MenuItem.mdx +15 -2
- package/src/NavItem.mdx +12 -0
- package/src/Person.mdx +7 -0
- package/src/PersonTrigger.mdx +11 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Popover.mdx +23 -0
- package/src/PreviewCard.mdx +19 -4
- package/src/PreviewCard.tsx +11 -4
- package/src/ProgressBar.mdx +8 -0
- package/src/Property.mdx +4 -0
- package/src/Rail.mdx +12 -1
- package/src/RailItem.mdx +12 -0
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +10 -0
- package/src/Reaction.tsx +1 -1
- package/src/ReactionPicker.mdx +8 -0
- package/src/ScrollArea.mdx +16 -2
- package/src/SearchInput.mdx +9 -0
- package/src/SectionHeader.mdx +18 -1
- package/src/SectionHeader.stories.tsx +9 -0
- package/src/SectionHeader.test.tsx +9 -0
- package/src/SectionHeader.tsx +9 -3
- package/src/SectionLabel.mdx +10 -2
- package/src/Select.mdx +24 -4
- package/src/Select.stories.tsx +4 -1
- package/src/Select.test.tsx +29 -0
- package/src/Select.tsx +36 -14
- package/src/Sidebar.mdx +8 -0
- package/src/Skeleton.mdx +8 -0
- package/src/Skeleton.tsx +1 -1
- package/src/Tabs.mdx +12 -1
- package/src/TextInput.mdx +9 -0
- package/src/TextInput.tsx +1 -1
- package/src/Textarea.mdx +8 -0
- package/src/Toast.mdx +12 -1
- package/src/Toolbar.mdx +20 -2
- package/src/Toolbar.stories.tsx +19 -2
- package/src/Toolbar.test.tsx +24 -1
- package/src/Toolbar.tsx +22 -0
- package/src/Tooltip.mdx +27 -2
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +51 -0
- package/src/Tooltip.tsx +17 -4
- package/src/TopBar.mdx +11 -0
- package/src/eslint/no-rebuilt-behaviour.ts +1 -1
- package/src/eslint/no-restyled-part.ts +1 -0
- package/src/heights.test.tsx +78 -0
- package/src/index.ts +2 -1
- package/src/pages.test.ts +142 -0
- package/src/registry/registry.test.ts +16 -8
- package/tailwind-preset.js +22 -1
package/src/IconButton.test.tsx
CHANGED
|
@@ -28,6 +28,31 @@ describe('IconButton', () => {
|
|
|
28
28
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
it('href: a link that looks like the button, with its tooltip', async () => {
|
|
32
|
+
const user = userEvent.setup()
|
|
33
|
+
render(
|
|
34
|
+
<IconButton href="/documents/12" aria-label="Open" tooltip="Open">
|
|
35
|
+
{icon}
|
|
36
|
+
</IconButton>,
|
|
37
|
+
)
|
|
38
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
39
|
+
const link = screen.getByRole('link', { name: 'Open' })
|
|
40
|
+
expect(link.getAttribute('href')).toBe('/documents/12')
|
|
41
|
+
expect(link.className).toContain('rounded-lg')
|
|
42
|
+
await user.hover(link)
|
|
43
|
+
expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('href while it cannot be used: the button, since a link cannot be disabled', () => {
|
|
47
|
+
render(
|
|
48
|
+
<IconButton href="/documents/12" aria-label="Open" disabledReason="Read only">
|
|
49
|
+
{icon}
|
|
50
|
+
</IconButton>,
|
|
51
|
+
)
|
|
52
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
53
|
+
expect(screen.getByRole('button', { name: 'Open' }).getAttribute('aria-disabled')).toBe('true')
|
|
54
|
+
})
|
|
55
|
+
|
|
31
56
|
it('shows the tooltip on hover, with its shortcut', async () => {
|
|
32
57
|
const user = userEvent.setup()
|
|
33
58
|
render(
|
package/src/IconButton.tsx
CHANGED
|
@@ -37,6 +37,14 @@ export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
|
|
|
37
37
|
/** Why the action cannot succeed right now. Disables the button, keeps it
|
|
38
38
|
* reachable by keyboard, and shows the reason as the tooltip. */
|
|
39
39
|
disabledReason?: string
|
|
40
|
+
/**
|
|
41
|
+
* A link that looks like an icon button: an anchor, so the address stays
|
|
42
|
+
* real for a modified click or a new tab, as `Link` keeps it. A router app
|
|
43
|
+
* passes `onClick`, prevents the default and navigates. A link cannot be
|
|
44
|
+
* disabled, so while it would be — `disabled`, `disabledReason`, a busy
|
|
45
|
+
* Form — it is the button (UIG-14, Katerina, 18 September).
|
|
46
|
+
*/
|
|
47
|
+
href?: string
|
|
40
48
|
/** The icon: 16px, stroke 1.5. */
|
|
41
49
|
children: ReactNode
|
|
42
50
|
}
|
|
@@ -53,56 +61,63 @@ export function IconButton({
|
|
|
53
61
|
tooltipShortcut,
|
|
54
62
|
tooltipPlacement,
|
|
55
63
|
type = 'button',
|
|
64
|
+
href,
|
|
56
65
|
...props
|
|
57
66
|
}: IconButtonProps) {
|
|
58
67
|
// Inside a busy Form: switched off, and looking it (formBusy.ts).
|
|
59
68
|
const formBusy = useFormBusy()
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
69
|
+
const unusable = disabled || !!disabledReason || formBusy
|
|
70
|
+
const look = (off: boolean) =>
|
|
71
|
+
cn(
|
|
72
|
+
// `shrink-0` stops a flex parent squashing the button; `self-center`
|
|
73
|
+
// stops one stretching it. Two different failures, and both have
|
|
74
|
+
// happened here: a row with no `items-*` drew this 24 wide and 228
|
|
75
|
+
// tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button
|
|
76
|
+
// is the size of its icon and its padding, whatever box it lands in.
|
|
77
|
+
//
|
|
78
|
+
// This is the one of the two that needs it: an IconButton states no
|
|
79
|
+
// height, so its cross size is `auto` and a stretching parent takes
|
|
80
|
+
// it. Measured in the same 260px row on 2026-09-12: 24px with this
|
|
81
|
+
// class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be
|
|
82
|
+
// stretched and carries no `self-center` — see the note there.
|
|
83
|
+
'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
|
|
84
|
+
!off && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
|
|
85
|
+
!off && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
|
|
86
|
+
!off && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
|
|
87
|
+
!off && variant === 'current' && 'hover:bg-bg-hover',
|
|
88
|
+
off && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
|
|
89
|
+
off && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled',
|
|
90
|
+
off && variant === 'outlined' && 'border border-border-default text-text-disabled',
|
|
91
|
+
// Always, disabled too: the colour of where it sits, and Resolve's green.
|
|
92
|
+
variant === 'current' && 'text-current',
|
|
93
|
+
variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default',
|
|
94
|
+
pressed && 'bg-bg-active text-text-primary',
|
|
95
|
+
glow && 'signal:shadow-glow-accent',
|
|
96
|
+
// `pointer-events-none` only where the button is truly out of reach:
|
|
97
|
+
// with a `disabledReason` the button IS the tooltip's trigger, and a
|
|
98
|
+
// trigger the pointer cannot land on never opens one. Base UI already
|
|
99
|
+
// swallows the click.
|
|
100
|
+
off && !disabledReason && 'pointer-events-none',
|
|
101
|
+
off && 'cursor-not-allowed',
|
|
102
|
+
className,
|
|
103
|
+
)
|
|
104
|
+
const button =
|
|
105
|
+
href && !unusable ? (
|
|
106
|
+
<a href={href} className={look(false)} {...(props as unknown as ComponentPropsWithRef<'a'>)}>
|
|
107
|
+
{children}
|
|
108
|
+
</a>
|
|
109
|
+
) : (
|
|
110
|
+
<BaseButton
|
|
111
|
+
type={type}
|
|
112
|
+
disabled={unusable}
|
|
113
|
+
focusableWhenDisabled={!!disabledReason}
|
|
114
|
+
className={(state) => look(state.disabled)}
|
|
115
|
+
aria-pressed={pressed}
|
|
116
|
+
{...props}
|
|
117
|
+
>
|
|
118
|
+
{children}
|
|
119
|
+
</BaseButton>
|
|
120
|
+
)
|
|
106
121
|
|
|
107
122
|
const label = disabledReason ?? tooltip
|
|
108
123
|
if (label) {
|
package/src/IdentityMenu.mdx
CHANGED
|
@@ -62,10 +62,19 @@ import { IdentityMenu } from '@estiva-app/ui'
|
|
|
62
62
|
row's full height, and the panel hung from the bottom of *that* — measured
|
|
63
63
|
360px below the face, with a scrollbar it should not have had.
|
|
64
64
|
|
|
65
|
+
## What it owns
|
|
66
|
+
|
|
67
|
+
It does these by itself. Write none of them around it.
|
|
68
|
+
|
|
69
|
+
| It does | So you never write | Refused when written by hand |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Everything a Menu owns: it floats, stays attached, closes on a press outside or on Escape, and walks its rows with the arrow keys | a menu of your own around the face | |
|
|
72
|
+
| Gives focus back to the face when it closes | focus code | |
|
|
73
|
+
|
|
65
74
|
## Keys
|
|
66
75
|
|
|
67
76
|
Since stage 4 the panel is a Base UI `Menu`, and **the arrow keys walk the
|
|
68
|
-
actions only
|
|
77
|
+
actions only**: they step over the identity block, the
|
|
69
78
|
workspace line and the notes, because those are not things you can do.
|
|
70
79
|
|
|
71
80
|
| Input | What happens |
|
package/src/InlineChip.mdx
CHANGED
|
@@ -51,6 +51,17 @@ import { InlineChip } from '@estiva-app/ui'
|
|
|
51
51
|
- A rich-text editor that renders chips from strings cannot use a component.
|
|
52
52
|
`inlineChipClassName(tone)` gives it the same classes.
|
|
53
53
|
|
|
54
|
+
- `INLINE_CHIP_CLASSES` and `INLINE_CHIP_TONE_CLASSES` give the same look where
|
|
55
|
+
only classes can go — inside an editor.
|
|
56
|
+
|
|
57
|
+
## What it owns
|
|
58
|
+
|
|
59
|
+
It does these by itself. Write none of them around it.
|
|
60
|
+
|
|
61
|
+
| It does | So you never write | Refused when written by hand |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| With `href`, keeps a real address, so a modified click or a new tab still works | a `<span>` with a click handler | |
|
|
64
|
+
|
|
54
65
|
## Props
|
|
55
66
|
|
|
56
67
|
<Controls of={InlineChipStories.Neutral} />
|
package/src/Kbd.mdx
CHANGED
|
@@ -41,8 +41,8 @@ Geist Mono owns `←` and `→` but draws them 10px wide, so a chip holding one
|
|
|
41
41
|
comes out wider than the chips beside it. Measure before adding a symbol to
|
|
42
42
|
this list; the font's coverage is not uniform.
|
|
43
43
|
|
|
44
|
-
And a trigger is the character you would actually type.
|
|
45
|
-
|
|
44
|
+
And a trigger is the character you would actually type. A shortcut that fires
|
|
45
|
+
on `->` shows `->` — there is no `→` key to press.
|
|
46
46
|
|
|
47
47
|
## When not
|
|
48
48
|
|
|
@@ -72,6 +72,10 @@ It renders what it is given and formats nothing. A modifier is called Cmd on
|
|
|
72
72
|
Apple platforms and Ctrl elsewhere, and only the caller knows which it is
|
|
73
73
|
looking at — so decide there and pass the finished label.
|
|
74
74
|
|
|
75
|
+
## What it owns
|
|
76
|
+
|
|
77
|
+
Nothing. It only draws. Clicks and keys are yours.
|
|
78
|
+
|
|
75
79
|
## Props
|
|
76
80
|
|
|
77
81
|
<Controls of={KbdStories.Default} />
|
package/src/Link.mdx
CHANGED
|
@@ -12,6 +12,8 @@ the app decides how it navigates.
|
|
|
12
12
|
|
|
13
13
|
## When
|
|
14
14
|
|
|
15
|
+
- **It takes the size and colour of the text around it**: put it inside that
|
|
16
|
+
text.
|
|
15
17
|
- **`text`** — a link written inside text: the info colour, always
|
|
16
18
|
underlined, dimming on hover.
|
|
17
19
|
- **`quiet`** — a title or a time that is also a link. It takes the colour and
|
|
@@ -28,6 +30,7 @@ the app decides how it navigates.
|
|
|
28
30
|
- An action — something happens, nothing is navigated to → **Button**.
|
|
29
31
|
- A row of a sidebar → **NavItem**. A tile of a rail → **RailItem**.
|
|
30
32
|
- The trail back up → **Breadcrumb**.
|
|
33
|
+
- A link that looks like an icon button → **IconButton** with `href`.
|
|
31
34
|
- A word standing for a person or a thing, drawn in a small box → **InlineChip**
|
|
32
35
|
with `href`.
|
|
33
36
|
|
|
@@ -58,7 +61,16 @@ import { Link } from '@estiva-app/ui'
|
|
|
58
61
|
Its size and colour still come from the text around it.
|
|
59
62
|
- `quiet`, `underlined` and `plain` take their size and colour from where they
|
|
60
63
|
sit, so put those on your own element around the link, not on the link —
|
|
61
|
-
the lint refuses a look passed in `className
|
|
64
|
+
the lint refuses a look passed in `className`.
|
|
65
|
+
|
|
66
|
+
## What it owns
|
|
67
|
+
|
|
68
|
+
It does these by itself. Write none of them around it.
|
|
69
|
+
|
|
70
|
+
| It does | So you never write | Refused when written by hand |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| Says what it is to assistive technology: a link | `role="link"` | ✓ |
|
|
73
|
+
| Is reachable with Tab | a `tabIndex` | ✓ |
|
|
62
74
|
|
|
63
75
|
## Props
|
|
64
76
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ListColumnStories from './ListColumn.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ListColumnStories} />
|
|
5
|
+
|
|
6
|
+
# ListColumn
|
|
7
|
+
|
|
8
|
+
The list column of a page: 290px wide with a line on its right, its name in a
|
|
9
|
+
`ContainerHeader`, and the list under it, which scrolls.
|
|
10
|
+
|
|
11
|
+
<Canvas of={ListColumnStories.Default} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **Its size is its own**: 290px wide and the full height of where it sits.
|
|
16
|
+
Nothing around it sets its width, and nothing squeezes it.
|
|
17
|
+
- A page that lists things beside the one that is open: the list on the left,
|
|
18
|
+
the chosen thing to its right.
|
|
19
|
+
- `spacing="rows"` (the default, 2px between rows) for a list of one kind of
|
|
20
|
+
row. `spacing="sections"` (4px) for groups with labels and dividers of their
|
|
21
|
+
own.
|
|
22
|
+
- `above` for a row that stays put while the list scrolls: a field that adds
|
|
23
|
+
to the list.
|
|
24
|
+
- `collapsed` closes it with the rail: it narrows to nothing and fades, in
|
|
25
|
+
300ms. What opens and closes the rail is yours.
|
|
26
|
+
|
|
27
|
+
<Canvas of={ListColumnStories.Sections} />
|
|
28
|
+
|
|
29
|
+
<Canvas of={ListColumnStories.WithARowAbove} />
|
|
30
|
+
|
|
31
|
+
## When not
|
|
32
|
+
|
|
33
|
+
- The app's own places, as the frame's column → **Sidebar**, in AppShell's
|
|
34
|
+
solid frame.
|
|
35
|
+
- A short list inside a panel or a card → your rows in a **ScrollArea**.
|
|
36
|
+
- A bar over something that is not a list → **ContainerHeader** on its own.
|
|
37
|
+
|
|
38
|
+
## How
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { ListColumn, Toolbar, ToolbarButton } from '@estiva-app/ui'
|
|
42
|
+
|
|
43
|
+
<ListColumn
|
|
44
|
+
title="Items"
|
|
45
|
+
actions={
|
|
46
|
+
<Toolbar aria-label="Item list actions" surface={false}>
|
|
47
|
+
<ToolbarButton tooltip="New item" aria-label="New item" onClick={create}>
|
|
48
|
+
<IconEdit size={16} stroke={1.5} />
|
|
49
|
+
</ToolbarButton>
|
|
50
|
+
</Toolbar>
|
|
51
|
+
}
|
|
52
|
+
collapsed={railClosed}
|
|
53
|
+
>
|
|
54
|
+
{items.map((item) => (
|
|
55
|
+
<ItemRow key={item.id} item={item} />
|
|
56
|
+
))}
|
|
57
|
+
</ListColumn>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- It sits beside the page's main area, inside AppShell's card: put both in a
|
|
61
|
+
flex row, the main area `flex-1`.
|
|
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
|
|
64
|
+
header and ends 12px above the bottom, and its rows sit 12px in from each
|
|
65
|
+
side.
|
|
66
|
+
- The rows are yours. It scrolls them, so a list that grows with its data is
|
|
67
|
+
never cut off at the fold, even when today's data fits.
|
|
68
|
+
- **Planned:** dragging its edge to resize it, with `SplitLayout` in
|
|
69
|
+
`@estiva-app/ui/layout` (migration stage 8). The 380px column beside a
|
|
70
|
+
conversation gets the same treatment then.
|
|
71
|
+
|
|
72
|
+
<Canvas of={ListColumnStories.ManyRows} />
|
|
73
|
+
|
|
74
|
+
## What it owns
|
|
75
|
+
|
|
76
|
+
It does these by itself. Write none of them around it.
|
|
77
|
+
|
|
78
|
+
| It does | So you never write | Refused when written by hand |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| Draws the column: 290px, a line on its right, and a ContainerHeader on top | a width, a border and a header row of your own | |
|
|
81
|
+
| Scrolls its rows in a ScrollArea, 16px under the header, 12px in from each side | `overflow-auto`, or padding of your own around the rows | |
|
|
82
|
+
| Closes with the rail when `collapsed`: narrows to nothing and fades | a width transition of your own | |
|
|
83
|
+
| Keeps its 290px beside a main area that grows | `shrink-0` | |
|
|
84
|
+
|
|
85
|
+
## Props
|
|
86
|
+
|
|
87
|
+
<Controls of={ListColumnStories.Default} />
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSortDescending, IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { Divider } from './Divider'
|
|
6
|
+
import { Form } from './Form'
|
|
7
|
+
import { ListColumn } from './ListColumn'
|
|
8
|
+
import { NavItem } from './NavItem'
|
|
9
|
+
import { SectionLabel } from './SectionLabel'
|
|
10
|
+
import { SkeletonList } from './Skeleton'
|
|
11
|
+
import { TextInput } from './TextInput'
|
|
12
|
+
import { Toolbar, ToolbarButton } from './Toolbar'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The list column of a page: 290px, a line on its right, a header, and a list
|
|
16
|
+
* that scrolls. The rows are the app's own; these stories draw `NavItem`s as
|
|
17
|
+
* stand-ins.
|
|
18
|
+
*/
|
|
19
|
+
const meta = {
|
|
20
|
+
title: 'Layout/ListColumn',
|
|
21
|
+
component: ListColumn,
|
|
22
|
+
parameters: { layout: 'fullscreen' },
|
|
23
|
+
args: { title: 'Items', children: null },
|
|
24
|
+
argTypes: { children: { control: false }, actions: { control: false }, above: { control: false } },
|
|
25
|
+
// The column beside a main area, inside a surface — where it sits in the frame's card.
|
|
26
|
+
decorators: [
|
|
27
|
+
(Story) => (
|
|
28
|
+
<div className="flex h-screen bg-bg-surface">
|
|
29
|
+
{Story()}
|
|
30
|
+
<div className="min-w-0 flex-1" />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
} satisfies Meta<typeof ListColumn>
|
|
35
|
+
|
|
36
|
+
export default meta
|
|
37
|
+
type Story = StoryObj<typeof meta>
|
|
38
|
+
|
|
39
|
+
const icon = <IconSquareRounded size={16} stroke={1.5} />
|
|
40
|
+
const names = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight']
|
|
41
|
+
const rows = (n: number) =>
|
|
42
|
+
Array.from({ length: n }, (_, i) => <NavItem key={i} href="#" label={`Item ${names[i] ?? i + 1}`} icon={icon} active={i === 1} />)
|
|
43
|
+
|
|
44
|
+
const actions = (
|
|
45
|
+
<Toolbar aria-label="Item list actions" surface={false}>
|
|
46
|
+
<ToolbarButton tooltip="Sort by" aria-label="Sort by">
|
|
47
|
+
<IconSortDescending size={16} stroke={1.5} />
|
|
48
|
+
</ToolbarButton>
|
|
49
|
+
<ToolbarButton tooltip="New item" aria-label="New item">
|
|
50
|
+
{icon}
|
|
51
|
+
</ToolbarButton>
|
|
52
|
+
</Toolbar>
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** A list of one kind of row, 2px apart, with the column's actions in its header. */
|
|
56
|
+
export const Default: Story = {
|
|
57
|
+
args: { title: 'Items', actions },
|
|
58
|
+
render: (args) => <ListColumn {...args}>{rows(6)}</ListColumn>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Groups with labels and a divider of their own: 4px between rows. */
|
|
62
|
+
export const Sections: Story = {
|
|
63
|
+
args: { title: 'Items', spacing: 'sections' },
|
|
64
|
+
render: (args) => (
|
|
65
|
+
<ListColumn {...args}>
|
|
66
|
+
<div className="flex h-8 shrink-0 items-center px-2">
|
|
67
|
+
<SectionLabel>Group one</SectionLabel>
|
|
68
|
+
</div>
|
|
69
|
+
{rows(3)}
|
|
70
|
+
<Divider className="my-2" />
|
|
71
|
+
<div className="flex h-8 shrink-0 items-center px-2">
|
|
72
|
+
<SectionLabel>Group two</SectionLabel>
|
|
73
|
+
</div>
|
|
74
|
+
{rows(4)}
|
|
75
|
+
</ListColumn>
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A row under the header that stays put while the list scrolls: a field that adds to the list. */
|
|
80
|
+
export const WithARowAbove: Story = {
|
|
81
|
+
parameters: { controls: { disable: true } },
|
|
82
|
+
render: function RowAbove() {
|
|
83
|
+
const [name, setName] = useState('')
|
|
84
|
+
return (
|
|
85
|
+
<ListColumn
|
|
86
|
+
title="Items"
|
|
87
|
+
above={
|
|
88
|
+
<Form onSubmit={() => setName('')} className="flex items-center gap-2 px-3 pt-3 pb-1">
|
|
89
|
+
<TextInput className="min-w-0 flex-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="New item name" aria-label="New item name" />
|
|
90
|
+
<Button className="shrink-0" type="submit" disabled={!name.trim()}>
|
|
91
|
+
Create
|
|
92
|
+
</Button>
|
|
93
|
+
</Form>
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
{rows(5)}
|
|
97
|
+
</ListColumn>
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** More rows than fit: the list scrolls under a header that stays. */
|
|
103
|
+
export const ManyRows: Story = {
|
|
104
|
+
args: { title: 'Items', actions },
|
|
105
|
+
render: (args) => <ListColumn {...args}>{rows(80)}</ListColumn>,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** While the list is on its way: a skeleton shaped like the rows. */
|
|
109
|
+
export const Loading: Story = {
|
|
110
|
+
args: { title: 'Items' },
|
|
111
|
+
render: (args) => (
|
|
112
|
+
<ListColumn {...args}>
|
|
113
|
+
<SkeletonList rows={8} />
|
|
114
|
+
</ListColumn>
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Closed with the rail: it narrows to nothing and fades. Press the button to watch it. */
|
|
119
|
+
export const Collapsing: Story = {
|
|
120
|
+
parameters: { controls: { disable: true } },
|
|
121
|
+
render: function Collapsing() {
|
|
122
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<ListColumn title="Items" collapsed={collapsed}>
|
|
126
|
+
{rows(6)}
|
|
127
|
+
</ListColumn>
|
|
128
|
+
<div className="p-4">
|
|
129
|
+
<Button variant="outlined" onClick={() => setCollapsed((c) => !c)}>
|
|
130
|
+
{collapsed ? 'Open the column' : 'Close the column'}
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the ListColumn page claims, pinned. Sizes are measured in the browser; these pin the parts. */
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
5
|
+
import { ListColumn } from './ListColumn'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
describe('ListColumn', () => {
|
|
10
|
+
it('is headed by its title', () => {
|
|
11
|
+
render(<ListColumn title="Items">{null}</ListColumn>)
|
|
12
|
+
expect(screen.getByText('Items')).toBeTruthy()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('is 290px wide with a line on its right, and keeps its width', () => {
|
|
16
|
+
const { container } = render(<ListColumn title="Items">{null}</ListColumn>)
|
|
17
|
+
const classes = (container.firstElementChild as HTMLElement).className.split(' ')
|
|
18
|
+
expect(classes).toContain('w-[290px]')
|
|
19
|
+
expect(classes).toContain('border-r')
|
|
20
|
+
expect(classes).toContain('shrink-0')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('collapsed: narrows to nothing, loses its line, and fades', () => {
|
|
24
|
+
const { container } = render(<ListColumn title="Items" collapsed>{null}</ListColumn>)
|
|
25
|
+
const classes = (container.firstElementChild as HTMLElement).className.split(' ')
|
|
26
|
+
expect(classes).toContain('w-0')
|
|
27
|
+
expect(classes).toContain('border-r-0')
|
|
28
|
+
expect(classes).toContain('opacity-0')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('draws its rows in the list, and a row above the list outside it', () => {
|
|
32
|
+
render(
|
|
33
|
+
<ListColumn title="Items" above={<p>Above</p>}>
|
|
34
|
+
<p>Row</p>
|
|
35
|
+
</ListColumn>,
|
|
36
|
+
)
|
|
37
|
+
const list = screen.getByText('Row').parentElement as HTMLElement
|
|
38
|
+
expect(list.className.split(' ')).toEqual(expect.arrayContaining(['px-3', 'pt-4', 'pb-3', 'gap-0.5']))
|
|
39
|
+
expect(list.contains(screen.getByText('Above'))).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('sections: 4px between rows', () => {
|
|
43
|
+
render(
|
|
44
|
+
<ListColumn title="Items" spacing="sections">
|
|
45
|
+
<p>Row</p>
|
|
46
|
+
</ListColumn>,
|
|
47
|
+
)
|
|
48
|
+
expect((screen.getByText('Row').parentElement as HTMLElement).className.split(' ')).toContain('gap-1')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { ContainerHeader } from './ContainerHeader'
|
|
4
|
+
import { ScrollArea } from './ScrollArea'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The list column of a page: the column beside the page's main area that lists
|
|
8
|
+
* what it can open — 290px wide, a line on its right, its name in a
|
|
9
|
+
* `ContainerHeader`, and a list under it that scrolls.
|
|
10
|
+
*
|
|
11
|
+
* Moved in from Peek as it looks (UIG-14, N9), where five pages drew it by
|
|
12
|
+
* hand and each carried the same numbers: the frame's 290px and its line, and
|
|
13
|
+
* the list's steps — 16px under the header, 12px above the bottom, rows 12px
|
|
14
|
+
* in from each side. The numbers live here now, once.
|
|
15
|
+
*
|
|
16
|
+
* It scrolls its rows itself. A list column that did not was the first of the
|
|
17
|
+
* eight mistakes on the page that led to UIG-15: the frame's card clips, so a
|
|
18
|
+
* list that grew past the fold was simply cut off, with nothing to scroll.
|
|
19
|
+
*
|
|
20
|
+
* `collapsed` closes it with the rail, as Peek's did: it narrows to nothing
|
|
21
|
+
* and fades, in 300ms. What opens and closes the rail stays the app's.
|
|
22
|
+
*/
|
|
23
|
+
export interface ListColumnProps {
|
|
24
|
+
/** The column's name, in its header. A node for a title that is more than words. */
|
|
25
|
+
title: ReactNode
|
|
26
|
+
/** A chevron after the title, as `ContainerHeader` draws it. */
|
|
27
|
+
chevron?: boolean
|
|
28
|
+
/** The column's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
|
|
29
|
+
actions?: ReactNode
|
|
30
|
+
/** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
|
|
31
|
+
above?: ReactNode
|
|
32
|
+
/**
|
|
33
|
+
* The room between rows. `rows` (2px) for a list of one kind of row;
|
|
34
|
+
* `sections` (4px) for groups with labels and dividers of their own.
|
|
35
|
+
*/
|
|
36
|
+
spacing?: 'rows' | 'sections'
|
|
37
|
+
/** Closes the column with the rail: it narrows to nothing and fades. */
|
|
38
|
+
collapsed?: boolean
|
|
39
|
+
/** The rows. */
|
|
40
|
+
children: ReactNode
|
|
41
|
+
/** Placement only. */
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ListColumn({ title, chevron = false, actions, above, spacing = 'rows', collapsed = false, children, className }: ListColumnProps) {
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={cn(
|
|
49
|
+
// `shrink-0`: the column keeps its 290px beside a main area that grows.
|
|
50
|
+
'flex shrink-0 flex-col overflow-hidden border-r border-border-subtle transition-[width,opacity] duration-300 ease-in-out',
|
|
51
|
+
collapsed ? 'w-0 border-r-0 opacity-0' : 'w-[290px] opacity-100',
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
data-collapsed={collapsed || undefined}
|
|
55
|
+
>
|
|
56
|
+
<ContainerHeader title={title} chevron={chevron} actions={actions} />
|
|
57
|
+
{above}
|
|
58
|
+
<ScrollArea className="flex-1" contentClassName={cn('flex flex-col px-3 pt-4 pb-3', spacing === 'rows' ? 'gap-0.5' : 'gap-1')}>
|
|
59
|
+
{children}
|
|
60
|
+
</ScrollArea>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
package/src/Menu.mdx
CHANGED
|
@@ -41,7 +41,7 @@ definition either way.
|
|
|
41
41
|
- Picking one value for a form field → **Select**.
|
|
42
42
|
- Rows that toggle and stay open → that is a listbox, not a menu; a Checkbox
|
|
43
43
|
inside a `menuitem` is invalid. Build it as a list in a **DialogShell**.
|
|
44
|
-
- A strip of icon actions →
|
|
44
|
+
- A strip of icon actions → **Toolbar**.
|
|
45
45
|
- Who-you-are and where-you-are → **IdentityMenu**, already built on this.
|
|
46
46
|
|
|
47
47
|
## How
|
|
@@ -76,6 +76,23 @@ the trigger as "close" rather than as a press outside.
|
|
|
76
76
|
|
|
77
77
|
<Canvas of={MenuStories.OnACard} />
|
|
78
78
|
|
|
79
|
+
- **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
|
|
80
|
+
|
|
81
|
+
## What it owns
|
|
82
|
+
|
|
83
|
+
It does these by itself. Write none of them around it.
|
|
84
|
+
|
|
85
|
+
| It does | So you never write | Refused when written by hand |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
88
|
+
| Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
|
|
89
|
+
| Closes on a press outside | a click listener on the page | ✓ |
|
|
90
|
+
| Takes its keys by itself: Enter and Space choose a row, Escape closes it | a key listener on the page | ✓ |
|
|
91
|
+
| Moves through its items with the arrow keys, and jumps to a row by its first letters | an arrow-key handler | ✓ |
|
|
92
|
+
| Says what it is to assistive technology: a menu, and each row a menu item | `role="menu"` or `role="menuitem"` | ✓ |
|
|
93
|
+
| Keeps its own open state, and closes when a row is chosen | `useState` for open | |
|
|
94
|
+
| Gives focus back to the trigger when it closes | focus code | |
|
|
95
|
+
|
|
79
96
|
## Keys
|
|
80
97
|
|
|
81
98
|
| Input | What happens |
|