@estiva-app/ui 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/Chip.d.ts +10 -2
  2. package/dist/Chip.d.ts.map +1 -1
  3. package/dist/CommandPalette.d.ts.map +1 -1
  4. package/dist/Field.d.ts.map +1 -1
  5. package/dist/Form.d.ts +18 -1
  6. package/dist/Form.d.ts.map +1 -1
  7. package/dist/Link.d.ts +15 -1
  8. package/dist/Link.d.ts.map +1 -1
  9. package/dist/Select.d.ts.map +1 -1
  10. package/dist/eslint/index.js +16 -2
  11. package/dist/eslint/index.js.map +3 -3
  12. package/dist/eslint/no-restyled-part.d.ts +4 -0
  13. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  14. package/dist/gates/app-checks.d.ts.map +1 -1
  15. package/dist/gates/index.js +14 -0
  16. package/dist/gates/index.js.map +2 -2
  17. package/dist/index.js +53 -40
  18. package/dist/index.js.map +3 -3
  19. package/dist/looks.d.ts +6 -4
  20. package/dist/looks.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/registry.json +54 -18
  23. package/src/Chip.mdx +13 -2
  24. package/src/Chip.stories.tsx +2 -0
  25. package/src/Chip.test.tsx +36 -0
  26. package/src/Chip.tsx +17 -5
  27. package/src/ChipInput.tsx +1 -1
  28. package/src/CommandPalette.tsx +6 -4
  29. package/src/DialogShell.stories.tsx +4 -3
  30. package/src/Field.mdx +5 -0
  31. package/src/Field.tsx +4 -1
  32. package/src/Form.mdx +9 -3
  33. package/src/Form.stories.tsx +18 -1
  34. package/src/Form.test.tsx +17 -3
  35. package/src/Form.tsx +20 -2
  36. package/src/Link.mdx +32 -0
  37. package/src/Link.stories.tsx +49 -0
  38. package/src/Link.test.tsx +15 -0
  39. package/src/Link.tsx +23 -2
  40. package/src/ListColumn.stories.tsx +1 -1
  41. package/src/Select.tsx +3 -0
  42. package/src/eslint/index.test.ts +1 -1
  43. package/src/eslint/no-restyled-part.test.ts +22 -6
  44. package/src/eslint/no-restyled-part.ts +27 -2
  45. package/src/gates/app-checks.ts +14 -0
  46. package/src/gates/create-app.test.ts +7 -2
  47. package/src/looks.ts +6 -4
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.40.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.40.0",
7
+ "packageVersion": "0.42.0",
8
8
  "exports": 90,
9
9
  "typeExports": 101,
10
10
  "files": null
@@ -792,6 +792,18 @@
792
792
  "required": false,
793
793
  "note": null
794
794
  },
795
+ {
796
+ "name": "href",
797
+ "takes": "text",
798
+ "required": false,
799
+ "note": "The chip is a link: a real anchor with the chip's own round shape, so the keyboard ring is round too."
800
+ },
801
+ {
802
+ "name": "onClick",
803
+ "takes": "MouseEventHandler<HTMLAnchorElement>",
804
+ "required": false,
805
+ "note": "With `href`: a router app takes the click here, like `Link`."
806
+ },
795
807
  {
796
808
  "name": "className",
797
809
  "takes": "text",
@@ -825,13 +837,13 @@
825
837
  "bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
826
838
  "bg-success-muted text-success-default signal:border signal:border-success-outline",
827
839
  "bg-error-muted text-error-default signal:border signal:border-error-outline",
840
+ "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
841
+ "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide",
828
842
  "rounded-full bg-accent-muted text-accent-primary signal:border signal:border-accent-outline",
829
843
  "rounded-full bg-info-muted text-info-default signal:border signal:border-info-outline",
830
844
  "rounded-full bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning",
831
845
  "rounded-full bg-success-muted text-success-default signal:border signal:border-success-outline",
832
- "rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline",
833
- "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide text-chip signal:font-mono signal:text-small signal:font-semibold signal:tabular-nums",
834
- "overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide"
846
+ "rounded-full bg-error-muted text-error-default signal:border signal:border-error-outline"
835
847
  ]
836
848
  },
837
849
  {
@@ -954,7 +966,6 @@
954
966
  "looks": [
955
967
  "bg-bg-elevated border border-border-subtle rounded-md",
956
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",
957
- "focus-within:border-border-focus focus-within:hover:border-border-focus cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring",
958
969
  "text-body-2 border-none bg-transparent text-text-primary placeholder:text-text-muted outline-none"
959
970
  ]
960
971
  },
@@ -2097,6 +2108,12 @@
2097
2108
  "required": false,
