@boostdev/design-system-components 2.6.0 → 2.7.1

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 (90) hide show
  1. package/AGENTS.md +102 -2
  2. package/dist/client.cjs +257 -219
  3. package/dist/client.css +587 -539
  4. package/dist/client.d.cts +27 -1
  5. package/dist/client.d.ts +27 -1
  6. package/dist/client.js +220 -183
  7. package/dist/index.cjs +257 -219
  8. package/dist/index.css +587 -539
  9. package/dist/index.d.cts +27 -1
  10. package/dist/index.d.ts +27 -1
  11. package/dist/index.js +220 -183
  12. package/dist/web-components/{chunk-T6AETZRP.js → chunk-3GFWLSLS.js} +1 -1
  13. package/dist/web-components/{chunk-N6IMUOY4.js → chunk-65JXQOBA.js} +3 -3
  14. package/dist/web-components/{chunk-ZTC6GRP7.js → chunk-BKIHQYJR.js} +1 -0
  15. package/dist/web-components/{chunk-X3FKVHLK.js → chunk-CM3KYZ6Z.js} +1 -1
  16. package/dist/web-components/{chunk-QLZ2362S.js → chunk-CZBIVR4V.js} +1 -1
  17. package/dist/web-components/{chunk-GER5UJOF.js → chunk-F5E3IT7G.js} +1 -3
  18. package/dist/web-components/{chunk-UFFCKVZE.js → chunk-FXURM6D5.js} +1 -1
  19. package/dist/web-components/{chunk-HHHRF2PS.js → chunk-IYGF6CD5.js} +1 -1
  20. package/dist/web-components/{chunk-OCODKRVZ.js → chunk-KUI55GFB.js} +2 -2
  21. package/dist/web-components/{chunk-ZNKEZIYE.js → chunk-NCPQ7AF3.js} +3 -2
  22. package/dist/web-components/{chunk-RTOS7LLG.js → chunk-PC3IOQAH.js} +3 -8
  23. package/dist/web-components/{chunk-JUKB3BUP.js → chunk-PXZWYO6Z.js} +1 -1
  24. package/dist/web-components/{chunk-EBJM3VD4.js → chunk-QBB6ZX4R.js} +1 -1
  25. package/dist/web-components/{chunk-ECW3HHWA.js → chunk-TQ7IQ4GZ.js} +7 -2
  26. package/dist/web-components/{chunk-AJSXNDAP.js → chunk-X46KL5BK.js} +19 -18
  27. package/dist/web-components/{chunk-XNA6WTXG.js → chunk-Y3POAKGS.js} +1 -1
  28. package/dist/web-components/{chunk-HGBNDR22.js → chunk-Y7V7LM6R.js} +1 -1
  29. package/dist/web-components/index.d.ts +56 -1
  30. package/dist/web-components/index.js +140 -19
  31. package/dist/web-components/interaction/bds-accordion-item.js +1 -1
  32. package/dist/web-components/interaction/bds-accordion.js +1 -1
  33. package/dist/web-components/interaction/bds-button.js +1 -1
  34. package/dist/web-components/interaction/bds-dialog.js +1 -1
  35. package/dist/web-components/interaction/bds-drawer.js +1 -1
  36. package/dist/web-components/interaction/bds-popover.js +1 -1
  37. package/dist/web-components/interaction/bds-tooltip.js +1 -1
  38. package/dist/web-components/interaction/form/bds-checkbox.js +1 -1
  39. package/dist/web-components/interaction/form/bds-combobox.js +1 -1
  40. package/dist/web-components/interaction/form/bds-number-input.js +1 -1
  41. package/dist/web-components/interaction/form/bds-radio.js +1 -1
  42. package/dist/web-components/interaction/form/bds-select.js +1 -1
  43. package/dist/web-components/interaction/form/bds-switch.js +1 -1
  44. package/dist/web-components/interaction/form/bds-textarea.js +1 -1
  45. package/dist/web-components/ui/bds-alert.js +1 -1
  46. package/dist/web-components/ui/bds-card.d.ts +3 -9
  47. package/dist/web-components/ui/bds-card.js +1 -1
  48. package/dist/web-components/ui/bds-notification-banner.js +1 -1
  49. package/package.json +4 -2
  50. package/src/components/interaction/form/FieldGroup/FieldGroup.mdx +113 -0
  51. package/src/components/interaction/form/FieldGroup/FieldGroup.module.css +96 -0
  52. package/src/components/interaction/form/FieldGroup/FieldGroup.spec.tsx +196 -0
  53. package/src/components/interaction/form/FieldGroup/FieldGroup.stories.tsx +99 -0
  54. package/src/components/interaction/form/FieldGroup/FieldGroup.tsx +64 -0
  55. package/src/components/interaction/form/FieldGroup/index.ts +2 -0
  56. package/src/components/interaction/form/atoms/Message.module.css +4 -0
  57. package/src/components/layout/IconWrapper/IconWrapper.module.css +2 -2
  58. package/src/index.ts +2 -0
  59. package/src/test/token-contract.spec.ts +86 -0
  60. package/src/web-components/index.ts +2 -0
  61. package/src/web-components/interaction/bds-accordion-item.ts +1 -1
  62. package/src/web-components/interaction/bds-accordion.ts +1 -0
  63. package/src/web-components/interaction/bds-button.ts +1 -3
  64. package/src/web-components/interaction/bds-dialog.ts +3 -3
  65. package/src/web-components/interaction/bds-drawer.ts +2 -2
  66. package/src/web-components/interaction/bds-popover.ts +7 -2
  67. package/src/web-components/interaction/bds-tooltip.ts +1 -1
  68. package/src/web-components/interaction/form/BdsFieldGroup.mdx +67 -0
  69. package/src/web-components/interaction/form/BdsFieldGroup.stories.tsx +110 -0
  70. package/src/web-components/interaction/form/bds-checkbox-group.ts +1 -0
  71. package/src/web-components/interaction/form/bds-checkbox.ts +1 -1
  72. package/src/web-components/interaction/form/bds-combobox.ts +1 -1
  73. package/src/web-components/interaction/form/bds-field-group.spec.ts +64 -0
  74. package/src/web-components/interaction/form/bds-field-group.ts +157 -0
  75. package/src/web-components/interaction/form/bds-form-input.ts +2 -1
  76. package/src/web-components/interaction/form/bds-number-input.ts +1 -1
  77. package/src/web-components/interaction/form/bds-radio-group.ts +1 -0
  78. package/src/web-components/interaction/form/bds-radio.ts +1 -1
  79. package/src/web-components/interaction/form/bds-select.ts +1 -1
  80. package/src/web-components/interaction/form/bds-switch.ts +1 -1
  81. package/src/web-components/interaction/form/bds-textarea.ts +1 -1
  82. package/src/web-components/ui/BdsCard.mdx +4 -3
  83. package/src/web-components/ui/BdsCard.stories.tsx +7 -11
  84. package/src/web-components/ui/BdsSkeleton.stories.tsx +4 -4
  85. package/src/web-components/ui/bds-alert.ts +3 -2
  86. package/src/web-components/ui/bds-card.spec.ts +3 -5
  87. package/src/web-components/ui/bds-card.ts +5 -12
  88. package/src/web-components/ui/bds-notification-banner.ts +19 -18
  89. package/src/web-components/ui/bds-pagination.ts +1 -1
  90. package/src/web-components/ui/bds-table.ts +4 -0
