@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
package/src/Select.test.tsx
CHANGED
|
@@ -32,6 +32,23 @@ function Controlled({ initial = 'todo', ...rest }: { initial?: string } & Partia
|
|
|
32
32
|
return <Select value={value} onChange={setValue} options={STATUSES} ariaLabel="Status" {...rest} />
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The open list, once it has focus.
|
|
37
|
+
*
|
|
38
|
+
* The list is findable as soon as it opens, but Base UI moves focus into it an
|
|
39
|
+
* animation frame later. A key pressed before that frame goes to the trigger:
|
|
40
|
+
* Tab then moves focus into the list instead of closing it, and an arrow or End
|
|
41
|
+
* is lost. A person presses keys at a list that has focus, so these tests wait
|
|
42
|
+
* for it. CI failed "Tab closes the list" this way on 15 September (PR #36);
|
|
43
|
+
* with every frame 100ms late the three tests that use this failed 3 runs of 3,
|
|
44
|
+
* focus measured still on the trigger when Tab was pressed.
|
|
45
|
+
*/
|
|
46
|
+
async function focusedList() {
|
|
47
|
+
const list = await screen.findByRole('listbox')
|
|
48
|
+
await waitFor(() => expect(list.contains(document.activeElement)).toBe(true))
|
|
49
|
+
return list
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
describe('Select', () => {
|
|
36
53
|
it('is a button naming itself, showing the chosen label', () => {
|
|
37
54
|
render(<Controlled initial="in_progress" />)
|
|
@@ -65,7 +82,7 @@ describe('Select', () => {
|
|
|
65
82
|
const trigger = screen.getByRole('combobox', { name: 'Status' })
|
|
66
83
|
trigger.focus()
|
|
67
84
|
await user.keyboard('{ArrowDown}')
|
|
68
|
-
expect(await
|
|
85
|
+
expect(await focusedList()).toBeTruthy()
|
|
69
86
|
await user.keyboard('{ArrowDown}{Enter}')
|
|
70
87
|
expect(onChange).toHaveBeenCalledWith('in_progress')
|
|
71
88
|
expect(document.activeElement).toBe(trigger)
|
|
@@ -130,7 +147,7 @@ describe('Select', () => {
|
|
|
130
147
|
render(<Controlled initial="in_progress" />)
|
|
131
148
|
screen.getByRole('combobox', { name: 'Status' }).focus()
|
|
132
149
|
await user.keyboard('{ArrowDown}')
|
|
133
|
-
expect(await
|
|
150
|
+
expect(await focusedList()).toBeTruthy()
|
|
134
151
|
const highlighted = () => screen.getAllByRole('option').find((o) => o.getAttribute('data-highlighted') !== null)?.textContent
|
|
135
152
|
await user.keyboard('{End}')
|
|
136
153
|
expect(highlighted()).toContain('Done')
|
|
@@ -142,7 +159,7 @@ describe('Select', () => {
|
|
|
142
159
|
const user = userEvent.setup()
|
|
143
160
|
render(<Controlled />)
|
|
144
161
|
await user.click(screen.getByRole('combobox', { name: 'Status' }))
|
|
145
|
-
expect(await
|
|
162
|
+
expect(await focusedList()).toBeTruthy()
|
|
146
163
|
await user.tab()
|
|
147
164
|
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
|
|
148
165
|
})
|
package/src/Toast.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
|
2
2
|
import { Toast as BaseToast } from '@base-ui/react/toast'
|
|
3
3
|
import { IconAlertCircle, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
|
4
|
+
import { Button } from './Button'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -78,14 +79,6 @@ const ICON_STYLES: Record<ToastType, string> = {
|
|
|
78
79
|
error: 'signal:text-error-default',
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
const ACTION_BORDER_STYLES: Record<ToastType, string> = {
|
|
82
|
-
success: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
83
|
-
brand: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
84
|
-
neutral: 'border border-border-default',
|
|
85
|
-
warning: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
86
|
-
error: 'signal:border signal:border-border-default signal:hover:border-border-strong',
|
|
87
|
-
}
|
|
88
|
-
|
|
89
82
|
const LABEL_CLASSES = 'text-body-2 text-text-primary whitespace-nowrap'
|
|
90
83
|
|
|
91
84
|
const pillClassName = (type: ToastType, hasAction: boolean, className?: string) =>
|
|
@@ -98,20 +91,11 @@ const pillClassName = (type: ToastType, hasAction: boolean, className?: string)
|
|
|
98
91
|
className,
|
|
99
92
|
)
|
|
100
93
|
|
|
101
|
-
const actionClassName = (type: ToastType) =>
|
|
102
|
-
cn(
|
|
103
|
-
'h-6 flex items-center justify-center gap-1 px-1 py-1 rounded-md shrink-0 transition-colors',
|
|
104
|
-
ACTION_BORDER_STYLES[type],
|
|
105
|
-
type === 'neutral' ? 'hover:border-border-strong' : 'hover:opacity-80',
|
|
106
|
-
)
|
|
107
|
-
|
|
108
94
|
function LeadingIcon({ type }: { type: ToastType }) {
|
|
109
95
|
const Icon = ICONS[type]
|
|
110
96
|
return <Icon size={16} stroke={1.5} className={cn('text-text-primary shrink-0', ICON_STYLES[type])} />
|
|
111
97
|
}
|
|
112
98
|
|
|
113
|
-
const ACTION_LABEL_CLASSES = 'text-btn-small text-text-primary whitespace-nowrap'
|
|
114
|
-
|
|
115
99
|
/**
|
|
116
100
|
* One toast, drawn in place. What the provider shows is this pill; draw it
|
|
117
101
|
* yourself only where a page needs a toast's look without its timing — a
|
|
@@ -126,9 +110,9 @@ export function Toast({ label, type = 'neutral', leadingIcon = true, actionLabel
|
|
|
126
110
|
<span className={LABEL_CLASSES}>{label}</span>
|
|
127
111
|
</div>
|
|
128
112
|
{hasAction && (
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
</
|
|
113
|
+
<Button variant="outlined" size="small" onClick={onAction}>
|
|
114
|
+
{actionLabel}
|
|
115
|
+
</Button>
|
|
132
116
|
)}
|
|
133
117
|
</div>
|
|
134
118
|
)
|
|
@@ -229,10 +213,14 @@ function ToastList() {
|
|
|
229
213
|
onAction?.()
|
|
230
214
|
close(toast.id)
|
|
231
215
|
}}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
216
|
+
// The same Button the toast drawn in place uses, told to *be* the
|
|
217
|
+
// Base UI action rather than sit inside one (Katerina, 16 September).
|
|
218
|
+
render={
|
|
219
|
+
<Button variant="outlined" size="small">
|
|
220
|
+
{actionLabel}
|
|
221
|
+
</Button>
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
236
224
|
)}
|
|
237
225
|
</BaseToast.Root>
|
|
238
226
|
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { AST, Rule } from 'eslint'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The escape marker (UIG-3, seam S4 in docs/GATES.md §16): one written reason
|
|
5
|
+
* why one element stays as it is.
|
|
6
|
+
*
|
|
7
|
+
* A line comment directly above the element,
|
|
8
|
+
*
|
|
9
|
+
* // @estiva-escape: <reason>
|
|
10
|
+
*
|
|
11
|
+
* or, where the element is a JSX child, the JSX comment on the line above,
|
|
12
|
+
*
|
|
13
|
+
* {/* @estiva-escape: <reason> *\/}
|
|
14
|
+
*
|
|
15
|
+
* with at least ten characters of reason, spaces not counted.
|
|
16
|
+
*
|
|
17
|
+
* Not `eslint-disable`. A directive switches a rule off without saying it was
|
|
18
|
+
* meant, and the count of escapes (`.gates-count.json`) could not see it. A
|
|
19
|
+
* marker written inside a directive that switches one of these rules off is
|
|
20
|
+
* reported for that reason.
|
|
21
|
+
*
|
|
22
|
+
* The token lint's older notes (`eslint-disable-next-line <rule> -- @estiva-escape:
|
|
23
|
+
* <reason>`, Katerina's ruling A2 of 15 September) are not read here: they
|
|
24
|
+
* escape the token lint's rules, which are not this plugin's, and they stay as
|
|
25
|
+
* they are (Katerina, 15 September).
|
|
26
|
+
*/
|
|
27
|
+
export const ESCAPE_MARKER = '@estiva-escape'
|
|
28
|
+
|
|
29
|
+
/** Characters of reason, spaces not counted. */
|
|
30
|
+
export const MIN_REASON = 10
|
|
31
|
+
|
|
32
|
+
/** The key under ESLint's `settings` this plugin reads. */
|
|
33
|
+
export const SETTINGS_KEY = 'estiva'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every rule spreads these into its `meta.messages`, because `isEscaped`
|
|
37
|
+
* reports through the rule that called it.
|
|
38
|
+
*/
|
|
39
|
+
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.`,
|
|
42
|
+
escaped: 'Escaped: {{reason}}',
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
export interface EstivaSettings {
|
|
46
|
+
/**
|
|
47
|
+
* Report every sanctioned escape as a message with the id `escaped`, so a
|
|
48
|
+
* count can read them. Off in the lint anyone runs; on only in the count.
|
|
49
|
+
*/
|
|
50
|
+
reportEscapes?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Located {
|
|
54
|
+
loc?: AST.SourceLocation | null
|
|
55
|
+
range?: [number, number]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?(?=\s|$)([^]*)$/
|
|
59
|
+
|
|
60
|
+
/** The rules a directive names: what comes before its ` -- ` description. None means every rule. */
|
|
61
|
+
function directiveRules(rest: string): string[] {
|
|
62
|
+
const dashes = rest.search(/(?:^|\s)--(?:\s|$)/)
|
|
63
|
+
return (dashes === -1 ? rest : rest.slice(0, dashes)).split(/[\s,]+/).filter(Boolean)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Only spaces and a JSX expression's closing brace between the marker and the element. */
|
|
67
|
+
const BETWEEN = /^[\s}]*$/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Whether the element at `node` carries a valid escape on the line above.
|
|
71
|
+
*
|
|
72
|
+
* Every rule of this plugin calls it before it reports. It reports, through
|
|
73
|
+
* that rule, a marker with too short a reason and a marker inside an
|
|
74
|
+
* eslint-disable directive for this rule; either way the element is not
|
|
75
|
+
* escaped, so the rule reports it too.
|
|
76
|
+
*/
|
|
77
|
+
export function isEscaped(context: Rule.RuleContext, node: Located): boolean {
|
|
78
|
+
const { sourceCode } = context
|
|
79
|
+
if (!node.loc || !node.range) return false
|
|
80
|
+
const line = node.loc.start.line
|
|
81
|
+
const nodeStart = node.range[0]
|
|
82
|
+
|
|
83
|
+
const comment = sourceCode
|
|
84
|
+
.getAllComments()
|
|
85
|
+
.find((c) => c.loc && c.range && c.loc.end.line === line - 1 && BETWEEN.test(sourceCode.text.slice(c.range[1], nodeStart)))
|
|
86
|
+
if (!comment?.loc) return false
|
|
87
|
+
|
|
88
|
+
const at = comment.value.indexOf(ESCAPE_MARKER)
|
|
89
|
+
if (at === -1) return false
|
|
90
|
+
|
|
91
|
+
const directive = DIRECTIVE.exec(comment.value)
|
|
92
|
+
if (directive) {
|
|
93
|
+
const rules = directiveRules(directive[1])
|
|
94
|
+
// A directive for other rules (the token lint's notes) is not an escape of this one.
|
|
95
|
+
if (rules.length > 0 && !rules.includes(context.id)) return false
|
|
96
|
+
context.report({ loc: comment.loc, messageId: 'escapeInDirective' })
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const reason = comment.value
|
|
101
|
+
.slice(at + ESCAPE_MARKER.length)
|
|
102
|
+
.replace(/^:/, '')
|
|
103
|
+
.trim()
|
|
104
|
+
if (reason.replace(/\s/g, '').length < MIN_REASON) {
|
|
105
|
+
context.report({ loc: comment.loc, messageId: 'escapeWithoutReason' })
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const settings = context.settings[SETTINGS_KEY] as EstivaSettings | undefined
|
|
110
|
+
if (settings?.reportEscapes) context.report({ loc: comment.loc, messageId: 'escaped', data: { reason } })
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { RuleTester } from 'eslint'
|
|
4
|
+
import { parser } from 'typescript-eslint'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
7
|
+
|
|
8
|
+
RuleTester.describe = describe
|
|
9
|
+
RuleTester.it = it
|
|
10
|
+
RuleTester.itOnly = it.only
|
|
11
|
+
|
|
12
|
+
const tester = new RuleTester({
|
|
13
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
14
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Real files of this package, so the rules are tested against what they read. */
|
|
18
|
+
const src = (name: string) => fileURLToPath(new URL(`../${name}`, import.meta.url))
|
|
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'
|
|
21
|
+
|
|
22
|
+
describe('the files these rules read', () => {
|
|
23
|
+
it('Button has a page and a story beside it, and FieldLine and MenuItem have both without a component file', () => {
|
|
24
|
+
expect(existsSync(src('Button.mdx'))).toBe(true)
|
|
25
|
+
expect(existsSync(src('Button.stories.tsx'))).toBe(true)
|
|
26
|
+
for (const name of ['FieldLine', 'MenuItem']) {
|
|
27
|
+
expect(existsSync(src(`${name}.mdx`))).toBe(true)
|
|
28
|
+
expect(existsSync(src(`${name}.stories.tsx`))).toBe(true)
|
|
29
|
+
// The false positive to avoid: no component file of their own.
|
|
30
|
+
expect(existsSync(src(`${name}.tsx`))).toBe(false)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
tester.run('component-has-a-page', componentHasAPage, {
|
|
36
|
+
valid: [
|
|
37
|
+
{ name: 'a component with its page beside it', code, filename: src('Button.tsx') },
|
|
38
|
+
{ name: 'a story file, which needs no page of its own', code, filename: src('Button.stories.tsx') },
|
|
39
|
+
{ name: 'a test file', code, filename: src('Button.test.tsx') },
|
|
40
|
+
{ name: 'a file that is not a component file at all', code, filename: src('index.ts') },
|
|
41
|
+
{ name: 'a component with its reason at the top', code: escaped, filename: src('NoPageProbe.tsx') },
|
|
42
|
+
],
|
|
43
|
+
invalid: [
|
|
44
|
+
{ name: 'a component with no page', code, filename: src('NoPageProbe.tsx'), errors: [{ messageId: 'missing' }] },
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
tester.run('component-has-a-story', componentHasAStory, {
|
|
49
|
+
valid: [
|
|
50
|
+
{ name: 'a component with its story beside it', code, filename: src('Button.tsx') },
|
|
51
|
+
{ name: 'a story file, which is not itself a component', code, filename: src('Button.stories.tsx') },
|
|
52
|
+
{ name: 'a component with its reason at the top', code: escaped, filename: src('NoStoryProbe.tsx') },
|
|
53
|
+
],
|
|
54
|
+
invalid: [
|
|
55
|
+
{ name: 'a component with no story', code, filename: src('NoStoryProbe.tsx'), errors: [{ messageId: 'missing' }] },
|
|
56
|
+
],
|
|
57
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import type { Rule } from 'eslint'
|
|
3
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A component of the package with no page, and one with no story (UIG-5, P2 and
|
|
7
|
+
* P3).
|
|
8
|
+
*
|
|
9
|
+
* A component nobody can read about is a component nobody uses correctly, and
|
|
10
|
+
* one nobody can look at is one nobody reviews. Both were at zero when these
|
|
11
|
+
* were switched on; they are here so the next component cannot arrive without
|
|
12
|
+
* them.
|
|
13
|
+
*
|
|
14
|
+
* The pair is by file name, beside the component, which is how this package is
|
|
15
|
+
* laid out: `Button.tsx`, `Button.mdx`, `Button.stories.tsx`, `Button.test.tsx`
|
|
16
|
+
* side by side in `src/`.
|
|
17
|
+
*
|
|
18
|
+
* **The false positive to avoid** (UIG-1 confirmed it is real): `FieldLine` and
|
|
19
|
+
* `MenuItem` have a page and a story but no `.tsx` of their own — they are
|
|
20
|
+
* exported from a sibling. These rules read a component file and ask for its
|
|
21
|
+
* page and its story, never the other way round, so a page without a component
|
|
22
|
+
* is not this rule's business.
|
|
23
|
+
*
|
|
24
|
+
* A file that stays without one says why at its top: `// @estiva-escape: <why>`.
|
|
25
|
+
*/
|
|
26
|
+
interface ProgramNode {
|
|
27
|
+
body: { loc?: Rule.Node['loc']; range?: [number, number] }[]
|
|
28
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
29
|
+
range: [number, number]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A component file: a `.tsx` in the package's source that is not a story or a test. */
|
|
33
|
+
function componentFile(filename: string): boolean {
|
|
34
|
+
return filename.endsWith('.tsx') && !/\.(stories|test)\.tsx$/.test(filename)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sibling(filename: string, extension: string): string {
|
|
38
|
+
return filename.replace(/\.tsx$/, extension)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Report on the file's first statement, so the escape is a comment at the top of
|
|
43
|
+
* the file — the only place a reason for a missing page could go.
|
|
44
|
+
*/
|
|
45
|
+
function reportOnFile(context: Rule.RuleContext, program: ProgramNode, messageId: string, data: Record<string, string>): void {
|
|
46
|
+
const anchor = program.body[0] ?? program
|
|
47
|
+
if (anchor.loc && anchor.range && isEscaped(context, { loc: anchor.loc, range: anchor.range })) return
|
|
48
|
+
context.report({ loc: anchor.loc ?? program.loc, messageId, data })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const componentHasAPage: Rule.RuleModule = {
|
|
52
|
+
meta: {
|
|
53
|
+
type: 'problem',
|
|
54
|
+
docs: { description: 'Every component of the package has a usage page beside it' },
|
|
55
|
+
schema: [],
|
|
56
|
+
messages: {
|
|
57
|
+
missing: '{{name}} has no page. Write {{page}} beside it — what it is, when, when not, how, and what it owns — or say why not at the top of the file.',
|
|
58
|
+
...ESCAPE_MESSAGES,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
create(context) {
|
|
62
|
+
return {
|
|
63
|
+
Program(node) {
|
|
64
|
+
const filename = context.filename
|
|
65
|
+
if (!componentFile(filename)) return
|
|
66
|
+
const page = sibling(filename, '.mdx')
|
|
67
|
+
if (existsSync(page)) return
|
|
68
|
+
const name = filename.split(/[\\/]/).pop() ?? filename
|
|
69
|
+
reportOnFile(context, node as unknown as ProgramNode, 'missing', { name, page: page.split(/[\\/]/).pop() ?? page })
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const componentHasAStory: Rule.RuleModule = {
|
|
76
|
+
meta: {
|
|
77
|
+
type: 'problem',
|
|
78
|
+
docs: { description: 'Every component of the package has a story beside it' },
|
|
79
|
+
schema: [],
|
|
80
|
+
messages: {
|
|
81
|
+
missing: '{{name}} has no story. Write {{story}} beside it, so it can be seen and reviewed, or say why not at the top of the file.',
|
|
82
|
+
...ESCAPE_MESSAGES,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
create(context) {
|
|
86
|
+
return {
|
|
87
|
+
Program(node) {
|
|
88
|
+
const filename = context.filename
|
|
89
|
+
if (!componentFile(filename)) return
|
|
90
|
+
const story = sibling(filename, '.stories.tsx')
|
|
91
|
+
if (existsSync(story)) return
|
|
92
|
+
const name = filename.split(/[\\/]/).pop() ?? filename
|
|
93
|
+
reportOnFile(context, node as unknown as ProgramNode, 'missing', { name, story: story.split(/[\\/]/).pop() ?? story })
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { ESLint, type Linter } from 'eslint'
|
|
3
|
+
import { parser } from 'typescript-eslint'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from './index'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The plugin as an app uses it: a flat config with `configs.recommended`,
|
|
9
|
+
* linting text the way Peek's editor hook does (`lintText`). The rule's own
|
|
10
|
+
* cases are in no-raw-button.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
const tsx: Linter.Config = {
|
|
13
|
+
files: ['**/*.tsx'],
|
|
14
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function lint(code: string, extra: Linter.Config[] = []) {
|
|
18
|
+
const eslint = new ESLint({
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
overrideConfigFile: true,
|
|
21
|
+
overrideConfig: [tsx, estiva.configs.recommended, ...extra],
|
|
22
|
+
})
|
|
23
|
+
return eslint.lintText(code, { filePath: 'src/Probe.tsx' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
|
|
27
|
+
const countMode: Linter.Config = { settings: { estiva: { reportEscapes: true } } }
|
|
28
|
+
|
|
29
|
+
describe('the plugin object', () => {
|
|
30
|
+
it('names itself and carries the package version', () => {
|
|
31
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
32
|
+
expect(estiva.meta).toEqual({ name: '@estiva-app/ui/eslint', version: pkg.version })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('carries every rule, the app ones and the inward ones', () => {
|
|
36
|
+
expect(Object.keys(estiva.rules)).toEqual([
|
|
37
|
+
'no-raw-button',
|
|
38
|
+
'raw-element-outside-a-wrapper',
|
|
39
|
+
'no-hand-rolled-behaviour',
|
|
40
|
+
'component-has-a-page',
|
|
41
|
+
'component-has-a-story',
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The apps spread `recommended`. A rule added for the package (UIG-5) must not
|
|
47
|
+
* arrive in Peek or Ship with the next version bump: an app is full of raw
|
|
48
|
+
* elements it may keep until UIG-7, and has no `.mdx` pages at all. This is
|
|
49
|
+
* the test that holds that line — if you add an app-facing rule on purpose,
|
|
50
|
+
* change it deliberately, here.
|
|
51
|
+
*/
|
|
52
|
+
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
53
|
+
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
54
|
+
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
55
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
|
|
56
|
+
}
|
|
57
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-button'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('gives this package its own set, as errors, and it reaches no app config', () => {
|
|
61
|
+
expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
62
|
+
expect(estiva.configs.package.rules).toEqual({
|
|
63
|
+
'estiva/raw-element-outside-a-wrapper': 'error',
|
|
64
|
+
'estiva/no-hand-rolled-behaviour': 'error',
|
|
65
|
+
'estiva/component-has-a-page': 'error',
|
|
66
|
+
'estiva/component-has-a-story': 'error',
|
|
67
|
+
})
|
|
68
|
+
expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
|
|
69
|
+
for (const id of PACKAGE_RULE_IDS) {
|
|
70
|
+
expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
|
|
71
|
+
expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('an app lint with configs.recommended', () => {
|
|
77
|
+
it('reports a raw <button>, naming Button', async () => {
|
|
78
|
+
const [result] = await lint(component(' <button type="button">x</button>'))
|
|
79
|
+
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
80
|
+
['estiva/no-raw-button', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('passes the same element under an escape', async () => {
|
|
85
|
+
const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
86
|
+
expect(result.messages).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('countGates', () => {
|
|
91
|
+
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
92
|
+
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
93
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 0 } })
|
|
94
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 1 } })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
98
|
+
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button\n <button>x</button>'), [countMode])
|
|
99
|
+
const count = countGates(results)
|
|
100
|
+
expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
|
|
101
|
+
expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-button' }])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('counts a marker inside that directive as an error of the rule', async () => {
|
|
105
|
+
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button -- @estiva-escape: a preview drawn from its own palette\n <button>x</button>'), [countMode])
|
|
106
|
+
const count = countGates(results)
|
|
107
|
+
expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
|
|
108
|
+
expect(count.disabled).toHaveLength(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('lists every rule of the plugin, even with nothing found', () => {
|
|
112
|
+
expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-button': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* `@estiva-app/ui/eslint` — the UI Guardrails' lint rules, as a plugin (UIG-3,
|
|
4
|
+
* seam S1 in docs/GATES.md §16).
|
|
5
|
+
*
|
|
6
|
+
* A plugin rather than config, so every app — Peek, Ship, Leaf — gets a new
|
|
7
|
+
* rule through an ordinary version bump, and an app's editor hook runs the
|
|
8
|
+
* very rule code its CI runs.
|
|
9
|
+
*
|
|
10
|
+
* import estiva from '@estiva-app/ui/eslint'
|
|
11
|
+
* export default [{ files: ['src/**\/*.tsx'], ...estiva.configs.recommended }]
|
|
12
|
+
*
|
|
13
|
+
* Register it under `estiva` (the configs do): the rule ids are
|
|
14
|
+
* `estiva/<rule>`, and `countGates` counts those ids.
|
|
15
|
+
*
|
|
16
|
+
* Built on its own by build.mjs, for Node, into `dist/eslint/`; nothing here
|
|
17
|
+
* reaches the components' browser bundle.
|
|
18
|
+
*/
|
|
19
|
+
import { createRequire } from 'node:module'
|
|
20
|
+
import type { ESLint, Linter } from 'eslint'
|
|
21
|
+
import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
22
|
+
import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
23
|
+
import { noRawButton } from './no-raw-button'
|
|
24
|
+
import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
|
|
25
|
+
|
|
26
|
+
export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
|
|
27
|
+
|
|
28
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
29
|
+
|
|
30
|
+
/** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
|
|
31
|
+
export const PLUGIN_KEY = 'estiva'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The rules an **app** runs: they say an app must not build what the package
|
|
35
|
+
* already has. `recommended` and `strict` carry these and only these.
|
|
36
|
+
*/
|
|
37
|
+
const appRules = {
|
|
38
|
+
'no-raw-button': noRawButton,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The rules the **package itself** runs, pointed inward (UIG-5): don't bury a
|
|
43
|
+
* raw element inside a component, don't rebuild what Base UI owns, don't ship a
|
|
44
|
+
* component without a page or a story.
|
|
45
|
+
*
|
|
46
|
+
* They are in `configs.package`, never in `recommended`, on purpose. Peek and
|
|
47
|
+
* Ship spread `recommended`, so a rule added here must not arrive in an app
|
|
48
|
+
* with the next version bump: an app is full of raw elements it is allowed to
|
|
49
|
+
* have until UIG-7, and has no `.mdx` pages at all. `index.test.ts` holds the
|
|
50
|
+
* apps' list to exactly the app rules.
|
|
51
|
+
*/
|
|
52
|
+
const packageRules = {
|
|
53
|
+
'raw-element-outside-a-wrapper': rawElementOutsideAWrapper,
|
|
54
|
+
'no-hand-rolled-behaviour': noHandRolledBehaviour,
|
|
55
|
+
'component-has-a-page': componentHasAPage,
|
|
56
|
+
'component-has-a-story': componentHasAStory,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rules = { ...appRules, ...packageRules }
|
|
60
|
+
|
|
61
|
+
const plugin = {
|
|
62
|
+
meta: { name: '@estiva-app/ui/eslint', version },
|
|
63
|
+
rules,
|
|
64
|
+
configs: {} as { recommended: Linter.Config; strict: Linter.Config; package: Linter.Config },
|
|
65
|
+
} satisfies ESLint.Plugin
|
|
66
|
+
|
|
67
|
+
/** The ids `recommended` and `strict` carry — what an app's gate runs and counts. */
|
|
68
|
+
export const APP_RULE_IDS = Object.keys(appRules).map((name) => `${PLUGIN_KEY}/${name}`)
|
|
69
|
+
/** The ids `package` carries — what this package's own gate runs and counts (UIG-5). */
|
|
70
|
+
export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGIN_KEY}/${name}`)
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `recommended` switches every **app** rule on at the level it was ruled at: an
|
|
74
|
+
* error blocks, a warning is reported and never blocks. `strict` makes every app
|
|
75
|
+
* rule an error. With one rule, an error, the two are the same today; they part
|
|
76
|
+
* when the first warning-level rule arrives (UIG-25).
|
|
77
|
+
*
|
|
78
|
+
* `package` is the inward set (UIG-5), which only this package runs.
|
|
79
|
+
*/
|
|
80
|
+
plugin.configs.recommended = {
|
|
81
|
+
name: '@estiva-app/ui/recommended',
|
|
82
|
+
plugins: { [PLUGIN_KEY]: plugin },
|
|
83
|
+
rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },
|
|
84
|
+
}
|
|
85
|
+
plugin.configs.strict = {
|
|
86
|
+
name: '@estiva-app/ui/strict',
|
|
87
|
+
plugins: { [PLUGIN_KEY]: plugin },
|
|
88
|
+
rules: Object.fromEntries(APP_RULE_IDS.map((id) => [id, 'error'])),
|
|
89
|
+
}
|
|
90
|
+
plugin.configs.package = {
|
|
91
|
+
name: '@estiva-app/ui/package',
|
|
92
|
+
plugins: { [PLUGIN_KEY]: plugin },
|
|
93
|
+
rules: Object.fromEntries(PACKAGE_RULE_IDS.map((id) => [id, 'error'])),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default plugin
|
|
97
|
+
|
|
98
|
+
export interface GateRuleCount {
|
|
99
|
+
errors: number
|
|
100
|
+
warnings: number
|
|
101
|
+
escapes: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface GateCount {
|
|
105
|
+
/** Per rule id, including rules with nothing to report. */
|
|
106
|
+
rules: Record<string, GateRuleCount>
|
|
107
|
+
/**
|
|
108
|
+
* Reports of these rules that an `eslint-disable` directive silenced. Not
|
|
109
|
+
* an escape: a gate fails on any of these (docs/GATES.md §16, S4).
|
|
110
|
+
*/
|
|
111
|
+
disabled: { filePath: string; line: number; ruleId: string }[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Count what a lint of these rules found, for `.gates-count.json` (seam S3).
|
|
116
|
+
*
|
|
117
|
+
* Run the lint with `settings: { estiva: { reportEscapes: true } }` for the
|
|
118
|
+
* escapes to be counted; without it they are silent and count 0. A marker
|
|
119
|
+
* with no reason, or inside a directive, counts as an error of its rule.
|
|
120
|
+
*
|
|
121
|
+
* `seed` is which rules the count lists when they found nothing, and it is the
|
|
122
|
+
* app rules unless a caller says otherwise: an app's count file must not gain
|
|
123
|
+
* rows for the inward rules (UIG-5) that its gate does not run. This package's
|
|
124
|
+
* own count script passes `PACKAGE_RULE_IDS`.
|
|
125
|
+
*/
|
|
126
|
+
export function countGates(results: ESLint.LintResult[], seed: readonly string[] = APP_RULE_IDS): GateCount {
|
|
127
|
+
const counts: Record<string, GateRuleCount> = Object.fromEntries(seed.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))
|
|
128
|
+
const disabled: GateCount['disabled'] = []
|
|
129
|
+
const ours = (ruleId: string | null | undefined): ruleId is string => typeof ruleId === 'string' && ruleId.startsWith(`${PLUGIN_KEY}/`)
|
|
130
|
+
for (const result of results) {
|
|
131
|
+
for (const message of result.messages) {
|
|
132
|
+
// Seeded or not: every rule of this plugin that reported is counted, so a
|
|
133
|
+
// lint of a set the caller did not name is still counted in full.
|
|
134
|
+
if (!ours(message.ruleId)) continue
|
|
135
|
+
const count = (counts[message.ruleId] ??= { errors: 0, warnings: 0, escapes: 0 })
|
|
136
|
+
if (message.messageId === 'escaped') count.escapes += 1
|
|
137
|
+
else if (message.severity === 2) count.errors += 1
|
|
138
|
+
else count.warnings += 1
|
|
139
|
+
}
|
|
140
|
+
for (const message of result.suppressedMessages ?? []) {
|
|
141
|
+
if (ours(message.ruleId)) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { rules: counts, disabled }
|
|
145
|
+
}
|