@estiva-app/ui 0.18.0 → 0.19.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 +13 -5
- package/dist/Checkbox.d.ts +16 -1
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +6 -0
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/eslint/index.d.ts +2 -0
- package/dist/eslint/index.d.ts.map +1 -1
- package/dist/eslint/index.js +459 -2
- package/dist/eslint/index.js.map +4 -4
- package/dist/eslint/no-rebuilt-behaviour.d.ts +88 -0
- package/dist/eslint/no-rebuilt-behaviour.d.ts.map +1 -0
- package/dist/index.js +10 -2
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/src/Checkbox.mdx +20 -1
- package/src/Checkbox.stories.tsx +44 -0
- package/src/Checkbox.test.tsx +63 -0
- package/src/Checkbox.tsx +30 -1
- package/src/ScrollArea.stories.tsx +18 -0
- package/src/ScrollArea.tsx +7 -1
- package/src/eslint/index.test.ts +31 -7
- package/src/eslint/index.ts +10 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +276 -0
- package/src/eslint/no-rebuilt-behaviour.ts +593 -0
- package/stories/Choosing.mdx +1 -1
package/package.json
CHANGED
package/src/Checkbox.mdx
CHANGED
|
@@ -27,9 +27,18 @@ the parent owns the state.
|
|
|
27
27
|
|
|
28
28
|
<Canvas of={CheckboxStories.WithLabel} />
|
|
29
29
|
|
|
30
|
+
- A list you tick several from, where the whole row is the target: pass
|
|
31
|
+
**`row`**, with `label` and, if the row has one, a `leading` picture. The
|
|
32
|
+
box sits at the end, and the row fills on hover and while checked.
|
|
33
|
+
|
|
34
|
+
<Canvas of={CheckboxStories.Row} />
|
|
35
|
+
|
|
30
36
|
## When not
|
|
31
37
|
|
|
32
38
|
- One choice out of several → **Select** (a value) or **Tabs** (a view).
|
|
39
|
+
- A row drawn by hand with `role="option"` and a Checkbox picture inside it: it
|
|
40
|
+
tells a screen reader "an option in a list" where there is no list. That is
|
|
41
|
+
`row`.
|
|
33
42
|
- Inside a **Menu** — a checkbox inside a `menuitem` is invalid HTML;
|
|
34
43
|
a checkable list lives in a DialogShell.
|
|
35
44
|
|
|
@@ -56,6 +65,16 @@ import { Checkbox } from '@estiva-app/ui'
|
|
|
56
65
|
the box. When disabled, only the box shows it; the words keep their colour
|
|
57
66
|
and lose the pointer. It is a field of its own, so it does not go inside a
|
|
58
67
|
**Field**.
|
|
68
|
+
- With `row` it is the same `Field`, its `Field.Label` the whole row:
|
|
69
|
+
`leading`, 12px, the words in `body-2` (cut off with an ellipsis when too
|
|
70
|
+
long), the box at the end; 40px tall, 12px in from each side, 8px corners.
|
|
71
|
+
It needs `onChange`: without one, `row` is ignored. Put the rows in a
|
|
72
|
+
column with `gap-0.5`.
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<Checkbox row leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item one" checked={on} onChange={setOn} />
|
|
76
|
+
```
|
|
77
|
+
|
|
59
78
|
- Inside a busy **Form** it is disabled, and looks it.
|
|
60
79
|
- It does not move when it toggles: the tick is always in the box, hidden
|
|
61
80
|
when unchecked, so both states hang on a line of text the same way.
|
|
@@ -64,7 +83,7 @@ import { Checkbox } from '@estiva-app/ui'
|
|
|
64
83
|
|
|
65
84
|
| Key | Does |
|
|
66
85
|
|---|---|
|
|
67
|
-
| Tab | Onto the box. |
|
|
86
|
+
| Tab | Onto the box (in a row too: the row is not a Tab stop of its own). |
|
|
68
87
|
| Space | Toggles it. |
|
|
69
88
|
| Enter | Nothing. As on a native checkbox, Enter is the form's key. |
|
|
70
89
|
|
package/src/Checkbox.stories.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { fn } from 'storybook/test'
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
4
5
|
import { Checkbox } from './Checkbox'
|
|
5
6
|
|
|
6
7
|
const meta = {
|
|
@@ -32,6 +33,49 @@ export const WithLabel: Story = {
|
|
|
32
33
|
/** Disabled with words: only the box shows it; the words keep their colour. */
|
|
33
34
|
export const WithLabelDisabled: Story = { args: { label: 'Label', 'aria-label': undefined, disabled: true } }
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A list you tick several from: each row is the target — a picture, the words,
|
|
38
|
+
* the box at the end — and fills on hover and while checked.
|
|
39
|
+
*/
|
|
40
|
+
export const Row: Story = {
|
|
41
|
+
parameters: { controls: { disable: true } },
|
|
42
|
+
render: () => {
|
|
43
|
+
const [ticked, setTicked] = useState(new Set(['Item two']))
|
|
44
|
+
const toggle = (item: string) =>
|
|
45
|
+
setTicked((prev) => {
|
|
46
|
+
const next = new Set(prev)
|
|
47
|
+
if (next.has(item)) next.delete(item)
|
|
48
|
+
else next.add(item)
|
|
49
|
+
return next
|
|
50
|
+
})
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex w-80 flex-col gap-0.5">
|
|
53
|
+
{['Item one', 'Item two', 'Item three'].map((item) => (
|
|
54
|
+
<Checkbox
|
|
55
|
+
key={item}
|
|
56
|
+
row
|
|
57
|
+
leading={<IconSquareRounded size={16} stroke={1.5} />}
|
|
58
|
+
label={item}
|
|
59
|
+
checked={ticked.has(item)}
|
|
60
|
+
onChange={() => toggle(item)}
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A row that cannot be changed: the box shows it, and the row neither fills nor points. */
|
|
69
|
+
export const RowDisabled: Story = {
|
|
70
|
+
parameters: { controls: { disable: true } },
|
|
71
|
+
render: () => (
|
|
72
|
+
<div className="flex w-80 flex-col gap-0.5">
|
|
73
|
+
<Checkbox row disabled leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item one" checked onChange={() => {}} />
|
|
74
|
+
<Checkbox row disabled leading={<IconSquareRounded size={16} stroke={1.5} />} label="Item two" checked={false} onChange={() => {}} />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
/** Controlled, as always — the parent owns the state. */
|
|
36
80
|
export const Toggles: Story = {
|
|
37
81
|
parameters: { controls: { disable: true } },
|
package/src/Checkbox.test.tsx
CHANGED
|
@@ -124,4 +124,67 @@ describe('Checkbox', () => {
|
|
|
124
124
|
expect(screen.getByText('Label')).not.toBeNull()
|
|
125
125
|
})
|
|
126
126
|
})
|
|
127
|
+
|
|
128
|
+
describe('with row', () => {
|
|
129
|
+
const picture = <svg data-testid="picture" />
|
|
130
|
+
|
|
131
|
+
it('is named by its words, and a click anywhere on the row toggles it once', async () => {
|
|
132
|
+
const user = userEvent.setup()
|
|
133
|
+
const onChange = vi.fn()
|
|
134
|
+
render(<Checkbox row leading={picture} checked={false} onChange={onChange} label="Item one" />)
|
|
135
|
+
const box = screen.getByRole('checkbox', { name: 'Item one' })
|
|
136
|
+
await user.click(screen.getByText('Item one'))
|
|
137
|
+
await user.click(screen.getByTestId('picture'))
|
|
138
|
+
await user.click(box)
|
|
139
|
+
expect(onChange).toHaveBeenCalledTimes(3)
|
|
140
|
+
expect(onChange).toHaveBeenNthCalledWith(1, true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('draws the picture, the words, then the box, in one row', () => {
|
|
144
|
+
const { container } = render(<Checkbox row leading={picture} checked={false} onChange={() => {}} label="Item one" />)
|
|
145
|
+
const children = [...(container.querySelector('label')?.children ?? [])]
|
|
146
|
+
expect(children[0]?.getAttribute('data-testid')).toBe('picture')
|
|
147
|
+
expect(children[1]?.textContent).toBe('Item one')
|
|
148
|
+
expect(children[2]?.getAttribute('role')).toBe('checkbox')
|
|
149
|
+
expect(container.querySelector('label')?.className).toContain('h-10')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('fills while checked, and on hover', () => {
|
|
153
|
+
const { container, rerender } = render(<Checkbox row checked={false} onChange={() => {}} label="Item one" />)
|
|
154
|
+
const row = () => container.querySelector('label')?.className ?? ''
|
|
155
|
+
expect(row()).not.toContain('bg-bg-selected')
|
|
156
|
+
expect(row()).toContain('hover:bg-bg-hover')
|
|
157
|
+
rerender(<Checkbox row checked onChange={() => {}} label="Item one" />)
|
|
158
|
+
expect(row()).toContain('bg-bg-selected')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('does nothing when disabled, and neither fills under the pointer nor points', async () => {
|
|
162
|
+
const user = userEvent.setup()
|
|
163
|
+
const onChange = vi.fn()
|
|
164
|
+
const { container } = render(<Checkbox row disabled checked={false} onChange={onChange} label="Item one" />)
|
|
165
|
+
await user.click(screen.getByText('Item one'))
|
|
166
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
167
|
+
const className = container.querySelector('label')?.className ?? ''
|
|
168
|
+
expect(className).not.toContain('cursor-pointer')
|
|
169
|
+
expect(className).not.toContain('hover:bg-bg-hover')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('keeps Space on the box, and adds no Tab stop of its own', async () => {
|
|
173
|
+
const user = userEvent.setup()
|
|
174
|
+
const onChange = vi.fn()
|
|
175
|
+
render(<Checkbox row checked={false} onChange={onChange} label="Item one" />)
|
|
176
|
+
await user.tab()
|
|
177
|
+
expect(document.activeElement).toBe(screen.getByRole('checkbox', { name: 'Item one' }))
|
|
178
|
+
await user.keyboard(' ')
|
|
179
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
180
|
+
await user.tab()
|
|
181
|
+
expect(document.activeElement).toBe(document.body)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('is ignored with no onChange', () => {
|
|
185
|
+
const { container } = render(<Checkbox row checked label="Item one" />)
|
|
186
|
+
expect(container.querySelector('label')).toBeNull()
|
|
187
|
+
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
188
|
+
})
|
|
189
|
+
})
|
|
127
190
|
})
|
package/src/Checkbox.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
|
|
2
2
|
import { Field as BaseField } from '@base-ui/react/field'
|
|
3
3
|
import { IconCheck } from '@tabler/icons-react'
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
4
5
|
import { cn } from './cn'
|
|
5
6
|
import { useFormBusy } from './formBusy'
|
|
6
7
|
|
|
@@ -33,6 +34,12 @@ import { useFormBusy } from './formBusy'
|
|
|
33
34
|
* Read state panel's, where it was written by hand (UIG-7, 16 September). The
|
|
34
35
|
* words keep their colour when the box is disabled (Katerina: "no need").
|
|
35
36
|
*
|
|
37
|
+
* With `row`, the whole row is the target: an optional `leading` picture, the
|
|
38
|
+
* words, the box at the end, and the row fills on hover and while checked — a
|
|
39
|
+
* list you tick several from. The class list is Peek's "Add to Open work" rows,
|
|
40
|
+
* which drew it by hand as a `role="option"` in a list that was not one (UIG-8,
|
|
41
|
+
* Katerina's pick B, 16 September: the same pixels, on the package part).
|
|
42
|
+
*
|
|
36
43
|
* Inside a busy `Form` it is disabled, and looks it (`formBusy.ts`).
|
|
37
44
|
*/
|
|
38
45
|
export interface CheckboxProps {
|
|
@@ -44,6 +51,14 @@ export interface CheckboxProps {
|
|
|
44
51
|
* they name it, so no `aria-label` is needed. `className` stays on the box.
|
|
45
52
|
*/
|
|
46
53
|
label?: string
|
|
54
|
+
/**
|
|
55
|
+
* The whole row is the target: `leading`, then the words, then the box at
|
|
56
|
+
* the end; the row fills on hover and while checked. For a list you tick
|
|
57
|
+
* several from. Needs `label` and `onChange`; without `onChange` it is ignored.
|
|
58
|
+
*/
|
|
59
|
+
row?: boolean
|
|
60
|
+
/** In a `row`, a picture before the words: an icon, a status. */
|
|
61
|
+
leading?: ReactNode
|
|
47
62
|
'aria-label'?: string
|
|
48
63
|
/** Set by a `Field` with `required`; a caller inside one owes nothing. */
|
|
49
64
|
'aria-required'?: boolean | 'true' | 'false'
|
|
@@ -65,7 +80,9 @@ const tickClasses = (checked: boolean) => cn('flex', !checked && 'invisible')
|
|
|
65
80
|
|
|
66
81
|
const WORDS_CLASSES = 'text-body-2 text-text-primary'
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
const ROW_CLASSES = 'flex shrink-0 items-center gap-3 h-10 px-3 rounded-lg transition-colors'
|
|
84
|
+
|
|
85
|
+
export function Checkbox({ checked, onChange, disabled: ownDisabled = false, label, row, leading, className, ...aria }: CheckboxProps) {
|
|
69
86
|
const formBusy = useFormBusy()
|
|
70
87
|
const disabled = ownDisabled || formBusy
|
|
71
88
|
if (!onChange) {
|
|
@@ -101,6 +118,18 @@ export function Checkbox({ checked, onChange, disabled: ownDisabled = false, lab
|
|
|
101
118
|
</BaseCheckbox.Root>
|
|
102
119
|
)
|
|
103
120
|
if (label === undefined) return box
|
|
121
|
+
if (row) {
|
|
122
|
+
return (
|
|
123
|
+
<BaseField.Root disabled={disabled}>
|
|
124
|
+
{/* No fill under the pointer, and no pointer, over a row that toggles nothing. */}
|
|
125
|
+
<BaseField.Label className={cn(ROW_CLASSES, checked && 'bg-bg-selected', !disabled && 'cursor-pointer hover:bg-bg-hover')}>
|
|
126
|
+
{leading}
|
|
127
|
+
<span className={cn('flex-1 min-w-0 truncate', WORDS_CLASSES)}>{label}</span>
|
|
128
|
+
{box}
|
|
129
|
+
</BaseField.Label>
|
|
130
|
+
</BaseField.Root>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
104
133
|
return (
|
|
105
134
|
<BaseField.Root disabled={disabled}>
|
|
106
135
|
{/* No pointer over words that toggle nothing. */}
|
|
@@ -29,6 +29,24 @@ export const Default: Story = {
|
|
|
29
29
|
),
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Rows that stick to the top as the list moves under them. The bar stays above them. */
|
|
33
|
+
export const StickyHeadings: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
36
|
+
{['Group one', 'Group two'].map((group) => (
|
|
37
|
+
<div key={group} className="flex flex-col">
|
|
38
|
+
<p className="sticky top-0 z-10 bg-bg-surface px-4 py-2 text-caption text-text-secondary">{group}</p>
|
|
39
|
+
{rows.slice(0, 8).map((row) => (
|
|
40
|
+
<p key={row} className="px-4 py-1.5 text-body-2 text-text-primary">
|
|
41
|
+
{row}
|
|
42
|
+
</p>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</ScrollArea>
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
/** Nothing to scroll: the region draws exactly as a plain box would, and no bar. */
|
|
33
51
|
export const Fits: Story = {
|
|
34
52
|
render: () => (
|
package/src/ScrollArea.tsx
CHANGED
|
@@ -30,6 +30,12 @@ import { cn } from './cn'
|
|
|
30
30
|
* the thin scrollbar both apps had styled by hand in their `index.css`, drawn
|
|
31
31
|
* once here instead.
|
|
32
32
|
*
|
|
33
|
+
* The bar sits above the content (`z-10`). A sticky row inside — a date line
|
|
34
|
+
* in a conversation, `sticky top-0 z-10` — is at the same level, and the bar
|
|
35
|
+
* comes after the content in the page, so it paints on top. Without it the
|
|
36
|
+
* row hid the bar wherever it crossed it: a gap in the thumb, in Peek's topic
|
|
37
|
+
* and direct-message lists (UIG-8, 16 September).
|
|
38
|
+
*
|
|
33
39
|
* `orientation` says which way the region scrolls; a table that is wider
|
|
34
40
|
* than its box scrolls `horizontal`, a list `vertical` (the default), a board
|
|
35
41
|
* `both`. A vertical region keeps its content no wider than itself, so a
|
|
@@ -58,7 +64,7 @@ export interface ScrollAreaProps {
|
|
|
58
64
|
children: ReactNode
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
const BAR = 'flex touch-none select-none rounded-full opacity-0 transition-opacity delay-300 data-[hovering]:opacity-100 data-[hovering]:delay-0 data-[scrolling]:opacity-100 data-[scrolling]:delay-0'
|
|
67
|
+
const BAR = 'z-10 flex touch-none select-none rounded-full opacity-0 transition-opacity delay-300 data-[hovering]:opacity-100 data-[hovering]:delay-0 data-[scrolling]:opacity-100 data-[scrolling]:delay-0'
|
|
62
68
|
const THUMB = 'rounded-full bg-border-strong'
|
|
63
69
|
|
|
64
70
|
export function ScrollArea({ orientation = 'vertical', className, viewportClassName, contentClassName, viewportRef, onScroll, children }: ScrollAreaProps) {
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -6,8 +6,8 @@ import estiva, { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, PLUGIN_KEY } from '
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
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
|
|
10
|
-
* cases are in no-raw-element.test.ts.
|
|
9
|
+
* linting text the way Peek's editor hook does (`lintText`). The rules' own
|
|
10
|
+
* cases are in no-raw-element.test.ts and no-rebuilt-behaviour.test.ts.
|
|
11
11
|
*/
|
|
12
12
|
const tsx: Linter.Config = {
|
|
13
13
|
files: ['**/*.tsx'],
|
|
@@ -35,6 +35,7 @@ describe('the plugin object', () => {
|
|
|
35
35
|
it('carries every rule, the app ones and the inward ones', () => {
|
|
36
36
|
expect(Object.keys(estiva.rules)).toEqual([
|
|
37
37
|
'no-raw-element',
|
|
38
|
+
'no-rebuilt-behaviour',
|
|
38
39
|
'raw-element-outside-a-wrapper',
|
|
39
40
|
'no-hand-rolled-behaviour',
|
|
40
41
|
'component-has-a-page',
|
|
@@ -54,9 +55,9 @@ describe('the plugin object', () => {
|
|
|
54
55
|
it('gives an app only the app rules, as errors, under estiva/', () => {
|
|
55
56
|
for (const config of [estiva.configs.recommended, estiva.configs.strict]) {
|
|
56
57
|
expect(config.plugins?.[PLUGIN_KEY]).toBe(estiva)
|
|
57
|
-
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error' })
|
|
58
|
+
expect(config.rules).toEqual({ 'estiva/no-raw-element': 'error', 'estiva/no-rebuilt-behaviour': 'error' })
|
|
58
59
|
}
|
|
59
|
-
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element'])
|
|
60
|
+
expect(APP_RULE_IDS).toEqual(['estiva/no-raw-element', 'estiva/no-rebuilt-behaviour'])
|
|
60
61
|
})
|
|
61
62
|
|
|
62
63
|
it('gives this package its own set, as errors, and it reaches no app config', () => {
|
|
@@ -90,6 +91,25 @@ describe('an app lint with configs.recommended', () => {
|
|
|
90
91
|
])
|
|
91
92
|
})
|
|
92
93
|
|
|
94
|
+
it('reports behaviour rebuilt by hand, naming the part', async () => {
|
|
95
|
+
const [result] = await lint(component(' <div className="h-64 overflow-y-auto" />'))
|
|
96
|
+
expect(result.messages.map((m) => [m.ruleId, m.severity, m.message])).toEqual([
|
|
97
|
+
['estiva/no-rebuilt-behaviour', 2, "`overflow-y-auto` scrolls with the browser's scrollbar. Use `ScrollArea` from @estiva-app/ui, which draws ours."],
|
|
98
|
+
])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* UIG-8's acceptance: a separator inside Divider is not a hand-written role.
|
|
103
|
+
* The package is exempt from the apps' rules; this runs them on Divider anyway,
|
|
104
|
+
* to show the role branch has nothing to say there (its Base UI import is the
|
|
105
|
+
* package's job, and reported only because the apps' config is not meant for it).
|
|
106
|
+
*/
|
|
107
|
+
it("finds no hand-written role in Divider's own source", async () => {
|
|
108
|
+
const source = readFileSync(new URL('../Divider.tsx', import.meta.url), 'utf8')
|
|
109
|
+
const [result] = await lint(source)
|
|
110
|
+
expect(result.messages.filter((m) => m.messageId === 'role' || m.messageId === 'roleNoPart')).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
|
|
93
113
|
it('passes the same element under an escape', async () => {
|
|
94
114
|
const [result] = await lint(component(' // @estiva-escape: a preview drawn from its own palette\n <button type="button">x</button>'))
|
|
95
115
|
expect(result.messages).toEqual([])
|
|
@@ -99,8 +119,9 @@ describe('an app lint with configs.recommended', () => {
|
|
|
99
119
|
describe('countGates', () => {
|
|
100
120
|
it('counts an error, and an escape only when the lint reports escapes', async () => {
|
|
101
121
|
const code = component(' <div>\n <button>x</button>\n {/* @estiva-escape: a preview drawn from its own palette */}\n <button>y</button>\n </div>')
|
|
102
|
-
|
|
103
|
-
expect(countGates(await lint(code
|
|
122
|
+
const none = { errors: 0, warnings: 0, escapes: 0 }
|
|
123
|
+
expect(countGates(await lint(code)).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': none })
|
|
124
|
+
expect(countGates(await lint(code, [countMode])).rules).toEqual({ 'estiva/no-raw-element': { errors: 1, warnings: 0, escapes: 1 }, 'estiva/no-rebuilt-behaviour': none })
|
|
104
125
|
})
|
|
105
126
|
|
|
106
127
|
it('lists a report an eslint-disable silenced, and counts it as neither an error nor an escape', async () => {
|
|
@@ -118,6 +139,9 @@ describe('countGates', () => {
|
|
|
118
139
|
})
|
|
119
140
|
|
|
120
141
|
it('lists every rule of the plugin, even with nothing found', () => {
|
|
121
|
-
expect(countGates([])).toEqual({
|
|
142
|
+
expect(countGates([])).toEqual({
|
|
143
|
+
rules: { 'estiva/no-raw-element': { errors: 0, warnings: 0, escapes: 0 }, 'estiva/no-rebuilt-behaviour': { errors: 0, warnings: 0, escapes: 0 } },
|
|
144
|
+
disabled: [],
|
|
145
|
+
})
|
|
122
146
|
})
|
|
123
147
|
})
|
package/src/eslint/index.ts
CHANGED
|
@@ -21,9 +21,11 @@ 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
23
|
import { noRawElement } from './no-raw-element'
|
|
24
|
+
import { noRebuiltBehaviour } from './no-rebuilt-behaviour'
|
|
24
25
|
import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper'
|
|
25
26
|
|
|
26
27
|
export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape'
|
|
28
|
+
export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour'
|
|
27
29
|
|
|
28
30
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
29
31
|
|
|
@@ -32,10 +34,12 @@ export const PLUGIN_KEY = 'estiva'
|
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* The rules an **app** runs: they say an app must not build what the package
|
|
35
|
-
* already has
|
|
37
|
+
* already has — a raw control (UIG-7), or a behaviour one of its parts owns
|
|
38
|
+
* (UIG-8). `recommended` and `strict` carry these and only these.
|
|
36
39
|
*/
|
|
37
40
|
const appRules = {
|
|
38
41
|
'no-raw-element': noRawElement,
|
|
42
|
+
'no-rebuilt-behaviour': noRebuiltBehaviour,
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/**
|
|
@@ -73,7 +77,7 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
|
|
|
73
77
|
/**
|
|
74
78
|
* `recommended` switches every **app** rule on at the level it was ruled at: an
|
|
75
79
|
* error blocks, a warning is reported and never blocks. `strict` makes every app
|
|
76
|
-
* rule an error. With
|
|
80
|
+
* rule an error. With every app rule an error, the two are the same today; they part
|
|
77
81
|
* when the first warning-level rule arrives (UIG-25).
|
|
78
82
|
*
|
|
79
83
|
* `package` is the inward set (UIG-5), which only this package runs.
|
|
@@ -81,7 +85,10 @@ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGI
|
|
|
81
85
|
plugin.configs.recommended = {
|
|
82
86
|
name: '@estiva-app/ui/recommended',
|
|
83
87
|
plugins: { [PLUGIN_KEY]: plugin },
|
|
84
|
-
rules: {
|
|
88
|
+
rules: {
|
|
89
|
+
[`${PLUGIN_KEY}/no-raw-element`]: 'error',
|
|
90
|
+
[`${PLUGIN_KEY}/no-rebuilt-behaviour`]: 'error',
|
|
91
|
+
},
|
|
85
92
|
}
|
|
86
93
|
plugin.configs.strict = {
|
|
87
94
|
name: '@estiva-app/ui/strict',
|