@estiva-app/ui 0.16.1 → 0.17.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 (55) 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/FilePicker.d.ts +22 -0
  6. package/dist/FilePicker.d.ts.map +1 -0
  7. package/dist/Form.d.ts +34 -0
  8. package/dist/Form.d.ts.map +1 -0
  9. package/dist/IconButton.d.ts.map +1 -1
  10. package/dist/SearchInput.d.ts.map +1 -1
  11. package/dist/TextInput.d.ts.map +1 -1
  12. package/dist/Textarea.d.ts.map +1 -1
  13. package/dist/eslint/index.d.ts +1 -1
  14. package/dist/eslint/index.d.ts.map +1 -1
  15. package/dist/eslint/index.js +88 -8
  16. package/dist/eslint/index.js.map +4 -4
  17. package/dist/eslint/no-raw-element.d.ts +88 -0
  18. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  19. package/dist/formBusy.d.ts +15 -0
  20. package/dist/formBusy.d.ts.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +356 -239
  24. package/dist/index.js.map +4 -4
  25. package/package.json +1 -1
  26. package/src/Button.tsx +4 -1
  27. package/src/Checkbox.mdx +19 -1
  28. package/src/Checkbox.stories.tsx +12 -0
  29. package/src/Checkbox.test.tsx +54 -0
  30. package/src/Checkbox.tsx +40 -3
  31. package/src/FilePicker.mdx +49 -0
  32. package/src/FilePicker.stories.tsx +61 -0
  33. package/src/FilePicker.test.tsx +79 -0
  34. package/src/FilePicker.tsx +40 -0
  35. package/src/Form.mdx +68 -0
  36. package/src/Form.stories.tsx +68 -0
  37. package/src/Form.test.tsx +282 -0
  38. package/src/Form.tsx +113 -0
  39. package/src/IconButton.tsx +4 -1
  40. package/src/SearchInput.mdx +2 -2
  41. package/src/SearchInput.tsx +3 -1
  42. package/src/TextInput.mdx +2 -2
  43. package/src/TextInput.test.tsx +7 -0
  44. package/src/TextInput.tsx +4 -1
  45. package/src/Textarea.tsx +4 -1
  46. package/src/eslint/index.test.ts +24 -15
  47. package/src/eslint/index.ts +6 -5
  48. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  49. package/src/eslint/no-raw-element.ts +180 -0
  50. package/src/formBusy.ts +19 -0
  51. package/src/index.ts +2 -0
  52. package/stories/Choosing.mdx +3 -0
  53. package/dist/eslint/no-raw-button.d.ts +0 -11
  54. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  55. package/src/eslint/no-raw-button.ts +0 -39