@@ -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
+ };
@@ -0,0 +1,64 @@
1
+ import { Children, CSSProperties, FieldsetHTMLAttributes, ReactNode } from 'react';
2
+ import { cn } from '@boostdev/design-system-foundation';
3
+ import css from './FieldGroup.module.css';
4
+ import type { WithClassName } from '../../../../types';
5
+
6
+ /**
7
+ * Layout variants for FieldGroup.
8
+ *
9
+ * - `horizontal` (default) — fields flow into a row, each claiming at least
10
+ * `--fieldGroup_min-field-width`. When the container's inline-size is too
11
+ * narrow to fit the row, a CSS container query collapses the layout to a
12
+ * single column.
13
+ * - `vertical` — fields always stack one per row.
14
+ */
15
+ export type FieldGroupVariant = 'horizontal' | 'vertical';
16
+
17
+ export interface FieldGroupProps
18
+ extends WithClassName,
19
+ FieldsetHTMLAttributes<HTMLFieldSetElement> {
20
+ /** Layout direction. Defaults to `horizontal`. */
21
+ variant?: FieldGroupVariant;
22
+ /**
23
+ * Optional legend rendered above the fields. When omitted, set
24
+ * `aria-label` or `aria-labelledby` on the fieldset for accessibility.
25
+ */
26
+ legend?: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * `FieldGroup` semantically groups related form fields under a single
31
+ * `<fieldset>` and lays them out either horizontally (with container-query
32
+ * fallback to vertical) or vertically.
33
+ */
34
+ export function FieldGroup({
35
+ variant = 'horizontal',
36
+ legend,
37
+ children,
38
+ className,
39
+ style,
40
+ ...rest
41
+ }: Readonly<FieldGroupProps>) {
42
+ // Surface the field count to CSS so the @container collapse threshold
43
+ // scales with the actual content. The CSS uses
44
+ // N * --fieldGroup_min-field-width + (N - 1) * --fieldGroup_gap
45
+ // which is exactly the inline-size required to lay all fields out at
46
+ // their minimum width with the configured gap. When the FieldGroup's own
47
+ // inline-size drops below that, the layout collapses to a single column.
48
+ const fieldCount = Children.count(children);
49
+ const styleWithCount = {
50
+ ...style,
51
+ '--fieldGroup_field-count': fieldCount,
52
+ } as CSSProperties;
53
+
54
+ return (
55
+ <fieldset
56
+ {...rest}
57
+ style={styleWithCount}
58
+ className={cn(css.fieldGroup, css[`--variant_${variant}`], className)}
59
+ >
60
+ {legend !== undefined && <legend className={css.legend}>{legend}</legend>}
61
+ <div className={css.fields}>{children}</div>
62
+ </fieldset>
63
+ );
64
+ }
@@ -0,0 +1,2 @@
1
+ export { FieldGroup } from './FieldGroup';
2
+ export type { FieldGroupProps, FieldGroupVariant } from './FieldGroup';
@@ -8,4 +8,8 @@
8
8
  .error {
9
9
  color: var(--bds-color_error_on-bg);
10
10
  }
11
+
12
+ .hint {
13
+ font-style: italic;
14
+ }
11
15
  }
