@boostdev/design-system-components 2.6.0 → 2.7.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 (29) hide show
  1. package/dist/client.cjs +257 -219
  2. package/dist/client.css +585 -537
  3. package/dist/client.d.cts +27 -1
  4. package/dist/client.d.ts +27 -1
  5. package/dist/client.js +220 -183
  6. package/dist/index.cjs +257 -219
  7. package/dist/index.css +585 -537
  8. package/dist/index.d.cts +27 -1
  9. package/dist/index.d.ts +27 -1
  10. package/dist/index.js +220 -183
  11. package/dist/web-components/index.d.ts +56 -1
  12. package/dist/web-components/index.js +117 -0
  13. package/package.json +3 -2
  14. package/src/components/interaction/form/FieldGroup/FieldGroup.mdx +113 -0
  15. package/src/components/interaction/form/FieldGroup/FieldGroup.module.css +96 -0
  16. package/src/components/interaction/form/FieldGroup/FieldGroup.spec.tsx +196 -0
  17. package/src/components/interaction/form/FieldGroup/FieldGroup.stories.tsx +99 -0
  18. package/src/components/interaction/form/FieldGroup/FieldGroup.tsx +64 -0
  19. package/src/components/interaction/form/FieldGroup/index.ts +2 -0
  20. package/src/components/interaction/form/atoms/Message.module.css +4 -0
  21. package/src/index.ts +2 -0
  22. package/src/web-components/index.ts +2 -0
  23. package/src/web-components/interaction/form/BdsFieldGroup.mdx +67 -0
  24. package/src/web-components/interaction/form/BdsFieldGroup.stories.tsx +110 -0
  25. package/src/web-components/interaction/form/bds-checkbox-group.ts +1 -0
  26. package/src/web-components/interaction/form/bds-field-group.spec.ts +64 -0
  27. package/src/web-components/interaction/form/bds-field-group.ts +157 -0
  28. package/src/web-components/interaction/form/bds-form-input.ts +1 -0
  29. package/src/web-components/interaction/form/bds-radio-group.ts +1 -0
@@ -426,6 +426,61 @@ declare global {
426
426
  }
427
427
  }
428
428
 
