@arbor-education/design-system.components 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/components/dataViewCard/DataViewCard.d.ts +5 -0
  3. package/dist/components/dataViewCard/DataViewCard.d.ts.map +1 -1
  4. package/dist/components/dataViewCard/DataViewCard.js +7 -3
  5. package/dist/components/dataViewCard/DataViewCard.js.map +1 -1
  6. package/dist/components/dataViewCard/DataViewCard.stories.d.ts +10 -0
  7. package/dist/components/dataViewCard/DataViewCard.stories.d.ts.map +1 -1
  8. package/dist/components/dataViewCard/DataViewCard.stories.js +31 -0
  9. package/dist/components/dataViewCard/DataViewCard.stories.js.map +1 -1
  10. package/dist/components/dataViewCard/DataViewCard.test.js +20 -0
  11. package/dist/components/dataViewCard/DataViewCard.test.js.map +1 -1
  12. package/dist/components/filterBar/FilterBar.d.ts +2 -2
  13. package/dist/components/filterBar/FilterBar.d.ts.map +1 -1
  14. package/dist/components/filterBar/FilterBar.js +7 -3
  15. package/dist/components/filterBar/FilterBar.js.map +1 -1
  16. package/dist/components/filterBar/FilterBar.stories.d.ts +16 -0
  17. package/dist/components/filterBar/FilterBar.stories.d.ts.map +1 -1
  18. package/dist/components/filterBar/FilterBar.stories.js +101 -7
  19. package/dist/components/filterBar/FilterBar.stories.js.map +1 -1
  20. package/dist/components/filterBar/FilterBar.test.js +30 -0
  21. package/dist/components/filterBar/FilterBar.test.js.map +1 -1
  22. package/dist/components/table/Table.d.ts.map +1 -1
  23. package/dist/components/table/Table.js +2 -1
  24. package/dist/components/table/Table.js.map +1 -1
  25. package/dist/components/table/Table.stories.d.ts +7 -0
  26. package/dist/components/table/Table.stories.d.ts.map +1 -1
  27. package/dist/components/table/Table.stories.js +44 -0
  28. package/dist/components/table/Table.stories.js.map +1 -1
  29. package/dist/components/table/Table.test.js +23 -0
  30. package/dist/components/table/Table.test.js.map +1 -1
  31. package/dist/components/table/tableControls/TableControls.d.ts +2 -0
  32. package/dist/components/table/tableControls/TableControls.d.ts.map +1 -1
  33. package/dist/components/table/tableControls/TableControls.js +2 -2
  34. package/dist/components/table/tableControls/TableControls.js.map +1 -1
  35. package/dist/components/table/tableControls/TableControls.stories.d.ts +25 -0
  36. package/dist/components/table/tableControls/TableControls.stories.d.ts.map +1 -1
  37. package/dist/components/table/tableControls/TableControls.stories.js +24 -0
  38. package/dist/components/table/tableControls/TableControls.stories.js.map +1 -1
  39. package/dist/components/table/tableControls/TableControls.test.js +16 -0
  40. package/dist/components/table/tableControls/TableControls.test.js.map +1 -1
  41. package/dist/components/table/useTableSettings.d.ts +2 -0
  42. package/dist/components/table/useTableSettings.d.ts.map +1 -1
  43. package/dist/components/table/useTableSettings.js +12 -7
  44. package/dist/components/table/useTableSettings.js.map +1 -1
  45. package/dist/index.css +7 -0
  46. package/dist/index.css.map +1 -1
  47. package/package.json +1 -1
  48. package/src/components/dataViewCard/DataViewCard.stories.tsx +45 -0
  49. package/src/components/dataViewCard/DataViewCard.test.tsx +44 -0
  50. package/src/components/dataViewCard/DataViewCard.tsx +28 -1
  51. package/src/components/dataViewCard/dataViewCard.scss +7 -0
  52. package/src/components/filterBar/FilterBar.stories.tsx +126 -4
  53. package/src/components/filterBar/FilterBar.test.tsx +51 -0
  54. package/src/components/filterBar/FilterBar.tsx +18 -12
  55. package/src/components/table/Table.stories.tsx +57 -0
  56. package/src/components/table/Table.test.tsx +42 -0
  57. package/src/components/table/Table.tsx +2 -0
  58. package/src/components/table/tableControls/TableControls.stories.tsx +28 -0
  59. package/src/components/table/tableControls/TableControls.test.tsx +20 -0
  60. package/src/components/table/tableControls/TableControls.tsx +6 -0
  61. package/src/components/table/useTableSettings.ts +20 -6
