@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.
- package/AGENTS.md +102 -2
- package/dist/client.cjs +257 -219
- package/dist/client.css +587 -539
- 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 +587 -539
- package/dist/index.d.cts +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +220 -183
- package/dist/web-components/{chunk-T6AETZRP.js → chunk-3GFWLSLS.js} +1 -1
- package/dist/web-components/{chunk-N6IMUOY4.js → chunk-65JXQOBA.js} +3 -3
- package/dist/web-components/{chunk-ZTC6GRP7.js → chunk-BKIHQYJR.js} +1 -0
- package/dist/web-components/{chunk-X3FKVHLK.js → chunk-CM3KYZ6Z.js} +1 -1
- package/dist/web-components/{chunk-QLZ2362S.js → chunk-CZBIVR4V.js} +1 -1
- package/dist/web-components/{chunk-GER5UJOF.js → chunk-F5E3IT7G.js} +1 -3
- package/dist/web-components/{chunk-UFFCKVZE.js → chunk-FXURM6D5.js} +1 -1
- package/dist/web-components/{chunk-HHHRF2PS.js → chunk-IYGF6CD5.js} +1 -1
- package/dist/web-components/{chunk-OCODKRVZ.js → chunk-KUI55GFB.js} +2 -2
- package/dist/web-components/{chunk-ZNKEZIYE.js → chunk-NCPQ7AF3.js} +3 -2
- package/dist/web-components/{chunk-RTOS7LLG.js → chunk-PC3IOQAH.js} +3 -8
- package/dist/web-components/{chunk-JUKB3BUP.js → chunk-PXZWYO6Z.js} +1 -1
- package/dist/web-components/{chunk-EBJM3VD4.js → chunk-QBB6ZX4R.js} +1 -1
- package/dist/web-components/{chunk-ECW3HHWA.js → chunk-TQ7IQ4GZ.js} +7 -2
- package/dist/web-components/{chunk-AJSXNDAP.js → chunk-X46KL5BK.js} +19 -18
- package/dist/web-components/{chunk-XNA6WTXG.js → chunk-Y3POAKGS.js} +1 -1
- package/dist/web-components/{chunk-HGBNDR22.js → chunk-Y7V7LM6R.js} +1 -1
- package/dist/web-components/index.d.ts +56 -1
- package/dist/web-components/index.js +140 -19
- package/dist/web-components/interaction/bds-accordion-item.js +1 -1
- package/dist/web-components/interaction/bds-accordion.js +1 -1
- package/dist/web-components/interaction/bds-button.js +1 -1
- package/dist/web-components/interaction/bds-dialog.js +1 -1
- package/dist/web-components/interaction/bds-drawer.js +1 -1
- package/dist/web-components/interaction/bds-popover.js +1 -1
- package/dist/web-components/interaction/bds-tooltip.js +1 -1
- package/dist/web-components/interaction/form/bds-checkbox.js +1 -1
- package/dist/web-components/interaction/form/bds-combobox.js +1 -1
- package/dist/web-components/interaction/form/bds-number-input.js +1 -1
- package/dist/web-components/interaction/form/bds-radio.js +1 -1
- package/dist/web-components/interaction/form/bds-select.js +1 -1
- package/dist/web-components/interaction/form/bds-switch.js +1 -1
- package/dist/web-components/interaction/form/bds-textarea.js +1 -1
- package/dist/web-components/ui/bds-alert.js +1 -1
- package/dist/web-components/ui/bds-card.d.ts +3 -9
- package/dist/web-components/ui/bds-card.js +1 -1
- package/dist/web-components/ui/bds-notification-banner.js +1 -1
- package/package.json +4 -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/components/layout/IconWrapper/IconWrapper.module.css +2 -2
- package/src/index.ts +2 -0
- package/src/test/token-contract.spec.ts +86 -0
- package/src/web-components/index.ts +2 -0
- package/src/web-components/interaction/bds-accordion-item.ts +1 -1
- package/src/web-components/interaction/bds-accordion.ts +1 -0
- package/src/web-components/interaction/bds-button.ts +1 -3
- package/src/web-components/interaction/bds-dialog.ts +3 -3
- package/src/web-components/interaction/bds-drawer.ts +2 -2
- package/src/web-components/interaction/bds-popover.ts +7 -2
- package/src/web-components/interaction/bds-tooltip.ts +1 -1
- 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-checkbox.ts +1 -1
- package/src/web-components/interaction/form/bds-combobox.ts +1 -1
- 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 +2 -1
- package/src/web-components/interaction/form/bds-number-input.ts +1 -1
- package/src/web-components/interaction/form/bds-radio-group.ts +1 -0
- package/src/web-components/interaction/form/bds-radio.ts +1 -1
- package/src/web-components/interaction/form/bds-select.ts +1 -1
- package/src/web-components/interaction/form/bds-switch.ts +1 -1
- package/src/web-components/interaction/form/bds-textarea.ts +1 -1
- package/src/web-components/ui/BdsCard.mdx +4 -3
- package/src/web-components/ui/BdsCard.stories.tsx +7 -11
- package/src/web-components/ui/BdsSkeleton.stories.tsx +4 -4
- package/src/web-components/ui/bds-alert.ts +3 -2
- package/src/web-components/ui/bds-card.spec.ts +3 -5
- package/src/web-components/ui/bds-card.ts +5 -12
- package/src/web-components/ui/bds-notification-banner.ts +19 -18
- package/src/web-components/ui/bds-pagination.ts +1 -1
- 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
|
+
}
|
|
@@ -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(--
|
|
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:
|
|
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:
|
|
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
|
|