@estiva-app/ui 0.16.1 → 0.18.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 (60) hide show
  1. package/README.md +3 -1
  2. package/dist/Button.d.ts.map +1 -1
  3. package/dist/Checkbox.d.ts +14 -1
  4. package/dist/Checkbox.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts.map +1 -1
  6. package/dist/FilePicker.d.ts +22 -0
  7. package/dist/FilePicker.d.ts.map +1 -0
  8. package/dist/Form.d.ts +49 -0
  9. package/dist/Form.d.ts.map +1 -0
  10. package/dist/IconButton.d.ts.map +1 -1
  11. package/dist/SearchInput.d.ts.map +1 -1
  12. package/dist/TextInput.d.ts.map +1 -1
  13. package/dist/Textarea.d.ts.map +1 -1
  14. package/dist/eslint/index.d.ts +1 -1
  15. package/dist/eslint/index.d.ts.map +1 -1
  16. package/dist/eslint/index.js +88 -8
  17. package/dist/eslint/index.js.map +4 -4
  18. package/dist/eslint/no-raw-element.d.ts +88 -0
  19. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  20. package/dist/formBusy.d.ts +15 -0
  21. package/dist/formBusy.d.ts.map +1 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +479 -350
  25. package/dist/index.js.map +4 -4
  26. package/package.json +1 -1
  27. package/src/Button.tsx +4 -1
  28. package/src/Checkbox.mdx +19 -1
  29. package/src/Checkbox.stories.tsx +12 -0
  30. package/src/Checkbox.test.tsx +54 -0
  31. package/src/Checkbox.tsx +40 -3
  32. package/src/CommandPalette.mdx +6 -2
  33. package/src/CommandPalette.stories.tsx +6 -3
  34. package/src/CommandPalette.test.tsx +15 -0
  35. package/src/CommandPalette.tsx +34 -46
  36. package/src/FilePicker.mdx +49 -0
  37. package/src/FilePicker.stories.tsx +61 -0
  38. package/src/FilePicker.test.tsx +79 -0
  39. package/src/FilePicker.tsx +40 -0
  40. package/src/Form.mdx +78 -0
  41. package/src/Form.stories.tsx +68 -0
  42. package/src/Form.test.tsx +385 -0
  43. package/src/Form.tsx +173 -0
  44. package/src/IconButton.tsx +4 -1
  45. package/src/SearchInput.mdx +2 -2
  46. package/src/SearchInput.tsx +3 -1
  47. package/src/TextInput.mdx +2 -2
  48. package/src/TextInput.test.tsx +7 -0
  49. package/src/TextInput.tsx +4 -1
  50. package/src/Textarea.tsx +4 -1
  51. package/src/eslint/index.test.ts +24 -15
  52. package/src/eslint/index.ts +6 -5
  53. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  54. package/src/eslint/no-raw-element.ts +180 -0
  55. package/src/formBusy.ts +19 -0
  56. package/src/index.ts +2 -0
  57. package/stories/Choosing.mdx +3 -0
  58. package/dist/eslint/no-raw-button.d.ts +0 -11
  59. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  60. package/src/eslint/no-raw-button.ts +0 -39