429
+ type FieldGroupVariant = 'horizontal' | 'vertical';
430
+ /**
431
+ * `<bds-field-group>` — framework-agnostic FieldGroup custom element.
432
+ *
433
+ * Semantically groups related form fields under a single `<fieldset>` and
434
+ * lays them out either horizontally (with a CSS container query that
435
+ * collapses to a vertical stack when the FieldGroup itself is too narrow)
436
+ * or vertically. Mirrors the React `FieldGroup` component.
437
+ *
438
+ * Attributes:
439
+ * variant — "horizontal" (default) | "vertical"
440
+ * legend — optional legend text rendered above the fields
441
+ * disabled — boolean; forwarded to the inner <fieldset>
442
+ *
443
+ * Slots:
444
+ * (default) — form fields (any element). The slotted-element count is
445
+ * measured on each `slotchange` and surfaced via the
446
+ * `--fieldGroup_field-count` custom property, which the auto-derived
447
+ * collapse threshold reads.
448
+ *
449
+ * @example
450
+ * <bds-field-group variant="horizontal" legend="Contact details">
451
+ * <bds-form-input label="First name" name="first"></bds-form-input>
452
+ * <bds-form-input label="Last name" name="last"></bds-form-input>
453
+ * <bds-form-input label="Email" name="email"></bds-form-input>
454
+ * </bds-field-group>
455
+ */
456
+ declare class BdsFieldGroup extends LitElement {
457
+ static styles: lit.CSSResult;
458
+ static properties: {
459
+ variant: {
460
+ type: StringConstructor;
461
+ reflect: boolean;
462
+ };
463
+ legend: {
464
+ type: StringConstructor;
465
+ };
466
+ disabled: {
467
+ type: BooleanConstructor;
468
+ reflect: boolean;
469
+ };
470
+ };
471
+ variant: FieldGroupVariant;
472
+ legend: string;
473
+ disabled: boolean;
474
+ constructor();
475
+ private _onSlotChange;
476
+ render(): lit.TemplateResult<1>;
477
+ }
478
+ declare global {
479
+ interface HTMLElementTagNameMap {
480
+ 'bds-field-group': BdsFieldGroup;
481
+ }
482
+ }
483
+
429
484
  /**
430
485
  * `<bds-form-input>` — form-associated text input with label, hint, and error support.
431
486
  *
@@ -749,4 +804,4 @@ declare global {
749
804
  }
750
805
  }
751
806
 
752
- export { BdsButtonGroup, BdsCalendar, BdsCarousel, BdsCheckboxGroup, BdsDropdownMenu, BdsDropdownMenuItem, BdsFormInput, BdsGrid, BdsGridItem, BdsPagination, BdsRadioGroup, BdsTable };
807
+ export { BdsButtonGroup, BdsCalendar, BdsCarousel, BdsCheckboxGroup, BdsDropdownMenu, BdsDropdownMenuItem, BdsFieldGroup, type FieldGroupVariant as BdsFieldGroupVariant, BdsFormInput, BdsGrid, BdsGridItem, BdsPagination, BdsRadioGroup, BdsTable };
@@ -121,6 +121,7 @@ import {
121
121
  BdsDescriptionList
122
122
  } from "./chunk-AGKJTMJI.js";
123
123
  import {
124
+ A,
124
125
  T,
125
126
  i,
126
127
  i2
@@ -881,6 +882,7 @@ var BdsCheckboxGroup = class extends i2 {
881
882
 
882
883
  .message--hint {
883
884
  color: var(--bds-color_on-bg--subtle);
885
+ font-style: italic;
884
886
  }
885
887
  `;
886
888
  static properties = {
@@ -957,6 +959,7 @@ var BdsRadioGroup = class extends i2 {
957
959
 
958
960
  .message--hint {
959
961
  color: var(--bds-color_on-bg--subtle);
962
+ font-style: italic;
960
963
  }
961
964
  `;
962
965
  static properties = {
@@ -1007,6 +1010,118 @@ var BdsRadioGroup = class extends i2 {
1007
1010
  };
1008
1011
  customElements.define("bds-radio-group", BdsRadioGroup);
1009
1012
 
1013
+ // src/web-components/interaction/form/bds-field-group.ts
1014
+ var BdsFieldGroup = class extends i2 {
1015
+ static styles = i`
1016
+ :host {
1017
+ display: block;
1018
+
1019
+ /* Establish an inline-size containment context so the @container rule
1020
+ queries the host's own width — independent of the viewport. */
1021
+ container-type: inline-size;
1022
+ container-name: field-group;
1023
+
1024
+ /* Auto-derived collapse threshold (mirror of the React component).
1025
+ --fieldGroup_field-count is set inline by _onSlotChange; the other
1026
+ tokens use foundation-default fallbacks so the calc rebuilds when
1027
+ a consumer overrides --fieldGroup_min-field-width or _gap. */
1028
+ --fieldGroup_collapse-threshold:
1029
+ calc(
1030
+ var(--fieldGroup_field-count, 1)
1031
+ * var(--fieldGroup_min-field-width, 12rem)
1032
+ + (var(--fieldGroup_field-count, 1) - 1)
1033
+ * var(--fieldGroup_gap, var(--bds-space_m))
1034
+ );
1035
+ }
1036
+
1037
+ fieldset {
1038
+ border: 0;
1039
+ margin: 0;
1040
+ padding: 0;
1041
+ min-inline-size: 0;
1042
+ }
1043
+
1044
+ .legend {
1045
+ inline-size: 100%;
1046
+ padding-inline: 0;
1047
+
1048
+ padding-block:
1049
+ var(--fieldGroup_legend-padding-block, var(--bds-space_xs));
1050
+ margin-block-end:
1051
+ var(--fieldGroup_legend-gap, var(--bds-space_m));
1052
+ border-block-end:
1053
+ var(--fieldGroup_legend-border-block-end, 1px solid var(--bds-color_bg--subtle));
1054
+
1055
+ color:
1056
+ var(--fieldGroup_legend-color, var(--bds-color_on-bg));
1057
+ font-family: var(--bds-font_family--body);
1058
+ font-size:
1059
+ var(--fieldGroup_legend-font-size, var(--bds-font_size--heading-3));
1060
+ font-weight:
1061
+ var(--fieldGroup_legend-font-weight, var(--bds-font_weight--semibold));
1062
+ line-height:
1063
+ var(--fieldGroup_legend-line-height, var(--bds-font_line-height--heading));
1064
+ }
1065
+
1066
+ .fields {
1067
+ display: flex;
1068
+ flex-direction: column;
1069
+ gap: var(--fieldGroup_gap, var(--bds-space_m));
1070
+ }
1071
+
1072
+ :host([variant='horizontal']) .fields {
1073
+ flex-flow: row wrap;
1074
+ }
1075
+
1076
+ :host([variant='horizontal']) .fields ::slotted(*) {
1077
+ flex: 1 1 var(--fieldGroup_min-field-width, 12rem);
1078
+ min-inline-size: 0;
1079
+ }
1080
+
1081
+ /* Auto-collapse: when the host's inline-size can't fit a complete
1082
+ horizontal row, switch to a single-column stack. Threshold scales
1083
+ with the actual slotted-element count (see :host calc above). */
1084
+ @container field-group (max-inline-size: var(--fieldGroup_collapse-threshold)) {
1085
+ :host([variant='horizontal']) .fields {
1086
+ flex-direction: column;
1087
+ }
1088
+
1089
+ :host([variant='horizontal']) .fields ::slotted(*) {
1090
+ flex: 1 1 auto;
1091
+ }
1092
+ }
1093
+ `;
1094
+ static properties = {
1095
+ variant: { type: String, reflect: true },
1096
+ legend: { type: String },
1097
+ disabled: { type: Boolean, reflect: true }
1098
+ };
1099
+ constructor() {
1100
+ super();
1101
+ this.variant = "horizontal";
1102
+ this.legend = "";
1103
+ this.disabled = false;
1104
+ }
1105
+ // Arrow-bound for stable identity (unused here but matches the
1106
+ // bds-popover / bds-button conventions for handlers attached in render).
1107
+ _onSlotChange = (e) => {
1108
+ const slot = e.target;
1109
+ const count = slot.assignedElements().length;
1110
+ this.style.setProperty("--fieldGroup_field-count", String(count));
1111
+ };
1112
+ render() {
1113
+ return T`
1114
+ <fieldset ?disabled=${this.disabled}>
1115
+ ${this.legend ? T`<legend class="legend">${this.legend}</legend>` : A}
1116
+ <div class="fields">
1117
+ <slot @slotchange=${this._onSlotChange}></slot>
1118
+ </div>
1119
+ </fieldset>
1120
+ `;
1121
+ }
1122
+ };
1123
+ customElements.define("bds-field-group", BdsFieldGroup);
1124
+
1010
1125
  // src/web-components/interaction/form/bds-form-input.ts
1011
1126
  var BdsFormInput = class extends i2 {
1012
1127
  static formAssociated = true;
@@ -1118,6 +1233,7 @@ var BdsFormInput = class extends i2 {
1118
1233
 
1119
1234
  .message--hint {
1120
1235
  color: var(--bds-color_on-bg--subtle);
1236
+ font-style: italic;
1121
1237
  }
1122
1238
  `;