@@ -5,6 +5,7 @@ import { Card, getCardInteractionProps } from 'Components/card/Card';
5
5
  import { Heading } from 'Components/heading/Heading';
6
6
  import { Row } from 'Components/row/Row';
7
7
  import { Separator } from 'Components/separator/Separator';
8
+ import { Tag } from 'Components/tag/Tag';
8
9
 
9
10
  export type DataViewCardRow = Row.Props;
10
11
 
@@ -14,12 +15,20 @@ type DataViewCardBaseProps = {
14
15
  avatar?: Avatar.Props;
15
16
  rows: DataViewCardRow[];
16
17
  disabled?: boolean;
18
+ tags?: { color: Tag.Color; label: string }[];
17
19
  };
18
20
 
19
21
  export type DataViewCardProps = DataViewCardBaseProps & Card.InteractionProps;
20
22
 
21
23
  export const DataViewCard = (props: DataViewCardProps): React.JSX.Element => {
22
- const { className, header, avatar, rows, disabled = false } = props;
24
+ const {
25
+ className,
26
+ header,
27
+ avatar,
28
+ rows,
29
+ disabled = false,
30
+ tags,
31
+ } = props;
23
32
 
24
33
  return (
25
34
  <Card
@@ -50,6 +59,24 @@ export const DataViewCard = (props: DataViewCardProps): React.JSX.Element => {
50
59
  {index < rows.length - 1 && <Separator />}
51
60
  </Fragment>
52
61
  ))}
62
+ {tags && tags.length > 0 && (
63
+ <>
64
+ <Separator />
65
+ <span className="ds-data-view-card__tag-list">
66
+ {tags.map((tag) => {
67
+ const { label, color } = tag;
68
+ return (
69
+ <Tag
70
+ key={`dvc-${label}-${color}`}
71
+ color={color}
72
+ >
73
+ {label}
74
+ </Tag>
75
+ );
76
+ })}
77
+ </span>
78
+ </>
79
+ )}
53
80
  </div>
54
81
  </article>
55
82
  </Card>
@@ -23,3 +23,10 @@
23
23
  text-overflow: ellipsis;
24
24
  white-space: nowrap;
25
25
  }
