@estiva-app/ui 0.31.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.
@@ -0,0 +1,25 @@
1
+ import type { Rule } from 'eslint';
2
+ /**
3
+ * UIG-23: an empty state drawn by hand, or the right part at the wrong scope.
4
+ *
5
+ * **A line drawn by hand.** A plain box whose only content is a short line
6
+ * saying there is nothing ("No tickets yet.") where a list's rows would be: one
7
+ * side of a condition whose other side maps the list, or under a condition that
8
+ * asks if something is empty. Both halves are needed. The words alone fire on a
9
+ * hint and a status note (UIG-1 measured it); the place alone fires on any
10
+ * line a list shows beside itself.
11
+ *
12
+ * **The wrong scope.** A `page`-scope `EmptyState` centres in the room a flex
13
+ * column gives it. Inside a `ScrollArea` the content box is only as tall as
14
+ * what is in it, so the state sits at the top of its pane. The rule looks at
15
+ * the `ScrollArea`'s content and at every component of the app's own placed in
16
+ * it, following them into their files, because that is how four of them
17
+ * arrived on Folders in one merge (peek `7f22e5e`): the state was drawn in one
18
+ * file and scrolled in another. A `contentClassName` with `min-h-full` or
19
+ * `h-full` gives the state its room, and passes.
20
+ *
21
+ * Not judged: the package's `EmptyState` and a part's own lines (a
22
+ * `FieldLine`, a palette's empty row) — a part is already a part.
23
+ */
24
+ export declare const noHandmadeEmptyState: Rule.RuleModule;
25
+ //# sourceMappingURL=no-handmade-empty-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-handmade-empty-state.d.ts","sourceRoot":"","sources":["../../src/eslint/no-handmade-empty-state.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAuPlC;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,EAAE,IAAI,CAAC,UAyEvC,CAAA"}
@@ -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"}
@@ -59,6 +59,8 @@ interface Node {
59
59
  parent?: Node;
60
60
  [key: string]: unknown;
61
61
  }
62
+ /** Walk every node under `root`, skipping the parser's bookkeeping keys. */
63
+ declare function walk(root: Node, visit: (node: Node) => void): void;
62
64
  /**
63
65
  * What a name is to this rule: the part it draws, and which of the caller's
64
66
  * class props reach which of the part's (`all` for a part, a re-export or a
@@ -70,6 +72,24 @@ export interface PartBinding {
70
72
  props: 'all' | Record<string, string>;
71
73
  via?: string;
72
74
  }
75
+ interface Parser {
76
+ parseForESLint?: (code: string, options: unknown) => {
77
+ ast: Node;
78
+ };
79
+ parse?: (code: string, options: unknown) => Node;
80
+ }
81
+ declare function parseFile(parser: Parser, filePath: string): Node | null;
82
+ declare function resolveModule(fromFile: string, specifier: string): string | null;
83
+ /** A function component declared in a module: its name, and the function. */
84
+ declare function functionComponents(program: Node): Map<string, Node>;
85
+ /**
86
+ * What a file's names stand for, found the way this rule finds them: for a rule
87
+ * that asks whether a tag is one particular part, through an app's wrapper too
88
+ * (UIG-23 asks for `EmptyState` and `ScrollArea`).
89
+ */
90
+ export declare function partsOfFile(file: string, program: Node, parser: Parser): Map<string, PartBinding | 'namespace'>;
91
+ export { bindingOfTag, functionComponents, parseFile, resolveModule, walk, type Node, type Parser };
92
+ declare function bindingOfTag(tag: Node | null | undefined, locals: Map<string, PartBinding | 'namespace'>): PartBinding | undefined;
73
93
  /**
74
94
  * Every class the code spells out for a value, as far as it can be read in this
75
95
  * file. `asMap` reads an object as a class map (its values) rather than as a
@@ -77,5 +97,4 @@ export interface PartBinding {
77
97
  */
78
98
  export declare function classesOf(value: Node | null | undefined, scopeOf: (node: Node) => Scope.Scope | null, asMap?: boolean, seen?: Set<Node>): string[];
79
99
  export declare const noRestyledPart: Rule.RuleModule;
