@estiva-app/ui 0.14.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 (56) hide show
  1. package/README.md +28 -0
  2. package/dist/Breadcrumb.d.ts.map +1 -1
  3. package/dist/ChipInput.d.ts +14 -1
  4. package/dist/ChipInput.d.ts.map +1 -1
  5. package/dist/CommandPalette.d.ts +114 -0
  6. package/dist/CommandPalette.d.ts.map +1 -0
  7. package/dist/Menu.d.ts +4 -0
  8. package/dist/Menu.d.ts.map +1 -1
  9. package/dist/Toast.d.ts.map +1 -1
  10. package/dist/eslint/escape.d.ts +61 -0
  11. package/dist/eslint/escape.d.ts.map +1 -0
  12. package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
  13. package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
  14. package/dist/eslint/index.d.ts +59 -0
  15. package/dist/eslint/index.d.ts.map +1 -0
  16. package/dist/eslint/index.js +286 -0
  17. package/dist/eslint/index.js.map +7 -0
  18. package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
  19. package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
  20. package/dist/eslint/no-raw-button.d.ts +11 -0
  21. package/dist/eslint/no-raw-button.d.ts.map +1 -0
  22. package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
  23. package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +614 -292
  27. package/dist/index.js.map +4 -4
  28. package/package.json +9 -1
  29. package/src/Avatar.tsx +1 -1
  30. package/src/AvatarGroup.tsx +1 -1
  31. package/src/Breadcrumb.tsx +6 -2
  32. package/src/ChipInput.mdx +9 -0
  33. package/src/ChipInput.stories.tsx +18 -0
  34. package/src/ChipInput.test.tsx +18 -0
  35. package/src/ChipInput.tsx +18 -4
  36. package/src/CommandPalette.mdx +133 -0
  37. package/src/CommandPalette.stories.tsx +416 -0
  38. package/src/CommandPalette.test.tsx +392 -0
  39. package/src/CommandPalette.tsx +643 -0
  40. package/src/Menu.tsx +4 -2
  41. package/src/Select.test.tsx +20 -3
  42. package/src/Toast.tsx +12 -24
  43. package/src/eslint/escape.ts +112 -0
  44. package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
  45. package/src/eslint/has-a-page-and-a-story.ts +97 -0
  46. package/src/eslint/index.test.ts +114 -0
  47. package/src/eslint/index.ts +145 -0
  48. package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
  49. package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
  50. package/src/eslint/no-raw-button.test.ts +118 -0
  51. package/src/eslint/no-raw-button.ts +39 -0
  52. package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
  53. package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
  54. package/src/index.ts +17 -0
  55. package/stories/Choosing.mdx +1 -0
  56. package/stories/TokensPage.tsx +1 -1
