@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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/dist/Button.d.ts.map +1 -1
  3. package/dist/Checkbox.d.ts +14 -1
  4. package/dist/Checkbox.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts +8 -1
  6. package/dist/CommandPalette.d.ts.map +1 -1
  7. package/dist/FilePicker.d.ts +22 -0
  8. package/dist/FilePicker.d.ts.map +1 -0
  9. package/dist/Form.d.ts +34 -0
  10. package/dist/Form.d.ts.map +1 -0
  11. package/dist/IconButton.d.ts.map +1 -1
  12. package/dist/SearchInput.d.ts.map +1 -1
  13. package/dist/TextInput.d.ts.map +1 -1
  14. package/dist/Textarea.d.ts.map +1 -1
  15. package/dist/eslint/index.d.ts +1 -1
  16. package/dist/eslint/index.d.ts.map +1 -1
  17. package/dist/eslint/index.js +88 -8
  18. package/dist/eslint/index.js.map +4 -4
  19. package/dist/eslint/no-raw-element.d.ts +88 -0
  20. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  21. package/dist/formBusy.d.ts +15 -0
  22. package/dist/formBusy.d.ts.map +1 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +363 -241
  26. package/dist/index.js.map +4 -4
  27. package/package.json +1 -1
  28. package/src/Button.tsx +4 -1
  29. package/src/Checkbox.mdx +19 -1
  30. package/src/Checkbox.stories.tsx +12 -0
  31. package/src/Checkbox.test.tsx +54 -0
  32. package/src/Checkbox.tsx +40 -3
  33. package/src/CommandPalette.mdx +11 -5
  34. package/src/CommandPalette.stories.tsx +3 -1
  35. package/src/CommandPalette.test.tsx +24 -1
  36. package/src/CommandPalette.tsx +23 -2
  37. package/src/FilePicker.mdx +49 -0
  38. package/src/FilePicker.stories.tsx +61 -0
  39. package/src/FilePicker.test.tsx +79 -0
  40. package/src/FilePicker.tsx +40 -0
  41. package/src/Form.mdx +68 -0
  42. package/src/Form.stories.tsx +68 -0
  43. package/src/Form.test.tsx +282 -0
  44. package/src/Form.tsx +113 -0
  45. package/src/IconButton.tsx +4 -1
  46. package/src/SearchInput.mdx +2 -2
  47. package/src/SearchInput.tsx +3 -1
  48. package/src/TextInput.mdx +2 -2
  49. package/src/TextInput.test.tsx +7 -0
  50. package/src/TextInput.tsx +4 -1
  51. package/src/Textarea.tsx +4 -1
  52. package/src/eslint/index.test.ts +24 -15
  53. package/src/eslint/index.ts +6 -5
  54. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  55. package/src/eslint/no-raw-element.ts +180 -0
  56. package/src/formBusy.ts +19 -0
  57. package/src/index.ts +2 -0
  58. package/stories/Choosing.mdx +3 -0
  59. package/dist/eslint/no-raw-button.d.ts +0 -11
  60. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  61. package/src/eslint/no-raw-button.ts +0 -39
@@ -7,7 +7,7 @@ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from '
7
7
  /**
8
8
  * The plugin as an app uses it: a flat config with `configs.recommended`,
9
9
  * linting text the way Peek's editor hook does (`lintText`). The rule's own
10
- * cases are in no-raw-button.test.ts.
10
+ * cases are in no-raw-element.test.ts.
11
11
  */
