@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.
@@ -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
- const NOT_A_LOOK: Record<string, string[]> = { AttachmentCard: ['size'] }
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
- if (!restyled.length && !padding.length) return
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
- /** A field's box: TextInput and Textarea. The hairline strengthens on hover; focus comes after it, so a focused field keeps its colour under the pointer. */
15
- export const FIELD_BOX_CLASSES = 'bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg'
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 90 names over 59 files: 82 components, 7 helpers and 1 hook (RichText and richTextClassName joined on 28 September)', () => {
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: 82, helper: 7, hook: 1 })
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(31)
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', () => {