@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.
Files changed (70) hide show
  1. package/dist/Avatar.d.ts +1 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Divider.d.ts +5 -2
  5. package/dist/Divider.d.ts.map +1 -1
  6. package/dist/EmptyState.d.ts +3 -0
  7. package/dist/EmptyState.d.ts.map +1 -1
  8. package/dist/Property.d.ts +7 -0
  9. package/dist/Property.d.ts.map +1 -1
  10. package/dist/Reaction.d.ts +12 -6
  11. package/dist/Reaction.d.ts.map +1 -1
  12. package/dist/TextInput.d.ts +10 -1
  13. package/dist/TextInput.d.ts.map +1 -1
  14. package/dist/Toast.d.ts +19 -6
  15. package/dist/Toast.d.ts.map +1 -1
  16. package/dist/Toolbar.d.ts +1 -1
  17. package/dist/Toolbar.d.ts.map +1 -1
  18. package/dist/index.js +272 -245
  19. package/dist/index.js.map +3 -3
  20. package/package.json +1 -1
  21. package/src/AttachmentCard.tsx +14 -14
  22. package/src/Avatar.mdx +7 -2
  23. package/src/Avatar.picture.test.tsx +60 -0
  24. package/src/Avatar.stories.tsx +1 -1
  25. package/src/Avatar.tsx +50 -14
  26. package/src/AvatarGroup.tsx +1 -0
  27. package/src/Breadcrumb.tsx +2 -2
  28. package/src/Checkbox.tsx +1 -1
  29. package/src/Chip.tsx +1 -1
  30. package/src/DialogShell.stories.tsx +1 -1
  31. package/src/Divider.mdx +3 -2
  32. package/src/Divider.test.tsx +15 -0
  33. package/src/Divider.tsx +11 -9
  34. package/src/EditableText.stories.tsx +2 -2
  35. package/src/EmptyState.mdx +3 -2
  36. package/src/EmptyState.stories.tsx +7 -3
  37. package/src/EmptyState.test.tsx +15 -0
  38. package/src/EmptyState.tsx +4 -1
  39. package/src/IconButton.stories.tsx +1 -1
  40. package/src/Kbd.tsx +3 -3
  41. package/src/Menu.stories.tsx +2 -2
  42. package/src/Menu.tsx +3 -3
  43. package/src/Popover.stories.tsx +1 -1
  44. package/src/PreviewCard.stories.tsx +5 -5
  45. package/src/Property.mdx +8 -0
  46. package/src/Property.stories.tsx +4 -4
  47. package/src/Property.test.tsx +44 -0
  48. package/src/Property.tsx +23 -8
  49. package/src/RailItem.tsx +1 -1
  50. package/src/Reaction.mdx +14 -3
  51. package/src/Reaction.test.tsx +62 -0
  52. package/src/Reaction.tsx +21 -12
  53. package/src/ReactionPicker.tsx +1 -1
  54. package/src/SectionLabel.stories.tsx +1 -1
  55. package/src/SectionLabel.tsx +1 -1
  56. package/src/Select.tsx +3 -3
  57. package/src/TextInput.mdx +6 -0
  58. package/src/TextInput.stories.tsx +45 -0
  59. package/src/TextInput.test.tsx +38 -0
  60. package/src/TextInput.tsx +16 -4
  61. package/src/Textarea.tsx +1 -1
  62. package/src/Toast.mdx +28 -4
  63. package/src/Toast.stories.tsx +12 -1
  64. package/src/Toast.test.tsx +137 -0
  65. package/src/Toast.tsx +127 -83
  66. package/src/Toolbar.tsx +4 -2
  67. package/src/cn.ts +1 -1
  68. package/stories/Choosing.mdx +1 -0
  69. package/stories/TokensPage.tsx +6 -1
  70. 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-[12px] leading-[1.45] text-text-primary">{v}</span>
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-[12px] leading-[1.45] text-text-secondary">
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-[14px] leading-[140%] text-text-primary">{label}</span>
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-[12px] leading-[1.45] text-text-secondary">
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-[12px] leading-[1.45] text-text-primary">Value {i + 1}</span>
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} />
@@ -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-[12px] leading-[120%] text-text-primary" />
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-[12px] leading-[120%] text-text-secondary">2d ago</span>
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-[14px] leading-[140%] text-text-primary" />,
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-[12px] leading-[120%] text-text-muted">—</span> },
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
- <div className={cn('flex flex-col gap-3', className)}>
33
- <span className="text-menu uppercase tracking-[0.08em] text-text-secondary">{label}</span>
34
- {children}
35
- </div>
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
- <div className={cn('flex items-center gap-2', className)}>
40
- <span className="w-[68px] shrink-0 text-caption text-text-secondary">{label}</span>
41
- {children}
42
- </div>
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-[9px] font-medium leading-[115%]', active ? 'text-text-primary' : 'text-text-secondary')}>
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`, so "mine" reaches assistive
46
- tech as a toggle rather than as a colour. Every native prop passes
47
- through; `type` defaults to `"button"`.
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
- * It is a real `<button>` with `aria-pressed`, so the state reaches assistive
26
- * tech as a toggle rather than as a colour.
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-is; `0` is not a reaction and is not drawn. */
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
- /** You are one of them: the accent tint and edge, and `aria-pressed`. */
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, type, ...props }: ReactionProps) {
63
+ export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
57
64
  return (
58
- <button
59
- type={type ?? 'button'}
60
- aria-pressed={pressed}
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-[16px] leading-none">
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-[10px] signal:font-semibold signal:tabular-nums">
97
+ <span className="text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums">
89
98
  {count}
90
99
  </span>
91
- </button>
100
+ </Toggle>
92
101
  )
93
102
  }
@@ -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-[18px] leading-none"
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-[14px] leading-[140%] text-text-primary hover:bg-bg-hover">
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
  ))}
@@ -18,7 +18,7 @@ export function SectionLabel({ children, className }: { children: ReactNode; cla
18
18
  return (
19
19
  <span
20
20
  className={cn(
21
- 'text-[12px] leading-[12px] font-medium text-text-primary signal:font-mono signal:text-[10px] signal:uppercase signal:tracking-[0.14em]',
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-[14px] leading-[1.4] font-normal',
92
- size === 'small' && 'h-6 px-2 text-[12px] leading-[1.4] font-normal',
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-[14px] leading-[140%] text-text-primary">{option.label}</BaseSelect.ItemText>
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 type TextInputProps = InputHTMLAttributes<HTMLInputElement>
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 px-3 py-2',
27
- 'text-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
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-[14px] leading-[1.4] font-normal text-text-primary placeholder:text-text-muted',
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
- - **One toast at a time** a new one replaces the standing one; nothing
52
- queues.
53
- - Auto-dismiss is 5s; `durationMs: 0` keeps one up until replaced or
54
- dismissed (`dismissToast`).
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} />
@@ -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
- /** The provider in motion: bottom-left, gone after 5 s, a new toast replaces the standing one. */
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: () => (