@estiva-app/ui 0.12.1 → 0.12.3
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/Button.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +3 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +14 -2
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/Field.d.ts +36 -0
- package/dist/Field.d.ts.map +1 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Popover.d.ts +11 -2
- package/dist/Popover.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +14 -2
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +10 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Toast.d.ts +17 -7
- package/dist/Toast.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +72 -21
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/src/Button.tsx +7 -1
- package/src/CollapsibleSection.tsx +4 -2
- package/src/DialogShell.mdx +9 -3
- package/src/DialogShell.stories.tsx +29 -0
- package/src/DialogShell.test.tsx +35 -0
- package/src/DialogShell.tsx +31 -4
- package/src/Field.mdx +3 -2
- package/src/Field.tsx +57 -2
- package/src/FieldLine.mdx +57 -0
- package/src/FieldLine.stories.tsx +80 -0
- package/src/FieldLine.test.tsx +56 -0
- package/src/IconButton.stories.tsx +16 -0
- package/src/IconButton.test.tsx +16 -0
- package/src/IconButton.tsx +6 -1
- package/src/Menu.test.tsx +13 -0
- package/src/Menu.tsx +11 -8
- package/src/Popover.mdx +3 -1
- package/src/Popover.stories.tsx +15 -0
- package/src/Popover.test.tsx +20 -0
- package/src/Popover.tsx +12 -3
- package/src/ScrollArea.mdx +5 -0
- package/src/ScrollArea.test.tsx +27 -1
- package/src/ScrollArea.tsx +16 -2
- package/src/SectionHeader.mdx +4 -0
- package/src/SectionHeader.stories.tsx +10 -1
- package/src/SectionHeader.test.tsx +25 -0
- package/src/SectionHeader.tsx +14 -1
- package/src/Toast.mdx +8 -2
- package/src/Toast.stories.tsx +13 -3
- package/src/Toast.tsx +44 -11
- package/src/cn.ts +1 -1
- package/src/index.ts +1 -1
- package/stories/Choosing.mdx +2 -0
- package/tailwind-preset.js +4 -0
package/src/Popover.test.tsx
CHANGED
|
@@ -186,3 +186,23 @@ describe('Popover', () => {
|
|
|
186
186
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
187
187
|
})
|
|
188
188
|
})
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `align="center"` (0.12.2, ADOPTION B21). Peek's selection toolbar is centred
|
|
192
|
+
* over the text you selected, and kept its own placement arithmetic while only
|
|
193
|
+
* the two edges were mapped.
|
|
194
|
+
*/
|
|
195
|
+
describe('Popover, centred on its anchor', () => {
|
|
196
|
+
it('asks Base UI for the middle', async () => {
|
|
197
|
+
render(
|
|
198
|
+
<Popover trigger={<Button>Open</Button>} align="center" ariaLabel="A panel">
|
|
199
|
+
<span>Inside</span>
|
|
200
|
+
</Popover>,
|
|
201
|
+
)
|
|
202
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }))
|
|
203
|
+
const panel = await screen.findByRole('dialog')
|
|
204
|
+
// Base UI writes the resolved placement on the positioner it owns.
|
|
205
|
+
const positioner = panel.closest('[data-align]') ?? panel.parentElement
|
|
206
|
+
expect(positioner?.getAttribute('data-align')).toBe('center')
|
|
207
|
+
})
|
|
208
|
+
})
|
package/src/Popover.tsx
CHANGED
|
@@ -51,8 +51,17 @@ export interface PopoverProps {
|
|
|
51
51
|
* Render it always and toggle `open`; do not mount it only while it is open.
|
|
52
52
|
*/
|
|
53
53
|
anchor?: HTMLElement | DOMRect | null
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Which of the panel's edges hangs from the trigger's, or `center` to put
|
|
56
|
+
* the panel's middle over the anchor's. Default left.
|
|
57
|
+
*
|
|
58
|
+
* **`center` is what a toolbar over a text selection wants** (Katerina,
|
|
59
|
+
* PEE-19, after Linear): the panel sits over the middle of what you
|
|
60
|
+
* selected, wherever in the line that is. Until 0.12.2 only the two edges
|
|
61
|
+
* were mapped, so Peek's selection toolbar kept the placement arithmetic
|
|
62
|
+
* the move onto this component was meant to delete (ADOPTION B21).
|
|
63
|
+
*/
|
|
64
|
+
align?: 'left' | 'center' | 'right'
|
|
56
65
|
/**
|
|
57
66
|
* Which side of the trigger, or of the anchor, the panel prefers. Default
|
|
58
67
|
* `bottom`.
|
|
@@ -117,7 +126,7 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
|
|
|
117
126
|
<BasePopover.Positioner
|
|
118
127
|
anchor={anchorTarget}
|
|
119
128
|
side={side}
|
|
120
|
-
align={align === 'right' ? 'end' : 'start'}
|
|
129
|
+
align={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
|
121
130
|
sideOffset={GAP}
|
|
122
131
|
collisionPadding={VIEWPORT_PAD}
|
|
123
132
|
className="z-50 data-[anchor-hidden]:hidden"
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -39,6 +39,11 @@ import { ScrollArea } from '@estiva-app/ui'
|
|
|
39
39
|
- `className` sizes and places the region; `viewportClassName` styles the
|
|
40
40
|
scrolling box inside it — padding, gap, the layout of the content.
|
|
41
41
|
- The region needs a height (or a `max-h-*`) to have anything to scroll.
|
|
42
|
+
- **`viewportRef` and `onScroll` hand back the box that scrolls**, for a
|
|
43
|
+
region that reads or drives its own scrolling: a conversation that arrives
|
|
44
|
+
at its newest message, keeps its place when older ones load above it, and
|
|
45
|
+
jumps to the bottom when a reply is sent. Nothing else here can do that for
|
|
46
|
+
the caller, and the caller cannot do it without the box.
|
|
42
47
|
- The native scrollbar is hidden by Base UI; a native `overflow-y-auto` on
|
|
43
48
|
the same element would draw a second one.
|
|
44
49
|
- A region keeps the wheel only in an axis it can actually scroll: a list
|
package/src/ScrollArea.test.tsx
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* it shows, is measured in Chrome (2026-09-08: the viewport keeps its full
|
|
6
6
|
* width with 40 rows overflowing; the native bar is hidden).
|
|
7
7
|
*/
|
|
8
|
+
import { createRef } from 'react'
|
|
8
9
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
-
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
10
11
|
import { ScrollArea } from './ScrollArea'
|
|
11
12
|
|
|
12
13
|
afterEach(cleanup)
|
|
@@ -63,3 +64,28 @@ describe('ScrollArea and the page behind it', () => {
|
|
|
63
64
|
expect(viewport.className).not.toMatch(/(^|\s)overscroll-contain(\s|$)/)
|
|
64
65
|
})
|
|
65
66
|
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The viewport is the box a caller reads and drives (0.12.2, ADOPTION B20).
|
|
70
|
+
* A conversation arrives at its newest message and jumps to the bottom on a
|
|
71
|
+
* reply; neither is something the region can do for the caller, and neither is
|
|
72
|
+
* possible without the box.
|
|
73
|
+
*/
|
|
74
|
+
describe('ScrollArea, the viewport it hands back', () => {
|
|
75
|
+
it('gives the caller the box that scrolls, and reports its scrolling', () => {
|
|
76
|
+
const ref = createRef<HTMLDivElement>()
|
|
77
|
+
let scrolled = 0
|
|
78
|
+
render(
|
|
79
|
+
<ScrollArea className="h-40" viewportRef={ref} onScroll={() => { scrolled += 1 }}>
|
|
80
|
+
<p>Inside</p>
|
|
81
|
+
</ScrollArea>,
|
|
82
|
+
)
|
|
83
|
+
// The box the caller gets is the one the content sits in, not the region.
|
|
84
|
+
expect(ref.current).not.toBe(null)
|
|
85
|
+
expect(ref.current!.contains(screen.getByText('Inside'))).toBe(true)
|
|
86
|
+
expect(ref.current!.className).toContain('overflow')
|
|
87
|
+
|
|
88
|
+
fireEvent.scroll(ref.current!)
|
|
89
|
+
expect(scrolled).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
})
|
package/src/ScrollArea.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ReactNode } from 'react'
|
|
1
|
+
import type { ReactNode, Ref, UIEventHandler } from 'react'
|
|
2
2
|
import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'
|
|
3
3
|
import { cn } from './cn'
|
|
4
4
|
|
|
@@ -43,13 +43,25 @@ export interface ScrollAreaProps {
|
|
|
43
43
|
viewportClassName?: string
|
|
44
44
|
/** On the content, the box the children sit in: padding, gap, layout. */
|
|
45
45
|
contentClassName?: string
|
|
46
|
+
/**
|
|
47
|
+
* The scrolling box itself, for a region that reads or drives its own
|
|
48
|
+
* scrolling: `ref.current.scrollTop`, `scrollTo`, `scrollHeight`.
|
|
49
|
+
*
|
|
50
|
+
* A conversation is the case this exists for (ADOPTION B20): it arrives at
|
|
51
|
+
* the newest message, keeps its place when older ones load above, and jumps
|
|
52
|
+
* to the bottom when a reply is sent — none of which the region can do for
|
|
53
|
+
* the caller, and all of which the caller cannot do without the box.
|
|
54
|
+
*/
|
|
55
|
+
viewportRef?: Ref<HTMLDivElement>
|
|
56
|
+
/** Fires as the viewport scrolls — the unread policy's input, beside `viewportRef`. */
|
|
57
|
+
onScroll?: UIEventHandler<HTMLDivElement>
|
|
46
58
|
children: ReactNode
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
const BAR = 'flex touch-none select-none rounded-full opacity-0 transition-opacity delay-300 data-[hovering]:opacity-100 data-[hovering]:delay-0 data-[scrolling]:opacity-100 data-[scrolling]:delay-0'
|
|
50
62
|
const THUMB = 'rounded-full bg-border-strong'
|
|
51
63
|
|
|
52
|
-
export function ScrollArea({ orientation = 'vertical', className, viewportClassName, contentClassName, children }: ScrollAreaProps) {
|
|
64
|
+
export function ScrollArea({ orientation = 'vertical', className, viewportClassName, contentClassName, viewportRef, onScroll, children }: ScrollAreaProps) {
|
|
53
65
|
const vertical = orientation !== 'horizontal'
|
|
54
66
|
const horizontal = orientation !== 'vertical'
|
|
55
67
|
return (
|
|
@@ -66,6 +78,8 @@ export function ScrollArea({ orientation = 'vertical', className, viewportClassN
|
|
|
66
78
|
Finding 40). `data-has-overflow-x` / `-y` are Base UI's word for
|
|
67
79
|
"this axis really overflows", written on the viewport as it changes. */}
|
|
68
80
|
<BaseScrollArea.Viewport
|
|
81
|
+
ref={viewportRef}
|
|
82
|
+
onScroll={onScroll}
|
|
69
83
|
className={cn(
|
|
70
84
|
'h-full w-full outline-none data-[has-overflow-x]:overscroll-x-contain data-[has-overflow-y]:overscroll-y-contain',
|
|
71
85
|
viewportClassName,
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -46,6 +46,10 @@ import { IconPlus } from '@tabler/icons-react'
|
|
|
46
46
|
- An action is `{ icon, tooltip, onClick }` — the tooltip doubles as the
|
|
47
47
|
action's accessible name. The actions sit beside the title button, not
|
|
48
48
|
inside it, so a click on one never also toggles the section.
|
|
49
|
+
- `trailing` is a count beside the title, held on screen while the actions
|
|
50
|
+
come and go: a count is information, not an affordance. It sits outside
|
|
51
|
+
the title button too, so it is neither part of the toggle's name nor part
|
|
52
|
+
of its hit target.
|
|
49
53
|
- `render` swaps the title's element in Base UI's manner; it is how
|
|
50
54
|
CollapsibleSection makes the title a `Collapsible.Trigger`.
|
|
51
55
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconPlus, IconSortDescending } from '@tabler/icons-react'
|
|
3
|
+
import { Chip } from './Chip'
|
|
3
4
|
import { SectionHeader } from './SectionHeader'
|
|
4
5
|
|
|
5
6
|
/** The 32px row a section starts with. Hover it: the row fills, and its actions appear. A section that folds is CollapsibleSection, whose header this is. */
|
|
@@ -8,7 +9,7 @@ const meta = {
|
|
|
8
9
|
component: SectionHeader,
|
|
9
10
|
decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
|
|
10
11
|
args: { title: 'Section', showActions: 'hover' },
|
|
11
|
-
argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false } },
|
|
12
|
+
argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false }, trailing: { control: false } },
|
|
12
13
|
} satisfies Meta<typeof SectionHeader>
|
|
13
14
|
|
|
14
15
|
export default meta
|
|
@@ -33,3 +34,11 @@ export const PersistentActions: Story = {
|
|
|
33
34
|
actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
|
|
34
35
|
},
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
/** A count beside the title, held on screen while the actions come and go. */
|
|
39
|
+
export const WithTrailing: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
trailing: <Chip type="brand" label="2" />,
|
|
42
|
+
actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
|
|
43
|
+
},
|
|
44
|
+
}
|
|
@@ -38,3 +38,28 @@ describe('SectionHeader', () => {
|
|
|
38
38
|
expect(screen.getByText('Section')).not.toBeNull()
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The trailing slot (0.12.2, ADOPTION B23). Peek's Screener header carries its
|
|
44
|
+
* count as a Chip there, and was the last hand-drawn folding header in the app
|
|
45
|
+
* for want of it.
|
|
46
|
+
*/
|
|
47
|
+
describe('SectionHeader, the trailing slot', () => {
|
|
48
|
+
it('holds a count beside the title, outside the button and outside the hover', () => {
|
|
49
|
+
render(
|
|
50
|
+
<SectionHeader
|
|
51
|
+
title="Section"
|
|
52
|
+
chevron
|
|
53
|
+
trailing={<span data-testid="count">2</span>}
|
|
54
|
+
actions={[{ icon: <span />, tooltip: 'Add', onClick: () => {} }]}
|
|
55
|
+
/>,
|
|
56
|
+
)
|
|
57
|
+
const count = screen.getByTestId('count')
|
|
58
|
+
const title = screen.getByRole('button', { name: /section/i })
|
|
59
|
+
// Not part of the toggle: neither its name nor its hit target.
|
|
60
|
+
expect(title.contains(count)).toBe(false)
|
|
61
|
+
expect(title.textContent).toBe('Section')
|
|
62
|
+
// Not part of the hover reveal either — a count is information, not an affordance.
|
|
63
|
+
expect(count.closest('.opacity-0')).toBe(null)
|
|
64
|
+
})
|
|
65
|
+
})
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -46,6 +46,15 @@ export interface SectionHeaderProps {
|
|
|
46
46
|
chevron?: boolean
|
|
47
47
|
isExpanded?: boolean
|
|
48
48
|
onToggle?: () => void
|
|
49
|
+
/**
|
|
50
|
+
* Beside the title and always visible, before the actions — a count.
|
|
51
|
+
*
|
|
52
|
+
* The actions come and go with the hover; this does not, because a count is
|
|
53
|
+
* information rather than an affordance. Peek's Screener header carries its
|
|
54
|
+
* number as a `Chip` here, and was the last hand-drawn folding header in the
|
|
55
|
+
* app for want of the slot (ADOPTION B23).
|
|
56
|
+
*/
|
|
57
|
+
trailing?: ReactNode
|
|
49
58
|
/** Right-aligned, in the order given. */
|
|
50
59
|
actions?: SectionAction[]
|
|
51
60
|
/** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
|
|
@@ -58,7 +67,7 @@ export interface SectionHeaderProps {
|
|
|
58
67
|
className?: string
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, actions, showActions = 'hover', render, className }: SectionHeaderProps) {
|
|
70
|
+
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, trailing, actions, showActions = 'hover', render, className }: SectionHeaderProps) {
|
|
62
71
|
const titleElement = useRender({
|
|
63
72
|
render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
|
|
64
73
|
props: {
|
|
@@ -93,6 +102,10 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
|
|
|
93
102
|
)}
|
|
94
103
|
>
|
|
95
104
|
{titleElement}
|
|
105
|
+
{/* Outside the title button, like the actions: a chip inside a button
|
|
106
|
+
would be part of the button's accessible name and part of its hit
|
|
107
|
+
target, and the count is neither. */}
|
|
108
|
+
{trailing != null && <div className="flex shrink-0 items-center">{trailing}</div>}
|
|
96
109
|
{actions && actions.length > 0 && (
|
|
97
110
|
<div
|
|
98
111
|
// `-mr-1`: an IconButton is 24px around a 16px icon, so at the row's
|
package/src/Toast.mdx
CHANGED
|
@@ -17,8 +17,14 @@ the positioning (bottom-left), the portal, and the auto-dismiss.
|
|
|
17
17
|
"Changes saved".
|
|
18
18
|
- `actionLabel` + `onAction` add the one follow-up worth offering — an
|
|
19
19
|
"Undo", a "View".
|
|
20
|
-
-
|
|
21
|
-
|
|
20
|
+
- **The leading icon says which type it is**: a check for `success`,
|
|
21
|
+
`brand` and `neutral`, a `!` for `warning`, an `×` for `error`. It was a
|
|
22
|
+
check on all of them, so a warning arrived with a tick beside it and a
|
|
23
|
+
caller with bad news turned the icon off instead.
|
|
24
|
+
- `neutral` states, `success` celebrates, `brand` speaks with the accent's
|
|
25
|
+
voice, `error` reports a failure, and `warning` says something needs
|
|
26
|
+
knowing — a notice usually paired with `durationMs: 0` and a Dismiss,
|
|
27
|
+
because a warning that fades takes itself with it.
|
|
22
28
|
|
|
23
29
|
<Canvas of={ToastStories.WithAction} />
|
|
24
30
|
|
package/src/Toast.stories.tsx
CHANGED
|
@@ -12,7 +12,7 @@ const meta = {
|
|
|
12
12
|
leadingIcon: true,
|
|
13
13
|
},
|
|
14
14
|
argTypes: {
|
|
15
|
-
type: { control: 'inline-radio', options: ['success', 'brand', 'neutral'] },
|
|
15
|
+
type: { control: 'inline-radio', options: ['success', 'brand', 'neutral', 'warning', 'error'] },
|
|
16
16
|
onAction: { control: false },
|
|
17
17
|
},
|
|
18
18
|
} satisfies Meta<typeof Toast>
|
|
@@ -30,6 +30,16 @@ export const Brand: Story = {
|
|
|
30
30
|
args: { type: 'brand', label: 'Session started' },
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Something that needs saying rather than celebrating — usually with `durationMs: 0` and a Dismiss, because a warning that fades takes itself with it. */
|
|
34
|
+
export const Warning: Story = {
|
|
35
|
+
args: { type: 'warning', label: 'Not everything went through' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** It did not happen. The icon is an ×, not a tick. */
|
|
39
|
+
export const Error: Story = {
|
|
40
|
+
args: { type: 'error', label: 'That did not go through' },
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
/** With a right-side action. */
|
|
34
44
|
export const WithAction: Story = {
|
|
35
45
|
args: {
|
|
@@ -44,12 +54,12 @@ export const NoIcon: Story = {
|
|
|
44
54
|
args: { leadingIcon: false, label: 'Copied to clipboard' },
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
/** All
|
|
57
|
+
/** All five surfaces, with and without an action — and each with its own icon. */
|
|
48
58
|
export const AllTypes: Story = {
|
|
49
59
|
parameters: { controls: { disable: true } },
|
|
50
60
|
render: () => (
|
|
51
61
|
<div className="flex flex-col items-start gap-2">
|
|
52
|
-
{(['success', 'brand', 'neutral'] as const).map((type) => (
|
|
62
|
+
{(['success', 'brand', 'neutral', 'warning', 'error'] as const).map((type) => (
|
|
53
63
|
<div key={type} className="flex items-center gap-2">
|
|
54
64
|
<Toast type={type} label="Changes saved" />
|
|
55
65
|
<Toast type={type} label="Changes saved" actionLabel="Undo" onAction={() => {}} />
|
package/src/Toast.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { IconCircleCheck } from '@tabler/icons-react'
|
|
3
|
+
import { IconAlertCircle, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
|
4
4
|
import { cn } from './cn'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Peek's Toast (Figma: Alert) and its provider (2026-09-01), verbatim.
|
|
8
8
|
*
|
|
9
|
-
* The pill:
|
|
10
|
-
*
|
|
9
|
+
* The pill: five types — `success`, `brand`, `neutral`, `warning`, `error` —
|
|
10
|
+
* a leading icon that says which (a check, a `!`, an `×`), an optional action
|
|
11
|
+
* on the right. Under Signal every toast is
|
|
11
12
|
* the same dark overlay pill; the type lives in the icon's colour and glow,
|
|
12
13
|
* not the surface.
|
|
13
14
|
*
|
|
@@ -15,13 +16,22 @@ import { cn } from './cn'
|
|
|
15
16
|
* (5 s; pass `durationMs: 0` to keep one up). One toast at a time — a new
|
|
16
17
|
* one replaces the standing one, it does not queue.
|
|
17
18
|
*/
|
|
18
|
-
export type ToastType = 'success' | 'brand' | 'neutral'
|
|
19
|
+
export type ToastType = 'success' | 'brand' | 'neutral' | 'warning' | 'error'
|
|
19
20
|
|
|
20
21
|
export interface ToastProps {
|
|
21
22
|
label: string
|
|
22
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Which of the five this is. It decides the surface in the light themes,
|
|
25
|
+
* and under Signal — where every pill is the same dark overlay — the icon
|
|
26
|
+
* and its colour.
|
|
27
|
+
*
|
|
28
|
+
* `warning` and `error` arrived at 0.12.2, for a notice that has to stay up
|
|
29
|
+
* and for one that reports a failure. Until then the set had no way to say
|
|
30
|
+
* either, so a caller with bad news passed `leadingIcon: false` rather than
|
|
31
|
+
* put a tick beside it.
|
|
32
|
+
*/
|
|
23
33
|
type?: ToastType
|
|
24
|
-
/** Show the leading
|
|
34
|
+
/** Show the leading icon — a check, a `!` or an `×`, per `type`. Defaults to true. */
|
|
25
35
|
leadingIcon?: boolean
|
|
26
36
|
/** When set together with onAction, renders a clickable action on the right side. */
|
|
27
37
|
actionLabel?: string
|
|
@@ -35,22 +45,47 @@ const SURFACE_STYLES: Record<ToastType, string> = {
|
|
|
35
45
|
success: 'bg-success-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
36
46
|
brand: 'bg-accent-muted signal:bg-bg-inset signal:border signal:border-border-default signal:shadow-[shadow:var(--shadow-md)]',
|
|
37
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)]',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The leading icon says which of the five this is (Katerina, 2026-09-10:
|
|
54
|
+
* *"the icons should be representative, x for error, ! for warning"*). It was
|
|
55
|
+
* a circle-check on all of them, so a warning and a failure both arrived with
|
|
56
|
+
* a tick beside them — the reason Peek passed `leadingIcon: false` rather than
|
|
57
|
+
* show one.
|
|
58
|
+
*/
|
|
59
|
+
const ICONS: Record<ToastType, typeof IconCircleCheck> = {
|
|
60
|
+
success: IconCircleCheck,
|
|
61
|
+
brand: IconCircleCheck,
|
|
62
|
+
neutral: IconCircleCheck,
|
|
63
|
+
warning: IconAlertCircle,
|
|
64
|
+
error: IconCircleX,
|
|
38
65
|
}
|
|
39
66
|
|
|
40
67
|
const ICON_STYLES: Record<ToastType, string> = {
|
|
41
68
|
success: 'signal:text-success-default signal:drop-shadow-glow-success',
|
|
42
69
|
brand: 'signal:text-text-interactive signal:drop-shadow-glow-accent',
|
|
43
70
|
neutral: 'signal:text-text-secondary',
|
|
71
|
+
warning: 'signal:text-warning-default signal:drop-shadow-glow-warning',
|
|
72
|
+
// No glow: the theme defines `--glow-warning`, `--glow-success` and
|
|
73
|
+
// `--glow-accent` and no error glow, and a colour with no token is not
|
|
74
|
+
// approximated here (D16).
|
|
75
|
+
error: 'signal:text-error-default',
|
|
44
76
|
}
|
|
45
77
|
|
|
46
78
|
const ACTION_BORDER_STYLES: Record<ToastType, string> = {
|
|
47
79
|
success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
48
80
|
brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
49
81
|
neutral: 'border border-border-default',
|
|
82
|
+
warning: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
83
|
+
error: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
50
84
|
}
|
|
51
85
|
|
|
52
86
|
export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel, onAction, className }: ToastProps) {
|
|
53
87
|
const hasAction = !!(actionLabel && onAction)
|
|
88
|
+
const LeadingIcon = ICONS[type]
|
|
54
89
|
return (
|
|
55
90
|
<div
|
|
56
91
|
className={cn(
|
|
@@ -63,9 +98,7 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
|
|
|
63
98
|
)}
|
|
64
99
|
>
|
|
65
100
|
<div className="flex items-center gap-2 shrink-0">
|
|
66
|
-
{leadingIcon && (
|
|
67
|
-
<IconCircleCheck size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
|
|
68
|
-
)}
|
|
101
|
+
{leadingIcon && <LeadingIcon size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />}
|
|
69
102
|
<span className="font-normal text-[14px] leading-[1.4] text-text-primary whitespace-nowrap">{label}</span>
|
|
70
103
|
</div>
|
|
71
104
|
{hasAction && (
|
|
@@ -87,12 +120,12 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
|
|
|
87
120
|
|
|
88
121
|
export interface ToastOptions {
|
|
89
122
|
label: string
|
|
90
|
-
/**
|
|
123
|
+
/** Which of the five this is — it decides the surface and the leading icon. Defaults to 'neutral'. */
|
|
91
124
|
type?: ToastType
|
|
92
125
|
/** When set, renders a clickable action on the right side. */
|
|
93
126
|
actionLabel?: string
|
|
94
127
|
onAction?: () => void
|
|
95
|
-
/** Show the leading
|
|
128
|
+
/** Show the leading icon — a check, a `!` or an `×`, per `type`. Defaults to true. */
|
|
96
129
|
leadingIcon?: boolean
|
|
97
130
|
/** Auto-dismiss after this many ms. Defaults to 5000. Pass 0 to disable. */
|
|
98
131
|
durationMs?: number
|
package/src/cn.ts
CHANGED
|
@@ -29,7 +29,7 @@ const FONT_SIZE_TOKENS = [
|
|
|
29
29
|
* `cn.test.ts` pins these to the preset too.
|
|
30
30
|
*/
|
|
31
31
|
const BOX_SHADOW_TOKENS = ['sm', 'md', 'lg', 'focus-ring', 'glow-warning', 'glow-success', 'glow-accent', 'highlight-inset']
|
|
32
|
-
const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent']
|
|
32
|
+
const DROP_SHADOW_TOKENS = ['glow-success', 'glow-accent', 'glow-warning']
|
|
33
33
|
|
|
34
34
|
const twMerge = extendTailwindMerge({
|
|
35
35
|
extend: {
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export { IconButton, type IconButtonProps, type IconButtonVariant } from './Icon
|
|
|
21
21
|
export { Kbd, type KbdProps } from './Kbd'
|
|
22
22
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
23
23
|
export { Tooltip, TooltipProvider, WithTooltip, type TooltipProps, type TooltipProviderProps, type WithTooltipProps } from './Tooltip'
|
|
24
|
-
export { Field, type FieldProps } from './Field'
|
|
24
|
+
export { Field, FieldLine, type FieldLineProps, type FieldLineTone, type FieldProps } from './Field'
|
|
25
25
|
export { Select, type SelectOption, type SelectProps } from './Select'
|
|
26
26
|
export { TextInput, type TextInputProps } from './TextInput'
|
|
27
27
|
export { Textarea, type TextareaProps } from './Textarea'
|
package/stories/Choosing.mdx
CHANGED
|
@@ -38,6 +38,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
38
38
|
| Typing that filters a list | **SearchInput** |
|
|
39
39
|
| A title edited in place | **EditableText** |
|
|
40
40
|
| Label and required-mark around any control | **Field** |
|
|
41
|
+
| A hint or a failure under **one** control | **Field**'s `helper` / `error` — wired to it, and announced |
|
|
42
|
+
| A hint or an outcome under a **group** of controls | **FieldLine** — the same line on its own, with a `warning` tone Field has not got |
|
|
41
43
|
|
|
42
44
|
## People
|
|
43
45
|
|
package/tailwind-preset.js
CHANGED
|
@@ -183,6 +183,10 @@ export default {
|
|
|
183
183
|
// The icon glows (D16), as `drop-shadow-glow-*`; a filter, so they follow the icon's shape.
|
|
184
184
|
'glow-success': 'var(--glow-success)',
|
|
185
185
|
'glow-accent': 'var(--glow-accent)',
|
|
186
|
+
// Added at 0.12.2 with the warning toast, so the set is symmetric:
|
|
187
|
+
// all three glows are a box shadow (a glow on a surface) and a drop
|
|
188
|
+
// shadow (a glow on an icon).
|
|
189
|
+
'glow-warning': 'var(--glow-warning)',
|
|
186
190
|
},
|
|
187
191
|
keyframes: {
|
|
188
192
|
'skeleton-in': {
|