12
12
  const tsx: Linter.Config = {
13
13
  files: ['**/*.tsx'],
@@ -34,7 +34,7 @@ describe('the plugin object', () => {
34
34
 
35
35
  it('carries every rule, the app ones and the inward ones', () => {
36
36
  expect(Object.keys(estiva.rules)).toEqual([
37
- 'no-raw-button',
37
+ 'no-raw-element',
38
38
  'raw-element-outside-a-wrapper',
39
39
  'no-hand-rolled-behaviour',
40
40
  'component-has-a-page',
@@ -44,17 +44,19 @@ describe('the plugin object', () => {
44
44
 
45
45
  /**
46
46
  * The apps spread `recommended`. A rule added for the package (UIG-5) must not
47
- * arrive in Peek or Ship with the next version bump: an app is full of raw
48
- * elements it may keep until UIG-7, and has no `.mdx` pages at all. This is
47
+ * arrive in Peek or Ship with the next version bump: an app's components are
48
+ * not primitives, so "buried inside a component" means nothing there
49
+ * (`no-raw-element` is the app's version, UIG-7), and an app has no `.mdx`
50
+ * pages at all. This is
49
51
  * the test that holds that line — if you add an app-facing rule on purpose,
50
52
  * change it deliberately, here.
51
53
  */
52
54
  it('gives an app only the app rules, as errors, under estiva/', () => {
53
55
  for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
54
56
  expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
55
- expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
57
+ expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
56
58
  }
57
- expect(APP_RULE_IDS).toEqual(['estiva/no-raw-button'])
59
+ expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element'])
58
60
  })
59
61
 
60
62
  it('gives this package its own set, as errors, and it reaches no app config', () => {
@@ -77,7 +79,14 @@ describe('an app lint with configs.recommended', () => {
77
79
  it('reports a raw <button>, naming Button', async () => {
78
80
  const [result] = await lint(component(' <button type="button">x</button>'))
79
81
  expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
80
- ['estiva/no-raw-button', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
82
+ ['estiva/no-raw-element', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
83
+ ])
84
+ })
85
+
86
+ it('reports a raw <a>, naming Link', async () => {
87
+ const [result] = await lint(component(' <a href="/x">x</a>'))
88
+ expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
89
+ ['estiva/no-raw-element', 2, 'Use `Link` from @estiva-app/ui instead of a raw <a>. For a chip, `InlineChip`; for a whole card, `Card` with `href`.'],
81
90
  ])
82
91
  })
83
92
 
@@ -90,25 +99,25 @@ describe('an app lint with configs.recommended', () => {
90
99
  describe('countGates', () => {
91
100
  it('counts an error, and an escape only when the lint reports escapes', async () => {
92
101
  const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
93
- expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 0 } })
94
- expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 1 } })
102
+ expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 } })
103
+ expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 } })
95
104
  })
96
105
 
97
106
  it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
98
- const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button\n <button>x</button>'), [countMode])
107
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element\n <button>x</button>'), [countMode])
99
108
  const count = countGates(results)
100
- expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
101
- expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-button' }])
109
+ expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
110
+ expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-element' }])
102
111
  })
103
112
 
104
113
  it('counts a marker inside that directive as an error of the rule', async () => {
105
- const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
114
+ const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
106
115
  const count = countGates(results)
107
- expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
116
+ expect(count.rules['estiva/no-raw-element']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
108
117
  expect(count.disabled).toHaveLength(1)
109
118
  })
110
119
 
111
120
  it('lists every rule of the plugin, even with nothing found', () => {
112
- expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-button': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
121
+ expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
113
122
  })
114
123
  })
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module'
20
20
  import type { ESLint, Linter } from 'eslint'
21
21
  import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
22
22
  import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
23
- import { noRawButton } from './no-raw-button'
23
+ import { noRawElement } from './no-raw-element'
24
24
  import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
25
25
 
26
26
  export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
@@ -35,7 +35,7 @@ export const PLUGIN_KEY = 'estiva'
35
35
  * already has. `recommended` and `strict` carry these and only these.
36
36
  */
37
37
  const appRules = {
38
- 'no-raw-button': noRawButton,
38
+ 'no-raw-element': noRawElement,
39
39
  }
40
40
 
41
41
  /**
@@ -45,8 +45,9 @@ const appRules = {
45
45
  *
46
46
  * They are in `configs.package`, never in `recommended`, on purpose. Peek and
47
47
  * Ship spread `recommended`, so a rule added here must not arrive in an app
48
- * with the next version bump: an app is full of raw elements it is allowed to
49
- * have until UIG-7, and has no `.mdx` pages at all. `index.test.ts` holds the
48
+ * with the next version bump: an app's components are not primitives, so
49
+ * "buried inside a component" means nothing there (`no-raw-element` is the
50
+ * app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
50
51
  * apps' list to exactly the app rules.
51
52
  */
