@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
@@ -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: () => (
@@ -0,0 +1,137 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Toast page claims, pinned: three at once with the newest nearest
4
+ * the corner, an announced region, the action closing its own toast, the
5
+ * timers, and the keys.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, render, screen, within } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { ToastProvider, useToast, type ToastOptions } from './Toast'
11
+
12
+ afterEach(() => {
13
+ cleanup()
14
+ vi.useRealTimers()
15
+ })
16
+
17
+ type Api = ReturnType<typeof useToast>
18
+ let api: Api
19
+ function Grab() {
20
+ api = useToast()
21
+ return null
22
+ }
23
+
24
+ const mount = () => render(<ToastProvider><Grab /></ToastProvider>)
25
+ const show = (opts: ToastOptions) => {
26
+ let id = ''
27
+ act(() => {
28
+ id = api.showToast(opts)
29
+ })
30
+ return id
31
+ }
32
+ const region = () => screen.getByRole('region', { name: 'Notifications' })
33
+ /** The toasts a reader can see: Base UI keeps a limited one in the DOM, hidden. */
34
+ const visible = () =>
35
+ within(region())
36
+ .queryAllByRole('dialog')
37
+ .filter((el) => !el.hasAttribute('data-limited'))
38
+ .map((el) => el.textContent)
39
+
40
+ describe('ToastProvider', () => {
41
+ it('draws the toast in a region a screen reader announces', () => {
42
+ mount()
43
+ show({ label: 'Changes saved' })
44
+ expect(region().getAttribute('aria-live')).toBe('polite')
45
+ expect(within(region()).getByRole('dialog', { name: 'Changes saved' })).toBeTruthy()
46
+ })
47
+
48
+ it('a failure is announced at once', () => {
49
+ mount()
50
+ show({ label: 'That did not go through', type: 'error' })
51
+ expect(screen.getByRole('alert').textContent).toContain('That did not go through')
52
+ })
53
+
54
+ it('shows three at once, and a fourth hides the oldest until there is room (D7)', () => {
55
+ mount()
56
+ const first = show({ label: 'One', durationMs: 0 })
57
+ show({ label: 'Two', durationMs: 0 })
58
+ show({ label: 'Three', durationMs: 0 })
59
+ expect(visible()).toEqual(['Three', 'Two', 'One'])
60
+ const fourth = show({ label: 'Four', durationMs: 0 })
61
+ expect(visible()).toEqual(['Four', 'Three', 'Two'])
62
+ const oldest = within(region()).getByRole('dialog', { name: 'One', hidden: true })
63
+ expect(oldest.hasAttribute('data-limited')).toBe(true)
64
+ expect(oldest.className).toContain('data-[limited]:hidden')
65
+ act(() => api.dismissToast(fourth))
66
+ expect(visible()).toEqual(['Three', 'Two', 'One'])
67
+ act(() => api.dismissToast(first))
68
+ expect(visible()).toEqual(['Three', 'Two'])
69
+ })
70
+
71
+ it('the newest stands nearest the corner: first in the list, and the stack is reversed', () => {
72
+ mount()
73
+ show({ label: 'Older' })
74
+ show({ label: 'Newer' })
75
+ expect(visible()).toEqual(['Newer', 'Older'])
76
+ expect(region().className).toContain('flex-col-reverse')
77
+ expect(region().className).toContain('bottom-4')
78
+ expect(region().className).toContain('left-4')
79
+ })
80
+
81
+ it('the action runs onAction and closes its own toast, and no other', async () => {
82
+ mount()
83
+ const onAction = vi.fn()
84
+ show({ label: 'Standing', durationMs: 0 })
85
+ show({ label: 'Item removed', actionLabel: 'Undo', onAction, durationMs: 0 })
86
+ await userEvent.click(screen.getByRole('button', { name: 'Undo' }))
87
+ expect(onAction).toHaveBeenCalledTimes(1)
88
+ expect(visible()).toEqual(['Standing'])
89
+ })
90
+
91
+ it('an action with a label and no handler is a Dismiss', async () => {
92
+ mount()
93
+ show({ label: 'Something needs knowing', type: 'warning', durationMs: 0, actionLabel: 'Dismiss' })
94
+ await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
95
+ expect(visible()).toEqual([])
96
+ })
97
+
98
+ it('closes after 5 seconds by default; durationMs: 0 stays up', () => {
99
+ vi.useFakeTimers()
100
+ mount()
101
+ show({ label: 'Fades' })
102
+ show({ label: 'Stays', durationMs: 0 })
103
+ act(() => vi.advanceTimersByTime(4999))
104
+ expect(visible()).toEqual(['Stays', 'Fades'])
105
+ act(() => vi.advanceTimersByTime(1))
106
+ expect(visible()).toEqual(['Stays'])
107
+ })
108
+
109
+ it('dismissToast with an id closes that toast; with none, every toast', () => {
110
+ mount()
111
+ const a = show({ label: 'A', durationMs: 0 })
112
+ show({ label: 'B', durationMs: 0 })
113
+ act(() => api.dismissToast(a))
114
+ expect(visible()).toEqual(['B'])
115
+ show({ label: 'C', durationMs: 0 })
116
+ act(() => api.dismissToast())
117
+ expect(visible()).toEqual([])
118
+ })
119
+
120
+ it('F6 moves focus into the toasts; Escape closes the focused one', async () => {
121
+ mount()
122
+ show({ label: 'Changes saved', durationMs: 0 })
123
+ await userEvent.keyboard('{F6}')
124
+ expect(region().contains(document.activeElement)).toBe(true)
125
+ await userEvent.tab()
126
+ const toast = screen.getByRole('dialog', { name: 'Changes saved' })
127
+ expect(document.activeElement).toBe(toast)
128
+ await userEvent.keyboard('{Escape}')
129
+ expect(visible()).toEqual([])
130
+ })
131
+
132
+ it('useToast outside a provider throws', () => {
133
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
134
+ expect(() => render(<Grab />)).toThrow('useToast must be used within ToastProvider')
135
+ spy.mockRestore()
136
+ })
137
+ })
package/src/Toast.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { createContext, useContext, useMemo, type ReactNode } from 'react'
2
+ import { Toast as BaseToast } from '@base-ui/react/toast'
3
3
  import { IconAlertCircle, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
4
4
  import { cn } from './cn'
5
5
 
@@ -12,9 +12,12 @@ import { cn } from './cn'
12
12
  * the same dark overlay pill; the type lives in the icon's colour and glow,
13
13
  * not the surface.
14
14
  *
15
- * The provider owns positioning (bottom-left), the portal, and auto-dismiss
16
- * (5 s; pass `durationMs: 0` to keep one up). One toast at a time — a new
17
- * one replaces the standing one, it does not queue.
15
+ * The provider sits on Base UI's `Toast` since stage 6 of the migration
16
+ * (2026-09-14). Base UI owns the portal, the timers, the stack and what a
17
+ * screen reader hears; this file owns how a toast looks and where the stack
18
+ * stands (bottom-left). **Up to three show at once** (D7): the newest nearest
19
+ * the corner, a fourth hides the oldest until one of the three closes. Before
20
+ * stage 6 a new toast replaced the standing one, and nothing announced either.
18
21
  */
19
22
  export type ToastType = 'success' | 'brand' | 'neutral' | 'warning' | 'error'
20
23
 
@@ -42,11 +45,11 @@ export interface ToastProps {
42
45
  // Signal: every toast is the same dark overlay pill (v3) — the type lives in
43
46
  // the icon color + glow, not the surface.
44
47
  const SURFACE_STYLES: Record<ToastType, string> = {
45
- success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
46
- brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
47
- neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
48
- warning: 'bg-warning-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
49
- error: 'bg-error-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
48
+ success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
49
+ brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
50
+ neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-md',
51
+ warning: 'bg-warning-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
52
+ error: 'bg-error-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-md',
50
53
  }
51
54
 
52
55
  /**
@@ -83,35 +86,48 @@ const ACTION_BORDER_STYLES: Record<ToastType, string> = {
83
86
  error: 'signal:border signal:border-border-default signal:hover:border-border-strong',
84
87
  }
85
88
 
89
+ const LABEL_CLASSES = 'text-body-2 text-text-primary whitespace-nowrap'
90
+
91
+ const pillClassName = (type: ToastType, hasAction: boolean, className?: string) =>
92
+ cn(
93
+ 'inline-flex items-center min-h-[32px] pl-2 py-1 rounded-lg shadow-lg',
94
+ // Without an action the label needs real right padding; the action
95
+ // button brings its own edge, so the tight pr-1 only applies there.
96
+ hasAction ? 'pr-1 gap-[46px]' : 'pr-3',
97
+ SURFACE_STYLES[type],
98
+ className,
99
+ )
100
+
101
+ const actionClassName = (type: ToastType) =>
102
+ cn(
103
+ 'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
104
+ ACTION_BORDER_STYLES[type],
105
+ type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
106
+ )
107
+
108
+ function LeadingIcon({ type }: { type: ToastType }) {
109
+ const Icon = ICONS[type]
110
+ return <Icon size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
111
+ }
112
+
113
+ const ACTION_LABEL_CLASSES = 'text-btn-small text-text-primary whitespace-nowrap'
114
+
115
+ /**
116
+ * One toast, drawn in place. What the provider shows is this pill; draw it
117
+ * yourself only where a page needs a toast's look without its timing — a
118
+ * story, a picture of the set.
119
+ */
86
120
  export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel, onAction, className }: ToastProps) {
87
121
  const hasAction = !!(actionLabel && onAction)
88
- const LeadingIcon = ICONS[type]
89
122
  return (
90
- <div
91
- className={cn(
92
- 'inline-flex items-center min-h-[32px] pl-2 py-1 rounded-lg shadow-lg',
93
- // Without an action the label needs real right padding; the action
94
- // button brings its own edge, so the tight pr-1 only applies there.
95
- hasAction ? 'pr-1 gap-[46px]' : 'pr-3',
96
- SURFACE_STYLES[type],
97
- className,
98
- )}
99
- >
123
+ <div className={pillClassName(type, hasAction, className)}>
100
124
  <div className="flex items-center gap-2 shrink-0">
101
- {leadingIcon && <LeadingIcon size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />}
102
- <span className="font-normal text-[14px] leading-[1.4] text-text-primary whitespace-nowrap">{label}</span>
125
+ {leadingIcon && <LeadingIcon type={type} />}
126
+ <span className={LABEL_CLASSES}>{label}</span>
103
127
  </div>
104
128
  {hasAction && (
105
- <button
106
- type="button"
107
- onClick={onAction}
108
- className={cn(
109
- 'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
110
- ACTION_BORDER_STYLES[type],
111
- type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
112
- )}
113
- >
114
- <span className="font-medium text-[12px] leading-[12px] text-text-primary whitespace-nowrap">{actionLabel}</span>
129
+ <button type="button" onClick={onAction} className={actionClassName(type)}>
130
+ <span className={ACTION_LABEL_CLASSES}>{actionLabel}</span>
115
131
  </button>
116
132
  )}
117
133
  </div>
@@ -122,7 +138,10 @@ export interface ToastOptions {
122
138
  label: string
123
139
  /** Which of the five this is — it decides the surface and the leading icon. Defaults to 'neutral'. */
124
140
  type?: ToastType
125
- /** When set, renders a clickable action on the right side. */
141
+ /**
142
+ * Renders an action on the right side. Pressing it runs `onAction`, if
143
+ * given, and closes this toast — so a Dismiss needs only its label.
144
+ */
126
145
  actionLabel?: string
127
146
  onAction?: () => void
128
147
  /** Show the leading icon — a check, a `!` or an `×`, per `type`. Defaults to true. */
@@ -131,70 +150,95 @@ export interface ToastOptions {
131
150
  durationMs?: number
132
151
  }
133
152
 
134
- interface ActiveToast extends ToastOptions {
135
- id: number
153
+ interface ToastValue {
154
+ /** Shows a toast and returns its id, for `dismissToast`. */
155
+ showToast: (opts: ToastOptions) => string
156
+ /** Closes the toast with this id; with no id, closes every toast on screen. */
157
+ dismissToast: (id?: string) => void
136
158
  }
137
159
 
138
- interface ToastValue {
139
- showToast: (opts: ToastOptions) => void
140
- dismissToast: () => void
160
+ /** What a toast carries beyond Base UI's own fields. */
161
+ interface ToastData {
162
+ leadingIcon: boolean
163
+ actionLabel?: string
164
+ onAction?: () => void
141
165
  }
142
166
 
143
167
  const ToastContext = createContext<ToastValue | null>(null)
144
168
 
145
- let toastSeq = 0
169
+ /** D7: three on screen at once. */
170
+ const VISIBLE_TOASTS = 3
146
171
 
147
172
  export function ToastProvider({ children }: { children: ReactNode }) {
148
- const [toast, setToast] = useState<ActiveToast | null>(null)
149
-
150
- const showToast = useCallback((opts: ToastOptions) => {
151
- setToast({ ...opts, id: ++toastSeq })
152
- }, [])
153
-
154
- const dismissToast = useCallback(() => {
155
- setToast(null)
156
- }, [])
157
-
158
- useEffect(() => {
159
- if (!toast) return
160
- const duration = toast.durationMs ?? 5000
161
- if (duration <= 0) return
162
- const timer = setTimeout(() => {
163
- setToast((current) => (current?.id === toast.id ? null : current))
164
- }, duration)
165
- return () => clearTimeout(timer)
166
- }, [toast])
167
-
168
- const value = useMemo<ToastValue>(() => ({ showToast, dismissToast }), [showToast, dismissToast])
173
+ // One manager per provider, so `useToast` can add and close without
174
+ // re-rendering every caller each time the list changes.
175
+ const manager = useMemo(() => BaseToast.createToastManager<ToastData>(), [])
176
+ const value = useMemo<ToastValue>(
177
+ () => ({
178
+ showToast: ({ label, type = 'neutral', leadingIcon = true, actionLabel, onAction, durationMs = 5000 }) =>
179
+ manager.add({
180
+ title: label,
181
+ type,
182
+ timeout: durationMs,
183
+ // A failure interrupts; everything else waits for a pause.
184
+ priority: type === 'error' ? 'high' : 'low',
185
+ data: { leadingIcon, actionLabel, onAction },
186
+ }),
187
+ dismissToast: (id) => manager.close(id),
188
+ }),
189
+ [manager],
190
+ )
169
191
 
170
192
  return (
171
193
  <ToastContext.Provider value={value}>
172
- {children}
173
- {toast &&
174
- createPortal(
175
- <div className="fixed bottom-4 left-4 z-[100] pointer-events-none">
176
- <Toast
177
- className="pointer-events-auto"
178
- label={toast.label}
179
- type={toast.type ?? 'neutral'}
180
- leadingIcon={toast.leadingIcon ?? true}
181
- actionLabel={toast.actionLabel}
182
- onAction={
183
- toast.onAction
184
- ? () => {
185
- toast.onAction?.()
186
- dismissToast()
187
- }
188
- : undefined
189
- }
190
- />
191
- </div>,
192
- document.body,
193
- )}
194
+ <BaseToast.Provider toastManager={manager} limit={VISIBLE_TOASTS}>
195
+ {children}
196
+ <BaseToast.Portal>
197
+ <BaseToast.Viewport className="fixed bottom-4 left-4 z-[100] flex flex-col-reverse items-start gap-2 pointer-events-none">
198
+ <ToastList />
199
+ </BaseToast.Viewport>
200
+ </BaseToast.Portal>
201
+ </BaseToast.Provider>
194
202
  </ToastContext.Provider>
195
203
  )
196
204
  }
197
205
 
206
+ function ToastList() {
207
+ const { toasts, close } = BaseToast.useToastManager<ToastData>()
208
+ // Base UI lists the newest first; the viewport is `flex-col-reverse`, so
209
+ // the newest stands nearest the corner.
210
+ return toasts.map((toast) => {
211
+ const type = (toast.type ?? 'neutral') as ToastType
212
+ const { leadingIcon = true, actionLabel, onAction } = toast.data ?? {}
213
+ return (
214
+ <BaseToast.Root
215
+ key={toast.id}
216
+ toast={toast}
217
+ // Towards the corner it stands in.
218
+ swipeDirection={['left', 'down']}
219
+ // A fourth toast hides the oldest (`data-limited`) until there is room.
220
+ className={pillClassName(type, !!actionLabel, 'pointer-events-auto data-[limited]:hidden')}
221
+ >
222
+ <div className="flex items-center gap-2 shrink-0">
223
+ {leadingIcon && <LeadingIcon type={type} />}
224
+ <BaseToast.Title render={<span />} className={LABEL_CLASSES} />
225
+ </div>
226
+ {actionLabel && (
227
+ <BaseToast.Action
228
+ onClick={() => {
229
+ onAction?.()
230
+ close(toast.id)
231
+ }}
232
+ className={actionClassName(type)}
233
+ >
234
+ <span className={ACTION_LABEL_CLASSES}>{actionLabel}</span>
235
+ </BaseToast.Action>
236
+ )}
237
+ </BaseToast.Root>
238
+ )
239
+ })
240
+ }
241
+
198
242
  export function useToast(): ToastValue {
199
243
  const ctx = useContext(ToastContext)
200
244
  if (!ctx) throw new Error('useToast must be used within ToastProvider')
package/src/Toolbar.tsx CHANGED
@@ -137,8 +137,10 @@ export function ToolbarButton({ ref, disabled, disabledReason, ...props }: Toolb
137
137
  */
138
138
  export type ToolbarInputProps = TextInputProps
139
139
 
140
- export function ToolbarInput(props: ToolbarInputProps) {
141
- return <BaseToolbar.Input render={<TextInput />} {...props} />
140
+ export function ToolbarInput({ size, ...props }: ToolbarInputProps) {
141
+ // `size` is TextInput's own (default or small), not the native attribute
142
+ // Base UI's part would take, so it goes to the field it draws.
143
+ return <BaseToolbar.Input render={<TextInput size={size} />} {...props} />
142
144
  }
143
145
 
144
146
  /**
package/src/cn.ts CHANGED
@@ -18,7 +18,7 @@ import { extendTailwindMerge } from 'tailwind-merge'
18
18
  const FONT_SIZE_TOKENS = [
19
19
  'h1', 'h2', 'h3', 'h4', 'h5',
20
20
  'body-1', 'body-2', 'body-2-strong', 'caption', 'menu',
21
- 'btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip',
21
+ 'btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip', 'small',
22
22
  ]
23
23
 
24
24
  /**