@@ -0,0 +1,70 @@
1
+ import { RuleTester } from 'eslint'
2
+ import { parser } from 'typescript-eslint'
3
+ import { describe, it } from 'vitest'
4
+ import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
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
+ tester.run('no-hand-rolled-behaviour', noHandRolledBehaviour, {
16
+ valid: [
17
+ { name: 'a Base UI part doing its own portalling', code: "import { Popover } from '@base-ui/react/popover'\nexport const Probe = () => <Popover.Portal />\n" },
18
+ {
19
+ name: "a virtual anchor handed to Base UI, as Popover's",
20
+ code: 'export function probe(rect: DOMRect) {\n return { getBoundingClientRect: () => rect }\n}\n',
21
+ },
22
+ {
23
+ name: "an element's own handler, which is not a global listener",
24
+ code: 'export function probe(el: HTMLElement) {\n el.addEventListener("keydown", () => {})\n}\n',
25
+ },
26
+ {
27
+ name: 'a listener for something no floating part owns',
28
+ code: 'export function probe() {\n window.addEventListener("online", () => {})\n}\n',
29
+ },
30
+ {
31
+ name: 'the import of a portal, kept with its reason',
32
+ code: "// @estiva-escape: the toast keeps its own portal until migration stage 6\nimport { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
33
+ },
34
+ {
35
+ name: 'the call, kept with its reason',
36
+ code: 'export function probe(to: HTMLElement) {\n // @estiva-escape: the toast keeps its own portal until migration stage 6\n return createPortal(null, to)\n}\n',
37
+ },
38
+ {
39
+ name: 'a global listener kept with its reason',
40
+ code: 'export function probe() {\n // @estiva-escape: the frame measures the window, which no floating part owns\n window.addEventListener("resize", () => {})\n}\n',
41
+ },
42
+ ],
43
+ invalid: [
44
+ {
45
+ name: 'importing createPortal',
46
+ code: "import { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
47
+ errors: [{ messageId: 'portal' }],
48
+ },
49
+ {
50
+ name: 'calling createPortal',
51
+ code: 'export function probe(to: HTMLElement) {\n return createPortal(null, to)\n}\n',
52
+ errors: [{ messageId: 'portal' }],
53
+ },
54
+ {
55
+ name: 'following the anchor by hand',
56
+ code: 'export function probe() {\n window.addEventListener("scroll", () => {})\n}\n',
57
+ errors: [{ messageId: 'listener', data: { event: 'scroll', target: 'window' } }],
58
+ },
59
+ {
60
+ name: 'closing on an outside press by hand',
61
+ code: 'export function probe() {\n document.addEventListener("mousedown", () => {})\n}\n',
62
+ errors: [{ messageId: 'listener', data: { event: 'mousedown', target: 'document' } }],
63
+ },
64
+ {
65
+ name: 'closing on Escape by hand',
66
+ code: 'export function probe() {\n document.addEventListener("keydown", () => {})\n}\n',
67
+ errors: [{ messageId: 'listener', data: { event: 'keydown', target: 'document' } }],
68
+ },
69
+ ],
70
+ })
@@ -0,0 +1,116 @@
1
+ import type { Rule } from 'eslint'
2
+ import { ESCAPE_MESSAGES, isEscaped } from './escape'
3
+
4
+ /**
5
+ * Behaviour Base UI already owns, written by hand inside the package (UIG-5,
6
+ * P1 — the migration's standing rule D6 as a machine).
7
+ *
8
+ * This is the rule with the most to lose. A hand-made portal or a global
9
+ * listener inside `DialogShell` is inherited by every caller in every app, and
10
+ * nobody sees it from there.
11
+ *
12
+ * Two facts, both mechanical, both the signature of a floating part rebuilt by
13
+ * hand:
14
+ *
15
+ * - **`createPortal`**, or importing it from `react-dom`. Base UI's parts carry
16
+ * their own portal.
17
+ * - **a listener on `window` or `document`** for the events a floating part
18
+ * lives on — resize, scroll, an outside press, a key. Base UI's Positioner
19
+ * follows the anchor and its Root closes on an outside press and on Escape.
20
+ *
21
+ * What it deliberately does not try to read: measuring arithmetic. `Popover`
22
+ * hands Base UI a virtual anchor with a `getBoundingClientRect`, which is the
23
+ * documented way to anchor to a rectangle rather than an element, and a rule
24
+ * that guessed at arithmetic would report it. Anything that shape is caught by
25
+ * a reader, not by this.
26
+ *
27
+ * A place that keeps one says why on the line above: `// @estiva-escape: <why>`.
28
+ */
29
+ const FLOATING_EVENTS = new Set(['resize', 'scroll', 'mousedown', 'pointerdown', 'keydown', 'keyup', 'focusin', 'focusout', 'click'])
30
+
31
+ interface Node {
32
+ type: string
33
+ loc: NonNullable<Rule.Node['loc']>
34
+ range: [number, number]
35
+ }
36
+
37
+ interface Identifier extends Node {
38
+ name: string
39
+ }
40
+
41
+ interface ImportDeclaration extends Node {
42
+ source: { value: unknown }
43
+ specifiers: { type: string; imported?: { name?: string }; local?: { name?: string }; loc?: Rule.Node['loc']; range?: [number, number] }[]
44
+ }
45
+
46
+ interface CallExpression extends Node {
47
+ callee: { type: string; name?: string; object?: { type: string; name?: string }; property?: { type: string; name?: string } }
48
+ arguments: { type: string; value?: unknown }[]
49
+ parent?: { type: string; loc?: Rule.Node['loc']; range?: [number, number]; parent?: CallExpression['parent'] }
50
+ }
51
+
52
+ /**
53
+ * The statement a call sits in, which is what an escape is written above:
54
+ * `return createPortal(…)` puts the call in the middle of its line, and a
55
+ * comment above the line is above the statement.
56
+ */
57
+ function statement(node: { loc: NonNullable<Rule.Node['loc']>; range: [number, number]; parent?: CallExpression['parent'] }): {
58
+ loc: NonNullable<Rule.Node['loc']>
59
+ range: [number, number]
60
+ } {
61
+ for (let p = node.parent; p; p = p.parent) {
62
+ if (!/(Statement|Declaration)$/.test(p.type)) continue
63
+ if (p.loc && p.range) return { loc: p.loc, range: p.range }
64
+ break
65
+ }
66
+ return { loc: node.loc, range: node.range }
67
+ }
68
+
69
+ export const noHandRolledBehaviour: Rule.RuleModule = {
70
+ meta: {
71
+ type: 'problem',
72
+ docs: { description: 'Overlay behaviour written by hand where Base UI has a part (D6)' },
73
+ schema: [],
74
+ messages: {
75
+ portal:
76
+ 'A portal written by hand. Base UI\'s parts carry their own (Popover, Menu, Select, Tooltip, Dialog, Toast): use the part, or say why this one stays.',
77
+ listener:
78
+ 'A `{{event}}` listener on `{{target}}`. A floating part follows its anchor and closes on an outside press and on Escape by itself (Base UI Positioner and Root): use the part, or say why this one stays.',
79
+ ...ESCAPE_MESSAGES,
80
+ },
81
+ },
82
+ create(context) {
83
+ return {
84
+ ImportDeclaration(node: Rule.Node) {
85
+ const declaration = node as unknown as ImportDeclaration
86
+ if (declaration.source.value !== 'react-dom') return
87
+ for (const specifier of declaration.specifiers) {
88
+ if (specifier.type !== 'ImportSpecifier' || specifier.imported?.name !== 'createPortal') continue
89
+ // The escape goes above the import line, not above the name inside it.
90
+ if (isEscaped(context, declaration)) return
91
+ context.report({ loc: declaration.loc, messageId: 'portal' })
92
+ return
93
+ }
94
+ },
95
+ CallExpression(node: Rule.Node) {
96
+ const call = node as unknown as CallExpression
97
+ const { callee } = call
98
+
99
+ if (callee.type === 'Identifier' && callee.name === 'createPortal') {
100
+ if (isEscaped(context, statement(call))) return
101
+ context.report({ loc: call.loc, messageId: 'portal' })
102
+ return
103
+ }
104
+
105
+ if (callee.type !== 'MemberExpression' || callee.property?.name !== 'addEventListener') return
106
+ const target = callee.object?.type === 'Identifier' ? callee.object.name : undefined
107
+ if (target !== 'window' && target !== 'document') return
108
+ const [first] = call.arguments
109
+ const event = first?.type === 'Literal' && typeof first.value === 'string' ? first.value : undefined
110
+ if (!event || !FLOATING_EVENTS.has(event)) return
111
+ if (isEscaped(context, statement(call))) return
112
+ context.report({ loc: call.loc, messageId: 'listener', data: { event, target } })
113
+ },
114
+ }
115
+ },
116
+ }
@@ -0,0 +1,118 @@
1
+ import { RuleTester } from 'eslint'
2
+ import { parser } from 'typescript-eslint'
3
+ import { describe, it } from 'vitest'
4
+ import { noRawButton } from './no-raw-button'
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
+ // A directive for a rule these cases do not load is not what they test.
13
+ linterOptions: { reportUnusedDisableDirectives: 'off' },
14
+ })
15
+
16
+ const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
17
+
18
+ tester.run('no-raw-button', noRawButton, {
19
+ valid: [
20
+ { name: "the package's Button", code: component('<Button>Save</Button>') },
21
+ { name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
22
+ { name: 'a name that starts with button', code: component('<buttonish />') },
23
+ {
24
+ name: 'a line comment escape directly above',
25
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
26
+ },
27
+ {
28
+ name: 'a JSX comment escape on the line above a child',
29
+ code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
30
+ },
31
+ {
32
+ name: 'a JSX comment escape over several lines',
33
+ code: component(' <div>\n {/*\n @estiva-escape: a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
34
+ },
35
+ {
36
+ name: 'an escape above an opening tag that spans lines',
37
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
38
+ },
39
+ {
40
+ name: 'exactly ten characters of reason, spaces not counted',
41
+ code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
42
+ },
43
+ ],
44
+ invalid: [
45
+ { 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
+ {
48
+ name: 'an opening tag that spans lines (what grep misses)',
49
+ code: component(' <button\n type="button"\n >\n x\n </button>'),
50
+ errors: [{ messageId: 'raw', line: 3 }],
51
+ },
52
+ {
53
+ name: 'a raw button nested in other elements',
54
+ code: component(' <div>\n <span>\n <button>x</button>\n </span>\n </div>'),
55
+ errors: [{ messageId: 'raw', line: 5 }],
56
+ },
57
+ {
58
+ name: 'the message names the component',
59
+ code: component(' <button>x</button>'),
60
+ errors: [{ message: 'Use `Button` from @estiva-app/ui instead of a raw <button>.' }],
61
+ },
62
+ {
63
+ name: 'an escape with no reason is an error, and hides nothing',
64
+ code: component(' // @estiva-escape:\n <button>x</button>'),
65
+ errors: [
66
+ { messageId: 'escapeWithoutReason', line: 3 },
67
+ { messageId: 'raw', line: 4 },
68
+ ],
69
+ },
70
+ {
71
+ name: 'nine characters of reason is too short',
72
+ code: component(' <div>\n {/* @estiva-escape: ab cd ef gh i */}\n <button>x</button>\n </div>'),
73
+ errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
74
+ },
75
+ {
76
+ name: 'a marker with no colon and no reason',
77
+ code: component(' // @estiva-escape\n <button>x</button>'),
78
+ errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
79
+ },
80
+ {
81
+ name: 'an escape two lines above does not reach the element',
82
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n\n <button>x</button>'),
83
+ errors: [{ messageId: 'raw', line: 5 }],
84
+ },
85
+ {
86
+ name: 'an escape above a sibling does not reach the next element',
87
+ code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
88
+ errors: [{ messageId: 'raw', line: 6 }],
89
+ },
90
+ {
91
+ name: 'a free comment is not an escape',
92
+ code: component(' // a raw button, on purpose\n <button>x</button>'),
93
+ errors: [{ messageId: 'raw' }],
94
+ },
95
+ {
96
+ name: 'a marker inside a directive that switches every rule off is refused',
97
+ code: component(' // eslint-disable-next-line -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
98
+ // The directive silences the element's own report; the refusal sits on the directive's line.
99
+ errors: [{ messageId: 'escapeInDirective', line: 3 }],
100
+ },
101
+ {
102
+ 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>'),
104
+ errors: [{ messageId: 'escapeInDirective', line: 3 }],
105
+ },
106
+ {
107
+ name: "the token lint's note for another rule is not an escape of this one",
108
+ code: component(' // eslint-disable-next-line no-console -- @estiva-escape: its hand-written type waits for that\n <button>x</button>'),
109
+ errors: [{ messageId: 'raw', line: 4 }],
110
+ },
111
+ {
112
+ name: 'with reportEscapes on, a sanctioned escape is reported for the count',
113
+ code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
114
+ settings: { estiva: { reportEscapes: true } },
115
+ errors: [{ messageId: 'escaped', data: { reason: 'a preview drawn from its own palette' }, line: 3 }],
116
+ },
117
+ ],
118
+ })
@@ -0,0 +1,39 @@
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
+ }
@@ -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
 
@@ -172,7 +172,7 @@ function Users({ names }: { names: string[] }) {
172
172
  function SwatchBox({ token }: { token: Token }) {
173
173
  const v = `var(${token.cssVar})`
174
174
  const base = 'h-6 w-10 shrink-0 rounded-md'
175
- /* eslint-disable no-restricted-syntax -- this page draws every token from its CSS
175
+ /* eslint-disable no-restricted-syntax -- @estiva-escape: this page draws every token from its CSS
176
176
  variable, so a swatch shows the value the theme holds, including a token no
177
177
  class spells yet. */
178
178
  switch (token.swatch) {