@estiva-app/ui 0.40.0 → 0.42.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/dist/Chip.d.ts +10 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/Field.d.ts.map +1 -1
- package/dist/Form.d.ts +18 -1
- package/dist/Form.d.ts.map +1 -1
- package/dist/Link.d.ts +15 -1
- package/dist/Link.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/eslint/index.js +16 -2
- package/dist/eslint/index.js.map +3 -3
- package/dist/eslint/no-restyled-part.d.ts +4 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/index.js +14 -0
- package/dist/gates/index.js.map +2 -2
- package/dist/index.js +53 -40
- package/dist/index.js.map +3 -3
- package/dist/looks.d.ts +6 -4
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +54 -18
- package/src/Chip.mdx +13 -2
- package/src/Chip.stories.tsx +2 -0
- package/src/Chip.test.tsx +36 -0
- package/src/Chip.tsx +17 -5
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +6 -4
- package/src/DialogShell.stories.tsx +4 -3
- package/src/Field.mdx +5 -0
- package/src/Field.tsx +4 -1
- package/src/Form.mdx +9 -3
- package/src/Form.stories.tsx +18 -1
- package/src/Form.test.tsx +17 -3
- package/src/Form.tsx +20 -2
- package/src/Link.mdx +32 -0
- package/src/Link.stories.tsx +49 -0
- package/src/Link.test.tsx +15 -0
- package/src/Link.tsx +23 -2
- package/src/ListColumn.stories.tsx +1 -1
- package/src/Select.tsx +3 -0
- package/src/eslint/index.test.ts +1 -1
- package/src/eslint/no-restyled-part.test.ts +22 -6
- package/src/eslint/no-restyled-part.ts +27 -2
- package/src/gates/app-checks.ts +14 -0
- package/src/gates/create-app.test.ts +7 -2
- package/src/looks.ts +6 -4
package/src/Link.mdx
CHANGED
|
@@ -24,6 +24,11 @@ the app decides how it navigates.
|
|
|
24
24
|
what it wraps draws itself.
|
|
25
25
|
- `external` — another site or app: it opens in a new tab, with
|
|
26
26
|
`noopener noreferrer`.
|
|
27
|
+
- **`cover`** — a row that opens one thing wherever you click it: an item in a
|
|
28
|
+
list, a row of a table. The link covers the row, and the keyboard ring goes
|
|
29
|
+
round the whole row.
|
|
30
|
+
|
|
31
|
+
<Canvas of={LinkStories.Cover} />
|
|
27
32
|
|
|
28
33
|
## When not
|
|
29
34
|
|
|
@@ -33,6 +38,13 @@ the app decides how it navigates.
|
|
|
33
38
|
- A link that looks like an icon button → **IconButton** with `href`.
|
|
34
39
|
- A word standing for a person or a thing, drawn in a small box → **InlineChip**
|
|
35
40
|
with `href`.
|
|
41
|
+
- A box that is one link → **Card** with `href`. `cover` is for a row that has
|
|
42
|
+
no frame of its own.
|
|
43
|
+
- `cover` on a row whose parts each open something different, or a row with
|
|
44
|
+
nothing to open. A row with only a second link inside it (a chip, say) is
|
|
45
|
+
fine: see How.
|
|
46
|
+
- Stretching a link over its row by hand (`after:absolute`): the lint refuses
|
|
47
|
+
it. Use `cover`.
|
|
36
48
|
|
|
37
49
|
## How
|
|
38
50
|
|
|
@@ -59,6 +71,26 @@ import { Link } from '@estiva-app/ui'
|
|
|
59
71
|
|
|
60
72
|
- `truncate` keeps a title that is a link on one line, cut with an ellipsis.
|
|
61
73
|
Its size and colour still come from the text around it.
|
|
74
|
+
|
|
75
|
+
- `cover` covers the nearest box that is `relative`: give the row `relative`.
|
|
76
|
+
The row keeps its own size, fill and hover; the link only adds the layer
|
|
77
|
+
and the ring. Pointing anywhere on the row counts as pointing at the link,
|
|
78
|
+
so `quiet` underlines the title while the row is hovered.
|
|
79
|
+
- A second link inside the row comes **after** the covering link and is
|
|
80
|
+
`relative` itself, so it sits above the layer. A **Chip** with `href` already
|
|
81
|
+
is.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<div className="relative flex h-9 items-center gap-3 px-3 hover:bg-bg-hover">
|
|
85
|
+
<Link href={itemUrl} variant="plain" cover onClick={navigate}>Item one</Link>
|
|
86
|
+
<Chip href={groupUrl} label="Group" onClick={navigate} />
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<tr className="relative">
|
|
90
|
+
<td><Link href={itemUrl} variant="quiet" cover>Item one</Link></td>
|
|
91
|
+
<td>…</td>
|
|
92
|
+
</tr>
|
|
93
|
+
```
|
|
62
94
|
- `quiet`, `underlined` and `plain` take their size and colour from where they
|
|
63
95
|
sit, so put those on your own element around the link, not on the link —
|
|
64
96
|
the lint refuses a look passed in `className`.
|
package/src/Link.stories.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { Link } from './Link'
|
|
3
3
|
import { Card } from './Card'
|
|
4
|
+
import { Chip } from './Chip'
|
|
4
5
|
|
|
5
6
|
const meta = {
|
|
6
7
|
title: 'Navigation/Link',
|
|
@@ -70,6 +71,54 @@ export const External: Story = {
|
|
|
70
71
|
),
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
const ROWS = [
|
|
75
|
+
{ key: 'A-1', title: 'Item one', group: 'Group', time: '2h' },
|
|
76
|
+
{ key: 'A-2', title: 'Item two', group: 'Group', time: '1d' },
|
|
77
|
+
{ key: 'A-3', title: 'Item three', time: '3d' },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A row that is one link: click anywhere on it, or Tab to it and the ring goes
|
|
82
|
+
* round the whole row. The chip is a second link, and opens something else.
|
|
83
|
+
*/
|
|
84
|
+
export const Cover: Story = {
|
|
85
|
+
parameters: { controls: { disable: true } },
|
|
86
|
+
render: () => (
|
|
87
|
+
<div className="flex w-full max-w-xl flex-col">
|
|
88
|
+
{ROWS.map((row) => (
|
|
89
|
+
<div key={row.key} className="relative flex h-9 items-center gap-3 rounded-md px-3 transition-colors hover:bg-bg-hover">
|
|
90
|
+
<Link href="#" variant="plain" cover className="flex min-w-0 flex-1 items-center gap-3">
|
|
91
|
+
<span className="w-10 shrink-0 font-mono text-caption text-text-muted">{row.key}</span>
|
|
92
|
+
<span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">{row.title}</span>
|
|
93
|
+
</Link>
|
|
94
|
+
{row.group && <Chip href="#" label={row.group} />}
|
|
95
|
+
<span className="w-8 shrink-0 text-right text-caption text-text-muted">{row.time}</span>
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The same in a table: the row is `relative`, the title a `quiet` link that covers it. */
|
|
103
|
+
export const CoverInTable: Story = {
|
|
104
|
+
parameters: { controls: { disable: true } },
|
|
105
|
+
render: () => (
|
|
106
|
+
<table className="w-full max-w-xl text-body-2">
|
|
107
|
+
<tbody>
|
|
108
|
+
{ROWS.map((row) => (
|
|
109
|
+
<tr key={row.key} className="relative border-b border-border-subtle last:border-b-0 hover:bg-bg-hover">
|
|
110
|
+
<td className="w-14 px-3 py-2 font-mono text-caption text-text-muted">{row.key}</td>
|
|
111
|
+
<td className="px-3 py-2 text-text-primary">
|
|
112
|
+
<Link href="#" variant="quiet" cover>{row.title}</Link>
|
|
113
|
+
</td>
|
|
114
|
+
<td className="w-16 px-3 py-2 text-right text-caption text-text-muted">{row.time}</td>
|
|
115
|
+
</tr>
|
|
116
|
+
))}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
/** The four looks, each where it belongs. */
|
|
74
123
|
export const AllVariants: Story = {
|
|
75
124
|
parameters: { controls: { disable: true } },
|
package/src/Link.test.tsx
CHANGED
|
@@ -88,6 +88,21 @@ describe('Link', () => {
|
|
|
88
88
|
expect(classesOf(screen.getByRole('link'))).toEqual([])
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
+
it('cover lays a layer over the row and moves the ring onto it; without it, nothing of the kind', () => {
|
|
92
|
+
render(
|
|
93
|
+
<>
|
|
94
|
+
<Link href="#" variant="plain" cover>
|
|
95
|
+
Row
|
|
96
|
+
</Link>
|
|
97
|
+
<Link href="#" variant="plain">
|
|
98
|
+
Words
|
|
99
|
+
</Link>
|
|
100
|
+
</>,
|
|
101
|
+
)
|
|
102
|
+
expect(classesOf(screen.getByRole('link', { name: 'Row' }))).toEqual(['after:absolute', 'after:inset-0', 'after:rounded-md', 'focus-visible:outline-none', 'focus-visible:after:[outline-style:auto]'])
|
|
103
|
+
expect(classesOf(screen.getByRole('link', { name: 'Words' }))).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
91
106
|
it('a ref reaches the anchor', () => {
|
|
92
107
|
const ref = createRef<HTMLAnchorElement>()
|
|
93
108
|
render(
|
package/src/Link.tsx
CHANGED
|
@@ -18,6 +18,15 @@ import { cn } from './cn'
|
|
|
18
18
|
* and a solid version became this one, solid (her ruling, 13 September).
|
|
19
19
|
* - `plain` — no look at all: a card or a row that is a link draws itself.
|
|
20
20
|
*
|
|
21
|
+
* **`cover`: a row that is one link** (UIG-33, Katerina 28 September). The link
|
|
22
|
+
* stretches an invisible layer over the nearest box the app has given
|
|
23
|
+
* `relative` (a row, a table row), so a click anywhere on it opens the link, and
|
|
24
|
+
* the keyboard ring goes round that whole box rather than round the words. The
|
|
25
|
+
* row stays the app's own: its size, its fill, its hover. A second link inside
|
|
26
|
+
* the row stays clickable when it comes after the covering link and is
|
|
27
|
+
* `relative` itself, which a `Chip` with an `href` already is. Both apps
|
|
28
|
+
* stretched a link by hand before this, each under a written exception.
|
|
29
|
+
*
|
|
21
30
|
* **Navigation is the app's.** The package cannot know a router, so like
|
|
22
31
|
* `NavItem` it renders a real anchor and passes every anchor prop through: a
|
|
23
32
|
* router app hands in `onClick`, prevents the default and navigates, and the
|
|
@@ -25,6 +34,13 @@ import { cn } from './cn'
|
|
|
25
34
|
*/
|
|
26
35
|
export type LinkVariant = 'text' | 'quiet' | 'underlined' | 'plain'
|
|
27
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The covering layer, and the ring moved onto it: `outline-none` takes the ring
|
|
39
|
+
* off the words, and `outline-style: auto` draws the browser's own ring round the
|
|
40
|
+
* layer, the same ring every other control shows, only the size of the row.
|
|
41
|
+
*/
|
|
42
|
+
const COVER_CLASSES = 'after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:[outline-style:auto]'
|
|
43
|
+
|
|
28
44
|
const VARIANT_CLASSES: Record<LinkVariant, string> = {
|
|
29
45
|
text: 'text-info-default underline underline-offset-2 hover:opacity-80',
|
|
30
46
|
quiet: 'hover:underline',
|
|
@@ -46,15 +62,20 @@ export interface LinkProps extends ComponentPropsWithRef<'a'> {
|
|
|
46
62
|
* Its size and colour still come from the text around it (UIG-9, 17 September).
|
|
47
63
|
*/
|
|
48
64
|
truncate?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* The link covers the row it sits in: a click anywhere on the row opens it,
|
|
67
|
+
* and the keyboard ring goes round the whole row. Give the row `relative`.
|
|
68
|
+
*/
|
|
69
|
+
cover?: boolean
|
|
49
70
|
children: ReactNode
|
|
50
71
|
}
|
|
51
72
|
|
|
52
|
-
export function Link({ href, variant = 'text', external = false, truncate = false, className, children, ...props }: LinkProps) {
|
|
73
|
+
export function Link({ href, variant = 'text', external = false, truncate = false, cover = false, className, children, ...props }: LinkProps) {
|
|
53
74
|
return (
|
|
54
75
|
<a
|
|
55
76
|
href={href}
|
|
56
77
|
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
57
|
-
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', className)}
|
|
78
|
+
className={cn(VARIANT_CLASSES[variant], truncate && 'truncate', cover && COVER_CLASSES, className)}
|
|
58
79
|
{...props}
|
|
59
80
|
>
|
|
60
81
|
{children}
|
|
@@ -97,7 +97,7 @@ export const WithARowAbove: Story = {
|
|
|
97
97
|
<ListColumn
|
|
98
98
|
title="Items"
|
|
99
99
|
above={
|
|
100
|
-
<Form onSubmit={() => setName('')} className="flex items-center gap-2 px-3 pt-3 pb-1">
|
|
100
|
+
<Form onSubmit={() => setName('')} layout="free" className="flex items-center gap-2 px-3 pt-3 pb-1">
|
|
101
101
|
<TextInput className="min-w-0 flex-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="New item name" aria-label="New item name" />
|
|
102
102
|
<Button className="shrink-0" type="submit" disabled={!name.trim()}>
|
|
103
103
|
Create
|
package/src/Select.tsx
CHANGED
|
@@ -94,7 +94,10 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
|
|
|
94
94
|
// The focused border survives a hover: hover alone strengthens the
|
|
95
95
|
// hairline, but hover while focused must not grey the focus colour —
|
|
96
96
|
// the stacked variant outranks plain hover by specificity.
|
|
97
|
+
// Anywhere in the `Field` around it counts as over it (Katerina, 28 September).
|
|
97
98
|
!held && 'hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus',
|
|
99
|
+
// Left out while focused or open, so their colour stays under the pointer.
|
|
100
|
+
!held && 'group-hover/field:border-border-strong group-hover/field:focus-visible:border-border-focus group-hover/field:aria-expanded:border-border-focus',
|
|
98
101
|
FIELD_DISABLED_CLASSES,
|
|
99
102
|
held && 'cursor-not-allowed bg-bg-disabled text-text-disabled',
|
|
100
103
|
'focus-visible:border-border-focus aria-expanded:border-border-focus',
|
package/src/eslint/index.test.ts
CHANGED
|
@@ -139,7 +139,7 @@ describe('an app lint with configs.recommended', () => {
|
|
|
139
139
|
[
|
|
140
140
|
'estiva/no-restyled-part',
|
|
141
141
|
2,
|
|
142
|
-
'`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant` or `
|
|
142
|
+
'`text-h2` on `Link` changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled: only space, size, flex and grid, and position pass in. Use its `variant`, `truncate` or `cover`. A look for what is around it goes on your own element around it.',
|
|
143
143
|
],
|
|
144
144
|
])
|
|
145
145
|
})
|
|
@@ -74,6 +74,9 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
74
74
|
{ name: 'style the code works out while it runs passes', filename: app, code: ui('Card', ' <Card style={props.style} />') },
|
|
75
75
|
{ name: 'style on a component that hands on only its className is not read', filename: app, code: local('ConversationCard', './components/ConversationCard', ' <ConversationCard title="x" style={{ opacity: 0.5 }} />') },
|
|
76
76
|
{ name: 'EmptyState placed', filename: app, code: ui('EmptyState', ' <EmptyState className="mt-2 flex-1" message="Nothing yet" />') },
|
|
77
|
+
{ name: 'Form placed, its fields its own', filename: app, code: ui('Form', ' <Form onSubmit={send} className="min-h-0 flex-1 px-5">x</Form>') },
|
|
78
|
+
{ name: 'a free Form lays itself out', filename: app, code: ui('Form', ' <Form onSubmit={send} layout="free" className="flex items-center gap-2">x</Form>') },
|
|
79
|
+
{ name: 'a Form whose layout the rule cannot read passes', filename: app, code: ui('Form', ' <Form onSubmit={send} layout={how} className="flex gap-2">x</Form>') },
|
|
77
80
|
// escapes
|
|
78
81
|
{ name: 'escaped, with its reason', filename: app, code: ui('Link', ' // @estiva-escape(no-restyled-part): the whole row is the link, and no part does that yet\n <Link href="/x" className="after:absolute after:inset-0">x</Link>') },
|
|
79
82
|
// the package itself
|
|
@@ -115,7 +118,7 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
115
118
|
name: 'a look behind a variant, and an extra layer',
|
|
116
119
|
filename: app,
|
|
117
120
|
code: ui('Link', ' <Link href="/x" className="signal:hover:bg-success-muted after:absolute [&>*]:shrink-0">x</Link>'),
|
|
118
|
-
errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant` or `
|
|
121
|
+
errors: [{ message: restyled('`signal:hover:bg-success-muted` `after:absolute` `[&>*]:shrink-0`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
119
122
|
},
|
|
120
123
|
{
|
|
121
124
|
name: 'an inner box’s class prop',
|
|
@@ -184,7 +187,19 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
184
187
|
name: 'a wrapper declared in the same file',
|
|
185
188
|
filename: app,
|
|
186
189
|
code: "import { Link, type LinkProps } from '@estiva-app/ui'\nfunction RouterLink({ href, ...props }: LinkProps) {\n return <Link href={href} {...props} />\n}\nexport function Probe() {\n return <RouterLink href=\"/x\" className=\"text-h5\">x</RouterLink>\n}\n",
|
|
187
|
-
errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant` or `
|
|
190
|
+
errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'a Form laid out by hand',
|
|
194
|
+
filename: app,
|
|
195
|
+
code: ui('Form', ' <Form onSubmit={send} className="flex flex-col gap-2 sm:gap-4 mt-2">x</Form>'),
|
|
196
|
+
errors: [{ message: '`flex` `flex-col` `gap-2` `sm:gap-4` lays out the fields of `Form`. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.' }],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'a Form that says fields is laid out by the Form',
|
|
200
|
+
filename: app,
|
|
201
|
+
code: ui('Form', ' <Form onSubmit={send} layout="fields" className="space-y-3">x</Form>'),
|
|
202
|
+
errors: [{ message: '`space-y-3` lays out the fields of `Form`. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.' }],
|
|
188
203
|
},
|
|
189
204
|
{
|
|
190
205
|
name: 'EmptyState padded directly',
|
|
@@ -196,19 +211,19 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
196
211
|
name: 'an escape with no reason is reported, and so is the class',
|
|
197
212
|
filename: app,
|
|
198
213
|
code: ui('Link', ' // @estiva-escape(no-restyled-part):\n <Link href="/x" className="text-h2">x</Link>'),
|
|
199
|
-
errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant` or `
|
|
214
|
+
errors: [{ messageId: 'escapeWithoutReason' }, { message: restyled('`text-h2`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
200
215
|
},
|
|
201
216
|
{
|
|
202
217
|
name: 'inside the package: a part imported from a sibling',
|
|
203
218
|
filename: pkg,
|
|
204
219
|
code: local('Link', './Link', ' <Link href="/x" className="rounded-lg border">x</Link>'),
|
|
205
|
-
errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant` or `
|
|
220
|
+
errors: [{ message: restyled('`rounded-lg` `border`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
206
221
|
},
|
|
207
222
|
{
|
|
208
223
|
name: 'inside the package: a part used in the file that declares it',
|
|
209
224
|
filename: pkg,
|
|
210
225
|
code: 'export function Link() {\n return null\n}\nexport function Probe() {\n return <Link className="text-caption" />\n}\n',
|
|
211
|
-
errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant` or `
|
|
226
|
+
errors: [{ message: restyled('`text-caption`', '`Link`', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
212
227
|
},
|
|
213
228
|
],
|
|
214
229
|
})
|
|
@@ -307,7 +322,8 @@ describe('the look props the message names are the package’s own', () => {
|
|
|
307
322
|
})
|
|
308
323
|
|
|
309
324
|
/** Named like a look, and not one: AttachmentCard's `size` is the file's, in bytes. */
|
|
310
|
-
|
|
325
|
+
// Form's `layout` says whether it places its fields at all, not how anything looks.
|
|
326
|
+
const NOT_A_LOOK: Record<string, string[]> = { AttachmentCard: ['size'], Form: ['layout'] }
|
|
311
327
|
|
|
312
328
|
it('every look prop a part has is named', () => {
|
|
313
329
|
for (const part of PARTS) {
|
|
@@ -36,6 +36,10 @@ import { ESCAPE_MESSAGES, isEscaped } from './escape'
|
|
|
36
36
|
*
|
|
37
37
|
* `EmptyState` takes no padding either (Katerina's ruling 6 of 14 September):
|
|
38
38
|
* its room comes from the box its rows live in.
|
|
39
|
+
*
|
|
40
|
+
* `Form` takes no layout for its fields (Katerina, 28 September): it stacks
|
|
41
|
+
* them 24px apart itself, so every form reads the same. A form that is not a
|
|
42
|
+
* list of fields says `layout="free"`, and then lays itself out.
|
|
39
43
|
*/
|
|
40
44
|
|
|
41
45
|
// ---------------------------------------------------------------- the classes
|
|
@@ -93,6 +97,14 @@ export function isPlacement(token: string): boolean {
|
|
|
93
97
|
return PLACEMENT.some(({ pattern }) => pattern.test(utility))
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/** How a form's fields are laid out: its display, direction, gaps and alignment. */
|
|
101
|
+
const FIELDS_LAYOUT = /^(?:block|flex|grid|inline-flex|inline-grid|contents|flow-root)$|^(?:gap|gap-x|gap-y|space-x|space-y|grid-cols|grid-rows|grid-flow|auto-cols|auto-rows|items|justify|justify-items|place-content|place-items)-|^flex-(?:row|col|wrap|nowrap)|^content-(?!\[)/
|
|
102
|
+
|
|
103
|
+
const isFieldsLayout = (token: string) => {
|
|
104
|
+
const { variants, utility } = splitClass(token)
|
|
105
|
+
return !variants.some((variant) => REACHING_VARIANT.test(variant)) && FIELDS_LAYOUT.test(utility)
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
const isPadding = (token: string) => {
|
|
97
109
|
const { variants, utility } = splitClass(token)
|
|
98
110
|
return !variants.some((variant) => REACHING_VARIANT.test(variant)) && /^p[xytrblse]?-/.test(utility)
|
|
@@ -119,7 +131,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
119
131
|
IconButton: ['variant', 'pressed', 'glow'],
|
|
120
132
|
InputChip: ['truncate'],
|
|
121
133
|
Kbd: ['tone'],
|
|
122
|
-
Link: ['variant', 'truncate'],
|
|
134
|
+
Link: ['variant', 'truncate', 'cover'],
|
|
123
135
|
MenuItem: ['size', 'selected', 'destructive'],
|
|
124
136
|
MenuSub: ['selected'],
|
|
125
137
|
NavItem: ['active'],
|
|
@@ -608,6 +620,8 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
608
620
|
'{{keys}} in the `style` of {{where}} changes how it looks. A part of @estiva-app/ui is placed from outside, never restyled, by class or by style: only space, size, flex and grid, and position pass in.{{use}} A look for what is around it goes on your own element around it.',
|
|
609
621
|
emptyStatePadding:
|
|
610
622
|
'{{classes}} pads {{where}}. EmptyState takes no padding (Katerina, 14 September): its room comes from the box its rows live in, so put the padding on that box.',
|
|
623
|
+
formLayout:
|
|
624
|
+
'{{classes}} lays out the fields of {{where}}. A Form places its own fields, 24px apart (Katerina, 28 September), so every form reads the same: remove it. A form that is not a list of fields (a field and its button, a composer) says `layout="free"` and lays itself out.',
|
|
611
625
|
...ESCAPE_MESSAGES,
|
|
612
626
|
},
|
|
613
627
|
},
|
|
@@ -623,6 +637,15 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
623
637
|
return escapes.get(anchor) === true
|
|
624
638
|
}
|
|
625
639
|
|
|
640
|
+
/** A `Form` lays out its own fields unless it says `layout="free"`; a layout the rule cannot read passes. */
|
|
641
|
+
const placesItsFields = (element: Node) =>
|
|
642
|
+
!children(element, 'attributes').some((attribute) => {
|
|
643
|
+
if (attribute?.type !== 'JSXAttribute' || nameOf(child(attribute, 'name')) !== 'layout') return false
|
|
644
|
+
const value = child(attribute, 'value')
|
|
645
|
+
const literal = value?.type === 'JSXExpressionContainer' ? child(value, 'expression') : value
|
|
646
|
+
return !(literal?.type === 'Literal' && literal.value === 'fields')
|
|
647
|
+
})
|
|
648
|
+
|
|
626
649
|
const check = (element: Node, binding: PartBinding, prop: string, value: Node | null | undefined, at: Node) => {
|
|
627
650
|
const partProp = binding.props === 'all' ? prop : binding.props[prop]
|
|
628
651
|
if (!partProp || !value) return
|
|
@@ -632,7 +655,8 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
632
655
|
const where = `${via}${partProp === 'className' ? '' : `'s \`${partProp}\``}`
|
|
633
656
|
const restyled = [...new Set(classes.filter((token) => !isPlacement(token)))]
|
|
634
657
|
const padding = binding.part === 'EmptyState' && binding.props === 'all' ? [...new Set(classes.filter(isPadding))] : []
|
|
635
|
-
|
|
658
|
+
const layout = binding.part === 'Form' && partProp === 'className' && placesItsFields(element) ? [...new Set(classes.filter(isFieldsLayout))] : []
|
|
659
|
+
if (!restyled.length && !padding.length && !layout.length) return
|
|
636
660
|
if (escaped(element)) return
|
|
637
661
|
if (restyled.length) {
|
|
638
662
|
const props = PART_LOOK_PROPS[binding.part]
|
|
@@ -640,6 +664,7 @@ export const noRestyledPart: Rule.RuleModule = {
|
|
|
640
664
|
context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'restyled', data: { classes: code(restyled), where, use } })
|
|
641
665
|
}
|
|
642
666
|
if (padding.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'emptyStatePadding', data: { classes: code(padding), where } })
|
|
667
|
+
if (layout.length) context.report({ loc: at.loc as NonNullable<Rule.Node['loc']>, messageId: 'formLayout', data: { classes: code(layout), where } })
|
|
643
668
|
}
|
|
644
669
|
|
|
645
670
|
/** `style` on a part: every key it can read must be placement (B6). */
|
package/src/gates/app-checks.ts
CHANGED
|
@@ -264,6 +264,20 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
264
264
|
return own.length ? h.FAIL(`draws the text tree itself (${own.join(', ')}): pass the tree to RichText`) : h.PASS('formatted text is drawn by RichText only')
|
|
265
265
|
} },
|
|
266
266
|
]),
|
|
267
|
+
// UIG-33, Katerina's picks of 28 September: a row that is one link is `Link` with `cover`, and a
|
|
268
|
+
// chip that is a link is `Chip` with `href`. Before, both apps stretched a link over its row by
|
|
269
|
+
// hand (`after:absolute`) and rounded a Link round a chip, each under an escape.
|
|
270
|
+
ticket('UIG-33', [
|
|
271
|
+
{ what: 'the installed package has Link cover and Chip href', run: all(h, [
|
|
272
|
+
() => h.contains(at('node_modules/@estiva-app/ui/dist/Link.d.ts'), /\bcover\?: boolean/, 'the installed Link takes cover'),
|
|
273
|
+
() => h.contains(at('node_modules/@estiva-app/ui/dist/Chip.d.ts'), /\bhref\?: string/, 'the installed Chip takes href'),
|
|
274
|
+
], 'the installed Link takes cover and Chip takes href') },
|
|
275
|
+
{ what: 'no link is stretched over its row by hand', run: () => {
|
|
276
|
+
const sources = h.listFiles(at('src'), (n) => /\.tsx$/.test(n) && !/\.(test|stories)\.tsx$/.test(n))
|
|
277
|
+
const own = sources.filter((f) => /<(?:Link|a)\b[^>]*\bafter:(?:absolute|inset-0)\b/.test(h.read(f)))
|
|
278
|
+
return own.length ? h.FAIL(`stretches a link by hand (${own.join(', ')}): give the Link cover`) : h.PASS('every whole-row link is Link with cover')
|
|
279
|
+
} },
|
|
280
|
+
]),
|
|
267
281
|
// Katerina's ruling of 17 September (docs/GATES.md §23): one copy, in the package. An app made
|
|
268
282
|
// by create-estiva-app passes this from its first commit; Peek and Ship do once UIG-32 lands.
|
|
269
283
|
ticket('UIG-32', [
|
|
@@ -194,8 +194,13 @@ describe('a made app, under the checks every app runs', () => {
|
|
|
194
194
|
const real = helpers(dir)
|
|
195
195
|
// "The installed package exports Link" reads its declarations, which CI's
|
|
196
196
|
// test step does not build (`pretest` bundles only): the same exports are read
|
|
197
|
-
// from their source when the declarations are not there.
|
|
198
|
-
|
|
197
|
+
// from their source when the declarations are not there. A part's own props
|
|
198
|
+
// (UIG-33: `Link.d.ts`) are read from its source file the same way.
|
|
199
|
+
const exportsFrom = (rel: string) => {
|
|
200
|
+
const declared = rel.replace(/\\/g, '/').match(/dist\/(\w+)\.d\.ts$/)
|
|
201
|
+
if (!declared || real.exists(rel)) return rel
|
|
202
|
+
return declared[1] === 'index' ? join(process.cwd(), 'src', 'index.ts') : join(process.cwd(), 'src', `${declared[1]}.tsx`)
|
|
203
|
+
}
|
|
199
204
|
const h: GateHelpers = {
|
|
200
205
|
...real,
|
|
201
206
|
contains: (rel, needle, label) => real.contains(exportsFrom(rel), needle, label),
|
package/src/looks.ts
CHANGED
|
@@ -11,14 +11,16 @@
|
|
|
11
11
|
* much room it takes stay in the part.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A field's box: TextInput and Textarea. The hairline strengthens on hover — of the box, or of anywhere in the `Field` around it, label included, so the field is one thing under the pointer (Katerina, 28 September) — and not while switched off: a label passes its hover to its box, switched off or not (FIELD_DISABLED_CLASSES holds it back). Focus outranks it, so a focused field keeps its colour under the pointer.
|
|
16
|
+
*/
|
|
17
|
+
export const FIELD_BOX_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong group-hover/field:border-border-strong focus:border-border-focus group-hover/field:focus:border-border-focus rounded-lg'
|
|
16
18
|
|
|
17
19
|
/** What a field's words look like, typed or waiting: TextInput, Textarea and Select. */
|
|
18
20
|
export const FIELD_TEXT_CLASSES = 'text-text-primary placeholder:text-text-muted'
|
|
19
21
|
|
|
20
22
|
/** A field that cannot be used: TextInput, Textarea and Select. */
|
|
21
|
-
export const FIELD_DISABLED_CLASSES = 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled'
|
|
23
|
+
export const FIELD_DISABLED_CLASSES = 'disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field group-hover/field:disabled:border-border-field'
|
|
22
24
|
|
|
23
25
|
/** The ring a focused field wears in Signal: TextInput and Textarea. */
|
|
24
26
|
export const FIELD_FOCUS_RING_CLASSES = 'signal:transition-shadow signal:focus:shadow-focus-ring'
|
|
@@ -30,7 +32,7 @@ export const FIELD_SIZE_CLASSES = {
|
|
|
30
32
|
} as const
|
|
31
33
|
|
|
32
34
|
/** The box around an input that sits inside it, before its focus look: SearchInput and ChipInput. */
|
|
33
|
-
export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong rounded-lg transition-colors'
|
|
35
|
+
export const FIELD_SHELL_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong group-hover/field:border-border-strong rounded-lg transition-colors'
|
|
34
36
|
|
|
35
37
|
/** A panel that floats over the page: Menu's panel and Tooltip. */
|
|
36
38
|
export const FLOATING_SURFACE_CLASSES = 'rounded-lg border border-border-default bg-bg-elevated shadow-lg'
|