package/src/Form.tsx ADDED
@@ -0,0 +1,173 @@
1
+ import { useLayoutEffect, useRef, type FormHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { Fieldset } from '@base-ui/react/fieldset'
3
+ import { Form as BaseForm } from '@base-ui/react/form'
4
+ import { cn } from './cn'
5
+ import { FormBusyContext, useFormBusy } from './formBusy'
6
+
7
+ /**
8
+ * A form: Enter in a field, or a submit button, sends it, and the page never
9
+ * reloads. On Base UI's `Form` (UIG-7, 16 September), which the apps' five
10
+ * forms had each written around a plain `<form>`: `preventDefault`, a busy
11
+ * flag, and `disabled={busy}` on every field one by one.
12
+ *
13
+ * Base UI's part, as it renders it: a `<form noValidate>` — the browser's own
14
+ * validation bubbles are off, which no field in either app used — that, on
15
+ * submit, checks every `Field` inside and moves focus to the first one that is
16
+ * invalid instead of sending. `Field`'s `error` is still how an error shows.
17
+ *
18
+ * `busy` is ours. While it is on, the children sit in a disabled
19
+ * `<fieldset>` (Base UI's `Fieldset`, with no box of its own), so every field
20
+ * and button inside is switched off at once — the package's Button,
21
+ * IconButton and Checkbox wearing their own switched-off look, which a
22
+ * fieldset alone does not give them (`formBusy.ts`) — and focus waits on the form
23
+ * rather than falling to the page. Chrome drops focus to `<body>` the moment
24
+ * the focused field is disabled, before any effect runs, and jsdom does not —
25
+ * so the form remembers the last element inside it that had focus, and takes
26
+ * focus back from the page on its behalf (measured in Chrome, 16 September).
27
+ * When `busy` ends, focus goes to the first invalid field, else to what sent
28
+ * the form, else to the first control. That is `CommandPalette`'s order
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.
40
+ */
41
+ export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children' | 'noValidate'> {
42
+ /** Enter in a field, or a submit button. The page's own submit is already prevented. */
43
+ onSubmit: () => void | Promise<void>
44
+ /** While sending: every field and button inside is switched off, and focus waits on the form. */
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
51
+ children: ReactNode
52
+ }
53
+
54
+ const CONTROL = 'input:not([type="hidden"]), textarea, select, button, [role="checkbox"], [role="combobox"], [tabindex]:not([tabindex="-1"])'
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
+
59
+ function usable(element: Element | null): element is HTMLElement {
60
+ return element instanceof HTMLElement && element.isConnected && element.matches(CONTROL) && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true'
61
+ }
62
+
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) {
74
+ // A form inside a busy form is busy too.
75
+ const outerBusy = useFormBusy()
76
+ const busy = ownBusy || outerBusy
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
81
+ // What had focus when the form went busy, and whether the form took focus from it.
82
+ const sender = useRef<Element | null>(null)
83
+ const holding = useRef(false)
84
+ // The last element inside the form that had focus, until focus moves to something outside it.
85
+ const lastInside = useRef<Element | null>(null)
86
+
87
+ useLayoutEffect(() => {
88
+ const element = form.current
89
+ if (!element) return
90
+ if (busy) {
91
+ const active = document.activeElement
92
+ // Focus is still inside (jsdom, or a field that stayed enabled), or the
93
+ // browser has already dropped it to the page from a field inside (Chrome).
94
+ const from =
95
+ active && active !== element && element.contains(active)
96
+ ? active
97
+ : !active || active === document.body
98
+ ? lastInside.current
99
+ : null
100
+ if (from?.isConnected) {
101
+ sender.current = from
102
+ holding.current = true
103
+ element.focus()
104
+ }
105
+ return
106
+ }
107
+ if (!holding.current) return
108
+ holding.current = false
109
+ // Someone who moved focus on while waiting keeps it where they put it.
110
+ if (document.activeElement !== element && document.activeElement !== document.body) return
111
+ const target = firstInvalid(element) ?? (usable(sender.current) ? sender.current : Array.from(element.querySelectorAll(CONTROL)).find(usable))
112
+ sender.current = null
113
+ target?.focus()
114
+ }, [busy])
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
+
144
+ return (
145
+ <BaseForm
146
+ ref={form}
147
+ {...props}
148
+ // Focus waits here only while busy; the rest of the time a click between two fields must not land on the form.
149
+ tabIndex={busy ? -1 : props.tabIndex}
150
+ // A form that holds focus for a moment must not draw a focus ring.
151
+ className={cn('outline-none', className)}
152
+ onFocus={(event) => {
153
+ if (event.target !== form.current) lastInside.current = event.target
154
+ props.onFocus?.(event)
155
+ }}
156
+ onBlur={(event) => {
157
+ const next = event.relatedTarget
158
+ if (next && !form.current?.contains(next)) lastInside.current = null
159
+ props.onBlur?.(event)
160
+ }}
161
+ onKeyDown={onKeyDown}
162
+ onSubmit={(event) => {
163
+ event.preventDefault()
164
+ if (busyNow.current) return
165
+ void onSubmit()
166
+ }}
167
+ >
168
+ <Fieldset.Root disabled={busy} className="contents">
169
+ <FormBusyContext.Provider value={busy}>{children}</FormBusyContext.Provider>
170
+ </Fieldset.Root>
171
+ </BaseForm>
172
+ )
173
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ComponentPropsWithRef, ReactNode } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { cn } from './cn'
4
+ import { useFormBusy } from './formBusy'
4
5
  import { TooltipTrigger } from './Tooltip'
