@estiva-app/ui 0.41.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/looks.d.ts CHANGED
@@ -10,12 +10,14 @@
10
10
  * Only looks — colour, text, border, corner, shadow. Where a part sits and how
11
11
  * much room it takes stay in the part.
12
12
  */
13
- /** 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. */
14
- export declare const FIELD_BOX_CLASSES = "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg";
13
+ /**
14
+ * 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.
15
+ */
16
+ export declare 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";
15
17
  /** What a field's words look like, typed or waiting: TextInput, Textarea and Select. */
16
18
  export declare const FIELD_TEXT_CLASSES = "text-text-primary placeholder:text-text-muted";
17
19
  /** A field that cannot be used: TextInput, Textarea and Select. */
18
- export declare const FIELD_DISABLED_CLASSES = "disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled";
20
+ export declare 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";
19
21
  /** The ring a focused field wears in Signal: TextInput and Textarea. */
20
22
  export declare const FIELD_FOCUS_RING_CLASSES = "signal:transition-shadow signal:focus:shadow-focus-ring";
21
23
  /** A field's two sizes, its padding and its words: TextInput, Textarea (default only) and Select. The small one is the small Select's trigger. */
@@ -24,7 +26,7 @@ export declare const FIELD_SIZE_CLASSES: {
24
26
  readonly small: "h-6 min-h-6 px-2 text-caption";
25
27
  };
26
28
  /** The box around an input that sits inside it, before its focus look: SearchInput and ChipInput. */
27
- export declare const FIELD_SHELL_CLASSES = "bg-bg-field border border-border-field shadow-field hover:border-border-strong rounded-lg transition-colors";
29
+ export declare 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";
28
30
  /** A panel that floats over the page: Menu's panel and Tooltip. */
29
31
  export declare const FLOATING_SURFACE_CLASSES = "rounded-lg border border-border-default bg-bg-elevated shadow-lg";
30
32
  /** A chip's words: Chip's label and Reaction's count. */
@@ -1 +1 @@
1
- {"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,6JAA6J;AAC7J,eAAO,MAAM,iBAAiB,wHAAwH,CAAA;AAEtJ,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,qFAAqF,CAAA;AAExH,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,gHAAgH,CAAA;AAEhJ,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA"}
1
+ {"version":3,"file":"looks.d.ts","sourceRoot":"","sources":["../src/looks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;GAEG;AACH,eAAO,MAAM,iBAAiB,2MAA2M,CAAA;AAEzO,wFAAwF;AACxF,eAAO,MAAM,kBAAkB,kDAAkD,CAAA;AAEjF,mEAAmE;AACnE,eAAO,MAAM,sBAAsB,iKAAiK,CAAA;AAEpM,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,4DAA4D,CAAA;AAEjG,kJAAkJ;AAClJ,eAAO,MAAM,kBAAkB;;;CAGrB,CAAA;AAEV,qGAAqG;AACrG,eAAO,MAAM,mBAAmB,uJAAuJ,CAAA;AAEvL,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,qEAAqE,CAAA;AAE1G,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,0FAA0F,CAAA;AAExH,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,8EAA8E,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.41.0",
3
+ "version": "0.42.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.41.0",
7
+ "packageVersion": "0.42.0",
8
8
  "exports": 90,
9
9
  "typeExports": 101,
10
10
  "files": null
@@ -966,7 +966,6 @@
966
966
  "looks": [
967
967
  "bg-bg-elevated border border-border-subtle rounded-md",
968
968
  "bg-bg-field border border-border-field shadow-field hover:border-border-strong rounded-lg transition-colors focus-within:border-border-focus focus-within:hover:border-border-focus cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring",
969
- "focus-within:border-border-focus focus-within:hover:border-border-focus cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring",
970
969
  "text-body-2 border-none bg-transparent text-text-primary placeholder:text-text-muted outline-none"
971
970
  ]
972
971
  },
@@ -2109,6 +2108,12 @@
2109
2108
  "required": false,
2110
2109
  "note": "Whether Enter in a one-line field sends."
2111
2110
  },
2111
+ {
2112
+ "name": "layout",
2113
+ "takes": "fields | free",
2114
+ "required": false,
2115
+ "note": "`fields`, the default: everything inside stacks 24px apart."
2116
+ },
2112
2117
  {
2113
2118
  "name": "children",
2114
2119
  "takes": "anything",
@@ -2116,7 +2121,15 @@
2116
2121
  "note": null
2117
2122
  }
2118
2123
  ],
