@estiva-app/ui 0.13.1 → 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/Avatar.d.ts +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +14 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/Divider.d.ts +5 -2
- package/dist/Divider.d.ts.map +1 -1
- package/dist/EmptyState.d.ts +3 -0
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Property.d.ts +7 -0
- package/dist/Property.d.ts.map +1 -1
- package/dist/Reaction.d.ts +12 -6
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/TextInput.d.ts +10 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Toast.d.ts +19 -6
- package/dist/Toast.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +1 -1
- package/dist/Toolbar.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 +276 -249
- package/dist/index.js.map +3 -3
- package/package.json +5 -1
- package/src/AttachmentCard.tsx +14 -14
- package/src/Avatar.mdx +7 -2
- package/src/Avatar.picture.test.tsx +60 -0
- package/src/Avatar.stories.tsx +1 -1
- package/src/Avatar.tsx +50 -14
- package/src/AvatarGroup.tsx +1 -0
- package/src/Breadcrumb.tsx +2 -2
- package/src/Checkbox.tsx +1 -1
- package/src/Chip.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/DialogShell.stories.tsx +1 -1
- package/src/Divider.mdx +3 -2
- package/src/Divider.test.tsx +15 -0
- package/src/Divider.tsx +11 -9
- package/src/EditableText.stories.tsx +2 -2
- package/src/EmptyState.mdx +3 -2
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +15 -0
- package/src/EmptyState.tsx +4 -1
- package/src/IconButton.stories.tsx +1 -1
- package/src/Kbd.tsx +3 -3
- package/src/Menu.stories.tsx +2 -2
- package/src/Menu.tsx +3 -3
- package/src/Popover.stories.tsx +1 -1
- package/src/PreviewCard.stories.tsx +5 -5
- package/src/Property.mdx +8 -0
- package/src/Property.stories.tsx +4 -4
- package/src/Property.test.tsx +44 -0
- package/src/Property.tsx +23 -8
- package/src/RailItem.tsx +1 -1
- package/src/Reaction.mdx +14 -3
- package/src/Reaction.test.tsx +62 -0
- package/src/Reaction.tsx +21 -12
- package/src/ReactionPicker.tsx +1 -1
- package/src/SectionLabel.stories.tsx +1 -1
- package/src/SectionLabel.tsx +1 -1
- package/src/Select.test.tsx +20 -3
- package/src/Select.tsx +3 -3
- package/src/TextInput.mdx +6 -0
- package/src/TextInput.stories.tsx +45 -0
- package/src/TextInput.test.tsx +38 -0
- package/src/TextInput.tsx +16 -4
- package/src/Textarea.tsx +1 -1
- package/src/Toast.mdx +28 -4
- package/src/Toast.stories.tsx +12 -1
- package/src/Toast.test.tsx +137 -0
- package/src/Toast.tsx +127 -83
- package/src/Toolbar.tsx +4 -2
- package/src/cn.ts +1 -1
- 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/Choosing.mdx +1 -0
- package/stories/TokensPage.tsx +6 -1
- package/tailwind-preset.js +7 -0
|
@@ -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/Choosing.mdx
CHANGED
|
@@ -34,6 +34,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
|
|
|
34
34
|
| You need | Reach for |
|
|
35
35
|
|---|---|
|
|
36
36
|
| One line | **TextInput** |
|
|
37
|
+
| One line in a dense row, beside small selects | **TextInput** `size="small"` — 24px, the small Select's height |
|
|
37
38
|
| Several lines | **Textarea** |
|
|
38
39
|
| Typing that filters a list | **SearchInput** |
|
|
39
40
|
| A title edited in place | **EditableText** |
|
package/stories/TokensPage.tsx
CHANGED
|
@@ -134,6 +134,7 @@ const TYPE_GROUPS: { label: string; blurb: string; tokens: TypeToken[] }[] = [
|
|
|
134
134
|
{ label: 'Headings', blurb: 'h1 is a page title, h2 a section, h3 a card or dialog title, h4 a row title, h5 a small label.', tokens: ['h1', 'h2', 'h3', 'h4', 'h5'].map((k) => ({ key: k, cls: `text-${k}` })) },
|
|
135
135
|
{ label: 'Body', blurb: 'body-1 for reading, body-2 for the interface, caption for what sits beside it.', tokens: ['body-1', 'body-2', 'body-2-strong', 'caption'].map((k) => ({ key: k, cls: `text-${k}` })) },
|
|
136
136
|
{ label: 'Controls', blurb: 'The sizes controls are set in, so a button, a field and a chip read the same everywhere.', tokens: ['btn-default', 'btn-small', 'input-label', 'input-value', 'input-helper', 'chip', 'menu'].map((k) => ({ key: k, cls: `text-${k}` })) },
|
|
137
|
+
{ label: 'A theme\'s smaller label', blurb: 'small is a size and nothing else. Under signal: or ship: it shrinks the token beside it to 10px and keeps that token\'s line height and weight. Spacing, where a label wants some, is tracking-wide or tracking-widest.', tokens: [{ key: 'small', cls: 'text-small' }] },
|
|
137
138
|
]
|
|
138
139
|
|
|
139
140
|
const RADII = [
|
|
@@ -152,7 +153,7 @@ const RADII = [
|
|
|
152
153
|
function Section({ label, blurb, children }: { label: string; blurb: string; children: ReactNode }) {
|
|
153
154
|
return (
|
|
154
155
|
<section className="mt-10 first:mt-0">
|
|
155
|
-
<h2 className="text-h5 uppercase tracking-
|
|
156
|
+
<h2 className="text-h5 uppercase tracking-widest text-text-secondary">{label}</h2>
|
|
156
157
|
<p className="mt-1.5 max-w-[640px] text-body-2 text-text-secondary">{blurb}</p>
|
|
157
158
|
<div className="mt-3">{children}</div>
|
|
158
159
|
</section>
|
|
@@ -171,6 +172,9 @@ function Users({ names }: { names: string[] }) {
|
|
|
171
172
|
function SwatchBox({ token }: { token: Token }) {
|
|
172
173
|
const v = `var(${token.cssVar})`
|
|
173
174
|
const base = 'h-6 w-10 shrink-0 rounded-md'
|
|
175
|
+
/* eslint-disable no-restricted-syntax -- @estiva-escape: this page draws every token from its CSS
|
|
176
|
+
variable, so a swatch shows the value the theme holds, including a token no
|
|
177
|
+
class spells yet. */
|
|
174
178
|
switch (token.swatch) {
|
|
175
179
|
case 'fill':
|
|
176
180
|
return <div className={`${base} border border-border-subtle`} style={{ background: v }} />
|
|
@@ -190,6 +194,7 @@ function SwatchBox({ token }: { token: Token }) {
|
|
|
190
194
|
case 'drop-shadow':
|
|
191
195
|
return <div className="my-1 h-8 w-12 shrink-0 rounded-md bg-bg-surface" style={{ filter: `drop-shadow(${v})` }} />
|
|
192
196
|
}
|
|
197
|
+
/* eslint-enable no-restricted-syntax */
|
|
193
198
|
}
|
|
194
199
|
|
|
195
200
|
function TokenRow({ token, utilities }: { token: Token; utilities: string }) {
|
package/tailwind-preset.js
CHANGED
|
@@ -100,6 +100,13 @@ export default {
|
|
|
100
100
|
'input-value': ['14px', { lineHeight: '140%', letterSpacing: '0', fontWeight: '400' }],
|
|
101
101
|
'input-helper': ['12px', { lineHeight: '120%', letterSpacing: '0', fontWeight: '400' }],
|
|
102
102
|
'chip': ['11px', { lineHeight: '110%', letterSpacing: '0', fontWeight: '500' }],
|
|
103
|
+
// A size and nothing else (Katerina, 2026-09-15, UIG-28): a theme's
|
|
104
|
+
// smaller label. Under `signal:` or `ship:` it shrinks the token beside
|
|
105
|
+
// it to 10px and keeps that token's line height and weight
|
|
106
|
+
// (`text-caption signal:text-small`). Letter spacing, where a label
|
|
107
|
+
// wants some, is Tailwind's own step: `tracking-wide`, `tracking-widest`.
|
|
108
|
+
// Alone, it takes its line height and weight from the parent.
|
|
109
|
+
'small': ['10px'],
|
|
103
110
|
},
|
|
104
111
|
borderRadius: {
|
|
105
112
|
none: '0px',
|