@developer-overheid-nl/don-register-components 1.3.1 → 1.6.2

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 (139) hide show
  1. package/CHANGELOG.md +176 -130
  2. package/LICENSE.md +291 -291
  3. package/README.md +20 -20
  4. package/package.json +10 -8
  5. package/src/Introduction.mdx +52 -0
  6. package/src/alert/Alert.stories.tsx +57 -0
  7. package/src/alert/Alert.tsx +19 -19
  8. package/src/alert/styles.module.css +3 -3
  9. package/src/alignBox/AlignBox.stories.tsx +84 -0
  10. package/src/alignBox/AlignBox.tsx +114 -114
  11. package/src/alignBox/styles.module.css +110 -110
  12. package/src/amountLabel/AmountLabel.stories.tsx +28 -0
  13. package/src/amountLabel/AmountLabel.tsx +21 -16
  14. package/src/amountLabel/styles.module.css +11 -11
  15. package/src/analytics/PiwikPro.tsx +94 -94
  16. package/src/analytics/index.ts +1 -1
  17. package/src/article/Article.stories.tsx +53 -0
  18. package/src/article/Article.tsx +10 -10
  19. package/src/badgeList/BadgeList.tsx +34 -34
  20. package/src/badgeList/styles.module.css +37 -37
  21. package/src/block/Block.stories.tsx +58 -0
  22. package/src/block/Block.tsx +35 -35
  23. package/src/block/styles.module.css +29 -29
  24. package/src/button/Button.stories.tsx +56 -0
  25. package/src/button/Button.tsx +3 -3
  26. package/src/button/ButtonAction.stories.tsx +62 -0
  27. package/src/button/ButtonAction.tsx +51 -0
  28. package/src/cardAsLink/CardAsLink.stories.tsx +81 -0
  29. package/src/cardAsLink/CardAsLink.tsx +21 -21
  30. package/src/cardAsLink/styles.module.css +23 -23
  31. package/src/cardsList/CardsList.tsx +56 -56
  32. package/src/cardsList/styles.module.css +18 -19
  33. package/src/client.ts +7 -7
  34. package/src/container/Container.stories.tsx +21 -0
  35. package/src/container/Container.tsx +10 -10
  36. package/src/container/styles.module.css +28 -28
  37. package/src/copyButton/CopyButton.stories.tsx +41 -0
  38. package/src/copyButton/CopyButton.tsx +45 -39
  39. package/src/copyButton/styles.module.css +13 -13
  40. package/src/dataBadgeLink/DataBadgeLink.stories.tsx +137 -0
  41. package/src/dataBadgeLink/DataBadgeLink.tsx +59 -53
  42. package/src/dataBadgeLink/styles.module.css +89 -89
  43. package/src/dataSummary/DataSummary.tsx +22 -22
  44. package/src/dataSummary/DataSummaryItem.tsx +21 -21
  45. package/src/dataSummary/styles.module.css +75 -75
  46. package/src/facetFilters/FacetFilters.stories.tsx +93 -0
  47. package/src/facetFilters/FacetFilters.tsx +424 -419
  48. package/src/facetFilters/styles.module.css +3 -3
  49. package/src/fieldset/FieldSet.stories.tsx +45 -0
  50. package/src/fieldset/FieldSet.tsx +3 -3
  51. package/src/filters/Filters.stories.tsx +45 -0
  52. package/src/filters/Filters.tsx +116 -116
  53. package/src/filters/styles.module.css +26 -26
  54. package/src/footer/Footer.stories.tsx +127 -0
  55. package/src/footer/Footer.tsx +81 -76
  56. package/src/footer/styles.module.css +103 -103
  57. package/src/formFieldCheckboxGroup/FormFieldCheckboxGroup.stories.tsx +48 -0
  58. package/src/formFieldCheckboxGroup/FormFieldCheckboxGroup.tsx +16 -16
  59. package/src/formFieldCheckboxGroup/styles.module.css +5 -5
  60. package/src/formFieldCheckboxOption/FormFieldCheckboxOption.stories.tsx +61 -0
  61. package/src/formFieldCheckboxOption/FormFieldCheckboxOption.tsx +26 -26
  62. package/src/formFieldCheckboxOption/styles.module.css +22 -22
  63. package/src/formFieldLabel/FormFieldLabel.stories.tsx +21 -0
  64. package/src/formFieldLabel/FormFieldLabel.tsx +23 -23
  65. package/src/formFieldRadioGroup/FormFieldRadioGroup.stories.tsx +57 -0
  66. package/src/formFieldRadioGroup/FormFieldRadioGroup.tsx +13 -13
  67. package/src/formFieldRadioGroup/styles.module.css +5 -5
  68. package/src/formFieldRadioOption/FormFieldRadioOption.stories.tsx +48 -0
  69. package/src/formFieldRadioOption/FormFieldRadioOption.tsx +75 -75
  70. package/src/formFieldRadioOption/styles.module.css +49 -49
  71. package/src/formFieldSwitch/FormFieldSwitch.stories.tsx +61 -0
  72. package/src/formFieldSwitch/FormFieldSwitch.tsx +82 -79
  73. package/src/formFieldSwitch/styles.module.css +32 -32
  74. package/src/formFieldTextInput/FormFieldTextInput.stories.tsx +45 -0
  75. package/src/formFieldTextInput/FormFieldTextInput.tsx +3 -3
  76. package/src/grid/Grid.stories.tsx +37 -0
  77. package/src/grid/Grid.tsx +48 -0
  78. package/src/grid/styles.module.css +24 -0
  79. package/src/header/Header.stories.tsx +54 -0
  80. package/src/header/Header.tsx +137 -137
  81. package/src/header/styles.module.css +71 -71
  82. package/src/heading/Heading.stories.tsx +60 -0
  83. package/src/heading/Heading.tsx +15 -15
  84. package/src/headingGroup/HeadingGroup.stories.tsx +57 -0
  85. package/src/headingGroup/HeadingGroup.tsx +48 -48
  86. package/src/headingGroup/styles.module.css +3 -3
  87. package/src/i18n.ts +24 -25
  88. package/src/iconBadge/IconBadge.stories.tsx +46 -0
  89. package/src/iconBadge/IconBadge.tsx +35 -35
  90. package/src/iconBadge/getAppearance.ts +53 -53
  91. package/src/iconBadge/styles.module.css +19 -19
  92. package/src/iconsSprite/Icon.stories.tsx +73 -0
  93. package/src/iconsSprite/Icon.tsx +17 -17
  94. package/src/iconsSprite/IconsSprite.tsx +5 -5
  95. package/src/index.ts +117 -107
  96. package/src/infoButton/InfoButton.stories.tsx +52 -0
  97. package/src/infoButton/InfoButton.tsx +45 -45
  98. package/src/infoButton/styles.module.css +43 -43
  99. package/src/link/Link.stories.tsx +49 -0
  100. package/src/link/Link.tsx +10 -10
  101. package/src/markdown/Markdown.stories.tsx +48 -0
  102. package/src/markdown/Markdown.tsx +42 -42
  103. package/src/overlay/Overlay.stories.tsx +38 -0
  104. package/src/overlay/Overlay.tsx +18 -18
  105. package/src/overlay/styles.module.css +15 -15
  106. package/src/pagination/Pagination.stories.tsx +91 -0
  107. package/src/pagination/Pagination.tsx +267 -267
  108. package/src/pagination/styles.module.css +28 -28
  109. package/src/paragraph/Paragraph.stories.tsx +45 -0
  110. package/src/paragraph/Paragraph.tsx +24 -24
  111. package/src/paragraph/styles.module.css +6 -6
  112. package/src/pillBadge/PillBadge.examples.tsx +107 -107
  113. package/src/pillBadge/PillBadge.stories.tsx +257 -0
  114. package/src/pillBadge/PillBadge.tsx +60 -51
  115. package/src/pillBadge/styles.module.css +214 -214
  116. package/src/readOnlyTextInput/ReadOnlyTextInput.stories.tsx +49 -0
  117. package/src/readOnlyTextInput/ReadOnlyTextInput.tsx +24 -24
  118. package/src/readOnlyTextInput/styles.module.css +19 -19
  119. package/src/scoreBadge/ScoreBadge.stories.tsx +45 -0
  120. package/src/scoreBadge/ScoreBadge.tsx +135 -133
  121. package/src/search/Search.stories.tsx +65 -0
  122. package/src/search/Search.tsx +82 -82
  123. package/src/search/styles.module.css +40 -39
  124. package/src/siteLogo/ExampleLogo.stories.tsx +40 -0
  125. package/src/siteLogo/SiteLogo.stories.tsx +28 -0
  126. package/src/siteLogo/SiteLogo.tsx +24 -24
  127. package/src/sortForm/SortForm.stories.tsx +111 -0
  128. package/src/sortForm/SortForm.tsx +121 -0
  129. package/src/sortForm/styles.module.css +40 -0
  130. package/src/switch/Switch.stories.tsx +68 -0
  131. package/src/switch/Switch.tsx +152 -86
  132. package/src/switch/styles.module.css +54 -7
  133. package/src/toolTip/ToolTip.stories.tsx +131 -0
  134. package/src/toolTip/ToolTip.tsx +55 -55
  135. package/src/toolTip/styles.module.css +49 -49
  136. package/src/topNavigation/TopNavigation.stories.tsx +71 -0
  137. package/src/topNavigation/TopNavigation.tsx +67 -67
  138. package/src/topNavigation/styles.module.css +54 -54
  139. package/tsconfig.json +122 -121
