@boostdev/design-system-components 2.5.1 → 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 (60) hide show
  1. package/dist/client.cjs +257 -219
  2. package/dist/client.css +673 -626
  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 +673 -626
  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/{chunk-N3TN6WCH.js → chunk-GER5UJOF.js} +22 -15
  12. package/dist/web-components/chunk-QWPACK5L.js +72 -0
  13. package/dist/web-components/{chunk-YRXCVKHV.js → chunk-RTOS7LLG.js} +9 -7
  14. package/dist/web-components/{chunk-BX3IPVOB.js → chunk-ZNKEZIYE.js} +20 -16
  15. package/dist/web-components/index.d.ts +56 -1
  16. package/dist/web-components/index.js +121 -4
  17. package/dist/web-components/interaction/bds-button.js +1 -1
  18. package/dist/web-components/ui/bds-alert.js +1 -1
  19. package/dist/web-components/ui/bds-badge.js +1 -1
  20. package/dist/web-components/ui/bds-card.js +1 -1
  21. package/package.json +3 -2
  22. package/src/components/interaction/Button/Button.mdx +87 -13
  23. package/src/components/interaction/Button/Button.module.css +22 -17
  24. package/src/components/interaction/Toast/Toast.mdx +22 -3
  25. package/src/components/interaction/Toast/Toast.module.css +13 -10
  26. package/src/components/interaction/form/FieldGroup/FieldGroup.mdx +113 -0
  27. package/src/components/interaction/form/FieldGroup/FieldGroup.module.css +96 -0
  28. package/src/components/interaction/form/FieldGroup/FieldGroup.spec.tsx +196 -0
  29. package/src/components/interaction/form/FieldGroup/FieldGroup.stories.tsx +99 -0
  30. package/src/components/interaction/form/FieldGroup/FieldGroup.tsx +64 -0
  31. package/src/components/interaction/form/FieldGroup/index.ts +2 -0
  32. package/src/components/interaction/form/atoms/Message.module.css +4 -0
  33. package/src/components/layout/Card/Card.mdx +20 -8
  34. package/src/components/layout/Card/Card.module.css +5 -3
  35. package/src/components/ui/Alert/Alert.mdx +39 -5
  36. package/src/components/ui/Alert/Alert.module.css +21 -15
  37. package/src/components/ui/Badge/Badge.mdx +23 -3
  38. package/src/components/ui/Badge/Badge.module.css +15 -12
  39. package/src/components/ui/Link/Link.mdx +20 -2
  40. package/src/components/ui/Link/Link.module.css +12 -8
  41. package/src/components/ui/NotificationBanner/NotificationBanner.mdx +25 -6
  42. package/src/components/ui/NotificationBanner/NotificationBanner.module.css +18 -15
  43. package/src/components/ui/Pagination/Pagination.mdx +22 -9
  44. package/src/components/ui/Pagination/Pagination.module.css +10 -8
  45. package/src/components/ui/Tabs/Tabs.mdx +20 -5
  46. package/src/components/ui/Tabs/Tabs.module.css +8 -5
  47. package/src/index.ts +2 -0
  48. package/src/web-components/index.ts +2 -0
  49. package/src/web-components/interaction/bds-button.ts +22 -15
  50. package/src/web-components/interaction/form/BdsFieldGroup.mdx +67 -0
  51. package/src/web-components/interaction/form/BdsFieldGroup.stories.tsx +110 -0
  52. package/src/web-components/interaction/form/bds-checkbox-group.ts +1 -0
  53. package/src/web-components/interaction/form/bds-field-group.spec.ts +64 -0
  54. package/src/web-components/interaction/form/bds-field-group.ts +157 -0
  55. package/src/web-components/interaction/form/bds-form-input.ts +1 -0
  56. package/src/web-components/interaction/form/bds-radio-group.ts +1 -0
  57. package/src/web-components/ui/bds-alert.ts +20 -16
  58. package/src/web-components/ui/bds-badge.ts +16 -13
  59. package/src/web-components/ui/bds-card.ts +9 -7
  60. package/dist/web-components/chunk-OHUTLSIQ.js +0 -69
@@ -46,17 +46,20 @@
46
46
  white-space: nowrap;
