@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.
Files changed (57) hide show
  1. package/dist/Button.d.ts.map +1 -1
  2. package/dist/CollapsibleSection.d.ts +3 -1
  3. package/dist/CollapsibleSection.d.ts.map +1 -1
  4. package/dist/DialogShell.d.ts +14 -2
  5. package/dist/DialogShell.d.ts.map +1 -1
  6. package/dist/Field.d.ts +36 -0
  7. package/dist/Field.d.ts.map +1 -1
  8. package/dist/IconButton.d.ts.map +1 -1
  9. package/dist/Menu.d.ts.map +1 -1
  10. package/dist/Popover.d.ts +11 -2
  11. package/dist/Popover.d.ts.map +1 -1
  12. package/dist/ScrollArea.d.ts +14 -2
  13. package/dist/ScrollArea.d.ts.map +1 -1
  14. package/dist/SectionHeader.d.ts +10 -1
  15. package/dist/SectionHeader.d.ts.map +1 -1
  16. package/dist/Toast.d.ts +17 -7
  17. package/dist/Toast.d.ts.map +1 -1
  18. package/dist/cn.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +72 -21
  22. package/dist/index.js.map +2 -2
  23. package/package.json +1 -1
  24. package/src/Button.tsx +7 -1
  25. package/src/CollapsibleSection.tsx +4 -2
  26. package/src/DialogShell.mdx +9 -3
  27. package/src/DialogShell.stories.tsx +29 -0
  28. package/src/DialogShell.test.tsx +35 -0
  29. package/src/DialogShell.tsx +31 -4
  30. package/src/Field.mdx +3 -2
  31. package/src/Field.tsx +57 -2
  32. package/src/FieldLine.mdx +57 -0
  33. package/src/FieldLine.stories.tsx +80 -0
  34. package/src/FieldLine.test.tsx +56 -0
  35. package/src/IconButton.stories.tsx +16 -0
  36. package/src/IconButton.test.tsx +16 -0
  37. package/src/IconButton.tsx +6 -1
  38. package/src/Menu.test.tsx +13 -0
  39. package/src/Menu.tsx +11 -8
  40. package/src/Popover.mdx +3 -1
  41. package/src/Popover.stories.tsx +15 -0
  42. package/src/Popover.test.tsx +20 -0
  43. package/src/Popover.tsx +12 -3
  44. package/src/ScrollArea.mdx +5 -0
  45. package/src/ScrollArea.test.tsx +27 -1
  46. package/src/ScrollArea.tsx +16 -2
  47. package/src/SectionHeader.mdx +4 -0
  48. package/src/SectionHeader.stories.tsx +10 -1
  49. package/src/SectionHeader.test.tsx +25 -0
  50. package/src/SectionHeader.tsx +14 -1
  51. package/src/Toast.mdx +8 -2
  52. package/src/Toast.stories.tsx +13 -3
  53. package/src/Toast.tsx +44 -11
  54. package/src/cn.ts +1 -1
  55. package/src/index.ts +1 -1
  56. package/stories/Choosing.mdx +2 -0
  57. package/tailwind-preset.js +4 -0
@@ -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
- /** Which of the panel's edges hangs from the trigger's. Default left. */
55
- align?: 'left' | 'right'
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"
@@ -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
@@ -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
+ })
@@ -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,
@@ -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
+ })
@@ -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
- - `neutral` states, `success` celebrates, `brand` speaks with the
21
- accent's voice.
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
 
@@ -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 three surfaces, with and without an action. */
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: three types — `success`, `brand`, `neutral` — an optional leading
10
- * circle-check, an optional action on the right. Under Signal every toast is
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
- /** Visual variant per Figma (Alert component): success, brand, or neutral. */
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 circle-check icon. Defaults to true. */
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
- /** Visual variant per Figma (Alert component). Defaults to 'neutral'. */
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 circle-check icon. Defaults to true. */
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'
@@ -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
 
@@ -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': {