@estiva-app/ui 0.30.0 → 0.31.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 +5 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +1 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +119 -22
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-handmade-header.d.ts +17 -0
- package/dist/eslint/no-handmade-header.d.ts.map +1 -0
- package/dist/gates/app-checks.d.ts +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/index.js +4 -2
- package/dist/gates/index.js.map +2 -2
- package/dist/index.js +142 -123
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/CommandPalette.tsx +1 -0
- package/src/ContainerHeader.mdx +3 -2
- package/src/DialogShell.tsx +22 -17
- package/src/eslint/index.test.ts +13 -9
- package/src/eslint/index.ts +4 -0
- package/src/eslint/no-handmade-header.test.ts +89 -0
- package/src/eslint/no-handmade-header.ts +153 -0
- package/src/gates/app-checks.ts +5 -3
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
package/package.json
CHANGED
package/registry.json
CHANGED
package/src/CommandPalette.tsx
CHANGED
|
@@ -553,6 +553,7 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
|
|
|
553
553
|
|
|
554
554
|
return (
|
|
555
555
|
<div ref={frameRef} tabIndex={-1} onKeyDown={onKeyDown} onFocus={measure} onBlur={measure} onInput={measure} className="flex min-h-0 flex-col outline-none">
|
|
556
|
+
{/* @estiva-escape: the palette's own input bar, the twin of its search row; the level chip stands where the field was */}
|
|
556
557
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border-subtle px-5">
|
|
557
558
|
{icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
|
|
558
559
|
<LevelChip chip={chip} onBack={back} />
|
package/src/ContainerHeader.mdx
CHANGED
|
@@ -23,10 +23,11 @@ with a hairline under it.
|
|
|
23
23
|
- One section inside a column → **SectionHeader**, the 32px row with a
|
|
24
24
|
SectionLabel.
|
|
25
25
|
- The top of the whole app → **TopBar**.
|
|
26
|
-
- A dialog's title → **DialogShell
|
|
26
|
+
- A dialog's title → **DialogShell**, which draws this bar with the dialog's own 18px title.
|
|
27
27
|
|
|
28
28
|
- A row you draw yourself at the top of a column — your own padding and a line
|
|
29
|
-
under it → this part. Its numbers are every column's.
|
|
29
|
+
under it → this part. Its numbers are every column's. The lint refuses the
|
|
30
|
+
hand-made row (`estiva/no-handmade-header`).
|
|
30
31
|
- A whole list column, header and scrolling list → **ListColumn**, which draws
|
|
31
32
|
this bar at its top.
|
|
32
33
|
|
package/src/DialogShell.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Dialog } from '@base-ui/react/dialog'
|
|
|
3
3
|
import { AlertDialog } from '@base-ui/react/alert-dialog'
|
|
4
4
|
import { IconX } from '@tabler/icons-react'
|
|
5
5
|
import { cn } from './cn'
|
|
6
|
+
import { ContainerHeader } from './ContainerHeader'
|
|
6
7
|
import { IconButton } from './IconButton'
|
|
7
8
|
import { ScrollArea } from './ScrollArea'
|
|
8
9
|
|
|
@@ -113,14 +114,17 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
|
|
|
113
114
|
className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden outline-none"
|
|
114
115
|
style={{ width }}
|
|
115
116
|
>
|
|
116
|
-
{/* Header */}
|
|
117
|
-
<
|
|
118
|
-
{
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
117
|
+
{/* Header: the package's one header bar, with the dialog's own title in it (UIG-22). */}
|
|
118
|
+
<ContainerHeader
|
|
119
|
+
title={
|
|
120
|
+
headerContent ?? (
|
|
121
|
+
<Parts.Title className="text-h4 text-text-primary" render={<span />}>
|
|
122
|
+
{title}
|
|
123
|
+
</Parts.Title>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
actions={
|
|
127
|
+
/*
|
|
124
128
|
The ✕ IS the `Close` part now, rather than a button that calls
|
|
125
129
|
`onClose` beside one (stage 4, 2026-09-07). Stage 3 could not do
|
|
126
130
|
this: an `IconButton` carrying a `tooltip` returned the tooltip
|
|
@@ -131,15 +135,16 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
|
|
|
131
135
|
What it buys: the dialog closes through its own state machine,
|
|
132
136
|
so the ✕, Escape and the outside press are one path with one
|
|
133
137
|
reason attached, instead of one of the three going around.
|
|
134
|
-
*/
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
*/
|
|
139
|
+
<Parts.Close
|
|
140
|
+
render={
|
|
141
|
+
<IconButton tooltip="Close" aria-label="Close">
|
|
142
|
+
<IconX size={16} stroke={1.5} />
|
|
143
|
+
</IconButton>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
143
148
|
|
|
144
149
|
{/* Body. With a cap it scrolls in the package's bar: the cap goes
|
|
145
150
|
on the viewport (ScrollArea's rule — on the region the viewport
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -37,6 +37,7 @@ describe('the plugin object', () => {
|
|
|
37
37
|
'no-raw-element',
|
|
38
38
|
'no-rebuilt-behaviour',
|
|
39
39
|
'no-restyled-part',
|
|
40
|
+
'no-handmade-header',
|
|
40
41
|
'raw-element-outside-a-wrapper',
|
|
41
42
|
'no-hand-rolled-behaviour',
|
|
42
43
|
'component-has-a-page',
|
|
@@ -56,17 +57,18 @@ describe('the plugin object', () => {
|
|
|
56
57
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
57
58
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
58
59
|
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
59
|
-
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error' })
|
|
60
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error' })
|
|
60
61
|
}
|
|
61
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part'])
|
|
62
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header'])
|
|
62
63
|
})
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* `no-restyled-part`
|
|
66
|
-
* none of its own parts either (Katerina, 17
|
|
67
|
-
*
|
|
66
|
+
* `no-restyled-part` (UIG-9) and `no-handmade-header` (UIG-22) are in both
|
|
67
|
+
* sets: the package restyles none of its own parts either (Katerina, 17
|
|
68
|
+
* September), and draws no header bar by hand ("my review isn't enough",
|
|
69
|
+
* 23 September). Every other inward rule still reaches no app.
|
|
68
70
|
*/
|
|
69
|
-
it('gives this package its own set, as errors, and only the shared
|
|
71
|
+
it('gives this package its own set, as errors, and only the shared rules reach an app config', () => {
|
|
70
72
|
expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
71
73
|
expect(estiva.configs.package.rules).toEqual({
|
|
72
74
|
'estiva/raw-element-outside-a-wrapper': 'error',
|
|
@@ -74,9 +76,10 @@ describe('the plugin object', () => {
|
|
|
74
76
|
'estiva/component-has-a-page': 'error',
|
|
75
77
|
'estiva/component-has-a-story': 'error',
|
|
76
78
|
'estiva/no-restyled-part': 'error',
|
|
79
|
+
'estiva/no-handmade-header': 'error',
|
|
77
80
|
})
|
|
78
81
|
expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
|
|
79
|
-
for (const id of PACKAGE_RULE_IDS.filter((id) => id !== 'estiva/no-restyled-part')) {
|
|
82
|
+
for (const id of PACKAGE_RULE_IDS.filter((id) => id !== 'estiva/no-restyled-part' && id !== 'estiva/no-handmade-header')) {
|
|
80
83
|
expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
|
|
81
84
|
expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
|
|
82
85
|
}
|
|
@@ -143,8 +146,8 @@ describe('countGates', () => {
|
|
|
143
146
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
144
147
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
145
148
|
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
146
|
-
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none })
|
|
147
|
-
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none })
|
|
149
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none })
|
|
150
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none })
|
|
148
151
|
})
|
|
149
152
|
|
|
150
153
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
@@ -165,6 +168,7 @@ describe('countGates', () => {
|
|
|
165
168
|
expect(countGates([])).toEqual({
|
|
166
169
|
rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 },
|
|
167
170
|
'estiva/no-restyled-part': { errors: 0, warnings: 0, escapes: 0 },
|
|
171
|
+
'estiva/no-handmade-header': { errors: 0, warnings: 0, escapes: 0 },
|
|
168
172
|
},
|
|
169
173
|
disabled: [],
|
|
170
174
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { createRequire } from 'node:module'
|
|
|
20
20
|
import type { ESLint, Linter } from 'eslint'
|
|
21
21
|
import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
22
22
|
import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
23
|
+
import { noHandmadeHeader } from './no-handmade-header'
|
|
23
24
|
import { noRawElement } from './no-raw-element'
|
|
24
25
|
import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
|
|
25
26
|
import { noRestyledPart } from './no-restyled-part'
|
|
@@ -44,6 +45,7 @@ const appRules = {
|
|
|
44
45
|
'no-raw-element': noRawElement,
|
|
45
46
|
'no-rebuilt-behaviour': noRebuiltBehaviour,
|
|
46
47
|
'no-restyled-part': noRestyledPart,
|
|
48
|
+
'no-handmade-header': noHandmadeHeader,
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/**
|
|
@@ -67,6 +69,7 @@ const packageRules = {
|
|
|
67
69
|
'component-has-a-page': componentHasAPage,
|
|
68
70
|
'component-has-a-story': componentHasAStory,
|
|
69
71
|
'no-restyled-part': noRestyledPart,
|
|
72
|
+
'no-handmade-header': noHandmadeHeader,
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
const rules = { ...appRules, ...packageRules }
|
|
@@ -97,6 +100,7 @@ plugin.configs.recommended = {
|
|
|
97
100
|
[`${PLUGIN_KEY}/no-raw-element`]: 'error',
|
|
98
101
|
[`${PLUGIN_KEY}/no-rebuilt-behaviour`]: 'error',
|
|
99
102
|
[`${PLUGIN_KEY}/no-restyled-part`]: 'error',
|
|
103
|
+
[`${PLUGIN_KEY}/no-handmade-header`]: 'error',
|
|
100
104
|
},
|
|
101
105
|
}
|
|
102
106
|
plugin.configs.strict = {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint'
|
|
2
|
+
import { parser } from 'typescript-eslint'
|
|
3
|
+
import { describe, it } from 'vitest'
|
|
4
|
+
import { noHandmadeHeader } from './no-handmade-header'
|
|
5
|
+
|
|
6
|
+
RuleTester.describe = describe
|
|
7
|
+
RuleTester.it = it
|
|
8
|
+
RuleTester.itOnly = it.only
|
|
9
|
+
|
|
10
|
+
const tester = new RuleTester({
|
|
11
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
12
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const component = (body: string) => `export function Probe({ title, open }: { title: string; open: boolean }) {\n return (\n${body}\n )\n}\n`
|
|
16
|
+
const handmade = [{ messageId: 'handmade' }]
|
|
17
|
+
|
|
18
|
+
tester.run('no-handmade-header', noHandmadeHeader, {
|
|
19
|
+
valid: [
|
|
20
|
+
{ name: 'the part itself', code: component('<div className="flex h-full flex-col">\n<ContainerHeader title="Files" />\n<div />\n</div>') },
|
|
21
|
+
{
|
|
22
|
+
name: "a group heading inside a list: SectionHeader's row, not a column's bar (Ship's issue groups)",
|
|
23
|
+
code: component('<section className="flex flex-col gap-px">\n<h3 className="flex h-8 items-center gap-2 px-3"><StatusLabel value="todo" /><span>{3}</span></h3>\n<div />\n</section>'),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "a label strip on a card (Peek's huddle card)",
|
|
27
|
+
code: component('<Card className="flex flex-col h-[130px]">\n<div className="h-6 bg-bg-inset px-2 flex items-center shrink-0"><span className="text-caption">Huddle</span></div>\n<div />\n</Card>'),
|
|
28
|
+
},
|
|
29
|
+
{ name: 'a row a person presses is a row, not a header', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center px-3 py-2" onClick={() => {}}><span>{title}</span></div>\n<div />\n</div>') },
|
|
30
|
+
{ name: 'a row with no title: a toolbar, a field', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center gap-1 px-3"><Button>One</Button><Button>Two</Button></div>\n<div />\n</div>') },
|
|
31
|
+
{ name: 'a row with nothing after it heads nothing', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n</div>') },
|
|
32
|
+
{ name: 'a row with no side padding of its own', code: component('<div className="flex h-full flex-col">\n<div className="flex items-center py-2"><span>{title}</span></div>\n<div />\n</div>') },
|
|
33
|
+
{ name: 'a column of rows', code: component('<div className="flex h-full flex-col">\n<div className="flex flex-col px-3"><span>{title}</span></div>\n<div />\n</div>') },
|
|
34
|
+
{
|
|
35
|
+
name: 'an escape with its reason',
|
|
36
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: a print header that must not follow the theme */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
invalid: [
|
|
40
|
+
{
|
|
41
|
+
// The Folders page's right pane, at the commit before 3dc663b.
|
|
42
|
+
name: 'a pane that heads itself around an editable name',
|
|
43
|
+
code: component('<div className="flex h-full flex-col">\n{/* ContainerHeader draws a static title, so this pane heads itself. */}\n<div className="flex items-center gap-2 px-3 py-2">\n<div className="min-w-0 flex-1"><EditableText value={title} label="Folder name" /></div>\n<IconButton icon={<IconX />} label="Close" />\n</div>\n<div className="min-h-0 flex-1" />\n</div>'),
|
|
44
|
+
errors: handmade,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
// The Folders page's thread pane, at the commit before 3dc663b.
|
|
48
|
+
name: 'a pane that heads itself with a title and a caption',
|
|
49
|
+
code: component('<div className="flex h-full flex-col">\n<div className="flex items-center gap-2 px-3 py-2">\n<div className="min-w-0 flex-1">\n<p className="truncate text-body-2-strong text-text-primary">{title ?? "Conversation"}</p>\n{open && <p className="truncate text-caption text-text-muted">In Ship</p>}\n</div>\n</div>\n<div />\n</div>'),
|
|
50
|
+
errors: handmade,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "ContainerHeader's exact class list, typed by hand (Peek's FilesPanel and ThreadPanel)",
|
|
54
|
+
code: component('<div className="flex flex-col h-full">\n<div className="h-12 shrink-0 flex items-center justify-between pl-5 pr-4 py-2 border-b border-border-subtle">\n<span className="text-body-2-strong text-text-primary">Files</span>\n</div>\n<div />\n</div>'),
|
|
55
|
+
errors: handmade,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'the shape, not the class names: other padding, other gap, a heading, a column that grows',
|
|
59
|
+
code: component('<aside className="flex grow flex-col">\n<header className="flex items-baseline gap-4 pl-6 pr-2 py-3"><h2>{title}</h2></header>\n<ul />\n</aside>'),
|
|
60
|
+
errors: handmade,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "a header bar's height and hairline, whatever the column is",
|
|
64
|
+
code: component('<section className="flex flex-col">\n<div className="flex h-11 items-center border-b px-4"><span>{title}</span></div>\n<div />\n</section>'),
|
|
65
|
+
errors: handmade,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'a bar shown only while something is open',
|
|
69
|
+
code: component('<div className="flex h-full flex-col">\n{open && (\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n)}\n<div />\n</div>'),
|
|
70
|
+
errors: handmade,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'classes put together with cn() and a variant',
|
|
74
|
+
code: component('<div className={cn("flex flex-col", "h-full")}>\n<div className={cn("flex items-center", "md:px-4 py-2")}><span>{title}</span></div>\n<div />\n</div>'),
|
|
75
|
+
errors: handmade,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// DialogShell's bar, before it drew ContainerHeader: a part's title, named as one.
|
|
79
|
+
name: "a bar whose title is a part's Title",
|
|
80
|
+
code: component('<div className="flex flex-col">\n<div className="h-12 flex items-center justify-between pl-5 pr-4 border-b shrink-0">\n{open ?? <Parts.Title render={<span />}>{title}</Parts.Title>}\n</div>\n<div />\n</div>'),
|
|
81
|
+
errors: handmade,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'an escape with no reason escapes nothing',
|
|
85
|
+
code: component('<div className="flex h-full flex-col">\n{/* @estiva-escape: */}\n<div className="flex items-center px-3 py-2"><span>{title}</span></div>\n<div />\n</div>'),
|
|
86
|
+
errors: [{ messageId: 'escapeWithoutReason' }, { messageId: 'handmade' }],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
|
|
4
|
+
/** The JSX an element is made of, as far as this rule reads it. */
|
|
5
|
+
interface Node {
|
|
6
|
+
type: string
|
|
7
|
+
[key: string]: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface JSXElement extends Node {
|
|
11
|
+
type: 'JSXElement'
|
|
12
|
+
openingElement: {
|
|
13
|
+
name: { type: string; name?: string; object?: { name?: string }; property?: { name?: string } }
|
|
14
|
+
attributes: { type: string; name?: { name?: string }; value?: Node | null }[]
|
|
15
|
+
loc: NonNullable<Rule.Node['loc']>
|
|
16
|
+
range: [number, number]
|
|
17
|
+
}
|
|
18
|
+
children: Node[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isElement = (n: unknown): n is JSXElement => typeof n === 'object' && n !== null && (n as Node).type === 'JSXElement'
|
|
22
|
+
|
|
23
|
+
function tagOf(el: JSXElement): string {
|
|
24
|
+
const n = el.openingElement.name
|
|
25
|
+
if (n.type === 'JSXIdentifier') return n.name ?? ''
|
|
26
|
+
if (n.type === 'JSXMemberExpression') return `${n.object?.name ?? ''}.${n.property?.name ?? ''}`
|
|
27
|
+
return ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attribute(el: JSXElement, name: string) {
|
|
31
|
+
return el.openingElement.attributes.find((a) => a.type === 'JSXAttribute' && a.name?.name === name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every string literal under a node: a `cn()` call, a template, a ternary. */
|
|
35
|
+
function strings(node: Node | null | undefined, out: string[] = []): string[] {
|
|
36
|
+
if (!node || typeof node !== 'object') return out
|
|
37
|
+
if (node.type === 'Literal' && typeof node.value === 'string') out.push(node.value)
|
|
38
|
+
else if (node.type === 'TemplateLiteral') for (const q of node.quasis as { value: { cooked: string | null } }[]) out.push(q.value.cooked ?? '')
|
|
39
|
+
for (const [key, value] of Object.entries(node)) {
|
|
40
|
+
if (key === 'parent' || key === 'loc' || key === 'range') continue
|
|
41
|
+
if (Array.isArray(value)) for (const v of value) strings(v as Node, out)
|
|
42
|
+
else if (value && typeof value === 'object' && (value as Node).type) strings(value as Node, out)
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The classes an element is given, variants taken off: `md:px-3` is `px-3`. */
|
|
48
|
+
function classesOf(el: JSXElement): string[] {
|
|
49
|
+
const value = attribute(el, 'className')?.value
|
|
50
|
+
if (!value) return []
|
|
51
|
+
const text = value.type === 'Literal' ? String(value.value) : strings(value.expression as Node).join(' ')
|
|
52
|
+
return text
|
|
53
|
+
.split(/\s+/)
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.map((c) => c.replace(/^(?:[^:\s[\]]+:)+/, ''))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An element's children as a reader sees them: whitespace and comments gone,
|
|
60
|
+
* `{open && <X />}` read as `<X />`.
|
|
61
|
+
*/
|
|
62
|
+
function meaningful(el: JSXElement): Node[] {
|
|
63
|
+
const out: Node[] = []
|
|
64
|
+
for (const child of el.children) {
|
|
65
|
+
if (child.type === 'JSXText') {
|
|
66
|
+
if (String(child.value).trim()) out.push(child)
|
|
67
|
+
} else if (child.type === 'JSXExpressionContainer') {
|
|
68
|
+
const e = child.expression as Node
|
|
69
|
+
if (e.type === 'JSXEmptyExpression') continue
|
|
70
|
+
if (e.type === 'LogicalExpression' && isElement(e.right)) out.push(e.right as Node)
|
|
71
|
+
else if (isElement(e)) out.push(e)
|
|
72
|
+
else out.push(child)
|
|
73
|
+
} else if (child.type === 'JSXElement' || child.type === 'JSXFragment') out.push(child)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parts that draw a line of text: a title made of one is still a title. */
|
|
79
|
+
const TEXT_PARTS = new Set(['EditableText', 'Person', 'Breadcrumb', 'Link', 'SectionLabel', 'InlineChip'])
|
|
80
|
+
/** What a title written as an expression looks like: a name, a field, a template, a fallback. */
|
|
81
|
+
const VALUE_EXPRESSIONS = new Set(['Identifier', 'MemberExpression', 'TemplateLiteral', 'Literal', 'LogicalExpression', 'ConditionalExpression', 'ChainExpression', 'CallExpression'])
|
|
82
|
+
|
|
83
|
+
/** Whether the row carries a title: text, a value, a heading, or a part that draws text, three boxes deep at most. */
|
|
84
|
+
function carriesTitle(el: JSXElement, depth = 0): boolean {
|
|
85
|
+
if (depth > 3) return false
|
|
86
|
+
for (const child of meaningful(el)) {
|
|
87
|
+
if (child.type === 'JSXText') return true
|
|
88
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
89
|
+
if (VALUE_EXPRESSIONS.has((child.expression as Node).type)) return true
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (!isElement(child)) continue
|
|
93
|
+
const tag = tagOf(child)
|
|
94
|
+
// `Dialog.Title`, `Parts.Title`: a part's title, named as one.
|
|
95
|
+
if (/^h[1-6]$/.test(tag) || TEXT_PARTS.has(tag) || /\.(?:Title|Heading)$/.test(tag)) return true
|
|
96
|
+
// A part's own words are its label, a Button's "Save": only plain boxes are looked into.
|
|
97
|
+
if (/^[a-z]/.test(tag) && carriesTitle(child, depth + 1)) return true
|
|
98
|
+
}
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ROW = (c: string[]) => c.includes('flex') && !c.includes('flex-col')
|
|
103
|
+
const OWN_SIDE_PADDING = (c: string[]) => c.some((x) => /^-?(?:p|px|pl|pr|ps|pe)-/.test(x))
|
|
104
|
+
/** A column that takes the height it is given: the pane a header bar tops. */
|
|
105
|
+
const FILLS = (c: string[]) => c.includes('flex-col') && c.some((x) => /^(?:h-full|h-screen|flex-1|grow|min-h-0|h-dvh)$/.test(x))
|
|
106
|
+
/** A header bar's own geometry: a column bar's height, or the hairline under it. */
|
|
107
|
+
const BAR = (c: string[]) => c.some((x) => /^(?:h|min-h)-(?:10|11|12|13|14|\[(?:4[0-9]|5[0-6])px\])$/.test(x)) || c.some((x) => /^border-b(?:-|$)/.test(x))
|
|
108
|
+
/** A row a person presses is a row of its own kind, not a header. */
|
|
109
|
+
const INTERACTIVE = ['onClick', 'href', 'role', 'onKeyDown', 'onPointerDown']
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* UIG-22: a column's header bar built by hand.
|
|
113
|
+
*
|
|
114
|
+
* The general rules cannot see this one: a hand-made header is made entirely of
|
|
115
|
+
* legal parts, a box, some text, some padding. So this rule reads the shape.
|
|
116
|
+
* A row, first in a column, with side padding of its own and a title in it,
|
|
117
|
+
* where the column fills the height it is given, or the row has a header bar's
|
|
118
|
+
* height or the hairline under one. `ContainerHeader` is that bar: 48px, the
|
|
119
|
+
* title, the column's buttons at the right edge, a hairline under it.
|
|
120
|
+
*
|
|
121
|
+
* What it leaves alone, on purpose: a group heading inside a list (the row one
|
|
122
|
+
* section starts with), a label strip on a card, and a row a person presses.
|
|
123
|
+
* Only a lowercase element is judged: a part is already a part.
|
|
124
|
+
*/
|
|
125
|
+
export const noHandmadeHeader: Rule.RuleModule = {
|
|
126
|
+
meta: {
|
|
127
|
+
type: 'problem',
|
|
128
|
+
docs: { description: "A column's header bar drawn by hand where ContainerHeader is the part" },
|
|
129
|
+
schema: [],
|
|
130
|
+
messages: {
|
|
131
|
+
handmade:
|
|
132
|
+
"A column's header bar drawn by hand. Use `ContainerHeader` from @estiva-app/ui: 48px, the title, the column's buttons at the right edge (`actions`), a hairline under it.",
|
|
133
|
+
...ESCAPE_MESSAGES,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
create(context) {
|
|
137
|
+
return {
|
|
138
|
+
JSXElement(node: Rule.Node) {
|
|
139
|
+
const column = node as unknown as JSXElement
|
|
140
|
+
const kids = meaningful(column)
|
|
141
|
+
const first = kids[0]
|
|
142
|
+
if (kids.length < 2 || !isElement(first) || !/^[a-z]/.test(tagOf(first))) return
|
|
143
|
+
const own = classesOf(first)
|
|
144
|
+
if (!ROW(own) || !OWN_SIDE_PADDING(own)) return
|
|
145
|
+
if (INTERACTIVE.some((name) => attribute(first, name))) return
|
|
146
|
+
if (!FILLS(classesOf(column)) && !BAR(own)) return
|
|
147
|
+
if (!carriesTitle(first)) return
|
|
148
|
+
if (isEscaped(context, first.openingElement)) return
|
|
149
|
+
context.report({ loc: first.openingElement.loc, messageId: 'handmade' })
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface AppCheckOptions {
|
|
|
31
31
|
chain?: { ref: string; owner: boolean }
|
|
32
32
|
/** The token lint configs the app has, relative to the app. */
|
|
33
33
|
tokenConfigs?: string[]
|
|
34
|
-
/** The
|
|
34
|
+
/** The part a hand-made column header should name (UIG-22): `ContainerHeader`. */
|
|
35
35
|
headers?: string[]
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -70,7 +70,7 @@ function all(h: GateHelpers, steps: (() => CheckResult | Promise<CheckResult>)[]
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader'
|
|
73
|
+
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader'] }: AppCheckOptions): GateTicket[] {
|
|
74
74
|
const WEB = app === '.' ? '' : `${app.replace(/\/$/, '')}/`
|
|
75
75
|
const cwd = app === '.' ? undefined : app
|
|
76
76
|
const PROBE = 'src/components/__gates_probe__.tsx'
|
|
@@ -219,7 +219,9 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
219
219
|
{ what: "a plain tailwind-merge is an error naming the package's cn()", run: tokenProbe("import { twMerge } from 'tailwind-merge'\nexport const merged = twMerge('p-2', 'p-3')\n", 'error', 'cn()', 'src/lib/__gates_probe__.ts') },
|
|
220
220
|
]),
|
|
221
221
|
ticket('UIG-22', [
|
|
222
|
-
|
|
222
|
+
// The Folders pane's own row, at the commit before peek 3dc663b.
|
|
223
|
+
{ what: 'a hand-made column header is an error naming ContainerHeader', run: all(h, headers.map((name) => probe(component('<div className="flex h-full flex-col"><div className="flex items-center gap-2 px-3 py-2"><span>Folders</span></div><div /></div>'), 'error', name)), 'a hand-made column header is an error naming ContainerHeader') },
|
|
224
|
+
{ what: 'a group heading inside a list is left alone', run: probe(component('<section className="flex flex-col gap-px"><h3 className="flex h-8 items-center gap-2 px-3"><span>Todo</span></h3><div /></section>'), 'none') },
|
|
223
225
|
]),
|
|
224
226
|
ticket('UIG-23', [
|
|
225
227
|
{ what: 'a hand-made empty line is an error naming EmptyState', run: probe("export function Probe({ items }: { items: string[] }) {\n return <div>{items.length === 0 ? <p className=\"text-text-secondary\">Nothing here</p> : items.map((i) => <span key={i}>{i}</span>)}</div>\n}\n", 'error', 'EmptyState') },
|
|
@@ -59,7 +59,7 @@ describe('create-estiva-app', () => {
|
|
|
59
59
|
|
|
60
60
|
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
61
61
|
const count = JSON.parse(files['.gates-count.json'])
|
|
62
|
-
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
62
|
+
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
63
63
|
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
|
|
64
64
|
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
65
65
|
})
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -153,7 +153,7 @@ describe('writeGateCount', () => {
|
|
|
153
153
|
const written = JSON.parse(readFileSync(join(dir, '.gates-count.json'), 'utf8'))
|
|
154
154
|
expect(written).toMatchObject({ schemaVersion: 1, repo: 'probe' })
|
|
155
155
|
expect(written.rules['estiva/no-raw-element']).toEqual({ errors: 0, warnings: 0, escapes: 1 })
|
|
156
|
-
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part'])
|
|
156
|
+
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header'])
|
|
157
157
|
|
|
158
158
|
expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
|
|
159
159
|
|