@estiva-app/ui 0.18.0 → 0.20.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 (84) hide show
  1. package/README.md +13 -5
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Button.d.ts +6 -1
  5. package/dist/Button.d.ts.map +1 -1
  6. package/dist/Card.d.ts +6 -1
  7. package/dist/Card.d.ts.map +1 -1
  8. package/dist/Checkbox.d.ts +16 -1
  9. package/dist/Checkbox.d.ts.map +1 -1
  10. package/dist/DialogShell.d.ts.map +1 -1
  11. package/dist/Divider.d.ts +7 -0
  12. package/dist/Divider.d.ts.map +1 -1
  13. package/dist/IconButton.d.ts +12 -2
  14. package/dist/IconButton.d.ts.map +1 -1
  15. package/dist/IdentityMenu.d.ts.map +1 -1
  16. package/dist/Link.d.ts +6 -1
  17. package/dist/Link.d.ts.map +1 -1
  18. package/dist/Menu.d.ts.map +1 -1
  19. package/dist/Popover.d.ts.map +1 -1
  20. package/dist/ReactionPicker.d.ts.map +1 -1
  21. package/dist/ScrollArea.d.ts +6 -0
  22. package/dist/ScrollArea.d.ts.map +1 -1
  23. package/dist/SectionLabel.d.ts +6 -1
  24. package/dist/SectionLabel.d.ts.map +1 -1
  25. package/dist/Tooltip.d.ts.map +1 -1
  26. package/dist/eslint/index.d.ts +4 -0
  27. package/dist/eslint/index.d.ts.map +1 -1
  28. package/dist/eslint/index.js +968 -3
  29. package/dist/eslint/index.js.map +4 -4
  30. package/dist/eslint/no-rebuilt-behaviour.d.ts +88 -0
  31. package/dist/eslint/no-rebuilt-behaviour.d.ts.map +1 -0
  32. package/dist/eslint/no-restyled-part.d.ts +81 -0
  33. package/dist/eslint/no-restyled-part.d.ts.map +1 -0
  34. package/dist/index.js +300 -268
  35. package/dist/index.js.map +4 -4
  36. package/package.json +1 -1
  37. package/src/AttachmentCard.tsx +7 -2
  38. package/src/Banner.tsx +2 -2
  39. package/src/Breadcrumb.tsx +9 -4
  40. package/src/Button.mdx +7 -0
  41. package/src/Button.stories.tsx +4 -1
  42. package/src/Button.tsx +10 -2
  43. package/src/Card.mdx +7 -0
  44. package/src/Card.stories.tsx +11 -0
  45. package/src/Card.tsx +8 -0
  46. package/src/Checkbox.mdx +20 -1
  47. package/src/Checkbox.stories.tsx +44 -0
  48. package/src/Checkbox.test.tsx +63 -0
  49. package/src/Checkbox.tsx +30 -1
  50. package/src/CommandPalette.tsx +1 -1
  51. package/src/ConfirmDialog.tsx +2 -2
  52. package/src/DialogShell.tsx +6 -7
  53. package/src/Divider.mdx +3 -0
  54. package/src/Divider.tsx +12 -2
  55. package/src/EditableText.mdx +5 -0
  56. package/src/IconButton.mdx +9 -0
  57. package/src/IconButton.stories.tsx +10 -1
  58. package/src/IconButton.tsx +22 -3
  59. package/src/IdentityMenu.tsx +4 -1
  60. package/src/Link.mdx +6 -0
  61. package/src/Link.stories.tsx +12 -0
  62. package/src/Link.tsx +7 -2
  63. package/src/Menu.stories.tsx +1 -1
  64. package/src/Menu.test.tsx +2 -2
  65. package/src/Menu.tsx +6 -3
  66. package/src/Person.stories.tsx +5 -5
  67. package/src/Popover.test.tsx +2 -2
  68. package/src/Popover.tsx +3 -2
  69. package/src/PreviewCard.tsx +1 -1
  70. package/src/Property.stories.tsx +2 -2
  71. package/src/ReactionPicker.tsx +6 -3
  72. package/src/ScrollArea.stories.tsx +38 -6
  73. package/src/ScrollArea.tsx +7 -1
  74. package/src/SectionLabel.mdx +3 -2
  75. package/src/SectionLabel.stories.tsx +3 -0
  76. package/src/SectionLabel.tsx +7 -2
  77. package/src/Tooltip.tsx +12 -14
  78. package/src/eslint/index.test.ts +58 -9
  79. package/src/eslint/index.ts +19 -3
  80. package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
  81. package/src/eslint/no-rebuilt-behaviour.ts +593 -0
  82. package/src/eslint/no-restyled-part.test.ts +301 -0
  83. package/src/eslint/no-restyled-part.ts +647 -0
  84. package/stories/Choosing.mdx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -186,6 +186,7 @@ export function AttachmentCard({
186
186
  const failed = state === 'failed'
187
187
  const warning = state === 'warning'
188
188
  return (
189
+ // @estiva-escape: a failed or warning file's strong hairline is AttachmentCard's own state; Card's attention hairlines are the soft ones (UIG-9, Katerina 17 September)
189
190
  <Card
190
191
  fill="elevated"
191
192
  className={cn('group relative flex items-center gap-2 w-[200px] p-1.5 pr-3', failed ? 'border-error-default' : warning && 'border-warning-default', className)}
@@ -233,6 +234,7 @@ export function AttachmentCard({
233
234
 
234
235
  // Fades in with the card's hover or keyboard focus, and keeps its slot when hidden, so revealing it never shifts the row.
235
236
  const download = onDownload ? (
237
+ // @estiva-escape: Download shows when its card is pointed at or focused, AttachmentCard's own action; IconButton has no reveal on its card's hover (UIG-9, Katerina 17 September)
236
238
  <IconButton
237
239
  variant="muted"
238
240
  tooltip="Download"
@@ -251,6 +253,7 @@ export function AttachmentCard({
251
253
  return (
252
254
  // `role="status"`: a plain box may not carry a name (axe, aria-prohibited-attr), and a
253
255
  // status says what it is doing — loading — to a reader that cannot see the pulse.
256
+ // @estiva-escape: the loading pulse is AttachmentCard's own state; Card has no loading state (UIG-9, Katerina 17 September)
254
257
  <Card role="status" fill="inset" aria-busy="true" aria-label={`Loading ${name}`} className={cn('w-[180px] h-28 animate-pulse', className)} {...props}>
255
258
  {null}
256
259
  </Card>
@@ -259,6 +262,7 @@ export function AttachmentCard({
259
262
 
260
263
  if (state === 'unreadable') {
261
264
  return (
265
+ // @estiva-escape: a file that could not be read is faded as well as dashed, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
262
266
  <Card fill="inset" unreadable className={cn('flex items-center gap-2 w-[240px] p-1.5 opacity-70', className)} {...props}>
263
267
  <div className={TILE_CLASSES}>
264
268
  <TypeIcon name={name} />
@@ -274,7 +278,7 @@ export function AttachmentCard({
274
278
  if (image && src) {
275
279
  const picture = <img src={src} alt={name} className="w-full h-28 object-cover" />
276
280
  return (
277
- <Card fill="inset" hover="hairline" className={cn('group relative flex flex-col w-[180px] overflow-hidden', className)} {...props}>
281
+ <Card fill="inset" hover="hairline" clip className={cn('group relative flex flex-col w-[180px]', className)} {...props}>
278
282
  {onOpen ? (
279
283
  <BaseButton
280
284
  type="button"
@@ -310,9 +314,10 @@ export function AttachmentCard({
310
314
  </>
311
315
  )
312
316
  return (
317
+ // @estiva-escape: a file that cannot be opened is faded, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
313
318
  <Card fill="inset" hover={href ? 'hairline' : 'none'} className={cn('group flex items-center gap-2 w-[240px] p-1.5 pr-1', !href && 'opacity-70', className)} {...props}>
314
319
  {href ? (
315
- <Link href={href} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1 cursor-pointer" onClick={(event) => event.stopPropagation()}>
320
+ <Link href={href} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
316
321
  {body}
317
322
  </Link>
318
323
  ) : (
package/src/Banner.tsx CHANGED
@@ -57,9 +57,9 @@ export function Banner({ tone, children, onDismiss, dismissLabel = 'Dismiss', cl
57
57
  return (
58
58
  <div role={role} className={cn('flex items-center gap-3 px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
59
59
  <span className="min-w-0 flex-1">{children}</span>
60
- {/* `text-current` so the ✕ takes the tone's colour rather than the
60
+ {/* `current` so the ✕ takes the tone's colour rather than the
61
61
  muted grey an IconButton wears on a neutral surface. */}
62
- <IconButton aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0 text-current hover:text-current">
62
+ <IconButton variant="current" aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0">
63
63
  <IconX size={16} stroke={1.5} />
64
64
  </IconButton>
65
65
  </div>
@@ -91,10 +91,15 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
91
91
  const crumb = item.href ? (
92
92
  // `plain` adds no look of its own: a crumb keeps the trail's size and
93
93
  // tone, and brightens on hover. The package's own Link, so a trail
94
- // follows whatever a link learns to do (UIG-5).
95
- <Link ref={setLabelRef} variant="plain" href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
96
- {item.label}
97
- </Link>
94
+ // follows whatever a link learns to do (UIG-5). The size and tone are
95
+ // on a box around it, which the link takes them from, rather than
96
+ // pushed into it (UIG-9); the link is still the one element that
97
+ // truncates and is measured.
98
+ <span className={cn('flex min-w-0', item.mono && 'shrink-0 font-mono text-caption', tone, 'hover:text-text-primary')}>
99
+ <Link ref={setLabelRef} variant="plain" truncate href={item.href} onClick={item.onClick}>
100
+ {item.label}
101
+ </Link>
102
+ </span>
98
103
  ) : (
99
104
  <span ref={setLabelRef} className={text} aria-current={last ? 'page' : undefined}>
100
105
  {item.label}
package/src/Button.mdx CHANGED
@@ -61,6 +61,13 @@ import { IconPlus } from '@tabler/icons-react'
61
61
  wrapper — what a caller gets is the `<button>`, and a `Menu.Trigger` or
62
62
  a `Dialog.Close` can be it.
63
63
 
64
+ - `variant="resolve"` is `primary` in every theme but Signal, where it is
65
+ outlined at rest and green when pointed at — the Resolve button. `IconButton`
66
+ has the same look.
67
+ - **Placed from outside, never restyled.** `className` takes space, size,
68
+ flex and grid, and position; a colour, a text size, a border, a corner or a
69
+ shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9).
70
+
64
71
  ## Keys
65
72
 
66
73
  | Key | Does |
@@ -7,7 +7,7 @@ const meta = {
7
7
  component: Button,
8
8
  args: { children: 'Button', variant: 'muted', size: 'default', disabled: false },
9
9
  argTypes: {
10
- variant: { control: 'inline-radio', options: ['primary', 'outlined', 'muted', 'destructive'] },
10
+ variant: { control: 'inline-radio', options: ['primary', 'outlined', 'muted', 'destructive', 'resolve'] },
11
11
  size: { control: 'inline-radio', options: ['default', 'small'] },
12
12
  leadingIcon: { control: false },
13
13
  },
@@ -83,3 +83,6 @@ export const AllVariants: Story = {
83
83
  </div>
84
84
  ),
85
85
  }
86
+
87
+ /** `resolve`: primary, and in Signal outlined at rest and green when pointed at — Resolve. */
88
+ export const Resolve: Story = { args: { variant: 'resolve', children: 'Resolve' } }
package/src/Button.tsx CHANGED
@@ -24,7 +24,12 @@ import { TooltipTrigger } from './Tooltip'
24
24
  * (Base UI's `focusableWhenDisabled`), and shows the reason as a tooltip on
25
25
  * hover. Ship wrote that wrapper by hand six times.
26
26
  */
27
- export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive'
27
+ /**
28
+ * `resolve` is `primary` in every theme but Signal, where it is outlined at rest
29
+ * and green when pointed at — Peek's Resolve (UIG-9, 17 September; Peek passed
30
+ * it as classes before). `IconButton` has the same look.
31
+ */
32
+ export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive' | 'resolve'
28
33
  export type ButtonSize = 'default' | 'small'
29
34
 
30
35
  /**
@@ -86,7 +91,7 @@ export function Button({
86
91
  // Extra right padding beside a leading icon, for optical balance.
87
92
  size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
88
93
  size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
89
- !state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
94
+ !state.disabled && (variant === 'primary' || variant === 'resolve') && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
90
95
  !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
91
96
  !state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
92
97
  !state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
@@ -98,6 +103,9 @@ export function Button({
98
103
  // and a prevented `onClick`), so nothing else needs it.
99
104
  state.disabled && !disabledReason && 'pointer-events-none',
100
105
  state.disabled && variant === 'outlined' && 'border border-border-default',
106
+ // Disabled too, as it always drew.
107
+ variant === 'resolve' &&
108
+ 'signal:bg-transparent signal:border signal:border-border-default signal:text-text-primary signal:shadow-none signal:hover:bg-success-muted signal:hover:border-success-outline signal:hover:text-success-default signal:transition-colors',
101
109
  className,
102
110
  )
103
111
  }
package/src/Card.mdx CHANGED
@@ -69,6 +69,13 @@ import { Card } from '@estiva-app/ui'
69
69
  state: set it when the pointer enters, and clear it when the pointer leaves
70
70
  and nothing of the card's is open.
71
71
 
72
+ - `clip` cuts what is inside to the card's rounded corners — a strip or a
73
+ picture that runs edge to edge. Without it their square corners show.
74
+ - **Placed from outside, never restyled.** `className` takes space, size,
75
+ flex and grid, and position; a colour, a text size, a border, a corner or a
76
+ shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9). What is inside the card is yours: its padding and its words go on
77
+ your own element inside.
78
+
72
79
  ## Props
73
80
 
74
81
  <Controls of={CardStories.Surface} />
@@ -110,3 +110,14 @@ export const AllFills: Story = {
110
110
  </div>
111
111
  ),
112
112
  }
113
+
114
+ /** `clip`: a strip that runs edge to edge is cut to the card's rounded corners. */
115
+ export const Clip: Story = {
116
+ parameters: { controls: { disable: true } },
117
+ render: () => (
118
+ <Card clip className="flex w-[240px] flex-col">
119
+ <div className="flex h-6 items-center bg-bg-inset px-2 text-caption text-text-primary">Huddle</div>
120
+ <div className="p-3 text-body-2 text-text-primary">What the card is about.</div>
121
+ </Card>
122
+ ),
123
+ }
package/src/Card.tsx CHANGED
@@ -80,6 +80,11 @@ export interface CardProps extends ComponentPropsWithRef<'div'> {
80
80
  attention?: CardAttention
81
81
  /** It stands for something that could not be read: the hairline is dashed. */
82
82
  unreadable?: boolean
83
+ /**
84
+ * What is inside is cut to the card's rounded corners: a strip or a picture that
85
+ * runs edge to edge (UIG-9, 17 September; the apps passed `overflow-hidden`).
86
+ */
87
+ clip?: boolean
83
88
  children: ReactNode
84
89
  }
85
90
 
@@ -93,6 +98,7 @@ export function Card({
93
98
  active = false,
94
99
  attention,
95
100
  unreadable = false,
101
+ clip = false,
96
102
  className,
97
103
  children,
98
104
  ...props
@@ -114,12 +120,14 @@ export function Card({
114
120
  active && 'bg-bg-selected border-accent-primary',
115
121
  !active && attention && ATTENTION_CLASSES[attention],
116
122
  unreadable && 'border-dashed',
123
+ clip && 'overflow-hidden',
117
124
  href && 'block',
118
125
  className,
119
126
  )
120
127
  if (href !== undefined) {
121
128
  const { ref, ...anchorProps } = props as ComponentPropsWithRef<'a'>
122
129
  return (
130
+ // @estiva-escape: a card that leads somewhere is drawn on Link's anchor (UIG-27 ruling 1), so this frame is Card's own look, not Link restyled
123
131
  <Link href={href} variant="plain" ref={ref} className={classes} {...anchorProps}>
124
132
  {children}
125
133
  </Link>
package/src/Checkbox.mdx CHANGED
@@ -27,9 +27,18 @@ the parent owns the state.
27
27
 
28
28
  <Canvas of={CheckboxStories.WithLabel} />
29
29
 
30
+ - A list you tick several from, where the whole row is the target: pass
31
+ **`row`**, with `label` and, if the row has one, a `leading` picture. The
32
+ box sits at the end, and the row fills on hover and while checked.
33
+
34
+ <Canvas of={CheckboxStories.Row} />
35
+
30
36
  ## When not
31
37
 
32
38
  - One choice out of several → **Select** (a value) or **Tabs** (a view).
39
+ - A row drawn by hand with `role="option"` and a Checkbox picture inside it: it
40
+ tells a screen reader "an option in a list" where there is no list. That is
41
+ `row`.
33
42
  - Inside a **Menu** — a checkbox inside a `menuitem` is invalid HTML;
34
43
  a checkable list lives in a DialogShell.
35
44
 
@@ -56,6 +65,16 @@ import { Checkbox } from '@estiva-app/ui'
56
65
  the box. When disabled, only the box shows it; the words keep their colour
57
66
  and lose the pointer. It is a field of its own, so it does not go inside a
58
67
  **Field**.
68
+ - With `row` it is the same `Field`, its `Field.Label` the whole row:
69
+ `leading`, 12px, the words in `body-2` (cut off with an ellipsis when too
70
+ long), the box at the end; 40px tall, 12px in from each side, 8px corners.
71
+ It needs `onChange`: without one, `row` is ignored. Put the rows in a
72
+ column with `gap-0.5`.
73
+
74
+ ```tsx
75
+ <Checkbox row leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item one" checked={on} onChange={setOn} />
76
+ ```
77
+
59
78
  - Inside a busy **Form** it is disabled, and looks it.
60
79
  - It does not move when it toggles: the tick is always in the box, hidden
61
80
  when unchecked, so both states hang on a line of text the same way.
@@ -64,7 +83,7 @@ import { Checkbox } from '@estiva-app/ui'
64
83
 
65
84
  | Key | Does |
66
85
  |---|---|
67
- | Tab | Onto the box. |
86
+ | Tab | Onto the box (in a row too: the row is not a Tab stop of its own). |
68
87
  | Space | Toggles it. |
69
88
  | Enter | Nothing. As on a native checkbox, Enter is the form's key. |
70
89
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { fn } from 'storybook/test'
3
3
  import { useState } from 'react'
4
+ import { IconSquareRounded } from '@tabler/icons-react'
4
5
  import { Checkbox } from './Checkbox'
5
6
 
6
7
  const meta = {
@@ -32,6 +33,49 @@ export const WithLabel: Story = {
32
33
  /** Disabled with words: only the box shows it; the words keep their colour. */
33
34
  export const WithLabelDisabled: Story = { args: { label: 'Label', 'aria-label': undefined, disabled: true } }
34
35
 
36
+ /**
37
+ * A list you tick several from: each row is the target — a picture, the words,
38
+ * the box at the end — and fills on hover and while checked.
39
+ */
40
+ export const Row: Story = {
41
+ parameters: { controls: { disable: true } },
42
+ render: () => {
43
+ const [ticked, setTicked] = useState(new Set(['Item two']))
44
+ const toggle = (item: string) =>
45
+ setTicked((prev) => {
46
+ const next = new Set(prev)
47
+ if (next.has(item)) next.delete(item)
48
+ else next.add(item)
49
+ return next
50
+ })
51
+ return (
52
+ <div className="flex w-80 flex-col gap-0.5">
53
+ {['Item one', 'Item two', 'Item three'].map((item) => (
54
+ <Checkbox
55
+ key={item}
56
+ row
57
+ leading={<IconSquareRounded size={16} stroke={1.5} />}
58
+ label={item}
59
+ checked={ticked.has(item)}
60
+ onChange={() => toggle(item)}
61
+ />
62
+ ))}
63
+ </div>
64
+ )
65
+ },
66
+ }
67
+
68
+ /** A row that cannot be changed: the box shows it, and the row neither fills nor points. */
69
+ export const RowDisabled: Story = {
70
+ parameters: { controls: { disable: true } },
71
+ render: () => (
72
+ <div className="flex w-80 flex-col gap-0.5">
73
+ <Checkbox row disabled leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item one" checked onChange={() => {}} />
74
+ <Checkbox row disabled leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item two" checked={false} onChange={() => {}} />
75
+ </div>
76
+ ),
77
+ }
78
+
35
79
  /** Controlled, as always — the parent owns the state. */
36
80
  export const Toggles: Story = {
37
81
  parameters: { controls: { disable: true } },
@@ -124,4 +124,67 @@ describe('Checkbox', () => {
124
124
  expect(screen.getByText('Label')).not.toBeNull()
125
125
  })
126
126
  })
127
+
128
+ describe('with row', () => {
129
+ const picture = <svg data-testid="picture" />
130
+
131
+ it('is named by its words, and a click anywhere on the row toggles it once', async () => {
132
+ const user = userEvent.setup()
133
+ const onChange = vi.fn()
134
+ render(<Checkbox row leading={picture} checked={false} onChange={onChange} label="Item one" />)
135
+ const box = screen.getByRole('checkbox', { name: 'Item one' })
136
+ await user.click(screen.getByText('Item one'))
137
+ await user.click(screen.getByTestId('picture'))
138
+ await user.click(box)
139
+ expect(onChange).toHaveBeenCalledTimes(3)
140
+ expect(onChange).toHaveBeenNthCalledWith(1, true)
141
+ })
142
+
143
+ it('draws the picture, the words, then the box, in one row', () => {
144
+ const { container } = render(<Checkbox row leading={picture} checked={false} onChange={() => {}} label="Item one" />)
145
+ const children = [...(container.querySelector('label')?.children ?? [])]
146
+ expect(children[0]?.getAttribute('data-testid')).toBe('picture')
147
+ expect(children[1]?.textContent).toBe('Item one')
148
+ expect(children[2]?.getAttribute('role')).toBe('checkbox')
149
+ expect(container.querySelector('label')?.className).toContain('h-10')
150
+ })
151
+
152
+ it('fills while checked, and on hover', () => {
153
+ const { container, rerender } = render(<Checkbox row checked={false} onChange={() => {}} label="Item one" />)
154
+ const row = () => container.querySelector('label')?.className ?? ''
155
+ expect(row()).not.toContain('bg-bg-selected')
156
+ expect(row()).toContain('hover:bg-bg-hover')
157
+ rerender(<Checkbox row checked onChange={() => {}} label="Item one" />)
158
+ expect(row()).toContain('bg-bg-selected')
159
+ })
160
+
161
+ it('does nothing when disabled, and neither fills under the pointer nor points', async () => {
162
+ const user = userEvent.setup()
163
+ const onChange = vi.fn()
164
+ const { container } = render(<Checkbox row disabled checked={false} onChange={onChange} label="Item one" />)
165
+ await user.click(screen.getByText('Item one'))
166
+ expect(onChange).not.toHaveBeenCalled()
167
+ const className = container.querySelector('label')?.className ?? ''
168
+ expect(className).not.toContain('cursor-pointer')
169
+ expect(className).not.toContain('hover:bg-bg-hover')
170
+ })
171
+
172
+ it('keeps Space on the box, and adds no Tab stop of its own', async () => {
173
+ const user = userEvent.setup()
174
+ const onChange = vi.fn()
175
+ render(<Checkbox row checked={false} onChange={onChange} label="Item one" />)
176
+ await user.tab()
177
+ expect(document.activeElement).toBe(screen.getByRole('checkbox', { name: 'Item one' }))
178
+ await user.keyboard(' ')
179
+ expect(onChange).toHaveBeenCalledWith(true)
180
+ await user.tab()
181
+ expect(document.activeElement).toBe(document.body)
182
+ })
183
+
184
+ it('is ignored with no onChange', () => {
185
+ const { container } = render(<Checkbox row checked label="Item one" />)
186
+ expect(container.querySelector('label')).toBeNull()
187
+ expect(screen.queryByRole('checkbox')).toBeNull()
188
+ })
189
+ })
127
190
  })
package/src/Checkbox.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
2
2
  import { Field as BaseField } from '@base-ui/react/field'
3
3
  import { IconCheck } from '@tabler/icons-react'
4
+ import type { ReactNode } from 'react'
4
5
  import { cn } from './cn'
5
6
  import { useFormBusy } from './formBusy'
6
7
 
@@ -33,6 +34,12 @@ import { useFormBusy } from './formBusy'
33
34
  * Read state panel's, where it was written by hand (UIG-7, 16 September). The
34
35
  * words keep their colour when the box is disabled (Katerina: "no need").
35
36
  *
37
+ * With `row`, the whole row is the target: an optional `leading` picture, the
38
+ * words, the box at the end, and the row fills on hover and while checked — a
39
+ * list you tick several from. The class list is Peek's "Add to Open work" rows,
40
+ * which drew it by hand as a `role="option"` in a list that was not one (UIG-8,
41
+ * Katerina's pick B, 16 September: the same pixels, on the package part).
42
+ *
36
43
  * Inside a busy `Form` it is disabled, and looks it (`formBusy.ts`).
37
44
  */
38
45
  export interface CheckboxProps {
@@ -44,6 +51,14 @@ export interface CheckboxProps {
44
51
  * they name it, so no `aria-label` is needed. `className` stays on the box.
45
52
  */
46
53
  label?: string
54
+ /**
55
+ * The whole row is the target: `leading`, then the words, then the box at
56
+ * the end; the row fills on hover and while checked. For a list you tick
57
+ * several from. Needs `label` and `onChange`; without `onChange` it is ignored.
58
+ */
59
+ row?: boolean
60
+ /** In a `row`, a picture before the words: an icon, a status. */
61
+ leading?: ReactNode
47
62
  'aria-label'?: string
48
63
  /** Set by a `Field` with `required`; a caller inside one owes nothing. */
49
64
  'aria-required'?: boolean | 'true' | 'false'
@@ -65,7 +80,9 @@ const tickClasses = (checked: boolean) => cn('flex', !checked && 'invisible')
65
80
 
66
81
  const WORDS_CLASSES = 'text-body-2 text-text-primary'
67
82
 
68
- export function Checkbox({ checked, onChange, disabled: ownDisabled = false, label, className, ...aria }: CheckboxProps) {
83
+ const ROW_CLASSES = 'flex shrink-0 items-center gap-3 h-10 px-3 rounded-lg transition-colors'
84
+
85
+ export function Checkbox({ checked, onChange, disabled: ownDisabled = false, label, row, leading, className, ...aria }: CheckboxProps) {
69
86
  const formBusy = useFormBusy()
70
87
  const disabled = ownDisabled || formBusy
71
88
  if (!onChange) {
@@ -101,6 +118,18 @@ export function Checkbox({ checked, onChange, disabled: ownDisabled = false, lab
101
118
  </BaseCheckbox.Root>
102
119
  )
103
120
  if (label === undefined) return box
121
+ if (row) {
122
+ return (
123
+ <BaseField.Root disabled={disabled}>
124
+ {/* No fill under the pointer, and no pointer, over a row that toggles nothing. */}
125
+ <BaseField.Label className={cn(ROW_CLASSES, checked && 'bg-bg-selected', !disabled && 'cursor-pointer hover:bg-bg-hover')}>
126
+ {leading}
127
+ <span className={cn('flex-1 min-w-0 truncate', WORDS_CLASSES)}>{label}</span>
128
+ {box}
129
+ </BaseField.Label>
130
+ </BaseField.Root>
131
+ )
132
+ }
104
133
  return (
105
134
  <BaseField.Root disabled={disabled}>
106
135
  {/* No pointer over words that toggle nothing. */}
@@ -395,7 +395,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
395
395
  {/* A heading labels the rows, it is not one of them: read
396
396
  secondary, as in a menu (MenuSection). */}
397
397
  <Autocomplete.GroupLabel className="flex h-7 shrink-0 items-center px-3">
398
- <SectionLabel className="text-text-secondary">{group.value}</SectionLabel>
398
+ <SectionLabel tone="secondary">{group.value}</SectionLabel>
399
399
  </Autocomplete.GroupLabel>
400
400
  <Autocomplete.Collection>
401
401
  {(row: CommandPaletteRow) => (
@@ -43,7 +43,6 @@ export function ConfirmDialog({ title, children, confirmLabel, destructive = fal
43
43
  alert
44
44
  title={title}
45
45
  onClose={onClose}
46
- bodyClassName="flex flex-col gap-3 text-body-2 text-text-primary"
47
46
  footer={
48
47
  <>
49
48
  <Button variant="muted" onClick={onClose} disabled={busy}>
@@ -55,7 +54,8 @@ export function ConfirmDialog({ title, children, confirmLabel, destructive = fal
55
54
  </>
56
55
  }
57
56
  >
58
- {children}
57
+ {/* The words' look on a box of the dialog's own, not pushed into DialogShell's body (UIG-9). */}
58
+ <div className="flex flex-col gap-3 text-body-2 text-text-primary">{children}</div>
59
59
  </DialogShell>
60
60
  )
61
61
  }
@@ -147,13 +147,12 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
147
147
  the caller's layout go on the content, so the bar is drawn over
148
148
  the padding rather than beside it. */}
149
149
  {bodyMaxHeight ? (
150
- <ScrollArea
151
- className={cn(footer != null && 'border-b border-border-subtle')}
152
- viewportClassName={bodyMaxHeight}
153
- contentClassName={cn('pl-5 pr-4 py-4', bodyClassName)}
154
- >
155
- {children}
156
- </ScrollArea>
150
+ // The line above the footer on a box of the shell's own, not pushed into ScrollArea (UIG-9).
151
+ <div className={cn(footer != null && 'border-b border-border-subtle')}>
152
+ <ScrollArea viewportClassName={bodyMaxHeight} contentClassName={cn('pl-5 pr-4 py-4', bodyClassName)}>
153
+ {children}
154
+ </ScrollArea>
155
+ </div>
157
156
  ) : (
158
157
  <div className={cn('pl-5 pr-4 py-4', footer != null && 'border-b border-border-subtle', bodyClassName)}>{children}</div>
159
158
  )}
package/src/Divider.mdx CHANGED
@@ -46,6 +46,9 @@ import { Divider } from '@estiva-app/ui'
46
46
  <Divider label="New since you last read this" tone="warning" />
47
47
  ```
48
48
 
49
+ - **Inside a `Menu` or a `Popover` it has no inset**: the hairline runs the
50
+ width of the rows. The panel tells it so; nothing is passed in.
51
+
49
52
  ## Props
50
53
 
51
54
  <Controls of={DividerStories.Default} />
package/src/Divider.tsx CHANGED
@@ -1,6 +1,15 @@
1
+ import { createContext, useContext } from 'react'
1
2
  import { Separator } from '@base-ui/react/separator'
2
3
  import { cn } from './cn'
3
4
 
5
+ /**
6
+ * Inside a `Menu` or a `Popover` a hairline runs edge to edge of the rows,
7
+ * with no inset of its own. The two panels used to reach in for it
8
+ * (`[&>[role=separator]]:mx-0`); they say so through this instead (UIG-9,
9
+ * 17 September). Not exported: only those two panels set it.
10
+ */
11
+ export const DividerInPanel = createContext(false)
12
+
4
13
  /**
5
14
  * Peek's Divider (2026-08-28): a hairline in `border-subtle`, inset 12px each
6
15
  * side. Plus what Ship added: `orientation="vertical"` — the same hairline
@@ -30,13 +39,14 @@ export interface DividerProps {
30
39
  }
31
40
 
32
41
  export function Divider({ orientation = 'horizontal', label, tone = 'default', className }: DividerProps) {
42
+ const inset = useContext(DividerInPanel) ? 'mx-0' : 'mx-3'
33
43
  if (label && orientation === 'horizontal') {
34
44
  const line = tone === 'warning' ? 'bg-warning-muted' : 'bg-border-subtle'
35
45
  return (
36
46
  <Separator
37
47
  orientation="horizontal"
38
48
  aria-label={label}
39
- className={cn('flex shrink-0 items-center gap-2 mx-3', className)}
49
+ className={cn('flex shrink-0 items-center gap-2', inset, className)}
40
50
  >
41
51
  <span aria-hidden="true" className={cn('h-px flex-1', line)} />
42
52
  <span className={cn('shrink-0 text-caption', tone === 'warning' ? 'text-warning-default' : 'text-text-muted')}>{label}</span>
@@ -51,7 +61,7 @@ export function Divider({ orientation = 'horizontal', label, tone = 'default', c
51
61
  // column shrinks to nothing, and a hairline that renders 0px tall is a
52
62
  // hairline nobody can see — Peek's `/` menu had been drawing two of
53
63
  // them, measured 0px, since it was built (2026-09-05).
54
- className={cn('shrink-0 bg-border-subtle', orientation === 'horizontal' ? 'h-px mx-3' : 'w-px self-stretch', className)}
64
+ className={cn('shrink-0 bg-border-subtle', orientation === 'horizontal' ? cn('h-px', inset) : 'w-px self-stretch', className)}
55
65
  />
56
66
  )
57
67
  }
@@ -50,6 +50,11 @@ import { EditableText } from '@estiva-app/ui'
50
50
  raw references, rendered richer when read) without losing the original
51
51
  on commit.
52
52
 
53
+ - **Its text size is where it sits**, like `Person`'s: put `text-h2` on your own
54
+ element around it, and the text and the field both take it (a field inherits
55
+ the page's font). `className` places it — `-mx-2` to line its text up with
56
+ a header's inset — and a size passed there is refused by the lint (UIG-9).
57
+
53
58
  ## Keys
54
59
 
55
60
  | Input | What happens |
@@ -40,6 +40,15 @@ import { IconPencil } from '@tabler/icons-react'
40
40
  control. The `tooltip` shows sighted hover users the same word; it does
41
41
  not replace the label.
42
42
  - The icon is 16px at stroke 1.5.
43
+ - `variant="current"` takes the colour of what it sits in — the ✕ on a
44
+ `Banner` wears the banner's tone. `variant="resolve"` is `muted` that turns
45
+ green when pointed at in Signal: Resolve, wherever it appears.
46
+ - `pressed` is a toggle that is on — Bold while the selection is bold: the
47
+ active fill, and `aria-pressed`. A `ToolbarButton` takes it too.
48
+ - `glow` lights it in Signal — the send arrow while there is something to send.
49
+ - **Placed from outside, never restyled.** `className` takes space, size,
50
+ flex and grid, and position; a colour, a text size, a border, a corner or a
51
+ shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9).
43
52
  - `type` defaults to `"button"`; it is a native `<button>` on Base UI's
44
53
  Button, and every native prop passes through.
45
54
  - `disabledReason="Read only"` disables it, keeps it reachable by Tab,
@@ -8,7 +8,7 @@ const meta = {
8
8
  // An icon-only button owes its name; axe's button-name rule fails without it.
9
9
  args: { variant: 'muted', disabled: false, 'aria-label': 'Settings', children: <IconSettings className="size-4" stroke={1.5} /> },
10
10
  argTypes: {
11
- variant: { control: 'inline-radio', options: ['muted', 'outlined', 'primary'] },
11
+ variant: { control: 'inline-radio', options: ['muted', 'outlined', 'primary', 'current', 'resolve'] },
12
12
  tooltipPlacement: { control: 'inline-radio', options: ['top', 'bottom'] },
13
13
  children: { control: false },
14
14
  },
@@ -63,3 +63,12 @@ export const InATallRow: Story = {
63
63
  </div>
64
64
  ),
65
65
  }
66
+
67
+ /** `resolve`: muted, and green when pointed at in Signal — Resolve. (`current` takes the colour it sits in: see Banner's ✕.) */
68
+ export const Resolve: Story = { args: { variant: 'resolve', 'aria-label': 'Resolve', tooltip: 'Resolve' } }
69
+
70
+ /** `pressed`: on — the active fill and `aria-pressed`, like Bold while the selection is bold. */
71
+ export const Pressed: Story = { args: { pressed: true, 'aria-label': 'Bold' } }
72
+
73
+ /** `glow`: Signal's glow, on the send arrow while there is something to send. */
74
+ export const Glow: Story = { args: { variant: 'primary', glow: true, 'aria-label': 'Send' } }