@arbor-education/design-system.components 0.22.0 → 0.23.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 (84) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/component-library.md +62 -0
  3. package/dist/components/combobox/Combobox.js +1 -1
  4. package/dist/components/combobox/Combobox.js.map +1 -1
  5. package/dist/components/combobox/Combobox.stories.d.ts +4 -0
  6. package/dist/components/combobox/Combobox.stories.d.ts.map +1 -1
  7. package/dist/components/combobox/Combobox.stories.js +144 -12
  8. package/dist/components/combobox/Combobox.stories.js.map +1 -1
  9. package/dist/components/combobox/Combobox.test.js +22 -0
  10. package/dist/components/combobox/Combobox.test.js.map +1 -1
  11. package/dist/components/combobox/ComboboxButtonTrigger.d.ts +4 -4
  12. package/dist/components/combobox/ComboboxButtonTrigger.d.ts.map +1 -1
  13. package/dist/components/combobox/ComboboxButtonTrigger.js +35 -40
  14. package/dist/components/combobox/ComboboxButtonTrigger.js.map +1 -1
  15. package/dist/components/combobox/ComboboxTrigger.d.ts.map +1 -1
  16. package/dist/components/combobox/ComboboxTrigger.js +11 -4
  17. package/dist/components/combobox/ComboboxTrigger.js.map +1 -1
  18. package/dist/components/combobox/useVisibleTriggerTags.d.ts +21 -0
  19. package/dist/components/combobox/useVisibleTriggerTags.d.ts.map +1 -0
  20. package/dist/components/combobox/useVisibleTriggerTags.js +46 -0
  21. package/dist/components/combobox/useVisibleTriggerTags.js.map +1 -0
  22. package/dist/components/combobox/useVisibleTriggerTags.test.d.ts +2 -0
  23. package/dist/components/combobox/useVisibleTriggerTags.test.d.ts.map +1 -0
  24. package/dist/components/combobox/useVisibleTriggerTags.test.js +81 -0
  25. package/dist/components/combobox/useVisibleTriggerTags.test.js.map +1 -0
  26. package/dist/components/filterBar/FilterBar.d.ts +71 -0
  27. package/dist/components/filterBar/FilterBar.d.ts.map +1 -0
  28. package/dist/components/filterBar/FilterBar.js +89 -0
  29. package/dist/components/filterBar/FilterBar.js.map +1 -0
  30. package/dist/components/filterBar/FilterBar.stories.d.ts +170 -0
  31. package/dist/components/filterBar/FilterBar.stories.d.ts.map +1 -0
  32. package/dist/components/filterBar/FilterBar.stories.js +894 -0
  33. package/dist/components/filterBar/FilterBar.stories.js.map +1 -0
  34. package/dist/components/filterBar/FilterBar.test.d.ts +2 -0
  35. package/dist/components/filterBar/FilterBar.test.d.ts.map +1 -0
  36. package/dist/components/filterBar/FilterBar.test.js +164 -0
  37. package/dist/components/filterBar/FilterBar.test.js.map +1 -0
  38. package/dist/components/icon/allowedIcons.d.ts +1 -0
  39. package/dist/components/icon/allowedIcons.d.ts.map +1 -1
  40. package/dist/components/icon/allowedIcons.js +2 -1
  41. package/dist/components/icon/allowedIcons.js.map +1 -1
  42. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts.map +1 -1
  43. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js +13 -2
  44. package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js.map +1 -1
  45. package/dist/index.css +142 -3
  46. package/dist/index.css.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/components/combobox/Combobox.stories.tsx +186 -12
  53. package/src/components/combobox/Combobox.test.tsx +53 -0
  54. package/src/components/combobox/Combobox.tsx +3 -3
  55. package/src/components/combobox/ComboboxButtonTrigger.tsx +52 -56
  56. package/src/components/combobox/ComboboxTrigger.tsx +19 -16
  57. package/src/components/combobox/combobox.scss +8 -3
  58. package/src/components/combobox/useVisibleTriggerTags.test.tsx +91 -0
  59. package/src/components/combobox/useVisibleTriggerTags.ts +83 -0
  60. package/src/components/filterBar/FilterBar.stories.tsx +1199 -0
  61. package/src/components/filterBar/FilterBar.test.tsx +248 -0
  62. package/src/components/filterBar/FilterBar.tsx +298 -0
  63. package/src/components/filterBar/filterBar.scss +143 -0
  64. package/src/components/icon/allowedIcons.tsx +3 -1
  65. package/src/components/table/cellRenderers/ComboboxCellRenderer.test.tsx +20 -3
  66. package/src/index.scss +3 -0
  67. package/src/index.ts +10 -0
  68. package/src/tokens.scss +1 -0
  69. package/stylelint.config.mjs +1 -0
  70. package/dist/components/combobox/useElementWidth.d.ts +0 -2
  71. package/dist/components/combobox/useElementWidth.d.ts.map +0 -1
  72. package/dist/components/combobox/useElementWidth.js +0 -31
  73. package/dist/components/combobox/useElementWidth.js.map +0 -1
  74. package/dist/components/combobox/useVisibleChips.d.ts +0 -21
  75. package/dist/components/combobox/useVisibleChips.d.ts.map +0 -1
  76. package/dist/components/combobox/useVisibleChips.js +0 -59
  77. package/dist/components/combobox/useVisibleChips.js.map +0 -1
  78. package/dist/components/combobox/useVisibleChips.test.d.ts +0 -2
  79. package/dist/components/combobox/useVisibleChips.test.d.ts.map +0 -1
  80. package/dist/components/combobox/useVisibleChips.test.js +0 -81
  81. package/dist/components/combobox/useVisibleChips.test.js.map +0 -1
  82. package/src/components/combobox/useElementWidth.ts +0 -40
  83. package/src/components/combobox/useVisibleChips.test.tsx +0 -91
  84. package/src/components/combobox/useVisibleChips.ts +0 -100
