@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.
- package/README.md +3 -1
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +14 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/FilePicker.d.ts +22 -0
- package/dist/FilePicker.d.ts.map +1 -0
- package/dist/Form.d.ts +49 -0
- package/dist/Form.d.ts.map +1 -0
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +88 -8
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-raw-element.d.ts +88 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -0
- package/dist/formBusy.d.ts +15 -0
- package/dist/formBusy.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +479 -350
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Button.tsx +4 -1
- package/src/Checkbox.mdx +19 -1
- package/src/Checkbox.stories.tsx +12 -0
- package/src/Checkbox.test.tsx +54 -0
- package/src/Checkbox.tsx +40 -3
- package/src/CommandPalette.mdx +6 -2
- package/src/CommandPalette.stories.tsx +6 -3
- package/src/CommandPalette.test.tsx +15 -0
- package/src/CommandPalette.tsx +34 -46
- package/src/FilePicker.mdx +49 -0
- package/src/FilePicker.stories.tsx +61 -0
- package/src/FilePicker.test.tsx +79 -0
- package/src/FilePicker.tsx +40 -0
- package/src/Form.mdx +78 -0
- package/src/Form.stories.tsx +68 -0
- package/src/Form.test.tsx +385 -0
- package/src/Form.tsx +173 -0
- package/src/IconButton.tsx +4 -1
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.tsx +3 -1
- package/src/TextInput.mdx +2 -2
- package/src/TextInput.test.tsx +7 -0
- package/src/TextInput.tsx +4 -1
- package/src/Textarea.tsx +4 -1
- package/src/eslint/index.test.ts +24 -15
- package/src/eslint/index.ts +6 -5
- package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
- package/src/eslint/no-raw-element.ts +180 -0
- package/src/formBusy.ts +19 -0
- package/src/index.ts +2 -0
- package/stories/Choosing.mdx +3 -0
- package/dist/eslint/no-raw-button.d.ts +0 -11
- package/dist/eslint/no-raw-button.d.ts.map +0 -1
- 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
|
+
}
|
package/src/IconButton.tsx
CHANGED
|
@@ -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(
|
package/src/SearchInput.mdx
CHANGED
|
@@ -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
|
|
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
|
|
package/src/SearchInput.tsx
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
package/src/TextInput.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
|
48
|
-
*
|
|
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-
|
|
57
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
|
|
56
58
|
}
|
|
57
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-
|
|
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-
|
|
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-
|
|
94
|
-
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-
|
|
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-
|
|
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-
|
|
101
|
-
expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-
|
|
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-
|
|
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-
|
|
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-
|
|
121
|
+
expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
|
|
113
122
|
})
|
|
114
123
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -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 {
|
|
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-
|
|
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
|
|
49
|
-
*
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
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(' <
|
|
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
|
|
54
|
-
code: component(' <div>\n <span>\n <
|
|
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:
|
|
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-
|
|
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
|
{
|