@@ -9,8 +9,8 @@
9
9
  justify-content: center;
10
10
  margin: var(--bds-space_l) auto var(--bds-space_m);
11
11
  background: var(--icon-wrapper-color, var(--bds-color_bg));
12
- color: var(--bds-on-icon-wrapper-color, var(--bds-color_on-bg));
13
- fill: var(--bds-on-icon-wrapper-color, var(--bds-color_on-bg));
12
+ color: var(--icon-wrapper_on-color, var(--bds-on-icon-wrapper-color, var(--bds-color_on-bg)));
13
+ fill: var(--icon-wrapper_on-color, var(--bds-on-icon-wrapper-color, var(--bds-color_on-bg)));
14
14
  position: relative;
15
15
  transition: var(--bds-animation_transition);
16
16
  }
package/src/index.ts CHANGED
@@ -48,6 +48,8 @@ export { Checkbox } from './components/interaction/form/Checkbox';
48
48
  export { CheckboxGroup } from './components/interaction/form/CheckboxGroup';
49
49
  export type { CheckboxGroupProps } from './components/interaction/form/CheckboxGroup';
50
50
  export { Combobox } from './components/interaction/form/Combobox';
51
+ export { FieldGroup } from './components/interaction/form/FieldGroup';
52
+ export type { FieldGroupProps, FieldGroupVariant } from './components/interaction/form/FieldGroup';
51
53
  export type { ComboboxOption } from './components/interaction/form/Combobox';
52
54
  export { FileInput } from './components/interaction/form/FileInput';