@@ -0,0 +1,1199 @@
1
+ import {
2
+ Controls,
3
+ Heading as DocHeading,
4
+ Primary as DocPrimary,
5
+ Markdown,
6
+ Stories,
7
+ Subtitle,
8
+ Title,
9
+ } from '@storybook/addon-docs/blocks';
10
+ import type { Meta, StoryObj } from '@storybook/react-vite';
11
+ import { Button } from 'Components/button/Button';
12
+ import { FormField } from 'Components/formField/FormField';
13
+ import { Modal } from 'Components/modal/Modal';
14
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
15
+ import { fn } from 'storybook/test';
16
+ import { FilterBar } from './FilterBar.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Docs page content
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const DESCRIPTION_INTRO = [
23
+ '`FilterBar` is a dumb compound component for composing filter, sort, and group triggers with a',
24
+ 'shared applied-item summary. It gives product code a consistent shell for toolbar buttons and',
25
+ 'active tags, while the consuming screen owns selected state, modal or slideover wiring, removal',
26
+ 'behaviour, and focus handoff into the opened surface.',
27
+ ].join(' ');
28
+
29
+ const USAGE_GUIDANCE = [
30
+ '### When to use',
31
+ '',
32
+ '- Reporting and analysis pages that need filter, sort, and grouping controls in one predictable row',
33
+ '- Views where users benefit from seeing applied criteria as editable tags directly beside the triggers',
34
+ '- Flows that open a dialog, modal, slideover, or popover to manage the underlying criteria',
35
+ '',
36
+ '---',
37
+ '',
38
+ '### When NOT to use',
39
+ '',
40
+ '| Situation | Use instead |',
41
+ '|---|---|',
42
+ '| You only need a passive summary of selected items | [`Tag`](?path=/docs/components-tag--docs) or a local summary pattern |',
43
+ '| You only have one control with no shared active-state summary | [`Button`](?path=/docs/components-button--docs) plus local content |',
44
+ '| The user edits criteria inline without a separate surface | Inline form fields or a dedicated toolbar layout |',
45
+ '| Applied values are read-only status labels, not editable criteria | [`Tag`](?path=/docs/components-tag--docs) or [`Pill`](?path=/docs/components-pill--docs) |',
46
+ ].join('\n');
47
+
48
+ const DEVELOPER_NOTES = [
49
+ '### Critical usage patterns',
50
+ '',
51
+ '**Label the toolbar.** `FilterBar.Toolbar` renders `role="toolbar"`, so it needs an accessible name',
52
+ 'via `aria-label` or `aria-labelledby`. A generic parent region label is not a substitute for the',
53
+ 'toolbar label itself.',
54
+ '',
55
+ '**Mirror popup semantics on the real trigger.** When a toolbar button opens a dialog,',
56
+ 'slideover, listbox, or menu, pass the native button attributes that describe that relationship:',
57
+ '`aria-controls`, `aria-expanded`, and the correct `aria-haspopup` value for the popup type.',
58
+ 'Applied tags currently support `ariaControls` and `ariaExpanded` for the same popup relationship.',
59
+ '',
60
+ '**Hand focus to the relevant field after opening.** `FilterBar` does not manage focus for you.',
61
+ 'If a user clicks `Year group` or an applied `Year group: Year 10` tag, move focus into the matching',
62
+ 'field once the modal or slideover has mounted. `fieldId` on `FilterBar.TagItem` is there to make',
63
+ 'that handoff easy. When the editor surface is a `Modal`, give it a `contentId` and mirror that value',
64
+ 'through `aria-controls` on the related buttons or tags so the relationship stays explicit.',
65
+ '',
66
+ '**Counts are summaries, not the source of truth.** Product code should derive them from the real',
67
+ 'applied state. Counts above `99` render visually as `99+`, but the generated accessible label keeps',
68
+ 'the full number.',
69
+ '',
70
+ '**Overflow only becomes interactive when you wire it.** `FilterBar.ActiveList` delegates to `TagList`.',
71
+ 'With `collapseOverflow` enabled, the `+N more` summary becomes a focusable tag only when',
72
+ '`onOverflowClick` is provided.',
73
+ '',
74
+ '**Choose one active-list pattern per instance.** Pass `items` for the common data-driven case, or',
75
+ 'compose custom `FilterBar.Tag` children when you need bespoke markup. Avoid mixing both patterns in',
76
+ 'the same production instance.',
77
+ '',
78
+ '---',
79
+ '',
80
+ '### Accessibility',
81
+ '',
82
+ '- Put an accessible name on the overall `FilterBar` region when it helps users understand the control set in context',
83
+ '- Put a separate accessible name on `FilterBar.Toolbar` because it renders `role="toolbar"`',
84
+ '- Use `aria-haspopup="dialog"` when a toolbar button opens a modal, `aria-haspopup="listbox"` for a listbox, and `aria-haspopup="menu"` for a menu',
85
+ '- Keep `aria-controls` and `aria-expanded` in sync on both toolbar buttons and applied tags when they reopen the same surface',
86
+ '- If an applied tag opens an editor, its `actionLabel` should describe both the criterion and the current value',
87
+ '- If a tag is removable, provide a specific `removeLabel` so screen readers announce the exact action',
88
+ '',
89
+ '---',
90
+ '',
91
+ '### TypeScript types',
92
+ '',
93
+ '```ts',
94
+ "import { FilterBar, type FilterBarTagItem, type FilterBarType } from '@arbor-education/design-system.components';",
95
+ '',
96
+ 'function AttendanceFilters(props: FilterBar.Props) {',
97
+ ' return <FilterBar {...props} />;',
98
+ '}',
99
+ '```',
100
+ '',
101
+ '| Type | Description |',
102
+ '|---|---|',
103
+ '| `FilterBar.Props` | Root wrapper props |',
104
+ '| `FilterBar.ToolbarProps` | Toolbar wrapper props |',
105
+ '| `FilterBar.ButtonProps` | Trigger button props |',
106
+ '| `FilterBar.ActiveListProps` | Applied-item summary props |',
107
+ '| `FilterBar.TagProps` | Manually composed tag props |',
108
+ '| `FilterBar.TagItem` | Data shape for `ActiveList.items` |',
109
+ '| `FilterBarType` | Union of `filter`, `sort`, and `group` |',
110
+ ].join('\n');
111
+
112
+ const RELATED_COMPONENTS = [
113
+ '## Related components',
114
+ '',
115
+ '[Tag](?path=/docs/components-tag--docs) · [Modal](?path=/docs/components-modals-modal--docs) · [Button](?path=/docs/components-button--docs) · [FormField](?path=/docs/components-formfield--docs)',
116
+ ].join('\n');
117
+
118
+ const PROPS_INTRO = [
119
+ 'The preview below is wired to the **Controls** panel for the most common root, toolbar, button-count,',
120
+ 'and active-list options. Because `FilterBar` is a compound component, the public API for',
121
+ '`FilterBar.Toolbar`, `FilterBar.Button`, `FilterBar.ActiveList`, and `FilterBar.Tag` is documented',
122
+ 'in the manual tables below.',
123
+ ].join(' ');
124
+
125
+ const TOOLBAR_PROPS = [
126
+ '| Prop | Type | Default | Description |',
127
+ '|---|---|---|---|',
128
+ '| `children` | `ReactNode` | — | Toolbar contents, usually one or more `FilterBar.Button` instances |',
129
+ '| `className` | `string` | — | Additional CSS classes on the toolbar wrapper |',
130
+ '| `aria-label` / `aria-labelledby` | native div attrs | — | Accessible name for the `role="toolbar"` element. Strongly recommended |',
131
+ '| `...HTMLAttributes<HTMLDivElement>` | native div attrs | — | Standard div attributes are forwarded to the toolbar wrapper |',
132
+ ].join('\n');
133
+
134
+ const BUTTON_PROPS = [
135
+ '| Prop | Type | Default | Description |',
136
+ '|---|---|---|---|',
137
+ '| `type` | `` `filter` \\| `sort` \\| `group` `` | — | **Required.** Chooses the default label, icon, and count announcement text |',
138
+ '| `label` | `string` | inferred from `type` | Override the visible button label |',
139
+ '| `count` | `number \\| null` | — | Shows a badge when `> 0`. The badge caps visually at `99+`, but the accessible label keeps the full number |',
140
+ '| `icon` | `IconName` | inferred from `type` | Override the default icon for that trigger type |',
141
+ '| `htmlType` | `` `button` \\| `submit` \\| `reset` `` | `button` | Native button `type` attribute |',
142
+ '| `aria-label` | `string` | generated from label + count | Override the accessible name when the default sentence is not specific enough |',
143
+ '| `aria-controls` / `aria-expanded` / `aria-haspopup` | native button attrs | — | Add these when the trigger opens a dialog, listbox, menu, or similar popup surface |',
144
+ '| `disabled`, `onClick`, and other button props | native button attrs | — | Standard button attributes are forwarded to the underlying `Button` component |',
145
+ ].join('\n');
146
+
147
+ const ACTIVE_LIST_PROPS = [
148
+ '| Prop | Type | Default | Description |',
149
+ '|---|---|---|---|',
150
+ '| `items` | `readonly FilterBar.TagItem[]` | — | Data-driven applied tags. This is the standard production pattern |',
151
+ '| `children` | `ReactNode` | — | Manual composition fallback, typically one or more `FilterBar.Tag` children |',
152
+ '| `className` | `string` | — | Additional CSS classes on the list wrapper |',
153
+ '| `ariaLabel` | `string` | `"Active filters, sorting, and grouping"` | Accessible name for the underlying list of applied items |',
154
+ '| `emptyState` | `ReactNode` | — | Content shown when there are no `items` and no `children` |',
155
+ '| `returnFocusRef` | `RefObject<HTMLElement \\| null>` | — | Fallback focus target used when a removal leaves no interactive tags to focus |',
156
+ '| `wrap` | `boolean` | `false` | Allows items to wrap onto multiple lines |',
157
+ '| `collapseOverflow` | `boolean` | `true` | Collapses hidden tags behind a `+N more` summary when `wrap` is `false` |',
158
+ '| `overflowActionLabel` | `string` | `"Show more applied items"` | Accessible name for the overflow summary when it is clickable |',
159
+ '| `onOverflowClick` | `MouseEventHandler<HTMLButtonElement>` | — | Makes the overflow summary interactive. Use it to open the managing surface |',
160
+ '| `overflowAriaControls` / `overflowAriaExpanded` | native button attrs | — | Tie the overflow summary to the dialog or popup it opens |',
161
+ ].join('\n');
162
+
163
+ const TAG_PROPS = [
164
+ '| Prop | Type | Default | Description |',
165
+ '|---|---|---|---|',
166
+ '| `type` | `` `filter` \\| `sort` \\| `group` `` | — | **Required.** Sets the default icon and action language |',
167
+ '| `label` | `string` | — | **Required.** Criterion label, e.g. `Year group` |',
168
+ '| `value` | `ReactNode` | — | **Required.** Current applied value, e.g. `Year 10` |',
169
+ '| `id` | `string` | derived from `fieldId` or label | Stable identifier for list rendering and event payloads |',
170
+ '| `fieldId` | `string` | — | Optional DOM id for the field that should receive focus after opening an editor |',
171
+ '| `icon` | `IconName` | inferred from `type` | Override the default icon |',
172
+ '| `children` | `ReactNode` | generated label + value layout | Supply custom tag content when the default `Label: Value` layout is not enough |',
173
+ '| `actionLabel` | `string` | generated from type + label + value | Accessible name for the primary tag action |',
174
+ '| `removeLabel` | `string` | Tag default | Accessible name for the remove button when `onRemove` is provided |',
175
+ '| `ariaControls` / `ariaExpanded` | native button attrs | — | Tie the primary tag action to the editor it opens |',
176
+ '| `disabled` | `boolean` | `false` | Disables both the primary action and the remove button |',
177
+ '| `onClick` | `(item, event) => void` | — | Fired when the primary tag action is activated |',
178
+ '| `onRemove` | `(item) => void` | — | Fired when the remove button is activated |',
179
+ ].join('\n');
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Story helpers
183
+ // ---------------------------------------------------------------------------
184
+
185
+ type FilterBarStoryArgs = FilterBar.Props & {
186
+ toolbarLabel: string;
187
+ activeListLabel: string;
188
+ filterCount: number;
189
+ sortCount: number;
190
+ groupCount: number;
191
+ wrap: boolean;
192
+ collapseOverflow: boolean;
193
+ overflowActionLabel: string;
194
+ };
195
+
196
+ const STORY_SHELL_STYLE: CSSProperties = {
197
+ width: '100%',
198
+ maxWidth: 'min(100%, 72rem)',
199
+ padding: 'var(--spacing-large)',
200
+ boxSizing: 'border-box',
201
+ };
202
+
203
+ const NARROW_STORY_SHELL_STYLE: CSSProperties = {
204
+ width: '100%',
205
+ maxWidth: 'min(100%, 34rem)',
206
+ padding: 'var(--spacing-large)',
207
+ boxSizing: 'border-box',
208
+ };
209
+
210
+ const MODAL_BODY_STYLE: CSSProperties = {
211
+ display: 'flex',
212
+ flexDirection: 'column',
213
+ gap: 'var(--spacing-medium)',
214
+ };
215
+
216
+ const MODAL_TEXT_STYLE: CSSProperties = {
217
+ margin: 0,
218
+ color: 'var(--color-grey-700)',
219
+ };
220
+
221
+ const StoryShell = ({
222
+ children,
223
+ narrow = false,
224
+ }: {
225
+ children: ReactNode;
226
+ narrow?: boolean;
227
+ }) => <div style={narrow ? NARROW_STORY_SHELL_STYLE : STORY_SHELL_STYLE}>{children}</div>;
228
+
229
+ const onOpenFilter = fn();
230
+ const onOpenSort = fn();
231
+ const onOpenGroup = fn();
232
+ const onOpenAppliedItem = fn();
233
+ const onRemoveAppliedItem = fn();
234
+ const onShowOverflow = fn();
235
+
236
+ const APPLIED_ITEM_SEEDS: ReadonlyArray<{
237
+ id: string;
238
+ type: FilterBar.Type;
239
+ label: string;
240
+ value: string;
241
+ fieldId: string;
242
+ }> = [
243
+ {
244
+ id: 'year-group',
245
+ type: 'filter',
246
+ label: 'Year group',
247
+ value: 'Year 10',
248
+ fieldId: 'attendance-filter-year-group',
249
+ },
250
+ {
251
+ id: 'attendance-band',
252
+ type: 'filter',
253
+ label: 'Attendance band',
254
+ value: 'Below 90%',
255
+ fieldId: 'attendance-filter-band',
256
+ },
257
+ {
258
+ id: 'date-range',
259
+ type: 'filter',
260
+ label: 'Date range',
261
+ value: 'Spring term',
262
+ fieldId: 'attendance-filter-date-range',
263
+ },
264
+ {
265
+ id: 'sort-order',
266
+ type: 'sort',
267
+ label: 'Sort order',
268
+ value: 'Priority first',
269
+ fieldId: 'attendance-sort-order',
270
+ },
271
+ {
272
+ id: 'group-by',
273
+ type: 'group',
274
+ label: 'Group by',
275
+ value: 'Tutor group',
276
+ fieldId: 'attendance-group-by',
277
+ },
278
+ ];
279
+
280
+ const INITIAL_MODAL_FIELD_VALUES = APPLIED_ITEM_SEEDS.reduce<Record<string, string>>((acc, item) => {
281
+ acc[item.fieldId] = item.value;
282
+ return acc;
283
+ }, {});
284
+
285
+ const OVERFLOW_ITEM_SEEDS: ReadonlyArray<{
286
+ id: string;
287
+ type: FilterBar.Type;
288
+ label: string;
289
+ value: string;
290
+ fieldId: string;
291
+ }> = [
292
+ ...APPLIED_ITEM_SEEDS,
293
+ {
294
+ id: 'intervention',
295
+ type: 'filter',
296
+ label: 'Intervention',
297
+ value: 'Attendance plan',
298
+ fieldId: 'attendance-filter-intervention',
299
+ },
300
+ {
301
+ id: 'reviewer',
302
+ type: 'filter',
303
+ label: 'Reviewer',
304
+ value: 'Heads of year',
305
+ fieldId: 'attendance-filter-reviewer',
306
+ },
307
+ {
308
+ id: 'status',
309
+ type: 'filter',
310
+ label: 'Status',
311
+ value: 'Open cases',
312
+ fieldId: 'attendance-filter-status',
313
+ },
314
+ ];
315
+
316
+ const buildAppliedItems = (
317
+ seeds: ReadonlyArray<{
318
+ id: string;
319
+ type: FilterBar.Type;
320
+ label: string;
321
+ value: string;
322
+ fieldId: string;
323
+ }>,
324
+ ): FilterBar.TagItem[] =>
325
+ seeds.map(item => ({
326
+ ...item,
327
+ actionLabel: `Open ${item.type} ${item.label}: ${item.value}`,
328
+ removeLabel: `Remove ${item.label.toLowerCase()} ${item.type}`,
329
+ onClick: (clickedItem, event) => onOpenAppliedItem(clickedItem, event),
330
+ onRemove: clickedItem => onRemoveAppliedItem(clickedItem),
331
+ }));
332
+
333
+ const DEFAULT_ITEMS = buildAppliedItems(APPLIED_ITEM_SEEDS);
334
+ const OVERFLOW_ITEMS = buildAppliedItems(OVERFLOW_ITEM_SEEDS);
335
+
336
+ const PANEL_FIELD_IDS: Record<FilterBar.Type, string> = {
337
+ filter: 'attendance-filter-year-group',
338
+ sort: 'attendance-sort-order',
339
+ group: 'attendance-group-by',
340
+ };
341
+
342
+ const PANEL_TITLES: Record<FilterBar.Type, string> = {
343
+ filter: 'Filters',
344
+ sort: 'Sorting',
345
+ group: 'Grouping',
346
+ };
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Custom DocsPage
350
+ // ---------------------------------------------------------------------------
351
+
352
+ function FilterBarDocsPage() {
353
+ return (
354
+ <>
355
+ <Title />
356
+ <Subtitle />
357
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
358
+ <DocHeading>Interactive example</DocHeading>
359
+ <Markdown>{PROPS_INTRO}</Markdown>
360
+ <DocPrimary />
361
+ <Controls />
362
+ <DocHeading>FilterBar.Toolbar props</DocHeading>
363
+ <Markdown>{TOOLBAR_PROPS}</Markdown>
364
+ <DocHeading>FilterBar.Button props</DocHeading>
365
+ <Markdown>{BUTTON_PROPS}</Markdown>
366
+ <DocHeading>FilterBar.ActiveList props</DocHeading>
367
+ <Markdown>{ACTIVE_LIST_PROPS}</Markdown>
368
+ <DocHeading>FilterBar.Tag props</DocHeading>
369
+ <Markdown>{TAG_PROPS}</Markdown>
370
+ <DocHeading>Usage guidance</DocHeading>
371
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
372
+ <DocHeading>Developer notes</DocHeading>
373
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
374
+ <DocHeading>Examples</DocHeading>
375
+ <Stories title="" />
376
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
377
+ </>
378
+ );
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Meta
383
+ // ---------------------------------------------------------------------------
384
+
385
+ const meta = {
386
+ title: 'Components/FilterBar',
387
+ component: FilterBar,
388
+ tags: ['autodocs'],
389
+ parameters: {
390
+ layout: 'padded',
391
+ docs: {
392
+ page: FilterBarDocsPage,
393
+ },
394
+ },
395
+ argTypes: {
396
+ 'aria-label': {
397
+ control: 'text',
398
+ description: 'Accessible name for the outer `FilterBar` region.',
399
+ table: {
400
+ category: 'FilterBar',
401
+ type: { summary: 'string' },
402
+ },
403
+ },
404
+ 'className': {
405
+ control: false,
406
+ description: 'Additional CSS classes applied to the root element.',
407
+ table: {
408
+ category: 'FilterBar',
409
+ type: { summary: 'string' },
410
+ },
411
+ },
412
+ 'children': {
413
+ control: false,
414
+ table: {
415
+ disable: true,
416
+ },
417
+ },
418
+ 'toolbarLabel': {
419
+ control: 'text',
420
+ description: 'Accessible name passed to `FilterBar.Toolbar` (`role="toolbar"`).',
421
+ table: {
422
+ category: 'FilterBar.Toolbar',
423
+ type: { summary: 'string' },
424
+ },
425
+ },
426
+ 'activeListLabel': {
427
+ control: 'text',
428
+ description: 'Accessible name passed to `FilterBar.ActiveList`.',
429
+ table: {
430
+ category: 'FilterBar.ActiveList',
431
+ type: { summary: 'string' },
432
+ },
433
+ },
434
+ 'filterCount': {
435
+ control: { type: 'number', min: 0, max: 250 },
436
+ description: 'Applied filter count shown on the Filter trigger badge.',
437
+ table: {
438
+ category: 'FilterBar.Button',
439
+ type: { summary: 'number' },
440
+ },
441
+ },
442
+ 'sortCount': {
443
+ control: { type: 'number', min: 0, max: 250 },
444
+ description: 'Applied sort count shown on the Sort trigger badge.',
445
+ table: {
446
+ category: 'FilterBar.Button',
447
+ type: { summary: 'number' },
448
+ },
449
+ },
450
+ 'groupCount': {
451
+ control: { type: 'number', min: 0, max: 250 },
452
+ description: 'Applied group count shown on the Group trigger badge.',
453
+ table: {
454
+ category: 'FilterBar.Button',
455
+ type: { summary: 'number' },
456
+ },
457
+ },
458
+ 'wrap': {
459
+ control: 'boolean',
460
+ description: 'Allows the applied-item summary to wrap onto multiple lines.',
461
+ table: {
462
+ category: 'FilterBar.ActiveList',
463
+ type: { summary: 'boolean' },
464
+ defaultValue: { summary: 'false' },
465
+ },
466
+ },
467
+ 'collapseOverflow': {
468
+ control: 'boolean',
469
+ description: 'Collapses hidden tags behind a `+N more` summary when `wrap` is `false`.',
470
+ table: {
471
+ category: 'FilterBar.ActiveList',
472
+ type: { summary: 'boolean' },
473
+ defaultValue: { summary: 'true' },
474
+ },
475
+ },
476
+ 'overflowActionLabel': {
477
+ control: 'text',
478
+ description: 'Accessible label for the overflow summary when it is clickable.',
479
+ table: {
480
+ category: 'FilterBar.ActiveList',
481
+ type: { summary: 'string' },
482
+ },
483
+ },
484
+ },
485
+ } satisfies Meta<FilterBarStoryArgs>;
486
+
487
+ export default meta;
488
+ type Story = StoryObj<FilterBarStoryArgs>;
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Helper: attach a per-story description to docs
492
+ // ---------------------------------------------------------------------------
493
+
494
+ const withDescription = (story: Story, description: string): Story => ({
495
+ ...story,
496
+ parameters: {
497
+ ...story.parameters,
498
+ docs: {
499
+ ...story.parameters?.docs,
500
+ description: {
501
+ story: description,
502
+ },
503
+ },
504
+ },
505
+ });
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Templates
509
+ // ---------------------------------------------------------------------------
510
+
511
+ const DefaultTemplate = ({
512
+ toolbarLabel,
513
+ activeListLabel,
514
+ filterCount,
515
+ sortCount,
516
+ groupCount,
517
+ wrap,
518
+ collapseOverflow,
519
+ overflowActionLabel,
520
+ ...rootProps
521
+ }: FilterBarStoryArgs) => (
522
+ <StoryShell>
523
+ <FilterBar {...rootProps}>
524
+ <FilterBar.Toolbar aria-label={toolbarLabel}>
525
+ <FilterBar.Button type="filter" count={filterCount} onClick={onOpenFilter} />
526
+ <FilterBar.Button type="sort" count={sortCount} onClick={onOpenSort} />
527
+ <FilterBar.Button type="group" count={groupCount} onClick={onOpenGroup} />
528
+ </FilterBar.Toolbar>
529
+ <FilterBar.ActiveList
530
+ items={DEFAULT_ITEMS}
531
+ ariaLabel={activeListLabel}
532
+ wrap={wrap}
533
+ collapseOverflow={collapseOverflow}
534
+ overflowActionLabel={overflowActionLabel}
535
+ onOverflowClick={onShowOverflow}
536
+ />
537
+ </FilterBar>
538
+ </StoryShell>
539
+ );
540
+
541
+ const ModalWiringTemplate = () => {
542
+ const [open, setOpen] = useState(false);
543
+ const [activeType, setActiveType] = useState<FilterBar.Type>('filter');
544
+ const [pendingFocusFieldId, setPendingFocusFieldId] = useState<string>(PANEL_FIELD_IDS.filter);
545
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>(() => ({ ...INITIAL_MODAL_FIELD_VALUES }));
546
+ const firstToolbarButtonRef = useRef<HTMLButtonElement>(null);
547
+ const modalId = 'attendance-filter-bar-modal';
548
+
549
+ const openModal = (type: FilterBar.Type, fieldId = PANEL_FIELD_IDS[type]) => {
550
+ setActiveType(type);
551
+ setPendingFocusFieldId(fieldId);
552
+ setOpen(true);
553
+ };
554
+
555
+ useEffect(() => {
556
+ if (!open) return;
557
+
558
+ const frame = requestAnimationFrame(() => {
559
+ document.getElementById(pendingFocusFieldId)?.focus();
560
+ });
561
+
562
+ return () => cancelAnimationFrame(frame);
563
+ }, [open, pendingFocusFieldId]);
564
+
565
+ const updateFieldValue = (fieldId: string, value: string) => {
566
+ setFieldValues(prev => ({
567
+ ...prev,
568
+ [fieldId]: value,
569
+ }));
570
+ };
571
+
572
+ const counts = APPLIED_ITEM_SEEDS.reduce<Record<FilterBar.Type, number>>((acc, item) => {
573
+ if (fieldValues[item.fieldId]?.trim()) {
574
+ acc[item.type] += 1;
575
+ }
576
+ return acc;
577
+ }, {
578
+ filter: 0,
579
+ sort: 0,
580
+ group: 0,
581
+ });
582
+
583
+ const items: FilterBar.TagItem[] = APPLIED_ITEM_SEEDS.flatMap((item) => {
584
+ const value = fieldValues[item.fieldId]?.trim();
585
+ if (!value) return [];
586
+
587
+ return [{
588
+ ...item,
589
+ value,
590
+ ariaControls: modalId,
591
+ ariaExpanded: open && activeType === item.type,
592
+ actionLabel: `Open ${item.type} ${item.label}: ${value}`,
593
+ removeLabel: `Remove ${item.label.toLowerCase()} ${item.type}`,
594
+ onClick: clickedItem => openModal(clickedItem.type, clickedItem.fieldId ?? PANEL_FIELD_IDS[clickedItem.type]),
595
+ onRemove: (removedItem) => {
596
+ if (!removedItem.fieldId) return;
597
+ updateFieldValue(removedItem.fieldId, '');
598
+ },
599
+ }];
600
+ });
601
+
602
+ const handleApplyChanges = () => {
603
+ setOpen(false);
604
+ };
605
+
606
+ return (
607
+ <>
608
+ <StoryShell>
609
+ <FilterBar aria-label="Attendance view controls">
610
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
611
+ <FilterBar.Button
612
+ type="filter"
613
+ count={counts.filter}
614
+ ref={firstToolbarButtonRef}
615
+ aria-controls={modalId}
616
+ aria-expanded={open && activeType === 'filter'}
617
+ aria-haspopup="dialog"
618
+ onClick={() => openModal('filter')}
619
+ />
620
+ <FilterBar.Button
621
+ type="sort"
622
+ count={counts.sort}
623
+ aria-controls={modalId}
624
+ aria-expanded={open && activeType === 'sort'}
625
+ aria-haspopup="dialog"
626
+ onClick={() => openModal('sort')}
627
+ />
628
+ <FilterBar.Button
629
+ type="group"
630
+ count={counts.group}
631
+ aria-controls={modalId}
632
+ aria-expanded={open && activeType === 'group'}
633
+ aria-haspopup="dialog"
634
+ onClick={() => openModal('group')}
635
+ />
636
+ </FilterBar.Toolbar>
637
+ <FilterBar.ActiveList
638
+ ariaLabel="Applied attendance filters, sorting, and grouping"
639
+ items={items}
640
+ returnFocusRef={firstToolbarButtonRef}
641
+ onOverflowClick={() => openModal('filter')}
642
+ overflowActionLabel="Show all applied attendance items"
643
+ overflowAriaControls={modalId}
644
+ overflowAriaExpanded={open}
645
+ />
646
+ </FilterBar>
647
+ </StoryShell>
648
+
649
+ <Modal open={open} closeHandler={() => setOpen(false)} contentId={modalId} title="Attendance view controls">
650
+ <Modal.Body>
651
+ <div style={MODAL_BODY_STYLE}>
652
+ <p style={MODAL_TEXT_STYLE}>
653
+ Update the attendance view settings below. Opening this dialog from a toolbar button or
654
+ applied tag moves focus to the related field so the next action is immediate. If you
655
+ remove every applied tag from its close button, focus returns to the first toolbar
656
+ button so keyboard users keep a reliable home position.
657
+ </p>
658
+ <p style={MODAL_TEXT_STYLE}>
659
+ Active panel:
660
+ {' '}
661
+ <strong>{PANEL_TITLES[activeType]}</strong>
662
+ </p>
663
+ <FormField
664
+ label="Year group"
665
+ id={PANEL_FIELD_IDS.filter}
666
+ inputType="text"
667
+ inputProps={{
668
+ value: fieldValues[PANEL_FIELD_IDS.filter] ?? '',
669
+ onChange: event => updateFieldValue(PANEL_FIELD_IDS.filter, event.target.value),
670
+ placeholder: 'e.g. Year 10',
671
+ }}
672
+ />
673
+ <FormField
674
+ label="Attendance band"
675
+ id="attendance-filter-band"
676
+ inputType="text"
677
+ inputProps={{
678
+ value: fieldValues['attendance-filter-band'] ?? '',
679
+ onChange: event => updateFieldValue('attendance-filter-band', event.target.value),
680
+ placeholder: 'e.g. Below 90%',
681
+ }}
682
+ />
683
+ <FormField
684
+ label="Date range"
685
+ id="attendance-filter-date-range"
686
+ inputType="text"
687
+ inputProps={{
688
+ value: fieldValues['attendance-filter-date-range'] ?? '',
689
+ onChange: event => updateFieldValue('attendance-filter-date-range', event.target.value),
690
+ placeholder: 'e.g. Spring term',
691
+ }}
692
+ />
693
+ <FormField
694
+ label="Sort order"
695
+ id={PANEL_FIELD_IDS.sort}
696
+ inputType="text"
697
+ inputProps={{
698
+ value: fieldValues[PANEL_FIELD_IDS.sort] ?? '',
699
+ onChange: event => updateFieldValue(PANEL_FIELD_IDS.sort, event.target.value),
700
+ placeholder: 'e.g. Priority first',
701
+ }}
702
+ />
703
+ <FormField
704
+ label="Group by"
705
+ id={PANEL_FIELD_IDS.group}
706
+ inputType="text"
707
+ inputProps={{
708
+ value: fieldValues[PANEL_FIELD_IDS.group] ?? '',
709
+ onChange: event => updateFieldValue(PANEL_FIELD_IDS.group, event.target.value),
710
+ placeholder: 'e.g. Tutor group',
711
+ }}
712
+ />
713
+ </div>
714
+ </Modal.Body>
715
+ <Modal.Footer>
716
+ <Button variant="secondary" onClick={() => setOpen(false)}>
717
+ Cancel
718
+ </Button>
719
+ <Button variant="primary" onClick={handleApplyChanges}>
720
+ Apply changes
721
+ </Button>
722
+ </Modal.Footer>
723
+ </Modal>
724
+ </>
725
+ );
726
+ };
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // Stories
730
+ // ---------------------------------------------------------------------------
731
+
732
+ export const Default: Story = withDescription(
733
+ {
734
+ args: {
735
+ 'aria-label': 'Attendance view controls',
736
+ 'toolbarLabel': 'Filter, sort, and group controls',
737
+ 'activeListLabel': 'Applied attendance filters, sorting, and grouping',
738
+ 'filterCount': 3,
739
+ 'sortCount': 1,
740
+ 'groupCount': 1,
741
+ 'wrap': false,
742
+ 'collapseOverflow': true,
743
+ 'overflowActionLabel': 'Show more applied items',
744
+ },
745
+ render: args => <DefaultTemplate {...args} />,
746
+ },
747
+ [
748
+ 'The controls-friendly reference composition. The root label, toolbar label, trigger counts, and',
749
+ 'key active-list behaviours are all wired to the Controls panel, while the applied tags stay as a',
750
+ 'realistic Arbor-style data set.',
751
+ ].join(' '),
752
+ );
753
+
754
+ export const HighCounts: Story = withDescription(
755
+ {
756
+ parameters: {
757
+ controls: { disable: true },
758
+ docs: {
759
+ source: {
760
+ language: 'tsx',
761
+ code: `
762
+ import { FilterBar, type FilterBarTagItem } from '@arbor-education/design-system.components';
763
+
764
+ const items: FilterBarTagItem[] = [
765
+ { id: 'year-group', type: 'filter', label: 'Year group', value: 'Year 10' },
766
+ { id: 'attendance-band', type: 'filter', label: 'Attendance band', value: 'Below 90%' },
767
+ { id: 'date-range', type: 'filter', label: 'Date range', value: 'Spring term' },
768
+ { id: 'sort-order', type: 'sort', label: 'Sort order', value: 'Priority first' },
769
+ { id: 'group-by', type: 'group', label: 'Group by', value: 'Tutor group' },
770
+ ];
771
+
772
+ function HighCountsExample() {
773
+ return (
774
+ <div style={{ width: '100%', maxWidth: 'min(100%, 72rem)', padding: 'var(--spacing-large)', boxSizing: 'border-box' }}>
775
+ <FilterBar aria-label="Attendance view controls">
776
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
777
+ <FilterBar.Button type="filter" count={142} onClick={() => {}} />
778
+ <FilterBar.Button type="sort" count={103} onClick={() => {}} />
779
+ <FilterBar.Button type="group" count={12} onClick={() => {}} />
780
+ </FilterBar.Toolbar>
781
+ <FilterBar.ActiveList items={items} ariaLabel="Applied attendance filters, sorting, and grouping" />
782
+ </FilterBar>
783
+ </div>
784
+ );
785
+ }
786
+ export default HighCountsExample;
787
+ `.trim(),
788
+ },
789
+ },
790
+ },
791
+ render: () => (
792
+ <StoryShell>
793
+ <FilterBar aria-label="Attendance view controls">
794
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
795
+ <FilterBar.Button type="filter" count={142} onClick={onOpenFilter} />
796
+ <FilterBar.Button type="sort" count={103} onClick={onOpenSort} />
797
+ <FilterBar.Button type="group" count={12} onClick={onOpenGroup} />
798
+ </FilterBar.Toolbar>
799
+ <FilterBar.ActiveList
800
+ items={DEFAULT_ITEMS}
801
+ ariaLabel="Applied attendance filters, sorting, and grouping"
802
+ />
803
+ </FilterBar>
804
+ </StoryShell>
805
+ ),
806
+ },
807
+ [
808
+ 'Counts above `99` render as `99+` visually, but the generated button label still announces the',
809
+ 'true total. Use this pattern for dense reporting views where badge counts may be much larger than',
810
+ 'the number of visible tags.',
811
+ ].join(' '),
812
+ );
813
+
814
+ export const ModalWiring: Story = withDescription(
815
+ {
816
+ parameters: {
817
+ controls: { disable: true },
818
+ docs: {
819
+ source: {
820
+ language: 'tsx',
821
+ code: `
822
+ import { useEffect, useRef, useState } from 'react';
823
+ import {
824
+ Button,
825
+ FilterBar,
826
+ FormField,
827
+ Modal,
828
+ type FilterBarTagItem,
829
+ type FilterBarType,
830
+ } from '@arbor-education/design-system.components';
831
+
832
+ const modalId = 'attendance-filter-bar-modal';
833
+
834
+ const panelFieldIds: Record<FilterBarType, string> = {
835
+ filter: 'attendance-filter-year-group',
836
+ sort: 'attendance-sort-order',
837
+ group: 'attendance-group-by',
838
+ };
839
+
840
+ const PANEL_TITLES: Record<FilterBarType, string> = {
841
+ filter: 'Filters',
842
+ sort: 'Sorting',
843
+ group: 'Grouping',
844
+ };
845
+
846
+ const seedItems: FilterBarTagItem[] = [
847
+ {
848
+ id: 'year-group',
849
+ type: 'filter',
850
+ label: 'Year group',
851
+ value: 'Year 10',
852
+ fieldId: 'attendance-filter-year-group',
853
+ },
854
+ {
855
+ id: 'attendance-band',
856
+ type: 'filter',
857
+ label: 'Attendance band',
858
+ value: 'Below 90%',
859
+ fieldId: 'attendance-filter-band',
860
+ },
861
+ {
862
+ id: 'date-range',
863
+ type: 'filter',
864
+ label: 'Date range',
865
+ value: 'Spring term',
866
+ fieldId: 'attendance-filter-date-range',
867
+ },
868
+ {
869
+ id: 'sort-order',
870
+ type: 'sort',
871
+ label: 'Sort order',
872
+ value: 'Priority first',
873
+ fieldId: 'attendance-sort-order',
874
+ },
875
+ {
876
+ id: 'group-by',
877
+ type: 'group',
878
+ label: 'Group by',
879
+ value: 'Tutor group',
880
+ fieldId: 'attendance-group-by',
881
+ },
882
+ ];
883
+
884
+ const initialFieldValues = seedItems.reduce<Record<string, string>>((acc, item) => {
885
+ acc[item.fieldId!] = item.value;
886
+ return acc;
887
+ }, {});
888
+
889
+ function FilterBarModalWiringExample() {
890
+ const [open, setOpen] = useState(false);
891
+ const [activeType, setActiveType] = useState<FilterBarType>('filter');
892
+ const [pendingFocusFieldId, setPendingFocusFieldId] = useState(panelFieldIds.filter);
893
+ const [fieldValues, setFieldValues] = useState<Record<string, string>>(() => ({ ...initialFieldValues }));
894
+ const firstToolbarButtonRef = useRef<HTMLButtonElement>(null);
895
+
896
+ const openModal = (type: FilterBarType, fieldId = panelFieldIds[type]) => {
897
+ setActiveType(type);
898
+ setPendingFocusFieldId(fieldId);
899
+ setOpen(true);
900
+ };
901
+
902
+ useEffect(() => {
903
+ if (!open) return;
904
+
905
+ const frame = requestAnimationFrame(() => {
906
+ document.getElementById(pendingFocusFieldId)?.focus();
907
+ });
908
+
909
+ return () => cancelAnimationFrame(frame);
910
+ }, [open, pendingFocusFieldId]);
911
+
912
+ const updateFieldValue = (fieldId: string, value: string) => {
913
+ setFieldValues(prev => ({
914
+ ...prev,
915
+ [fieldId]: value,
916
+ }));
917
+ };
918
+
919
+ const counts = seedItems.reduce<Record<FilterBarType, number>>((acc, item) => {
920
+ if (fieldValues[item.fieldId!]?.trim()) {
921
+ acc[item.type] += 1;
922
+ }
923
+ return acc;
924
+ }, {
925
+ filter: 0,
926
+ sort: 0,
927
+ group: 0,
928
+ });
929
+
930
+ const items = seedItems.flatMap((item) => {
931
+ const value = fieldValues[item.fieldId!]?.trim();
932
+ if (!value) return [];
933
+
934
+ return [{
935
+ ...item,
936
+ value,
937
+ ariaControls: modalId,
938
+ ariaExpanded: open && activeType === item.type,
939
+ actionLabel: \`Open \${item.type} \${item.label}: \${value}\`,
940
+ onClick: (clickedItem: FilterBarTagItem) =>
941
+ openModal(clickedItem.type, clickedItem.fieldId ?? panelFieldIds[clickedItem.type]),
942
+ onRemove: (removedItem: FilterBarTagItem) => {
943
+ if (!removedItem.fieldId) return;
944
+ updateFieldValue(removedItem.fieldId, '');
945
+ },
946
+ }];
947
+ });
948
+
949
+ const handleApplyChanges = () => {
950
+ setOpen(false);
951
+ };
952
+
953
+ return (
954
+ <>
955
+ <FilterBar aria-label="Attendance view controls">
956
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
957
+ <FilterBar.Button
958
+ type="filter"
959
+ count={counts.filter}
960
+ ref={firstToolbarButtonRef}
961
+ aria-controls={modalId}
962
+ aria-expanded={open && activeType === 'filter'}
963
+ aria-haspopup="dialog"
964
+ onClick={() => openModal('filter')}
965
+ />
966
+ <FilterBar.Button
967
+ type="sort"
968
+ count={counts.sort}
969
+ aria-controls={modalId}
970
+ aria-expanded={open && activeType === 'sort'}
971
+ aria-haspopup="dialog"
972
+ onClick={() => openModal('sort')}
973
+ />
974
+ <FilterBar.Button
975
+ type="group"
976
+ count={counts.group}
977
+ aria-controls={modalId}
978
+ aria-expanded={open && activeType === 'group'}
979
+ aria-haspopup="dialog"
980
+ onClick={() => openModal('group')}
981
+ />
982
+ </FilterBar.Toolbar>
983
+
984
+ <FilterBar.ActiveList
985
+ items={items}
986
+ ariaLabel="Applied attendance filters, sorting, and grouping"
987
+ returnFocusRef={firstToolbarButtonRef}
988
+ onOverflowClick={() => openModal('filter')}
989
+ overflowActionLabel="Show all applied attendance items"
990
+ overflowAriaControls={modalId}
991
+ overflowAriaExpanded={open}
992
+ />
993
+ </FilterBar>
994
+
995
+ <Modal open={open} closeHandler={() => setOpen(false)} contentId={modalId} title="Attendance view controls">
996
+ <Modal.Body>
997
+ <p style={{ marginBottom: 'var(--spacing-large)' }}>
998
+ Update the attendance view settings below. Opening this dialog from a toolbar button or
999
+ applied tag moves focus to the related field so the next action is immediate. If you
1000
+ remove every applied tag from its close button, focus returns to the first toolbar
1001
+ button so keyboard users keep a reliable home position.
1002
+ </p>
1003
+ <p style={{ marginBottom: 'var(--spacing-large)' }}>
1004
+ Active panel: <strong>{PANEL_TITLES[activeType]}</strong>
1005
+ </p>
1006
+ <FormField
1007
+ label="Year group"
1008
+ id="attendance-filter-year-group"
1009
+ inputType="text"
1010
+ inputProps={{
1011
+ value: fieldValues['attendance-filter-year-group'] ?? '',
1012
+ onChange: event => updateFieldValue('attendance-filter-year-group', event.target.value),
1013
+ placeholder: 'e.g. Year 10',
1014
+ }}
1015
+ />
1016
+ <FormField
1017
+ label="Attendance band"
1018
+ id="attendance-filter-band"
1019
+ inputType="text"
1020
+ inputProps={{
1021
+ value: fieldValues['attendance-filter-band'] ?? '',
1022
+ onChange: event => updateFieldValue('attendance-filter-band', event.target.value),
1023
+ placeholder: 'e.g. Below 90%',
1024
+ }}
1025
+ />
1026
+ <FormField
1027
+ label="Date range"
1028
+ id="attendance-filter-date-range"
1029
+ inputType="text"
1030
+ inputProps={{
1031
+ value: fieldValues['attendance-filter-date-range'] ?? '',
1032
+ onChange: event => updateFieldValue('attendance-filter-date-range', event.target.value),
1033
+ placeholder: 'e.g. Spring term',
1034
+ }}
1035
+ />
1036
+ <FormField
1037
+ label="Sort order"
1038
+ id="attendance-sort-order"
1039
+ inputType="text"
1040
+ inputProps={{
1041
+ value: fieldValues['attendance-sort-order'] ?? '',
1042
+ onChange: event => updateFieldValue('attendance-sort-order', event.target.value),
1043
+ placeholder: 'e.g. Priority first',
1044
+ }}
1045
+ />
1046
+ <FormField
1047
+ label="Group by"
1048
+ id="attendance-group-by"
1049
+ inputType="text"
1050
+ inputProps={{
1051
+ value: fieldValues['attendance-group-by'] ?? '',
1052
+ onChange: event => updateFieldValue('attendance-group-by', event.target.value),
1053
+ placeholder: 'e.g. Tutor group',
1054
+ }}
1055
+ />
1056
+ </Modal.Body>
1057
+ <Modal.Footer>
1058
+ <Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>
1059
+ <Button variant="primary" onClick={handleApplyChanges}>Apply changes</Button>
1060
+ </Modal.Footer>
1061
+ </Modal>
1062
+ </>
1063
+ );
1064
+ }
1065
+ export default FilterBarModalWiringExample;
1066
+ `.trim(),
1067
+ },
1068
+ },
1069
+ },
1070
+ render: () => <ModalWiringTemplate />,
1071
+ },
1072
+ [
1073
+ 'The recommended production wiring when toolbar buttons and applied tags reopen the same dialog.',
1074
+ 'Notice the matching `aria-controls`, `aria-expanded`, and `aria-haspopup="dialog"` attributes,',
1075
+ 'plus the post-open focus handoff into the field represented by the clicked trigger.',
1076
+ 'This example also shows `returnFocusRef`: arrow onto the tag remove buttons and remove tags',
1077
+ 'sequentially until none remain, and focus returns to the `Filter` button.',
1078
+ ].join(' '),
1079
+ );
1080
+
1081
+ export const Overflow: Story = withDescription(
1082
+ {
1083
+ parameters: {
1084
+ controls: { disable: true },
1085
+ docs: {
1086
+ source: {
1087
+ language: 'tsx',
1088
+ code: `
1089
+ import { FilterBar, type FilterBarTagItem } from '@arbor-education/design-system.components';
1090
+
1091
+ const items: FilterBarTagItem[] = [
1092
+ { id: 'year-group', type: 'filter', label: 'Year group', value: 'Year 10' },
1093
+ { id: 'attendance-band', type: 'filter', label: 'Attendance band', value: 'Below 90%' },
1094
+ { id: 'date-range', type: 'filter', label: 'Date range', value: 'Spring term' },
1095
+ { id: 'sort-order', type: 'sort', label: 'Sort order', value: 'Priority first' },
1096
+ { id: 'group-by', type: 'group', label: 'Group by', value: 'Tutor group' },
1097
+ { id: 'intervention', type: 'filter', label: 'Intervention', value: 'Attendance plan' },
1098
+ { id: 'reviewer', type: 'filter', label: 'Reviewer', value: 'Heads of year' },
1099
+ { id: 'status', type: 'filter', label: 'Status', value: 'Open cases' },
1100
+ ];
1101
+
1102
+ function OverflowExample() {
1103
+ return (
1104
+ <div style={{ width: '100%', maxWidth: 'min(100%, 34rem)', padding: 'var(--spacing-large)', boxSizing: 'border-box' }}>
1105
+ <FilterBar aria-label="Attendance view controls">
1106
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
1107
+ <FilterBar.Button type="filter" count={6} onClick={() => {}} />
1108
+ <FilterBar.Button type="sort" count={1} onClick={() => {}} />
1109
+ <FilterBar.Button type="group" count={1} onClick={() => {}} />
1110
+ </FilterBar.Toolbar>
1111
+ <FilterBar.ActiveList
1112
+ items={items}
1113
+ ariaLabel="Applied attendance filters, sorting, and grouping"
1114
+ collapseOverflow
1115
+ overflowActionLabel="Show all applied attendance items"
1116
+ onOverflowClick={() => {}}
1117
+ />
1118
+ </FilterBar>
1119
+ </div>
1120
+ );
1121
+ }
1122
+ export default OverflowExample;
1123
+ `.trim(),
1124
+ },
1125
+ },
1126
+ },
1127
+ render: () => (
1128
+ <StoryShell narrow>
1129
+ <FilterBar aria-label="Attendance view controls">
1130
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
1131
+ <FilterBar.Button type="filter" count={6} onClick={onOpenFilter} />
1132
+ <FilterBar.Button type="sort" count={1} onClick={onOpenSort} />
1133
+ <FilterBar.Button type="group" count={1} onClick={onOpenGroup} />
1134
+ </FilterBar.Toolbar>
1135
+ <FilterBar.ActiveList
1136
+ items={OVERFLOW_ITEMS}
1137
+ ariaLabel="Applied attendance filters, sorting, and grouping"
1138
+ collapseOverflow
1139
+ overflowActionLabel="Show all applied attendance items"
1140
+ onOverflowClick={onShowOverflow}
1141
+ />
1142
+ </FilterBar>
1143
+ </StoryShell>
1144
+ ),
1145
+ },
1146
+ [
1147
+ 'A constrained container showing the single-row overflow behaviour. Hidden tags collapse behind a',
1148
+ 'focusable `+N more` summary when `onOverflowClick` is provided, making it suitable for reopening a',
1149
+ 'full filter-management surface.',
1150
+ ].join(' '),
1151
+ );
1152
+
1153
+ export const Empty: Story = withDescription(
1154
+ {
1155
+ parameters: {
1156
+ controls: { disable: true },
1157
+ docs: {
1158
+ source: {
1159
+ language: 'tsx',
1160
+ code: `
1161
+ import { FilterBar } from '@arbor-education/design-system.components';
1162
+
1163
+ function EmptyFilterBarExample() {
1164
+ return (
1165
+ <div style={{ width: '100%', maxWidth: 'min(100%, 72rem)', padding: 'var(--spacing-large)', boxSizing: 'border-box' }}>
1166
+ <FilterBar aria-label="Attendance view controls">
1167
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
1168
+ <FilterBar.Button type="filter" onClick={() => {}} />
1169
+ <FilterBar.Button type="sort" onClick={() => {}} />
1170
+ <FilterBar.Button type="group" onClick={() => {}} />
1171
+ </FilterBar.Toolbar>
1172
+ <FilterBar.ActiveList emptyState="No filters, sorting, or grouping applied." />
1173
+ </FilterBar>
1174
+ </div>
1175
+ );
1176
+ }
1177
+ export default EmptyFilterBarExample;
1178
+ `.trim(),
1179
+ },
1180
+ },
1181
+ },
1182
+ render: () => (
1183
+ <StoryShell>
1184
+ <FilterBar aria-label="Attendance view controls">
1185
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
1186
+ <FilterBar.Button type="filter" onClick={onOpenFilter} />
1187
+ <FilterBar.Button type="sort" onClick={onOpenSort} />
1188
+ <FilterBar.Button type="group" onClick={onOpenGroup} />
1189
+ </FilterBar.Toolbar>
1190
+ <FilterBar.ActiveList emptyState="No filters, sorting, or grouping applied." />
1191
+ </FilterBar>
1192
+ </StoryShell>
1193
+ ),
1194
+ },
1195
+ [
1196
+ 'The no-selection state. Use a clear empty-state sentence so users understand that nothing is',
1197
+ 'currently applied, rather than assuming the summary area failed to render.',
1198
+ ].join(' '),
1199
+ );