@estiva-app/ui 0.14.0 → 0.15.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/ChipInput.d.ts +14 -1
- package/dist/ChipInput.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/index.d.ts +45 -0
- package/dist/eslint/index.d.ts.map +1 -0
- package/dist/eslint/index.js +118 -0
- package/dist/eslint/index.js.map +7 -0
- package/dist/eslint/no-raw-button.d.ts +11 -0
- package/dist/eslint/no-raw-button.d.ts.map +1 -0
- package/dist/index.js +5 -5
- package/dist/index.js.map +2 -2
- package/package.json +5 -1
- package/src/Avatar.tsx +1 -1
- package/src/AvatarGroup.tsx +1 -1
- 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/Select.test.tsx +20 -3
- package/src/eslint/escape.ts +112 -0
- package/src/eslint/index.test.ts +82 -0
- package/src/eslint/index.ts +100 -0
- package/src/eslint/no-raw-button.test.ts +118 -0
- package/src/eslint/no-raw-button.ts +39 -0
- package/stories/TokensPage.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"types": "./dist/index.d.ts",
|
|
20
20
|
"default": "./dist/index.js"
|
|
21
21
|
},
|
|
22
|
+
"./eslint": {
|
|
23
|
+
"types": "./dist/eslint/index.d.ts",
|
|
24
|
+
"default": "./dist/eslint/index.js"
|
|
25
|
+
},
|
|
22
26
|
"./tailwind-preset": "./tailwind-preset.js",
|
|
23
27
|
"./tokens.css": "./tokens.css",
|
|
24
28
|
"./package.json": "./package.json",
|
package/src/Avatar.tsx
CHANGED
|
@@ -118,7 +118,7 @@ export function Avatar({ src, name, alt = '', size = 36, label: spoken, classNam
|
|
|
118
118
|
*/
|
|
119
119
|
className="w-full h-full flex items-center justify-center font-semibold leading-none"
|
|
120
120
|
style={{
|
|
121
|
-
/* eslint-disable no-restricted-syntax -- the per-person palette (the note at
|
|
121
|
+
/* eslint-disable no-restricted-syntax -- @estiva-escape: the per-person palette (the note at
|
|
122
122
|
the top): eight hues picked from the name, the one ink that reads on all
|
|
123
123
|
of them, and a size that follows `size`. None of it can be a token. */
|
|
124
124
|
// The one ink colour that reads on all eight hues, which are a
|
package/src/AvatarGroup.tsx
CHANGED
|
@@ -55,7 +55,7 @@ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
|
|
|
55
55
|
<span
|
|
56
56
|
key={i}
|
|
57
57
|
className="relative flex rounded-sm"
|
|
58
|
-
/* eslint-disable-next-line no-restricted-syntax -- the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
|
|
58
|
+
/* eslint-disable-next-line no-restricted-syntax -- @estiva-escape: the ring's width is the `ring` prop, so it cannot be a class; its colour is the surface token */
|
|
59
59
|
style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
|
|
60
60
|
>
|
|
61
61
|
{/* Each face says whose it is: a stack stands on its own, with no
|
package/src/ChipInput.mdx
CHANGED
|
@@ -65,6 +65,15 @@ import { ChipInput } from '@estiva-app/ui'
|
|
|
65
65
|
<ChipInput aria-labelledby="to-label" value={chosen} onChange={setChosen} options={directory} />
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
- **`InputChip` on its own** names its ✕ `Remove <label>`. Where the ✕ does
|
|
69
|
+
something else, say what with `removeLabel` (a scope chip: "Leave Ship").
|
|
70
|
+
- To cap a long label, give the chip a `max-w-*` and `truncate`: the label is
|
|
71
|
+
cut and the ✕ keeps its size. `truncate` is off unless asked, because
|
|
72
|
+
cutting clips a letter's soft edge by up to 4px at 1x even when the label
|
|
73
|
+
fits.
|
|
74
|
+
|
|
75
|
+
<Canvas of={ChipInputStories.ALongLabelCut} />
|
|
76
|
+
|
|
68
77
|
## Keys
|
|
69
78
|
|
|
70
79
|
| Input | What happens |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { useState } from 'react'
|
|
3
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
4
|
import { Avatar } from './Avatar'
|
|
4
5
|
import { ChipInput, InputChip, type ChipInputOption } from './ChipInput'
|
|
5
6
|
|
|
@@ -97,3 +98,20 @@ export const TheChipItself: Story = {
|
|
|
97
98
|
</div>
|
|
98
99
|
),
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
/** A chip capped in width, its long label cut, and a ✕ named for what it does. */
|
|
103
|
+
export const ALongLabelCut: Story = {
|
|
104
|
+
parameters: { controls: { disable: true } },
|
|
105
|
+
render: () => (
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<InputChip
|
|
108
|
+
label="A label much longer than the chip may be"
|
|
109
|
+
leading={<IconSquareRounded size={16} stroke={1.5} className="text-text-secondary" />}
|
|
110
|
+
onRemove={() => {}}
|
|
111
|
+
removeLabel="Leave the label"
|
|
112
|
+
truncate
|
|
113
|
+
className="max-w-44"
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
),
|
|
117
|
+
}
|
package/src/ChipInput.test.tsx
CHANGED
|
@@ -215,4 +215,22 @@ describe('InputChip', () => {
|
|
|
215
215
|
render(<InputChip label="Label" />)
|
|
216
216
|
expect(screen.queryByRole('button')).toBeNull()
|
|
217
217
|
})
|
|
218
|
+
|
|
219
|
+
it('names its ✕ with removeLabel when given', async () => {
|
|
220
|
+
const user = userEvent.setup()
|
|
221
|
+
const onRemove = vi.fn()
|
|
222
|
+
render(<InputChip label="Label" onRemove={onRemove} removeLabel="Leave Label" />)
|
|
223
|
+
await user.click(screen.getByRole('button', { name: 'Leave Label' }))
|
|
224
|
+
expect(onRemove).toHaveBeenCalledTimes(1)
|
|
225
|
+
expect(screen.queryByRole('button', { name: 'Remove Label' })).toBeNull()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('cuts its label only when asked, and then the ✕ never gives way', () => {
|
|
229
|
+
const { rerender } = render(<InputChip label="Label" onRemove={() => {}} />)
|
|
230
|
+
expect(screen.getByText('Label').className).not.toMatch(/\btruncate\b/)
|
|
231
|
+
expect(screen.getByRole('button').className).not.toMatch(/\bshrink-0\b/)
|
|
232
|
+
rerender(<InputChip label="Label" onRemove={() => {}} truncate className="max-w-[160px]" />)
|
|
233
|
+
expect(screen.getByText('Label').className).toMatch(/\bmin-w-0\b.*\btruncate\b/)
|
|
234
|
+
expect(screen.getByRole('button').className).toMatch(/\bshrink-0\b/)
|
|
235
|
+
})
|
|
218
236
|
})
|
package/src/ChipInput.tsx
CHANGED
|
@@ -38,14 +38,27 @@ export interface InputChipProps {
|
|
|
38
38
|
leading?: ReactNode
|
|
39
39
|
/** Draws the ✕; absent, the chip is display-only. */
|
|
40
40
|
onRemove?: () => void
|
|
41
|
+
/**
|
|
42
|
+
* The ✕'s name for a screen reader. `Remove <label>` when not given. A chip
|
|
43
|
+
* whose ✕ does something other than remove it names what it does — a
|
|
44
|
+
* launcher's scope chip leaves the scope ("Leave Ship").
|
|
45
|
+
*/
|
|
46
|
+
removeLabel?: string
|
|
47
|
+
/**
|
|
48
|
+
* Cut a long label with an ellipsis once the chip is capped, by a
|
|
49
|
+
* `max-w-*` on `className`. Off unless asked: cutting clips up to 4px of a
|
|
50
|
+
* letter's soft edge at 1x even when the label fits (measured 2026-09-15),
|
|
51
|
+
* so a chip that is never capped keeps every pixel.
|
|
52
|
+
*/
|
|
53
|
+
truncate?: boolean
|
|
41
54
|
className?: string
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
export function InputChip({ label, leading, onRemove, className }: InputChipProps) {
|
|
57
|
+
export function InputChip({ label, leading, onRemove, removeLabel, truncate, className }: InputChipProps) {
|
|
45
58
|
return (
|
|
46
59
|
<div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
|
|
47
60
|
{leading && <span className="flex shrink-0 items-center">{leading}</span>}
|
|
48
|
-
<span className={CHIP_LABEL}>{label}</span>
|
|
61
|
+
<span className={cn(CHIP_LABEL, truncate && 'min-w-0 truncate')}>{label}</span>
|
|
49
62
|
{/* Base UI's `Button`, as every button in the package is (D6). Base UI
|
|
50
63
|
has no chip of its own — its only chips are `Combobox.Chip` and
|
|
51
64
|
`ChipRemove`, which throw outside a combobox — so the ✕ is the one
|
|
@@ -57,8 +70,9 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
|
|
|
57
70
|
e.stopPropagation()
|
|
58
71
|
onRemove()
|
|
59
72
|
}}
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
// In a capped chip the label gives way, never the ✕.
|
|
74
|
+
className={cn(CHIP_REMOVE, truncate && 'shrink-0')}
|
|
75
|
+
aria-label={removeLabel ?? `Remove ${label}`}
|
|
62
76
|
>
|
|
63
77
|
<IconX size={10} stroke={1.5} />
|
|
64
78
|
</BaseButton>
|
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
|
})
|
|
@@ -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,82 @@
|
|
|
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, { countGates, 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('has no-raw-button, and both configs switch it on as an error under estiva/', () => {
|
|
36
|
+
expect(Object.keys(estiva.rules)).toEqual(['no-raw-button'])
|
|
37
|
+
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
38
|
+
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
39
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-button': 'error' })
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('an app lint with configs.recommended', () => {
|
|
45
|
+
it('reports a raw <button>, naming Button', async () => {
|
|
46
|
+
const [result] = await lint(component(' <button type="button">x</button>'))
|
|
47
|
+
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
48
|
+
['estiva/no-raw-button', 2, 'Use `Button` from @estiva-app/ui instead of a raw <button>.'],
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('passes the same element under an escape', async () => {
|
|
53
|
+
const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
54
|
+
expect(result.messages).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('countGates', () => {
|
|
59
|
+
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
60
|
+
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
61
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 0 } })
|
|
62
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-button': { errors: 1, warnings: 0, escapes: 1 } })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
66
|
+
const results = await lint(component(' // eslint-disable-next-line estiva/no-raw-button\n <button>x</button>'), [countMode])
|
|
67
|
+
const count = countGates(results)
|
|
68
|
+
expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 0, warnings: 0, escapes: 0 })
|
|
69
|
+
expect(count.disabled).toEqual([{ filePath: results[0].filePath, line: 4, ruleId: 'estiva/no-raw-button' }])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('counts a marker inside that directive as an error of the rule', async () => {
|
|
73
|
+
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])
|
|
74
|
+
const count = countGates(results)
|
|
75
|
+
expect(count.rules['estiva/no-raw-button']).toEqual({ errors: 1, warnings: 0, escapes: 0 })
|
|
76
|
+
expect(count.disabled).toHaveLength(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('lists every rule of the plugin, even with nothing found', () => {
|
|
80
|
+
expect(countGates([])).toEqual({ rules: { 'estiva/no-raw-button': { errors: 0, warnings: 0, escapes: 0 } }, disabled: [] })
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { noRawButton } from './no-raw-button'
|
|
22
|
+
|
|
23
|
+
export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
|
|
24
|
+
|
|
25
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
26
|
+
|
|
27
|
+
/** The name the configs register the plugin under, so every rule id is `estiva/<rule>`. */
|
|
28
|
+
export const PLUGIN_KEY = 'estiva'
|
|
29
|
+
|
|
30
|
+
const rules = {
|
|
31
|
+
'no-raw-button': noRawButton,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const plugin = {
|
|
35
|
+
meta: { name: '@estiva-app/ui/eslint', version },
|
|
36
|
+
rules,
|
|
37
|
+
configs: {} as { recommended: Linter.Config; strict: Linter.Config },
|
|
38
|
+
} satisfies ESLint.Plugin
|
|
39
|
+
|
|
40
|
+
const ruleIds = Object.keys(rules).map((name) => `${PLUGIN_KEY}/${name}`)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `recommended` switches every rule on at the level it was ruled at: an error
|
|
44
|
+
* blocks, a warning is reported and never blocks. `strict` makes every rule an
|
|
45
|
+
* error. With one rule, an error, the two are the same today; they part when
|
|
46
|
+
* the first warning-level rule arrives (UIG-25).
|
|
47
|
+
*/
|
|
48
|
+
plugin.configs.recommended = {
|
|
49
|
+
name: '@estiva-app/ui/recommended',
|
|
50
|
+
plugins: { [PLUGIN_KEY]: plugin },
|
|
51
|
+
rules: { [`${PLUGIN_KEY}/no-raw-button`]: 'error' },
|
|
52
|
+
}
|
|
53
|
+
plugin.configs.strict = {
|
|
54
|
+
name: '@estiva-app/ui/strict',
|
|
55
|
+
plugins: { [PLUGIN_KEY]: plugin },
|
|
56
|
+
rules: Object.fromEntries(ruleIds.map((id) => [id, 'error'])),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default plugin
|
|
60
|
+
|
|
61
|
+
export interface GateRuleCount {
|
|
62
|
+
errors: number
|
|
63
|
+
warnings: number
|
|
64
|
+
escapes: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GateCount {
|
|
68
|
+
/** Per rule id, including rules with nothing to report. */
|
|
69
|
+
rules: Record<string, GateRuleCount>
|
|
70
|
+
/**
|
|
71
|
+
* Reports of these rules that an `eslint-disable` directive silenced. Not
|
|
72
|
+
* an escape: a gate fails on any of these (docs/GATES.md §16, S4).
|
|
73
|
+
*/
|
|
74
|
+
disabled: { filePath: string; line: number; ruleId: string }[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Count what a lint of these rules found, for `.gates-count.json` (seam S3).
|
|
79
|
+
*
|
|
80
|
+
* Run the lint with `settings: { estiva: { reportEscapes: true } }` for the
|
|
81
|
+
* escapes to be counted; without it they are silent and count 0. A marker
|
|
82
|
+
* with no reason, or inside a directive, counts as an error of its rule.
|
|
83
|
+
*/
|
|
84
|
+
export function countGates(results: ESLint.LintResult[]): GateCount {
|
|
85
|
+
const counts: Record<string, GateRuleCount> = Object.fromEntries(ruleIds.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }]))
|
|
86
|
+
const disabled: GateCount['disabled'] = []
|
|
87
|
+
for (const result of results) {
|
|
88
|
+
for (const message of result.messages) {
|
|
89
|
+
const count = message.ruleId ? counts[message.ruleId] : undefined
|
|
90
|
+
if (!count) continue
|
|
91
|
+
if (message.messageId === 'escaped') count.escapes += 1
|
|
92
|
+
else if (message.severity === 2) count.errors += 1
|
|
93
|
+
else count.warnings += 1
|
|
94
|
+
}
|
|
95
|
+
for (const message of result.suppressedMessages ?? []) {
|
|
96
|
+
if (message.ruleId && counts[message.ruleId]) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { rules: counts, disabled }
|
|
100
|
+
}
|
|
@@ -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
|
+
}
|
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) {
|