5
6
 
6
7
  /**
@@ -42,10 +43,12 @@ export function IconButton({
42
43
  type = 'button',
43
44
  ...props
44
45
  }: IconButtonProps) {
46
+ // Inside a busy Form: switched off, and looking it (formBusy.ts).
47
+ const formBusy = useFormBusy()
45
48
  const button = (
46
49
  <BaseButton
47
50
  type={type}
48
- disabled={disabled || !!disabledReason}
51
+ disabled={disabled || !!disabledReason || formBusy}
49
52
  focusableWhenDisabled={!!disabledReason}
50
53
  className={(state) =>
51
54
  cn(
@@ -5,8 +5,8 @@ import * as SearchInputStories from './SearchInput.stories'
5
5
 
6
6
  # SearchInput
7
7
 
8
- An inset field with a hairline border that strengthens on focus, and an
9
- optional keyboard hint at the right edge.
8
+ An inset field with a hairline border that strengthens on hover and on
9
+ focus, and an optional keyboard hint at the right edge.
10
10
 
11
11
  <Canvas of={SearchInputStories.WithShortcut} />
12
12
 
@@ -33,7 +33,9 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
33
33
  className={cn(
34
34
  'flex gap-2 items-center px-3 py-2 rounded-lg',
35
35
  'bg-bg-inset border border-border-default',
36
- 'focus-within:border-border-strong transition-colors',
36
+ // Hover strengthens the border as it does on every other field (16 September). Here that
37
+ // is also the focus look, which was the stronger border before hover existed.
38
+ 'hover:border-border-strong focus-within:border-border-strong transition-colors',
37
39
  className,
38
40
  )}
39
41
  >
package/src/TextInput.mdx CHANGED
@@ -5,8 +5,8 @@ import * as TextInputStories from './TextInput.stories'
5
5
 
6
6
  # TextInput
7
7
 
8
- The single-line field: inset surface, 8px radius, 14px text, the focus
9
- border in every theme.
8
+ The single-line field: inset surface, 8px radius, 14px text, a stronger
9
+ border on hover, the focus border in every theme.
10
10
 
11
11
  <Canvas of={TextInputStories.Filled} />
12
12
 
@@ -35,4 +35,11 @@ describe('TextInput', () => {
35
35
  for (const c of ['px-3', 'py-2', 'text-input-value']) expect(classes).not.toContain(c)
36
36
  expect(input.hasAttribute('size')).toBe(false)
37
37
  })
38
+
39
+ it("a stronger border on hover, as Select's, and the focus border still wins while focused", () => {
40
+ render(<TextInput aria-label="Title" />)
41
+ const classes = screen.getByRole('textbox').className.split(' ')
42
+ // Both survive cn(); Tailwind emits focus after hover, so the order in CSS decides, not here.
43
+ for (const c of ['border-border-default', 'hover:border-border-strong', 'focus:border-border-focus']) expect(classes).toContain(c)
44
+ })
38
45
  })
package/src/TextInput.tsx CHANGED
@@ -32,7 +32,10 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
32
32
  ref={ref}
33
33
  type={type}
34
34
  className={cn(
35
- 'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg',
35
+ // The border strengthens on hover, as Select's and ChipInput's do (Katerina, 16 September:
36
+ // "aren't there hover states in text input and text area?"). Focus comes after hover in
37
+ // Tailwind's order, so a focused field keeps the focus border under the pointer.
38
+ 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg',
36
39
  // The small size is the small Select's trigger, class for class.
37
40
  size === 'default' && 'px-3 py-2 text-input-value',
38
41
  size === 'small' && 'h-6 px-2 text-caption',
package/src/Textarea.tsx CHANGED
@@ -21,7 +21,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
21
21
  ref={ref as React.Ref<HTMLElement>}
22
22
  render={<textarea />}
23
23
  className={cn(
24
- 'bg-bg-inset border border-border-default focus:border-border-focus rounded-lg px-3 py-2',
24
+ // The border strengthens on hover, as Select's and ChipInput's do (Katerina, 16 September:
25
+ // "aren't there hover states in text input and text area?"). Focus comes after hover in
26
+ // Tailwind's order, so a focused field keeps the focus border under the pointer.
27
+ 'bg-bg-inset border border-border-default hover:border-border-strong focus:border-border-focus rounded-lg px-3 py-2',
25
28
  'text-input-value text-text-primary placeholder:text-text-muted',
26
29
  'resize-none outline-none transition-colors',
27
30
  'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled',
@@ -7,7 +7,7 @@ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from '
7
7
  /**
8
8
  * The plugin as an app uses it: a flat config with `configs.recommended`,
9
9
  * linting text the way Peek's editor hook does (`lintText`). The rule's own
10
- * cases are in no-raw-button.test.ts.
10
+ * cases are in no-raw-element.test.ts.
11
11
  */
