@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.
- package/README.md +28 -0
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +14 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts +114 -0
- package/dist/CommandPalette.d.ts.map +1 -0
- package/dist/Menu.d.ts +4 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Toast.d.ts.map +1 -1
- package/dist/eslint/escape.d.ts +61 -0
- package/dist/eslint/escape.d.ts.map +1 -0
- package/dist/eslint/has-a-page-and-a-story.d.ts +4 -0
- package/dist/eslint/has-a-page-and-a-story.d.ts.map +1 -0
- package/dist/eslint/index.d.ts +59 -0
- package/dist/eslint/index.d.ts.map +1 -0
- package/dist/eslint/index.js +286 -0
- package/dist/eslint/index.js.map +7 -0
- package/dist/eslint/no-hand-rolled-behaviour.d.ts +3 -0
- package/dist/eslint/no-hand-rolled-behaviour.d.ts.map +1 -0
- package/dist/eslint/no-raw-button.d.ts +11 -0
- package/dist/eslint/no-raw-button.d.ts.map +1 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts +29 -0
- package/dist/eslint/raw-element-outside-a-wrapper.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +614 -292
- package/dist/index.js.map +4 -4
- package/package.json +9 -1
- package/src/Avatar.tsx +1 -1
- package/src/AvatarGroup.tsx +1 -1
- package/src/Breadcrumb.tsx +6 -2
- package/src/ChipInput.mdx +9 -0
- package/src/ChipInput.stories.tsx +18 -0
- package/src/ChipInput.test.tsx +18 -0
- package/src/ChipInput.tsx +18 -4
- package/src/CommandPalette.mdx +133 -0
- package/src/CommandPalette.stories.tsx +416 -0
- package/src/CommandPalette.test.tsx +392 -0
- package/src/CommandPalette.tsx +643 -0
- package/src/Menu.tsx +4 -2
- package/src/Select.test.tsx +20 -3
- package/src/Toast.tsx +12 -24
- package/src/eslint/escape.ts +112 -0
- package/src/eslint/has-a-page-and-a-story.test.ts +57 -0
- package/src/eslint/has-a-page-and-a-story.ts +97 -0
- package/src/eslint/index.test.ts +114 -0
- package/src/eslint/index.ts +145 -0
- package/src/eslint/no-hand-rolled-behaviour.test.ts +70 -0
- package/src/eslint/no-hand-rolled-behaviour.ts +116 -0
- package/src/eslint/no-raw-button.test.ts +118 -0
- package/src/eslint/no-raw-button.ts +39 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +82 -0
- package/src/eslint/raw-element-outside-a-wrapper.ts +85 -0
- package/src/index.ts +17 -0
- package/stories/Choosing.mdx +1 -0
- 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,
|
package/stories/Choosing.mdx
CHANGED
|
@@ -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
|
|
package/stories/TokensPage.tsx
CHANGED
|
@@ -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) {
|