52
53
  const packageRules = {
@@ -80,7 +81,7 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
80
81
  plugin.configs.recommended = {
81
82
  name: '@estiva-app/ui/recommended',
82
83
  plugins: { [PLUGIN_KEY]: plugin },
83
- rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },
84
+ rules: { [`${PLUGIN_KEY}/no-raw-element`]: 'error' },
84
85
  }
85
86
  plugin.configs.strict = {
86
87
  name: '@estiva-app/ui/strict',
@@ -1,7 +1,7 @@
1
1
  import { RuleTester } from 'eslint'
2
2
  import { parser } from 'typescript-eslint'
3
3
  import { describe, it } from 'vitest'
4
- import { noRawButton } from './no-raw-button'
4
+ import { noRawElement, RAW_ELEMENT_PARTS, RAW_INPUT_PARTS } from './no-raw-element'
5
5
 
6
6
  RuleTester.describe = describe
7
7
  RuleTester.it = it
@@ -14,12 +14,27 @@ const tester = new RuleTester({
14
14
  })
15
15
 
16
16
  const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
17
+ const noPart = (element: string) => `@estiva-app/ui has no part for a raw ${element} yet. Do not build one here: ask Katerina, and it gets made in @estiva-app/ui.`
17
18
 
18
- tester.run('no-raw-button', noRawButton, {
19
+ /** Every element the mapping names, drawn the simplest way that makes it a control. */
20
+ const withControls: Record<string, string> = { audio: '<audio controls />', video: '<video controls />', img: '<img usemap="#m" alt="" />' }
21
+ const mapped = Object.entries(RAW_ELEMENT_PARTS).map(([name, part]) => ({ name, part, code: withControls[name] ?? `<${name} />` }))
22
+
23
+ tester.run('no-raw-element', noRawElement, {
19
24
  valid: [
20
25
  { name: "the package's Button", code: component('<Button>Save</Button>') },
21
26
  { name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
22
27
  { name: 'a name that starts with button', code: component('<buttonish />') },
28
+ {
29
+ name: 'the elements that are not controls',
30
+ code: component(' <section>\n <h1>Title</h1>\n <p>Text <strong>and</strong> <em>more</em></p>\n <ul><li>One</li></ul>\n <img src="x.png" alt="" />\n <hr />\n <kbd>Enter</kbd>\n <svg><path d="M0 0" /></svg>\n <time dateTime="2026-09-16">today</time>\n </section>'),
31
+ },
32
+ { name: 'a hidden input is data, not a control', code: component('<input type="hidden" name="id" value="1" />') },
33
+ { name: "a summary is reported through its details, not twice", code: component('<Foo>\n<summary>More</summary>\n</Foo>') },
34
+ { name: 'a fieldset and its legend are a frame', code: component('<fieldset><legend>Group</legend></fieldset>') },
35
+ { name: 'an option and a datalist are parts of another control', code: component('<Foo><option value="a">A</option><datalist id="d" /></Foo>') },
36
+ { name: 'a video with no controls is a picture that moves', code: component('<video src="a.mp4" autoPlay muted loop />') },
37
+ { name: 'controls written as false', code: component('<audio src="a.mp3" controls={false} />') },
23
38
  {
24
39
  name: 'a line comment escape directly above',
25
40
  code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
@@ -40,25 +55,55 @@ tester.run('no-raw-button', noRawButton, {
40
55
  name: 'exactly ten characters of reason, spaces not counted',
41
56
  code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
42
57
  },
58
+ {
59
+ name: 'an element with no part, escaped',
60
+ code: component(' // @estiva-escape: a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
61
+ },
43
62
  ],
44
63
  invalid: [
45
64
  { name: 'a raw button', code: component(' <button type="button">x</button>'), errors: [{ messageId: 'raw', line: 3 }] },
46
- { name: 'a self-closing raw button', code: component(' <button />'), errors: [{ messageId: 'raw' }] },
47
65
  {
48
66
  name: 'an opening tag that spans lines (what grep misses)',
49
- code: component(' <button\n type="button"\n >\n x\n </button>'),
67
+ code: component(' <input\n type="text"\n value={x}\n />'),
50
68
  errors: [{ messageId: 'raw', line: 3 }],
51
69
  },
52
70
  {
53
- name: 'a raw button nested in other elements',
54
- code: component(' <div>\n <span>\n <button>x</button>\n </span>\n </div>'),
71
+ name: 'a raw element nested in other elements',
72
+ code: component(' <div>\n <span>\n <a href="/x">x</a>\n </span>\n </div>'),
55
73
  errors: [{ messageId: 'raw', line: 5 }],
56
74
  },
57
75
  {
58
- name: 'the message names the component',
76
+ name: "the button's message is UIG-3's, word for word",
59
77
  code: component(' <button>x</button>'),
60
78
  errors: [{ message: 'Use `Button` from @estiva-app/ui instead of a raw <button>.' }],
61
79
  },
80
+
81
+ // Every element in the mapping names its part, or says there is none yet.
82
+ ...mapped.map(({ name, part, code }) => ({
83
+ name: part ? `<${name}> names ${part.use}` : `<${name}> has no part yet`,
84
+ code: component(` ${code}`),
85
+ errors: [{ message: part ? `Use \`${part.use}\` from @estiva-app/ui instead of a raw <${name}>.${part.more ?? ''}` : noPart(`<${name}>`) }],
86
+ })),
87
+ ...Object.entries(RAW_INPUT_PARTS).map(([type, part]) => ({
88
+ name: part ? `<input type="${type}"> names ${part.use}` : `<input type="${type}"> has no part yet`,
89
+ code: component(` <input type="${type}" />`),
90
+ errors: [{ message: part ? `Use \`${part.use}\` from @estiva-app/ui instead of a raw <input type="${type}">.` : noPart(`<input type="${type}">`) }],
91
+ })),
92
+
93
+ { name: 'an input with no type is a text box', code: component(' <input />'), errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>.' }] },
94
+ { name: 'an email box is a text box', code: component(' <input type="email" />'), errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>.' }] },
95
+ { name: 'a type the browser does not know is a text box', code: component(' <input type="wibble" />'), errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>.' }] },
96
+ { name: 'a type written in capitals', code: component(' <input type="CHECKBOX" />'), errors: [{ message: 'Use `Checkbox` from @estiva-app/ui instead of a raw <input type="checkbox">.' }] },
97
+ { name: 'a type in braces', code: component(" <input type={'file'} />"), errors: [{ message: 'Use `FilePicker` from @estiva-app/ui instead of a raw <input type="file">.' }] },
98
+ { name: 'a type in a template with nothing computed', code: component(' <input type={`search`} />'), errors: [{ message: 'Use `SearchInput` from @estiva-app/ui instead of a raw <input type="search">.' }] },
99
+ {
100
+ name: 'a computed type names every input part',
101
+ code: component(' <input type={kind} />'),
102
+ errors: [{ message: 'Use `TextInput` from @estiva-app/ui instead of a raw <input>. For a search, `SearchInput`; for a tick box, `Checkbox`; to pick files, `FilePicker`.' }],
103
+ },
104
+ { name: 'a video with controls', code: component(' <video src="a.mp4" controls={true} />'), errors: [{ message: noPart('<video>') }] },
105
+ { name: 'an image map written the React way', code: component(' <img useMap="#m" alt="" />'), errors: [{ message: noPart('<img>') }] },
106
+
62
107
  {
63
108
  name: 'an escape with no reason is an error, and hides nothing',
64
109
  code: component(' // @estiva-escape:\n <button>x</button>'),
@@ -100,7 +145,7 @@ tester.run('no-raw-button', noRawButton, {
100
145
  },
101
146
  {
102
147
  name: 'a marker inside a directive that names this rule is refused',
103
- code: component(' // eslint-disable-next-line rule-to-test/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
148
+ code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
104
149
  errors: [{ messageId: 'escapeInDirective', line: 3 }],
105
150
  },
106
151
  {
@@ -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
+ }
@@ -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'
@@ -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
- }