53
55
  export { FormInput } from './components/interaction/form/FormInput';
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { extractFoundationRefs, extractTokens } from '../../mcp/extract.ts';
5
+
6
+ function collectFiles(dir: string, ext: string): string[] {
7
+ const results: string[] = [];
8
+ for (const entry of readdirSync(dir)) {
9
+ const full = join(dir, entry);
10
+ if (statSync(full).isDirectory()) {
11
+ results.push(...collectFiles(full, ext));
12
+ } else if (full.endsWith(ext)) {
13
+ results.push(full);
14
+ }
15
+ }
16
+ return results;
17
+ }
18
+
19
+ function loadDeclaredTokens(): Set<string> {
20
+ const tokensDir = join(__dirname, '../../node_modules/@boostdev/design-system-foundation/src/tokens');
21
+ const propertiesFile = join(tokensDir, 'properties.css');
22
+ const declared = new Set<string>();
23
+
24
+ for (const file of collectFiles(tokensDir, '.css')) {
25
+ const css = readFileSync(file, 'utf-8');
26
+ for (const token of extractTokens(css, 'all')) {
27
+ declared.add(token.name);
28
+ }
29
+ }
30
+
31
+ // @property declarations aren't captured by extractTokens — parse them separately
32
+ if (statSync(propertiesFile).isFile()) {
33
+ const css = readFileSync(propertiesFile, 'utf-8');
34
+ for (const [, name] of css.matchAll(/@property\s+(--bds-[\w-]+)/g)) {
35
+ declared.add(name);
36
+ }
37
+ }
38
+
39
+ // Also include deprecated aliases (they resolve to real tokens)
40
+ const deprecatedFile = join(tokensDir, 'deprecated.css');
41
+ const deprecatedCss = readFileSync(deprecatedFile, 'utf-8');
42
+ for (const [, name] of deprecatedCss.matchAll(/^\s*(--bds-[\w-]+)\s*:/gm)) {
43
+ declared.add(name);
44
+ }
45
+
46
+ return declared;
47
+ }
48
+
49
+ // Component-local tokens that were incorrectly given the --bds-* prefix.
50
+ // Kept as deprecated fallbacks for backwards compatibility — remove in next major.
51
+ const DEPRECATED_BDS_ALIASES = new Set([
52
+ '--bds-on-icon-wrapper-color',
53
+ ]);
54
+
55
+ describe('token contract', () => {
56
+ const declared = loadDeclaredTokens();
57
+ const srcRoot = join(__dirname, '../');
58
+ const componentCssFiles = collectFiles(join(srcRoot, 'components'), '.module.css');
59
+ const bdcFile = join(srcRoot, 'css/bdc.css');
60
+
61
+ it('has a non-empty set of declared foundation tokens', () => {
62
+ expect(declared.size).toBeGreaterThan(50);
63
+ });
64
+
65
+ it('resolves every var(--bds-*) reference in component CSS to a declared token', () => {
66
+ const undeclared: { file: string; token: string }[] = [];
67
+
68
+ for (const file of [...componentCssFiles, bdcFile]) {
69
+ const css = readFileSync(file, 'utf-8');
70
+ for (const ref of extractFoundationRefs(css)) {
71
+ if (!declared.has(ref) && !DEPRECATED_BDS_ALIASES.has(ref)) {
72
+ undeclared.push({ file: relative(srcRoot, file), token: ref });
73
+ }
74
+ }
75
+ }
76
+
77
+ if (undeclared.length > 0) {
78
+ const report = undeclared
79
+ .map(({ file, token }) => ` ${token} in ${file}`)
80
+ .join('\n');
81
+ expect.fail(
82
+ `Found ${undeclared.length} reference(s) to undeclared foundation tokens:\n${report}`,
83
+ );
84
+ }
85
+ });
86
+ });
@@ -68,6 +68,8 @@ export { BdsTable } from './ui/bds-table';
68
68
  export { BdsPagination } from './ui/bds-pagination';
69
69
  export { BdsCheckboxGroup } from './interaction/form/bds-checkbox-group';
70
70
  export { BdsRadioGroup } from './interaction/form/bds-radio-group';
71
+ export { BdsFieldGroup } from './interaction/form/bds-field-group';
72
+ export type { FieldGroupVariant as BdsFieldGroupVariant } from './interaction/form/bds-field-group';
71
73
  export { BdsFormInput } from './interaction/form/bds-form-input';
72
74
  export { BdsCarousel } from './ui/bds-carousel';
