@estiva-app/ui 0.17.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/src/Form.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, type FormHTMLAttributes, type ReactNode } from 'react'
1
+ import { useLayoutEffect, useRef, type FormHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
2
2
  import { Fieldset } from '@base-ui/react/fieldset'
3
3
  import { Form as BaseForm } from '@base-ui/react/form'
4
4
  import { cn } from './cn'
@@ -27,26 +27,57 @@ import { FormBusyContext, useFormBusy } from './formBusy'
27
27
  * When `busy` ends, focus goes to the first invalid field, else to what sent
28
28
  * the form, else to the first control. That is `CommandPalette`'s order
29
29
  * too, so the two read the same (UIG-29).
30
+ *
31
+ * The keys are ours, the same in every form (Katerina, 16 September): Enter in
32
+ * a one-line field sends; Enter in a text area is a new line; Enter in a list
33
+ * or a people picker picks; Ctrl+Enter (Cmd+Enter) sends from anywhere inside.
34
+ * The form sends on Enter itself rather than leaving it to the browser, whose
35
+ * implicit submission depends on whether the form has a submit button and how
36
+ * many fields it holds. `enterSends={false}` keeps Enter in a one-line field
37
+ * from sending, for a form where only Ctrl+Enter may send (`CommandPalette`).
38
+ * Every way of sending goes through the form's submit, so Base UI's field
39
+ * check runs for each.
30
40
  */
31
41
  export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children' | 'noValidate'> {
32
42
  /** Enter in a field, or a submit button. The page's own submit is already prevented. */
33
43
  onSubmit: () => void | Promise<void>
34
44
  /** While sending: every field and button inside is switched off, and focus waits on the form. */
35
45
  busy?: boolean
46
+ /**
47
+ * Whether Enter in a one-line field sends. On by default. Off where only
48
+ * Ctrl+Enter may send — a form inside `CommandPalette`. Ctrl+Enter sends either way.
49
+ */
50
+ enterSends?: boolean
36
51
  children: ReactNode
37
52
  }
38
53
 
39
54
  const CONTROL = 'input:not([type="hidden"]), textarea, select, button, [role="checkbox"], [role="combobox"], [tabindex]:not([tabindex="-1"])'
40
55
 
56
+ /** The inputs a person types one line into; a picker's input (`role="combobox"`) is not one. */
57
+ const ONE_LINE = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password', 'number'])
58
+
41
59
  function usable(element: Element | null): element is HTMLElement {
42
60
  return element instanceof HTMLElement && element.isConnected && element.matches(CONTROL) && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true'
43
61
  }
44
62
 