80
- export {};
81
100
  //# sourceMappingURL=no-restyled-part.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"no-restyled-part.d.ts","sourceRoot":"","sources":["../../src/eslint/no-restyled-part.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AAGzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAIH,6FAA6F;AAC7F,eAAO,MAAM,SAAS,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAUxD,CAAA;AASD,oGAAoG;AACpG,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAajF;AAED,4CAA4C;AAC5C,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAIlD;AAOD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAiCpD,CAAA;AAOD,UAAU,IAAI;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvB,GAAG,CAAC,EAAE,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;IACnC,MAAM,CAAC,EAAE,IAAI,CAAA;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAwBD;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACrC,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AA8UD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,KAAK,CAAC,KAAK,GAAG,IAAI,EAAE,KAAK,UAAQ,EAAE,IAAI,YAAkB,GAAG,MAAM,EAAE,CAqDtJ;AAOD,eAAO,MAAM,cAAc,EAAE,IAAI,CAAC,UAwEjC,CAAA"}
1
+ {"version":3,"file":"no-restyled-part.d.ts","sourceRoot":"","sources":["../../src/eslint/no-restyled-part.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.31.0",
3
+ "version": "0.33.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",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.31.0",
7
+ "packageVersion": "0.33.0",
8
8
  "exports": 87,
9
9
  "typeExports": 96,
10
10
  "files": null
@@ -83,7 +83,9 @@ padding by hand, and is left behind the day the box changes.
83
83
 
84
84
  The message is the caller's — a shared component has no words of its own
85
85
  for what is missing. A hand-written "Nothing here" paragraph or span in a
86
- section is the `section` manner, and becomes it.
86
+ section is the `section` manner, and becomes it. The lint refuses one
87
+ (`estiva/no-handmade-empty-state`): a line of words that shows in place of an
88
+ empty list, whatever it says.
87
89
 
88
90
  ## What it owns
89
91
 
@@ -91,7 +93,8 @@ It does these by itself. Write none of them around it.
91
93
 
92
94
  | It does | So you never write | Refused when written by hand |
93
95
  |---|---|---|
94
- | As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | |
96
+ | As a `page`, fills the room it is given and centres itself in it | centring, or padding around it | a `page` inside a `ScrollArea`, where it cannot centre (`estiva/no-handmade-empty-state`) |
97
+ | Says a list is empty, in place of its rows | a paragraph or span of your own saying so | `estiva/no-handmade-empty-state` |
95
98
  | Draws its one action, an outlined Button, under the line when you pass `action` | a button of your own under the line | |
96
99
 
97
100
  ## Props
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
4
4
  import { Button } from './Button'
5
5
  import { FilePicker } from './FilePicker'
6
6
  import { IconButton } from './IconButton'
7
+ import { EmptyState } from './EmptyState'
7
8
 
8
9
  const meta = {
9
10
  title: 'Inputs/FilePicker',
@@ -20,9 +21,12 @@ type Story = StoryObj<typeof meta>
20
21
 
21
22
  /** What was chosen, one line per pick, so choosing the same file twice shows twice. */
22
23
  function Picks({ picks }: { picks: string[] }) {
24
+ if (picks.length === 0) return <EmptyState scope="section" message="Nothing chosen yet." />
23
25
  return (
24
26
  <ul className="flex flex-col gap-1 text-body-2 text-text-secondary">
25
- {picks.length === 0 ? <li>Nothing chosen yet.</li> : picks.map((pick, i) => <li key={i}>{pick}</li>)}
27
+ {picks.map((pick, i) => (
28
+ <li key={i}>{pick}</li>
29
+ ))}
26
30
  </ul>
27
31
  )
28
32
  }
@@ -5,6 +5,7 @@ import { Popover } from './Popover'
5
5
  import { Reaction } from './Reaction'
6
6
  import { ReactionPicker, type ReactionOption } from './ReactionPicker'
7
7
  import { Toolbar, ToolbarButton } from './Toolbar'
8
+ import { EmptyState } from './EmptyState'
8
9
 
9
10
  /**
10
11
  * The reactions on offer, to choose one from. **A `Reaction` is the answer;
@@ -98,7 +99,7 @@ export const AndWhatItProduces: Story = {
98
99
  />
99
100
  <div className="flex flex-wrap items-center gap-1.5">
100
101
  {mine.length === 0 ? (
101
- <span className="text-caption text-text-muted">Nothing here yet.</span>
102
+ <EmptyState scope="section" message="Nothing here yet." />
102
103
  ) : (
103
104
  mine.map((emoji) => {
104
105
  const option = OPTIONS.find((o) => o.emoji === emoji)!
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
 
@@ -38,6 +38,8 @@ describe('the plugin object', () => {
38
38
  'no-rebuilt-behaviour',
39
39
  'no-restyled-part',
40
40
  'no-handmade-header',
41
+ 'no-handmade-empty-state',
42
+ 'no-native-title',
41
43
  'raw-element-outside-a-wrapper',
42
44
  'no-hand-rolled-behaviour',
43
45
  'component-has-a-page',
@@ -57,16 +59,17 @@ describe('the plugin object', () => {
57
59
  it('gives an app only the app rules, as errors, under estiva/', () => {
58
60
  for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
59
61
  expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
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' })
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' })
61
63
  }
62
- expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour', 'estiva/no-restyled-part', 'estiva/no-handmade-header'])
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'])
63
65
  })
64
66
 
65
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
+ * `no-restyled-part` (UIG-9), `no-handmade-header` (UIG-22) and
69
+ * `no-handmade-empty-state` (UIG-23) and `no-native-title` (UIG-24) are in both sets: the package restyles
70
+ * none of its own parts either (Katerina, 17 September), and draws no header
71
+ * bar or empty state by hand ("my review isn't enough", 23 September). Every
72
+ * other inward rule still reaches no app.
70
73
  */
71
74
  it('gives this package its own set, as errors, and only the shared rules reach an app config', () => {
72
75
  expect(estiva.configs.package.plugins?.[PLUGIN_KEY]).toBe(estiva)
@@ -77,9 +80,11 @@ describe('the plugin object', () => {
77
80
  'estiva/component-has-a-story': 'error',
78
81
  'estiva/no-restyled-part': 'error',
79
82
  'estiva/no-handmade-header': 'error',
83
+ 'estiva/no-handmade-empty-state': 'error',
84
+ 'estiva/no-native-title': 'error',
80
85
  })
81
86
  expect(PACKAGE_RULE_IDS).toEqual(Object.keys(estiva.configs.package.rules ?? {}))
82
- for (const id of PACKAGE_RULE_IDS.filter((id) => id !== 'estiva/no-restyled-part' && id !== 'estiva/no-handmade-header')) {
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))) {
83
88
  expect(estiva.configs.recommended.rules?.[id]).toBeUndefined()
84
89
  expect(estiva.configs.strict.rules?.[id]).toBeUndefined()
85
90
  }
@@ -146,8 +151,8 @@ describe('countGates', () => {
146
151
  it('counts an error, and an escape only when the lint reports escapes', async () => {
147
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>')
148
153
  const none = { errors: 0, warnings: 0, escapes: 0 }
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 })
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 })
151
156
  })
152
157
 
153
158
  it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
@@ -169,6 +174,8 @@ describe('countGates', () => {
169
174
  rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 },
170
175
  'estiva/no-restyled-part': { errors: 0, warnings: 0, escapes: 0 },
171
176
  'estiva/no-handmade-header': { errors: 0, warnings: 0, escapes: 0 },
177
+ 'estiva/no-handmade-empty-state': { errors: 0, warnings: 0, escapes: 0 },
178
+ 'estiva/no-native-title': { errors: 0, warnings: 0, escapes: 0 },
172
179
  },
173
180
  disabled: [],
174
181
  })
