@estiva-app/ui 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +3 -1
  2. package/dist/Button.d.ts.map +1 -1
  3. package/dist/Checkbox.d.ts +14 -1
  4. package/dist/Checkbox.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts.map +1 -1
  6. package/dist/FilePicker.d.ts +22 -0
  7. package/dist/FilePicker.d.ts.map +1 -0
  8. package/dist/Form.d.ts +49 -0
  9. package/dist/Form.d.ts.map +1 -0
  10. package/dist/IconButton.d.ts.map +1 -1
  11. package/dist/SearchInput.d.ts.map +1 -1
  12. package/dist/TextInput.d.ts.map +1 -1
  13. package/dist/Textarea.d.ts.map +1 -1
  14. package/dist/eslint/index.d.ts +1 -1
  15. package/dist/eslint/index.d.ts.map +1 -1
  16. package/dist/eslint/index.js +88 -8
  17. package/dist/eslint/index.js.map +4 -4
  18. package/dist/eslint/no-raw-element.d.ts +88 -0
  19. package/dist/eslint/no-raw-element.d.ts.map +1 -0
  20. package/dist/formBusy.d.ts +15 -0
  21. package/dist/formBusy.d.ts.map +1 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +479 -350
  25. package/dist/index.js.map +4 -4
  26. package/package.json +1 -1
  27. package/src/Button.tsx +4 -1
  28. package/src/Checkbox.mdx +19 -1
  29. package/src/Checkbox.stories.tsx +12 -0
  30. package/src/Checkbox.test.tsx +54 -0
  31. package/src/Checkbox.tsx +40 -3
  32. package/src/CommandPalette.mdx +6 -2
  33. package/src/CommandPalette.stories.tsx +6 -3
  34. package/src/CommandPalette.test.tsx +15 -0
  35. package/src/CommandPalette.tsx +34 -46
  36. package/src/FilePicker.mdx +49 -0
  37. package/src/FilePicker.stories.tsx +61 -0
  38. package/src/FilePicker.test.tsx +79 -0
  39. package/src/FilePicker.tsx +40 -0
  40. package/src/Form.mdx +78 -0
  41. package/src/Form.stories.tsx +68 -0
  42. package/src/Form.test.tsx +385 -0
  43. package/src/Form.tsx +173 -0
  44. package/src/IconButton.tsx +4 -1
  45. package/src/SearchInput.mdx +2 -2
  46. package/src/SearchInput.tsx +3 -1
  47. package/src/TextInput.mdx +2 -2
  48. package/src/TextInput.test.tsx +7 -0
  49. package/src/TextInput.tsx +4 -1
  50. package/src/Textarea.tsx +4 -1
  51. package/src/eslint/index.test.ts +24 -15
  52. package/src/eslint/index.ts +6 -5
  53. package/src/eslint/{no-raw-button.test.ts → no-raw-element.test.ts} +53 -8
  54. package/src/eslint/no-raw-element.ts +180 -0
  55. package/src/formBusy.ts +19 -0
  56. package/src/index.ts +2 -0
  57. package/stories/Choosing.mdx +3 -0
  58. package/dist/eslint/no-raw-button.d.ts +0 -11
  59. package/dist/eslint/no-raw-button.d.ts.map +0 -1
  60. package/src/eslint/no-raw-button.ts +0 -39
@@ -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
- }