47
47
  line-height: 1;
48
48
 
49
- /* Colour model: override --button_color + --button_on-color to re-theme */
49
+ /* Identity tokens override --button_color + --button_on-color to re-theme */
50
50
  --button_color: var(--bds-color_interactive);
51
51
  --button_on-color: var(--bds-color_on-interactive);
52
52
 
53
- /* Text-safe variant of --button_color for use on page background (ghost text/border) */
53
+ /* Text-safe variant of --button_color for use on page background (outline text/border) */
54
54
  --button_color--on-bg: var(--bds-color_interactive_on-bg);
55
55
 
56
- background-color: var(--button_bg, var(--button_color));
57
- color: var(--button_text, var(--button_on-color));
58
- border: var(--button_border, none);
59
- --bdc_color: transparent;
56
+ /* Layered cascade: the consumer-facing overrides (--button_bg, --button_text,
57
+ --button_border) are read OUTERMOST so an ancestor-set value always wins
58
+ over variant + hover defaults. Variants and hover write to the inner
59
+ --button_*--variant slot, never to the public tokens. */
60
+ background-color: var(--button_bg, var(--button_bg--variant, var(--button_color)));
61
+ color: var(--button_text, var(--button_text--variant, var(--button_on-color)));
62
+ --bdc_color: var(--button_border, var(--button_border--variant, transparent));
60
63
 
61
64
  box-shadow: inset 0 0 0 var(--bdc-outline_width) var(--bdc_color);
62
65
  transition: var(--bds-animation_transition), --bdc_color var(--bds-animation_transition-duration) var(--bds-animation_easing);
@@ -65,12 +68,14 @@
65
68
  /* Default: filled with --button_color, no visible border */
66
69
  .button.--default { /* inherits base — no overrides needed */ }
67
70
 
68
- /* Outline: transparent background, border and text use text-safe --button_color--on-bg */
71
+ /* Outline: transparent background, border and text use text-safe --button_color--on-bg.
72
+ Resting overrides (--button_bg--outline, --button_text--outline, --button_border--outline)
73
+ are honored if the consumer sets them. */
69
74
  .button.--outline,
70
75
  .button.--ghost /* @deprecated — use variant="outline" */ {
71
- --button_bg: transparent;
72
- --button_text: var(--button_color--on-bg);
73
- --bdc_color: var(--button_color--on-bg);
76
+ --button_bg--variant: var(--button_bg--outline, transparent);
77
+ --button_text--variant: var(--button_text--outline, var(--button_color--on-bg));
78
+ --button_border--variant: var(--button_border--outline, var(--button_color--on-bg));
74
79
  }
75
80
 
76
81
  .button.--size_small {
@@ -164,10 +169,10 @@
164
169
  /* Default hover: outline appears, bg becomes page surface */
165
170
  @media (hover: hover) and (pointer: fine) {
166
171
  .button.--default:hover {
167
- --button_bg: var(--button_bg--hover, var(--bds-color_bg));
168
- --button_text: var(--button_text--hover, var(--bds-color_interactive_on-bg));
169
- --button_pulse-color: var(--button_pulse-color--hover, var(--bds-color_interactive_on-bg));
170
- --bdc_color: currentcolor;
172
+ --button_bg--variant: var(--button_bg--hover, var(--bds-color_bg));
173
+ --button_text--variant: var(--button_text--hover, var(--bds-color_interactive_on-bg));
174
+ --button_border--variant: var(--button_border--hover, currentcolor);
175
+ --button_pulse-color: var(--button_pulse-color--hover, var(--bds-color_interactive_on-bg));
171
176
  }
172
177
  }
173
178
 
@@ -175,9 +180,9 @@
175
180
  @media (hover: hover) and (pointer: fine) {
176
181
  .button.--outline:hover,
177
182
  .button.--ghost:hover {
178
- --button_bg: var(--button_bg--outline-hover, var(--button_color));
179
- --button_text: var(--button_text--outline-hover, var(--button_on-color));
180
- --bdc_color: transparent;
183
+ --button_bg--variant: var(--button_bg--outline-hover, var(--button_color));
184
+ --button_text--variant: var(--button_text--outline-hover, var(--button_on-color));
185
+ --button_border--variant: var(--button_border--outline-hover, transparent);
181
186
  }
182
187
  }
183
188
  }