@@ -20,7 +20,9 @@ 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 { noHandmadeEmptyState } from './no-handmade-empty-state'
23
24
  import { noHandmadeHeader } from './no-handmade-header'
25
+ import { noNativeTitle } from './no-native-title'
24
26
  import { noRawElement } from './no-raw-element'
25
27
  import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
26
28
  import { noRestyledPart } from './no-restyled-part'
@@ -29,6 +31,7 @@ import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
29
31
  export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
30
32
  export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
31
33
  export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part'
34
+ export { PARTS_WITH_A_TITLE } from './no-native-title'
32
35
 
33
36
  const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
34
37
 
@@ -46,6 +49,8 @@ const appRules = {
46
49
  'no-rebuilt-behaviour': noRebuiltBehaviour,
47
50
  'no-restyled-part': noRestyledPart,
48
51
  'no-handmade-header': noHandmadeHeader,
52
+ 'no-handmade-empty-state': noHandmadeEmptyState,
53
+ 'no-native-title': noNativeTitle,
49
54
  }
50
55
 
51
56
  /**
@@ -60,8 +65,10 @@ const appRules = {
60
65
  * app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the
61
66
  * apps' list to exactly the app rules.
62
67
  *
63
- * One rule is in both sets: `no-restyled-part` (UIG-9). The package restyles
64
- * none of its own parts either (Katerina, 17 September).
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
71
+ * September), and "my review isn't enough" (23 September).
65
72
  */
