@estiva-app/ui 0.18.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
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. */}
@@ -29,6 +29,24 @@ export const Default: Story = {
29
29
  ),
30
30
  }
31
31
 
32
+ /** Rows that stick to the top as the list moves under them. The bar stays above them. */
33
+ export const StickyHeadings: Story = {
34
+ render: () => (
35
+ <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
36
+ {['Group one', 'Group two'].map((group) => (
37
+ <div key={group} className="flex flex-col">
38
+ <p className="sticky top-0 z-10 bg-bg-surface px-4 py-2 text-caption text-text-secondary">{group}</p>
39
+ {rows.slice(0, 8).map((row) => (
40
+ <p key={row} className="px-4 py-1.5 text-body-2 text-text-primary">
41
+ {row}
42
+ </p>
43
+ ))}
44
+ </div>
45
+ ))}
46
+ </ScrollArea>
47
+ ),
48
+ }
49
+
32
50
  /** Nothing to scroll: the region draws exactly as a plain box would, and no bar. */
33
51
  export const Fits: Story = {
34
52
  render: () => (
@@ -30,6 +30,12 @@ import { cn } from './cn'
30
30
  * the thin scrollbar both apps had styled by hand in their `index.css`, drawn
31
31
  * once here instead.
32
32
  *
33
+ * The bar sits above the content (`z-10`). A sticky row inside — a date line
34
+ * in a conversation, `sticky top-0 z-10` — is at the same level, and the bar
35
+ * comes after the content in the page, so it paints on top. Without it the
36
+ * row hid the bar wherever it crossed it: a gap in the thumb, in Peek's topic
37
+ * and direct-message lists (UIG-8, 16 September).
38
+ *
33
39
  * `orientation` says which way the region scrolls; a table that is wider
34
40
  * than its box scrolls `horizontal`, a list `vertical` (the default), a board
35
41
  * `both`. A vertical region keeps its content no wider than itself, so a
@@ -58,7 +64,7 @@ export interface ScrollAreaProps {
58
64
  children: ReactNode
59
65
  }
60
66
 
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'
67
+ const BAR = 'z-10 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'
62
68
  const THUMB = 'rounded-full bg-border-strong'
63
69
 
64
70
  export function ScrollArea({ orientation = 'vertical', className, viewportClassName, contentClassName, viewportRef, onScroll, children }: ScrollAreaProps) {
@@ -6,8 +6,8 @@ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from '
6
6
 
7
7
  /**
8
8
  * The plugin as an app uses it: a flat config with `configs.recommended`,
9
- * linting text the way Peek's editor hook does (`lintText`). The rule's own
10
- * cases are in no-raw-element.test.ts.
9
+ * linting text the way Peek's editor hook does (`lintText`). The rules' own
10
+ * cases are in no-raw-element.test.ts and no-rebuilt-behaviour.test.ts.
11
11
  */
12
12
  const tsx: Linter.Config = {
13
13
  files: ['**/*.tsx'],
@@ -35,6 +35,7 @@ describe('the plugin object', () => {
35
35
  it('carries every rule, the app ones and the inward ones', () => {
36
36
  expect(Object.keys(estiva.rules)).toEqual([
37
37
  'no-raw-element',
38
+ 'no-rebuilt-behaviour',
38
39
  'raw-element-outside-a-wrapper',
39
40
  'no-hand-rolled-behaviour',
40
41
  'component-has-a-page',
@@ -54,9 +55,9 @@ describe('the plugin object', () => {
54
55
  it('gives an app only the app rules, as errors, under estiva/', () => {
55
56
  for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
56
57
  expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
57
- expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
58
+ expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error' })
58
59
  }
59
- expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element'])
60
+ expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour'])
60
61
  })
61
62
 
62
63
  it('gives this package its own set, as errors, and it reaches no app config', () => {
@@ -90,6 +91,25 @@ describe('an app lint with configs.recommended', () => {
90
91
  ])
91
92
  })
92
93
 
94
+ it('reports behaviour rebuilt by hand, naming the part', async () => {
95
+ const [result] = await lint(component(' <div className="h-64 overflow-y-auto" />'))
96
+ expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
97
+ ['estiva/no-rebuilt-behaviour', 2, "`overflow-y-auto` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours."],
98
+ ])
99
+ })
100
+
101
+ /**
102
+ * UIG-8's acceptance: a separator inside Divider is not a hand-written role.
103
+ * The package is exempt from the apps' rules; this runs them on Divider anyway,
104
+ * to show the role branch has nothing to say there (its Base UI import is the
105
+ * package's job, and reported only because the apps' config is not meant for it).
106
+ */
107
+ it("finds no hand-written role in Divider's own source", async () => {
108
+ const source = readFileSync(new URL('../Divider.tsx', import.meta.url), 'utf8')
109
+ const [result] = await lint(source)
110
+ expect(result.messages.filter((m) => m.messageId === 'role' || m.messageId === 'roleNoPart')).toEqual([])
111
+ })
112
+
93
113
  it('passes the same element under an escape', async () => {
94
114
  const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
95
115
  expect(result.messages).toEqual([])
@@ -99,8 +119,9 @@ describe('an app lint with configs.recommended', () => {
99
119
  describe('countGates', () => {
100
120
  it('counts an error, and an escape only when the lint reports escapes', async () => {
101
121
  const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
102
- expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 } })
103
- expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 } })
122
+ const none = { errors: 0, warnings: 0, escapes: 0 }
123
+ expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none })
124
+ expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none })
104
125
  })
105
126
 
106
127
  it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
@@ -118,6 +139,9 @@ describe('countGates', () => {
118
139
  })
119
140
 
120
141
  it('lists every rule of the plugin, even with nothing found', () => {
121
- expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
142
+ expect(countGates([])).toEqual({
143
+ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 } },
144
+ disabled: [],
145
+ })
122
146
  })
123
147
  })
@@ -21,9 +21,11 @@ import type { ESLint, Linter } from 'eslint'
21
21
  import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
22
22
  import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
23
23
  import { noRawElement } from './no-raw-element'
24
+ import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
24
25
  import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
25
26
 
26
27
  export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
28
+ export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
27
29
 
28
30
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
29
31
 
@@ -32,10 +34,12 @@ export const PLUGIN_KEY = 'estiva'
32
34
 
33
35
  /**
34
36
  * The rules an **app** runs: they say an app must not build what the package
35
- * already has. `recommended` and `strict` carry these and only these.
37
+ * already has a raw control (UIG-7), or a behaviour one of its parts owns
38
+ * (UIG-8). `recommended` and `strict` carry these and only these.
36
39
  */
37
40
  const appRules = {
38
41
  'no-raw-element': noRawElement,
42
+ 'no-rebuilt-behaviour': noRebuiltBehaviour,
39
43
  }
40
44
 
41
45
  /**
@@ -73,7 +77,7 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
73
77
  /**
74
78
  * `recommended` switches every **app** rule on at the level it was ruled at: an
75
79
  * error blocks, a warning is reported and never blocks. `strict` makes every app
76
- * rule an error. With one rule, an error, the two are the same today; they part
80
+ * rule an error. With every app rule an error, the two are the same today; they part
77
81
  * when the first warning-level rule arrives (UIG-25).
78
82
  *
79
83
  * `package` is the inward set (UIG-5), which only this package runs.
@@ -81,7 +85,10 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
81
85
  plugin.configs.recommended = {
82
86
  name: '@estiva-app/ui/recommended',
83
87
  plugins: { [PLUGIN_KEY]: plugin },
84
- rules: { [`${PLUGIN_KEY}/no-raw-element`]: 'error' },
88
+ rules: {
89
+ [`${PLUGIN_KEY}/no-raw-element`]: 'error',
90
+ [`${PLUGIN_KEY}/no-rebuilt-behaviour`]: 'error',
91
+ },
85
92
  }
86
93
  plugin.configs.strict = {
87
94
  name: '@estiva-app/ui/strict',