@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.
- package/dist/client.cjs +257 -219
- package/dist/client.css +585 -537
- package/dist/client.d.cts +27 -1
- package/dist/client.d.ts +27 -1
- package/dist/client.js +220 -183
- package/dist/index.cjs +257 -219
- package/dist/index.css +585 -537
- package/dist/index.d.cts +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +220 -183
- package/dist/web-components/index.d.ts +56 -1
- package/dist/web-components/index.js +117 -0
- package/package.json +3 -2
- package/src/components/interaction/form/FieldGroup/FieldGroup.mdx +113 -0
- package/src/components/interaction/form/FieldGroup/FieldGroup.module.css +96 -0
- package/src/components/interaction/form/FieldGroup/FieldGroup.spec.tsx +196 -0
- package/src/components/interaction/form/FieldGroup/FieldGroup.stories.tsx +99 -0
- package/src/components/interaction/form/FieldGroup/FieldGroup.tsx +64 -0
- package/src/components/interaction/form/FieldGroup/index.ts +2 -0
- package/src/components/interaction/form/atoms/Message.module.css +4 -0
- package/src/index.ts +2 -0
- package/src/web-components/index.ts +2 -0
- package/src/web-components/interaction/form/BdsFieldGroup.mdx +67 -0
- package/src/web-components/interaction/form/BdsFieldGroup.stories.tsx +110 -0
- package/src/web-components/interaction/form/bds-checkbox-group.ts +1 -0
- package/src/web-components/interaction/form/bds-field-group.spec.ts +64 -0
- package/src/web-components/interaction/form/bds-field-group.ts +157 -0
- package/src/web-components/interaction/form/bds-form-input.ts +1 -0
- 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.
|
|
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
|
+
});
|