@estiva-app/ui 0.41.0 → 0.43.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/CommandPalette.d.ts +16 -0
- 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/Select.d.ts.map +1 -1
- package/dist/eslint/index.js +15 -1
- 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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -17
- package/dist/index.js.map +2 -2
- package/dist/looks.d.ts +6 -4
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +67 -16
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.mdx +6 -0
- package/src/CommandPalette.stories.tsx +26 -1
- package/src/CommandPalette.test.tsx +14 -0
- package/src/CommandPalette.tsx +35 -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/ListColumn.stories.tsx +1 -1
- package/src/Select.tsx +3 -0
- package/src/eslint/no-restyled-part.test.ts +17 -1
- package/src/eslint/no-restyled-part.ts +26 -1
- package/src/index.ts +2 -0
- package/src/looks.ts +6 -4
- package/src/registry/registry.test.ts +3 -3
|
@@ -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',
|
|
@@ -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
|
|
@@ -186,6 +189,18 @@ tester.run('no-restyled-part', noRestyledPart, {
|
|
|
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
190
|
errors: [{ message: restyled('`text-h5`', '`RouterLink` (it hands its props to `Link`)', ' Use its `variant`, `truncate` or `cover`.') }],
|
|
188
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.' }],
|
|
203
|
+
},
|
|
189
204
|
{
|
|
190
205
|
name: 'EmptyState padded directly',
|
|
191
206
|
filename: app,
|
|
@@ -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)
|
|
@@ -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/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
CommandPalette,
|
|
23
23
|
CommandPaletteAnswer,
|
|
24
24
|
CommandPaletteForm,
|
|
25
|
+
CommandPaletteProposal,
|
|
25
26
|
CommandPaletteQuote,
|
|
26
27
|
CommandPaletteSearch,
|
|
27
28
|
CommandPaletteWorking,
|
|
@@ -30,6 +31,7 @@ export {
|
|
|
30
31
|
type CommandPaletteFormProps,
|
|
31
32
|
type CommandPaletteGroup,
|
|
32
33
|
type CommandPaletteProps,
|
|
34
|
+
type CommandPaletteProposalProps,
|
|
33
35
|
type CommandPaletteQuoteProps,
|
|
34
36
|
type CommandPaletteRow,
|
|
35
37
|
type CommandPaletteSearchProps,
|
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'
|
|
@@ -45,17 +45,17 @@ describe('the registry builds', () => {
|
|
|
45
45
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
it('counts
|
|
48
|
+
it('counts 91 names over 59 files: 83 components, 7 helpers and 1 hook (CommandPaletteProposal joined on 28 September)', () => {
|
|
49
49
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
50
50
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
51
|
-
expect(kinds).toEqual({ component:
|
|
51
|
+
expect(kinds).toEqual({ component: 83, helper: 7, hook: 1 })
|
|
52
52
|
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(59)
|
|
53
53
|
})
|
|
54
54
|
|
|
55
55
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
56
56
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
57
57
|
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(59)
|
|
58
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(
|
|
58
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(32)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
61
|
it('takes a name with a page of its own from the page, and a name without one from the code', () => {
|