@estiva-app/ui 0.13.1 → 0.15.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 (91) hide show
  1. package/README.md +28 -0
  2. package/dist/Avatar.d.ts +1 -1
  3. package/dist/Avatar.d.ts.map +1 -1
  4. package/dist/AvatarGroup.d.ts.map +1 -1
  5. package/dist/ChipInput.d.ts +14 -1
  6. package/dist/ChipInput.d.ts.map +1 -1
  7. package/dist/Divider.d.ts +5 -2
  8. package/dist/Divider.d.ts.map +1 -1
  9. package/dist/EmptyState.d.ts +3 -0
  10. package/dist/EmptyState.d.ts.map +1 -1
  11. package/dist/Property.d.ts +7 -0
  12. package/dist/Property.d.ts.map +1 -1
  13. package/dist/Reaction.d.ts +12 -6
  14. package/dist/Reaction.d.ts.map +1 -1
  15. package/dist/TextInput.d.ts +10 -1
  16. package/dist/TextInput.d.ts.map +1 -1
  17. package/dist/Toast.d.ts +19 -6
  18. package/dist/Toast.d.ts.map +1 -1
  19. package/dist/Toolbar.d.ts +1 -1
  20. package/dist/Toolbar.d.ts.map +1 -1
  21. package/dist/eslint/escape.d.ts +61 -0
  22. package/dist/eslint/escape.d.ts.map +1 -0
  23. package/dist/eslint/index.d.ts +45 -0
  24. package/dist/eslint/index.d.ts.map +1 -0
  25. package/dist/eslint/index.js +118 -0
  26. package/dist/eslint/index.js.map +7 -0
  27. package/dist/eslint/no-raw-button.d.ts +11 -0
  28. package/dist/eslint/no-raw-button.d.ts.map +1 -0
  29. package/dist/index.js +276 -249
  30. package/dist/index.js.map +3 -3
  31. package/package.json +5 -1
  32. package/src/AttachmentCard.tsx +14 -14
  33. package/src/Avatar.mdx +7 -2
  34. package/src/Avatar.picture.test.tsx +60 -0
  35. package/src/Avatar.stories.tsx +1 -1
  36. package/src/Avatar.tsx +50 -14
  37. package/src/AvatarGroup.tsx +1 -0
  38. package/src/Breadcrumb.tsx +2 -2
  39. package/src/Checkbox.tsx +1 -1
  40. package/src/Chip.tsx +1 -1
  41. package/src/ChipInput.mdx +9 -0
  42. package/src/ChipInput.stories.tsx +18 -0
  43. package/src/ChipInput.test.tsx +18 -0
  44. package/src/ChipInput.tsx +18 -4
  45. package/src/DialogShell.stories.tsx +1 -1
  46. package/src/Divider.mdx +3 -2
  47. package/src/Divider.test.tsx +15 -0
  48. package/src/Divider.tsx +11 -9
  49. package/src/EditableText.stories.tsx +2 -2
  50. package/src/EmptyState.mdx +3 -2
  51. package/src/EmptyState.stories.tsx +7 -3
  52. package/src/EmptyState.test.tsx +15 -0
  53. package/src/EmptyState.tsx +4 -1
  54. package/src/IconButton.stories.tsx +1 -1
  55. package/src/Kbd.tsx +3 -3
  56. package/src/Menu.stories.tsx +2 -2
  57. package/src/Menu.tsx +3 -3
  58. package/src/Popover.stories.tsx +1 -1
  59. package/src/PreviewCard.stories.tsx +5 -5
  60. package/src/Property.mdx +8 -0
  61. package/src/Property.stories.tsx +4 -4
  62. package/src/Property.test.tsx +44 -0
  63. package/src/Property.tsx +23 -8
  64. package/src/RailItem.tsx +1 -1
  65. package/src/Reaction.mdx +14 -3
  66. package/src/Reaction.test.tsx +62 -0
  67. package/src/Reaction.tsx +21 -12
  68. package/src/ReactionPicker.tsx +1 -1
  69. package/src/SectionLabel.stories.tsx +1 -1
  70. package/src/SectionLabel.tsx +1 -1
  71. package/src/Select.test.tsx +20 -3
  72. package/src/Select.tsx +3 -3
  73. package/src/TextInput.mdx +6 -0
  74. package/src/TextInput.stories.tsx +45 -0
  75. package/src/TextInput.test.tsx +38 -0
  76. package/src/TextInput.tsx +16 -4
  77. package/src/Textarea.tsx +1 -1
  78. package/src/Toast.mdx +28 -4
  79. package/src/Toast.stories.tsx +12 -1
  80. package/src/Toast.test.tsx +137 -0
  81. package/src/Toast.tsx +127 -83
  82. package/src/Toolbar.tsx +4 -2
  83. package/src/cn.ts +1 -1
  84. package/src/eslint/escape.ts +112 -0
  85. package/src/eslint/index.test.ts +82 -0
  86. package/src/eslint/index.ts +100 -0
  87. package/src/eslint/no-raw-button.test.ts +118 -0
  88. package/src/eslint/no-raw-button.ts +39 -0
  89. package/stories/Choosing.mdx +1 -0
  90. package/stories/TokensPage.tsx +6 -1
  91. package/tailwind-preset.js +7 -0
