@estiva-app/ui 0.16.0 → 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.
- 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 +8 -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 +34 -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 +363 -241
- 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 +11 -5
- package/src/CommandPalette.stories.tsx +3 -1
- package/src/CommandPalette.test.tsx +24 -1
- package/src/CommandPalette.tsx +23 -2
- 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 +68 -0
- package/src/Form.stories.tsx +68 -0
- package/src/Form.test.tsx +282 -0
- package/src/Form.tsx +113 -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/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
|
{
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
|
|
4
|
+
interface JSXAttributeValue {
|
|
5
|
+
type: string
|
|
6
|
+
value?: unknown
|
|
7
|
+
expression?: { type: string; value?: unknown; expressions?: unknown[]; quasis?: { value: { cooked: string | null } }[] }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface JSXAttribute {
|
|
11
|
+
type: 'JSXAttribute'
|
|
12
|
+
name: { type: string; name?: string }
|
|
13
|
+
value: JSXAttributeValue | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface JSXOpeningElement {
|
|
17
|
+
name: { type: string; name?: string }
|
|
18
|
+
attributes: (JSXAttribute | { type: 'JSXSpreadAttribute' })[]
|
|
19
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
20
|
+
range: [number, number]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the package has instead of one raw element: the component the message
|
|
25
|
+
* names, and where one element does more than one job, the others after it.
|
|
26
|
+
* `null` is an element the package has no part for yet.
|
|
27
|
+
*/
|
|
28
|
+
export interface RawElementPart {
|
|
29
|
+
use: string
|
|
30
|
+
more?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The mapping (UIG-7), derived from the package's exports: every interactive
|
|
35
|
+
* element in the HTML standard's list ("interactive content"), plus `<dialog>`,
|
|
36
|
+
* `<form>` and `<progress>`, which have a part here. docs/GATES.md prints it.
|
|
37
|
+
*
|
|
38
|
+
* Not here, on purpose: `<summary>` (it lives inside a `<details>`, which is
|
|
39
|
+
* reported), `<option>`, `<optgroup>` and `<datalist>` (they live inside a
|
|
40
|
+
* `<select>` or beside an input), `<fieldset>` and `<legend>` (a group's frame,
|
|
41
|
+
* no control of its own), and `<img>` without `usemap`, `<hr>`, `<kbd>`, which
|
|
42
|
+
* are not controls. An `<audio>` or `<video>` is a control only with `controls`.
|
|
43
|
+
*/
|
|
44
|
+
export const RAW_ELEMENT_PARTS: Record<string, RawElementPart | null> = {
|
|
45
|
+
a: { use: 'Link', more: ' For a chip, `InlineChip`; for a whole card, `Card` with `href`.' },
|
|
46
|
+
button: { use: 'Button' },
|
|
47
|
+
textarea: { use: 'Textarea' },
|
|
48
|
+
select: { use: 'Select' },
|
|
49
|
+
dialog: { use: 'DialogShell', more: ' To ask yes or no, `ConfirmDialog`.' },
|
|
50
|
+
label: { use: 'Field', more: ' For words beside a tick box, `Checkbox` with `label`.' },
|
|
51
|
+
details: { use: 'CollapsibleSection' },
|
|
52
|
+
progress: { use: 'ProgressBar' },
|
|
53
|
+
form: { use: 'Form' },
|
|
54
|
+
meter: null,
|
|
55
|
+
iframe: null,
|
|
56
|
+
embed: null,
|
|
57
|
+
object: null,
|
|
58
|
+
audio: null,
|
|
59
|
+
video: null,
|
|
60
|
+
img: null,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An `<input>` is as many controls as its `type`. A type missing from here
|
|
65
|
+
* (text, email, password, url, tel, number, or one the browser does not know)
|
|
66
|
+
* is a text box.
|
|
67
|
+
*/
|
|
68
|
+
export const RAW_INPUT_PARTS: Record<string, RawElementPart | null> = {
|
|
69
|
+
search: { use: 'SearchInput' },
|
|
70
|
+
checkbox: { use: 'Checkbox' },
|
|
71
|
+
file: { use: 'FilePicker' },
|
|
72
|
+
submit: { use: 'Button' },
|
|
73
|
+
button: { use: 'Button' },
|
|
74
|
+
reset: { use: 'Button' },
|
|
75
|
+
image: { use: 'Button' },
|
|
76
|
+
radio: null,
|
|
77
|
+
range: null,
|
|
78
|
+
color: null,
|
|
79
|
+
date: null,
|
|
80
|
+
'datetime-local': null,
|
|
81
|
+
month: null,
|
|
82
|
+
week: null,
|
|
83
|
+
time: null,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const TEXT_INPUT: RawElementPart = { use: 'TextInput' }
|
|
87
|
+
const ANY_INPUT: RawElementPart = {
|
|
88
|
+
use: 'TextInput',
|
|
89
|
+
more: ' For a search, `SearchInput`; for a tick box, `Checkbox`; to pick files, `FilePicker`.',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function attribute(element: JSXOpeningElement, name: string): JSXAttribute | undefined {
|
|
93
|
+
return element.attributes.find((a): a is JSXAttribute => a.type === 'JSXAttribute' && a.name.name === name)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** An attribute's value when the code spells it out, `undefined` when it is computed. */
|
|
97
|
+
function literal(attr: JSXAttribute): string | boolean | undefined {
|
|
98
|
+
const value = attr.value
|
|
99
|
+
if (value === null) return true
|
|
100
|
+
if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : undefined
|
|
101
|
+
if (value.type !== 'JSXExpressionContainer' || !value.expression) return undefined
|
|
102
|
+
const expression = value.expression
|
|
103
|
+
if (expression.type === 'Literal' && (typeof expression.value === 'string' || typeof expression.value === 'boolean')) return expression.value
|
|
104
|
+
if (expression.type === 'TemplateLiteral' && expression.expressions?.length === 0) return expression.quasis?.[0]?.value.cooked ?? undefined
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Present, and not written as `={false}`. */
|
|
109
|
+
function isOn(element: JSXOpeningElement, name: string): boolean {
|
|
110
|
+
const attr = attribute(element, name)
|
|
111
|
+
return attr !== undefined && literal(attr) !== false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | null
|
|
115
|
+
|
|
116
|
+
/** What this element is to the rule: a raw element with a part, one with none yet, or none of its business. */
|
|
117
|
+
export function classify(element: JSXOpeningElement): Found {
|
|
118
|
+
if (element.name.type !== 'JSXIdentifier' || !element.name.name) return null
|
|
119
|
+
const name = element.name.name
|
|
120
|
+
|
|
121
|
+
if (name === 'input') {
|
|
122
|
+
const attr = attribute(element, 'type')
|
|
123
|
+
const type = attr ? literal(attr) : 'text'
|
|
124
|
+
if (typeof type !== 'string') return { report: 'raw', element: '<input>', part: ANY_INPUT }
|
|
125
|
+
const key = type.toLowerCase()
|
|
126
|
+
// A hidden input draws nothing and takes no focus: it is data, not a control.
|
|
127
|
+
if (key === 'hidden') return null
|
|
128
|
+
if (!(key in RAW_INPUT_PARTS)) return { report: 'raw', element: '<input>', part: TEXT_INPUT }
|
|
129
|
+
const part = RAW_INPUT_PARTS[key]
|
|
130
|
+
const shown = `<input type="${key}">`
|
|
131
|
+
return part ? { report: 'raw', element: shown, part } : { report: 'noPart', element: shown }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) return null
|
|
135
|
+
if ((name === 'audio' || name === 'video') && !isOn(element, 'controls')) return null
|
|
136
|
+
if (name === 'img' && !attribute(element, 'usemap') && !attribute(element, 'useMap')) return null
|
|
137
|
+
|
|
138
|
+
const part = RAW_ELEMENT_PARTS[name]
|
|
139
|
+
return part ? { report: 'raw', element: `<${name}>`, part } : { report: 'noPart', element: `<${name}>` }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A raw interactive element in an app, where the package has a part for it
|
|
144
|
+
* (UIG-3 began it with `<button>`; UIG-7 made it every element). A control
|
|
145
|
+
* the app writes itself has a look, a focus ring and keys that nobody else
|
|
146
|
+
* keeps in step with — and it looks nearly right, which is why nobody catches
|
|
147
|
+
* it by eye. Every message names the part to use.
|
|
148
|
+
*
|
|
149
|
+
* An element the package has no part for yet is refused too, with a message
|
|
150
|
+
* that says so: the part is made in the package, not by hand in an app
|
|
151
|
+
* (Katerina, 16 September).
|
|
152
|
+
*
|
|
153
|
+
* Only a JSX element written with a lowercase name. `<Button>`,
|
|
154
|
+
* `<Foo.button>` and `createElement('button')` are not this rule's business.
|
|
155
|
+
*/
|
|
156
|
+
export const noRawElement: Rule.RuleModule = {
|
|
157
|
+
meta: {
|
|
158
|
+
type: 'problem',
|
|
159
|
+
docs: { description: 'A raw interactive element where @estiva-app/ui has a part, or where it needs one' },
|
|
160
|
+
schema: [],
|
|
161
|
+
messages: {
|
|
162
|
+
raw: 'Use `{{use}}` from @estiva-app/ui instead of a raw {{element}}.{{more}}',
|
|
163
|
+
noPart: '@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.',
|
|
164
|
+
...ESCAPE_MESSAGES,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
create(context) {
|
|
168
|
+
return {
|
|
169
|
+
// ESLint's types know ESTree's nodes, not JSX's; the parser hands this one over.
|
|
170
|
+
JSXOpeningElement(node: Rule.Node) {
|
|
171
|
+
const element = node as unknown as JSXOpeningElement
|
|
172
|
+
const found = classify(element)
|
|
173
|
+
if (!found) return
|
|
174
|
+
if (isEscaped(context, element)) return
|
|
175
|
+
if (found.report === 'noPart') context.report({ loc: element.loc, messageId: 'noPart', data: { element: found.element } })
|
|
176
|
+
else context.report({ loc: element.loc, messageId: 'raw', data: { element: found.element, use: found.part.use, more: found.part.more ?? '' } })
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
package/src/formBusy.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether a `Form` around this is busy (UIG-7).
|
|
5
|
+
*
|
|
6
|
+
* `Form` switches its children off with a disabled `<fieldset>`, which the
|
|
7
|
+
* browser applies to every native control inside — but a part that draws its
|
|
8
|
+
* look from Base UI's own `disabled` (Button, IconButton, Checkbox) is not
|
|
9
|
+
* told, so it kept its normal look while it could not be pressed, and a
|
|
10
|
+
* Checkbox, which is a `<span>`, could still be ticked (Katerina saw the busy
|
|
11
|
+
* story's button unchanged, 16 September). Those parts read this and switch
|
|
12
|
+
* off the way their own `disabled` does. Not exported: it is the package's
|
|
13
|
+
* wiring between Form and its parts.
|
|
14
|
+
*/
|
|
15
|
+
export const FormBusyContext = createContext(false)
|
|
16
|
+
|
|
17
|
+
export function useFormBusy(): boolean {
|
|
18
|
+
return useContext(FormBusyContext)
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,8 @@ export { Kbd, type KbdProps } from './Kbd'
|
|
|
50
50
|
export { IdentityMenu, IdentityPanel, type Identity, type IdentityMenuProps, type IdentityPanelProps } from './IdentityMenu'
|
|
51
51
|
export { Tooltip, TooltipProvider, WithTooltip, type TooltipProps, type TooltipProviderProps, type WithTooltipProps } from './Tooltip'
|
|
52
52
|
export { Field, FieldLine, type FieldLineProps, type FieldLineTone, type FieldProps } from './Field'
|
|
53
|
+
export { FilePicker, type FilePickerProps } from './FilePicker'
|
|
54
|
+
export { Form, type FormProps } from './Form'
|
|
53
55
|
export { Select, type SelectOption, type SelectProps } from './Select'
|
|
54
56
|
export { TextInput, type TextInputProps } from './TextInput'
|
|
55
57
|
export { Textarea, type TextareaProps } from './Textarea'
|
package/stories/Choosing.mdx
CHANGED
|
@@ -25,6 +25,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
25
25
|
| One value for a form field | **Select** | a Menu — Select's trigger shows the value |
|
|
26
26
|
| Several values, typed then removable | **ChipInput** | checkboxes in a Menu |
|
|
27
27
|
| Yes or no | **Checkbox** | a toggle we don't have |
|
|
28
|
+
| Yes or no, with words beside it that toggle it too | **Checkbox** `label` | words written beside it by hand — they name nothing and toggle nothing |
|
|
28
29
|
| Switching between views of one thing | **Tabs** | a row of Buttons |
|
|
29
30
|
| View options behind a `…` | **Menu**, `selected` on the current row | Select |
|
|
30
31
|
| Rows of checkboxes that stay open | a list in a **DialogShell** | a Menu — a Checkbox inside a `menuitem` is invalid HTML |
|
|
@@ -39,6 +40,8 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
39
40
|
| Typing that filters a list | **SearchInput** |
|
|
40
41
|
| A title edited in place | **EditableText** |
|
|
41
42
|
| Label and required-mark around any control | **Field** |
|
|
43
|
+
| The fields and the button that sends them | **Form** — Enter sends; `busy` switches everything inside off while it waits |
|
|
44
|
+
| A button that opens the file picker | **FilePicker**, opened by your own **Button** or **IconButton** |
|
|
42
45
|
| A hint or a failure under **one** control | **Field**'s `helper` / `error` — wired to it, and announced |
|
|
43
46
|
| A hint or an outcome under a **group** of controls | **FieldLine** — the same line on its own, with a `warning` tone Field has not got |
|
|
44
47
|
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { Rule } from 'eslint';
|
|
2
|
-
/**
|
|
3
|
-
* A raw `<button>` in an app (UIG-3, the tracer). The package's `Button`,
|
|
4
|
-
* `IconButton`, `MenuItem` and chips are buttons already; an app that writes
|
|
5
|
-
* its own has a look and a behaviour nobody else keeps in step with.
|
|
6
|
-
*
|
|
7
|
-
* Only the JSX element named `button`. `<Button>`, `<Foo.button>` and
|
|
8
|
-
* `createElement('button')` are not this rule's business.
|
|
9
|
-
*/
|
|
10
|
-
export declare const noRawButton: Rule.RuleModule;
|
|
11
|
-
//# sourceMappingURL=no-raw-button.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"no-raw-button.d.ts","sourceRoot":"","sources":["../../src/eslint/no-raw-button.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AASlC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,EAAE,IAAI,CAAC,UAqB9B,CAAA"}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { Rule } from 'eslint'
|
|
2
|
-
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
-
|
|
4
|
-
interface JSXOpeningElement {
|
|
5
|
-
name: { type: string; name?: string }
|
|
6
|
-
loc: NonNullable<Rule.Node['loc']>
|
|
7
|
-
range: [number, number]
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* A raw `<button>` in an app (UIG-3, the tracer). The package's `Button`,
|
|
12
|
-
* `IconButton`, `MenuItem` and chips are buttons already; an app that writes
|
|
13
|
-
* its own has a look and a behaviour nobody else keeps in step with.
|
|
14
|
-
*
|
|
15
|
-
* Only the JSX element named `button`. `<Button>`, `<Foo.button>` and
|
|
16
|
-
* `createElement('button')` are not this rule's business.
|
|
17
|
-
*/
|
|
18
|
-
export const noRawButton: Rule.RuleModule = {
|
|
19
|
-
meta: {
|
|
20
|
-
type: 'problem',
|
|
21
|
-
docs: { description: 'A raw <button> where @estiva-app/ui has one' },
|
|
22
|
-
schema: [],
|
|
23
|
-
messages: {
|
|
24
|
-
raw: 'Use `Button` from @estiva-app/ui instead of a raw <button>.',
|
|
25
|
-
...ESCAPE_MESSAGES,
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
create(context) {
|
|
29
|
-
return {
|
|
30
|
-
// ESLint's types know ESTree's nodes, not JSX's; the parser hands this one over.
|
|
31
|
-
JSXOpeningElement(node: Rule.Node) {
|
|
32
|
-
const element = node as unknown as JSXOpeningElement
|
|
33
|
-
if (element.name.type !== 'JSXIdentifier' || element.name.name !== 'button') return
|
|
34
|
-
if (isEscaped(context, element)) return
|
|
35
|
-
context.report({ loc: element.loc, messageId: 'raw' })
|
|
36
|
-
},
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
}
|