@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/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.43.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,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.41.0",
8
- "exports": 90,
9
- "typeExports": 101,
7
+ "packageVersion": "0.43.0",
8
+ "exports": 91,
9
+ "typeExports": 102,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -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
  },
@@ -1281,6 +1280,44 @@
1281
1280
  "app": null,
1282
1281
  "looks": []
1283
1282
  },
1283
+ {
1284
+ "name": "CommandPaletteProposal",
1285
+ "repo": "estiva-ui",
1286
+ "kind": "component",
1287
+ "importPath": "@estiva-app/ui",
1288
+ "sourceFile": "src/CommandPalette.tsx",
1289
+ "purpose": "Something the palette offers to make, before anything is made: a card with where it would be made on top, then what it would be.",
1290
+ "purposeFrom": "comment",
1291
+ "props": [
1292
+ {
1293
+ "name": "source",
1294
+ "takes": "text",
1295
+ "required": true,
1296
+ "note": "Where it would be made: the app and the action — \"App › New item\"."
1297
+ },
1298
+ {
1299
+ "name": "mark",
1300
+ "takes": "anything",
1301
+ "required": false,
1302
+ "note": "The app's mark before the source, as small as the line (about 10px)."
1303
+ },
1304
+ {
1305
+ "name": "children",
1306
+ "takes": "text",
1307
+ "required": true,
1308
+ "note": "What would be made, in a sentence."
1309
+ }
1310
+ ],
1311
+ "variants": [],
1312
+ "ownsBehaviours": [],
1313
+ "status": "stable",
1314
+ "migrationStage": null,
1315
+ "docsId": "overlays-commandpalette--docs",
1316
+ "storyId": "overlays-commandpalette--first-level",
1317
+ "docPage": null,
1318
+ "app": null,
1319
+ "looks": []
1320
+ },
1284
1321
  {
1285
1322
  "name": "CommandPaletteQuote",
1286
1323
  "repo": "estiva-ui",
@@ -2109,6 +2146,12 @@
2109
2146
  "required": false,
2110
2147
  "note": "Whether Enter in a one-line field sends."
2111
2148
  },
2149
+ {
2150
+ "name": "layout",
2151
+ "takes": "fields | free",
2152
+ "required": false,
2153
+ "note": "`fields`, the default: everything inside stacks 24px apart."
2154
+ },
2112
2155
  {
2113
2156
  "name": "children",
2114
2157
  "takes": "anything",
@@ -2116,7 +2159,15 @@
2116
2159
  "note": null
2117
2160
  }
2118
2161
  ],
2119
- "variants": [],
2162
+ "variants": [
2163
+ {
2164
+ "prop": "layout",
2165
+ "values": [
2166
+ "fields",
2167
+ "free"
2168
+ ]
2169
+ }
2170
+ ],
2120
2171
  "ownsBehaviours": [
2121
2172
  {
2122
2173
  "id": "base-ui",
@@ -4596,14 +4647,14 @@
4596
4647
  "docPage": "src/Select.mdx",
4597
4648
  "app": null,
4598
4649
  "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",
4650
+ "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",
4651
+ "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",
4652
+ "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",
4653
+ "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",
4654
+ "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",
4655
+ "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",
4656
+ "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",
4657
+ "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
4658
  "rounded-lg border bg-bg-field shadow-field text-left"
4608
4659
  ]
4609
4660
  },
@@ -4851,7 +4902,7 @@
4851
4902
  "docPage": "src/Textarea.mdx",
4852
4903
  "app": null,
4853
4904
  "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"
4905
+ "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
4906
  ]
4856
4907
  },
4857
4908
  {
@@ -4892,8 +4943,8 @@
4892
4943
  "docPage": "src/TextInput.mdx",
4893
4944
  "app": null,
4894
4945
  "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"
4946
+ "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",
4947
+ "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
4948
  ]
4898
4949
  },
4899
4950
  {
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>}
@@ -23,6 +23,12 @@ form — and the app keeps the levels.
23
23
  above the rows, for a result that is written rather than listed: a line while
24
24
  it is on its way, an answer with marks pointing at the rows it came from, and
25
25
  text written to be used somewhere else.