@@ -67,17 +67,36 @@ showToast('New message received', 'info');
67
67
 
68
68
  ## CSS variables
69
69
 
70
+ The override surface is **layered**. Setting a public token on the toast (or any ancestor) themes every variant; setting a per-variant token themes only that one variant.
71
+
72
+ ### Public override tokens (win over variant defaults)
73
+
74
+ <table>
75
+ <thead>
76
+ <tr><th>Variable</th><th>Default</th><th>Description</th></tr>
77
+ </thead>
78
+ <tbody>
79
+ <tr><td>`--toast_color`</td><td>per variant</td><td>Toast background. Set to theme every variant at once.</td></tr>
80
+ <tr><td>`--toast_on-color`</td><td>per variant</td><td>Toast text colour.</td></tr>
81
+ </tbody>
82
+ </table>
83
+
84
+ ### Per-variant overrides
70
85
 
71
86
  <table>
72
87
  <thead>
73
- <tr><th>Variable</th><th>Description</th></tr>
88
+ <tr><th>Variant</th><th>Background</th><th>Text</th></tr>
74
89
  </thead>
75
90
  <tbody>
76
- <tr><td>`--toast_color`</td><td>Toast background</td></tr>
77
- <tr><td>`--toast_on-color`</td><td>Toast text colour</td></tr>
91
+ <tr><td>`info`</td><td>`--toast_color--info`</td><td>`--toast_on-color--info`</td></tr>
92
+ <tr><td>`success`</td><td>`--toast_color--success`</td><td>`--toast_on-color--success`</td></tr>
93
+ <tr><td>`warning`</td><td>`--toast_color--warning`</td><td>`--toast_on-color--warning`</td></tr>
94
+ <tr><td>`error`</td><td>`--toast_color--error`</td><td>`--toast_on-color--error`</td></tr>
78
95
  </tbody>
79
96
  </table>
80
97
 
98
+ Defaults resolve to the corresponding foundation tokens.
99
+
81
100
 
82
101
  ## Accessibility
83
102
 
@@ -16,8 +16,11 @@
16
16
 
17
17
  .toast {
18
18
  padding: var(--bds-space_m) var(--bds-space_l);
19
- background-color: var(--toast_color, var(--bds-color_bg));
20
- color: var(--toast_on-color, var(--bds-color_on-bg));
19
+
20
+ /* Layered cascade: public overrides (--toast_color, --toast_on-color) win
21
+ over per-variant defaults. Variants write to the inner --toast_*--variant slot. */
22
+ background-color: var(--toast_color, var(--toast_color--variant, var(--bds-color_bg)));
23
+ color: var(--toast_on-color, var(--toast_on-color--variant, var(--bds-color_on-bg)));
21
24
  border-radius: var(--bds-border_radius--s);
22
25
  box-shadow: var(--bds-shadow_s);
23
26
  display: flex;
@@ -28,23 +31,23 @@
28
31
  }
29
32
 
30
33
  .toast.--variant_success {
31
- --toast_color: var(--bds-color_success);
32
- --toast_on-color: var(--bds-color_on-success);
34
+ --toast_color--variant: var(--toast_color--success, var(--bds-color_success));
35
+ --toast_on-color--variant: var(--toast_on-color--success, var(--bds-color_on-success));
33
36
  }
34
37
 
35
38
  .toast.--variant_warning {
36
- --toast_color: var(--bds-color_warning);
37
- --toast_on-color: var(--bds-color_on-warning);
39
+ --toast_color--variant: var(--toast_color--warning, var(--bds-color_warning));
40
+ --toast_on-color--variant: var(--toast_on-color--warning, var(--bds-color_on-warning));
38
41
  }
39
42
 
40
43
  .toast.--variant_info {
41
- --toast_color: var(--bds-color_bg--subtle);
42
- --toast_on-color: var(--bds-color_on-bg--subtle);
44
+ --toast_color--variant: var(--toast_color--info, var(--bds-color_bg--subtle));
45
+ --toast_on-color--variant: var(--toast_on-color--info, var(--bds-color_on-bg--subtle));
43
46
  }
