@estiva-app/ui 0.35.2 → 0.36.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/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +70 -9
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +37 -183
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +7 -6
- package/dist/registry/cli.js.map +2 -2
- package/dist/registry/contract.d.ts +13 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/package.json +4 -1
- package/registry.json +293 -17
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +4 -0
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +2 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +4 -3
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +2 -0
- package/src/gates/gates.test.ts +86 -1
- package/src/gates/hook.ts +38 -4
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/pages.test.ts +6 -0
- package/src/registry/app.test.ts +34 -0
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +5 -3
- package/src/registry/contract.test.ts +27 -2
- package/src/registry/contract.ts +28 -3
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +14 -0
- package/dist/gates/chunk-DBMUQAR6.js +0 -607
- package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
package/src/CommandPalette.tsx
CHANGED
|
@@ -123,7 +123,7 @@ export function CommandPalette({ open, onOpenChange, label, where, modKey = 'Ctr
|
|
|
123
123
|
<Dialog.Portal>
|
|
124
124
|
<Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim" />
|
|
125
125
|
<Dialog.Viewport className="fixed inset-0 z-50 flex items-start justify-center pt-[16vh]">
|
|
126
|
-
{/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
|
|
126
|
+
{/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): the palette's window keeps its own border */}
|
|
127
127
|
<Dialog.Popup
|
|
128
128
|
ref={popupRef}
|
|
129
129
|
aria-label={label}
|
|
@@ -374,7 +374,7 @@ export function CommandPaletteSearch({ query, onQueryChange, placeholder, groups
|
|
|
374
374
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
375
375
|
<IconSearch size={16} stroke={1.5} className="shrink-0 text-text-secondary" />
|
|
376
376
|
{chip && <LevelChip chip={chip} onBack={back} />}
|
|
377
|
-
{/* @estiva-escape: A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
|
|
377
|
+
{/* @estiva-escape(no-copied-look): A5 (Katerina, 24 September: leave CommandPalette alone): the palette's search field keeps its own classes */}
|
|
378
378
|
<Autocomplete.Input
|
|
379
379
|
ref={inputRef}
|
|
380
380
|
data-command-palette-field=""
|
|
@@ -555,7 +555,7 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
555
555
|
|
|
556
556
|
return (
|
|
557
557
|
<div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
|
|
558
|
-
{/* @estiva-escape: the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
|
|
558
|
+
{/* @estiva-escape(no-handmade-header): the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
|
|
559
559
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
560
560
|
{icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
|
|
561
561
|
<LevelChip chip={chip} onBack={back} />
|
package/src/DialogShell.tsx
CHANGED
|
@@ -86,7 +86,7 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
|
|
|
86
86
|
|
|
87
87
|
{/* Dialog */}
|
|
88
88
|
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
|
89
|
-
{/* @estiva-escape: A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
|
|
89
|
+
{/* @estiva-escape(no-copied-look): A3 (Katerina, 24 September: leave CommandPalette alone): a dialog's window and the palette's are left as they are */}
|
|
90
90
|
<Parts.Popup
|
|
91
91
|
ref={popupRef}
|
|
92
92
|
/*
|
package/src/EmptyState.mdx
CHANGED
|
@@ -36,9 +36,10 @@ need a height from the caller (`className="h-full"`).
|
|
|
36
36
|
|
|
37
37
|
## When not
|
|
38
38
|
|
|
39
|
-
- Data is still on its way → **Skeleton
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
- Data is still on its way → **Skeleton**: **SkeletonList**, or a shape of
|
|
40
|
+
your own from **SkeletonBar**; empty and loading are different truths.
|
|
41
|
+
When the shape of what arrives is unknown, an EmptyState that says so —
|
|
42
|
+
"Opening…" — is fine.
|
|
42
43
|
- Something went wrong → say what failed, in words. An EmptyState can carry
|
|
43
44
|
it, in its usual look — never red. An empty state that hides a failure
|
|
44
45
|
misleads.
|
package/src/Lightbox.tsx
CHANGED
|
@@ -74,7 +74,7 @@ export function Lightbox({ src, alt, onClose, className }: LightboxProps) {
|
|
|
74
74
|
onClick={(e) => e.stopPropagation()}
|
|
75
75
|
className={cn('max-w-full max-h-full object-contain rounded-lg', className)}
|
|
76
76
|
/>
|
|
77
|
-
{/* @estiva-escape: N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
|
|
77
|
+
{/* @estiva-escape(no-copied-look): N4 (Katerina, 24 September: record only): the small round button AttachmentCard's remove also draws; a part if a third appears */}
|
|
78
78
|
<Dialog.Close
|
|
79
79
|
aria-label="Close"
|
|
80
80
|
className="absolute top-4 right-4 size-8 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary"
|
package/src/NavItem.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
|
|
|
28
28
|
|
|
29
29
|
export function NavItem({ label, href, count, countLabel, active = false, icon, className, ...props }: NavItemProps) {
|
|
30
30
|
return (
|
|
31
|
-
// @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
|
|
31
|
+
// @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as PersonTrigger's row also does
|
|
32
32
|
<a
|
|
33
33
|
href={href}
|
|
34
34
|
aria-current={active ? 'page' : undefined}
|
package/src/PersonTrigger.tsx
CHANGED
|
@@ -60,7 +60,7 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
return (
|
|
63
|
-
// @estiva-escape: N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
|
|
63
|
+
// @estiva-escape(no-copied-look): N5 (Katerina, 24 September: record only): a row that fills on hover, as NavItem also does
|
|
64
64
|
<BaseButton
|
|
65
65
|
type="button"
|
|
66
66
|
aria-haspopup="menu"
|
package/src/Reaction.tsx
CHANGED
|
@@ -63,7 +63,7 @@ export interface ReactionProps extends Omit<ButtonHTMLAttributes<HTMLButtonEleme
|
|
|
63
63
|
|
|
64
64
|
export function Reaction({ emoji, count, pressed = false, className, ...props }: ReactionProps) {
|
|
65
65
|
return (
|
|
66
|
-
// @estiva-escape: W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
|
|
66
|
+
// @estiva-escape(no-copied-look): W1 (Katerina, 24 September: leave alone): a reaction pill shares four words with a field's box by coincidence
|
|
67
67
|
<Toggle
|
|
68
68
|
// Controlled: a reaction is yours when the data says so, not when the
|
|
69
69
|
// pill was last pressed — a write can fail, and another client can
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -27,6 +27,10 @@ the content is moving; nothing moves when it appears.
|
|
|
27
27
|
- A box that never overflows. A ScrollArea around it changes nothing, and
|
|
28
28
|
is one more element.
|
|
29
29
|
- The page itself. The browser owns that bar.
|
|
30
|
+
- A whole list column → **ListColumn**, which scrolls its rows itself.
|
|
31
|
+
|
|
32
|
+
No alternative for the first two: a box that never overflows needs nothing
|
|
33
|
+
around it, and the page's bar is the browser's.
|
|
30
34
|
|
|
31
35
|
## How
|
|
32
36
|
|
package/src/Select.tsx
CHANGED
|
@@ -74,7 +74,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
74
74
|
// a native `disabled` button, which Tab skips and a tooltip cannot open on.
|
|
75
75
|
const held = Boolean(disabledReason)
|
|
76
76
|
const trigger = (
|
|
77
|
-
// @estiva-escape: N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
|
|
77
|
+
// @estiva-escape(no-copied-look): N1 (Katerina, 24 September: record only): Select's box drops the hover while held, so it cannot take FIELD_SHELL_CLASSES as it is
|
|
78
78
|
<BaseSelect.Trigger
|
|
79
79
|
aria-label={ariaLabel}
|
|
80
80
|
aria-required={aria['aria-required']}
|
package/src/TopBar.mdx
CHANGED
|
@@ -33,7 +33,7 @@ reachable.
|
|
|
33
33
|
## When not
|
|
34
34
|
|
|
35
35
|
- Page-level headers inside the content → not this; this is the frame's
|
|
36
|
-
bar, once per app.
|
|
36
|
+
bar, once per app. The top of a column is **ContainerHeader**.
|
|
37
37
|
- Anything the bar would need to know — who you are, what is searched —
|
|
38
38
|
stays outside: pass composed slots.
|
|
39
39
|
|
package/src/eslint/escape.ts
CHANGED
|
@@ -4,15 +4,21 @@ import type { AST, Rule } from 'eslint'
|
|
|
4
4
|
* The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
|
|
5
5
|
* why one element stays as it is.
|
|
6
6
|
*
|
|
7
|
-
* A line comment directly above the element,
|
|
7
|
+
* A line comment directly above the element, naming the rule it keeps off,
|
|
8
8
|
*
|
|
9
|
-
* // @estiva-escape: <reason>
|
|
9
|
+
* // @estiva-escape(no-raw-element): <reason>
|
|
10
10
|
*
|
|
11
11
|
* or, where the element is a JSX child, the JSX comment on the line above,
|
|
12
12
|
*
|
|
13
|
-
* {/* @estiva-escape: <reason> *\/}
|
|
13
|
+
* {/* @estiva-escape(no-raw-element): <reason> *\/}
|
|
14
14
|
*
|
|
15
|
-
* with at least ten characters of reason, spaces not counted.
|
|
15
|
+
* with at least ten characters of reason, spaces not counted. It escapes only
|
|
16
|
+
* the rules it names (Katerina's ruling B3, 25 September: one reason used to
|
|
17
|
+
* switch off every rule on the element, so a raw button that also wrote a
|
|
18
|
+
* dialog's role by hand passed under one reason). Two rules on one element are
|
|
19
|
+
* named together, `@estiva-escape(no-raw-element, no-rebuilt-behaviour)`, each
|
|
20
|
+
* on purpose. A marker that names no rule escapes nothing, and says which name
|
|
21
|
+
* to write.
|
|
16
22
|
*
|
|
17
23
|
* Not `eslint-disable`. A directive switches a rule off without saying it was
|
|
18
24
|
* meant, and the count of escapes (`.gates-count.json`) could not see it. A
|
|
@@ -37,11 +43,29 @@ export const SETTINGS_KEY = 'estiva'
|
|
|
37
43
|
* reports through the rule that called it.
|
|
38
44
|
*/
|
|
39
45
|
export const ESCAPE_MESSAGES = {
|
|
40
|
-
escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}: <why this stays>\`. An escape with no reason hides nothing.`,
|
|
41
|
-
escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}: <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
|
|
46
|
+
escapeWithoutReason: `An escape needs its reason, at least ${MIN_REASON} characters: \`// ${ESCAPE_MARKER}({{rule}}): <why this stays>\`. An escape with no reason hides nothing.`,
|
|
47
|
+
escapeInDirective: `Write the escape as its own comment on the line above: \`// ${ESCAPE_MARKER}({{rule}}): <reason>\`. Inside an eslint-disable comment it switches the rule off instead of recording why.`,
|
|
48
|
+
escapeWithoutRule: `Name the rule this escape keeps off: \`${ESCAPE_MARKER}({{rule}}): <reason>\`. An escape now keeps off only the rules it names.`,
|
|
42
49
|
escaped: 'Escaped: {{reason}}',
|
|
43
50
|
} as const
|
|
44
51
|
|
|
52
|
+
/** A rule's name as a marker writes it: `no-raw-element` for `estiva/no-raw-element`. */
|
|
53
|
+
export const shortRuleName = (id: string) => id.slice(id.lastIndexOf('/') + 1)
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a marker says, read from a comment's text: the rules it names (none when
|
|
57
|
+
* it names none) and its reason. Null when the comment carries no marker.
|
|
58
|
+
*/
|
|
59
|
+
export function readMarker(text: string): { rules: string[]; reason: string } | null {
|
|
60
|
+
const at = text.indexOf(ESCAPE_MARKER)
|
|
61
|
+
if (at === -1) return null
|
|
62
|
+
const rest = text.slice(at + ESCAPE_MARKER.length)
|
|
63
|
+
const named = /^\s*\(([^)]*)\)/.exec(rest)
|
|
64
|
+
const rules = named ? named[1].split(/[\s,]+/).filter(Boolean).map(shortRuleName) : []
|
|
65
|
+
const reason = (named ? rest.slice(named[0].length) : rest).replace(/^\s*:/, '').trim()
|
|
66
|
+
return { rules, reason }
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
export interface EstivaSettings {
|
|
46
70
|
/**
|
|
47
71
|
* Report every sanctioned escape as a message with the id `escaped`, so a
|
|
@@ -85,24 +109,29 @@ export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
|
|
|
85
109
|
.find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
|
|
86
110
|
if (!comment?.loc) return false
|
|
87
111
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
112
|
+
const marker = readMarker(comment.value)
|
|
113
|
+
if (!marker) return false
|
|
114
|
+
const rule = shortRuleName(context.id)
|
|
90
115
|
|
|
91
116
|
const directive = DIRECTIVE.exec(comment.value)
|
|
92
117
|
if (directive) {
|
|
93
118
|
const rules = directiveRules(directive[1])
|
|
94
119
|
// A directive for other rules (the token lint's notes) is not an escape of this one.
|
|
95
120
|
if (rules.length > 0 && !rules.includes(context.id)) return false
|
|
96
|
-
context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
|
|
121
|
+
context.report({ loc: comment.loc, messageId: 'escapeInDirective', data: { rule } })
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Named, but not this rule: another rule on the element may be the one it keeps off.
|
|
126
|
+
if (marker.rules.length > 0 && !marker.rules.includes(rule)) return false
|
|
127
|
+
if (marker.rules.length === 0) {
|
|
128
|
+
context.report({ loc: comment.loc, messageId: 'escapeWithoutRule', data: { rule } })
|
|
97
129
|
return false
|
|
98
130
|
}
|
|
99
131
|
|
|
100
|
-
const reason =
|
|
101
|
-
.slice(at + ESCAPE_MARKER.length)
|
|
102
|
-
.replace(/^:/, '')
|
|
103
|
-
.trim()
|
|
132
|
+
const { reason } = marker
|
|
104
133
|
if (reason.replace(/\s/g, '').length < MIN_REASON) {
|
|
105
|
-
context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
|
|
134
|
+
context.report({ loc: comment.loc, messageId: 'escapeWithoutReason', data: { rule } })
|
|
106
135
|
return false
|
|
107
136
|
}
|
|
108
137
|
|
|
@@ -17,7 +17,7 @@ const tester = new RuleTester({
|
|
|
17
17
|
/** Real files of this package, so the rules are tested against what they read. */
|
|
18
18
|
const src = (name: string) => fileURLToPath(new URL(`../${name}`, import.meta.url))
|
|
19
19
|
const code = 'export function Probe() {\n return null\n}\n'
|
|
20
|
-
const escaped = '// @estiva-escape: a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
|
|
20
|
+
const escaped = '// @estiva-escape(component-has-a-page, component-has-a-story): a probe file that documents itself in its own page\nexport function Probe() {\n return null\n}\n'
|
|
21
21
|
|
|
22
22
|
describe('the files these rules read', () => {
|
|
23
23
|
it('Button has a page and a story beside it, and FieldLine and MenuItem have both without a component file', () => {
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -145,14 +145,14 @@ describe('an app lint with configs.recommended', () => {
|
|
|
145
145
|
})
|
|
146
146
|
|
|
147
147
|
it('passes the same element under an escape', async () => {
|
|
148
|
-
const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
148
|
+
const [result] = await lint(component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
149
149
|
expect(result.messages).toEqual([])
|
|
150
150
|
})
|
|
151
151
|
})
|
|
152
152
|
|
|
153
153
|
describe('countGates', () => {
|
|
154
154
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
155
|
-
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
155
|
+
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
156
156
|
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
157
157
|
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
|
|
158
158
|
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none, 'estiva/no-copied-look': none })
|
|
@@ -65,7 +65,7 @@ tester.run('no-copied-look', noCopiedLook, {
|
|
|
65
65
|
{
|
|
66
66
|
name: 'an escape with its reason',
|
|
67
67
|
filename: app,
|
|
68
|
-
code: probe(`<>\n {/* @estiva-escape: recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
|
|
68
|
+
code: probe(`<>\n {/* @estiva-escape(no-copied-look): recorded in GATES.md, a label strip Katerina left as it is */}\n <span className="${SECTION_LABEL}">x</span>\n </>`),
|
|
69
69
|
},
|
|
70
70
|
],
|
|
71
71
|
invalid: [
|
|
@@ -29,15 +29,15 @@ tester.run('no-hand-rolled-behaviour', noHandRolledBehaviour, {
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
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",
|
|
32
|
+
code: "// @estiva-escape(no-hand-rolled-behaviour): the toast keeps its own portal until migration stage 6\nimport { createPortal } from 'react-dom'\nexport const Probe = () => null\n",
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
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',
|
|
36
|
+
code: 'export function probe(to: HTMLElement) {\n // @estiva-escape(no-hand-rolled-behaviour): the toast keeps its own portal until migration stage 6\n return createPortal(null, to)\n}\n',
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
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',
|
|
40
|
+
code: 'export function probe() {\n // @estiva-escape(no-hand-rolled-behaviour): the frame measures the window, which no floating part owns\n window.addEventListener("resize", () => {})\n}\n',
|
|
41
41
|
},
|
|
42
42
|
],
|
|
43
43
|
invalid: [
|
|
@@ -91,7 +91,7 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
|
|
|
91
91
|
{ name: 'a box holding more than the line', code: jsx('<div>{rows.length === 0 ? <div className="px-3"><p>Nothing yet.</p><Button>Add one</Button></div> : rows.map((r) => <p key={r}>{r}</p>)}</div>') },
|
|
92
92
|
{
|
|
93
93
|
name: 'an escape with its reason',
|
|
94
|
-
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape: a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
94
|
+
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state): a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
95
95
|
},
|
|
96
96
|
|
|
97
97
|
// the right scope
|
|
@@ -118,7 +118,7 @@ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
|
|
|
118
118
|
},
|
|
119
119
|
{
|
|
120
120
|
name: 'an escape with no reason is not an escape',
|
|
121
|
-
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
121
|
+
code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape(no-handmade-empty-state)\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
|
|
122
122
|
errors: [{ messageId: 'escapeWithoutReason' }, ...handmade],
|
|
123
123
|
},
|
|
124
124
|
|
|
@@ -33,7 +33,7 @@ tester.run('no-handmade-header', noHandmadeHeader, {
|
|
|
33
33
|
{ name: 'a column of rows', code: component('<div className="flex h-full flex-col">\n<div className="flex flex-col px-3"><span>{title}</span></div>\n<div />\n</div>') },
|
|
34
34
|
{
|
|
35
35
|
name: 'an escape with its reason',
|
|
36
|
-
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
36
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape(no-handmade-header): a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
37
37
|
},
|
|
38
38
|
],
|
|
39
39
|
invalid: [
|
|
@@ -82,7 +82,7 @@ tester.run('no-handmade-header', noHandmadeHeader, {
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
name: 'an escape with no reason escapes nothing',
|
|
85
|
-
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
85
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape(no-handmade-header): */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
86
86
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'handmade' }],
|
|
87
87
|
},
|
|
88
88
|
],
|
|
@@ -40,7 +40,7 @@ tester.run('no-native-title', noNativeTitle, {
|
|
|
40
40
|
{ name: "the app's own component with a title of its own (Peek's NewTopicBanner)", filename: app, code: component('<NewTopicBanner title={name} />', "import { NewTopicBanner } from '@/components/Banner'\n") },
|
|
41
41
|
{ name: "a story's title is the Storybook's, an object key", code: "const meta = { title: 'Primitives/Tooltip', component: Tooltip }\nexport default meta\n" },
|
|
42
42
|
{ name: 'a prop of another name', code: component('<span aria-label={name} data-title={name}>{name}</span>') },
|
|
43
|
-
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape: the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
|
|
43
|
+
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape(no-native-title): the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
|
|
44
44
|
],
|
|
45
45
|
invalid: [
|
|
46
46
|
{
|
|
@@ -54,7 +54,7 @@ tester.run('no-native-title', noNativeTitle, {
|
|
|
54
54
|
{ name: "a part that hands it on to its element: the apps' status probe", code: component('<Button title="Delete">x</Button>', "import { Button } from '@estiva-app/ui'\n"), errors: native },
|
|
55
55
|
{ name: "through the app's re-export", filename: app, code: component('<IconButton title="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@/components/ui/IconButton'\n"), errors: native },
|
|
56
56
|
{ name: "through the app's wrapper that hands its props on", filename: app, code: component('<Button title="Delete">x</Button>', "import { Button } from '@/components/ui/Button'\n"), errors: native },
|
|
57
|
-
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
|
|
57
|
+
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape(no-native-title) */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
|
|
58
58
|
],
|
|
59
59
|
})
|
|
60
60
|
|
|
@@ -25,6 +25,10 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
25
25
|
{ name: "the package's Button", code: component('<Button>Save</Button>') },
|
|
26
26
|
{ name: 'a member expression named button', code: component('<Foo.button>Save</Foo.button>') },
|
|
27
27
|
{ name: 'a name that starts with button', code: component('<buttonish />') },
|
|
28
|
+
// R5: a handler that only stops the click passes; a part may take one
|
|
29
|
+
{ name: 'a wrapper that only stops the click', code: component('<div onClick={(e) => e.stopPropagation()} onPointerDown={(event) => { event.stopPropagation(); event.preventDefault() }}><Button>Save</Button></div>') },
|
|
30
|
+
{ name: 'a press handler on a part', code: component('<Card onClick={open}>x</Card>') },
|
|
31
|
+
{ name: 'a clickable element, escaped with its reason', code: component(' // @estiva-escape(no-raw-element): a surface that holds headings and lists, which a button cannot hold\n <div onClick={edit}>x</div>') },
|
|
28
32
|
{
|
|
29
33
|
name: 'the elements that are not controls',
|
|
30
34
|
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>'),
|
|
@@ -37,31 +41,45 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
37
41
|
{ name: 'controls written as false', code: component('<audio src="a.mp3" controls={false} />') },
|
|
38
42
|
{
|
|
39
43
|
name: 'a line comment escape directly above',
|
|
40
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'),
|
|
44
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'a marker naming two rules keeps off each of them, written with or without the plugin name',
|
|
48
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour, estiva/no-raw-element): a preview drawn from its own palette\n <button type="button">x</button>'),
|
|
41
49
|
},
|
|
42
50
|
{
|
|
43
51
|
name: 'a JSX comment escape on the line above a child',
|
|
44
|
-
code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
|
|
52
|
+
code: component(' <div>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <button type="button">x</button>\n </div>'),
|
|
45
53
|
},
|
|
46
54
|
{
|
|
47
55
|
name: 'a JSX comment escape over several lines',
|
|
48
|
-
code: component(' <div>\n {/*\n @estiva-escape: a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
|
|
56
|
+
code: component(' <div>\n {/*\n @estiva-escape(no-raw-element): a preview drawn from its own palette\n */}\n <button type="button">x</button>\n </div>'),
|
|
49
57
|
},
|
|
50
58
|
{
|
|
51
59
|
name: 'an escape above an opening tag that spans lines',
|
|
52
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
|
|
60
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button\n type="button"\n onClick={() => {}}\n >\n x\n </button>'),
|
|
53
61
|
},
|
|
54
62
|
{
|
|
55
63
|
name: 'exactly ten characters of reason, spaces not counted',
|
|
56
|
-
code: component(' // @estiva-escape: ab cd ef gh ij\n <button>x</button>'),
|
|
64
|
+
code: component(' // @estiva-escape(no-raw-element): ab cd ef gh ij\n <button>x</button>'),
|
|
57
65
|
},
|
|
58
66
|
{
|
|
59
67
|
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" />'),
|
|
68
|
+
code: component(' // @estiva-escape(no-raw-element): a map from another service, until the package has a frame\n <iframe src="https://example.com" title="Map" />'),
|
|
61
69
|
},
|
|
62
70
|
],
|
|
63
71
|
invalid: [
|
|
64
72
|
{ name: 'a raw button', code: component(' <button type="button">x</button>'), errors: [{ messageId: 'raw', line: 3 }] },
|
|
73
|
+
{
|
|
74
|
+
name: 'a clickable div names Button and Card with href (R5)',
|
|
75
|
+
code: component(' <div onClick={open}>x</div>'),
|
|
76
|
+
errors: [{ message: 'A clickable <div onClick> is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.' }],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'a press that does more than stop the click, on any plain element',
|
|
80
|
+
code: component(' <li onMouseDown={(e) => { e.preventDefault(); onSearch() }}>\n <span onPointerDown={handle} />\n </li>'),
|
|
81
|
+
errors: [{ messageId: 'clickable', data: { element: '<li onMouseDown>' } }, { messageId: 'clickable', data: { element: '<span onPointerDown>' } }],
|
|
82
|
+
},
|
|
65
83
|
{
|
|
66
84
|
name: 'an opening tag that spans lines (what grep misses)',
|
|
67
85
|
code: component(' <input\n type="text"\n value={x}\n />'),
|
|
@@ -106,7 +124,7 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
106
124
|
|
|
107
125
|
{
|
|
108
126
|
name: 'an escape with no reason is an error, and hides nothing',
|
|
109
|
-
code: component(' // @estiva-escape:\n <button>x</button>'),
|
|
127
|
+
code: component(' // @estiva-escape(no-raw-element):\n <button>x</button>'),
|
|
110
128
|
errors: [
|
|
111
129
|
{ messageId: 'escapeWithoutReason', line: 3 },
|
|
112
130
|
{ messageId: 'raw', line: 4 },
|
|
@@ -114,22 +132,36 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
114
132
|
},
|
|
115
133
|
{
|
|
116
134
|
name: 'nine characters of reason is too short',
|
|
117
|
-
code: component(' <div>\n {/* @estiva-escape: ab cd ef gh i */}\n <button>x</button>\n </div>'),
|
|
135
|
+
code: component(' <div>\n {/* @estiva-escape(no-raw-element): ab cd ef gh i */}\n <button>x</button>\n </div>'),
|
|
118
136
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
|
|
119
137
|
},
|
|
120
138
|
{
|
|
121
139
|
name: 'a marker with no colon and no reason',
|
|
122
|
-
code: component(' // @estiva-escape\n <button>x</button>'),
|
|
140
|
+
code: component(' // @estiva-escape(no-raw-element)\n <button>x</button>'),
|
|
123
141
|
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'raw' }],
|
|
124
142
|
},
|
|
143
|
+
// B3 (Katerina, 25 September): a marker keeps off only the rules it names.
|
|
144
|
+
{
|
|
145
|
+
name: 'a marker that names no rule escapes nothing, and says which name to write',
|
|
146
|
+
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
147
|
+
errors: [
|
|
148
|
+
{ line: 3, message: 'Name the rule this escape keeps off: `@estiva-escape(no-raw-element): <reason>`. An escape now keeps off only the rules it names.' },
|
|
149
|
+
{ messageId: 'raw', line: 4 },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'a marker that names another rule does not keep this one off',
|
|
154
|
+
code: component(' // @estiva-escape(no-rebuilt-behaviour): a preview drawn from its own palette\n <button>x</button>'),
|
|
155
|
+
errors: [{ messageId: 'raw', line: 4 }],
|
|
156
|
+
},
|
|
125
157
|
{
|
|
126
158
|
name: 'an escape two lines above does not reach the element',
|
|
127
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n\n <button>x</button>'),
|
|
159
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n\n <button>x</button>'),
|
|
128
160
|
errors: [{ messageId: 'raw', line: 5 }],
|
|
129
161
|
},
|
|
130
162
|
{
|
|
131
163
|
name: 'an escape above a sibling does not reach the next element',
|
|
132
|
-
code: component(' <div>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
|
|
164
|
+
code: component(' <div>\n {/* @estiva-escape(no-raw-element): a preview drawn from its own palette */}\n <span />\n <button>x</button>\n </div>'),
|
|
133
165
|
errors: [{ messageId: 'raw', line: 6 }],
|
|
134
166
|
},
|
|
135
167
|
{
|
|
@@ -139,23 +171,23 @@ tester.run('no-raw-element', noRawElement, {
|
|
|
139
171
|
},
|
|
140
172
|
{
|
|
141
173
|
name: 'a marker inside a directive that switches every rule off is refused',
|
|
142
|
-
code: component(' // eslint-disable-next-line -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
174
|
+
code: component(' // eslint-disable-next-line -- @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
|
|
143
175
|
// The directive silences the element's own report; the refusal sits on the directive's line.
|
|
144
176
|
errors: [{ messageId: 'escapeInDirective', line: 3 }],
|
|
145
177
|
},
|
|
146
178
|
{
|
|
147
179
|
name: 'a marker inside a directive that names this rule is refused',
|
|
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>'),
|
|
180
|
+
code: component(' // eslint-disable-next-line rule-to-test/no-raw-element -- @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
|
|
149
181
|
errors: [{ messageId: 'escapeInDirective', line: 3 }],
|
|
150
182
|
},
|
|
151
183
|
{
|
|
152
184
|
name: "the token lint's note for another rule is not an escape of this one",
|
|
153
|
-
code: component(' // eslint-disable-next-line no-console -- @estiva-escape: its hand-written type waits for that\n <button>x</button>'),
|
|
185
|
+
code: component(' // eslint-disable-next-line no-console -- @estiva-escape(no-raw-element): its hand-written type waits for that\n <button>x</button>'),
|
|
154
186
|
errors: [{ messageId: 'raw', line: 4 }],
|
|
155
187
|
},
|
|
156
188
|
{
|
|
157
189
|
name: 'with reportEscapes on, a sanctioned escape is reported for the count',
|
|
158
|
-
code: component(' // @estiva-escape: a preview drawn from its own palette\n <button>x</button>'),
|
|
190
|
+
code: component(' // @estiva-escape(no-raw-element): a preview drawn from its own palette\n <button>x</button>'),
|
|
159
191
|
settings: { estiva: { reportEscapes: true } },
|
|
160
192
|
errors: [{ messageId: 'escaped', data: { reason: 'a preview drawn from its own palette' }, line: 3 }],
|
|
161
193
|
},
|
|
@@ -111,7 +111,43 @@ function isOn(element: JSXOpeningElement, name: string): boolean {
|
|
|
111
111
|
return attr !== undefined && literal(attr) !== false
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | null
|
|
114
|
+
type Found = { report: 'raw'; element: string; part: RawElementPart } | { report: 'noPart'; element: string } | { report: 'clickable'; element: string } | null
|
|
115
|
+
|
|
116
|
+
/** The pointer's press, as a handler on a plain element makes it a control (R5). */
|
|
117
|
+
const CLICK_HANDLERS = ['onClick', 'onMouseDown', 'onMouseUp', 'onPointerDown', 'onPointerUp', 'onDoubleClick']
|
|
118
|
+
|
|
119
|
+
interface Expression {
|
|
120
|
+
type: string
|
|
121
|
+
[key: string]: unknown
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `e.stopPropagation()` or `e.preventDefault()`: a handler that only keeps the click from what is behind. */
|
|
125
|
+
function onlyStops(handler: Expression | undefined): boolean {
|
|
126
|
+
if (!handler || (handler.type !== 'ArrowFunctionExpression' && handler.type !== 'FunctionExpression')) return false
|
|
127
|
+
const body = handler.body as Expression
|
|
128
|
+
const calls = body.type === 'BlockStatement' ? (body.body as Expression[]).map((s) => (s.type === 'ExpressionStatement' ? (s.expression as Expression) : s)) : [body]
|
|
129
|
+
return (
|
|
130
|
+
calls.length > 0 &&
|
|
131
|
+
calls.every((call) => {
|
|
132
|
+
if (call.type !== 'CallExpression') return false
|
|
133
|
+
const callee = call.callee as Expression
|
|
134
|
+
const property = callee.type === 'MemberExpression' ? (callee.property as Expression) : undefined
|
|
135
|
+
return property?.type === 'Identifier' && (property.name === 'stopPropagation' || property.name === 'preventDefault')
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A plain element made clickable by hand: the first press handler that does more than stop the click. */
|
|
141
|
+
function clickHandler(element: JSXOpeningElement): string | undefined {
|
|
142
|
+
for (const name of CLICK_HANDLERS) {
|
|
143
|
+
const attr = attribute(element, name)
|
|
144
|
+
if (!attr) continue
|
|
145
|
+
const value = attr.value
|
|
146
|
+
const handler = value?.type === 'JSXExpressionContainer' ? (value.expression as Expression | undefined) : undefined
|
|
147
|
+
if (!onlyStops(handler)) return name
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
115
151
|
|
|
116
152
|
/** What this element is to the rule: a raw element with a part, one with none yet, or none of its business. */
|
|
117
153
|
export function classify(element: JSXOpeningElement): Found {
|
|
@@ -131,7 +167,14 @@ export function classify(element: JSXOpeningElement): Found {
|
|
|
131
167
|
return part ? { report: 'raw', element: shown, part } : { report: 'noPart', element: shown }
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name))
|
|
170
|
+
if (!Object.hasOwn(RAW_ELEMENT_PARTS, name)) {
|
|
171
|
+
// A plain element with a press handler is a button drawn by hand (Katerina's ruling R5,
|
|
172
|
+
// 25 September): no focus, no keys, no look anybody keeps in step. `<option>` and
|
|
173
|
+
// `<summary>` live inside a control the rule already reads.
|
|
174
|
+
if (!/^[a-z][a-z0-9]*$/.test(name) || name === 'option' || name === 'summary') return null
|
|
175
|
+
const handler = clickHandler(element)
|
|
176
|
+
return handler ? { report: 'clickable', element: `<${name} ${handler}>` } : null
|
|
177
|
+
}
|
|
135
178
|
if ((name === 'audio' || name === 'video') && !isOn(element, 'controls')) return null
|
|
136
179
|
if (name === 'img' && !attribute(element, 'usemap') && !attribute(element, 'useMap')) return null
|
|
137
180
|
|
|
@@ -150,6 +193,10 @@ export function classify(element: JSXOpeningElement): Found {
|
|
|
150
193
|
* that says so: the part is made in the package, not by hand in an app
|
|
151
194
|
* (Katerina, 16 September).
|
|
152
195
|
*
|
|
196
|
+
* A plain element with a press handler (`<div onClick>`) is a control too, and
|
|
197
|
+
* is refused naming `Button` (R5, 25 September); a handler that only stops the
|
|
198
|
+
* click reaching what is behind passes.
|
|
199
|
+
*
|
|
153
200
|
* Only a JSX element written with a lowercase name. `<Button>`,
|
|
154
201
|
* `<Foo.button>` and `createElement('button')` are not this rule's business.
|
|
155
202
|
*/
|
|
@@ -160,6 +207,8 @@ export const noRawElement: Rule.RuleModule = {
|
|
|
160
207
|
schema: [],
|
|
161
208
|
messages: {
|
|
162
209
|
raw: 'Use `{{use}}` from @estiva-app/ui instead of a raw {{element}}.{{more}}',
|
|
210
|
+
clickable:
|
|
211
|
+
'A clickable {{element}} is a button made by hand: it takes no focus and answers no key. Use `Button` from @estiva-app/ui; for a whole card that opens something, `Card` with `href`. A handler that only stops the click (`e.stopPropagation()`) is fine.',
|
|
163
212
|
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
213
|
...ESCAPE_MESSAGES,
|
|
165
214
|
},
|
|
@@ -172,7 +221,8 @@ export const noRawElement: Rule.RuleModule = {
|
|
|
172
221
|
const found = classify(element)
|
|
173
222
|
if (!found) return
|
|
174
223
|
if (isEscaped(context, element)) return
|
|
175
|
-
if (found.report === '
|
|
224
|
+
if (found.report === 'clickable') context.report({ loc: element.loc, messageId: 'clickable', data: { element: found.element } })
|
|
225
|
+
else if (found.report === 'noPart') context.report({ loc: element.loc, messageId: 'noPart', data: { element: found.element } })
|
|
176
226
|
else context.report({ loc: element.loc, messageId: 'raw', data: { element: found.element, use: found.part.use, more: found.part.more ?? '' } })
|
|
177
227
|
},
|
|
178
228
|
}
|