73
75
  export { BdsCalendar } from './ui/bds-calendar';
@@ -69,7 +69,7 @@ export class BdsAccordionItem extends LitElement {
69
69
 
70
70
  @media (hover: hover) and (pointer: fine) {
71
71
  .trigger:not(:disabled):hover {
72
- background-color: var(--bds-color_bg--subtle);
72
+ background-color: var(--accordion_trigger-bg--hover, var(--bds-color_bg--subtle));
73
73
  }
74
74
  }
75
75
 
@@ -26,6 +26,7 @@ export class BdsAccordion extends LitElement {
26
26
  static styles = css`
27
27
  :host {
28
28
  display: block;
29
+ border: var(--accordion_border, none);
29
30
  --bdc_color: var(--bds-color_bg--subtle);
30
31
  --bdc-outline_radius: var(--bds-border_radius--s);
31
32
  border-radius: var(--accordion_radius, var(--bdc-outline_radius));
@@ -56,7 +56,7 @@ export class BdsButton extends LitElement {
56
56
  padding-inline: var(--bds-space_l);
57
57
  display: inline-flex;
58
58
  gap: var(--bds-space_xs);
59
- border-radius: var(--button_border_radius--s, var(--bds-border_radius--s));
59
+ border-radius: var(--button_radius, var(--bds-border_radius--full));
60
60
  align-items: center;
61
61
  cursor: pointer;
62
62
  justify-content: center;
@@ -102,7 +102,6 @@ export class BdsButton extends LitElement {
102
102
  .button.small {
103
103
  --button_height: 2.25em;
104
104
  --button_font-size: var(--bds-font_size--body--s);
105
- --button_border_radius--s: var(--bds-border_radius--xs);
106
105
  padding-inline: var(--bds-space_m);
107
106
  }
108
107
 
@@ -113,7 +112,6 @@ export class BdsButton extends LitElement {
113
112
 
114
113
  .button.large {
115
114
  --button_font-size: var(--bds-font_size--heading-3);
116
- --button_border_radius--s: var(--bds-border_radius--m);
117
115
  padding-inline: var(--bds-space_xl);
118
116
  }
119
117
 
@@ -34,7 +34,7 @@ export class BdsDialog extends LitElement {
34
34
  .dialog {
35
35
  font-size: var(--bds-font_size--body--s);
36
36
  border: var(--dialog_border, none);
37
- --bdc_color: currentcolor;
37
+ --bdc_color: var(--dialog_border-color, currentcolor);
38
38
  --bdc-outline_radius: var(--bds-border_radius--m);
39
39
 
40
40
  border-radius: var(--dialog_radius, var(--bdc-outline_radius));
@@ -66,11 +66,11 @@ export class BdsDialog extends LitElement {
66
66
  display: flex;
67
67
  align-items: center;
68
68
  justify-content: center;
69
- border-radius: 50%;
69
+ border-radius: var(--dialog_close-radius, var(--bds-border_radius--circle));
70
70
  background-color: var(--bds-color_bg);
71
71
  color: currentcolor;
72
72
  border: var(--dialog_close-border, none);
73
- --bdc_color: currentcolor;
73
+ --bdc_color: var(--dialog_close-border-color, currentcolor);
74
74
 
75
75
  box-shadow: inset 0 0 0 var(--bdc-outline_width) var(--bdc_color);
76
76
  cursor: pointer;
@@ -119,7 +119,7 @@ export class BdsDrawer extends LitElement {
119
119
  justify-content: center;
120
120
  inline-size: 2rem;
121
121
  block-size: 2rem;
122
- border-radius: 50%;
122
+ border-radius: var(--drawer_close-radius, var(--bds-border_radius--circle));
123
123
  cursor: pointer;
124
124
  transition: var(--bds-animation_transition);
125
125
  }
@@ -136,7 +136,7 @@ export class BdsDrawer extends LitElement {
136
136
 
137
137
  @media (hover: hover) and (pointer: fine) {
138
138
  .close-button:hover {
139
- background-color: var(--bds-color_bg--subtle);
139
+ background-color: var(--drawer_close-bg--hover, var(--bds-color_bg--subtle));
140
140
  }
141
141
  }
142
142