44
47
 
45
48
  .toast.--variant_error {
46
- --toast_color: var(--bds-color_error);
47
- --toast_on-color: var(--bds-color_on-error);
49
+ --toast_color--variant: var(--toast_color--error, var(--bds-color_error));
50
+ --toast_on-color--variant: var(--toast_on-color--error, var(--bds-color_on-error));
48
51
  }
49
52
 
50
53
  .message {
@@ -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
+ });
@@ -0,0 +1,99 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { FieldGroup } from './FieldGroup';
3
+ import { FormInput } from '../FormInput';
4
+ import { Select } from '../Select';
5
+
6
+ const meta = {
7
+ title: 'React/Form/FieldGroup',
8
+ component: FieldGroup,
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'A `<fieldset>` wrapper that groups related form fields under a styled `<legend>`. ' +
14
+ 'The `horizontal` variant lays fields out in a row that auto-collapses to a single ' +
15
+ 'column via a CSS container query when the FieldGroup itself is too narrow.',
16
+ },
17
+ },
18
+ },
19
+ } satisfies Meta<typeof FieldGroup>;
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ export const Horizontal: Story = {
25
+ args: { legend: 'Contact details', variant: 'horizontal' },
26
+ render: args => (
27
+ <FieldGroup {...args}>
28
+ <FormInput label="First name" name="firstName" />
29
+ <FormInput label="Last name" name="lastName" />
30
+ <FormInput label="Email" name="email" type="email" />
31
+ </FieldGroup>
32
+ ),
33
+ };
34
+
35
+ export const Vertical: Story = {
36
+ args: { legend: 'Contact details', variant: 'vertical' },
37
+ render: args => (
38
+ <FieldGroup {...args}>
39
+ <FormInput label="First name" name="firstName" />
40
+ <FormInput label="Last name" name="lastName" />
41
+ <FormInput label="Email" name="email" type="email" />
42
+ </FieldGroup>
43
+ ),
44
+ };
45
+
46
+ export const HorizontalCollapsesInNarrowContainer: Story = {
47
+ name: 'Horizontal — collapses in narrow container',
48
+ args: { legend: 'Same fields, narrower parent', variant: 'horizontal' },
49
+ render: args => (
50
+ <div style={{ inlineSize: '20rem', border: '1px dashed currentcolor', padding: '1rem' }}>
51
+ <FieldGroup {...args}>
52
+ <FormInput label="First name" name="firstName" />
53
+ <FormInput label="Last name" name="lastName" />
54
+ <FormInput label="Email" name="email" type="email" />
55
+ </FieldGroup>
56
+ </div>
57
+ ),
58
+ };
59
+
60
+ export const WithSelectAndInputs: Story = {
61
+ args: { legend: 'Address', variant: 'horizontal' },
62
+ render: args => (
63
+ <FieldGroup {...args}>
64
+ <FormInput label="Street" name="street" />
65
+ <FormInput label="House number" name="houseNumber" />
66
+ <FormInput label="Postal code" name="postalCode" />
67
+ <Select
68
+ label="Country"
69
+ name="country"
70
+ options={[
71
+ { value: 'nl', label: 'Netherlands' },
72
+ { value: 'be', label: 'Belgium' },
73
+ { value: 'de', label: 'Germany' },
74
+ ]}
75
+ />
76
+ </FieldGroup>
77
+ ),
78
+ };
79
+
80
+ export const WithoutLegend: Story = {
81
+ args: { 'aria-label': 'Search filters', variant: 'horizontal' },
82
+ render: args => (
83
+ <FieldGroup {...args}>
84
+ <FormInput label="Query" name="q" />
85
+ <FormInput label="From" name="from" type="date" />
86
+ <FormInput label="To" name="to" type="date" />
87
+ </FieldGroup>
88
+ ),
89
+ };
90
+
91
+ export const Disabled: Story = {
92
+ args: { legend: 'Disabled section', variant: 'horizontal', disabled: true },
93
+ render: args => (
94
+ <FieldGroup {...args}>
95
+ <FormInput label="First name" name="firstName" />
96
+ <FormInput label="Last name" name="lastName" />
97
+ </FieldGroup>
98
+ ),
99
+ };