66
73
  const packageRules = {
67
74
  'raw-element-outside-a-wrapper': rawElementOutsideAWrapper,
@@ -70,6 +77,8 @@ const packageRules = {
70
77
  'component-has-a-story': componentHasAStory,
71
78
  'no-restyled-part': noRestyledPart,
72
79
  'no-handmade-header': noHandmadeHeader,
80
+ 'no-handmade-empty-state': noHandmadeEmptyState,
81
+ 'no-native-title': noNativeTitle,
73
82
  }
74
83
 
75
84
  const rules = { ...appRules, ...packageRules }
@@ -101,6 +110,8 @@ plugin.configs.recommended = {
101
110
  [`${PLUGIN_KEY}/no-rebuilt-behaviour`]: 'error',
102
111
  [`${PLUGIN_KEY}/no-restyled-part`]: 'error',
103
112
  [`${PLUGIN_KEY}/no-handmade-header`]: 'error',
113
+ [`${PLUGIN_KEY}/no-handmade-empty-state`]: 'error',
114
+ [`${PLUGIN_KEY}/no-native-title`]: 'error',
104
115
  },
105
116
  }
106
117
  plugin.configs.strict = {
@@ -0,0 +1,151 @@
1
+ import { mkdirSync, mkdtempSync, 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, it } from 'vitest'
7
+ import { noHandmadeEmptyState } from './no-handmade-empty-state'
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
+ /**
19
+ * The wrong-scope half follows the app's components into their files, so its
20
+ * cases live in a small app on disk, laid out the way Peek's Folders page was at
21
+ * `7f22e5e~1`: the page scrolls a view drawn in another file.
22
+ */
23
+ const root = mkdtempSync(join(tmpdir(), 'no-handmade-empty-state-'))
24
+ const write = (path: string, text: string) => {
25
+ mkdirSync(dirname(join(root, path)), { recursive: true })
26
+ writeFileSync(join(root, path), text)
27
+ }
28
+ write('app/package.json', '{ "name": "app" }')
29
+ write(
30
+ 'app/src/components/ui/EmptyState.tsx',
31
+ "import { EmptyState as UiEmptyState, type EmptyStateProps } from '@estiva-app/ui'\nexport function EmptyState({ message = 'Nothing', ...rest }: EmptyStateProps) {\n return <UiEmptyState message={message} {...rest} />\n}\n",
32
+ )
33
+ // The file conversation, as it arrived in the merge: two page states in a view another file scrolls.
34
+ write(
35
+ 'app/src/components/views/ForeignConversationView.tsx',
36
+ "import { EmptyState } from '@/components/ui/EmptyState'\nexport function ForeignConversationView({ state }: { state: string[] | 'failed' }) {\n if (state === 'failed') return <EmptyState message=\"This conversation could not be read.\" />\n if (state.length === 0) return <EmptyState message=\"Nothing said about this yet.\" />\n return <div>{state.map((c) => <p key={c}>{c}</p>)}</div>\n}\n",
37
+ )
38
+ // The folder view: its own states are sections; the rows it draws hold a page state two components down.
39
+ write(
40
+ 'app/src/components/views/FolderContentsView.tsx',
41
+ "import { EmptyState } from '@/components/ui/EmptyState'\nexport function FolderContentsView({ files }: { files: string[] }) {\n if (files.length === 0) return <EmptyState scope=\"section\" message=\"Nothing in this folder yet.\" />\n return <div>{files.map((f) => <FileRow key={f} file={f} />)}</div>\n}\nfunction FileRow({ file }: { file: string }) {\n return <div><p>{file}</p><ChildRows file={file} /></div>\n}\nfunction ChildRows({ file }: { file: string }) {\n const children: string[] = file ? [] : [file]\n if (children.length === 0) return <EmptyState message=\"Nothing in this yet.\" />\n return <>{children.map((c) => <FileRow key={c} file={c} />)}</>\n}\n",
42
+ )
43
+ // Ship's Activity: it hands its className on to a section EmptyState. It is the app's own, and is followed.
44
+ write(
45
+ 'app/src/components/Activity.tsx',
46
+ "import { EmptyState } from '@estiva-app/ui'\nexport function Activity({ items, className }: { items: string[]; className?: string }) {\n if (items.length === 0) return <EmptyState scope=\"section\" className={className} message=\"No related conversations yet.\" />\n return <ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>\n}\n",
47
+ )
48
+
49
+ const app = join(root, 'app/src/pages/Probe.tsx')
50
+ const component = (body: string) => `export function Probe({ rows, report, answer, choosing }: { rows: string[]; report?: string; answer: false | { text: string; sources: string[] }; choosing: boolean }) {\n${body}\n}\n`
51
+ const jsx = (body: string) => component(` return (\n${body}\n )`)
52
+ const scrolled = (imports: string, body: string) =>
53
+ `import { ScrollArea, EmptyState } from '@estiva-app/ui'\n${imports}\nexport function Probe({ rows }: { rows: string[] }) {\n return (\n${body}\n )\n}\n`
54
+ const handmade = [{ messageId: 'handmade' }]
55
+
56
+ tester.run('no-handmade-empty-state', noHandmadeEmptyState, {
57
+ valid: [
58
+ // the part itself
59
+ { name: 'the part, in place of the list', code: jsx('<div>{rows.length === 0 ? <EmptyState scope="section" message="Nothing here" /> : rows.map((r) => <p key={r}>{r}</p>)}</div>') },
60
+
61
+ // UIG-1's two false alarms, which the ticket makes regression tests
62
+ {
63
+ name: '"Nothing is created until you submit it." — a hint beside a button (CommandLauncher.tsx:1498)',
64
+ code: jsx('<>\n<Button>Open the form</Button>\n<span className="text-[12px] text-text-secondary">Nothing is created until you submit it.</span>\n</>'),
65
+ },
66
+ {
67
+ name: '"…this folder has no folder state yet." — a status note, under a condition that asks nothing about a list (FolderContentsView.tsx:101)',
68
+ code: component(' const folder = { source: "channel" }\n return (\n<div>\n{folder.source === "channel" && (\n<p className="text-caption text-text-muted">From channel contents; this folder has no folder state yet.</p>\n)}\n{rows.map((r) => <p key={r}>{r}</p>)}\n</div>\n )'),
69
+ },
70
+
71
+ // the ticket's three ❓, ruled not empty states (GATES.md)
72
+ {
73
+ name: '"No comparison yet — it needs one relay fetch." — a debug line for a report not fetched yet (ReadStatePanel.tsx:65)',
74
+ code: component(' if (!report) return <p className="text-caption text-text-muted">No comparison yet — it needs one relay fetch.</p>\n return <p>{report}</p>'),
75
+ },
76
+ {
77
+ name: '"No answer came back." — a failure after a request; the other side is an answer, with its sources listed inside it (CommandLauncher.tsx:1514)',
78
+ code: jsx('<div>{answer === false ? (\n<p className="text-text-secondary">No answer came back. Edit the question and press Enter to try again.</p>\n) : (\n<>\n<p>{answer.text}</p>\n<ol>{answer.sources.map((s) => <li key={s}>{s}</li>)}</ol>\n</>\n)}</div>'),
79
+ },
80
+
81
+ // look-alikes
82
+ { name: 'the full side of the test: a count', code: jsx('<div>{rows.length > 0 && <span className="font-mono text-caption">{rows.length}</span>}</div>') },
83
+ { name: 'the full side of the test, with words', code: jsx('<div>{rows.length > 0 && <p className="text-caption">Recent</p>}{rows.map((r) => <p key={r}>{r}</p>)}</div>') },
84
+ { name: 'the full side of a ternary', code: jsx('<div>{rows.length ? <p>Pick one below.</p> : <Button>Add</Button>}</div>') },
85
+ {
86
+ name: 'a status line whose words are a choice (ReactionPicker.stories.tsx:76)',
87
+ code: component(' const chosen = rows[0]\n return <span className="text-caption text-text-secondary">{chosen ? `Chose ${chosen}` : "Nothing chosen yet."}</span>'),
88
+ },
89
+ { name: 'a paragraph, not a line', code: jsx(`<div>{rows.length === 0 ? <p>${'Nothing here, and a long explanation of why that is and what to do next about it. '.repeat(2)}</p> : rows.map((r) => <p key={r}>{r}</p>)}</div>`) },
90
+ { name: 'a line that is a value, not words: a message body', code: jsx('<div>{rows.length === 0 ? <p className="pl-8">{report}</p> : rows.map((r) => <p key={r}>{r}</p>)}</div>') },
91
+ { name: 'a box holding more than the line', code: jsx('<div>{rows.length === 0 ? <div className="px-3"><p>Nothing yet.</p><Button>Add one</Button></div> : rows.map((r) => <p key={r}>{r}</p>)}</div>') },
92
+ {
93
+ name: 'an escape with its reason',
94
+ code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape: a printed report keeps its own empty line, set in the print style\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
95
+ },
96
+
97
+ // the right scope
98
+ { name: 'a page state for a whole column, outside any ScrollArea', filename: app, code: scrolled('', '<div className="flex h-full flex-col">{rows.length === 0 ? <EmptyState message="No topics yet." /> : <ScrollArea className="flex-1">{rows.map((r) => <p key={r}>{r}</p>)}</ScrollArea>}</div>') },
99
+ { name: 'a section state inside a ScrollArea', filename: app, code: scrolled('', '<ScrollArea className="flex-1">{rows.length === 0 ? <EmptyState scope="section" message="No issues yet." /> : null}</ScrollArea>') },
100
+ { name: 'a page state inside a ScrollArea whose content fills it', filename: app, code: scrolled('', '<ScrollArea className="flex-1" contentClassName="flex min-h-full flex-col">{rows.length === 0 && <EmptyState message="Nothing here yet." />}</ScrollArea>') },
101
+ { name: 'a scope that is decided at run time is not judged', filename: app, code: scrolled('', '<ScrollArea className="flex-1"><EmptyState scope={rows.length ? "section" : "page"} message="Nothing here yet." /></ScrollArea>') },
102
+ { name: "a component that hands its className to a section state (Ship's Activity), followed and fine", filename: app, code: scrolled("import { Activity } from '@/components/Activity'", '<ScrollArea className="flex-1" contentClassName="px-6"><section><Activity items={rows} /></section></ScrollArea>') },
103
+ ],
104
+ invalid: [
105
+ // the hand-made line, each way it stands in for a list
106
+ { name: 'the other side of a map (Ship Board.tsx:61 at 6693025)', code: jsx('<div className="flex flex-col gap-1.5">{rows.length === 0 ? <p className="px-1 py-2 text-caption text-text-muted">Nothing here</p> : rows.map((r) => <p key={r}>{r}</p>)}</div>'), errors: handmade },
107
+ { name: 'same shape, other class names and another tag (FilePicker.stories.tsx:25)', code: jsx('<ul className="flex flex-col gap-1 text-body-2">{rows.length === 0 ? <li>Nothing chosen yet.</li> : rows.map((r) => <li key={r}>{r}</li>)}</ul>'), errors: handmade },
108
+ { name: 'a map on the other side is enough, whatever the test', code: jsx('<div>{rows[0] === undefined ? <span className="text-caption">Nothing here yet.</span> : rows.map((r) => <p key={r}>{r}</p>)}</div>'), errors: handmade },
109
+ { name: 'under a test that the list is empty, in a box around it (CommandLauncher.tsx:1716 at d094006)', code: jsx('<div>{rows.length === 0 && (\n<div className="flex items-center h-12 px-3">\n<span className="text-[14px] text-text-secondary">Nothing for that here.</span>\n</div>\n)}</div>'), errors: handmade },
110
+ { name: 'returned when the list is empty', code: component(' if (rows.length === 0) return <p className="text-body-2">No tickets in this project yet.</p>\n return <ul>{rows.map((r) => <li key={r}>{r}</li>)}</ul>'), errors: handmade },
111
+ { name: "the empty side is the second: `rows.length ? … : …`", code: jsx('<div>{rows.length ? <Rows rows={rows} /> : <p>None yet.</p>}</div>'), errors: handmade },
112
+ { name: '`!rows.length`', code: jsx('<div>{!rows.length && <p>Nothing to show.</p>}</div>'), errors: handmade },
113
+ { name: 'optional chaining', code: jsx('<div>{rows?.length === 0 && <p>Nothing in it yet.</p>}</div>'), errors: handmade },
114
+ {
115
+ name: "words that do not say \"no\": Peek's dialogs (StartTopicDialog.tsx:106), and a name in the sentence",
116
+ code: jsx('<Dialog>{choosing && rows.length === 0 ? (\n<p className="text-body-2 text-text-secondary">There is nowhere in {report} to start one yet — make a folder first.</p>\n) : (\n<Form />\n)}</Dialog>'),
117
+ errors: handmade,
118
+ },
119
+ {
120
+ name: 'an escape with no reason is not an escape',
121
+ code: jsx('<div>{rows.length === 0 ? (\n// @estiva-escape\n<p>Nothing to print.</p>\n) : rows.map((r) => <p key={r}>{r}</p>)}</div>'),
122
+ errors: [{ messageId: 'escapeWithoutReason' }, ...handmade],
123
+ },
124
+
125
+ // the wrong scope
126
+ { name: 'a page state straight inside a ScrollArea', filename: app, code: scrolled('', '<ScrollArea className="min-h-0 flex-1">{rows.length === 0 && <EmptyState message="Nothing here yet." />}</ScrollArea>'), errors: [{ messageId: 'wrongScope' }] },
127
+ { name: 'scope="page" said out loud', filename: app, code: scrolled('', '<ScrollArea className="flex-1" contentClassName="flex flex-col gap-3 p-4"><EmptyState scope="page" message="Nothing here yet." /></ScrollArea>'), errors: [{ messageId: 'wrongScope' }] },
128
+ { name: "through the app's wrapper, one box down", filename: app, code: "import { ScrollArea } from '@estiva-app/ui'\nimport { EmptyState } from '@/components/ui/EmptyState'\nexport function Probe() {\n return <ScrollArea className=\"flex-1\"><div className=\"p-4\"><EmptyState message=\"Nothing said about this yet.\" /></div></ScrollArea>\n}\n", errors: [{ messageId: 'wrongScope' }] },
129
+ {
130
+ name: 'the file conversation at 7f22e5e~1: a view drawn in another file, scrolled here — both its states',
131
+ filename: app,
132
+ code: scrolled("import { ForeignConversationView } from '@/components/views/ForeignConversationView'", '<ScrollArea className="min-h-0 flex-1">\n<ForeignConversationView state={rows} />\n</ScrollArea>'),
133
+ errors: [
134
+ { messageId: 'wrongScopeVia', data: { component: 'ForeignConversationView', where: 'ForeignConversationView.tsx:3' } },
135
+ { messageId: 'wrongScopeVia', data: { component: 'ForeignConversationView', where: 'ForeignConversationView.tsx:4' } },
136
+ ],
137
+ },
138
+ {
139
+ name: 'the nested children at 7f22e5e~1: a page state under one row of a list, two components down in another file',
140
+ filename: app,
141
+ code: scrolled("import { FolderContentsView } from '@/components/views/FolderContentsView'", '<ScrollArea className="min-h-0 flex-1">\n<FolderContentsView files={rows} />\n</ScrollArea>'),
142
+ errors: [{ messageId: 'wrongScopeVia', data: { component: 'FolderContentsView', where: 'FolderContentsView.tsx:11' } }],
143
+ },
144
+ {
145
+ name: 'a component in the same file',
146
+ filename: app,
147
+ code: "import { ScrollArea, EmptyState } from '@estiva-app/ui'\nfunction Replies({ rows }: { rows: string[] }) {\n if (rows.length === 0) return <EmptyState message=\"No replies yet.\" />\n return <>{rows.map((r) => <p key={r}>{r}</p>)}</>\n}\nexport function Probe({ rows }: { rows: string[] }) {\n return <ScrollArea className=\"flex-1\"><Replies rows={rows} /></ScrollArea>\n}\n",
148
+ errors: [{ messageId: 'wrongScopeVia', data: { component: 'Replies', where: 'line 3' } }],
149
+ },
150
+ ],
151
+ })