@estiva-app/ui 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Avatar.d.ts +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Divider.d.ts +5 -2
- package/dist/Divider.d.ts.map +1 -1
- package/dist/EmptyState.d.ts +3 -0
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Property.d.ts +7 -0
- package/dist/Property.d.ts.map +1 -1
- package/dist/Reaction.d.ts +12 -6
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/TextInput.d.ts +10 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Toast.d.ts +19 -6
- package/dist/Toast.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +1 -1
- package/dist/Toolbar.d.ts.map +1 -1
- package/dist/index.js +272 -245
- package/dist/index.js.map +3 -3
- package/package.json +1 -1
- package/src/AttachmentCard.tsx +14 -14
- package/src/Avatar.mdx +7 -2
- package/src/Avatar.picture.test.tsx +60 -0
- package/src/Avatar.stories.tsx +1 -1
- package/src/Avatar.tsx +50 -14
- package/src/AvatarGroup.tsx +1 -0
- package/src/Breadcrumb.tsx +2 -2
- package/src/Checkbox.tsx +1 -1
- package/src/Chip.tsx +1 -1
- package/src/DialogShell.stories.tsx +1 -1
- package/src/Divider.mdx +3 -2
- package/src/Divider.test.tsx +15 -0
- package/src/Divider.tsx +11 -9
- package/src/EditableText.stories.tsx +2 -2
- package/src/EmptyState.mdx +3 -2
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +15 -0
- package/src/EmptyState.tsx +4 -1
- package/src/IconButton.stories.tsx +1 -1
- package/src/Kbd.tsx +3 -3
- package/src/Menu.stories.tsx +2 -2
- package/src/Menu.tsx +3 -3
- package/src/Popover.stories.tsx +1 -1
- package/src/PreviewCard.stories.tsx +5 -5
- package/src/Property.mdx +8 -0
- package/src/Property.stories.tsx +4 -4
- package/src/Property.test.tsx +44 -0
- package/src/Property.tsx +23 -8
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +14 -3
- package/src/Reaction.test.tsx +62 -0
- package/src/Reaction.tsx +21 -12
- package/src/ReactionPicker.tsx +1 -1
- package/src/SectionLabel.stories.tsx +1 -1
- package/src/SectionLabel.tsx +1 -1
- package/src/Select.tsx +3 -3
- package/src/TextInput.mdx +6 -0
- package/src/TextInput.stories.tsx +45 -0
- package/src/TextInput.test.tsx +38 -0
- package/src/TextInput.tsx +16 -4
- package/src/Textarea.tsx +1 -1
- package/src/Toast.mdx +28 -4
- package/src/Toast.stories.tsx +12 -1
- package/src/Toast.test.tsx +137 -0
- package/src/Toast.tsx +127 -83
- package/src/Toolbar.tsx +4 -2
- package/src/cn.ts +1 -1
- package/stories/Choosing.mdx +1 -0
- package/stories/TokensPage.tsx +6 -1
- package/tailwind-preset.js +7 -0
|
@@ -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,
|
|
2
|
-
import {
|
|
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
|
|
16
|
-
* (
|
|
17
|
-
*
|
|
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-
|
|
46
|
-
brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-
|
|
47
|
-
neutral: 'bg-bg-inset border border-border-subtle signal:border-border-default signal:shadow-
|
|
48
|
-
warning: 'bg-warning-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-
|
|
49
|
-
error: 'bg-error-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-
|
|
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
|
|
102
|
-
<span className=
|
|
125
|
+
{leadingIcon && <LeadingIcon type={type} />}
|
|
126
|
+
<span className={LABEL_CLASSES}>{label}</span>
|
|
103
127
|
</div>
|
|
104
128
|
{hasAction && (
|
|
105
|
-
<button
|
|
106
|
-
|
|
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
|
-
/**
|
|
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
|
|
135
|
-
id
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
169
|
+
/** D7: three on screen at once. */
|
|
170
|
+
const VISIBLE_TOASTS = 3
|
|
146
171
|
|
|
147
172
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/stories/Choosing.mdx
CHANGED
|
@@ -34,6 +34,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
34
34
|
| You need | Reach for |
|
|
35
35
|
|---|---|
|
|
36
36
|
| One line | **TextInput** |
|
|
37
|
+
| One line in a dense row, beside small selects | **TextInput** `size="small"` — 24px, the small Select's height |
|
|
37
38
|
| Several lines | **Textarea** |
|
|
38
39
|
| Typing that filters a list | **SearchInput** |
|
|
39
40
|
| A title edited in place | **EditableText** |
|
package/stories/TokensPage.tsx
CHANGED
|
@@ -134,6 +134,7 @@ const TYPE_GROUPS: { label: string; blurb: string; tokens: TypeToken[] }[] = [
|
|
|
134
134
|
{ label: 'Headings', blurb: 'h1 is a page title, h2 a section, h3 a card or dialog title, h4 a row title, h5 a small label.', tokens: ['h1', 'h2', 'h3', 'h4', 'h5'].map((k) => ({ key: k, cls: `text-${k}` })) },
|
|
135
135
|
{ label: 'Body', blurb: 'body-1 for reading, body-2 for the interface, caption for what sits beside it.', tokens: ['body-1', 'body-2', 'body-2-strong', 'caption'].map((k) => ({ key: k, cls: `text-${k}` })) },
|
|
136
136
|
{ label: 'Controls', blurb: 'The sizes controls are set in, so a button, a field and a chip read the same everywhere.', tokens: ['btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip', 'menu'].map((k) => ({ key: k, cls: `text-${k}` })) },
|
|
137
|
+
{ label: 'A theme\'s smaller label', blurb: 'small is a size and nothing else. Under signal: or ship: it shrinks the token beside it to 10px and keeps that token\'s line height and weight. Spacing, where a label wants some, is tracking-wide or tracking-widest.', tokens: [{ key: 'small', cls: 'text-small' }] },
|
|
137
138
|
]
|
|
138
139
|
|
|
139
140
|
const RADII = [
|
|
@@ -152,7 +153,7 @@ const RADII = [
|
|
|
152
153
|
function Section({ label, blurb, children }: { label: string; blurb: string; children: ReactNode }) {
|
|
153
154
|
return (
|
|
154
155
|
<section className="mt-10 first:mt-0">
|
|
155
|
-
<h2 className="text-h5 uppercase tracking-
|
|
156
|
+
<h2 className="text-h5 uppercase tracking-widest text-text-secondary">{label}</h2>
|
|
156
157
|
<p className="mt-1.5 max-w-[640px] text-body-2 text-text-secondary">{blurb}</p>
|
|
157
158
|
<div className="mt-3">{children}</div>
|
|
158
159
|
</section>
|
|
@@ -171,6 +172,9 @@ function Users({ names }: { names: string[] }) {
|
|
|
171
172
|
function SwatchBox({ token }: { token: Token }) {
|
|
172
173
|
const v = `var(${token.cssVar})`
|
|
173
174
|
const base = 'h-6 w-10 shrink-0 rounded-md'
|
|
175
|
+
/* eslint-disable no-restricted-syntax -- this page draws every token from its CSS
|
|
176
|
+
variable, so a swatch shows the value the theme holds, including a token no
|
|
177
|
+
class spells yet. */
|
|
174
178
|
switch (token.swatch) {
|
|
175
179
|
case 'fill':
|
|
176
180
|
return <div className={`${base} border border-border-subtle`} style={{ background: v }} />
|
|
@@ -190,6 +194,7 @@ function SwatchBox({ token }: { token: Token }) {
|
|
|
190
194
|
case 'drop-shadow':
|
|
191
195
|
return <div className="my-1 h-8 w-12 shrink-0 rounded-md bg-bg-surface" style={{ filter: `drop-shadow(${v})` }} />
|
|
192
196
|
}
|
|
197
|
+
/* eslint-enable no-restricted-syntax */
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
function TokenRow({ token, utilities }: { token: Token; utilities: string }) {
|
package/tailwind-preset.js
CHANGED
|
@@ -100,6 +100,13 @@ export default {
|
|
|
100
100
|
'input-value': ['14px', { lineHeight: '140%', letterSpacing: '0', fontWeight: '400' }],
|
|
101
101
|
'input-helper': ['12px', { lineHeight: '120%', letterSpacing: '0', fontWeight: '400' }],
|
|
102
102
|
'chip': ['11px', { lineHeight: '110%', letterSpacing: '0', fontWeight: '500' }],
|
|
103
|
+
// A size and nothing else (Katerina, 2026-09-15, UIG-28): a theme's
|
|
104
|
+
// smaller label. Under `signal:` or `ship:` it shrinks the token beside
|
|
105
|
+
// it to 10px and keeps that token's line height and weight
|
|
106
|
+
// (`text-caption signal:text-small`). Letter spacing, where a label
|
|
107
|
+
// wants some, is Tailwind's own step: `tracking-wide`, `tracking-widest`.
|
|
108
|
+
// Alone, it takes its line height and weight from the parent.
|
|
109
|
+
'small': ['10px'],
|
|
103
110
|
},
|
|
104
111
|
borderRadius: {
|
|
105
112
|
none: '0px',
|