45
- export function Form({ onSubmit, busy: ownBusy = false, className, children, ...props }: FormProps) {
63
+ /** A marked field's own control: Base UI marks the control and the `Field` around it. */
64
+ function firstInvalid(form: HTMLElement): HTMLElement | undefined {
65
+ for (const marked of form.querySelectorAll('[data-invalid]')) {
66
+ if (usable(marked)) return marked
67
+ const inside = Array.from(marked.querySelectorAll(CONTROL)).find(usable)
68
+ if (inside) return inside
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, className, children, ...props }: FormProps) {
46
74
  // A form inside a busy form is busy too.
47
75
  const outerBusy = useFormBusy()
48
76
  const busy = ownBusy || outerBusy
49
77
  const form = useRef<HTMLFormElement>(null)
78
+ // Read at submit time: a form that is sending does not send again (Ctrl+Enter reaches it while busy).
79
+ const busyNow = useRef(busy)
80
+ busyNow.current = busy
50
81
  // What had focus when the form went busy, and whether the form took focus from it.
51
82
  const sender = useRef<Element | null>(null)
52
83
  const holding = useRef(false)
@@ -77,12 +108,39 @@ export function Form({ onSubmit, busy: ownBusy = false, className, children, ...
77
108
  holding.current = false
78
109
  // Someone who moved focus on while waiting keeps it where they put it.
79
110
  if (document.activeElement !== element && document.activeElement !== document.body) return
80
- const invalid = Array.from(element.querySelectorAll('[data-invalid]')).find(usable)
81
- const target = invalid ?? (usable(sender.current) ? sender.current : Array.from(element.querySelectorAll(CONTROL)).find(usable))
111
+ const target = firstInvalid(element) ?? (usable(sender.current) ? sender.current : Array.from(element.querySelectorAll(CONTROL)).find(usable))
82
112
  sender.current = null
83
113
  target?.focus()
84
114
  }, [busy])
85
115
 
116
+ /*
117
+ Bubble phase, after the field's own handler: a picker picks on Enter and
118
+ says so by preventing it, and a field that handles Enter itself does too.
119
+ */
120
+ const onKeyDown = (event: KeyboardEvent<HTMLFormElement>) => {
121
+ props.onKeyDown?.(event)
122
+ if (event.key !== 'Enter' || event.defaultPrevented || event.nativeEvent.isComposing) return
123
+ const target = event.target
124
+ const inInput = target instanceof HTMLInputElement
125
+ /*
126
+ In an input the browser sends a form on its own — on Enter with Shift or
127
+ Alt too (measured in Chrome: Shift+Enter sent Peek's comment box, 16
128
+ September) — so the form stops that every time and sends only by these
129
+ rules. A text area's Enter is a new line and a button's Enter presses it:
130
+ those are left to the browser.
131
+ */
132
+ if (inInput) event.preventDefault()
133
+ if (event.altKey || event.shiftKey) return
134
+ if (event.ctrlKey || event.metaKey) {
135
+ event.preventDefault()
136
+ form.current?.requestSubmit()
137
+ return
138
+ }
139
+ if (!inInput) return
140
+ const oneLine = ONE_LINE.has((target.getAttribute('type') ?? '').toLowerCase()) && target.getAttribute('role') !== 'combobox'
141
+ if (enterSends && oneLine) form.current?.requestSubmit()
142
+ }
143
+
86
144
  return (
87
145
  <BaseForm
88
146
  ref={form}
@@ -100,8 +158,10 @@ export function Form({ onSubmit, busy: ownBusy = false, className, children, ...
100
158
  if (next && !form.current?.contains(next)) lastInside.current = null
101
159
  props.onBlur?.(event)
102
160
  }}
161
+ onKeyDown={onKeyDown}
103
162
  onSubmit={(event) => {
104
163
  event.preventDefault()
164
+ if (busyNow.current) return
105
165
  void onSubmit()
106
166
  }}
107
167
  >
@@ -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',
@@ -0,0 +1,276 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { RuleTester } from 'eslint'
4
+ import ts from 'typescript'
5
+ import { parser } from 'typescript-eslint'
6
+ import { describe, expect, it } from 'vitest'
7
+ import {
8
+ BASE_UI_PARTS,
9
+ baseUiModule,
10
+ noRebuiltBehaviour,
11
+ OWNED_BEHAVIOURS,
12
+ ROLE_PARTS,
13
+ utilityOf,
14
+ WALKING_KEYS,
15
+ } from './no-rebuilt-behaviour'
16
+
17
+ RuleTester.describe = describe
18
+ RuleTester.it = it
19
+ RuleTester.itOnly = it.only
20
+
21
+ const tester = new RuleTester({
22
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
23
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
24
+ })
25
+
26
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
27
+ const effect = (body: string) => `useEffect(() => {\n${body}\n}, [])\n`
28
+ const LIST = ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`.'
29
+
30
+ tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
31
+ valid: [
32
+ // Base UI and portals
33
+ { name: 'a part from the package', code: "import { Select, Popover } from '@estiva-app/ui'" },
34
+ { name: 'react-dom without createPortal', code: "import { flushSync } from 'react-dom'\nflushSync(() => {})" },
35
+ { name: 'a package whose name only starts like Base UI', code: "import x from '@base-uix/react'" },
36
+
37
+ // listeners
38
+ { name: 'the window coming back is not a floating part', code: effect(" window.addEventListener('focus', refresh)\n document.addEventListener('visibilitychange', refresh)\n window.addEventListener('storage', sync)") },
39
+ { name: 'a listener on an element, not the page', code: effect(" ref.current.addEventListener('keydown', onKey)") },
40
+ { name: 'an event the code computes', code: effect(' document.addEventListener(name, handler)') },
41
+ { name: "the app's own event on window", code: effect(" window.addEventListener('highlight-tag-click', open)") },
42
+
43
+ // keys
44
+ { name: 'Enter and Escape in a field are typing', code: "const onKeyDown = (e) => {\n if (e.key === 'Enter') send()\n if (e.key === 'Escape') cancel()\n}" },
45
+ { name: 'a shortcut letter', code: "const onKeyDown = (e) => {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') open()\n}" },
46
+ { name: 'the word ArrowDown that is not a key', code: "const label = 'ArrowDown'\nif (label === 'ArrowDown') go()" },
47
+
48
+ // roles
49
+ { name: 'role img on an svg', code: component(' <svg role="img" aria-label="Logo"><path d="M0 0" /></svg>') },
50
+ { name: 'the roles that describe rather than behave', code: component(' <div role="group" aria-label="Format">\n <div role="presentation" />\n <section role="region" aria-label="A" />\n <span role="none" />\n </div>') },
51
+ { name: 'a role the code computes', code: component(' <div role={role} />') },
52
+
53
+ // tab stops
54
+ { name: 'tabIndex -1 makes a box focusable by script, not a Tab stop', code: component(' <p tabIndex={-1}>measured</p>') },
55
+ { name: 'tabIndex on a control', code: component(' <div>\n <button tabIndex={0}>x</button>\n <input tabIndex={0} />\n <a href="/x" tabIndex={0}>x</a>\n </div>') },
56
+ { name: 'tabIndex passed through from a prop', code: component(' <div tabIndex={props.tabIndex} />') },
57
+ { name: 'tabIndex on an editable region', code: component(' <div contentEditable tabIndex={0} />') },
58
+ { name: 'tabIndex on a package component', code: component(' <MenuItem tabIndex={-1} label="Item" />') },
59
+
60
+ // scrolling
61
+ { name: 'overflow that does not scroll', code: component(' <div className="overflow-hidden overflow-x-clip overflow-visible overscroll-contain" />') },
62
+ { name: 'a word that only contains the class', code: "const id = 'my-overflow-auto-thing'" },
63
+ { name: 'a style that hides overflow', code: component(" <div style={{ overflow: 'hidden' }} />") },
64
+
65
+ // escapes
66
+ {
67
+ name: 'an escape above the statement keeps a page listener',
68
+ code: effect(" // @estiva-escape: an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)"),
69
+ },
70
+ {
71
+ name: 'an escape above the element keeps a hand-written role',
72
+ code: component(' // @estiva-escape: a list the editor drives from its caret\n <MenuItem role="option" aria-selected label="Item" />'),
73
+ },
74
+ {
75
+ name: 'an escape above the object property keeps its arrow keys',
76
+ code: "useImperativeHandle(ref, () => ({\n // @estiva-escape: a list the editor drives from its caret\n onKeyDown: ({ event }) => {\n if (event.key === 'ArrowDown') next()\n if (event.key === 'ArrowUp') previous()\n return false\n },\n}))",
77
+ },
78
+ {
79
+ name: 'an escape above the call keeps a portal',
80
+ code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n // @estiva-escape: becomes the package Lightbox at stage 7\n return createPortal(<div />, document.body)\n}",
81
+ },
82
+ {
83
+ name: 'an escape above an array item keeps its class',
84
+ code: "const CLASSES = [\n 'flex',\n // @estiva-escape: a code block the editor draws, which nothing can wrap\n '[&_pre]:overflow-x-auto',\n]",
85
+ },
86
+ {
87
+ name: 'one escape above an element covers everything found on it',
88
+ code: component(' // @estiva-escape: a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} className="overflow-auto" />'),
89
+ },
90
+ ],
91
+ invalid: [
92
+ // Base UI
93
+ {
94
+ name: 'a Base UI import names the part built on it',
95
+ code: "import { Dialog } from '@base-ui/react/dialog'",
96
+ errors: [{ message: 'Only @estiva-app/ui imports Base UI (`@base-ui/react/dialog`). Use `DialogShell` from @estiva-app/ui. To search and act, `CommandPalette`.', line: 1 }],
97
+ },
98
+ ...Object.entries(BASE_UI_PARTS).map(([module, part]) => ({
99
+ name: part ? `@base-ui/react/${module} names ${part.use}` : `@base-ui/react/${module} has no part yet`,
100
+ code: `import * as Part from '@base-ui/react/${module}'`,
101
+ errors: [{ messageId: part ? 'baseUi' : 'baseUiNoPart' }],
102
+ })),
103
+ { name: 'the old package spelling', code: "import { Menu } from '@base-ui-components/react/menu'", errors: [{ message: 'Only @estiva-app/ui imports Base UI (`@base-ui-components/react/menu`). Use `Menu` from @estiva-app/ui.' }] },
104
+ { name: 'the package root', code: "import { Popover } from '@base-ui/react'", errors: [{ messageId: 'baseUiNoPart' }] },
105
+ { name: "a Base UI utility", code: "import { useRender } from '@base-ui/react/use-render'", errors: [{ messageId: 'baseUiNoPart' }] },
106
+ { name: 'a type-only import', code: "import type { PopoverRootProps } from '@base-ui/react/popover'", errors: [{ messageId: 'baseUi' }] },
107
+ { name: 're-exported', code: "export { Popover } from '@base-ui/react/popover'", errors: [{ messageId: 'baseUi' }] },
108
+ { name: 'imported when needed', code: "const Tabs = lazy(() => import('@base-ui/react/tabs'))", errors: [{ messageId: 'baseUi' }] },
109
+ { name: 'required', code: "const { Toolbar } = require('@base-ui/react/toolbar')", errors: [{ messageId: 'baseUi' }] },
110
+
111
+ // portals
112
+ {
113
+ name: 'createPortal, called: reported where it is called, once',
114
+ code: "import { createPortal } from 'react-dom'\nfunction Viewer() {\n return createPortal(<div />, document.body)\n}",
115
+ errors: [{ messageId: 'portal', line: 3 }],
116
+ },
117
+ { name: 'createPortal imported and never called', code: "import { createPortal } from 'react-dom'\nexport function Header() {\n return null\n}", errors: [{ messageId: 'portal', line: 1 }] },
118
+ { name: 'createPortal on the namespace', code: "import ReactDOM from 'react-dom'\nReactDOM.createPortal(child, node)", errors: [{ messageId: 'portal', line: 2 }] },
119
+
120
+ // listeners
121
+ {
122
+ name: 'a press outside, by hand',
123
+ code: effect(" document.addEventListener('mousedown', close)"),
124
+ errors: [{ message: 'A `mousedown` listener on `document`: closing on a press outside, by hand. `Popover`, `Menu`, `Select`, `DialogShell` and `PreviewCard` from @estiva-app/ui close themselves.', line: 2 }],
125
+ },
126
+ { name: 'keys for the whole page', code: effect(" window.addEventListener('keydown', onKey)"), errors: [{ messageId: 'key', data: { event: 'keydown', target: 'window' } }] },
127
+ { name: 'focus held by hand', code: effect(" document.body.addEventListener('focusin', keep, true)"), errors: [{ messageId: 'focus', data: { event: 'focusin', target: 'document.body' } }] },
128
+ { name: 'following an anchor by hand', code: effect(" window.addEventListener('resize', place)\n window.addEventListener('scroll', place, true)"), errors: [{ messageId: 'follow', line: 2 }, { messageId: 'follow', line: 3 }] },
129
+ { name: 'a handler property on window', code: 'window.onkeydown = (e) => close(e)', errors: [{ messageId: 'key', data: { event: 'keydown', target: 'window' } }] },
130
+ { name: "the page's scroll locked by hand", code: "document.body.style.overflow = 'hidden'", errors: [{ messageId: 'scrollLock' }] },
131
+
132
+ // keys
133
+ {
134
+ name: 'arrow keys in one handler: one report, at the first',
135
+ code: "const onKeyDown = (e) => {\n if (e.key === 'ArrowDown') next()\n if (e.key === 'ArrowUp') previous()\n}",
136
+ errors: [{ message: 'Arrow keys handled by hand (`ArrowDown`). `Menu`, `Select`, `ChipInput`, `CommandPalette`, `Tabs` and `Toolbar` from @estiva-app/ui move through their items themselves.', line: 2 }],
137
+ },
138
+ ...WALKING_KEYS.map((key) => ({ name: `${key} is a walking key`, code: `function onKey(event) {\n if (event.code == '${key}') go()\n}`, errors: [{ messageId: 'walking', data: { key } }] })),
139
+ { name: 'two handlers are two reports', code: "const a = (e) => e.key === 'ArrowLeft'\nconst b = (e) => e.key === 'ArrowRight'", errors: [{ messageId: 'walking', line: 1 }, { messageId: 'walking', line: 2 }] },
140
+ { name: 'a switch on the key', code: "function onKey(e) {\n switch (e.key) {\n case 'Enter': return send()\n case 'Home': return first()\n }\n}", errors: [{ messageId: 'walking', data: { key: 'Home' }, line: 4 }] },
141
+ { name: 'a list of keys', code: "const walks = (event) => ['ArrowUp', 'ArrowDown'].includes(event.key)", errors: [{ messageId: 'walking', data: { key: 'ArrowUp' } }] },
142
+ { name: 'a destructured key', code: "function onKey({ key }) {\n if (key !== 'PageDown') return\n}", errors: [{ messageId: 'walking', data: { key: 'PageDown' } }] },
143
+ { name: 'the Tab key, by hand', code: "const trap = (e) => {\n if (e.key === 'Tab') keepInside(e)\n}", errors: [{ messageId: 'tabKey', line: 2 }] },
144
+ { name: 'arrow keys in a JSX handler', code: component(" <div onKeyDown={(e) => { if (e.key === 'ArrowDown') next() }} />"), errors: [{ messageId: 'walking', line: 3 }] },
145
+
146
+ // roles
147
+ ...Object.entries(ROLE_PARTS).map(([role, { thing, part }]) => ({
148
+ name: part ? `role="${role}" names ${part.use}` : `role="${role}" has no part yet`,
149
+ code: component(` <div role="${role}" />`),
150
+ errors: [
151
+ part
152
+ ? { message: `A hand-written \`role="${role}"\` is a hand-made ${thing}. Use \`${part.use}\` from @estiva-app/ui.${part.more ?? ''}` }
153
+ : { message: `A hand-written \`role="${role}"\` is a hand-made ${thing}, and @estiva-app/ui has no part for one yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.` },
154
+ ],
155
+ })),
156
+ { name: 'a hand-made option row (Peek, AddToOpenWorkDialog)', code: component(' <div role="option" aria-selected={checked} onClick={toggle} />'), errors: [{ message: `A hand-written \`role="option"\` is a hand-made list. Use \`Select\` from @estiva-app/ui.${LIST}` }] },
157
+ { name: 'a role on a package component', code: component(' <MenuItem role="option" label="Item" />'), errors: [{ messageId: 'role' }] },
158
+ { name: 'a role in braces, or one of two', code: component(" <p role={failed ? 'alert' : 'status'} />"), errors: [{ messageId: 'role', data: { role: 'alert', thing: 'message', use: 'FieldLine', more: ' For a notice, `Banner`; for a message that comes and goes, `Toast`.' } }] },
159
+
160
+ // tab stops
161
+ { name: 'tabIndex 0 on a div', code: component(' <div tabIndex={0} />'), errors: [{ message: '`tabIndex=0` makes a `<div>` a Tab stop by hand. Use `Button`, `IconButton` or `Link` from @estiva-app/ui, which are reachable already.' }] },
162
+ { name: 'tabIndex written as a string', code: component(' <span tabIndex="0" />'), errors: [{ messageId: 'tabStop' }] },
163
+ { name: 'tabIndex above 0', code: component(' <li tabIndex={2} />'), errors: [{ messageId: 'tabStop' }] },
164
+ { name: 'tabIndex 0 on one side of a condition (Ship, DescriptionEditor)', code: component(' <div tabIndex={readOnly ? undefined : 0} />'), errors: [{ messageId: 'tabStop', data: { value: 'readOnly ? undefined : 0', element: 'div' } }] },
165
+
166
+ // scrolling
167
+ { name: 'overflow-y-auto', code: component(' <div className="h-64 overflow-y-auto" />'), errors: [{ message: "`overflow-y-auto` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours." }] },
168
+ { name: 'every overflow that scrolls', code: component(' <div className="overflow-auto overflow-scroll overflow-x-auto overflow-y-scroll" />'), errors: [{ messageId: 'scrollClass' }, { messageId: 'scrollClass' }, { messageId: 'scrollClass' }, { messageId: 'scrollClass' }] },
169
+ { name: 'behind a word-shaped variant', code: component(' <div className="md:overflow-auto" />'), errors: [{ messageId: 'scrollClass', data: { token: 'md:overflow-auto' } }] },
170
+ { name: 'behind an arbitrary variant (Ship, prose.ts)', code: "const PROSE_CLASSES = [\n '[&_pre]:overflow-x-auto [&_pre]:rounded-md',\n]", errors: [{ messageId: 'scrollClass', data: { token: '[&_pre]:overflow-x-auto' }, line: 2 }] },
171
+ { name: 'marked important', code: "const box = cn('!overflow-y-auto')", errors: [{ messageId: 'scrollClass' }] },
172
+ { name: 'inside a template', code: 'const box = `flex ${open ? "a" : "b"} overflow-y-auto`', errors: [{ messageId: 'scrollClass' }] },
173
+ { name: 'in a style', code: component(" <div style={{ overflowY: 'auto' }} />"), errors: [{ message: "`overflowY: 'auto'` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours." }] },
174
+
175
+ // escapes
176
+ {
177
+ name: 'an escape with no reason hides nothing',
178
+ code: effect(" // @estiva-escape:\n document.addEventListener('mousedown', close)"),
179
+ errors: [{ messageId: 'escapeWithoutReason', line: 2 }, { messageId: 'press', line: 3 }],
180
+ },
181
+ {
182
+ name: 'an escape above one statement does not reach the next',
183
+ code: effect(" // @estiva-escape: an inline panel, not a floating one, closes on a press outside\n document.addEventListener('mousedown', close)\n document.addEventListener('keydown', onKey)"),
184
+ errors: [{ messageId: 'key', line: 4 }],
185
+ },
186
+ {
187
+ name: 'with reportEscapes on, one escape over two findings is counted once',
188
+ code: component(' // @estiva-escape: a surface that holds headings, which a button cannot\n <div role="button" tabIndex={0} />'),
189
+ settings: { estiva: { reportEscapes: true } },
190
+ errors: [{ messageId: 'escaped', line: 3 }],
191
+ },
192
+ ],
193
+ })
194
+
195
+ describe('the behaviour table is derived from the package source', () => {
196
+ const src = new URL('../', import.meta.url)
197
+ const index = readFileSync(new URL('index.ts', src), 'utf8')
198
+
199
+ /** Every name the package root exports, and the file it comes from. */
200
+ const exportedFrom = new Map<string, string>()
201
+ for (const statement of ts.createSourceFile('index.ts', index, ts.ScriptTarget.Latest, true).statements) {
202
+ if (!ts.isExportDeclaration(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue
203
+ if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue
204
+ for (const element of statement.exportClause.elements) exportedFrom.set(element.name.text, statement.moduleSpecifier.text.replace(/^\.\//, ''))
205
+ }
206
+
207
+ /** Which Base UI modules each component file imports, read with the TypeScript parser. */
208
+ const importsOf = new Map<string, Set<string>>()
209
+ for (const file of readdirSync(src).filter((f) => /\.tsx?$/.test(f) && !/\.(test|stories)\.tsx?$/.test(f))) {
210
+ const sourceFile = ts.createSourceFile(file, readFileSync(new URL(file, src), 'utf8'), ts.ScriptTarget.Latest, true)
211
+ const modules = new Set<string>()
212
+ for (const statement of sourceFile.statements) {
213
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
214
+ const module = baseUiModule(statement.moduleSpecifier.text)
215
+ if (module !== undefined) modules.add(module)
216
+ }
217
+ importsOf.set(file.replace(/\.tsx?$/, ''), modules)
218
+ }
219
+ const importedAnywhere = new Set([...importsOf.values()].flatMap((s) => [...s]))
220
+
221
+ /** Base UI's helpers, which no component is built on: an import of one says to ask. */
222
+ const UTILITIES = new Set(['', 'types', 'use-render', 'merge-props', 'csp-provider', 'direction-provider', 'unstable-use-media-query'])
223
+
224
+ it('names every Base UI part the package imports', () => {
225
+ const unnamed = [...importedAnywhere].filter((m) => !UTILITIES.has(m) && !BASE_UI_PARTS[m])
226
+ expect(unnamed).toEqual([])
227
+ expect(importedAnywhere.size).toBeGreaterThan(20)
228
+ })
229
+
230
+ it('names, for each part, a component whose own file imports it', () => {
231
+ for (const [module, part] of Object.entries(BASE_UI_PARTS)) {
232
+ if (!part) continue
233
+ const file = exportedFrom.get(part.use)
234
+ expect(file, `${part.use} is exported`).toBeDefined()
235
+ expect([...(importsOf.get(file as string) ?? [])], `${part.use} (${file}) imports @base-ui/react/${module}`).toContain(module)
236
+ }
237
+ })
238
+
239
+ it('says "no part yet" only for parts no component imports', () => {
240
+ const named = Object.entries(BASE_UI_PARTS).filter(([, part]) => part === null).map(([module]) => module)
241
+ expect(named.filter((m) => importedAnywhere.has(m))).toEqual([])
242
+ })
243
+
244
+ it("covers every module Base UI publishes", () => {
245
+ const base = createRequire(import.meta.url)('@base-ui/react/package.json') as { exports: Record<string, unknown> }
246
+ const published = Object.keys(base.exports)
247
+ .filter((key) => key.startsWith('./') && !key.startsWith('./internals/') && !key.endsWith('.json'))
248
+ .map((key) => key.slice(2))
249
+ expect(published.filter((m) => !UTILITIES.has(m) && !Object.hasOwn(BASE_UI_PARTS, m))).toEqual([])
250
+ })
251
+
252
+ it('names only components the package exports, everywhere it names one', () => {
253
+ const messages = Object.values(noRebuiltBehaviour.meta?.messages ?? {}).join(' ')
254
+ const tables = JSON.stringify([BASE_UI_PARTS, ROLE_PARTS, OWNED_BEHAVIOURS.map((b) => b.owners)])
255
+ const named = new Set([...`${messages} ${tables}`.matchAll(/`([A-Z]\w+)`|"use":"(\w+)"|"([A-Z]\w+)"/g)].map((m) => m[1] ?? m[2] ?? m[3]))
256
+ expect([...named].filter((name) => !exportedFrom.has(name))).toEqual([])
257
+ })
258
+
259
+ it('has one row per behaviour, each naming an owner and what it reads', () => {
260
+ expect(OWNED_BEHAVIOURS.map((b) => b.id)).toEqual(['base-ui', 'portal', 'press-outside', 'page-keys', 'focus', 'scroll-lock', 'follow', 'walking', 'role', 'tab-stop', 'scroll'])
261
+ for (const row of OWNED_BEHAVIOURS) {
262
+ expect(row.owners.length, row.id).toBeGreaterThan(0)
263
+ expect(row.baseUi.length, row.id).toBeGreaterThan(0)
264
+ expect(row.reads, row.id).not.toBe('')
265
+ }
266
+ })
267
+ })
268
+
269
+ describe('reading a class past its variants', () => {
270
+ it('keeps brackets whole', () => {
271
+ expect(utilityOf('[&_pre]:overflow-x-auto')).toBe('overflow-x-auto')
272
+ expect(utilityOf('md:hover:overflow-auto')).toBe('overflow-auto')
273
+ expect(utilityOf('[&:not(pre)>code]:overflow-y-scroll')).toBe('overflow-y-scroll')
274
+ expect(utilityOf('!overflow-auto')).toBe('overflow-auto')
275
+ })
276
+ })