@estiva-app/ui 0.13.0 → 0.14.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/Avatar.d.ts +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Card.d.ts +7 -1
- package/dist/Card.d.ts.map +1 -1
- package/dist/Divider.d.ts +5 -2
- package/dist/Divider.d.ts.map +1 -1
- package/dist/EmptyState.d.ts +3 -0
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Property.d.ts +7 -0
- package/dist/Property.d.ts.map +1 -1
- package/dist/Reaction.d.ts +12 -6
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/TextInput.d.ts +10 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Toast.d.ts +19 -6
- package/dist/Toast.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +1 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/index.js +283 -246
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/src/AttachmentCard.tsx +14 -14
- package/src/Avatar.mdx +7 -2
- package/src/Avatar.picture.test.tsx +60 -0
- package/src/Avatar.stories.tsx +1 -1
- package/src/Avatar.tsx +50 -14
- package/src/AvatarGroup.tsx +1 -0
- package/src/Breadcrumb.tsx +2 -2
- package/src/Card.mdx +9 -3
- package/src/Card.stories.tsx +3 -0
- package/src/Card.test.tsx +41 -0
- package/src/Card.tsx +19 -1
- package/src/Checkbox.tsx +1 -1
- package/src/Chip.tsx +1 -1
- package/src/DialogShell.stories.tsx +1 -1
- package/src/Divider.mdx +3 -2
- package/src/Divider.test.tsx +15 -0
- package/src/Divider.tsx +11 -9
- package/src/EditableText.stories.tsx +2 -2
- package/src/EmptyState.mdx +3 -2
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +15 -0
- package/src/EmptyState.tsx +4 -1
- package/src/IconButton.stories.tsx +1 -1
- package/src/Kbd.tsx +3 -3
- package/src/Menu.stories.tsx +2 -2
- package/src/Menu.tsx +3 -3
- package/src/Popover.stories.tsx +1 -1
- package/src/PreviewCard.stories.tsx +5 -5
- package/src/Property.mdx +8 -0
- package/src/Property.stories.tsx +4 -4
- package/src/Property.test.tsx +44 -0
- package/src/Property.tsx +23 -8
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +14 -3
- package/src/Reaction.test.tsx +62 -0
- package/src/Reaction.tsx +21 -12
- package/src/ReactionPicker.tsx +1 -1
- package/src/SectionLabel.stories.tsx +1 -1
- package/src/SectionLabel.tsx +1 -1
- package/src/Select.tsx +3 -3
- package/src/TextInput.mdx +6 -0
- package/src/TextInput.stories.tsx +45 -0
- package/src/TextInput.test.tsx +38 -0
- package/src/TextInput.tsx +16 -4
- package/src/Textarea.tsx +1 -1
- package/src/Toast.mdx +28 -4
- package/src/Toast.stories.tsx +12 -1
- package/src/Toast.test.tsx +137 -0
- package/src/Toast.tsx +127 -83
- package/src/Toolbar.tsx +4 -2
- package/src/cn.ts +1 -1
- package/stories/Choosing.mdx +1 -0
- package/stories/TokensPage.tsx +6 -1
- package/tailwind-preset.js +7 -0
package/src/EmptyState.test.tsx
CHANGED
|
@@ -31,6 +31,21 @@ describe('EmptyState', () => {
|
|
|
31
31
|
expect(line.className).not.toContain('text-center')
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
+
it('a section is the quiet line: caption size, muted colour (Katerina, 2026-09-15)', () => {
|
|
35
|
+
render(<EmptyState scope="section" message="Nothing here yet." />)
|
|
36
|
+
const classes = screen.getByText('Nothing here yet.').className.split(' ')
|
|
37
|
+
expect(classes).toContain('text-caption')
|
|
38
|
+
expect(classes).toContain('text-text-muted')
|
|
39
|
+
expect(classes).not.toContain('text-body-2')
|
|
40
|
+
expect(classes).not.toContain('text-text-secondary')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('a caller’s placement class does not take the quiet look away', () => {
|
|
44
|
+
render(<EmptyState scope="section" className="py-8" message="Nothing here yet." />)
|
|
45
|
+
const classes = screen.getByText('Nothing here yet.').className.split(' ')
|
|
46
|
+
expect(classes).toEqual(expect.arrayContaining(['text-caption', 'text-text-muted', 'py-8']))
|
|
47
|
+
})
|
|
48
|
+
|
|
34
49
|
it('a section carries no space of its own, so the box it sits in decides where it starts', () => {
|
|
35
50
|
// Katerina, 2026-09-14: the box is written once and holds the rows or the empty
|
|
36
51
|
// state; the empty state is never padded. So it may carry no padding or margin.
|
package/src/EmptyState.tsx
CHANGED
|
@@ -15,6 +15,9 @@ import { cn } from './cn'
|
|
|
15
15
|
* - `section` — one section of a page is empty, and the page has other
|
|
16
16
|
* things on it: the line alone, left-aligned, no icon. A section's
|
|
17
17
|
* emptiness is a line among the page's content, not a stage of its own.
|
|
18
|
+
* It is the quiet line — caption size in the muted colour, as a Field's
|
|
19
|
+
* helper line is — so beside rows it reads as "nothing here", not as one
|
|
20
|
+
* more row (Katerina, 2026-09-15: "not that intense", "smaller font").
|
|
18
21
|
*
|
|
19
22
|
* The message is the caller's — a shared component has no words of its own
|
|
20
23
|
* for what is missing.
|
|
@@ -30,7 +33,7 @@ export interface EmptyStateProps {
|
|
|
30
33
|
|
|
31
34
|
export function EmptyState({ icon, message, scope = 'page', className }: EmptyStateProps) {
|
|
32
35
|
if (scope === 'section') {
|
|
33
|
-
return <p className={cn('text-
|
|
36
|
+
return <p className={cn('text-caption text-text-muted', className)}>{message}</p>
|
|
34
37
|
}
|
|
35
38
|
return (
|
|
36
39
|
<div className={cn('flex flex-1 flex-col items-center justify-center gap-2', className)}>
|
|
@@ -41,7 +41,7 @@ export const AllVariants: Story = {
|
|
|
41
41
|
<IconButton variant={variant} disabled aria-label="Settings">
|
|
42
42
|
<IconSettings className="size-4" stroke={1.5} />
|
|
43
43
|
</IconButton>
|
|
44
|
-
<span className="text-
|
|
44
|
+
<span className="text-caption text-text-muted">{variant}</span>
|
|
45
45
|
</div>
|
|
46
46
|
))}
|
|
47
47
|
</div>
|
package/src/Kbd.tsx
CHANGED
|
@@ -30,9 +30,9 @@ export function Kbd({ children, className }: KbdProps) {
|
|
|
30
30
|
return (
|
|
31
31
|
<kbd
|
|
32
32
|
className={cn(
|
|
33
|
-
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-
|
|
34
|
-
'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-
|
|
35
|
-
'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-
|
|
33
|
+
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-caption text-text-secondary',
|
|
34
|
+
'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-small',
|
|
35
|
+
'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-small',
|
|
36
36
|
className,
|
|
37
37
|
)}
|
|
38
38
|
>
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -72,7 +72,7 @@ export const WithARow: Story = {
|
|
|
72
72
|
render: () => (
|
|
73
73
|
<MenuPanel className="w-64">
|
|
74
74
|
<MenuRow>
|
|
75
|
-
<span className="min-w-0 flex-1 truncate text-
|
|
75
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">Item one</span>
|
|
76
76
|
<SectionLabel className="text-text-secondary">Label</SectionLabel>
|
|
77
77
|
</MenuRow>
|
|
78
78
|
<Divider className="my-1" />
|
|
@@ -134,7 +134,7 @@ export const OnACard: Story = {
|
|
|
134
134
|
parameters: { controls: { disable: true } },
|
|
135
135
|
render: () => (
|
|
136
136
|
<div className="flex w-[420px] items-start gap-3 rounded-lg border border-border-default p-3">
|
|
137
|
-
<span className="min-w-0 flex-1 text-
|
|
137
|
+
<span className="min-w-0 flex-1 text-body-2 text-text-primary">
|
|
138
138
|
A card. Press the control at the end of the row, then walk down the rows and out to the submenu.
|
|
139
139
|
</span>
|
|
140
140
|
<Menu
|
package/src/Menu.tsx
CHANGED
|
@@ -426,10 +426,10 @@ function MenuItemBody({ label, children, size = 'default', description, leading,
|
|
|
426
426
|
colour. Ship's copy said `text-sm`, which was never the ramp. */}
|
|
427
427
|
{children ?? (
|
|
428
428
|
<span className={cn('flex min-w-0 flex-1 flex-col', size === 'tall' && 'gap-[2px]')}>
|
|
429
|
-
<span className={cn('truncate text-
|
|
429
|
+
<span className={cn('truncate text-body-2', destructive ? 'text-error-default' : 'text-text-primary')}>
|
|
430
430
|
{label}
|
|
431
431
|
</span>
|
|
432
|
-
{description && <span className="truncate text-
|
|
432
|
+
{description && <span className="truncate text-caption text-text-secondary">{description}</span>}
|
|
433
433
|
</span>
|
|
434
434
|
)}
|
|
435
435
|
{(edge || hint) && (
|
|
@@ -538,7 +538,7 @@ export function EnterHint({ target }: { target?: string }) {
|
|
|
538
538
|
<span className="flex shrink-0 items-center gap-1.5 text-text-muted">
|
|
539
539
|
<Kbd>↩ Enter</Kbd>
|
|
540
540
|
{target && (
|
|
541
|
-
<span className="text-
|
|
541
|
+
<span className="text-menu signal:font-mono signal:text-small signal:tracking-wider">{target}</span>
|
|
542
542
|
)}
|
|
543
543
|
</span>
|
|
544
544
|
)
|
package/src/Popover.stories.tsx
CHANGED
|
@@ -167,7 +167,7 @@ export const FromASelection: Story = {
|
|
|
167
167
|
can, which is the real case; `-1` gives this one the same property
|
|
168
168
|
without adding a Tab stop. Without it focus is left on the document
|
|
169
169
|
body when the panel closes — measured. */}
|
|
170
|
-
<p ref={body} tabIndex={-1} onMouseUp={read} onKeyUp={readOnKey} className="text-
|
|
170
|
+
<p ref={body} tabIndex={-1} onMouseUp={read} onKeyUp={readOnKey} className="text-body-2 text-text-primary outline-none">
|
|
171
171
|
Select any part of this sentence with the pointer, and a panel appears
|
|
172
172
|
above the selection rather than beside a button — because a selection
|
|
173
173
|
is not a control and there is no trigger to hang from.
|
|
@@ -40,11 +40,11 @@ function Detail() {
|
|
|
40
40
|
].map(([k, v], i) => (
|
|
41
41
|
<div key={i} className="flex items-baseline gap-2">
|
|
42
42
|
<span className="w-20 shrink-0 text-caption text-text-secondary">{k}</span>
|
|
43
|
-
<span className="min-w-0 flex-1 truncate text-
|
|
43
|
+
<span className="min-w-0 flex-1 truncate text-caption text-text-primary">{v}</span>
|
|
44
44
|
</div>
|
|
45
45
|
))}
|
|
46
46
|
</div>
|
|
47
|
-
<span className="text-
|
|
47
|
+
<span className="text-caption text-text-secondary">
|
|
48
48
|
This is the card. It opened because the pointer came to rest on the row
|
|
49
49
|
and stayed there for 350ms, and it will close 200ms after the pointer
|
|
50
50
|
leaves — long enough to move into the card without losing it.
|
|
@@ -57,7 +57,7 @@ function Row({ label, note }: { label: string; note?: string }) {
|
|
|
57
57
|
return (
|
|
58
58
|
<div className="flex w-full items-center gap-2 rounded-lg border border-border-default px-3 py-2 hover:bg-bg-hover">
|
|
59
59
|
<IconSquareRounded size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
|
|
60
|
-
<span className="min-w-0 flex-1 truncate text-
|
|
60
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">{label}</span>
|
|
61
61
|
{note && <span className="shrink-0 text-caption text-text-muted">{note}</span>}
|
|
62
62
|
</div>
|
|
63
63
|
)
|
|
@@ -131,7 +131,7 @@ export const Scrolling: Story = {
|
|
|
131
131
|
content={
|
|
132
132
|
<>
|
|
133
133
|
<span className="text-body-2-strong text-text-primary">Item with more than fits</span>
|
|
134
|
-
<span className="text-
|
|
134
|
+
<span className="text-caption text-text-secondary">
|
|
135
135
|
There is more here than the card's 300px cap allows, so it scrolls.
|
|
136
136
|
You can reach that scrollbar because the pointer can enter this card.
|
|
137
137
|
A tooltip cannot be entered, so a tooltip that scrolled would be a
|
|
@@ -140,7 +140,7 @@ export const Scrolling: Story = {
|
|
|
140
140
|
{Array.from({ length: 14 }, (_, i) => (
|
|
141
141
|
<div key={i} className="flex items-baseline gap-2">
|
|
142
142
|
<span className="w-20 shrink-0 text-caption text-text-secondary">Label</span>
|
|
143
|
-
<span className="min-w-0 flex-1 truncate text-
|
|
143
|
+
<span className="min-w-0 flex-1 truncate text-caption text-text-primary">Value {i + 1}</span>
|
|
144
144
|
</div>
|
|
145
145
|
))}
|
|
146
146
|
</>
|
package/src/Property.mdx
CHANGED
|
@@ -46,6 +46,14 @@ import { Property } from '@estiva-app/ui'
|
|
|
46
46
|
Any control sits inside — a Chip, a Person, a Select, plain text (the
|
|
47
47
|
WithControls story shows the pairings).
|
|
48
48
|
|
|
49
|
+
- It is HTML's own name–value markup: a `<dl>` with the label as its `<dt>`
|
|
50
|
+
and what you pass as its `<dd>`, so a screen reader hears the label as the
|
|
51
|
+
name of the value. Base UI has no part for this. Each property is a list of
|
|
52
|
+
one; nothing needs wrapping.
|
|
53
|
+
- The `<dd>` draws no box (`display: contents`), so what you pass lays out as
|
|
54
|
+
a child of the row or the column: a control with `flex-1` still fills the
|
|
55
|
+
row, and two values still sit the row's gap apart.
|
|
56
|
+
|
|
49
57
|
## Props
|
|
50
58
|
|
|
51
59
|
<Controls of={PropertyStories.Row} />
|
package/src/Property.stories.tsx
CHANGED
|
@@ -28,10 +28,10 @@ export const APanel: Story = {
|
|
|
28
28
|
<Chip type="success" label="Done" />
|
|
29
29
|
</Property>
|
|
30
30
|
<Property label="Lead">
|
|
31
|
-
<Person name="Ana Duarte" size={16} className="text-
|
|
31
|
+
<Person name="Ana Duarte" size={16} className="text-caption text-text-primary" />
|
|
32
32
|
</Property>
|
|
33
33
|
<Property label="Updated">
|
|
34
|
-
<span className="text-
|
|
34
|
+
<span className="text-caption text-text-secondary">2d ago</span>
|
|
35
35
|
</Property>
|
|
36
36
|
</div>
|
|
37
37
|
),
|
|
@@ -42,7 +42,7 @@ export const Stacked: Story = {
|
|
|
42
42
|
args: {
|
|
43
43
|
label: 'Assignee',
|
|
44
44
|
layout: 'stacked',
|
|
45
|
-
children: <Person name="Ravi Mehta" size={20} className="text-
|
|
45
|
+
children: <Person name="Ravi Mehta" size={20} className="text-body-2 text-text-primary" />,
|
|
46
46
|
},
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -87,5 +87,5 @@ export const WithControls: Story = {
|
|
|
87
87
|
|
|
88
88
|
/** No value: the em dash, muted — the same mark Person uses for the unnamed. */
|
|
89
89
|
export const Empty: Story = {
|
|
90
|
-
args: { label: 'Folder', children: <span className="text-
|
|
90
|
+
args: { label: 'Folder', children: <span className="text-caption text-text-muted">—</span> },
|
|
91
91
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Property page claims, pinned: the label is the term, the value is
|
|
4
|
+
* its definition, and the value's wrapper draws no box, so what a caller puts
|
|
5
|
+
* inside lays out as a child of the row.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import { Property } from './Property'
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup)
|
|
12
|
+
|
|
13
|
+
describe('Property', () => {
|
|
14
|
+
it.each(['row', 'stacked'] as const)('%s: a list of one term and its definition', (layout) => {
|
|
15
|
+
const { container } = render(
|
|
16
|
+
<Property label="Status" layout={layout}>
|
|
17
|
+
<span>Done</span>
|
|
18
|
+
</Property>,
|
|
19
|
+
)
|
|
20
|
+
const list = container.firstElementChild!
|
|
21
|
+
expect(list.tagName).toBe('DL')
|
|
22
|
+
expect(Array.from(list.children).map((child) => child.tagName)).toEqual(['DT', 'DD'])
|
|
23
|
+
expect(screen.getByRole('term').textContent).toBe('Status')
|
|
24
|
+
expect(screen.getByRole('definition').textContent).toBe('Done')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it.each(['row', 'stacked'] as const)('%s: the value wrapper draws no box of its own', (layout) => {
|
|
28
|
+
render(
|
|
29
|
+
<Property label="Status" layout={layout}>
|
|
30
|
+
<span>Done</span>
|
|
31
|
+
</Property>,
|
|
32
|
+
)
|
|
33
|
+
expect(screen.getByRole('definition').className).toBe('contents')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('the caller’s className lands on the list', () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<Property label="Status" className="mt-2">
|
|
39
|
+
<span>Done</span>
|
|
40
|
+
</Property>,
|
|
41
|
+
)
|
|
42
|
+
expect(container.firstElementChild!.className).toContain('mt-2')
|
|
43
|
+
})
|
|
44
|
+
})
|
package/src/Property.tsx
CHANGED
|
@@ -15,6 +15,13 @@ import { cn } from './cn'
|
|
|
15
15
|
* is the `menu` token (9px / 115% / 500), uppercase and letter-spaced —
|
|
16
16
|
* its own voice, deliberately not SectionLabel's (unmerged, Katerina
|
|
17
17
|
* 2026-09-01). A literal string, not merged, so the token size survives.
|
|
18
|
+
*
|
|
19
|
+
* **A name and its value, in the markup made for it** (stage 6 of the
|
|
20
|
+
* migration, 2026-09-14). Base UI has no part for this, so it is HTML's own:
|
|
21
|
+
* a `<dl>` holding one `<dt>` (the label) and one `<dd>` (the value), so a
|
|
22
|
+
* screen reader hears the label as the name of what follows rather than as a
|
|
23
|
+
* loose word beside it. Each property is a list of one, which needs no
|
|
24
|
+
* wrapper from the caller.
|
|
18
25
|
*/
|
|
19
26
|
export interface PropertyProps {
|
|
20
27
|
label: string
|
|
@@ -23,22 +30,30 @@ export interface PropertyProps {
|
|
|
23
30
|
className?: string
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The value's `<dd>` draws no box of its own (`display: contents`): whatever
|
|
35
|
+
* the caller passes stays a child of the row or the column, exactly as it was
|
|
36
|
+
* before the `<dd>` existed — a control that fills the row (`flex-1`) still
|
|
37
|
+
* fills it, and two values still sit the row's gap apart.
|
|
38
|
+
*/
|
|
39
|
+
const VALUE_CLASSES = 'contents'
|
|
40
|
+
|
|
26
41
|
export function Property({ label, layout = 'row', children, className }: PropertyProps) {
|
|
27
42
|
if (layout === 'stacked') {
|
|
28
43
|
return (
|
|
29
44
|
// gap-3 — the one label-above-content distance (Katerina, 2026-09-01):
|
|
30
45
|
// Peek's topic-details sections already sit at 12px, Ship's rail was
|
|
31
46
|
// 6px, and unifying means the bigger, calmer one.
|
|
32
|
-
<
|
|
33
|
-
<
|
|
34
|
-
{children}
|
|
35
|
-
</
|
|
47
|
+
<dl className={cn('flex flex-col gap-3', className)}>
|
|
48
|
+
<dt className="text-menu uppercase tracking-widest text-text-secondary">{label}</dt>
|
|
49
|
+
<dd className={VALUE_CLASSES}>{children}</dd>
|
|
50
|
+
</dl>
|
|
36
51
|
)
|
|
37
52
|
}
|
|
38
53
|
return (
|
|
39
|
-
<
|
|
40
|
-
<
|
|
41
|
-
{children}
|
|
42
|
-
</
|
|
54
|
+
<dl className={cn('flex items-center gap-2', className)}>
|
|
55
|
+
<dt className="w-[68px] shrink-0 text-caption text-text-secondary">{label}</dt>
|
|
56
|
+
<dd className={VALUE_CLASSES}>{children}</dd>
|
|
57
|
+
</dl>
|
|
43
58
|
)
|
|
44
59
|
}
|
package/src/RailItem.tsx
CHANGED
|
@@ -45,7 +45,7 @@ export function RailItem({ href, icon, label, active = false, className, ...prop
|
|
|
45
45
|
{icon}
|
|
46
46
|
</span>
|
|
47
47
|
</div>
|
|
48
|
-
<span className={cn('text-center text-
|
|
48
|
+
<span className={cn('text-center text-menu', active ? 'text-text-primary' : 'text-text-secondary')}>
|
|
49
49
|
{label}
|
|
50
50
|
</span>
|
|
51
51
|
</a>
|
package/src/Reaction.mdx
CHANGED
|
@@ -42,9 +42,20 @@ import { Reaction } from '@estiva-app/ui'
|
|
|
42
42
|
aloud is noise, and its spoken name differs per screen reader — so the
|
|
43
43
|
count is the only visible text and the control would otherwise be
|
|
44
44
|
announced as "2". Pass the meaning and the count.
|
|
45
|
-
- It is a real `<button>` with `aria-pressed
|
|
46
|
-
tech as a toggle rather than as
|
|
47
|
-
|
|
45
|
+
- It is Base UI's `Toggle`: a real `<button>` with `aria-pressed` and
|
|
46
|
+
`data-pressed`, so "mine" reaches assistive tech as a toggle rather than as
|
|
47
|
+
a colour. It is always `type="button"`, never a form's submit.
|
|
48
|
+
- **You own `pressed`.** A press calls `onClick` and changes nothing on its
|
|
49
|
+
own; the pill shows "yours" when your data says so. A write can fail, and
|
|
50
|
+
another client can change the count.
|
|
51
|
+
- `disabled` takes it out of the Tab order and ignores presses.
|
|
52
|
+
|
|
53
|
+
## Keys
|
|
54
|
+
|
|
55
|
+
| Key | What happens |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Tab | Moves to the reaction; a disabled one is skipped |
|
|
58
|
+
| Enter, Space | Presses it: calls `onClick` |
|
|
48
59
|
|
|
49
60
|
## Props
|
|
50
61
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Reaction page claims, pinned: a toggle named by its label, the emoji
|
|
4
|
+
* kept out of the name, "yours" carried as `aria-pressed`, and the keys a
|
|
5
|
+
* native button answers to.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { Reaction } from './Reaction'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
describe('Reaction', () => {
|
|
15
|
+
it('is a button named by its label, never by the emoji or the bare count', () => {
|
|
16
|
+
render(<Reaction emoji="👍" count={2} aria-label="Makes sense, 2" />)
|
|
17
|
+
const button = screen.getByRole('button', { name: 'Makes sense, 2' })
|
|
18
|
+
expect(button.getAttribute('type')).toBe('button')
|
|
19
|
+
expect(button.querySelector('[aria-hidden="true"]')?.textContent).toBe('👍')
|
|
20
|
+
expect(button.textContent).toBe('👍2')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('says whether the reaction is yours as a pressed state', () => {
|
|
24
|
+
const { rerender } = render(<Reaction emoji="👍" count={2} aria-label="Makes sense, 2" />)
|
|
25
|
+
const button = screen.getByRole('button')
|
|
26
|
+
expect(button.getAttribute('aria-pressed')).toBe('false')
|
|
27
|
+
expect(button.hasAttribute('data-pressed')).toBe(false)
|
|
28
|
+
rerender(<Reaction emoji="👍" count={3} aria-label="Makes sense, 3" pressed />)
|
|
29
|
+
expect(button.getAttribute('aria-pressed')).toBe('true')
|
|
30
|
+
expect(button.hasAttribute('data-pressed')).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a press calls onClick and leaves the pressed state to the caller', async () => {
|
|
34
|
+
const onClick = vi.fn()
|
|
35
|
+
render(<Reaction emoji="👍" count={2} aria-label="Makes sense, 2" onClick={onClick} />)
|
|
36
|
+
const button = screen.getByRole('button')
|
|
37
|
+
await userEvent.click(button)
|
|
38
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
39
|
+
expect(button.getAttribute('aria-pressed')).toBe('false')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('Tab reaches it; Enter and Space press it', async () => {
|
|
43
|
+
const onClick = vi.fn()
|
|
44
|
+
render(<Reaction emoji="👍" count={2} aria-label="Makes sense, 2" onClick={onClick} />)
|
|
45
|
+
await userEvent.tab()
|
|
46
|
+
expect(document.activeElement).toBe(screen.getByRole('button'))
|
|
47
|
+
await userEvent.keyboard('{Enter}')
|
|
48
|
+
await userEvent.keyboard(' ')
|
|
49
|
+
expect(onClick).toHaveBeenCalledTimes(2)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('disabled: not reachable by Tab, and a press does nothing', async () => {
|
|
53
|
+
const onClick = vi.fn()
|
|
54
|
+
render(<Reaction emoji="👍" count={2} aria-label="Makes sense, 2" onClick={onClick} disabled />)
|
|
55
|
+
const button = screen.getByRole('button')
|
|
56
|
+
expect((button as HTMLButtonElement).disabled).toBe(true)
|
|
57
|
+
await userEvent.tab()
|
|
58
|
+
expect(document.activeElement).not.toBe(button)
|
|
59
|
+
await userEvent.click(button)
|
|
60
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/Reaction.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { Toggle } from '@base-ui/react/toggle'
|
|
2
3
|
import { cn } from './cn'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -22,8 +23,10 @@ import { cn } from './cn'
|
|
|
22
23
|
* 1px hairline against no border at all — which is legible and is not the
|
|
23
24
|
* signal Peek's accent fill gives.
|
|
24
25
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* On Base UI's `Toggle` since stage 6 of the migration (2026-09-14): a native
|
|
27
|
+
* `<button>` with `aria-pressed` and `data-pressed`, so the state reaches
|
|
28
|
+
* assistive tech as a toggle rather than as a colour. Base UI writes both;
|
|
29
|
+
* this component used to write the first by hand.
|
|
27
30
|
*
|
|
28
31
|
* ## `aria-label` is required, and the reason is specific
|
|
29
32
|
*
|
|
@@ -39,12 +42,16 @@ import { cn } from './cn'
|
|
|
39
42
|
* `small`, the same 8px horizontal padding, the `chip` type token for the
|
|
40
43
|
* count so it sits at 11px/500 like every other count in the system.
|
|
41
44
|
*/
|
|
42
|
-
export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
45
|
+
export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'type' | 'value'> {
|
|
43
46
|
/** The emoji, drawn decoratively — name the control with `aria-label`. */
|
|
44
47
|
emoji: ReactNode
|
|
45
|
-
/** How many people reacted. Drawn as
|
|
48
|
+
/** How many people reacted. Drawn as it is. A count of `0` is not a reaction: do not render one. */
|
|
46
49
|
count: number
|
|
47
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* You are one of them: the accent tint and edge, and `aria-pressed`. The
|
|
52
|
+
* caller owns it — a press calls `onClick` and the pill keeps showing this
|
|
53
|
+
* value until the caller changes it.
|
|
54
|
+
*/
|
|
48
55
|
pressed?: boolean
|
|
49
56
|
/**
|
|
50
57
|
* Names the control. **Required** — the emoji is decorative and the count is
|
|
@@ -53,11 +60,13 @@ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonEleme
|
|
|
53
60
|
'aria-label': string
|
|
54
61
|
}
|
|
55
62
|
|
|
56
|
-
export function Reaction({ emoji, count, pressed = false, className,
|
|
63
|
+
export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
|
|
57
64
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
<Toggle
|
|
66
|
+
// Controlled: a reaction is yours when the data says so, not when the
|
|
67
|
+
// pill was last pressed — a write can fail, and another client can
|
|
68
|
+
// change it.
|
|
69
|
+
pressed={pressed}
|
|
61
70
|
className={cn(
|
|
62
71
|
// Chip's pill at a control's height, so a reaction and a status chip
|
|
63
72
|
// read as the same family — 24px matches Button `small`.
|
|
@@ -78,16 +87,16 @@ export function Reaction({ emoji, count, pressed = false, className, type, ...pr
|
|
|
78
87
|
>
|
|
79
88
|
{/* Decorative: the control is named by `aria-label`, and a glyph read
|
|
80
89
|
aloud is noise. 16px so the emoji is legible at chip scale. */}
|
|
81
|
-
<span aria-hidden="true" className="shrink-0 text-
|
|
90
|
+
<span aria-hidden="true" className="shrink-0 text-body-1 leading-none">
|
|
82
91
|
{emoji}
|
|
83
92
|
</span>
|
|
84
93
|
{/* `text-chip` stands alone here, so there is nothing to merge it with.
|
|
85
94
|
The old note said it must NEVER be merged; that stopped being true
|
|
86
95
|
when `cn()` was taught the ramp — `chip` is in it, and `cn.test.ts`
|
|
87
96
|
pins that. */}
|
|
88
|
-
<span className="text-chip signal:font-mono signal:text-
|
|
97
|
+
<span className="text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums">
|
|
89
98
|
{count}
|
|
90
99
|
</span>
|
|
91
|
-
</
|
|
100
|
+
</Toggle>
|
|
92
101
|
)
|
|
93
102
|
}
|
package/src/ReactionPicker.tsx
CHANGED
|
@@ -77,7 +77,7 @@ export function ReactionPicker({ options, onSelect, 'aria-label': ariaLabel = 'R
|
|
|
77
77
|
/* Peek's geometry, verbatim: a 28px square rather than the
|
|
78
78
|
IconButton's 24, and the emoji at 18px — larger than a `Reaction`
|
|
79
79
|
pill's 16, because here the emoji is the whole control. */
|
|
80
|
-
className="size-7 text-
|
|
80
|
+
className="size-7 text-h3 leading-none"
|
|
81
81
|
>
|
|
82
82
|
{/* Decorative, as on `Reaction`: the control is named above. */}
|
|
83
83
|
<span aria-hidden="true">{option.emoji}</span>
|
|
@@ -22,7 +22,7 @@ export const HeadingAList: Story = {
|
|
|
22
22
|
<SectionLabel>Recent</SectionLabel>
|
|
23
23
|
</div>
|
|
24
24
|
{['Quarterly plan', 'Reading list', 'Archive'].map((row) => (
|
|
25
|
-
<div key={row} className="rounded-lg px-2 py-1.5 text-
|
|
25
|
+
<div key={row} className="rounded-lg px-2 py-1.5 text-body-2 text-text-primary hover:bg-bg-hover">
|
|
26
26
|
{row}
|
|
27
27
|
</div>
|
|
28
28
|
))}
|
package/src/SectionLabel.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export function SectionLabel({ children, className }: { children: ReactNode; cla
|
|
|
18
18
|
return (
|
|
19
19
|
<span
|
|
20
20
|
className={cn(
|
|
21
|
-
'text-
|
|
21
|
+
'text-h5 leading-3 text-text-primary signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
|
|
22
22
|
className,
|
|
23
23
|
)}
|
|
24
24
|
>
|
package/src/Select.tsx
CHANGED
|
@@ -88,8 +88,8 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
88
88
|
'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
89
89
|
'focus-visible:border-border-focus aria-expanded:border-border-focus',
|
|
90
90
|
'signal:transition-shadow signal:focus-visible:shadow-focus-ring',
|
|
91
|
-
size === 'default' && 'px-3 py-2 text-
|
|
92
|
-
size === 'small' && 'h-6 px-2 text-
|
|
91
|
+
size === 'default' && 'px-3 py-2 text-input-value',
|
|
92
|
+
size === 'small' && 'h-6 px-2 text-caption',
|
|
93
93
|
className,
|
|
94
94
|
)}
|
|
95
95
|
>
|
|
@@ -167,7 +167,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
167
167
|
>
|
|
168
168
|
<span className="flex min-w-0 items-center gap-2">
|
|
169
169
|
{option.leading && <span className="flex shrink-0 items-center">{option.leading}</span>}
|
|
170
|
-
<BaseSelect.ItemText className="truncate text-
|
|
170
|
+
<BaseSelect.ItemText className="truncate text-body-2 text-text-primary">{option.label}</BaseSelect.ItemText>
|
|
171
171
|
</span>
|
|
172
172
|
<BaseSelect.ItemIndicator
|
|
173
173
|
render={<IconCheck size={16} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
package/src/TextInput.mdx
CHANGED
|
@@ -14,6 +14,10 @@ border in every theme.
|
|
|
14
14
|
|
|
15
15
|
- One line of text in a form or dialog — a name, a title, a URL. Label it
|
|
16
16
|
with a **Field**.
|
|
17
|
+
- **`size="small"`** in a dense row beside small **Select**s: 24px tall,
|
|
18
|
+
12px text, the small Select's padding. Default is 38px.
|
|
19
|
+
|
|
20
|
+
<Canvas of={TextInputStories.BesideSmallSelects} />
|
|
17
21
|
|
|
18
22
|
## When not
|
|
19
23
|
|
|
@@ -39,6 +43,8 @@ import { TextInput } from '@estiva-app/ui'
|
|
|
39
43
|
- **Outside a `Field`, name it yourself** with `aria-label`: a placeholder
|
|
40
44
|
is not a name.
|
|
41
45
|
- `disabled` gets the disabled surface and text; say why with a tooltip.
|
|
46
|
+
- `size` is this component's (`default` or `small`), not the native attribute
|
|
47
|
+
that counts characters. Set a width with the layout around it.
|
|
42
48
|
|
|
43
49
|
## Props
|
|
44
50
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
1
2
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { Select } from './Select'
|
|
2
4
|
import { TextInput } from './TextInput'
|
|
3
5
|
|
|
4
6
|
const meta = {
|
|
@@ -16,3 +18,46 @@ type Story = StoryObj<typeof meta>
|
|
|
16
18
|
export const Empty: Story = {}
|
|
17
19
|
export const Filled: Story = { args: { defaultValue: 'Q3 launch planning' } }
|
|
18
20
|
export const Disabled: Story = { args: { defaultValue: 'Read only', disabled: true } }
|
|
21
|
+
|
|
22
|
+
/** 24px and 12px text — the small `Select`'s size, for a dense row. */
|
|
23
|
+
export const Small: Story = { args: { size: 'small', placeholder: 'Label…' } }
|
|
24
|
+
|
|
25
|
+
function DenseRow() {
|
|
26
|
+
const [first, setFirst] = useState('one')
|
|
27
|
+
const [second, setSecond] = useState('a')
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-center gap-1">
|
|
30
|
+
<div className="w-28">
|
|
31
|
+
<Select
|
|
32
|
+
ariaLabel="First"
|
|
33
|
+
size="small"
|
|
34
|
+
value={first}
|
|
35
|
+
onChange={setFirst}
|
|
36
|
+
options={[
|
|
37
|
+
{ value: 'one', label: 'Option one' },
|
|
38
|
+
{ value: 'two', label: 'Option two' },
|
|
39
|
+
]}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
<div className="w-24">
|
|
43
|
+
<Select
|
|
44
|
+
ariaLabel="Second"
|
|
45
|
+
size="small"
|
|
46
|
+
value={second}
|
|
47
|
+
onChange={setSecond}
|
|
48
|
+
options={[
|
|
49
|
+
{ value: 'a', label: 'Item' },
|
|
50
|
+
{ value: 'b', label: 'Other item' },
|
|
51
|
+
]}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
<TextInput size="small" aria-label="Value" placeholder="Label…" className="min-w-0 flex-1" />
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Beside two small selects, in one row: the three are the same height, and the text sits on one line. */
|
|
60
|
+
export const BesideSmallSelects: Story = {
|
|
61
|
+
parameters: { controls: { disable: true } },
|
|
62
|
+
render: () => <DenseRow />,
|
|
63
|
+
}
|