@estiva-app/ui 0.32.0 → 0.33.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 +6 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +63 -20
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-native-title.d.ts +31 -0
- package/dist/eslint/no-native-title.d.ts.map +1 -0
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/Tooltip.mdx +1 -0
- package/src/eslint/index.test.ts +9 -6
- package/src/eslint/index.ts +8 -3
- package/src/eslint/no-native-title.test.ts +67 -0
- package/src/eslint/no-native-title.ts +72 -0
- package/src/gates/create-app.test.ts +1 -1
- package/src/gates/gates.test.ts +1 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Rule } from 'eslint';
|
|
2
|
+
/**
|
|
3
|
+
* The parts whose `title` is their own: the words of a heading or a dialog,
|
|
4
|
+
* drawn on the page. Every other part hands a `title` on to its element, and
|
|
5
|
+
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
6
|
+
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PARTS_WITH_A_TITLE: readonly ["CollapsibleSection", "ConfirmDialog", "ContainerHeader", "DialogShell", "ListColumn", "SectionHeader"];
|
|
9
|
+
/**
|
|
10
|
+
* UIG-24: the browser's tooltip where ours belongs.
|
|
11
|
+
*
|
|
12
|
+
* A `title` on an element is the browser's hover box: grey, in the system's
|
|
13
|
+
* font, after a delay nobody chose, and never on a touch screen. The package's
|
|
14
|
+
* `WithTooltip` is the tooltip here (and `IconButton`'s `tooltip`, which draws
|
|
15
|
+
* it). The rule reads the attribute, never the page: a tooltip's words are not
|
|
16
|
+
* on the page until it opens.
|
|
17
|
+
*
|
|
18
|
+
* Judged: a `title` on a plain element, and on a part of the package (or an
|
|
19
|
+
* app's wrapper that hands all its props on to one) whose `title` is not its
|
|
20
|
+
* own — `<Button title>` reaches the `<button>`. Not judged, on purpose:
|
|
21
|
+
*
|
|
22
|
+
* - the `<title>` **element**, in an `<svg>` (a label for a screen reader) or
|
|
23
|
+
* a document's head: an element, not an attribute, so the rule never sees it;
|
|
24
|
+
* - a part whose `title` is its own heading (`PARTS_WITH_A_TITLE`);
|
|
25
|
+
* - the app's own components: what one does with a `title` is its own business,
|
|
26
|
+
* and its elements are judged where it draws them;
|
|
27
|
+
* - a story's `title` (`meta = { title: 'Group/Name' }`): an object key, not an
|
|
28
|
+
* attribute.
|
|
29
|
+
*/
|
|
30
|
+
export declare const noNativeTitle: Rule.RuleModule;
|
|
31
|
+
//# sourceMappingURL=no-native-title.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-native-title.d.ts","sourceRoot":"","sources":["../../src/eslint/no-native-title.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAIlC;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,mHAAoH,CAAA;AAMnJ;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,aAAa,EAAE,IAAI,CAAC,UAkChC,CAAA"}
|
package/package.json
CHANGED
package/registry.json
CHANGED
package/src/Tooltip.mdx
CHANGED
|
@@ -104,6 +104,7 @@ It does these by itself. Write none of them around it.
|
|
|
104
104
|
| Floats on top of the page | a portal, or a `z-index` to win | ✓ |
|
|
105
105
|
| Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
|
|
106
106
|
| Says what it is to assistive technology: a tooltip | `role="tooltip"` | ✓ |
|
|
107
|
+
| Is the tooltip: our look, our 300ms, one shared delay | a `title` attribute, the browser's grey box | ✓ (`estiva/no-native-title`) |
|
|
107
108
|
| Opens after the pointer rests, and at once on keyboard focus | a timer, or hover and focus handlers | |
|
|
108
109
|
| Closes on Escape, on a click of its control, and when the pointer leaves | close handlers | |
|
|
109
110
|
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -39,6 +39,7 @@ describe('the plugin object', () => {
|
|
|
39
39
|
'no-restyled-part',
|
|
40
40
|
'no-handmade-header',
|
|
41
41
|
'no-handmade-empty-state',
|
|
42
|
+
'no-native-title',
|
|
42
43
|
'raw-element-outside-a-wrapper',
|
|
43
44
|
'no-hand-rolled-behaviour',
|
|
44
45
|
'component-has-a-page',
|
|
@@ -58,14 +59,14 @@ describe('the plugin object', () => {
|
|
|
58
59
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
59
60
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
60
61
|
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
61
|
-
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error' })
|
|
62
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error', 'estiva/no-restyled-part': 'error', 'estiva/no-handmade-header': 'error', 'estiva/no-handmade-empty-state': 'error', 'estiva/no-native-title': 'error' })
|
|
62
63
|
}
|
|
63
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state'])
|
|
64
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'])
|
|
64
65
|
})
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
68
|
* `no-restyled-part` (UIG-9), `no-handmade-header` (UIG-22) and
|
|
68
|
-
* `no-handmade-empty-state` (UIG-23) are in both sets: the package restyles
|
|
69
|
+
* `no-handmade-empty-state` (UIG-23) and `no-native-title` (UIG-24) are in both sets: the package restyles
|
|
69
70
|
* none of its own parts either (Katerina, 17 September), and draws no header
|
|
70
71
|
* bar or empty state by hand ("my review isn't enough", 23 September). Every
|
|
71
72
|
* other inward rule still reaches no app.
|
|
@@ -80,9 +81,10 @@ describe('the plugin object', () => {
|
|
|
80
81
|
'estiva/no-restyled-part': 'error',
|
|
81
82
|
'estiva/no-handmade-header': 'error',
|
|
82
83
|
'estiva/no-handmade-empty-state': 'error',
|
|
84
|
+
'estiva/no-native-title': 'error',
|
|
83
85
|
})
|
|
84
86
|
expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
|
|
85
|
-
for (const id of PACKAGE_RULE_IDS.filter((id) => !['estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state'].includes(id))) {
|
|
87
|
+
for (const id of PACKAGE_RULE_IDS.filter((id) => !['estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'].includes(id))) {
|
|
86
88
|
expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
|
|
87
89
|
expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
|
|
88
90
|
}
|
|
@@ -149,8 +151,8 @@ describe('countGates', () => {
|
|
|
149
151
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
150
152
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
151
153
|
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
152
|
-
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none })
|
|
153
|
-
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none })
|
|
154
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none })
|
|
155
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none, 'estiva/no-restyled-part': none, 'estiva/no-handmade-header': none, 'estiva/no-handmade-empty-state': none, 'estiva/no-native-title': none })
|
|
154
156
|
})
|
|
155
157
|
|
|
156
158
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
@@ -173,6 +175,7 @@ describe('countGates', () => {
|
|
|
173
175
|
'estiva/no-restyled-part': { errors: 0, warnings: 0, escapes: 0 },
|
|
174
176
|
'estiva/no-handmade-header': { errors: 0, warnings: 0, escapes: 0 },
|
|
175
177
|
'estiva/no-handmade-empty-state': { errors: 0, warnings: 0, escapes: 0 },
|
|
178
|
+
'estiva/no-native-title': { errors: 0, warnings: 0, escapes: 0 },
|
|
176
179
|
},
|
|
177
180
|
disabled: [],
|
|
178
181
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story'
|
|
|
22
22
|
import { noHandRolledBehaviour } from './no-hand-rolled-behaviour'
|
|
23
23
|
import { noHandmadeEmptyState } from './no-handmade-empty-state'
|
|
24
24
|
import { noHandmadeHeader } from './no-handmade-header'
|
|
25
|
+
import { noNativeTitle } from './no-native-title'
|
|
25
26
|
import { noRawElement } from './no-raw-element'
|
|
26
27
|
import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
|
|
27
28
|
import { noRestyledPart } from './no-restyled-part'
|
|
@@ -30,6 +31,7 @@ import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
|
|
|
30
31
|
export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
|
|
31
32
|
export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
|
|
32
33
|
export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
|
|
34
|
+
export { PARTS_WITH_A_TITLE } from './no-native-title'
|
|
33
35
|
|
|
34
36
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
35
37
|
|
|
@@ -48,6 +50,7 @@ const appRules = {
|
|
|
48
50
|
'no-restyled-part': noRestyledPart,
|
|
49
51
|
'no-handmade-header': noHandmadeHeader,
|
|
50
52
|
'no-handmade-empty-state': noHandmadeEmptyState,
|
|
53
|
+
'no-native-title': noNativeTitle,
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/**
|
|
@@ -62,9 +65,9 @@ const appRules = {
|
|
|
62
65
|
* app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
|
|
63
66
|
* apps' list to exactly the app rules.
|
|
64
67
|
*
|
|
65
|
-
*
|
|
66
|
-
* fingerprints `no-handmade-header` (UIG-22)
|
|
67
|
-
* (UIG-23). The package restyles none of its own parts either (Katerina, 17
|
|
68
|
+
* Four rules are in both sets: `no-restyled-part` (UIG-9), and the
|
|
69
|
+
* fingerprints `no-handmade-header` (UIG-22), `no-handmade-empty-state`
|
|
70
|
+
* (UIG-23) and `no-native-title` (UIG-24). The package restyles none of its own parts either (Katerina, 17
|
|
68
71
|
* September), and "my review isn't enough" (23 September).
|
|
69
72
|
*/
|
|
70
73
|
const packageRules = {
|
|
@@ -75,6 +78,7 @@ const packageRules = {
|
|
|
75
78
|
'no-restyled-part': noRestyledPart,
|
|
76
79
|
'no-handmade-header': noHandmadeHeader,
|
|
77
80
|
'no-handmade-empty-state': noHandmadeEmptyState,
|
|
81
|
+
'no-native-title': noNativeTitle,
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
const rules = { ...appRules, ...packageRules }
|
|
@@ -107,6 +111,7 @@ plugin.configs.recommended = {
|
|
|
107
111
|
[`${PLUGIN_KEY}/no-restyled-part`]: 'error',
|
|
108
112
|
[`${PLUGIN_KEY}/no-handmade-header`]: 'error',
|
|
109
113
|
[`${PLUGIN_KEY}/no-handmade-empty-state`]: 'error',
|
|
114
|
+
[`${PLUGIN_KEY}/no-native-title`]: 'error',
|
|
110
115
|
},
|
|
111
116
|
}
|
|
112
117
|
plugin.configs.strict = {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { RuleTester } from 'eslint'
|
|
5
|
+
import { parser } from 'typescript-eslint'
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { noNativeTitle, PARTS_WITH_A_TITLE } from './no-native-title'
|
|
8
|
+
|
|
9
|
+
RuleTester.describe = describe
|
|
10
|
+
RuleTester.it = it
|
|
11
|
+
RuleTester.itOnly = it.only
|
|
12
|
+
|
|
13
|
+
const tester = new RuleTester({
|
|
14
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
15
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
/** An app on disk, so a wrapper of the app's own is followed to its part, as Peek's are. */
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'no-native-title-'))
|
|
20
|
+
const write = (path: string, text: string) => {
|
|
21
|
+
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
22
|
+
writeFileSync(join(root, path), text)
|
|
23
|
+
}
|
|
24
|
+
write('app/package.json', '{ "name": "app" }')
|
|
25
|
+
write('app/src/components/ui/IconButton.tsx', "export { IconButton } from '@estiva-app/ui'\n")
|
|
26
|
+
write('app/src/components/ui/Button.tsx', "import { Button as UiButton, type ButtonProps } from '@estiva-app/ui'\nexport function Button(props: ButtonProps) {\n return <UiButton {...props} />\n}\n")
|
|
27
|
+
write('app/src/components/Banner.tsx', 'export function NewTopicBanner({ title }: { title: string }) {\n return <p>{title}</p>\n}\n')
|
|
28
|
+
const app = join(root, 'app/src/Probe.tsx')
|
|
29
|
+
|
|
30
|
+
const component = (body: string, imports = '') => `${imports}export function Probe({ at, name }: { at: string; name: string }) {\n return (\n${body}\n )\n}\n`
|
|
31
|
+
const native = [{ messageId: 'native' }]
|
|
32
|
+
|
|
33
|
+
tester.run('no-native-title', noNativeTitle, {
|
|
34
|
+
valid: [
|
|
35
|
+
{ name: 'the part: WithTooltip', code: component('<WithTooltip label={at}><time>{at}</time></WithTooltip>', "import { WithTooltip } from '@estiva-app/ui'\n") },
|
|
36
|
+
{ name: "IconButton's own tooltip", code: component('<IconButton tooltip="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@estiva-app/ui'\n") },
|
|
37
|
+
{ name: 'a <title> inside an <svg> is a label for a screen reader, not a tooltip', code: component('<svg viewBox="0 0 16 16"><title>Unread</title><circle r="4" /></svg>') },
|
|
38
|
+
{ name: "a <title> in a document's head", code: component('<html><head><title>Estiva</title></head><body /></html>') },
|
|
39
|
+
...PARTS_WITH_A_TITLE.map((part) => ({ name: `${part}'s title is its own heading`, code: component(`<${part} title="Files" />`, `import { ${part} } from '@estiva-app/ui'\n`) })),
|
|
40
|
+
{ name: "the app's own component with a title of its own (Peek's NewTopicBanner)", filename: app, code: component('<NewTopicBanner title={name} />', "import { NewTopicBanner } from '@/components/Banner'\n") },
|
|
41
|
+
{ name: "a story's title is the Storybook's, an object key", code: "const meta = { title: 'Primitives/Tooltip', component: Tooltip }\nexport default meta\n" },
|
|
42
|
+
{ name: 'a prop of another name', code: component('<span aria-label={name} data-title={name}>{name}</span>') },
|
|
43
|
+
{ name: 'an escape with its reason', code: component('<>\n{/* @estiva-escape: the print view has no pointer, and the address must survive a copy */}\n<span title={at}>{at}</span>\n</>') },
|
|
44
|
+
],
|
|
45
|
+
invalid: [
|
|
46
|
+
{
|
|
47
|
+
name: 'the file conversation timestamp at peek 3dc663b~1 (ForeignConversationView.tsx:106)',
|
|
48
|
+
code: component('<time\n dateTime={at}\n title={new Date(at).toLocaleString()}\n className="text-caption text-text-muted"\n>\n {at}\n</time>'),
|
|
49
|
+
errors: native,
|
|
50
|
+
},
|
|
51
|
+
{ name: "a reference chip's cut-short title (Reference.tsx at UIG-1's commits)", code: component('<span className="truncate" title={name}>{name}</span>'), errors: native },
|
|
52
|
+
{ name: 'a title that is only there sometimes (MentionChip.tsx)', code: component('<span title={name ? undefined : at}>{name}</span>'), errors: native },
|
|
53
|
+
{ name: 'a string', code: component('<div title="online presence" />'), errors: native },
|
|
54
|
+
{ name: "a part that hands it on to its element: the apps' status probe", code: component('<Button title="Delete">x</Button>', "import { Button } from '@estiva-app/ui'\n"), errors: native },
|
|
55
|
+
{ name: "through the app's re-export", filename: app, code: component('<IconButton title="Close" aria-label="Close">x</IconButton>', "import { IconButton } from '@/components/ui/IconButton'\n"), errors: native },
|
|
56
|
+
{ name: "through the app's wrapper that hands its props on", filename: app, code: component('<Button title="Delete">x</Button>', "import { Button } from '@/components/ui/Button'\n"), errors: native },
|
|
57
|
+
{ name: 'an escape with no reason is not an escape', code: component('<>\n{/* @estiva-escape */}\n<span title={at}>{at}</span>\n</>'), errors: [{ messageId: 'escapeWithoutReason' }, ...native] },
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('PARTS_WITH_A_TITLE', () => {
|
|
62
|
+
it('is every part the catalogue says takes a title of its own, and no other', () => {
|
|
63
|
+
const registry = JSON.parse(readFileSync(new URL('../../registry.json', import.meta.url), 'utf8')) as { entries: { name: string; props?: { name: string }[] | null }[] }
|
|
64
|
+
const declared = registry.entries.filter((entry) => entry.props?.some((prop) => prop.name === 'title')).map((entry) => entry.name).sort()
|
|
65
|
+
expect([...PARTS_WITH_A_TITLE].sort()).toEqual(declared)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Rule } from 'eslint'
|
|
2
|
+
import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
3
|
+
import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } from './no-restyled-part'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The parts whose `title` is their own: the words of a heading or a dialog,
|
|
7
|
+
* drawn on the page. Every other part hands a `title` on to its element, and
|
|
8
|
+
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
9
|
+
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
10
|
+
*/
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'SectionHeader'] as const
|
|
12
|
+
|
|
13
|
+
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
14
|
+
const nameOf = (node: Node | null | undefined): string | undefined =>
|
|
15
|
+
node?.type === 'JSXIdentifier' || node?.type === 'Identifier' ? (node.name as string) : undefined
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* UIG-24: the browser's tooltip where ours belongs.
|
|
19
|
+
*
|
|
20
|
+
* A `title` on an element is the browser's hover box: grey, in the system's
|
|
21
|
+
* font, after a delay nobody chose, and never on a touch screen. The package's
|
|
22
|
+
* `WithTooltip` is the tooltip here (and `IconButton`'s `tooltip`, which draws
|
|
23
|
+
* it). The rule reads the attribute, never the page: a tooltip's words are not
|
|
24
|
+
* on the page until it opens.
|
|
25
|
+
*
|
|
26
|
+
* Judged: a `title` on a plain element, and on a part of the package (or an
|
|
27
|
+
* app's wrapper that hands all its props on to one) whose `title` is not its
|
|
28
|
+
* own — `<Button title>` reaches the `<button>`. Not judged, on purpose:
|
|
29
|
+
*
|
|
30
|
+
* - the `<title>` **element**, in an `<svg>` (a label for a screen reader) or
|
|
31
|
+
* a document's head: an element, not an attribute, so the rule never sees it;
|
|
32
|
+
* - a part whose `title` is its own heading (`PARTS_WITH_A_TITLE`);
|
|
33
|
+
* - the app's own components: what one does with a `title` is its own business,
|
|
34
|
+
* and its elements are judged where it draws them;
|
|
35
|
+
* - a story's `title` (`meta = { title: 'Group/Name' }`): an object key, not an
|
|
36
|
+
* attribute.
|
|
37
|
+
*/
|
|
38
|
+
export const noNativeTitle: Rule.RuleModule = {
|
|
39
|
+
meta: {
|
|
40
|
+
type: 'problem',
|
|
41
|
+
docs: { description: "A title attribute: the browser's tooltip where WithTooltip is the part" },
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
native:
|
|
45
|
+
"`title` is the browser's tooltip, not ours. Wrap it in `WithTooltip` from @estiva-app/ui, or use `IconButton`'s `tooltip`: its page (Primitives/Tooltip) says when it shows and how.",
|
|
46
|
+
...ESCAPE_MESSAGES,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
const parser = (context.languageOptions?.parser ?? {}) as Parser
|
|
51
|
+
let locals: Map<string, PartBinding | 'namespace'> | undefined
|
|
52
|
+
return {
|
|
53
|
+
Program(program) {
|
|
54
|
+
locals = partsOfFile(context.filename, program as unknown as Node, parser)
|
|
55
|
+
},
|
|
56
|
+
JSXAttribute(ruleNode: Rule.Node) {
|
|
57
|
+
const attribute = ruleNode as unknown as Node
|
|
58
|
+
if (nameOf(child(attribute, 'name')) !== 'title') return
|
|
59
|
+
const opening = attribute.parent as Node
|
|
60
|
+
const tag = child(opening, 'name')
|
|
61
|
+
const name = nameOf(tag)
|
|
62
|
+
if (!(name && /^[a-z]/.test(name))) {
|
|
63
|
+
const binding = locals && bindingOfTag(tag, locals)
|
|
64
|
+
if (!binding || binding.props !== 'all') return
|
|
65
|
+
if ((PARTS_WITH_A_TITLE as readonly string[]).includes(binding.part)) return
|
|
66
|
+
}
|
|
67
|
+
if (isEscaped(context, opening)) return
|
|
68
|
+
context.report({ loc: attribute.loc as NonNullable<Rule.Node['loc']>, messageId: 'native' })
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
@@ -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 }, { 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 }, { 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', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state'])
|
|
156
|
+
expect(Object.keys(written.rules)).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header', 'estiva/no-handmade-empty-state', 'estiva/no-native-title'])
|
|
157
157
|
|
|
158
158
|
expect((await writeGateCount({ root: dir, repo: 'probe' })).changed).toBe(false)
|
|
159
159
|
|