2119
- "variants": [],
2124
+ "variants": [
2125
+ {
2126
+ "prop": "layout",
2127
+ "values": [
2128
+ "fields",
2129
+ "free"
2130
+ ]
2131
+ }
2132
+ ],
2120
2133
  "ownsBehaviours": [
2121
2134
  {
2122
2135
  "id": "base-ui",
@@ -4596,14 +4609,14 @@
4596
4609
  "docPage": "src/Select.mdx",
4597
4610
  "app": null,
4598
4611
  "looks": [
4599
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4600
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4601
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4602
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4603
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4604
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4605
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4606
- "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4612
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4613
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4614
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4615
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4616
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4617
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4618
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-input-value",
4619
+ "rounded-lg border bg-bg-field shadow-field text-left border-border-field outline-none transition-colors text-text-primary placeholder:text-text-muted hover:border-border-strong focus-visible:hover:border-border-focus aria-expanded:hover:border-border-focus disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field cursor-not-allowed bg-bg-disabled text-text-disabled focus-visible:border-border-focus aria-expanded:border-border-focus signal:transition-shadow signal:focus-visible:shadow-focus-ring text-caption",
4607
4620
  "rounded-lg border bg-bg-field shadow-field text-left"
4608
4621
  ]
4609
4622
  },
@@ -4851,7 +4864,7 @@
4851
4864
  "docPage": "src/Textarea.mdx",
4852
4865
  "app": null,
4853
4866
  "looks": [
4854
- "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-input-value text-text-primary placeholder:text-text-muted resize-none outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled signal:transition-shadow signal:focus:shadow-focus-ring"
4867
+ "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-input-value text-text-primary placeholder:text-text-muted resize-none outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field signal:transition-shadow signal:focus:shadow-focus-ring"
4855
4868
  ]
4856
4869
  },
4857
4870
  {
@@ -4892,8 +4905,8 @@
4892
4905
  "docPage": "src/TextInput.mdx",
4893
4906
  "app": null,
4894
4907
  "looks": [
4895
- "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-input-value text-text-primary placeholder:text-text-muted outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled signal:transition-shadow signal:focus:shadow-focus-ring",
4896
- "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-caption text-text-primary placeholder:text-text-muted outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled signal:transition-shadow signal:focus:shadow-focus-ring"
4908
+ "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-input-value text-text-primary placeholder:text-text-muted outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field signal:transition-shadow signal:focus:shadow-focus-ring",
4909
+ "bg-bg-field border border-border-field shadow-field hover:border-border-strong focus:border-border-focus rounded-lg text-caption text-text-primary placeholder:text-text-muted outline-none transition-colors disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-field signal:transition-shadow signal:focus:shadow-focus-ring"
4897
4910
  ]
4898
4911
  },
