@estiva-app/ui 0.15.0 → 0.16.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 (38) hide show
  1. package/dist/Breadcrumb.d.ts.map +1 -1
  2. package/dist/CommandPalette.d.ts +114 -0
  3. package/dist/CommandPalette.d.ts.map +1 -0
  4. package/dist/Menu.d.ts +4 -0
  5. package/dist/Menu.d.ts.map +1 -1
  6. package/dist/Toast.d.ts.map +1 -1
  7. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  8. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  9. package/dist/eslint/index.d.ts +15 -1
  10. package/dist/eslint/index.d.ts.map +1 -1
  11. package/dist/eslint/index.js +176 -8
  12. package/dist/eslint/index.js.map +3 -3
  13. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  14. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  15. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  16. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +609 -287
  20. package/dist/index.js.map +4 -4
  21. package/package.json +5 -1
  22. package/src/Breadcrumb.tsx +6 -2
  23. package/src/CommandPalette.mdx +133 -0
  24. package/src/CommandPalette.stories.tsx +416 -0
  25. package/src/CommandPalette.test.tsx +392 -0
  26. package/src/CommandPalette.tsx +643 -0
  27. package/src/Menu.tsx +4 -2
  28. package/src/Toast.tsx +12 -24
  29. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  30. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  31. package/src/eslint/index.test.ts +35 -3
  32. package/src/eslint/index.ts +58 -13
  33. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  34. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  35. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  36. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  37. package/src/index.ts +17 -0
  38. package/stories/Choosing.mdx +1 -0