1123
1239
  static properties = {
@@ -2068,6 +2184,7 @@ export {
2068
2184
  BdsDrawer,
2069
2185
  BdsDropdownMenu,
2070
2186
  BdsDropdownMenuItem,
2187
+ BdsFieldGroup,
2071
2188
  BdsFileInput,
2072
2189
  BdsFormInput,
2073
2190
  BdsGrid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boostdev/design-system-components",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "BoostDev React component library: accessible, token-driven components built on @boostdev/design-system-foundation",
5
5
  "keywords": [
6
6
  "React",
@@ -335,5 +335,6 @@
335
335
  ],
336
336
  "dependencies": {
337
337
  "@oddbird/css-anchor-positioning": "^0.9.0"
338
- }
338
+ },
339
+ "packageManager": "pnpm@11.0.8"
339
340
  }
@@ -0,0 +1,113 @@
1
+ import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
2
+ import * as Stories from './FieldGroup.stories';
3
+
4
+ <Meta of={Stories} />
5
+
6
+ # FieldGroup
7
+
8
+ Semantically groups related form fields under a single `<fieldset>` and lays them out either horizontally (with a CSS container query that collapses to a vertical stack when the FieldGroup itself is too narrow) or vertically.
9
+
10
+ Use this any time multiple fields belong to the same conceptual section — contact details, address, search filters, registration step — and you want them grouped for screen readers AND visually arranged on a single row when there's room.
11
+
12
+ ## When to use
13
+
14
+ - Two or more related form fields that should sit side by side on wide screens.
15
+ - Sections inside a longer form that need a visual + semantic boundary.
16
+ - A fieldset whose layout should adapt to its **container's** width, not the viewport's (e.g. a form rendered inside a sidebar or dialog).
17
+
18
+ ## When **not** to use
19
+
20
+ - A group of related checkboxes — use `CheckboxGroup` instead (it manages legend + error/hint slots specifically for the boolean-list case).
21
+ - A group of related radio inputs — use `RadioGroup`.
22
+ - A single field — wrap nothing; just render the field.
23
+
24
+ ## Variants
25
+
26
+ - **`horizontal`** (default) — fields flow into a row; each claims at least `--fieldGroup_min-field-width`. When the FieldGroup's own inline-size drops below the **auto-derived** threshold (`N × min-field-width + (N − 1) × gap`, where `N` is the actual number of children), the layout collapses to a single column via a CSS container query. A 2-field group collapses earlier than a 5-field group; you don't have to tune anything per call site.
27
+ - **`vertical`** — fields always stack one per row.
28
+
29
+ <Canvas of={Stories.Horizontal} />
30
+ <Canvas of={Stories.Vertical} />
31
+
32
+ ### Container query in action
33
+
34
+ The collapse-to-vertical threshold measures the **FieldGroup itself**, not the viewport. The FieldGroup below sits inside a 20 rem wrapper, so the same horizontal variant collapses cleanly to a single column even though the page is wide.
35
+
36
+ <Canvas of={Stories.HorizontalCollapsesInNarrowContainer} />
37
+
38
+ ### Mixed inputs
39
+
40
+ Any form input works as a child — `FormInput`, `Select`, `Combobox`, `NumberInput`, `Textarea`, `Switch`, etc.
41
+
42
+ <Canvas of={Stories.WithSelectAndInputs} />
43
+
44
+ ### Without a legend
45
+
46
+ Omit `legend` and provide an `aria-label` (or `aria-labelledby`) on the fieldset directly when the surrounding context already names the group.
47
+
48
+ <Canvas of={Stories.WithoutLegend} />
49
+
50
+ ### Disabled
51
+
52
+ The native `disabled` attribute on the fieldset disables every form control inside it without per-field wiring.
53
+
54
+ <Canvas of={Stories.Disabled} />
55
+
56
+ ## Props
57
+
58
+ <ArgTypes of={Stories} />
59
+
60
+ ## HTML attribute forwarding
61
+
62
+ Extends `FieldsetHTMLAttributes<HTMLFieldSetElement>`; spreads all unrecognised props onto the root `<fieldset>` (`id`, `name`, `form`, `disabled`, `aria-*`, `data-*`, every DOM event handler, etc.). The consumer-provided `className` is merged with the internal classes via `cn()`.
63
+
64
+ ## CSS variables
65
+
66
+ All public override tokens. Set on the FieldGroup or any ancestor; they cascade.
67
+
68
+ ### Layout
69
+
70
+ | Variable | Default | Description |
71
+ |----------|---------|-------------|
72
+ | `--fieldGroup_gap` | `var(--bds-space_m)` | Gap between fields. |
73
+ | `--fieldGroup_min-field-width` | `12rem` | Minimum width each field claims in horizontal mode before wrapping. |
74
+
75
+ ### Legend
76
+
77
+ | Variable | Default | Description |
78
+ |----------|---------|-------------|
79
+ | `--fieldGroup_legend-color` | `var(--bds-color_on-bg)` | Legend text colour. |
80
+ | `--fieldGroup_legend-font-size` | `var(--bds-font_size--heading-3)` | Legend font size. |
81
+ | `--fieldGroup_legend-font-weight` | `var(--bds-font_weight--semibold)` | Legend font weight. |
82
+ | `--fieldGroup_legend-line-height` | `var(--bds-font_line-height--heading)` | Legend line height. |
83
+ | `--fieldGroup_legend-padding-block` | `var(--bds-space_xs)` | Legend block padding. |
84
+ | `--fieldGroup_legend-gap` | `var(--bds-space_m)` | Space below the legend before the fields. |
85
+ | `--fieldGroup_legend-border-block-end` | `1px solid var(--bds-color_bg--subtle)` | Subtle separator under the legend. Set to `none` to remove. |
86
+
87
+ ## Accessibility
88
+
89
+ - Renders as `<fieldset>` so assistive tech announces the legend before each field, and the native `disabled` attribute cascades to all descendants.
90
+ - When `legend` is omitted, you must provide `aria-label` or `aria-labelledby` on the FieldGroup so the group still has an accessible name.
91
+ - The container query is a layout-only change — DOM order stays in source order, so the tab/reading order is unaffected by the horizontal-to-vertical switch.
92
+
93
+ ## How the responsive layout works
94
+
95
+ The horizontal variant uses `display: flex; flex-flow: row wrap;` with each child claiming at least `--fieldGroup_min-field-width`. This handles natural wrapping across multiple rows.
96
+
97
+ Layered on top, a CSS [`@container`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@container) query forces a clean single-column layout when the FieldGroup itself is narrower than the **auto-derived** collapse threshold:
98
+
99
+ ```css
100
+ --fieldGroup_collapse-threshold:
101
+ calc(
102
+ var(--fieldGroup_field-count) * var(--fieldGroup_min-field-width)
103
+ + (var(--fieldGroup_field-count) - 1) * var(--fieldGroup_gap)
104
+ );
105
+
106
+ @container field-group (max-inline-size: var(--fieldGroup_collapse-threshold)) {
107
+ .fieldGroup.--variant_horizontal .fields { flex-direction: column; }
108
+ }
109
+ ```
110
+
111
+ `--fieldGroup_field-count` is set by the React component as an inline `style` custom property derived from `Children.count(children)`, so the threshold scales with the actual content. A 2-field group collapses around `2 × 12rem + 1 × gap`; a 5-field group collapses around `5 × 12rem + 4 × gap`. No magic numbers per call site.
112
+
113
+ Because the query is bound to the FieldGroup's own inline-size (via `container-type: inline-size`), the same FieldGroup behaves correctly whether you place it in a sidebar, a modal, a 2-up grid cell, or a full-width page — with zero media-query wiring at the call site.
@@ -0,0 +1,96 @@
1
+ @layer boostdev.component {
2
+ .fieldGroup {
3
+ /* Reset fieldset defaults */
4
+ border: 0;
5
+ margin: 0;
6
+ padding: 0;
7
+ min-inline-size: 0;
8
+
9
+ /* Establish an inline-size containment context so descendant rules can
10
+ use @container against the FieldGroup's own width — independent of the
11
+ viewport. Without this, the @container rule below would never match. */
12
+ container-type: inline-size;
13
+ container-name: field-group;
14
+
15
+ /* Field count is injected by the React component as an inline style:
16
+ `style={{ '--fieldGroup_field-count': N }}`. Read with a `1` fallback
17
+ so the calc below stays well-formed if the property is ever missing
18
+ (SSR pre-hydration, manual `<fieldset class="…">` usage in tests).
19
+ This is the only public token we *write* — every other public token is
20
+ only ever *read* with a foundation default as fallback, so a consumer
21
+ can override `--fieldGroup_*` on any ancestor and the new value
22
+ cascades in without being clobbered by a local declaration. */
23
+ --fieldGroup_collapse-threshold:
24
+ calc(
25
+ var(--fieldGroup_field-count, 1)
26
+ * var(--fieldGroup_min-field-width, 12rem)
27
+ + (var(--fieldGroup_field-count, 1) - 1)
28
+ * var(--fieldGroup_gap, var(--bds-space_m))
29
+ );
30
+ }
31
+
32
+ .legend {
33
+ /* Reset legend's UA quirks: float, default offset, default font.
34
+ <legend> is a fieldset child, not a flex/grid child of .fields, so it
35
+ gets its own positioning. We render it as a block-level section heading. */
36
+ inline-size: 100%;
37
+ padding-inline: 0;
38
+ padding-block:
39
+ var(--fieldGroup_legend-padding-block, var(--bds-space_xs));
40
+ margin-block-end:
41
+ var(--fieldGroup_legend-gap, var(--bds-space_m));
42
+ border-block-end:
43
+ var(--fieldGroup_legend-border-block-end, 1px solid var(--bds-color_bg--subtle));
44
+ color:
45
+ var(--fieldGroup_legend-color, var(--bds-color_on-bg));
46
+ font-family: var(--bds-font_family--body);
47
+ font-size:
48
+ var(--fieldGroup_legend-font-size, var(--bds-font_size--heading-3));
49
+ font-weight:
50
+ var(--fieldGroup_legend-font-weight, var(--bds-font_weight--semibold));
51
+ line-height:
52
+ var(--fieldGroup_legend-line-height, var(--bds-font_line-height--heading));
53
+ }
54
+
55
+ .fields {
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: var(--fieldGroup_gap, var(--bds-space_m));
59
+ }
60
+
61
+ /* Horizontal variant: row layout with flex-wrap. Each child claims at least
62
+ --fieldGroup_min-field-width and grows to fill remaining space. The
63
+ `flex-wrap` already handles partial wrapping; the @container rule below
64
+ forces a clean single-column stack when the whole row would no longer
65
+ fit cleanly. */
66
+ .fieldGroup.--variant_horizontal .fields {
67
+ flex-flow: row wrap;
68
+
69
+ /* `align-items` left at its `stretch` default — each field's
70
+ label/input/message stays top-anchored by block flow, so the inputs
71
+ line up regardless. Consumers that want a non-default cross-axis
72
+ behavior set it from the outside via the consumer-supplied
73
+ className. */
74
+ }
75
+
76
+ .fieldGroup.--variant_horizontal .fields > * {
77
+ flex: 1 1 var(--fieldGroup_min-field-width, 12rem);
78
+ min-inline-size: 0;
79
+ }
80
+
81
+ /* When the FieldGroup itself is too narrow to fit a complete horizontal
82
+ row of all its fields at minimum width + gaps, collapse to a clean
83
+ single column. The threshold is derived automatically from the field
84
+ count + min-field-width + gap (see --fieldGroup_collapse-threshold
85
+ above), so the same FieldGroup adapts whether it has 2 or 6 fields,
86
+ in any container — no media-query wiring at the call site. */
87
+ @container field-group (max-inline-size: var(--fieldGroup_collapse-threshold)) {
88
+ .fieldGroup.--variant_horizontal .fields {
89
+ flex-direction: column;
90
+ }
91
+
92
+ .fieldGroup.--variant_horizontal .fields > * {
93
+ flex: 1 1 auto;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,196 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { FieldGroup } from './FieldGroup';
5
+
6
+ const cssSource = readFileSync(
7
+ resolve(process.cwd(), 'src/components/interaction/form/FieldGroup/FieldGroup.module.css'),
8
+ 'utf8',
9
+ );
10
+
11
+ describe('FieldGroup', () => {
12
+ it('renders a <fieldset> with the children inside', () => {
13
+ render(
14
+ <FieldGroup>
15
+ <input data-testid="email" />
16
+ <input data-testid="phone" />
17
+ </FieldGroup>,
18
+ );
19
+ const group = screen.getByRole('group');
20
+ expect(group.tagName).toBe('FIELDSET');
21
+ expect(screen.getByTestId('email')).toBeInTheDocument();
22
+ expect(screen.getByTestId('phone')).toBeInTheDocument();
23
+ });
24
+
25
+ it('renders the legend when provided', () => {
26
+ render(
27
+ <FieldGroup legend="Contact details">
28
+ <input />
29
+ </FieldGroup>,
30
+ );
31
+ expect(screen.getByRole('group', { name: 'Contact details' })).toBeInTheDocument();
32
+ expect(screen.getByText('Contact details').tagName).toBe('LEGEND');
33
+ });
34
+
35
+ it('omits the legend when not provided', () => {
36
+ render(
37
+ <FieldGroup aria-label="Address">
38
+ <input />
39
+ </FieldGroup>,
40
+ );
41
+ expect(screen.queryByText(/./, { selector: 'legend' })).not.toBeInTheDocument();
42
+ });
43
+
44
+ it('applies the horizontal variant class by default', () => {
45
+ const { container } = render(
46
+ <FieldGroup>
47
+ <input />
48
+ </FieldGroup>,
49
+ );
50
+ const fieldset = container.querySelector('fieldset');
51
+ expect(fieldset?.className).toMatch(/--variant_horizontal/);
52
+ });
53
+
54
+ it('applies the vertical variant class when requested', () => {
55
+ const { container } = render(
56
+ <FieldGroup variant="vertical">
57
+ <input />
58
+ </FieldGroup>,
59
+ );
60
+ const fieldset = container.querySelector('fieldset');
61
+ expect(fieldset?.className).toMatch(/--variant_vertical/);
62
+ expect(fieldset?.className).not.toMatch(/--variant_horizontal/);
63
+ });
64
+
65
+ it('merges a consumer-provided className alongside the internal classes', () => {
66
+ const { container } = render(
67
+ <FieldGroup className="my-group">
68
+ <input />
69
+ </FieldGroup>,
70
+ );
71
+ const fieldset = container.querySelector('fieldset')!;
72
+ expect(fieldset.classList.contains('my-group')).toBe(true);
73
+ expect(fieldset.className).toMatch(/fieldGroup/);
74
+ });
75
+
76
+ it('forwards arbitrary HTML attributes to the fieldset', () => {
77
+ render(
78
+ <FieldGroup id="contact" data-testid="grp" aria-describedby="hint">
79
+ <input />
80
+ </FieldGroup>,
81
+ );
82
+ const group = screen.getByTestId('grp');
83
+ expect(group.id).toBe('contact');
84
+ expect(group.getAttribute('aria-describedby')).toBe('hint');
85
+ });
86
+
87
+ it('exposes the field count as a CSS custom property for the collapse threshold', () => {
88
+ const { rerender } = render(
89
+ <FieldGroup>
90
+ <input />
91
+ <input />
92
+ <input />
93
+ </FieldGroup>,
94
+ );
95
+ let fieldset = screen.getByRole('group') as HTMLFieldSetElement;
96
+ // The CSS @container rule resolves `--fieldGroup_collapse-threshold` from
97
+ // this value, so a wrong count silently makes the auto-collapse fire at
98
+ // the wrong width.
99
+ expect(fieldset.style.getPropertyValue('--fieldGroup_field-count')).toBe('3');
100
+
101
+ rerender(
102
+ <FieldGroup>
103
+ <input />
104
+ <input />
105
+ </FieldGroup>,
106
+ );
107
+ fieldset = screen.getByRole('group') as HTMLFieldSetElement;
108
+ expect(fieldset.style.getPropertyValue('--fieldGroup_field-count')).toBe('2');
109
+ });
110
+
111
+ it('renders cleanly with zero children and sets the field count to 0', () => {
112
+ // Threshold calc with N=0 produces a negative max-inline-size; container
113
+ // queries with a negative length never match, so the layout stays in its
114
+ // declared variant. No fields, no row, no crash.
115
+ const { container } = render(<FieldGroup legend="Empty section" />);
116
+ const fieldset = container.querySelector('fieldset')!;
117
+ expect(fieldset.style.getPropertyValue('--fieldGroup_field-count')).toBe('0');
118
+ expect(container.querySelector('legend')).toBeInTheDocument();
119
+ });
120
+
121
+ it('reads every documented public token with a foundation-default fallback', () => {
122
+ // A reader-site like `color: var(--fieldGroup_legend-color);` (no fallback)
123
+ // would silently leave unthemed legends unstyled. Lock in the contract:
124
+ // every documented public token must appear inside a `var(name, ...)`
125
+ // with a non-empty fallback somewhere in the CSS.
126
+ const PUBLIC_TOKENS = [
127
+ '--fieldGroup_gap',
128
+ '--fieldGroup_min-field-width',
129
+ '--fieldGroup_legend-color',
130
+ '--fieldGroup_legend-font-size',
131
+ '--fieldGroup_legend-font-weight',
132
+ '--fieldGroup_legend-line-height',
133
+ '--fieldGroup_legend-padding-block',
134
+ '--fieldGroup_legend-gap',
135
+ '--fieldGroup_legend-border-block-end',
136
+ ];
137
+
138
+ for (const token of PUBLIC_TOKENS) {
139
+ // Match `var(<token>, ...)` with at least one non-whitespace char before
140
+ // the closing `)`. Captures any read site with a real fallback.
141
+ const pattern = new RegExp(
142
+ `var\\(\\s*${token.replace('-', '\\-')}\\s*,\\s*\\S`,
143
+ );
144
+ expect(cssSource).toMatch(pattern);
145
+ }
146
+ });
147
+
148
+ it('does not redeclare any public --fieldGroup_* token at root, so consumer overrides cascade in', () => {
149
+ // Public override tokens are READ with foundation-default fallbacks
150
+ // throughout the CSS — never DECLARED at root. If anyone reintroduces a
151
+ // root-level `--fieldGroup_legend-color: ...` etc., a consumer's override
152
+ // on an ancestor would be silently clobbered (same bug pattern fixed
153
+ // across Alert/Badge/Toast/Link/Card/Pagination/Tabs in MR !63).
154
+ //
155
+ // The only declarations we allow are:
156
+ // - --fieldGroup_collapse-threshold (computed from the public tokens
157
+ // via calc; consumers can still override it at any level if they
158
+ // want to bypass the formula entirely).
159
+ // - --fieldGroup_field-count (set by React inline style, not CSS).
160
+ //
161
+ // Anything else with the --fieldGroup_ prefix on a left-hand side is a
162
+ // regression.
163
+ const declarations = [
164
+ ...cssSource.matchAll(/^\s*(--fieldGroup_[A-Za-z0-9_-]+)\s*:/gm),
165
+ ].map(m => m[1]);
166
+
167
+ const publicTokenDeclarations = declarations.filter(
168
+ name => name !== '--fieldGroup_collapse-threshold',
169
+ );
170
+ expect(publicTokenDeclarations).toEqual([]);
171
+ });
172
+
173
+ it('preserves a consumer-supplied inline style alongside the field count', () => {
174
+ render(
175
+ <FieldGroup style={{ marginBlockEnd: '2rem' }}>
176
+ <input />
177
+ </FieldGroup>,
178
+ );
179
+ const fieldset = screen.getByRole('group') as HTMLFieldSetElement;
180
+ expect(fieldset.style.marginBlockEnd).toBe('2rem');
181
+ expect(fieldset.style.getPropertyValue('--fieldGroup_field-count')).toBe('1');
182
+ });
183
+
184
+ it('forwards the disabled attribute to the fieldset', () => {
185
+ render(
186
+ <FieldGroup disabled>
187
+ <input />
188
+ </FieldGroup>,
189
+ );
190
+ // jsdom does not propagate fieldset[disabled] to descendants the way real
191
+ // browsers do; assert the attribute lands on the fieldset itself. The
192
+ // descendant-disabling behaviour is a UA contract on top of that.
193
+ const fieldset = screen.getByRole('group') as HTMLFieldSetElement;
194
+ expect(fieldset.disabled).toBe(true);
195
+ });
196
+ });