12
12
  const tsx: Linter.Config = {
13
13
  files: ['**/*.tsx'],
@@ -34,7 +34,7 @@ describe('the plugin object', () => {
34
34
 
35
35
  it('carries every rule, the app ones and the inward ones', () => {
36
36
  expect(Object.keys(estiva.rules)).toEqual([
37
- 'no-raw-button',
37
+ 'no-raw-element',
38
38
  'raw-element-outside-a-wrapper',
39
39
  'no-hand-rolled-behaviour',
40
40
  'component-has-a-page',
@@ -44,17 +44,19 @@ describe('the plugin object', () => {
44
44
 
45
45
  /**
46
46
  * The apps spread `recommended`. A rule added for the package (UIG-5) must not
47
- * arrive in Peek or Ship with the next version bump: an app is full of raw
48
- * elements it may keep until UIG-7, and has no `.mdx` pages at all. This is
47
+ * arrive in Peek or Ship with the next version bump: an app's components are
48
+ * not primitives, so "buried inside a component" means nothing there
49
+ * (`no-raw-element` is the app's version, UIG-7), and an app has no `.mdx`
50
+ * pages at all. This is
49
51
  * the test that holds that line — if you add an app-facing rule on purpose,
50
52
  * change it deliberately, here.
51
53
  */
52
54
  it('gives an app only the app rules, as errors, under estiva/', () => {
53
55
  for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
54
56
  expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
55
- expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
57
+ expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
56
58
  }
57
- expect(APP_RULE_IDS).toEqual(['estiva/no-raw-button'])
59
+ expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element'])
58
60
  })
59
61
 
60
62
  it('gives this package its own set, as errors, and it reaches no app config', () => {
@@ -77,7 +79,14 @@ describe('an app lint with configs.recommended', () => {
77
79
  it('reports a raw <button>, naming Button', async () => {
78
80
  const [result] = await lint(component(' <button type="button">x</button>'))
79
81
  expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
80
- ['estiva/no-raw-button', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
82
+ ['estiva/no-raw-element', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
83
+ ])
84
+ })
85
+
86
+ it('reports a raw <a>, naming Link', async () => {
87
+ const [result] = await lint(component(' <a href="/x">x</a>'))
88
+ expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
89
+ ['estiva/no-raw-element', 2, 'Use `Link` from @estiva-app/ui instead of a raw <a>. For a chip, `InlineChip`; for a whole card, `Card` with `href`.'],
81
90
  ])
82
91
  })
83
92
 
@@ -90,25 +99,25 @@ describe('an app lint with configs.recommended', () => {
90
99
  describe('countGates', () => {
91
100
  it('counts an error, and an escape only when the lint reports escapes', async () => {
92
101
  const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
93
- expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 0 } })
94
- expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 1 } })
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 } })
95
104
  })
96
105
 
97
106
  it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
98
- const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button\n <button>x</button>'), [countMode])
107
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element\n <button>x</button>'), [countMode])
99
108
  const count = countGates(results)
100
- expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
101
- expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-button' }])
109
+ expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
110
+ expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-element' }])
102
111
  })
103
112
 
104
113
  it('counts a marker inside that directive as an error of the rule', async () => {
105
- const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
114
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
106
115
  const count = countGates(results)
107
- expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
116
+ expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
108
117
  expect(count.disabled).toHaveLength(1)
109
118
  })
110
119
 
111
120
  it('lists every rule of the plugin, even with nothing found', () => {
112
- expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-button': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
121
+ expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
113
122
  })
114
123
  })
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module'
20
20
  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
- import { noRawButton } from './no-raw-button'
23
+ import { noRawElement } from './no-raw-element'
24
24
  import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
25
25
 
26
26
  export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
@@ -35,7 +35,7 @@ export const PLUGIN_KEY = 'estiva'
35
35
  * already has. `recommended` and `strict` carry these and only these.
36
36
  */
37
37
  const appRules = {
38
- 'no-raw-button': noRawButton,
38
+ 'no-raw-element': noRawElement,
39
39
  }
40
40
 
41
41
  /**
@@ -45,8 +45,9 @@ const appRules = {
45
45
  *
46
46
  * They are in `configs.package`, never in `recommended`, on purpose. Peek and
47
47
  * Ship spread `recommended`, so a rule added here must not arrive in an app
48
- * with the next version bump: an app is full of raw elements it is allowed to
49
- * have until UIG-7, and has no `.mdx` pages at all. `index.test.ts` holds the
48
+ * with the next version bump: an app's components are not primitives, so
49
+ * "buried inside a component" means nothing there (`no-raw-element` is the
50
+ * app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
50
51
  * apps' list to exactly the app rules.
51
52
  */
52
53
  const packageRules = {
@@ -80,7 +81,7 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
80
81
  plugin.configs.recommended = {
81
82
  name: '@estiva-app/ui/recommended',
82
83
  plugins: { [PLUGIN_KEY]: plugin },
83
- rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },
84
+ rules: { [`${PLUGIN_KEY}/no-raw-element`]: 'error' },
84
85
  }
85
86
  plugin.configs.strict = {
86
87
  name: '@estiva-app/ui/strict',
@@ -1,7 +1,7 @@
1
1
  import { RuleTester } from 'eslint'
2
2
  import { parser } from 'typescript-eslint'
3
3
  import { describe, it } from 'vitest'
4
- import { noRawButton } from './no-raw-button'
4
+ import { noRawElement, RAW_ELEMENT_PARTS, RAW_INPUT_PARTS } from './no-raw-element'
5
5
 
6
6
  RuleTester.describe = describe
7
7
  RuleTester.it = it
@@ -14,12 +14,27 @@ const tester = new RuleTester({
14
14
  })
15
15
 
16
16
  const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
17
+ const noPart = (element: string) => `@estiva-app/ui has no part for a raw ${element} yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.`
17
18
 
18
- tester.run('no-raw-button', noRawButton, {
19
+ /** Every element the mapping names, drawn the simplest way that makes it a control. */
20
+ const withControls: Record<string, string> = { audio: '<audio controls />', video: '<video controls />', img: '<img usemap="#m" alt="" />' }
21
+ const mapped = Object.entries(RAW_ELEMENT_PARTS).map(([name, part]) => ({ name, part, code: withControls[name] ?? `<${name} />` }))
22
+
23
+ tester.run('no-raw-element', noRawElement, {
19
24
  valid: [
20
25
  { name: "the package's Button", code: component('<Button>Save</Button>') },
21
26
  { name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
22
27
  { name: 'a name that starts with button', code: component('<buttonish />') },
28
+ {
29
+ name: 'the elements that are not controls',
30
+ code: component(' <section>\n <h1>Title</h1>\n <p>Text <strong>and</strong> <em>more</em></p>\n <ul><li>One</li></ul>\n <img src="x.png" alt="" />\n <hr />\n <kbd>Enter</kbd>\n <svg><path d="M0 0" /></svg>\n <time dateTime="2026-09-16">today</time>\n </section>'),
31
+ },
32
+ { name: 'a hidden input is data, not a control', code: component('<input type="hidden" name="id" value="1" />') },
33
+ { name: "a summary is reported through its details, not twice", code: component('<Foo>\n<summary>More</summary>\n</Foo>') },
34
+ { name: 'a fieldset and its legend are a frame', code: component('<fieldset><legend>Group</legend></fieldset>') },
35
+ { name: 'an option and a datalist are parts of another control', code: component('<Foo><option value="a">A</option><datalist id="d" /></Foo>') },
36
+ { name: 'a video with no controls is a picture that moves', code: component('<video src="a.mp4" autoPlay muted loop />') },
37
+ { name: 'controls written as false', code: component('<audio src="a.mp3" controls={false} />') },
23
38
  {
24
39
  name: 'a line comment escape directly above',
25
40
  code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
@@ -40,25 +55,55 @@ tester.run('no-raw-button', noRawButton, {
40
55
  name: 'exactly ten characters of reason, spaces not counted',
41
56
  code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
42
57
  },
58
+ {
59
+ name: 'an element with no part, escaped',
60
+ code: component(' // @estiva-escape: a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
61
+ },
43
62
  ],
44
63
  invalid: [
45
64
  { name: 'a raw button', code: component(' <button type="button">x</button>'), errors: [{ messageId: 'raw', line: 3 }] },
46
- { name: 'a self-closing raw button', code: component(' <button />'), errors: [{ messageId: 'raw' }] },
47
65
  {
48
66
  name: 'an opening tag that spans lines (what grep misses)',
49
- code: component(' <button\n type="button"\n >\n x\n </button>'),
67
+ code: component(' <input\n type="text"\n value={x}\n />'),
50
68
  errors: [{ messageId: 'raw', line: 3 }],
51
69
  },
52
70
  {
53
- name: 'a raw button nested in other elements',
54
- code: component(' <div>\n <span>\n <button>x</button>\n </span>\n </div>'),
71
+ name: 'a raw element nested in other elements',
72
+ code: component(' <div>\n <span>\n <a href="/x">x</a>\n </span>\n </div>'),
55
73
  errors: [{ messageId: 'raw', line: 5 }],
56
74
  },
57
75
  {
58
- name: 'the message names the component',
76
+ name: "the button's message is UIG-3's, word for word",
59
77
  code: component(' <button>x</button>'),
60
78
  errors: [{ message: 'Use `Button` from @estiva-app/ui instead of a raw <button>.' }],
61
79
  },
80
+
81
+ // Every element in the mapping names its part, or says there is none yet.
82
+ ...mapped.map(({ name, part, code }) => ({
83
+ name: part ? `<${name}> names ${part.use}` : `<${name}> has no part yet`,
84
+ code: component(` ${code}`),
85
+ errors: [{ message: part ? `Use \`${part.use}\` from @estiva-app/ui instead of a raw <${name}>.${part.more ?? ''}` : noPart(`<${name}>`) }],
86
+ })),
87
+ ...Object.entries(RAW_INPUT_PARTS).map(([type, part]) => ({
88
+ name: part ? `<input type="${type}"> names ${part.use}` : `<input type="${type}"> has no part yet`,
89
+ code: component(` <input type="${type}" />`),
90
+ errors: [{ message: part ? `Use \`${part.use}\` from @estiva-app/ui instead of a raw <input type="${type}">.` : noPart(`<input type="${type}">`) }],
91
+ })),
92
+
93
+ { name: 'an input with no type is a text box', code: component(' <input />'), errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>.' }] },
94
+ { name: 'an email box is a text box', code: component(' <input type="email" />'), errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>.' }] },
95
+ { name: 'a type the browser does not know is a text box', code: component(' <input type="wibble" />'), errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>.' }] },
96
+ { name: 'a type written in capitals', code: component(' <input type="CHECKBOX" />'), errors: [{ message: 'Use `Checkbox` from @estiva-app/ui instead of a raw <input type="checkbox">.' }] },
97
+ { name: 'a type in braces', code: component(" <input type={'file'} />"), errors: [{ message: 'Use `FilePicker` from @estiva-app/ui instead of a raw <input type="file">.' }] },
98
+ { name: 'a type in a template with nothing computed', code: component(' <input type={`search`} />'), errors: [{ message: 'Use `SearchInput` from @estiva-app/ui instead of a raw <input type="search">.' }] },
99
+ {
100
+ name: 'a computed type names every input part',
101
+ code: component(' <input type={kind} />'),
102
+ errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>. For a search, `SearchInput`; for a tick box, `Checkbox`; to pick files, `FilePicker`.' }],
103
+ },
104
+ { name: 'a video with controls', code: component(' <video src="a.mp4" controls={true} />'), errors: [{ message: noPart('<video>') }] },
105
+ { name: 'an image map written the React way', code: component(' <img useMap="#m" alt="" />'), errors: [{ message: noPart('<img>') }] },
106
+
62
107
  {
63
108
  name: 'an escape with no reason is an error, and hides nothing',
64
109
  code: component(' // @estiva-escape:\n <button>x</button>'),
@@ -100,7 +145,7 @@ tester.run('no-raw-button', noRawButton, {
100
145
  },
101
146
  {
102
147
  name: 'a marker inside a directive that names this rule is refused',
103
- code: component(' // eslint-disable-next-line rule-to-test/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
148
+ code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
104
149
  errors: [{ messageId: 'escapeInDirective', line: 3 }],
105
150
  },
106
151
  {