@@ -0,0 +1,82 @@
1
+ import { RuleTester } from 'eslint'
2
+ import { parser } from 'typescript-eslint'
3
+ import { describe, it } from 'vitest'
4
+ import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
5
+
6
+ RuleTester.describe = describe
7
+ RuleTester.it = it
8
+ RuleTester.itOnly = it.only
9
+
10
+ const tester = new RuleTester({
11
+ languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
12
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
13
+ })
14
+
15
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
16
+
17
+ tester.run('raw-element-outside-a-wrapper', rawElementOutsideAWrapper, {
18
+ valid: [
19
+ // The two shapes the package is built out of.
20
+ { name: "the component's own outermost element, as NavItem is an <a>", code: component(' <a href="/x">Issues</a>') },
21
+ { name: "the component's own element with children inside it", code: component(' <a href="/x">\n <span>Issues</span>\n </a>') },
22
+ {
23
+ name: 'either branch of a component that returns one element or another, as EditableText does',
24
+ code: 'export function Probe({ editing }: { editing: boolean }) {\n if (editing) return <input aria-label="x" />\n return <button type="button">x</button>\n}\n',
25
+ },
26
+ {
27
+ name: 'handed to a Base UI render prop, as Menu.Item does',
28
+ code: component(' <BaseMenu.Item render={<button type="button" />} className="row" />'),
29
+ },
30
+ {
31
+ name: 'handed to a render prop while nested, as a part inside a part',
32
+ code: component(' <BaseMenu.Positioner>\n <BaseMenu.Item render={<button type="button" />} />\n </BaseMenu.Positioner>'),
33
+ },
34
+ { name: 'a component, not an element', code: component(' <div>\n <Button>Save</Button>\n </div>') },
35
+ { name: 'layout inside a component', code: component(' <div>\n <span>Issues</span>\n </div>') },
36
+ {
37
+ name: 'a nested element with its reason on the line above',
38
+ code: component(' <div>\n {/* @estiva-escape: the trail is one line of text and an anchor keeps it */}\n <a href="/x">Issues</a>\n </div>'),
39
+ },
40
+ ],
41
+ invalid: [
42
+ {
43
+ name: 'an anchor buried in a component, as the breadcrumb trail had',
44
+ code: component(' <div>\n <a href="/x">Issues</a>\n </div>'),
45
+ errors: [{ messageId: 'nested', data: { name: 'a' } }],
46
+ },
47
+ {
48
+ name: 'a button buried in a component, as the toast had',
49
+ code: component(' <div>\n <p>Saved</p>\n <button type="button">Undo</button>\n </div>'),
50
+ errors: [{ messageId: 'nested', data: { name: 'button' } }],
51
+ },
52
+ {
53
+ name: 'inside a fragment, which is several children rather than one element',
54
+ code: component(' <>\n <span>x</span>\n <input aria-label="x" />\n </>'),
55
+ errors: [{ messageId: 'nested', data: { name: 'input' } }],
56
+ },
57
+ {
58
+ name: 'inside a component of the package, which is not a render prop',
59
+ code: component(' <Card>\n <button type="button">x</button>\n </Card>'),
60
+ errors: [{ messageId: 'nested', data: { name: 'button' } }],
61
+ },
62
+ {
63
+ name: 'nested inside an element that was itself handed to a render prop',
64
+ code: component(' <BaseMenu.Item render={<div><button type="button" /></div>} />'),
65
+ errors: [{ messageId: 'nested', data: { name: 'button' } }],
66
+ },
67
+ {
68
+ name: 'a reason too short to be a reason',
69
+ code: component(' <div>\n {/* @estiva-escape: short */}\n <a href="/x">Issues</a>\n </div>'),
70
+ errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'nested', data: { name: 'a' } }],
71
+ },
72
+ {
73
+ name: 'an eslint-disable instead of a reason',
74
+ // The tester runs the rule under its bare name; in a real lint the plugin
75
+ // prefixes it, and `isEscaped` compares against whichever the rule has.
76
+ code: component(' <div>\n {/* eslint-disable-next-line rule-to-test/raw-element-outside-a-wrapper -- @estiva-escape: the trail keeps its anchor */}\n <a href="/x">Issues</a>\n </div>'),
77
+ // The directive silences the rule's own report, which is exactly why it is
78
+ // refused: `countGates` sees the silenced report and the gate fails on it.
79
+ errors: [{ messageId: 'escapeInDirective' }],
80
+ },
81
+ ],
82
+ })
@@ -0,0 +1,85 @@
1
+ import type { Rule } from 'eslint'
2
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
3
+
4
+ interface JSXOpeningElement {
5
+ type: string
6
+ name: { type: string; name?: string }
7
+ loc: NonNullable<Rule.Node['loc']>
8
+ range: [number, number]
9
+ parent?: Parent
10
+ }
11
+
12
+ interface Parent {
13
+ type: string
14
+ name?: { name?: string }
15
+ openingElement?: unknown
16
+ parent?: Parent
17
+ }
18
+
19
+ /**
20
+ * The elements this package writes by hand. The set the count used (UIG-1,
21
+ * docs/GATES.md §5 and §7): every element that carries behaviour or a role of
22
+ * its own. A `div` or a `span` is layout and is nobody's business here.
23
+ */
24
+ export const RAW_ELEMENTS = new Set(['a', 'button', 'input', 'textarea', 'select', 'dialog', 'form', 'label'])
25
+
26
+ /**
27
+ * A raw element nested inside another element, in the package itself (UIG-5,
28
+ * the inward tracer).
29
+ *
30
+ * The package is allowed to write raw elements — that is what a primitive is.
31
+ * Two shapes are right, and both are facts about the code rather than a
32
+ * judgement:
33
+ *
34
+ * - **the component's own outermost element.** `NavItem` is an `<a>`, `Link` is
35
+ * an `<a>`, a `MenuItem` outside a `Menu` is a `<button>`. A primitive owning
36
+ * its element is the whole point of it.
37
+ * - **handed to a Base UI `render` prop.** `render={<button type="button" />}`
38
+ * tells a Base UI part which element to be; the behaviour stays Base UI's.
39
+ *
40
+ * Anything else is an element buried inside a bigger component, where its look
41
+ * and its behaviour are kept in step by nobody — the package's own version of
42
+ * the rule the apps get from UIG-3 and UIG-7.
43
+ *
44
+ * It keeps its reason on the line above if it stays: `// @estiva-escape: <why>`.
45
+ */
46
+ export const rawElementOutsideAWrapper: Rule.RuleModule = {
47
+ meta: {
48
+ type: 'problem',
49
+ docs: { description: 'A raw element nested inside a component, rather than the component itself' },
50
+ schema: [],
51
+ messages: {
52
+ nested:
53
+ 'A raw <{{name}}> inside another element. In this package a raw element is either the component\'s own outermost element or handed to a Base UI `render` prop; one buried inside belongs in a component of its own (Link, Button, MenuItem...).',
54
+ ...ESCAPE_MESSAGES,
55
+ },
56
+ },
57
+ create(context) {
58
+ return {
59
+ // ESLint's types know ESTree's nodes, not JSX's; the parser hands this one over.
60
+ JSXOpeningElement(node: Rule.Node) {
61
+ const element = node as unknown as JSXOpeningElement
62
+ const name = element.name.name
63
+ if (element.name.type !== 'JSXIdentifier' || !name || !RAW_ELEMENTS.has(name)) return
64
+
65
+ // An opening tag's own parent is its JSXElement: that is this element,
66
+ // not an element around it.
67
+ let parent = element.parent
68
+ if (parent?.type === 'JSXElement' && parent.openingElement === (element as unknown)) parent = parent.parent
69
+
70
+ // Whichever comes first going outward decides: an element around it
71
+ // means it is nested; a `render` prop means Base UI draws it.
72
+ for (let p = parent; p; p = p.parent) {
73
+ if (p.type === 'JSXAttribute') {
74
+ if (p.name?.name === 'render') return
75
+ continue
76
+ }
77
+ if (p.type !== 'JSXElement' && p.type !== 'JSXFragment') continue
78
+ if (isEscaped(context, element)) return
79
+ context.report({ loc: element.loc, messageId: 'nested', data: { name } })
80
+ return
81
+ }
82
+ },
83
+ }
84
+ },
85
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,23 @@ export { Button, type ButtonProps, type ButtonSize, type ButtonVariant } from '.
18
18
  export { Card, type CardAttention, type CardFill, type CardHover, type CardProps } from './Card'
19
19
  export { Checkbox, type CheckboxProps } from './Checkbox'
20
20
  export { Chip, type ChipProps, type ChipType } from './Chip'
21
+ export {
22
+ CommandPalette,
23
+ CommandPaletteAnswer,
24
+ CommandPaletteForm,
25
+ CommandPaletteQuote,
26
+ CommandPaletteSearch,
27
+ CommandPaletteWorking,
28
+ type CommandPaletteAnswerProps,
29
+ type CommandPaletteChip,
30
+ type CommandPaletteFormProps,
31
+ type CommandPaletteGroup,
32
+ type CommandPaletteProps,
33
+ type CommandPaletteQuoteProps,
34
+ type CommandPaletteRow,
35
+ type CommandPaletteSearchProps,
36
+ type CommandPaletteWorkingProps,
37
+ } from './CommandPalette'
21
38
  export { ChipInput, InputChip, type ChipInputOption, type ChipInputProps, type InputChipProps } from './ChipInput'
22
39
  export {
23
40
  INLINE_CHIP_CLASSES,
@@ -62,6 +62,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
62
62
  | A hint on hover or on keyboard focus | **Tooltip** / **WithTooltip** — one `TooltipProvider` at the app root shares the delay |
63
63
  | A strip of controls that act on one thing | **Toolbar** — one Tab stop for the row, arrow keys along it |
64
64
  | Reactions to choose from | **ReactionPicker** — the row; **Reaction** is the pill it produces |
65
+ | One window to find something and act on it, opened by a shortcut | **CommandPalette** — rows, forms and written answers, one level at a time |
65
66
  | Anything with a title, body and footer | **DialogShell** |
66
67
  | "Are you sure?" | **ConfirmDialog** |
67
68