4899
4912
  {
package/src/ChipInput.tsx CHANGED
@@ -246,7 +246,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
246
246
  >
247
247
  {/* The box the chips and the input share. `Combobox.Chips` is what makes
248
248
  the two one control for the keyboard; the look is what it always was. */}
249
- <Combobox.Chips ref={setBox} className={cn(FIELD_SHELL_CLASSES, 'focus-within:border-border-focus focus-within:hover:border-border-focus px-3 py-1.5 flex flex-wrap items-center gap-1.5 min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring')}>
249
+ <Combobox.Chips ref={setBox} className={cn(FIELD_SHELL_CLASSES, 'focus-within:border-border-focus focus-within:hover:border-border-focus group-hover/field:focus-within:border-border-focus px-3 py-1.5 flex flex-wrap items-center gap-1.5 min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring')}>
250
250
  {value.map((option) => (
251
251
  <Combobox.Chip key={option.id} className={cn(CHIP_BOX, chipPadding(!!chipLeading, true))}>
252
252
  {chipLeading && <span className="flex shrink-0 items-center">{chipLeading(option)}</span>}
@@ -19,7 +19,7 @@ import { Button } from './Button'
19
19
  import { InputChip } from './ChipInput'
20
20
  import { EmptyState } from './EmptyState'
21
21
  import { FieldLine } from './Field'
22
- import { Form } from './Form'
22
+ import { FIELDS_CLASSES, Form } from './Form'
23
23
  import { Kbd } from './Kbd'
24
24
  import { EnterHint, MenuItemBody, menuItemClassName } from './Menu'
25
25
  import { ScrollArea } from './ScrollArea'
@@ -563,10 +563,12 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
563
563
  {/* The package Form, from the fields to the button (UIG-7). The chip row
564
564
  stays outside it, so its ✕ still goes back while the form works; the
565
565
  button row is inside it, so focus that was on the button is held.
566
- The fields are found through data-command-palette-fields. */}
567
- <Form data-command-palette-fields="" busy={busy} enterSends={false} onSubmit={submit} className="flex min-h-0 flex-col">
566
+ The fields are found through data-command-palette-fields. The form is
567
+ `free`, a scrolling body over a button row; the fields inside stack as
568
+ every form's do, 24px apart (Katerina, 28 September: they were 16). */}
569
+ <Form data-command-palette-fields="" busy={busy} enterSends={false} onSubmit={submit} layout="free" className="flex min-h-0 flex-col">
568
570
  <ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
569
- <div className="flex flex-col gap-4">{children}</div>
571
+ <div className={FIELDS_CLASSES}>{children}</div>
570
572
  </ScrollArea>
571
573
  <div className="flex shrink-0 items-center gap-3 px-5 pb-4">
572
574
  <div className="min-w-0 flex-1">
@@ -6,6 +6,7 @@ import { Chip } from './Chip'
6
6
  import { DialogShell } from './DialogShell'
7
7
  import { IconButton } from './IconButton'
8
8
  import { Field } from './Field'
9
+ import { Form } from './Form'
9
10
  import { TextInput } from './TextInput'
10
11
  import { Textarea } from './Textarea'
11
12
 
@@ -32,22 +33,22 @@ type Story = StoryObj<typeof meta>
32
33
  */
33
34
  export const Default: Story = {
34
35
  args: {
35
- bodyClassName: 'flex flex-col gap-6',
36
36
  footer: (
37
37
  <>
38
38
  <Button variant="muted">Cancel</Button>
39
39
  <Button variant="primary">Confirm</Button>
40
40
  </>
41
41
  ),
42
+ // In a Form, as a dialog's fields always are: it places them 24px apart.
42
43
  children: (
43
- <>
44
+ <Form onSubmit={() => {}} aria-label="Example">
44
45
  <Field label="Title" required>
45
46
  <TextInput placeholder="What is this about?" />
46
47
  </Field>
47
48
  <Field label="Notes">
48
49
  <Textarea placeholder="Optional notes…" className="h-[80px]" />
49
50
  </Field>
50
- </>
51
+ </Form>
51
52
  ),
52
53
  },
53
54
  }
package/src/Field.mdx CHANGED
@@ -53,6 +53,10 @@ import { Field, TextInput } from '@estiva-app/ui'
53
53
  `Field` has no `required` of its own, so the Field puts it there. A control
54
54
  that says so already is left alone, and a `children` of several elements
55
55
  keeps the asterisk and owes its own.
56
+ - **The whole field is one thing under the pointer** (Katerina, 28
57
+ September): the label, the space under it, the box and the line under the
58
+ box. Anywhere on it strengthens the box's border, as hovering the box does;
59
+ a focused box keeps its focus colour, and a switched-off one stays as it is.
56
60
 
57
61
  ## What it owns
58
62
 
@@ -62,6 +66,7 @@ It does these by itself. Write none of them around it.
62
66
  |---|---|---|
63
67
  | Ties the label, the description and the error to the control inside it | `aria-label`, an `id` / `htmlFor` pair, or `aria-describedby` | |
64
68
  | Marks the control invalid when it has an `error`, and announces it | `aria-invalid` | |
69
+ | Strengthens the box's border while the pointer is anywhere on the field | a hover of your own on the label or around the box | |
65
70
 
66
71
  ## Props
67
72
 
package/src/Field.tsx CHANGED
@@ -83,7 +83,10 @@ export function Field({ label, required = false, helper, error, children }: Fiel
83
83
  })
84
84
  : children
85
85
  return (
86
- <BaseField.Root invalid={!!error} className="flex flex-col gap-2">
86
+ // `group/field`: the label, the box and the line under it are one thing under the pointer —
87
+ // anywhere in it strengthens the box's border, as hovering the box does (Katerina, 28 September).
88
+ // Before, the 8px between the label and the box was a gap where the border went out again.
89
+ <BaseField.Root invalid={!!error} className="group/field flex flex-col gap-2">
87
90
  {/* `cn`, like everywhere else. It was a template literal, with a comment
88
91
  saying the type token must never be merged — which stopped being true
89
92
  when `cn()` was taught the ramp: `input-label` is in it, and
package/src/Form.mdx CHANGED
@@ -27,7 +27,7 @@ is switched off at once.
27
27
  ```tsx
28
28
  import { Button, Field, Form, TextInput } from '@estiva-app/ui'
29
29
 
30
- <Form onSubmit={send} busy={sending} className="flex flex-col gap-6">
30
+ <Form onSubmit={send} busy={sending}>
31
31
  <Field label="Title" required error={titleError}>
32
32
  <TextInput value={title} onChange={(e) => { setTitle(e.target.value); setTitleError(undefined) }} />
33
33
  </Field>
@@ -55,8 +55,13 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
55
55
  standing keeps the form from ever sending.
56
56
  - The browser's own validation bubbles are off (`noValidate`). `Field`'s
57
57
  `required` marks a field; it does not block sending by itself.
58
- - `className` places the fields: the form draws no box, and neither does the
59
- `<fieldset>` inside it.
58
+ - **The form places its fields: one column, 24px apart** (Katerina, 28
59
+ September), so every form reads the same. Pass no layout of your own: the
60
+ gate lint refuses a gap, a direction or a grid on a `Form`. `className`
61
+ still places the form itself: its space around, its size.
62
+ - **`layout="free"`** is for a form that is not a list of fields: a field and
63
+ its button, a composer. It places nothing, and `className` lays it out.
64
+ - The form draws no box, and neither does the `<fieldset>` inside it.
60
65
 
61
66
  ## What it owns
62
67
 
@@ -67,6 +72,7 @@ It does these by itself. Write none of them around it.
67
72
  | Sends on Enter in a one-line field, and on Ctrl+Enter anywhere inside | a key handler that submits | |
68
73
  | Leaves Enter alone where it means something else: a Textarea, a list, a people picker | exceptions of your own | |
69
74
  | Does nothing while it is sending: no second send, and nothing inside takes focus | a busy flag on every control | |
75
+ | Stacks its fields in one column, 24px apart | a gap, a direction or a grid on the form | `no-restyled-part` |
70
76
 
71
77
  ## Keys
72
78
 
@@ -11,7 +11,7 @@ const meta = {
11
11
  title: 'Inputs/Form',
12
12
  component: Form,
13
13
  parameters: { layout: 'padded' },
14
- args: { onSubmit: fn(), busy: false, className: 'flex flex-col gap-6', 'aria-label': 'Example', children: null },
14
+ args: { onSubmit: fn(), busy: false, 'aria-label': 'Example', children: null },
15
15
  argTypes: { children: { control: false }, onSubmit: { control: false } },
16
16
  decorators: [(Story) => <div className="w-96"><Story /></div>],
17
17
  } satisfies Meta<typeof Form>
@@ -35,6 +35,7 @@ const fields = (error?: string) => (
35
35
  </>
36
36
  )
37
37
 
38
+ /** A list of fields: the form stacks them 24px apart itself. */
38
39
  export const Default: Story = { render: (args) => <Form {...args}>{fields()}</Form> }
39
40
 
40
41
  /** Sending: every field and button inside is switched off at once. */
@@ -43,6 +44,22 @@ export const Busy: Story = { args: { busy: true }, render: (args) => <Form {...a
43
44
  /** A field showing its error. The form does not send while it shows. */
44
45
  export const WithError: Story = { render: (args) => <Form {...args}>{fields('That is not a valid value.')}</Form> }
45
46
 
47
+ /**
48
+ * **`layout="free"`**: a form that is not a list of fields, here a field and
49
+ * its button, places nothing, and lays itself out through `className`.
50
+ */
51
+ export const Free: Story = {
52
+ args: { layout: 'free', className: 'flex items-center gap-2' },
53
+ render: (args) => (
54
+ <Form {...args}>
55
+ <TextInput placeholder="Add a name" aria-label="Name" className="min-w-0 flex-1" />
56
+ <Button variant="primary" type="submit">
57
+ Add
58
+ </Button>
59
+ </Form>
60
+ ),
61
+ }
62
+
46
63
  /**
47
64
  * Live. Press Enter in the first field, or Send: the form is busy for a
48
65
  * second and a half, and focus comes back to where it was.
package/src/Form.test.tsx CHANGED
@@ -26,7 +26,7 @@ afterEach(cleanup)
26
26
 
27
27
  function Harness({ onSubmit = vi.fn(), error, busy = false }: { onSubmit?: () => void; error?: string; busy?: boolean }) {
28
28
  return (
29
- <Form onSubmit={onSubmit} busy={busy} aria-label="Probe" className="flex flex-col gap-6" id="probe">
29
+ <Form onSubmit={onSubmit} busy={busy} aria-label="Probe" id="probe">
30
30
  <Field label="First">
31
31
  <TextInput />
32
32
  </Field>
@@ -213,13 +213,27 @@ describe('Form', () => {
213
213
  expect(fireEvent.submit(form)).toBe(false)
214
214
  })
215
215
 
216
- it('is a form with no browser validation bubbles, and passes id and className on', () => {
216
+ it('is a form with no browser validation bubbles, and passes id on', () => {
217
217
  render(<Harness />)
218
218
  const form = screen.getByRole('form', { name: 'Probe' }) as HTMLFormElement
219
219
  expect(form.tagName).toBe('FORM')
220
220
  expect(form.noValidate).toBe(true)
221
221
  expect(form.id).toBe('probe')
222
- expect(form.className).toContain('flex-col')
222
+ })
223
+
224
+ it('stacks its fields 24px apart itself, and a free form places nothing', () => {
225
+ render(
226
+ <>
227
+ <Form onSubmit={() => {}} aria-label="Fields" className="px-5">x</Form>
228
+ <Form onSubmit={() => {}} aria-label="Free" layout="free" className="flex items-center gap-2">x</Form>
229
+ </>,
230
+ )
231
+ const fields = screen.getByRole('form', { name: 'Fields' }).className.split(' ')
232
+ expect(fields).toEqual(expect.arrayContaining(['flex', 'flex-col', 'gap-6', 'px-5']))
233
+ const free = screen.getByRole('form', { name: 'Free' }).className.split(' ')
234
+ expect(free).not.toContain('flex-col')
235
+ expect(free).not.toContain('gap-6')
236
+ expect(free).toEqual(expect.arrayContaining(['flex', 'items-center', 'gap-2']))
223
237
  })
224
238
 
225
239
  it('draws no box of its own around the fields', () => {
package/src/Form.tsx CHANGED
@@ -37,6 +37,15 @@ import { FormBusyContext, useFormBusy } from './formBusy'
37
37
  * from sending, for a form where only Ctrl+Enter may send (`CommandPalette`).
38
38
  * Every way of sending goes through the form's submit, so Base UI's field
39
39
  * check runs for each.
40
+ *
41
+ * **The form places its fields** (Katerina, 28 September). It drew no box and
42
+ * left the space between fields to every caller, and they chose three: Peek's
43
+ * dialogs 8px — the label's own distance from its box, so a label sat as close
44
+ * to the field above it as to its own — Ship's 24px, and the package's own
45
+ * launcher 16px. Everything inside now stacks 24px apart, and the gate lint
46
+ * refuses a layout passed in (`no-restyled-part`). A form that is not a list
47
+ * of fields — a field and its button, a composer — says `layout="free"` and
48
+ * lays itself out.
40
49
  */
41
50
  export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children' | 'noValidate'> {
42
51
  /** Enter in a field, or a submit button. The page's own submit is already prevented. */
@@ -48,9 +57,18 @@ export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'on
48
57
  * Ctrl+Enter may send — a form inside `CommandPalette`. Ctrl+Enter sends either way.
49
58
  */
50
59
  enterSends?: boolean
60
+ /**
61
+ * `fields`, the default: everything inside stacks 24px apart. `free`: the
62
+ * form places nothing, for a form that is not a list of fields — a field and
63
+ * its button, a composer — which lays itself out through `className`.
64
+ */
65
+ layout?: 'fields' | 'free'
51
66
  children: ReactNode
52
67
  }
53
68
 
69
+ /** A list of fields: one column, 24px apart. `CommandPalette` stacks its fields with it too. */
70
+ export const FIELDS_CLASSES = 'flex flex-col gap-6'
71
+
54
72
  const CONTROL = 'input:not([type="hidden"]), textarea, select, button, [role="checkbox"], [role="combobox"], [tabindex]:not([tabindex="-1"])'
55
73
 
56
74
  /** The inputs a person types one line into; a picker's input (`role="combobox"`) is not one. */
@@ -70,7 +88,7 @@ function firstInvalid(form: HTMLElement): HTMLElement | undefined {
70
88
  return undefined
71
89
  }
72
90
 
73
- export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, className, children, ...props }: FormProps) {
91
+ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, layout = 'fields', className, children, ...props }: FormProps) {
74
92
  // A form inside a busy form is busy too.
75
93
  const outerBusy = useFormBusy()
76
94
  const busy = ownBusy || outerBusy
@@ -148,7 +166,7 @@ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, class
148
166
  // Focus waits here only while busy; the rest of the time a click between two fields must not land on the form.
149
167
  tabIndex={busy ? -1 : props.tabIndex}
150
168
  // A form that holds focus for a moment must not draw a focus ring.
151
- className={cn('outline-none', className)}
169
+ className={cn('outline-none', layout === 'fields' && FIELDS_CLASSES, className)}
152
170
  onFocus={(event) => {
153
171
  if (event.target !== form.current) lastInside.current = event.target
154
172
  props.onFocus?.(event)
@@ -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/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'