@@ -1,419 +1,424 @@
1
- "use client";
2
-
3
- import clsx from "clsx";
4
- import type { ChangeEvent } from "react";
5
- import { type HTMLProps, type PropsWithChildren, useRef } from "react";
6
- import { I18nextProvider, useTranslation } from "react-i18next";
7
- import FormFieldCheckboxGroup from "../formFieldCheckboxGroup/FormFieldCheckboxGroup";
8
- import FormFieldCheckboxOption from "../formFieldCheckboxOption/FormFieldCheckboxOption";
9
- import FormFieldRadioGroup from "../formFieldRadioGroup/FormFieldRadioGroup";
10
- import FormFieldRadioOption from "../formFieldRadioOption/FormFieldRadioOption";
11
- import FormFieldSwitch from "../formFieldSwitch/FormFieldSwitch";
12
- import Heading, {
13
- clampHeadingLevel,
14
- type HeadingProps,
15
- } from "../heading/Heading";
16
- import i18n from "../i18n";
17
- import Paragraph from "../paragraph/Paragraph";
18
- import ToolTip from "../toolTip/ToolTip";
19
- import styles from "./styles.module.css";
20
-
21
- enum FilterType {
22
- Toggle = "toggle",
23
- Date = "date",
24
- Multi = "multi-select",
25
- Single = "single-select",
26
- }
27
-
28
- export interface SelectOption {
29
- value: string;
30
- label: string;
31
- description?: string | null;
32
- count: number;
33
- selected: boolean;
34
- }
35
-
36
- interface BaseFilterItems {
37
- key: string;
38
- label: string;
39
- description: string;
40
- }
41
-
42
- interface ToggleFilterData extends BaseFilterItems {
43
- type: FilterType.Toggle;
44
- value?: boolean;
45
- count?: number;
46
- }
47
-
48
- interface DateFilterData extends BaseFilterItems {
49
- type: FilterType.Date;
50
- value?: string;
51
- count?: number;
52
- }
53
-
54
- interface MultiSelectFilterData extends BaseFilterItems {
55
- type: FilterType.Multi;
56
- options: SelectOption[];
57
- }
58
-
59
- interface SingleSelectFilterData extends BaseFilterItems {
60
- type: FilterType.Single;
61
- options: SelectOption[];
62
- }
63
-
64
- export type FilterData =
65
- | ToggleFilterData
66
- | DateFilterData
67
- | MultiSelectFilterData
68
- | SingleSelectFilterData;
69
-
70
- type SelectedFilters = Record<string, Array<string | [string, string]>>;
71
-
72
- type SelectedFiltersMap = Map<
73
- string | [string, string],
74
- Array<string | [string, string]>
75
- >;
76
-
77
- type ActiveFilters = Record<string, string | string[] | undefined>;
78
-
79
- export interface FacetFiltersProps extends HTMLProps<HTMLDivElement> {
80
- title?: string;
81
- startHeadingLevel?: HeadingProps["level"];
82
- filters: FilterData[] | null | undefined;
83
- /**
84
- * Callback function called when filter option has changed.
85
- * @param selectedFilters current selected filters at the moment of change
86
- * @returns
87
- */
88
- onFilterChange?: (selectedFilters: SelectedFilters) => void;
89
- /**
90
- * Types of filters for which the onFilterChange callback should be debounced. Default: ["date", "single-select"]
91
- */
92
- debounceTypes?: Array<FilterType>;
93
- /**
94
- * Delay for debouncing the onFilterChange callback, in milliseconds. Default: 600
95
- */
96
- debounceDelay?: number;
97
- }
98
-
99
- /**
100
- * @deprecated Use getSelectedFiltersMap, that will return a map with keys as a tuple with key and label
101
- * @param filters
102
- * @param withLabels Note: keys have no labels, use the map version
103
- * @returns object
104
- */
105
- export const getSelectedFilters = (
106
- filters: FilterData[] | null | undefined,
107
- withLabels = false,
108
- ): SelectedFilters => {
109
- return (
110
- filters?.reduce((acc, filter) => {
111
- if (filter.type === FilterType.Multi) {
112
- const keys: (string | [string, string])[] = filter.options
113
- ?.filter((option) => option.selected)
114
- .map((option) =>
115
- withLabels ? [option.value, option.label] : option.value,
116
- );
117
- if (keys?.length > 0) {
118
- acc[filter.key] = keys;
119
- }
120
- } else if (filter.type === FilterType.Toggle && filter.value) {
121
- acc[filter.key] = withLabels ? [["true", filter.label]] : ["true"];
122
- } else if (filter.type === FilterType.Date && filter.value) {
123
- acc[filter.key] = withLabels
124
- ? [[filter.value, filter.label]]
125
- : [filter.value];
126
- } else if (filter.type === FilterType.Single) {
127
- const selectedOption = filter.options?.find(
128
- (option) => option.selected,
129
- );
130
- if (selectedOption) {
131
- acc[filter.key] = withLabels
132
- ? [[selectedOption.value, selectedOption.label]]
133
- : [selectedOption.value];
134
- }
135
- }
136
- return acc;
137
- }, {} as SelectedFilters) ?? {}
138
- );
139
- };
140
-
141
- export const getSelectedFiltersMap = (
142
- filters: FilterData[] | null | undefined,
143
- withLabels = false,
144
- activeFilters: ActiveFilters = {},
145
- ): SelectedFiltersMap => {
146
- return (
147
- filters?.reduce((acc, filter) => {
148
- if (filter.type === FilterType.Multi) {
149
- const keys: (string | [string, string])[] = filter.options
150
- ?.filter((option) => option.selected)
151
- .map((option) =>
152
- withLabels ? [option.value, option.label] : option.value,
153
- );
154
- if (keys?.length > 0) {
155
- withLabels
156
- ? acc.set([filter.key, filter.label], keys)
157
- : acc.set(filter.key, keys);
158
- }
159
- } else if (filter.type === FilterType.Toggle) {
160
- const activeValue = activeFilters[filter.key];
161
- const values = Array.isArray(activeValue) ? activeValue : [activeValue];
162
- const isExplicitFalse = values.includes("false");
163
-
164
- if (filter.value || isExplicitFalse) {
165
- const value = filter.value ? "true" : "false";
166
- const label =
167
- value === "true"
168
- ? filter.label
169
- : filter.label.replace(/^Heeft\s+/i, "Heeft geen ");
170
-
171
- withLabels
172
- ? acc.set([filter.key, filter.label], [[value, label]])
173
- : acc.set(filter.key, [value]);
174
- }
175
- } else if (filter.type === FilterType.Date && filter.value) {
176
- withLabels
177
- ? acc.set([filter.key, filter.label], [[filter.value, filter.label]])
178
- : acc.set(filter.key, [filter.value]);
179
- } else if (filter.type === FilterType.Single) {
180
- const selectedOption = filter.options?.find(
181
- (option) => option.selected,
182
- );
183
- if (selectedOption) {
184
- withLabels
185
- ? acc.set(
186
- [filter.key, filter.label],
187
- [[selectedOption.value, selectedOption.label]],
188
- )
189
- : acc.set(filter.key, [selectedOption.value]);
190
- }
191
- }
192
- return acc;
193
- }, new Map() as SelectedFiltersMap) ?? new Map()
194
- );
195
- };
196
-
197
- const updateFilters = (
198
- selectedFilters: SelectedFilters,
199
- filterKey: string,
200
- filterValue: string,
201
- checked: boolean,
202
- type: FilterType,
203
- ) => {
204
- if (type === FilterType.Toggle) {
205
- return {
206
- ...selectedFilters,
207
- [filterKey]: [checked ? filterValue : "false"],
208
- };
209
- }
210
-
211
- return {
212
- ...selectedFilters,
213
- [filterKey]: checked
214
- ? [...(selectedFilters[filterKey] || []), filterValue]
215
- : (selectedFilters[filterKey] || []).filter(
216
- (value) => value !== filterValue,
217
- ),
218
- };
219
- };
220
-
221
- const debounce = <T extends unknown[]>(
222
- fn: (...args: T) => void,
223
- delay: number,
224
- ) => {
225
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
226
- return (...args: T) => {
227
- if (timeoutId) {
228
- clearTimeout(timeoutId);
229
- }
230
- timeoutId = setTimeout(() => {
231
- fn(...args);
232
- }, delay);
233
- };
234
- };
235
-
236
- const FacetFilters = (props: PropsWithChildren<FacetFiltersProps>) => {
237
- const { t } = useTranslation();
238
- const thisRef = useRef<HTMLDivElement>(null);
239
- const {
240
- title,
241
- startHeadingLevel = 2,
242
- filters,
243
- onFilterChange = () => {},
244
- debounceTypes = [FilterType.Date, FilterType.Single],
245
- debounceDelay = 600,
246
- ...restProps
247
- } = props;
248
-
249
- const selectedFilters = getSelectedFilters(filters);
250
-
251
- // Callback function (for React) and CustomEvent (for non-React) to notify about filter changes
252
- const emitChange = (newSelectedFilters: SelectedFilters) => {
253
- onFilterChange(newSelectedFilters);
254
- const customEvent = new CustomEvent("onFilterChange", {
255
- detail: newSelectedFilters,
256
- });
257
- if (thisRef.current) thisRef.current.dispatchEvent(customEvent);
258
- };
259
-
260
- const debouncedEmitChange = useRef(
261
- debounce(emitChange, debounceDelay),
262
- ).current;
263
-
264
- const handleChange = (
265
- event: ChangeEvent<HTMLInputElement>,
266
- type: FilterType,
267
- filterKey = event.target.name,
268
- ) => {
269
- const newSelectedFilters = updateFilters(
270
- selectedFilters,
271
- filterKey,
272
- event.target.value,
273
- event.target.checked,
274
- type,
275
- );
276
-
277
- if (debounceTypes.includes(type)) {
278
- debouncedEmitChange(newSelectedFilters);
279
- } else {
280
- emitChange(newSelectedFilters);
281
- }
282
- };
283
-
284
- const handleToggleChange = (
285
- event: ChangeEvent<HTMLInputElement>,
286
- filterKey: string,
287
- ) => {
288
- const hiddenInput = event.target.form?.elements.namedItem(filterKey);
289
-
290
- if (hiddenInput instanceof HTMLInputElement) {
291
- hiddenInput.value = event.target.checked ? "true" : "false";
292
- }
293
-
294
- handleChange(event, FilterType.Toggle, filterKey);
295
- };
296
-
297
- return (
298
- <div
299
- ref={thisRef}
300
- className={clsx([styles.facetFilters, props.className])}
301
- {...restProps}
302
- >
303
- {title ? (
304
- <Heading level={startHeadingLevel} appearanceLevel={3}>
305
- {title}
306
- </Heading>
307
- ) : null}
308
- {/* Temporary filter on `date` types */}
309
- {filters
310
- ?.filter(({ type }) => type !== "date")
311
- .map((facet) => (
312
- <div className={styles.facet} key={facet.key}>
313
- <Heading
314
- level={
315
- title
316
- ? clampHeadingLevel(startHeadingLevel + 1)
317
- : startHeadingLevel
318
- }
319
- appearanceLevel={5}
320
- >
321
- {facet.label}
322
- </Heading>
323
- <Paragraph id={`${facet.key}-label`} purpose="short">
324
- {facet.description}
325
- </Paragraph>
326
- {facet.type === "toggle" ? (
327
- <>
328
- <input
329
- type="hidden"
330
- name={facet.key}
331
- defaultValue={facet.value ? "true" : "false"}
332
- />
333
- <FormFieldSwitch
334
- label=""
335
- labelledById={`${facet.key}-label`}
336
- defaultChecked={facet.value}
337
- value="true"
338
- amount={facet.count || 0}
339
- labels={{
340
- on: t("components.filter-on"),
341
- off: t("components.filter-off"),
342
- }}
343
- onChange={(event) => handleToggleChange(event, facet.key)}
344
- />
345
- </>
346
- ) : null}
347
- {/* TODO: Date selector
348
- {facet.type === "date" ? <div>TODO</div> : null}
349
- */}
350
- {facet.type === "multi-select" ? (
351
- <FormFieldCheckboxGroup>
352
- {facet.options?.map((option) => (
353
- <FormFieldCheckboxOption
354
- key={option.value}
355
- label={option.label}
356
- name={facet.key}
357
- value={option.value}
358
- defaultChecked={option.selected}
359
- amount={option.count}
360
- onChange={(event) => handleChange(event, facet.type)}
361
- >
362
- {option.description ? (
363
- <ToolTip
364
- text={option.description}
365
- aria-label={t("components.info-about", {
366
- subject: option.label,
367
- })}
368
- />
369
- ) : null}
370
- </FormFieldCheckboxOption>
371
- ))}
372
- </FormFieldCheckboxGroup>
373
- ) : null}
374
- {facet.type === "single-select" ? (
375
- <FormFieldRadioGroup>
376
- <FormFieldRadioOption
377
- key="none"
378
- label={t("components.filter-all")}
379
- name={facet.key}
380
- value=""
381
- onChange={(event) => handleChange(event, facet.type)}
382
- />
383
- {facet.options?.map((option) => (
384
- <FormFieldRadioOption
385
- key={option.value}
386
- label={option.label}
387
- name={facet.key}
388
- value={option.value}
389
- defaultChecked={option.selected}
390
- amount={option.count}
391
- onChange={(event) => handleChange(event, facet.type)}
392
- >
393
- {option.description ? (
394
- <ToolTip
395
- text={option.description}
396
- aria-label={t("components.info-about", {
397
- subject: option.label,
398
- })}
399
- />
400
- ) : null}
401
- </FormFieldRadioOption>
402
- ))}
403
- </FormFieldRadioGroup>
404
- ) : null}
405
- </div>
406
- ))}
407
- </div>
408
- );
409
- };
410
-
411
- const TranslatedFacetFilters = (props: FacetFiltersProps) => {
412
- return (
413
- <I18nextProvider i18n={i18n}>
414
- <FacetFilters {...props} />
415
- </I18nextProvider>
416
- );
417
- };
418
-
419
- export default TranslatedFacetFilters;
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import type { ChangeEvent } from "react";
5
+ import { type HTMLProps, type PropsWithChildren, useRef } from "react";
6
+ import { I18nextProvider, useTranslation } from "react-i18next";
7
+ import FormFieldCheckboxGroup from "../formFieldCheckboxGroup/FormFieldCheckboxGroup";
8
+ import FormFieldCheckboxOption from "../formFieldCheckboxOption/FormFieldCheckboxOption";
9
+ import FormFieldRadioGroup from "../formFieldRadioGroup/FormFieldRadioGroup";
10
+ import FormFieldRadioOption from "../formFieldRadioOption/FormFieldRadioOption";
11
+ import FormFieldSwitch from "../formFieldSwitch/FormFieldSwitch";
12
+ import Heading, {
13
+ clampHeadingLevel,
14
+ type HeadingProps,
15
+ } from "../heading/Heading";
16
+ import i18n from "../i18n";
17
+ import Paragraph from "../paragraph/Paragraph";
18
+ import ToolTip from "../toolTip/ToolTip";
19
+ import styles from "./styles.module.css";
20
+
21
+ enum FilterType {
22
+ Toggle = "toggle",
23
+ Date = "date",
24
+ Multi = "multi-select",
25
+ Single = "single-select",
26
+ }
27
+
28
+ export interface SelectOption {
29
+ value: string;
30
+ label: string;
31
+ description?: string | null;
32
+ count: number;
33
+ selected: boolean;
34
+ }
35
+
36
+ type FilterLabel<T> = T extends string ? string : string | [string, string];
37
+
38
+ interface BaseFilterItems {
39
+ key: string;
40
+ label: FilterLabel<string>;
41
+ description: string;
42
+ }
43
+
44
+ interface ToggleFilterData extends Omit<BaseFilterItems, "label"> {
45
+ type: FilterType.Toggle;
46
+ isExplicitFalse?: boolean;
47
+ label: FilterLabel<unknown>;
48
+ value?: boolean;
49
+ count?: number;
50
+ }
51
+
52
+ interface DateFilterData extends BaseFilterItems {
53
+ type: FilterType.Date;
54
+ value?: string;
55
+ count?: number;
56
+ }
57
+
58
+ interface MultiSelectFilterData extends BaseFilterItems {
59
+ type: FilterType.Multi;
60
+ options: SelectOption[];
61
+ }
62
+
63
+ interface SingleSelectFilterData extends BaseFilterItems {
64
+ type: FilterType.Single;
65
+ options: SelectOption[];
66
+ }
67
+
68
+ export type FilterData =
69
+ | ToggleFilterData
70
+ | DateFilterData
71
+ | MultiSelectFilterData
72
+ | SingleSelectFilterData;
73
+
74
+ type SelectedFilters = Record<string, Array<string | [string, string]>>;
75
+
76
+ type SelectedFiltersMap = Map<
77
+ string | [string, string],
78
+ Array<string | [string, string]>
79
+ >;
80
+
81
+ export interface FacetFiltersProps extends HTMLProps<HTMLDivElement> {
82
+ title?: string;
83
+ startHeadingLevel?: HeadingProps["level"];
84
+ filters: FilterData[] | null | undefined;
85
+
86
+ /**
87
+ * Keys of toggle-filters that return true when not set;
88
+ * FIXME: Should be returned by the API: `toggle` shoud have a `isExplicitFalse` key
89
+ */
90
+ explicitFalseFilters?: string[];
91
+ /**
92
+ * Callback function called when filter option has changed.
93
+ * @param selectedFilters current selected filters at the moment of change
94
+ * @returns
95
+ */
96
+ onFilterChange?: (selectedFilters: SelectedFilters) => void;
97
+ /**
98
+ * Types of filters for which the onFilterChange callback should be debounced. Default: ["date", "single-select"]
99
+ */
100
+ debounceTypes?: Array<FilterType>;
101
+ /**
102
+ * Delay for debouncing the onFilterChange callback, in milliseconds. Default: 600
103
+ */
104
+ debounceDelay?: number;
105
+ }
106
+
107
+ /**
108
+ * @deprecated Use getSelectedFiltersMap, that will return a map with keys as a tuple with key and label
109
+ * @param filters
110
+ * @param withLabels Note: keys have no labels, use the map version
111
+ * @returns object
112
+ */
113
+ export const getSelectedFilters = (
114
+ filters: FilterData[] | null | undefined,
115
+ withLabels = false,
116
+ ): SelectedFilters => {
117
+ return (
118
+ filters?.reduce((acc, filter) => {
119
+ if (filter.type === FilterType.Multi) {
120
+ const keys: (string | [string, string])[] = filter.options
121
+ ?.filter((option) => option.selected)
122
+ .map((option) =>
123
+ withLabels ? [option.value, option.label] : option.value,
124
+ );
125
+ if (keys?.length > 0) {
126
+ acc[filter.key] = keys;
127
+ }
128
+ } else if (filter.type === FilterType.Toggle && filter.value) {
129
+ acc[filter.key] = withLabels
130
+ ? [
131
+ [
132
+ "true",
133
+ Array.isArray(filter.label) ? filter.label[0] : filter.label,
134
+ ],
135
+ ]
136
+ : ["true"];
137
+ } else if (filter.type === FilterType.Date && filter.value) {
138
+ acc[filter.key] = withLabels
139
+ ? [[filter.value, filter.label]]
140
+ : [filter.value];
141
+ } else if (filter.type === FilterType.Single) {
142
+ const selectedOption = filter.options?.find(
143
+ (option) => option.selected,
144
+ );
145
+ if (selectedOption) {
146
+ acc[filter.key] = withLabels
147
+ ? [[selectedOption.value, selectedOption.label]]
148
+ : [selectedOption.value];
149
+ }
150
+ }
151
+ return acc;
152
+ }, {} as SelectedFilters) ?? {}
153
+ );
154
+ };
155
+
156
+ export const getSelectedFiltersMap = (
157
+ filters: FilterData[] | null | undefined,
158
+ withLabels = false,
159
+ ): SelectedFiltersMap => {
160
+ return (
161
+ filters?.reduce((acc, filter) => {
162
+ if (filter.type === FilterType.Multi) {
163
+ const keys: (string | [string, string])[] = filter.options
164
+ ?.filter((option) => option.selected)
165
+ .map((option) =>
166
+ withLabels ? [option.value, option.label] : option.value,
167
+ );
168
+ if (keys?.length > 0) {
169
+ withLabels
170
+ ? acc.set([filter.key, filter.label], keys)
171
+ : acc.set(filter.key, keys);
172
+ }
173
+ } else if (filter.type === FilterType.Toggle) {
174
+ let value = filter.value && "true";
175
+ const keyLabel = Array.isArray(filter.label)
176
+ ? filter.label[0]
177
+ : filter.label;
178
+ const valueLabel = Array.isArray(filter.label)
179
+ ? filter.value
180
+ ? filter.label[0]
181
+ : filter.label[1]
182
+ : filter.label;
183
+
184
+ if (filter.isExplicitFalse) {
185
+ value = filter.value ? "true" : "false";
186
+ }
187
+
188
+ if (value) {
189
+ withLabels
190
+ ? acc.set([filter.key, keyLabel], [[value, valueLabel]])
191
+ : acc.set(filter.key, [value]);
192
+ }
193
+ } else if (filter.type === FilterType.Date && filter.value) {
194
+ withLabels
195
+ ? acc.set([filter.key, filter.label], [[filter.value, filter.label]])
196
+ : acc.set(filter.key, [filter.value]);
197
+ } else if (filter.type === FilterType.Single) {
198
+ const selectedOption = filter.options?.find(
199
+ (option) => option.selected,
200
+ );
201
+ if (selectedOption) {
202
+ withLabels
203
+ ? acc.set(
204
+ [filter.key, filter.label],
205
+ [[selectedOption.value, selectedOption.label]],
206
+ )
207
+ : acc.set(filter.key, [selectedOption.value]);
208
+ }
209
+ }
210
+ return acc;
211
+ }, new Map() as SelectedFiltersMap) ?? new Map()
212
+ );
213
+ };
214
+
215
+ const updateFilters = (
216
+ selectedFilters: SelectedFilters,
217
+ filterKey: string,
218
+ filterValue: string,
219
+ checked: boolean,
220
+ type: FilterType,
221
+ ) => {
222
+ if (type === FilterType.Toggle) {
223
+ return {
224
+ ...selectedFilters,
225
+ [filterKey]: [checked ? filterValue : "false"],
226
+ };
227
+ }
228
+
229
+ return {
230
+ ...selectedFilters,
231
+ [filterKey]: checked
232
+ ? [...(selectedFilters[filterKey] || []), filterValue]
233
+ : (selectedFilters[filterKey] || []).filter(
234
+ (value) => value !== filterValue,
235
+ ),
236
+ };
237
+ };
238
+
239
+ const debounce = <T extends unknown[]>(
240
+ fn: (...args: T) => void,
241
+ delay: number,
242
+ ) => {
243
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
244
+ return (...args: T) => {
245
+ if (timeoutId) {
246
+ clearTimeout(timeoutId);
247
+ }
248
+ timeoutId = setTimeout(() => {
249
+ fn(...args);
250
+ }, delay);
251
+ };
252
+ };
253
+
254
+ const FacetFilters = (props: PropsWithChildren<FacetFiltersProps>) => {
255
+ const { t } = useTranslation();
256
+ const thisRef = useRef<HTMLDivElement>(null);
257
+ const {
258
+ title,
259
+ startHeadingLevel = 2,
260
+ filters,
261
+ explicitFalseFilters,
262
+ onFilterChange = () => {},
263
+ debounceTypes = [FilterType.Date, FilterType.Single],
264
+ debounceDelay = 600,
265
+ ...restProps
266
+ } = props;
267
+
268
+ const selectedFilters = getSelectedFilters(filters);
269
+
270
+ // Callback function (for React) and CustomEvent (for non-React) to notify about filter changes
271
+ const emitChange = (newSelectedFilters: SelectedFilters) => {
272
+ onFilterChange(newSelectedFilters);
273
+ const customEvent = new CustomEvent("onFilterChange", {
274
+ detail: newSelectedFilters,
275
+ });
276
+ if (thisRef.current) thisRef.current.dispatchEvent(customEvent);
277
+ };
278
+
279
+ const debouncedEmitChange = useRef(
280
+ debounce(emitChange, debounceDelay),
281
+ ).current;
282
+
283
+ const handleChange = (
284
+ event: ChangeEvent<HTMLInputElement>,
285
+ type: FilterType,
286
+ filterKey = event.target.name,
287
+ ) => {
288
+ const newSelectedFilters = updateFilters(
289
+ selectedFilters,
290
+ filterKey,
291
+ event.target.value,
292
+ event.target.checked,
293
+ type,
294
+ );
295
+
296
+ if (debounceTypes.includes(type)) {
297
+ debouncedEmitChange(newSelectedFilters);
298
+ } else {
299
+ emitChange(newSelectedFilters);
300
+ }
301
+ };
302
+
303
+ return (
304
+ <div
305
+ ref={thisRef}
306
+ className={clsx([styles.facetFilters, props.className])}
307
+ {...restProps}
308
+ >
309
+ {title ? (
310
+ <Heading level={startHeadingLevel} appearanceLevel={3}>
311
+ {title}
312
+ </Heading>
313
+ ) : null}
314
+ {/* Temporary filter on `date` types */}
315
+ {filters
316
+ ?.filter(({ type }) => type !== "date")
317
+ .map((facet) => (
318
+ <div className={styles.facet} key={facet.key}>
319
+ <Heading
320
+ level={
321
+ title
322
+ ? clampHeadingLevel(startHeadingLevel + 1)
323
+ : startHeadingLevel
324
+ }
325
+ appearanceLevel={5}
326
+ >
327
+ {facet.label}
328
+ </Heading>
329
+ <Paragraph id={`${facet.key}-label`} purpose="short">
330
+ {facet.description}
331
+ </Paragraph>
332
+ {facet.type === "toggle" ? (
333
+ <FormFieldSwitch
334
+ label=""
335
+ labelledById={`${facet.key}-label`}
336
+ name={facet.key}
337
+ explicit={explicitFalseFilters?.includes(facet.key)}
338
+ defaultChecked={facet.value}
339
+ value={
340
+ explicitFalseFilters?.includes(facet.key)
341
+ ? ["true", "false"]
342
+ : "true"
343
+ }
344
+ amount={facet.count || 0}
345
+ labels={{
346
+ on: t("components.filter-on"),
347
+ off: t("components.filter-off"),
348
+ }}
349
+ onChange={(event) => handleChange(event, facet.type)}
350
+ />
351
+ ) : null}
352
+ {/* TODO: Date selector
353
+ {facet.type === "date" ? <div>TODO</div> : null}
354
+ */}
355
+ {facet.type === "multi-select" ? (
356
+ <FormFieldCheckboxGroup>
357
+ {facet.options?.map((option) => (
358
+ <FormFieldCheckboxOption
359
+ key={option.value}
360
+ label={option.label}
361
+ name={facet.key}
362
+ value={option.value}
363
+ defaultChecked={option.selected}
364
+ amount={option.count}
365
+ onChange={(event) => handleChange(event, facet.type)}
366
+ >
367
+ {option.description ? (
368
+ <ToolTip
369
+ text={option.description}
370
+ aria-label={t("components.info-about", {
371
+ subject: option.label,
372
+ })}
373
+ />
374
+ ) : null}
375
+ </FormFieldCheckboxOption>
376
+ ))}
377
+ </FormFieldCheckboxGroup>
378
+ ) : null}
379
+ {facet.type === "single-select" ? (
380
+ <FormFieldRadioGroup>
381
+ <FormFieldRadioOption
382
+ key="none"
383
+ label={t("components.filter-all")}
384
+ name={facet.key}
385
+ value=""
386
+ onChange={(event) => handleChange(event, facet.type)}
387
+ />
388
+ {facet.options?.map((option) => (
389
+ <FormFieldRadioOption
390
+ key={option.value}
391
+ label={option.label}
392
+ name={facet.key}
393
+ value={option.value}
394
+ defaultChecked={option.selected}
395
+ amount={option.count}
396
+ onChange={(event) => handleChange(event, facet.type)}
397
+ >
398
+ {option.description ? (
399
+ <ToolTip
400
+ text={option.description}
401
+ aria-label={t("components.info-about", {
402
+ subject: option.label,
403
+ })}
404
+ />
405
+ ) : null}
406
+ </FormFieldRadioOption>
407
+ ))}
408
+ </FormFieldRadioGroup>
409
+ ) : null}
410
+ </div>
411
+ ))}
412
+ </div>
413
+ );
414
+ };
415
+
416
+ const TranslatedFacetFilters = (props: FacetFiltersProps) => {
417
+ return (
418
+ <I18nextProvider i18n={i18n}>
419
+ <FacetFilters {...props} />
420
+ </I18nextProvider>
421
+ );
422
+ };
423
+
424
+ export default TranslatedFacetFilters;