26
+
27
+ .ds-data-view-card__tag-list {
28
+ display: flex;
29
+ flex-direction: row;
30
+ justify-content: start;
31
+ gap: var(--spacing-xsmall);
32
+ }
@@ -75,6 +75,12 @@ const DEVELOPER_NOTES = [
75
75
  'compose custom `FilterBar.Tag` children when you need bespoke markup. Avoid mixing both patterns in',
76
76
  'the same production instance.',
77
77
  '',
78
+ '**`label` is optional.** Omit it when the value alone is self-explanatory, such as a free-text',
79
+ 'search term or a single unambiguous criterion. Without a `label`, the tag renders just the value',
80
+ '(no `Label:` prefix) and the generated `actionLabel` drops the label segment too, e.g. `Open filter',
81
+ 'Interventions` instead of `Open filter Event Type: Interventions`. Most applied tags in a filter,',
82
+ 'sort, or group list benefit from a label, so reach for this only when it would be redundant.',
83
+ '',
78
84
  '---',
79
85
  '',
80
86
  '### Accessibility',
@@ -106,7 +112,7 @@ const DEVELOPER_NOTES = [
106
112
  '| `FilterBar.ActiveListProps` | Applied-item summary props |',
107
113
  '| `FilterBar.TagProps` | Manually composed tag props |',
108
114
  '| `FilterBar.TagItem` | Data shape for `ActiveList.items` |',
109
- '| `FilterBarType` | Union of `filter`, `sort`, and `group` |',
115
+ '| `FilterBarType` | Union of `filter`, `sort`, `group`, and `add` |',
110
116
  ].join('\n');
111
117
 
112
118
  const RELATED_COMPONENTS = [
@@ -134,7 +140,7 @@ const TOOLBAR_PROPS = [
134
140
  const BUTTON_PROPS = [
135
141
  '| Prop | Type | Default | Description |',
136
142
  '|---|---|---|---|',
137
- '| `type` | `` `filter` \\| `sort` \\| `group` `` | — | **Required.** Chooses the default label, icon, and count announcement text |',
143
+ '| `type` | `` `filter` \\| `sort` \\| `group` \\| `add` `` | — | **Required.** Chooses the default label, icon, and count announcement text |',
138
144
  '| `label` | `string` | inferred from `type` | Override the visible button label |',
139
145
  '| `count` | `number \\| null` | — | Shows a badge when `> 0`. The badge caps visually at `99+`, but the accessible label keeps the full number |',
140
146
  '| `icon` | `IconName` | inferred from `type` | Override the default icon for that trigger type |',
@@ -163,8 +169,8 @@ const ACTIVE_LIST_PROPS = [
163
169
  const TAG_PROPS = [
164
170
  '| Prop | Type | Default | Description |',
165
171
  '|---|---|---|---|',
166
- '| `type` | `` `filter` \\| `sort` \\| `group` `` | — | **Required.** Sets the default icon and action language |',
167
- '| `label` | `string` | — | **Required.** Criterion label, e.g. `Year group` |',
172
+ '| `type` | `` `filter` \\| `sort` \\| `group` \\| `add` `` | — | **Required.** Sets the default icon and action language |',
173
+ '| `label` | `string` | — | Criterion label, e.g. `Year group`. Optional — omit it when the value alone is self-explanatory; the tag then renders just the value with no `Label:` prefix, and the generated `actionLabel` drops the label segment too |',
168
174
  '| `value` | `ReactNode` | — | **Required.** Current applied value, e.g. `Year 10` |',
169
175
  '| `id` | `string` | derived from `fieldId` or label | Stable identifier for list rendering and event payloads |',
170
176
  '| `fieldId` | `string` | — | Optional DOM id for the field that should receive focus after opening an editor |',
@@ -188,6 +194,7 @@ type FilterBarStoryArgs = FilterBar.Props & {
188
194
  filterCount: number;
189
195
  sortCount: number;
190
196
  groupCount: number;
197
+ addCount: number;
191
198
  wrap: boolean;
192
199
  collapseOverflow: boolean;
193
200
  overflowActionLabel: string;
@@ -229,6 +236,7 @@ const StoryShell = ({
229
236
  const onOpenFilter = fn();
230
237
  const onOpenSort = fn();
231
238
  const onOpenGroup = fn();
239
+ const onOpenAdd = fn();
232
240
  const onOpenAppliedItem = fn();
233
241
  const onRemoveAppliedItem = fn();
234
242
  const onShowOverflow = fn();
@@ -337,12 +345,14 @@ const PANEL_FIELD_IDS: Record<FilterBar.Type, string> = {
337
345
  filter: 'attendance-filter-year-group',
338
346
  sort: 'attendance-sort-order',
339
347
  group: 'attendance-group-by',
348
+ add: 'attendance-add-criteria',
340
349
  };
341
350
 
342
351
  const PANEL_TITLES: Record<FilterBar.Type, string> = {
343
352
  filter: 'Filters',
344
353
  sort: 'Sorting',
345
354
  group: 'Grouping',
355
+ add: 'Add criteria',
346
356
  };
347
357
 
348
358
  // ---------------------------------------------------------------------------
@@ -455,6 +465,14 @@ const meta = {
455
465
  type: { summary: 'number' },
456
466
  },
457
467
  },
468
+ 'addCount': {
469
+ control: { type: 'number', min: 0, max: 250 },
470
+ description: 'Applied add count shown on the Add trigger badge.',
471
+ table: {
472
+ category: 'FilterBar.Button',
473
+ type: { summary: 'number' },
474
+ },
475
+ },
458
476
  'wrap': {
459
477
  control: 'boolean',
460
478
  description: 'Allows the applied-item summary to wrap onto multiple lines.',
@@ -514,6 +532,7 @@ const DefaultTemplate = ({
514
532
  filterCount,
515
533
  sortCount,
516
534
  groupCount,
535
+ addCount,
517
536
  wrap,
518
537
  collapseOverflow,
519
538
  overflowActionLabel,
@@ -525,6 +544,7 @@ const DefaultTemplate = ({
525
544
  <FilterBar.Button type="filter" count={filterCount} onClick={onOpenFilter} />
526
545
  <FilterBar.Button type="sort" count={sortCount} onClick={onOpenSort} />
527
546
  <FilterBar.Button type="group" count={groupCount} onClick={onOpenGroup} />
547
+ <FilterBar.Button type="add" count={addCount} onClick={onOpenAdd} />
528
548
  </FilterBar.Toolbar>
529
549
  <FilterBar.ActiveList
530
550
  items={DEFAULT_ITEMS}
@@ -580,6 +600,7 @@ const ModalWiringTemplate = () => {
580
600
  filter: 0,
581
601
  sort: 0,
582
602
  group: 0,
603
+ add: 0,
583
604
  });
584
605
 
585
606
  const items: FilterBar.TagItem[] = APPLIED_ITEM_SEEDS.flatMap((item) => {
@@ -639,6 +660,14 @@ const ModalWiringTemplate = () => {
639
660
  aria-haspopup="dialog"
640
661
  onClick={() => openModal('group')}
641
662
  />
663
+ <FilterBar.Button
664
+ type="add"
665
+ count={counts.add}
666
+ aria-controls={modalId}
667
+ aria-expanded={open && activeType === 'add'}
668
+ aria-haspopup="dialog"
669
+ onClick={() => openModal('add')}
670
+ />
642
671
  </FilterBar.Toolbar>
643
672
  <FilterBar.ActiveList
644
673
  ariaLabel="Applied attendance filters, sorting, and grouping"
@@ -744,6 +773,7 @@ export const Default: Story = withDescription(
744
773
  'filterCount': 3,
745
774
  'sortCount': 1,
746
775
  'groupCount': 1,
776
+ 'addCount': 0,
747
777
  'wrap': false,
748
778
  'collapseOverflow': true,
749
779
  'overflowActionLabel': 'Show more applied items',
@@ -783,6 +813,7 @@ function HighCountsExample() {
783
813
  <FilterBar.Button type="filter" count={142} onClick={() => {}} />
784
814
  <FilterBar.Button type="sort" count={103} onClick={() => {}} />
785
815
  <FilterBar.Button type="group" count={12} onClick={() => {}} />
816
+ <FilterBar.Button type="add" onClick={() => {}} />
786
817
  </FilterBar.Toolbar>
787
818
  <FilterBar.ActiveList items={items} ariaLabel="Applied attendance filters, sorting, and grouping" />
788
819
  </FilterBar>
@@ -801,6 +832,7 @@ export default HighCountsExample;
801
832
  <FilterBar.Button type="filter" count={142} onClick={onOpenFilter} />
802
833
  <FilterBar.Button type="sort" count={103} onClick={onOpenSort} />
803
834
  <FilterBar.Button type="group" count={12} onClick={onOpenGroup} />
835
+ <FilterBar.Button type="add" onClick={onOpenAdd} />
804
836
  </FilterBar.Toolbar>
805
837
  <FilterBar.ActiveList
806
838
  items={DEFAULT_ITEMS}
@@ -841,12 +873,14 @@ const panelFieldIds: Record<FilterBarType, string> = {
841
873
  filter: 'attendance-filter-year-group',
842
874
  sort: 'attendance-sort-order',
843
875
  group: 'attendance-group-by',
876
+ add: 'attendance-add-criteria',
844
877
  };
845
878
 
846
879
  const PANEL_TITLES: Record<FilterBarType, string> = {
847
880
  filter: 'Filters',
848
881
  sort: 'Sorting',
849
882
  group: 'Grouping',
883
+ add: 'Add criteria',
850
884
  };
851
885
 
852
886
  const seedItems: FilterBarTagItem[] = [
@@ -931,6 +965,7 @@ function FilterBarModalWiringExample() {
931
965
  filter: 0,
932
966
  sort: 0,
933
967
  group: 0,
968
+ add: 0,
934
969
  });
935
970
 
936
971
  const items = seedItems.flatMap((item) => {
@@ -985,6 +1020,14 @@ function FilterBarModalWiringExample() {
985
1020
  aria-haspopup="dialog"
986
1021
  onClick={() => openModal('group')}
987
1022
  />
1023
+ <FilterBar.Button
1024
+ type="add"
1025
+ count={counts.add}
1026
+ aria-controls={modalId}
1027
+ aria-expanded={open && activeType === 'add'}
1028
+ aria-haspopup="dialog"
1029
+ onClick={() => openModal('add')}
1030
+ />
988
1031
  </FilterBar.Toolbar>
989
1032
 
990
1033
  <FilterBar.ActiveList
@@ -1156,6 +1199,85 @@ export default OverflowExample;
1156
1199
  ].join(' '),
1157
1200
  );
1158
1201
 
1202
+ export const WithoutLabels: Story = withDescription(
1203
+ {
1204
+ parameters: {
1205
+ controls: { disable: true },
1206
+ docs: {
1207
+ source: {
1208
+ language: 'tsx',
1209
+ code: `
1210
+ import { FilterBar, type FilterBarTagItem } from '@arbor-education/design-system.components';
1211
+
1212
+ const items: FilterBarTagItem[] = [
1213
+ { id: 'search-term', type: 'filter', value: 'Jordan Casey' },
1214
+ { id: 'year-group', type: 'filter', label: 'Year group', value: 'Year 10' },
1215
+ { id: 'attendance-band', type: 'filter', label: 'Attendance band', value: 'Below 90%' },
1216
+ ];
1217
+
1218
+ function WithoutLabelsExample() {
1219
+ return (
1220
+ <div style={{ width: '100%', maxWidth: 'min(100%, 72rem)', padding: 'var(--spacing-large)', boxSizing: 'border-box' }}>
1221
+ <FilterBar aria-label="Attendance view controls">
1222
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
1223
+ <FilterBar.Button type="filter" count={3} onClick={() => {}} />
1224
+ </FilterBar.Toolbar>
1225
+ <FilterBar.ActiveList items={items} ariaLabel="Applied attendance filters" />
1226
+ </FilterBar>
1227
+ </div>
1228
+ );
1229
+ }
1230
+ export default WithoutLabelsExample;
1231
+ `.trim(),
1232
+ },
1233
+ },
1234
+ },
1235
+ render: () => (
1236
+ <StoryShell>
1237
+ <FilterBar aria-label="Attendance view controls">
1238
+ <FilterBar.Toolbar aria-label="Filter, sort, and group controls">
1239
+ <FilterBar.Button type="filter" count={3} onClick={onOpenFilter} />
1240
+ </FilterBar.Toolbar>
1241
+ <FilterBar.ActiveList
1242
+ items={[
1243
+ {
1244
+ id: 'search-term',
1245
+ type: 'filter',
1246
+ value: 'Jordan Casey',
1247
+ onClick: onOpenAppliedItem,
1248
+ onRemove: onRemoveAppliedItem,
1249
+ },
1250
+ {
1251
+ id: 'year-group',
1252
+ type: 'filter',
1253
+ label: 'Year group',
1254
+ value: 'Year 10',
1255
+ onClick: onOpenAppliedItem,
1256
+ onRemove: onRemoveAppliedItem,
1257
+ },
1258
+ {
1259
+ id: 'attendance-band',
1260
+ type: 'filter',
1261
+ label: 'Attendance band',
1262
+ value: 'Below 90%',
1263
+ onClick: onOpenAppliedItem,
1264
+ onRemove: onRemoveAppliedItem,
1265
+ },
1266
+ ]}
1267
+ ariaLabel="Applied attendance filters"
1268
+ />
1269
+ </FilterBar>
1270
+ </StoryShell>
1271
+ ),
1272
+ },
1273
+ [
1274
+ 'Omit `label` when the value alone is self-explanatory, such as the free-text search term shown',
1275
+ 'first here. That tag renders as just `Jordan Casey` with a shorter generated action label',
1276
+ '(`Open filter Jordan Casey` instead of `Open filter Search: Jordan Casey`), while the labelled',
1277
+ 'tags alongside it keep their `Label: Value` layout for comparison.',
1278
+ ].join(' '),
1279
+ );
1280
+
1159
1281
  export const Empty: Story = withDescription(
1160
1282
  {
1161
1283
  parameters: {
@@ -171,6 +171,57 @@ describe('FilterBar', () => {
171
171
  }
172
172
  });
173
173
 
174
+ test('renders active list tags without a label and omits the label segment from the content and action label', async () => {
175
+ const user = userEvent.setup();
176
+ const onItemClick = vi.fn();
177
+
178
+ render(
179
+ <FilterBar>
180
+ <FilterBar.ActiveList
181
+ items={[
182
+ {
183
+ id: 'search',
184
+ type: 'filter',
185
+ value: 'Interventions',
186
+ onClick: onItemClick,
187
+ },
188
+ ]}
189
+ />
190
+ </FilterBar>,
191
+ );
192
+
193
+ const tagButton = screen.getByRole('button', { name: 'Open filter Interventions' });
194
+ expect(tagButton).toBeInTheDocument();
195
+ expect(screen.queryByText(':')).not.toBeInTheDocument();
196
+
197
+ await user.click(tagButton);
198
+
199
+ expect(onItemClick).toHaveBeenCalledOnce();
200
+ const [clickedItem] = onItemClick.mock.calls[0] ?? [];
201
+ expect(clickedItem).toMatchObject({ id: 'search', type: 'filter', value: 'Interventions' });
202
+ expect(clickedItem?.label).toBeUndefined();
203
+ });
204
+
205
+ test('renders a manually composed FilterBar.Tag without a label', async () => {
206
+ const user = userEvent.setup();
207
+ const onClick = vi.fn();
208
+
209
+ render(
210
+ <FilterBar>
211
+ <FilterBar.ActiveList>
212
+ <FilterBar.Tag type="sort" value="Priority first" onClick={onClick} />
213
+ </FilterBar.ActiveList>
214
+ </FilterBar>,
215
+ );
216
+
217
+ const tagButton = screen.getByRole('button', { name: 'Open sort Priority first' });
218
+ expect(screen.getByText('Priority first')).toBeInTheDocument();
219
+ expect(screen.queryByText(':')).not.toBeInTheDocument();
220
+
221
+ await user.click(tagButton);
222
+ expect(onClick).toHaveBeenCalledOnce();
223
+ });
224
+
174
225
  test('returns focus to the first toolbar button after sequentially removing all tags from their remove buttons', async () => {
175
226
  const user = userEvent.setup();
176
227
 
@@ -7,12 +7,12 @@ import { TagList } from 'Components/tagList/TagList';
7
7
  import classNames from 'classnames';
8
8
  import { Children, forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type MouseEventHandler, type RefObject } from 'react';
9
9
 
10
- export type FilterBarType = 'filter' | 'sort' | 'group';
10
+ export type FilterBarType = 'filter' | 'sort' | 'group' | 'add';
11
11
 
12
12
  export type FilterBarTagItem = {
13
13
  id: string;
14
14
  type: FilterBarType;
15
- label: string;
15
+ label?: string;
16
16
  value: React.ReactNode;
17
17
  fieldId?: string;
18
18
  icon?: IconName;
@@ -64,12 +64,14 @@ const FILTER_BAR_TYPE_LABELS: Record<FilterBarType, string> = {
64
64
  filter: 'Filter',
65
65
  sort: 'Sort',
66
66
  group: 'Group',
67
+ add: 'Add',
67
68
  };
68
69
 
69
70
  const FILTER_BAR_TYPE_ICONS: Record<FilterBarType, IconName> = {
70
71
  filter: 'sliders-horizontal',
71
72
  sort: 'sorting',
72
73
  group: 'list-tree',
74
+ add: 'circle-plus',
73
75
  };
74
76
 
75
77
  const getVisibleCount = (count?: number | null): string | null => {
@@ -87,17 +89,21 @@ const getButtonA11yLabel = (type: FilterBarType, label: string, count?: number |
87
89
  return `Open ${label.toLowerCase()}. ${count} ${type} item${count === 1 ? '' : 's'} applied`;
88
90
  };
89
91
 
90
- const getTagActionLabel = (item: FilterBarTagItem): string => `Open ${item.type} ${item.label}: ${item.value}`;
92
+ const getTagActionLabel = (item: FilterBarTagItem): string => item.label ? `Open ${item.type} ${item.label}: ${item.value}` : `Open ${item.type} ${item.value}`;
91
93
 
92
- const FilterBarTagContent = ({ label, value }: Pick<FilterBarTagProps, 'label' | 'value'>) => (
93
- <span className="ds-filter-bar__tag-content">
94
- <span className="ds-filter-bar__tag-label">
95
- {label}
96
- :
94
+ const FilterBarTagContent = ({ label, value }: Pick<FilterBarTagProps, 'label' | 'value'>) => {
95
+ return (
96
+ <span className="ds-filter-bar__tag-content">
97
+ {label && (
98
+ <span className="ds-filter-bar__tag-label">
99
+ {label}
100
+ :
101
+ </span>
102
+ )}
103
+ <span className="ds-filter-bar__tag-value">{value}</span>
97
104
  </span>
98
- <span className="ds-filter-bar__tag-value">{value}</span>
99
- </span>
100
- );
105
+ );
106
+ };
101
107
 
102
108
  export const FilterBar = ({
103
109
  children,
@@ -193,7 +199,7 @@ const FilterBarTag = ({
193
199
  onRemove,
194
200
  }: FilterBarTagProps): React.JSX.Element => {
195
201
  const item: FilterBarTagItem = {
196
- id: id ?? fieldId ?? `${type}-${label}`,
202
+ id: id ?? fieldId ?? `${type}-${label}-${value}`,
197
203
  type,
198
204
  label,
199
205
  value,
@@ -11,6 +11,7 @@ import {
11
11
  } from '@storybook/addon-docs/blocks';
12
12
  import { type Column, type ColDef, type ColGroupDef } from 'ag-grid-enterprise';
13
13
  import { Table } from './Table.js';
14
+ import { TABLE_SPACING } from './tableConsts.js';
14
15
  import { Button } from 'Components/button/Button';
15
16
  import { Icon } from 'Components/icon/Icon';
16
17
 
@@ -348,6 +349,14 @@ const meta: Meta<TableProps> = {
348
349
  defaultValue: { summary: 'false' },
349
350
  },
350
351
  },
352
+ initialSettings: {
353
+ description: 'Seeds the table settings (column borders, spacing, cell colours) on mount, e.g. to restore a user\'s previously persisted preferences. Only affects the initial render — `resetSettings` still falls back to the DS defaults, not these values.',
354
+ control: false,
355
+ table: {
356
+ type: { summary: 'Partial<TableSettings>' },
357
+ defaultValue: { summary: 'undefined' },
358
+ },
359
+ },
351
360
  onTableSettingsChanged: {
352
361
  description: 'Fired when the user changes table settings (column borders, spacing, cell colours) via the settings dropdown.',
353
362
  control: false,
@@ -2212,6 +2221,54 @@ export const WithTableSettingsDropdownCustomItems: Story = {
2212
2221
  },
2213
2222
  };
2214
2223
 
2224
+ type WithInitialSettingsArgs = TableProps & {
2225
+ hasColumnBorders?: boolean;
2226
+ tableSpacing?: TABLE_SPACING;
2227
+ areCellColorsEnabled?: boolean;
2228
+ };
2229
+
2230
+ export const WithInitialSettings: StoryObj<WithInitialSettingsArgs> = {
2231
+ parameters: {
2232
+ docs: {
2233
+ description: {
2234
+ story:
2235
+ '`initialSettings` seeds column borders, spacing and cell colours on mount — useful for restoring a user\'s previously persisted preferences without a flash of the DS defaults. Resetting via the settings dropdown still falls back to the DS defaults, not these values. Use the Controls panel below to try different combinations.',
2236
+ },
2237
+ },
2238
+ },
2239
+ argTypes: {
2240
+ hasColumnBorders: {
2241
+ description: 'Seeds `initialSettings.hasColumnBorders`.',
2242
+ control: 'boolean',
2243
+ },
2244
+ tableSpacing: {
2245
+ description: 'Seeds `initialSettings.tableSpacing`.',
2246
+ control: 'select',
2247
+ options: Object.values(TABLE_SPACING),
2248
+ },
2249
+ areCellColorsEnabled: {
2250
+ description: 'Seeds `initialSettings.areCellColorsEnabled`.',
2251
+ control: 'boolean',
2252
+ },
2253
+ },
2254
+ args: {
2255
+ rowData: sampleData,
2256
+ columnDefs: sampleColumnDefs,
2257
+ defaultColDef,
2258
+ domLayout: 'autoHeight',
2259
+ headerContent: <Table.TableSettingsDropdown />,
2260
+ hasColumnBorders: true,
2261
+ tableSpacing: Table.Spacing.L,
2262
+ areCellColorsEnabled: false,
2263
+ },
2264
+ render: ({ hasColumnBorders, tableSpacing, areCellColorsEnabled, ...rest }) => (
2265
+ <Table
2266
+ {...rest}
2267
+ initialSettings={{ hasColumnBorders, tableSpacing, areCellColorsEnabled }}
2268
+ />
2269
+ ),
2270
+ };
2271
+
2215
2272
  export const WithCheckboxCellRenderer: Story = {
2216
2273
  parameters: {
2217
2274
  docs: {
@@ -894,6 +894,48 @@ describe('Table', () => {
894
894
  expect(onTableSettingsChanged).toHaveBeenNthCalledWith(2, expect.objectContaining({ hasColumnBorders: true }));
895
895
  });
896
896
 
897
+ test('renders with initialSettings applied instead of the hardcoded defaults', async () => {
898
+ render(
899
+ <Table
900
+ headerContent={<TableSettingsDropdown />}
901
+ initialSettings={{
902
+ hasColumnBorders: true,
903
+ tableSpacing: Table.Spacing.L,
904
+ areCellColorsEnabled: false,
905
+ }}
906
+ />,
907
+ );
908
+
909
+ await userEvent.click(screen.getByRole('button', { name: /Table settings/i }));
910
+
911
+ expect((screen.getByLabelText('Large') as HTMLInputElement).checked).toBe(true);
912
+ expect((screen.getByLabelText('Column borders') as HTMLInputElement).checked).toBe(true);
913
+ expect((screen.getByLabelText('Cell colours') as HTMLInputElement).checked).toBe(false);
914
+ });
915
+
916
+ test('resetSettings falls back to the DS defaults, not initialSettings', async () => {
917
+ const onTableSettingsReset = vi.fn();
918
+
919
+ render(
920
+ <Table
921
+ headerContent={<TableSettingsDropdown />}
922
+ initialSettings={{ hasColumnBorders: true, tableSpacing: Table.Spacing.L }}
923
+ onTableSettingsReset={onTableSettingsReset}
924
+ />,
925
+ );
926
+
927
+ await userEvent.click(screen.getByRole('button', { name: /Table settings/i }));
928
+
929
+ const resetButton = screen.getByRole('button', { name: /Reset Table Settings/i });
930
+ await userEvent.click(resetButton);
931
+
932
+ expect(onTableSettingsReset).toHaveBeenCalledOnce();
933
+ await waitFor(() => {
934
+ expect((screen.getByLabelText('Medium') as HTMLInputElement).checked).toBe(true);
935
+ expect((screen.getByLabelText('Column borders') as HTMLInputElement).checked).toBe(false);
936
+ });
937
+ });
938
+
897
939
  test('resetSettings is called appropriately', async () => {
898
940
  const onTableSettingsReset = vi.fn();
899
941
 
@@ -69,6 +69,7 @@ export const Table = (props: TableProps) => {
69
69
  footerContent,
70
70
  footerTestId,
71
71
  onGridReady,
72
+ initialSettings,
72
73
  onTableSettingsChanged,
73
74
  onTableSettingsReset,
74
75
  onColumnBordersChanged,
@@ -100,6 +101,7 @@ export const Table = (props: TableProps) => {
100
101
  setTableSpacing,
101
102
  setAreCellColorsEnabled,
102
103
  } = useTableSettings({
104
+ initialSettings,
103
105
  onTableSettingsChanged,
104
106
  onTableSettingsReset,
105
107
  onColumnBordersChanged,
@@ -51,7 +51,9 @@ const USAGE_GUIDANCE = [
51
51
  '|---|---|---|',
52
52
  '| `bulkActions` | Start | Array of `{ displayName, callback, disabled? }` — shown as a dropdown when rows are selected |',
53
53
  '| `onUndo` | Start | Renders an undo icon button |',
54
+ '| `isUndoDisabled` | Start | Disables the undo button (e.g. when there is nothing to undo) |',
54
55
  '| `onRedo` | Start | Renders a redo icon button |',
56
+ '| `isRedoDisabled` | Start | Disables the redo button (e.g. when there is nothing to redo) |',
55
57
  '| `showHideColumns` | Start | Renders the `HideColumnsDropdown` |',
56
58
  '| `hasSearch` | End | Renders the `SearchBar` |',
57
59
  '| `onDownload` | End | Renders a download icon button |',
@@ -79,6 +81,7 @@ const DEVELOPER_NOTES = [
79
81
  ' { displayName: "Delete selected", callback: api => deleteRows(api) },',
80
82
  ' ]}',
81
83
  ' onUndo={handleUndo}',
84
+ ' isUndoDisabled={!canUndo}',
82
85
  ' showHideColumns',
83
86
  ' onDownload={handleDownload}',
84
87
  ' showSettings',
@@ -195,11 +198,21 @@ const meta = {
195
198
  control: false,
196
199
  table: { type: { summary: '() => void' }, defaultValue: { summary: 'undefined' } },
197
200
  },
201
+ isUndoDisabled: {
202
+ description: 'When `true`, disables the undo button (e.g. when there is nothing to undo). Has no effect if `onUndo` is not provided.',
203
+ control: 'boolean',
204
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
205
+ },
198
206
  onRedo: {
199
207
  description: 'Renders a redo icon button in the start group. Called with no arguments on click.',
200
208
  control: false,
201
209
  table: { type: { summary: '() => void' }, defaultValue: { summary: 'undefined' } },
202
210
  },
211
+ isRedoDisabled: {
212
+ description: 'When `true`, disables the redo button (e.g. when there is nothing to redo). Has no effect if `onRedo` is not provided.',
213
+ control: 'boolean',
214
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
215
+ },
203
216
  showHideColumns: {
204
217
  description: 'When `true`, renders the `HideColumnsDropdown` in the start group. Reads column visibility from `GridApiContext` (provided by `<Table />`). Pass `hideColumnsProps` for manual column control.',
205
218
  control: 'boolean',
@@ -299,6 +312,21 @@ export const StartGroupOnly: Story = withDescription(
299
312
  + 'see the **Within a Table** story for a fully wired example.',
300
313
  );
301
314
 
315
+ export const UndoRedoDisabled: Story = withDescription(
316
+ {
317
+ args: {
318
+ onUndo: fn(),
319
+ isUndoDisabled: true,
320
+ onRedo: fn(),
321
+ isRedoDisabled: true,
322
+ },
323
+ },
324
+ 'Undo and redo rendered with `isUndoDisabled` / `isRedoDisabled` set — use these when there is '
325
+ + 'nothing left to undo or redo, so the buttons stay visible but communicate they are unavailable '
326
+ + 'instead of silently no-opping when clicked. Toggle `isUndoDisabled` / `isRedoDisabled` off in the '
327
+ + 'Controls panel to compare against the enabled state.',
328
+ );
329
+
302
330
  export const EndGroupOnly: Story = withDescription(
303
331
  {
304
332
  parameters: { controls: { disable: true } },