2098
2109
  "note": "Whether Enter in a one-line field sends."
2099
2110
  },
2111
+ {
2112
+ "name": "layout",
2113
+ "takes": "fields | free",
2114
+ "required": false,
2115
+ "note": "`fields`, the default: everything inside stacks 24px apart."
2116
+ },
2100
2117
  {
2101
2118
  "name": "children",
2102
2119
  "takes": "anything",
@@ -2104,7 +2121,15 @@
2104
2121
  "note": null
2105
2122
  }
2106
2123
  ],
2107
- "variants": [],
2124
+ "variants": [
2125
+ {
2126
+ "prop": "layout",
2127
+ "values": [
2128
+ "fields",
2129
+ "free"
2130
+ ]
2131
+ }
2132
+ ],
2108
2133
  "ownsBehaviours": [
2109
2134
  {
2110
2135
  "id": "base-ui",
@@ -2749,6 +2774,12 @@
2749
2774
  "required": false,
2750
2775
  "note": "One line, cut with an ellipsis when it does not fit — a title that is a link."
2751
2776
  },
2777
+ {
2778
+ "name": "cover",
2779
+ "takes": "true/false",
2780
+ "required": false,
2781
+ "note": "The link covers the row it sits in: a click anywhere on the row opens it, and the keyboard ring goes round the whole row."
2782
+ },
2752
2783
  {
2753
2784
  "name": "children",
2754
2785
  "takes": "anything",
@@ -2785,8 +2816,13 @@
2785
2816
  "app": null,
2786
2817
  "looks": [
2787
2818
  "text-info-default underline underline-offset-2 hover:opacity-80",
2819
+ "text-info-default underline underline-offset-2 hover:opacity-80 after:rounded-md focus-visible:outline-none",
2788
2820
  "text-info-default underline underline-offset-2 hover:opacity-80 truncate",
2789
- "underline underline-offset-2 hover:text-text-primary truncate"
2821
+ "text-info-default underline underline-offset-2 hover:opacity-80 truncate after:rounded-md focus-visible:outline-none",
2822
+ "hover:underline truncate after:rounded-md focus-visible:outline-none",
2823
+ "underline underline-offset-2 hover:text-text-primary after:rounded-md focus-visible:outline-none",
2824
+ "underline underline-offset-2 hover:text-text-primary truncate",
2825
+ "underline underline-offset-2 hover:text-text-primary truncate after:rounded-md focus-visible:outline-none"
2790
2826
  ]
2791
2827
  },
2792
2828
  {
@@ -4573,14 +4609,14 @@
4573
4609
  "docPage": "src/Select.mdx",
4574
4610
  "app": null,
4575
4611
  "looks": [
4576
- "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",
4577
- "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",
4578
- "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",
4579
- "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",
4580
- "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",
4581
- "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",
4582
- "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",
4583
- "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",
4584
4620
  "rounded-lg border bg-bg-field shadow-field text-left"
4585
4621
  ]
4586
4622
  },
@@ -4828,7 +4864,7 @@
4828
4864
  "docPage": "src/Textarea.mdx",
4829
4865
  "app": null,
4830
4866
  "looks": [
4831
- "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"
4832
4868
  ]
4833
4869
  },
4834
4870
  {
@@ -4869,8 +4905,8 @@
4869
4905
  "docPage": "src/TextInput.mdx",
4870
4906
  "app": null,
4871
4907
  "looks": [
4872
- "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",
4873
- "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"
4874
4910
  ]
4875
4911
  },
