@estiva-app/ui 0.13.1 → 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/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 +272 -245
- 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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the TextInput page claims, pinned: a native input with a ref, the two
|
|
4
|
+
* sizes, and the small one drawn with the small Select's classes.
|
|
5
|
+
*/
|
|
6
|
+
import { createRef } from 'react'
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import { TextInput } from './TextInput'
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup)
|
|
12
|
+
|
|
13
|
+
describe('TextInput', () => {
|
|
14
|
+
it('is a native text input that forwards its ref and native props', () => {
|
|
15
|
+
const ref = createRef<HTMLInputElement>()
|
|
16
|
+
render(<TextInput ref={ref} aria-label="Title" placeholder="Untitled" />)
|
|
17
|
+
const input = screen.getByRole('textbox', { name: 'Title' })
|
|
18
|
+
expect(ref.current).toBe(input)
|
|
19
|
+
expect(input.getAttribute('type')).toBe('text')
|
|
20
|
+
expect(input.getAttribute('placeholder')).toBe('Untitled')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('default size: 14px text with 12px by 8px padding', () => {
|
|
24
|
+
render(<TextInput aria-label="Title" />)
|
|
25
|
+
const classes = screen.getByRole('textbox').className.split(' ')
|
|
26
|
+
for (const c of ['px-3', 'py-2', 'text-input-value']) expect(classes).toContain(c)
|
|
27
|
+
expect(classes).not.toContain('h-6')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('small: 24px tall, 12px text, 8px side padding — and no native size attribute', () => {
|
|
31
|
+
render(<TextInput aria-label="Title" size="small" />)
|
|
32
|
+
const input = screen.getByRole('textbox')
|
|
33
|
+
const classes = input.className.split(' ')
|
|
34
|
+
for (const c of ['h-6', 'px-2', 'text-caption']) expect(classes).toContain(c)
|
|
35
|
+
for (const c of ['px-3', 'py-2', 'text-input-value']) expect(classes).not.toContain(c)
|
|
36
|
+
expect(input.hasAttribute('size')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
package/src/TextInput.tsx
CHANGED
|
@@ -15,16 +15,28 @@ import { cn } from './cn'
|
|
|
15
15
|
* context behind it — is gone. Outside a `Field` it behaves as before: the
|
|
16
16
|
* field context has a default, so nothing has to be wrapped.
|
|
17
17
|
*/
|
|
18
|
-
export
|
|
18
|
+
export interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
|
19
|
+
/**
|
|
20
|
+
* `small` is 24px tall with 12px text — the small `Select`'s size, so a
|
|
21
|
+
* field can share a dense row with small selects (D67, 2026-09-13: Peek's
|
|
22
|
+
* reference widget hand-built one because this was 38px). Replaces the
|
|
23
|
+
* native `size` attribute, which counts characters and which no caller
|
|
24
|
+
* used; set a width with the layout instead.
|
|
25
|
+
*/
|
|
26
|
+
size?: 'default' | 'small'
|
|
27
|
+
}
|
|
19
28
|
|
|
20
|
-
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', ...props }, ref) {
|
|
29
|
+
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput({ className, type = 'text', size = 'default', ...props }, ref) {
|
|
21
30
|
return (
|
|
22
31
|
<Input
|
|
23
32
|
ref={ref}
|
|
24
33
|
type={type}
|
|
25
34
|
className={cn(
|
|
26
|
-
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg
|
|
27
|
-
|
|
35
|
+
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg',
|
|
36
|
+
// The small size is the small Select's trigger, class for class.
|
|
37
|
+
size === 'default' && 'px-3 py-2 text-input-value',
|
|
38
|
+
size === 'small' && 'h-6 px-2 text-caption',
|
|
39
|
+
'text-text-primary placeholder:text-text-muted',
|
|
28
40
|
'outline-none transition-colors',
|
|
29
41
|
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
30
42
|
'signal:transition-shadow signal:focus:shadow-focus-ring',
|
package/src/Textarea.tsx
CHANGED
|
@@ -22,7 +22,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
|
|
22
22
|
render={<textarea />}
|
|
23
23
|
className={cn(
|
|
24
24
|
'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
|
|
25
|
-
'text-
|
|
25
|
+
'text-input-value text-text-primary placeholder:text-text-muted',
|
|
26
26
|
'resize-none outline-none transition-colors',
|
|
27
27
|
'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
|
|
28
28
|
'signal:transition-shadow signal:focus:shadow-focus-ring',
|
package/src/Toast.mdx
CHANGED
|
@@ -48,13 +48,37 @@ const { showToast } = useToast()
|
|
|
48
48
|
showToast({ label: 'Link copied', type: 'success' })
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
- **
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
- **Three at once** (D7). The newest stands nearest the corner. A fourth
|
|
52
|
+
hides the oldest, which comes back when one of the three closes — so a
|
|
53
|
+
warning kept up with `durationMs: 0` is never lost under newer ones.
|
|
54
|
+
- Auto-dismiss is 5s; `durationMs: 0` keeps one up until it is dismissed.
|
|
55
|
+
The timers pause while the pointer is over the toasts, while focus is in
|
|
56
|
+
them, and while the window is in the background.
|
|
57
|
+
- **The action closes its own toast.** Pressing it runs `onAction`, if you
|
|
58
|
+
gave one, then closes that toast and no other. A Dismiss is just
|
|
59
|
+
`actionLabel: 'Dismiss'`.
|
|
60
|
+
- `showToast` returns the toast's id. `dismissToast(id)` closes that toast;
|
|
61
|
+
`dismissToast()` with no id closes **every** toast on screen.
|
|
62
|
+
- A toast can be swiped away towards its corner — left or down.
|
|
63
|
+
- It is Base UI's `Toast`. The stack is a region named "Notifications" that
|
|
64
|
+
a screen reader announces politely; an `error` toast is announced at once.
|
|
65
|
+
- **Each toast is a `dialog`** (not modal), so it can take focus. A test that
|
|
66
|
+
waits for "no dialog" after closing one will find the toast: ask for the
|
|
67
|
+
dialog by its name.
|
|
68
|
+
- Focus on a toast shows the browser's own outline, as the package's buttons
|
|
69
|
+
do.
|
|
55
70
|
- `useToast` outside a provider throws — mount the provider at the root,
|
|
56
71
|
once.
|
|
57
72
|
|
|
73
|
+
## Keys
|
|
74
|
+
|
|
75
|
+
| Key | What happens |
|
|
76
|
+
|---|---|
|
|
77
|
+
| F6 | Moves focus to the toasts, from anywhere on the page |
|
|
78
|
+
| Tab | Walks the toasts and their actions; Shift+Tab from the region goes back to where you were |
|
|
79
|
+
| Enter, Space | On an action: presses it |
|
|
80
|
+
| Escape | On a toast: closes it |
|
|
81
|
+
|
|
58
82
|
## Props
|
|
59
83
|
|
|
60
84
|
<Controls of={ToastStories.Neutral} />
|
package/src/Toast.stories.tsx
CHANGED
|
@@ -82,11 +82,22 @@ function ProviderDemo() {
|
|
|
82
82
|
>
|
|
83
83
|
With an action
|
|
84
84
|
</Button>
|
|
85
|
+
<Button
|
|
86
|
+
variant="muted"
|
|
87
|
+
onClick={() =>
|
|
88
|
+
showToast({ label: 'Not everything went through', type: 'warning', durationMs: 0, actionLabel: 'Dismiss' })
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
Keep one up
|
|
92
|
+
</Button>
|
|
85
93
|
</div>
|
|
86
94
|
)
|
|
87
95
|
}
|
|
88
96
|
|
|
89
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The provider in motion: bottom-left, gone after 5 s. Press a few times — three stand at once, the newest
|
|
99
|
+
* nearest the corner, and a fourth hides the oldest until there is room. Hovering the toasts pauses them.
|
|
100
|
+
*/
|
|
90
101
|
export const FromTheProvider: Story = {
|
|
91
102
|
parameters: { controls: { disable: true } },
|
|
92
103
|
render: () => (
|