@@ -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-body-2 text-text-secondary', className)}>{message}</p>
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-[12px] leading-[120%] text-text-muted">{variant}</span>
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-[12px] leading-[120%] font-normal text-text-secondary',
34
- 'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-[10px]',
35
- 'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-[10px]',
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
  >
@@ -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-[14px] leading-[140%] text-text-primary">Item one</span>
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-[14px] leading-[140%] text-text-primary">
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-[14px] leading-[140%]', destructive ? 'text-error-default' : 'text-text-primary')}>
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-[12px] leading-[120%] text-text-secondary">{description}</span>}
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-[9px] font-medium leading-[115%] signal:font-mono signal:text-[9.5px] signal:tracking-[0.04em]">{target}</span>
541
+ <span className="text-menu signal:font-mono signal:text-small signal:tracking-wider">{target}</span>
542
542
  )}
543
543
  </span>
544
544
  )
@@ -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-[14px] leading-[1.6] text-text-primary outline-none">
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-[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
  >
@@ -32,6 +32,23 @@ function Controlled({ initial = 'todo', ...rest }: { initial?: string } & Partia
32
32
  return <Select value={value} onChange={setValue} options={STATUSES} ariaLabel="Status" {...rest} />
33
33
  }
34
34
 
35
+ /**
36
+ * The open list, once it has focus.
37
+ *
38
+ * The list is findable as soon as it opens, but Base UI moves focus into it an
39
+ * animation frame later. A key pressed before that frame goes to the trigger:
40
+ * Tab then moves focus into the list instead of closing it, and an arrow or End
41
+ * is lost. A person presses keys at a list that has focus, so these tests wait
42
+ * for it. CI failed "Tab closes the list" this way on 15 September (PR #36);
43
+ * with every frame 100ms late the three tests that use this failed 3 runs of 3,
44
+ * focus measured still on the trigger when Tab was pressed.
45
+ */
46
+ async function focusedList() {
47
+ const list = await screen.findByRole('listbox')
48
+ await waitFor(() => expect(list.contains(document.activeElement)).toBe(true))
49
+ return list
50
+ }
51
+
35
52
  describe('Select', () => {
36
53
  it('is a button naming itself, showing the chosen label', () => {
37
54
  render(<Controlled initial="in_progress" />)
@@ -65,7 +82,7 @@ describe('Select', () => {
65
82
  const trigger = screen.getByRole('combobox', { name: 'Status' })
66
83
  trigger.focus()
67
84
  await user.keyboard('{ArrowDown}')
68
- expect(await screen.findByRole('listbox')).toBeTruthy()
85
+ expect(await focusedList()).toBeTruthy()
69
86
  await user.keyboard('{ArrowDown}{Enter}')
70
87
  expect(onChange).toHaveBeenCalledWith('in_progress')
71
88
  expect(document.activeElement).toBe(trigger)
@@ -130,7 +147,7 @@ describe('Select', () => {
130
147
  render(<Controlled initial="in_progress" />)
131
148
  screen.getByRole('combobox', { name: 'Status' }).focus()
132
149
  await user.keyboard('{ArrowDown}')
133
- expect(await screen.findByRole('listbox')).toBeTruthy()
150
+ expect(await focusedList()).toBeTruthy()
134
151
  const highlighted = () => screen.getAllByRole('option').find((o) => o.getAttribute('data-highlighted') !== null)?.textContent
135
152
  await user.keyboard('{End}')
136
153
  expect(highlighted()).toContain('Done')
@@ -142,7 +159,7 @@ describe('Select', () => {
142
159
  const user = userEvent.setup()
143
160
  render(<Controlled />)
144
161
  await user.click(screen.getByRole('combobox', { name: 'Status' }))
145
- expect(await screen.findByRole('listbox')).toBeTruthy()
162
+ expect(await focusedList()).toBeTruthy()
146
163
  await user.tab()
147
164
  await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
148
165
  })
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
+ }