@@ -0,0 +1,282 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Form page claims, pinned: Enter and a submit button send it, the
4
+ * page's own submit is prevented, `busy` switches off everything inside and
5
+ * holds focus, and focus comes back in the page's order.
6
+ *
7
+ * jsdom does not move focus off a field that becomes disabled; Chrome drops it
8
+ * to `<body>`. So "focus is held" is checked as "focus is on the form, and not
9
+ * on a disabled element" — without the form taking focus, that fails here too
10
+ * (UIG-29 measured it). The browser check is in the PR.
11
+ */
12
+ import { useState } from 'react'
13
+ import { afterEach, describe, expect, it, vi } from 'vitest'
14
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
15
+ import userEvent from '@testing-library/user-event'
16
+ import { Button } from './Button'
17
+ import { Checkbox } from './Checkbox'
18
+ import { IconButton } from './IconButton'
19
+ import { Field } from './Field'
20
+ import { Form } from './Form'
21
+ import { TextInput } from './TextInput'
22
+ import { Textarea } from './Textarea'
23
+
24
+ afterEach(cleanup)
25
+
26
+ function Harness({ onSubmit = vi.fn(), error, busy = false }: { onSubmit?: () => void; error?: string; busy?: boolean }) {
27
+ return (
28
+ <Form onSubmit={onSubmit} busy={busy} aria-label="Probe" className="flex flex-col gap-6" id="probe">
29
+ <Field label="First">
30
+ <TextInput />
31
+ </Field>
32
+ <Field label="Second" error={error}>
33
+ <TextInput />
34
+ </Field>
35
+ <Button type="submit">Send</Button>
36
+ </Form>
37
+ )
38
+ }
39
+
40
+ describe('Form', () => {
41
+ it('sends on Enter in a field', async () => {
42
+ const user = userEvent.setup()
43
+ const onSubmit = vi.fn()
44
+ render(<Harness onSubmit={onSubmit} />)
45
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
46
+ expect(onSubmit).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ it('sends from its submit button', async () => {
50
+ const user = userEvent.setup()
51
+ const onSubmit = vi.fn()
52
+ render(<Harness onSubmit={onSubmit} />)
53
+ await user.click(screen.getByRole('button', { name: 'Send' }))
54
+ expect(onSubmit).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('does not send while a field shows its error', async () => {
58
+ const user = userEvent.setup()
59
+ const onSubmit = vi.fn()
60
+ render(<Harness onSubmit={onSubmit} error="Not like that." />)
61
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
62
+ expect(onSubmit).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('sends on Enter in a one-line field, and not in a textarea', async () => {
66
+ const user = userEvent.setup()
67
+ const onSubmit = vi.fn()
68
+ render(
69
+ <Form onSubmit={onSubmit} aria-label="Probe">
70
+ <Field label="Words">
71
+ <Textarea />
72
+ </Field>
73
+ </Form>,
74
+ )
75
+ await user.type(screen.getByRole('textbox', { name: 'Words' }), 'one{Enter}two')
76
+ expect(onSubmit).not.toHaveBeenCalled()
77
+ expect((screen.getByRole('textbox', { name: 'Words' }) as HTMLTextAreaElement).value).toBe('one\ntwo')
78
+ })
79
+
80
+ it("prevents the page's own submit, so nothing reloads", () => {
81
+ render(<Harness />)
82
+ const form = screen.getByRole('form', { name: 'Probe' })
83
+ // dispatchEvent answers false when the event's default was prevented.
84
+ expect(fireEvent.submit(form)).toBe(false)
85
+ })
86
+
87
+ it('is a form with no browser validation bubbles, and passes id and className on', () => {
88
+ render(<Harness />)
89
+ const form = screen.getByRole('form', { name: 'Probe' }) as HTMLFormElement
90
+ expect(form.tagName).toBe('FORM')
91
+ expect(form.noValidate).toBe(true)
92
+ expect(form.id).toBe('probe')
93
+ expect(form.className).toContain('flex-col')
94
+ })
95
+
96
+ it('draws no box of its own around the fields', () => {
97
+ const { container } = render(<Harness />)
98
+ expect(container.querySelector('fieldset')?.className).toBe('contents')
99
+ })
100
+
101
+ it('switches off every field and button inside while busy, and back on after', () => {
102
+ const { rerender } = render(<Harness />)
103
+ const controls = () => [...screen.getAllByRole('textbox'), screen.getByRole('button', { name: 'Send' })]
104
+ expect(controls().every((c) => !c.matches(':disabled'))).toBe(true)
105
+ rerender(<Harness busy />)
106
+ expect(controls().every((c) => c.matches(':disabled'))).toBe(true)
107
+ rerender(<Harness />)
108
+ expect(controls().every((c) => !c.matches(':disabled'))).toBe(true)
109
+ })
110
+
111
+ it("switches the package's own parts off in their own look, not only the browser's way", async () => {
112
+ // A disabled part has pointer-events: none; the click is tried anyway, as a person would.
113
+ const user = userEvent.setup({ pointerEventsCheck: 0 })
114
+ const onTick = vi.fn()
115
+ const parts = (busy: boolean) => (
116
+ <Form onSubmit={() => {}} busy={busy} aria-label="Probe">
117
+ <Button type="submit">Send</Button>
118
+ <IconButton aria-label="Icon">x</IconButton>
119
+ <Checkbox checked={false} onChange={onTick} aria-label="Bare" />
120
+ <Checkbox checked={false} onChange={onTick} label="Words" />
121
+ </Form>
122
+ )
123
+ const { rerender, getByRole, getByText } = render(parts(false))
124
+ const drawnOff = () => ['Send', 'Icon'].map((name) => getByRole('button', { name }).hasAttribute('data-disabled'))
125
+ expect(drawnOff()).toEqual([false, false])
126
+ rerender(parts(true))
127
+ // Button and IconButton take their switched-off classes from Base UI's state, which data-disabled shows.
128
+ expect(drawnOff()).toEqual([true, true])
129
+ expect(getByRole('checkbox', { name: 'Bare' }).getAttribute('aria-disabled')).toBe('true')
130
+ // A checkbox is a span: the fieldset alone never stopped a click on it.
131
+ await user.click(getByRole('checkbox', { name: 'Bare' }))
132
+ await user.click(getByText('Words'))
133
+ expect(onTick).not.toHaveBeenCalled()
134
+ rerender(parts(false))
135
+ expect(drawnOff()).toEqual([false, false])
136
+ })
137
+
138
+ it('holds focus on the form while busy, never on a disabled field', () => {
139
+ const { rerender } = render(<Harness />)
140
+ const field = screen.getByRole('textbox', { name: 'Second' })
141
+ act(() => field.focus())
142
+ rerender(<Harness busy />)
143
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
144
+ expect(document.activeElement?.matches(':disabled')).toBe(false)
145
+ })
146
+
147
+ it('holds focus even when the browser already dropped it to the page, as Chrome does', () => {
148
+ const { rerender } = render(<Harness />)
149
+ const field = screen.getByRole('textbox', { name: 'Second' })
150
+ act(() => field.focus())
151
+ // Chrome blurs a field the moment it is disabled, before any effect runs;
152
+ // jsdom never does, so the test does it by hand.
153
+ act(() => field.blur())
154
+ rerender(<Harness busy />)
155
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
156
+ rerender(<Harness />)
157
+ expect(document.activeElement).toBe(field)
158
+ })
159
+
160
+ it('does not take focus when it was last somewhere outside the form', () => {
161
+ const { rerender } = render(
162
+ <>
163
+ <Harness />
164
+ <button type="button">Elsewhere</button>
165
+ </>,
166
+ )
167
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
168
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).focus())
169
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).blur())
170
+ rerender(
171
+ <>
172
+ <Harness busy />
173
+ <button type="button">Elsewhere</button>
174
+ </>,
175
+ )
176
+ expect(document.activeElement).toBe(document.body)
177
+ })
178
+
179
+ it('takes focus only while busy: the rest of the time it is not a Tab stop or a click target', () => {
180
+ const { rerender } = render(<Harness />)
181
+ const form = screen.getByRole('form', { name: 'Probe' })
182
+ expect(form.hasAttribute('tabindex')).toBe(false)
183
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
184
+ rerender(<Harness busy />)
185
+ expect(form.getAttribute('tabindex')).toBe('-1')
186
+ rerender(<Harness />)
187
+ expect(form.hasAttribute('tabindex')).toBe(false)
188
+ })
189
+
190
+ describe('when busy ends, focus goes', () => {
191
+ it('to the first invalid field', () => {
192
+ const { rerender } = render(<Harness />)
193
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
194
+ rerender(<Harness busy />)
195
+ rerender(<Harness error="Not like that." />)
196
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Second' }))
197
+ })
198
+
199
+ it('else back to what sent the form', () => {
200
+ const { rerender } = render(<Harness />)
201
+ act(() => screen.getByRole('button', { name: 'Send' }).focus())
202
+ rerender(<Harness busy />)
203
+ rerender(<Harness />)
204
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Send' }))
205
+ })
206
+
207
+ it('else to the first control, when what sent it is gone', () => {
208
+ function Vanishing({ busy, sent }: { busy: boolean; sent: boolean }) {
209
+ return (
210
+ <Form onSubmit={() => {}} busy={busy} aria-label="Probe">
211
+ <Field label="First">
212
+ <TextInput />
213
+ </Field>
214
+ {!sent && <Button type="submit">Send</Button>}
215
+ </Form>
216
+ )
217
+ }
218
+ const { rerender } = render(<Vanishing busy={false} sent={false} />)
219
+ act(() => screen.getByRole('button', { name: 'Send' }).focus())
220
+ rerender(<Vanishing busy sent={false} />)
221
+ // Gone while the form waits, as a row that sent itself can be.
222
+ rerender(<Vanishing busy sent />)
223
+ rerender(<Vanishing busy={false} sent />)
224
+ expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'First' }))
225
+ })
226
+
227
+ it('nowhere, when someone moved focus on while waiting', () => {
228
+ const { rerender } = render(
229
+ <>
230
+ <Harness />
231
+ <button type="button">Elsewhere</button>
232
+ </>,
233
+ )
234
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
235
+ rerender(
236
+ <>
237
+ <Harness busy />
238
+ <button type="button">Elsewhere</button>
239
+ </>,
240
+ )
241
+ act(() => screen.getByRole('button', { name: 'Elsewhere' }).focus())
242
+ rerender(
243
+ <>
244
+ <Harness />
245
+ <button type="button">Elsewhere</button>
246
+ </>,
247
+ )
248
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Elsewhere' }))
249
+ })
250
+ })
251
+
252
+ it('a caller that waits: busy while sending, then back', async () => {
253
+ const user = userEvent.setup()
254
+ let finish: () => void = () => {}
255
+ function Sending() {
256
+ const [busy, setBusy] = useState(false)
257
+ return (
258
+ <Form
259
+ aria-label="Probe"
260
+ busy={busy}
261
+ onSubmit={async () => {
262
+ setBusy(true)
263
+ await new Promise<void>((resolve) => (finish = resolve))
264
+ setBusy(false)
265
+ }}
266
+ >
267
+ <Field label="First">
268
+ <TextInput />
269
+ </Field>
270
+ </Form>
271
+ )
272
+ }
273
+ render(<Sending />)
274
+ const field = screen.getByRole('textbox', { name: 'First' })
275
+ await user.type(field, 'x{Enter}')
276
+ expect(field.matches(':disabled')).toBe(true)
277
+ expect(document.activeElement).toBe(screen.getByRole('form', { name: 'Probe' }))
278
+ await act(async () => finish())
279
+ expect(field.matches(':disabled')).toBe(false)
280
+ expect(document.activeElement).toBe(field)
281
+ })
282
+ })
package/src/Form.tsx ADDED
@@ -0,0 +1,113 @@
1
+ import { useLayoutEffect, useRef, type FormHTMLAttributes, 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
+ export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children' | 'noValidate'> {
32
+ /** Enter in a field, or a submit button. The page's own submit is already prevented. */
33
+ onSubmit: () => void | Promise<void>
34
+ /** While sending: every field and button inside is switched off, and focus waits on the form. */
35
+ busy?: boolean
36
+ children: ReactNode
37
+ }
38
+
39
+ const CONTROL = 'input:not([type="hidden"]), textarea, select, button, [role="checkbox"], [role="combobox"], [tabindex]:not([tabindex="-1"])'
40
+
41
+ function usable(element: Element | null): element is HTMLElement {
42
+ return element instanceof HTMLElement && element.isConnected && element.matches(CONTROL) && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true'
43
+ }
44
+
45
+ export function Form({ onSubmit, busy: ownBusy = false, className, children, ...props }: FormProps) {
46
+ // A form inside a busy form is busy too.
47
+ const outerBusy = useFormBusy()
48
+ const busy = ownBusy || outerBusy
49
+ const form = useRef<HTMLFormElement>(null)
50
+ // What had focus when the form went busy, and whether the form took focus from it.
51
+ const sender = useRef<Element | null>(null)
52
+ const holding = useRef(false)
53
+ // The last element inside the form that had focus, until focus moves to something outside it.
54
+ const lastInside = useRef<Element | null>(null)
55
+
56
+ useLayoutEffect(() => {
57
+ const element = form.current
58
+ if (!element) return
59
+ if (busy) {
60
+ const active = document.activeElement
61
+ // Focus is still inside (jsdom, or a field that stayed enabled), or the
62
+ // browser has already dropped it to the page from a field inside (Chrome).
63
+ const from =
64
+ active && active !== element && element.contains(active)
65
+ ? active
66
+ : !active || active === document.body
67
+ ? lastInside.current
68
+ : null
69
+ if (from?.isConnected) {
70
+ sender.current = from
71
+ holding.current = true
72
+ element.focus()
73
+ }
74
+ return
75
+ }
76
+ if (!holding.current) return
77
+ holding.current = false
78
+ // Someone who moved focus on while waiting keeps it where they put it.
79
+ 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))
82
+ sender.current = null
83
+ target?.focus()
84
+ }, [busy])
85
+
86
+ return (
87
+ <BaseForm
88
+ ref={form}
89
+ {...props}
90
+ // Focus waits here only while busy; the rest of the time a click between two fields must not land on the form.
91
+ tabIndex={busy ? -1 : props.tabIndex}
92
+ // A form that holds focus for a moment must not draw a focus ring.
93
+ className={cn('outline-none', className)}
94
+ onFocus={(event) => {
95
+ if (event.target !== form.current) lastInside.current = event.target
96
+ props.onFocus?.(event)
97
+ }}
98
+ onBlur={(event) => {
99
+ const next = event.relatedTarget
100
+ if (next && !form.current?.contains(next)) lastInside.current = null
101
+ props.onBlur?.(event)
102
+ }}
103
+ onSubmit={(event) => {
104
+ event.preventDefault()
105
+ void onSubmit()
106
+ }}
107
+ >
108
+ <Fieldset.Root disabled={busy} className="contents">
109
+ <FormBusyContext.Provider value={busy}>{children}</FormBusyContext.Provider>
110
+ </Fieldset.Root>
111
+ </BaseForm>
112
+ )
113
+ }
@@ -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',