4876
4912
  {
package/src/Chip.mdx CHANGED
@@ -20,8 +20,8 @@ muted tints; the label is the `chip` type token, 11px / 500.
20
20
 
21
21
  ## When not
22
22
 
23
- - Something clickable → **Button** (small) or **MenuItem** — a Chip is not
24
- a control.
23
+ - Something that does an action → **Button** (small) or **MenuItem**. A Chip
24
+ is not a control. It can only be a link, with `href`.
25
25
  - A removable token in a multi-select → **InputChip** (see ChipInput).
26
26
  - A count beside a tab label → **Tabs** draws its own mono number, not a
27
27
  Chip.
@@ -37,6 +37,17 @@ import { Chip } from '@estiva-app/ui'
37
37
  - `leadingIcon` / `trailingIcon` take a 12px icon; the slots centre it, so
38
38
  it never rides the text baseline.
39
39
  - A count alone works too — `label="3"`, no icon.
40
+ - `href` makes the chip a link to the thing it names: a real anchor with the
41
+ chip's round shape, so the keyboard ring is round too. A router app passes
42
+ `onClick`, as with **Link**. Inside a row whose **Link** has `cover`, it
43
+ stays clickable on its own: the row opens one thing, the chip another.
44
+
45
+ ```tsx
46
+ <div className="relative flex items-center gap-3">
47
+ <Link href={itemUrl} variant="plain" cover>Item one</Link>
48
+ <Chip href={groupUrl} label="Group" />
49
+ </div>
50
+ ```
40
51
 
41
52
  ## What it owns
42
53
 
@@ -27,6 +27,8 @@ export const WithLeadingIcon: Story = { args: { type: 'info', label: 'Replies',
27
27
  export const WithTrailingIcon: Story = { args: { type: 'success', label: 'Sorted', trailingIcon: <IconArrowUp className="size-3" stroke={1.5} /> } }
28
28
  /** A count alone. */
29
29
  export const CountOnly: Story = { args: { type: 'neutral', label: '2' } }
30
+ /** A link to the thing it names. Tab to it: the ring is round, like the chip. */
31
+ export const AsLink: Story = { args: { type: 'neutral', label: 'Group', href: '#' } }
30
32
 
31
33
  /** All six colour types. */
32
34
  export const AllTypes: Story = {
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Chip page claims about `href`, pinned: without it the chip is not a
4
+ * link; with it the chip is a real anchor named by its label, keeps its round
5
+ * shape, sits above a covering link (`relative`), and hands the click to a
6
+ * router app through `onClick`.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { Chip } from './Chip'
12
+
13
+ afterEach(cleanup)
14
+
15
+ describe('Chip', () => {
16
+ it('is not a link without href', () => {
17
+ render(<Chip label="Group" />)
18
+ expect(screen.queryByRole('link')).toBeNull()
19
+ expect(screen.getByText('Group').parentElement?.tagName).toBe('DIV')
20
+ })
21
+
22
+ it('with href is an anchor named by its label, round, and lifted above a covering link', () => {
23
+ render(<Chip label="Group" href="/groups/one" />)
24
+ const link = screen.getByRole('link', { name: 'Group' })
25
+ expect(link.getAttribute('href')).toBe('/groups/one')
26
+ expect(link.classList.contains('rounded-full')).toBe(true)
27
+ expect(link.classList.contains('relative')).toBe(true)
28
+ })
29
+
30
+ it('a router app takes the click through onClick', async () => {
31
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
32
+ render(<Chip label="Group" href="/groups/one" onClick={onClick} />)
33
+ await userEvent.click(screen.getByRole('link', { name: 'Group' }))
34
+ expect(onClick).toHaveBeenCalledTimes(1)
35
+ })
36
+ })
package/src/Chip.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import type { MouseEventHandler, ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { CHIP_TEXT_CLASSES } from './looks'
4
4
 
@@ -20,6 +20,14 @@ export interface ChipProps {
20
20
  label?: string
21
21
  leadingIcon?: ReactNode
22
22
  trailingIcon?: ReactNode
23
+ /**
24
+ * The chip is a link: a real anchor with the chip's own round shape, so the
25
+ * keyboard ring is round too. It sits above a row's covering `Link`, so the
26
+ * row opens one thing and the chip another (UIG-33, Katerina 28 September).
27
+ */
28
+ href?: string
29
+ /** With `href`: a router app takes the click here, like `Link`. */
30
+ onClick?: MouseEventHandler<HTMLAnchorElement>
23
31
  className?: string
24
32
  }
25
33
 
@@ -32,9 +40,10 @@ const typeStyles: Record<ChipType, string> = {
32
40
  error: 'bg-error-muted text-error-default signal:border signal:border-error-outline',
33
41
  }
34
42
 
35
- export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
36
- return (
37
- <div className={cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)}>
43
+ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, href, onClick, className }: ChipProps) {
44
+ const classes = cn('inline-flex min-w-0 items-center justify-center gap-1.5 rounded-full max-h-[20px] px-2 py-1', typeStyles[type], className)
45
+ const inside = (
46
+ <>
38
47
  {leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
39
48
  {label && (
40
49
  // A chip given a `max-w-*` cuts a long label with an ellipsis instead
@@ -48,6 +57,9 @@ export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, class
48
57
  <span className={cn('min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap signal:tracking-wide', CHIP_TEXT_CLASSES)}>{label}</span>
49
58
  )}
50
59
  {trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
51
- </div>
60
+ </>
52
61
  )
62
+ // `relative` lifts a linked chip above a covering link that comes before it.
63
+ if (href) return <a href={href} onClick={onClick} className={cn('relative', classes)}>{inside}</a>
64
+ return <div className={classes}>{inside}</div>
53
65
  }
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)