26
+ - **`CommandPaletteProposal`** — above the rows, for something the palette
27
+ offers to make before anything is made: a card with where it would be made
28
+ (`source`, with the app's `mark`), then what it would be. The rows under it
29
+ say what happens next.
30
+
31
+ <Canvas of={CommandPaletteStories.Proposal} />
26
32
 
27
33
  <Canvas of={CommandPaletteStories.Answer} />
28
34
 
@@ -7,6 +7,7 @@ import {
7
7
  CommandPalette,
8
8
  CommandPaletteAnswer,
9
9
  CommandPaletteForm,
10
+ CommandPaletteProposal,
10
11
  CommandPaletteQuote,
11
12
  CommandPaletteSearch,
12
13
  CommandPaletteWorking,
@@ -54,6 +55,7 @@ type Level =
54
55
  | { kind: 'place'; name: string }
55
56
  | { kind: 'ask' }
56
57
  | { kind: 'draft' }
58
+ | { kind: 'make' }
57
59
  | { kind: 'form' }
58
60
  | { kind: 'one-list' }
59
61
 
@@ -62,7 +64,7 @@ interface Frame {
62
64
  query: string
63
65
  }
64
66
 
65
- type Start = 'first' | 'arriving' | 'nothing' | 'place' | 'working' | 'answer' | 'draft' | 'form' | 'refused' | 'one-list'
67
+ type Start = 'first' | 'arriving' | 'nothing' | 'place' | 'working' | 'answer' | 'draft' | 'make' | 'form' | 'refused' | 'one-list'
66
68
 
67
69
  const PLACES = ['Place one', 'Place two']
68
70
  const ACTIONS = [
@@ -81,6 +83,7 @@ const GROUPS = [
81
83
  ]
82
84
  const ANSWER = 'Item one moved on Monday [1], and item two is waiting on a reply [2].\n\nNothing else changed this week.'
83
85
  const DRAFT = 'Thanks, both. I will look at item one today\nand reply on item two tomorrow.'
86
+ const PROPOSAL = 'An item for the reply on item two, due tomorrow.'
84
87
 
85
88
  function startStack(start: Start): Frame[] {
86
89
  const first: Frame = { level: { kind: 'first' }, query: '' }
@@ -96,6 +99,8 @@ function startStack(start: Start): Frame[] {
96
99
  return [first, { level: { kind: 'ask' }, query: 'What changed this week?' }]
97
100
  case 'draft':
98
101
  return [first, { level: { kind: 'draft' }, query: 'Say I will look today' }]
102
+ case 'make':
103
+ return [first, { level: { kind: 'make' }, query: '' }]
99
104
  case 'form':
100
105
  case 'refused':
101
106
  return [first, { level: { kind: 'place', name: 'Place one' }, query: '' }, { level: { kind: 'form' }, query: '' }]
@@ -180,6 +185,17 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
180
185
  },
181
186
  ]
182
187
  }
188
+ if (level.kind === 'make') {
189
+ return [
190
+ {
191
+ label: 'Next',
192
+ rows: [
193
+ { id: 'make-open', label: 'Open the form', description: 'Place one › Action one · nothing is made until you submit', icon, onSelect: () => push({ kind: 'form' }) },
194
+ { id: 'make-again', label: 'Suggest something else', icon, onSelect: () => close('Suggest something else') },
195
+ ],
196
+ },
197
+ ]
198
+ }
183
199
  if (level.kind === 'ask' || level.kind === 'draft') {
184
200
  if (!asked?.done) return []
185
201
  const changed = frame.query.trim() !== asked.question.trim()
@@ -236,6 +252,7 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
236
252
  rows: [
237
253
  { id: 'ask', label: 'Ask about this page', icon, onSelect: () => push({ kind: 'ask' }) },
238
254
  { id: 'draft', label: 'Write a draft', icon, onSelect: () => push({ kind: 'draft' }) },
255
+ { id: 'make', label: 'Make something from this page', icon, onSelect: () => push({ kind: 'make' }) },
239
256
  { id: 'one-list', label: 'Change kind', description: 'Item one', icon, onSelect: () => push({ kind: 'one-list' }) },
240
257
  ],
241
258
  },
@@ -289,6 +306,7 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
289
306
  }
290
307
 
291
308
  const body = (() => {
309
+ if (level.kind === 'make') return <CommandPaletteProposal source="Place one › Action one" mark={<IconSquareRounded size={10} stroke={1.5} />}>{PROPOSAL}</CommandPaletteProposal>
292
310
  if (level.kind !== 'ask' && level.kind !== 'draft') return undefined
293
311
  if (!asked || !asked.done) return <CommandPaletteWorking>Reading 12 notes…</CommandPaletteWorking>
294
312
  const changed = frame.query.trim() !== asked.question.trim()
@@ -355,6 +373,8 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
355
373
  ? 'Ask anything about this page'
356
374
  : level.kind === 'draft'
357
375
  ? 'What should it say?'
376
+ : level.kind === 'make'
377
+ ? 'Looking at this page'
358
378
  : 'Search, or choose a place'
359
379
  }
360
380
  groups={groups}
@@ -365,6 +385,8 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
365
385
  ? { label: 'Ask', onBack: back }
366
386
  : level.kind === 'draft'
367
387
  ? { label: 'Draft', onBack: back }
388
+ : level.kind === 'make'
389
+ ? { label: 'Make', onBack: back }
368
390
  : undefined
369
391
  }
370
392
  pending={level.kind === 'first' && searching ? 'Searching…' : undefined}
@@ -412,6 +434,9 @@ export const Answer: Story = story('answer')
412
434
  export const Quote: Story = story('draft')
413
435
 
414
436
  /** A form. Ctrl+Enter creates; a missing field says so and takes focus; Backspace in an empty field goes back and keeps what was typed. */
437
+ /** Something it offers to make: where, then what. Nothing is made until the form is sent. */
438
+ export const Proposal: Story = story('make')
439
+
415
440
  export const Form: Story = story('form')
416
441
 
417
442
  /** Creating is refused: the fields unlock, the reason sits beside the button, and focus is back where it was. */
@@ -17,6 +17,7 @@ import {
17
17
  CommandPalette,
18
18
  CommandPaletteAnswer,
19
19
  CommandPaletteForm,
20
+ CommandPaletteProposal,
20
21
  CommandPaletteQuote,
21
22
  CommandPaletteSearch,
22
23
  CommandPaletteWorking,
@@ -421,6 +422,19 @@ describe('what sits above the rows', () => {
421
422
  expect(screen.getByText(/Line one/).textContent).toBe('Line one\nLine two')
422
423
  })
423
424
 
425
+ it('draws a proposal as a card: where it would be made, with its mark, then what', () => {
426
+ const { container } = render(
427
+ <CommandPaletteProposal source="Place one › Action one" mark={<svg data-testid="mark" />}>
428
+ An item for tomorrow.
429
+ </CommandPaletteProposal>,
430
+ )
431
+ const lines = container.firstElementChild?.children
432
+ expect(lines).toHaveLength(2)
433
+ expect(lines?.[0].textContent).toBe('Place one › Action one')
434
+ expect(lines?.[0].contains(screen.getByTestId('mark'))).toBe(true)
435
+ expect(lines?.[1].textContent).toBe('An item for tomorrow.')
436
+ })
437
+
424
438
  it('announces what is being worked on, with its grey bars', () => {
425
439
  const { container } = render(<CommandPaletteWorking bars={3}>Reading…</CommandPaletteWorking>)
426
440
  expect(screen.getByRole('status').textContent).toContain('Reading…')
@@ -16,10 +16,11 @@ import type { BaseUIEvent } from '@base-ui/react/types'
16
16
  import { IconLoader2, IconSearch } from '@tabler/icons-react'
17
17
  import { cn } from './cn'
18
18
  import { Button } from './Button'
19
+ import { Card } from './Card'
19
20
  import { InputChip } from './ChipInput'
20
21
  import { EmptyState } from './EmptyState'
21
22
  import { FieldLine } from './Field'
22
- import { Form } from './Form'
23
+ import { FIELDS_CLASSES, Form } from './Form'
23
24
  import { Kbd } from './Kbd'
24
25
  import { EnterHint, MenuItemBody, menuItemClassName } from './Menu'
25
26
  import { ScrollArea } from './ScrollArea'
@@ -563,10 +564,12 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
563
564
  {/* The package Form, from the fields to the button (UIG-7). The chip row
564
565
  stays outside it, so its ✕ still goes back while the form works; the
565
566
  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">
567
+ The fields are found through data-command-palette-fields. The form is
568
+ `free`, a scrolling body over a button row; the fields inside stack as
569
+ every form's do, 24px apart (Katerina, 28 September: they were 16). */}
570
+ <Form data-command-palette-fields="" busy={busy} enterSends={false} onSubmit={submit} layout="free" className="flex min-h-0 flex-col">
568
571
  <ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
569
- <div className="flex flex-col gap-4">{children}</div>
572
+ <div className={FIELDS_CLASSES}>{children}</div>
570
573
  </ScrollArea>
571
574
  <div className="flex shrink-0 items-center gap-3 px-5 pb-4">
572
575
  <div className="min-w-0 flex-1">
@@ -659,3 +662,31 @@ export interface CommandPaletteQuoteProps {
659
662
  export function CommandPaletteQuote({ children }: CommandPaletteQuoteProps) {
660
663
  return <p className="whitespace-pre-wrap border-l-2 border-border-default pl-3 text-body-2 text-text-primary">{children}</p>
661
664
  }
665
+
666
+ export interface CommandPaletteProposalProps {
667
+ /** Where it would be made: the app and the action — "App › New item". */
668
+ source: string
669
+ /** The app's mark before the source, as small as the line (about 10px). */
670
+ mark?: ReactNode
671
+ /** What would be made, in a sentence. */
672
+ children: string
673
+ }
674
+
675
+ /**
676
+ * Something the palette offers to make, before anything is made: a card with
677
+ * where it would be made on top, then what it would be. A card because it is a
678
+ * thing about to exist, not text to read or use; the source first because two
679
+ * apps may offer something alike (UIG-40, Katerina 28 September: the look Peek's
680
+ * launcher already had, now drawn by the palette, ruling P4).
681
+ */
682
+ export function CommandPaletteProposal({ source, mark, children }: CommandPaletteProposalProps) {
683
+ return (
684
+ <Card fill="surface" className="flex flex-col gap-1 p-3">
685
+ <span className="flex items-center gap-2 text-caption text-text-secondary">
686
+ {mark}
687
+ {source}
688
+ </span>
689
+ <span className="text-body-2 text-text-primary">{children}</span>
690
+ </Card>
691
+ )
692
+ }
@@ -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)