@visns-studio/visns-components 6.17.1 → 6.20.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 (84) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +45 -25
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +134 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/navActive.js +25 -0
  29. package/src/components/settings/IntegrationsManager.jsx +474 -0
  30. package/src/components/sms/SmsLineSettings.jsx +16 -12
  31. package/src/components/styles/AssociationManager.module.scss +4 -363
  32. package/src/components/styles/AuditLog.module.scss +6 -6
  33. package/src/components/styles/AuditLogs.module.scss +3 -3
  34. package/src/components/styles/Autocomplete.module.scss +7 -7
  35. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  36. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  37. package/src/components/styles/CameraPlacement.module.scss +19 -19
  38. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  39. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  40. package/src/components/styles/ClientAuth.module.scss +1 -1
  41. package/src/components/styles/DataGrid.module.scss +455 -8
  42. package/src/components/styles/Field.module.scss +32 -24
  43. package/src/components/styles/Form.module.scss +123 -3
  44. package/src/components/styles/GenericDashboard.module.scss +612 -825
  45. package/src/components/styles/GenericDetail.module.scss +406 -61
  46. package/src/components/styles/GenericDynamic.module.scss +25 -25
  47. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  48. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  49. package/src/components/styles/GenericIndex.module.scss +483 -38
  50. package/src/components/styles/GenericMain.module.scss +6 -3
  51. package/src/components/styles/GenericQuote.module.scss +399 -81
  52. package/src/components/styles/GenericReport.module.scss +8 -8
  53. package/src/components/styles/GenericSettings.module.scss +209 -0
  54. package/src/components/styles/GenericSort.module.scss +9 -9
  55. package/src/components/styles/Integrations.module.scss +321 -0
  56. package/src/components/styles/List.module.scss +4 -4
  57. package/src/components/styles/Login.module.scss +57 -0
  58. package/src/components/styles/MultiSelect.module.scss +4 -4
  59. package/src/components/styles/Navigation.module.scss +12 -12
  60. package/src/components/styles/Notification.module.scss +12 -8
  61. package/src/components/styles/NotificationList.module.scss +5 -5
  62. package/src/components/styles/Profile.module.scss +5 -5
  63. package/src/components/styles/QrCode.module.scss +4 -4
  64. package/src/components/styles/QuickAction.module.scss +20 -20
  65. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  66. package/src/components/styles/TableFilter.module.scss +128 -17
  67. package/src/components/styles/TicketConversation.module.scss +497 -0
  68. package/src/components/styles/UniversalSearch.module.scss +229 -0
  69. package/src/components/styles/Vault.module.scss +413 -0
  70. package/src/components/styles/_associationManager.scss +467 -0
  71. package/src/components/styles/_authBrandPanel.scss +15 -9
  72. package/src/components/styles/_controls.scss +43 -39
  73. package/src/components/styles/global-datagrid.css +196 -0
  74. package/src/components/styles/global.css +168 -17
  75. package/src/components/tickets/TicketConversation.jsx +455 -0
  76. package/src/components/utils/rowActionSettings.js +117 -0
  77. package/src/components/vault/VaultClientPicker.jsx +183 -0
  78. package/src/components/vault/VaultEntryForm.jsx +23 -0
  79. package/src/components/vault/VaultManager.jsx +382 -34
  80. package/src/components/vault/VaultShareModal.jsx +454 -0
  81. package/src/components/vault/vaultEndpoints.js +16 -2
  82. package/src/components/vault/vaultShares.js +247 -0
  83. package/src/index.js +35 -0
  84. package/src/utils/fetchUtil.js +38 -14
@@ -52,7 +52,10 @@ const getFileIcon = (extension) => {
52
52
  return File;
53
53
  };
54
54
 
55
- function VisnsDropZone({ fetchData, files, settings, url, urlKey, deleteUrl, entityData = {}, routeParams = {}, dataKey }) {
55
+ // `settings` is optional in every documented usage but had no default, and
56
+ // the URL resolver reads `settings.url` unguarded - so a consumer that passes
57
+ // only `url` crashed on mount rather than using the url it passed.
58
+ function VisnsDropZone({ fetchData, files, settings = {}, url, urlKey, deleteUrl, entityData = {}, routeParams = {}, dataKey }) {
56
59
  console.log('DropZone: Component initialized with props:', {
57
60
  hasFetchData: typeof fetchData === 'function',
58
61
  files,
@@ -156,6 +156,13 @@ function Form({
156
156
  fetchTable,
157
157
  mapbox,
158
158
  modalOpen,
159
+ /**
160
+ * `{ url, key }` for the detail page of a record created by this form,
161
+ * derived by <DataGrid> from the columns' own `link` config. Only
162
+ * consulted when `formSettings.openOnCreate` is set, and only as a
163
+ * fallback: an explicit `openOnCreate: { url, key }` wins.
164
+ */
165
+ openOnCreateLink,
159
166
  paramValue,
160
167
  style,
161
168
  type,
@@ -163,8 +170,65 @@ function Form({
163
170
  userProfile,
164
171
  bulkEditMode = false,
165
172
  bulkEditGroupData = null,
173
+ /**
174
+ * Hands the parent this form's guarded close.
175
+ *
176
+ * The dialog shell owns the backdrop and the Escape key, but only the form
177
+ * knows whether anything has been typed. Rather than lift the dirty flag
178
+ * up (and re-render the form on every keystroke), the form publishes the
179
+ * one function that knows what to do.
180
+ */
181
+ registerClose,
166
182
  }) {
167
183
  const navigate = useNavigate();
184
+
185
+ /**
186
+ * Has the user typed anything since this form opened?
187
+ *
188
+ * A ref, not state: nothing renders differently because the form is dirty,
189
+ * and making it state would re-render the whole form on the first
190
+ * keystroke in every field.
191
+ *
192
+ * It exists for one reason — closing a half-filled form by clicking the
193
+ * backdrop, or pressing Escape, silently discarded the work. That is the
194
+ * single most common way a person loses data in a CRM, and it is entirely
195
+ * preventable.
196
+ */
197
+ const dirtyRef = useRef(false);
198
+
199
+ const markDirty = useCallback(() => {
200
+ dirtyRef.current = true;
201
+ }, []);
202
+
203
+ /**
204
+ * Close, asking first if there is anything to lose.
205
+ *
206
+ * `window.confirm` deliberately: it is synchronous, it cannot be missed,
207
+ * and it works identically from the backdrop, the Escape key and the
208
+ * Cancel button. A prettier in-app dialog would be a second modal stacked
209
+ * on the first, which is its own problem.
210
+ */
211
+ const guardedClose = useCallback(() => {
212
+ if (
213
+ dirtyRef.current &&
214
+ typeof window !== 'undefined' &&
215
+ !window.confirm(
216
+ 'You have unsaved changes. Close this form and discard them?'
217
+ )
218
+ ) {
219
+ return;
220
+ }
221
+
222
+ dirtyRef.current = false;
223
+ closeModal?.();
224
+ }, [closeModal]);
225
+
226
+ useEffect(() => {
227
+ registerClose?.(guardedClose);
228
+
229
+ return () => registerClose?.(null);
230
+ }, [registerClose, guardedClose]);
231
+
168
232
  const [fetchTrigger, setFetchTrigger] = useState(false);
169
233
  const [formData, setFormData] = useState({});
170
234
  const [inputClass, setInputClass] = useState({});
@@ -230,6 +294,8 @@ function Form({
230
294
  };
231
295
 
232
296
  const handleChange = (e) => {
297
+ markDirty();
298
+
233
299
  const id = e.target.dataset.jsonkey || e.target.dataset.name;
234
300
  const value = e.target.value;
235
301
  const parent = e.target.dataset.parent ? e.target.dataset.parent : '';
@@ -950,13 +1016,41 @@ function Form({
950
1016
  : address
951
1017
  : text;
952
1018
  const address2 = '';
953
- const suburb = context[1]?.text || '';
954
- const postcode = context[0]?.text || '';
955
1019
 
956
- const [state, country] =
957
- context.length === 5
958
- ? [context[3]?.text, context[4]?.text]
959
- : [context[2]?.text, context[3]?.text];
1020
+ /*
1021
+ * Mapbox's `context` is a list, not a record: its entries vary with
1022
+ * the kind of place. A POI carries one more than a street address, an
1023
+ * address with no suburb one fewer. Reading it by position therefore
1024
+ * filed the state under "suburb" (and vice versa) on anything but the
1025
+ * shape the indices were written against. Each entry does carry a
1026
+ * typed `id` — `postcode.123`, `region.456` — so match on that.
1027
+ */
1028
+ const contexts = Array.isArray(context) ? context : [];
1029
+ const contextOf = (...types) => {
1030
+ for (const type of types) {
1031
+ const found = contexts.find(
1032
+ (entry) =>
1033
+ typeof entry?.id === 'string' &&
1034
+ entry.id.split('.')[0] === type
1035
+ );
1036
+
1037
+ if (found) {
1038
+ return found;
1039
+ }
1040
+ }
1041
+
1042
+ return undefined;
1043
+ };
1044
+
1045
+ const postcode = contextOf('postcode')?.text || '';
1046
+ // `locality` is the suburb; `place` is the city it sits in. Australian
1047
+ // addresses carry both, and the suburb is the one on the envelope.
1048
+ const suburb = contextOf('locality', 'place')?.text || '';
1049
+ const region = contextOf('region');
1050
+ const state = region?.text || '';
1051
+ // ISO 3166-2, e.g. "AU-SA" — the last segment is the state's initials.
1052
+ const stateInitials = region?.short_code?.split('-').pop() || '';
1053
+ const country = contextOf('country')?.text || '';
960
1054
 
961
1055
  // Determine the prefix based on the id parameter
962
1056
  let prefix = '';
@@ -970,6 +1064,11 @@ function Form({
970
1064
 
971
1065
  // Create the formDataUpdate object with the appropriate field names
972
1066
  const formDataUpdate = {
1067
+ // The field that was typed into always gets the street line, even
1068
+ // if it is not named `<prefix>address1` and the prefix guess above
1069
+ // came out empty — otherwise the box the user is looking at is the
1070
+ // one field the pick does not fill.
1071
+ [id]: address1,
973
1072
  [`${prefix}address1`]: address1,
974
1073
  [`${prefix}address2`]: address2,
975
1074
  [`${prefix}suburb`]: suburb,
@@ -978,6 +1077,47 @@ function Form({
978
1077
  [`${prefix}country`]: country,
979
1078
  };
980
1079
 
1080
+ /*
1081
+ * A form that stores the state as a foreign key (`state_id`, an
1082
+ * ajax dropdown) cannot use the name — and being required, it left
1083
+ * the form unsaveable after a pick, which is how "the address
1084
+ * autocomplete doesn't work" looked from the outside. The options
1085
+ * live inside that <Field>, so resolve the id from the same endpoint
1086
+ * it reads, matching on the state's name or its initials.
1087
+ */
1088
+ const stateIdField = formSettings.fields?.find(
1089
+ (item) => item.id === `${prefix}state_id`
1090
+ );
1091
+
1092
+ if (stateIdField?.url && (state || stateInitials)) {
1093
+ const normalise = (value) =>
1094
+ String(value ?? '')
1095
+ .trim()
1096
+ .toLowerCase();
1097
+
1098
+ CustomFetch(stateIdField.url, 'POST', {}, (result) => {
1099
+ const options = Array.isArray(result?.data) ? result.data : [];
1100
+ const match =
1101
+ options.find(
1102
+ (option) => normalise(option.label) === normalise(state)
1103
+ ) ||
1104
+ (stateInitials
1105
+ ? options.find(
1106
+ (option) =>
1107
+ normalise(option.label) ===
1108
+ normalise(stateInitials)
1109
+ )
1110
+ : undefined);
1111
+
1112
+ if (match) {
1113
+ setFormData((prevState) => ({
1114
+ ...prevState,
1115
+ [stateIdField.id]: match.id,
1116
+ }));
1117
+ }
1118
+ });
1119
+ }
1120
+
981
1121
  // If same_address is true and we're updating the primary address,
982
1122
  // also update the postal address fields
983
1123
  if (formData.same_address === true && id === 'address1') {
@@ -2316,6 +2456,71 @@ function Form({
2316
2456
  );
2317
2457
  }
2318
2458
 
2459
+ /**
2460
+ * Open a just-created record instead of dropping
2461
+ * the user back on the list.
2462
+ *
2463
+ * Creating a customer from the index used to leave
2464
+ * you looking at a refreshed table, with the record
2465
+ * you just filled in somewhere on page four — so
2466
+ * the next thing anyone did was search for it. A
2467
+ * view opts in with `form.openOnCreate`; without
2468
+ * the flag nothing here runs and the old behaviour
2469
+ * stands.
2470
+ *
2471
+ * Creates only: an update already has you on the
2472
+ * record, and navigating out of it would be a
2473
+ * regression rather than a shortcut.
2474
+ */
2475
+ if (formType === 'create' && formSettings.openOnCreate) {
2476
+ const openOnCreate =
2477
+ formSettings.openOnCreate === true
2478
+ ? {}
2479
+ : formSettings.openOnCreate;
2480
+
2481
+ const detailUrl =
2482
+ openOnCreate.url || openOnCreateLink?.url;
2483
+ const idKey =
2484
+ openOnCreate.key ||
2485
+ openOnCreateLink?.key ||
2486
+ formSettings.primaryKey ||
2487
+ 'id';
2488
+
2489
+ // The endpoints disagree on shape: some return
2490
+ // the saved record under `data`, some return
2491
+ // the id at the top level. Take the first that
2492
+ // actually carries one.
2493
+ const record = res.data.data;
2494
+ const newId =
2495
+ (record && typeof record === 'object'
2496
+ ? record[idKey] ?? record.id
2497
+ : record) ??
2498
+ res.data[idKey] ??
2499
+ res.data.id;
2500
+
2501
+ if (
2502
+ detailUrl &&
2503
+ newId != null &&
2504
+ newId !== '' &&
2505
+ typeof newId !== 'object'
2506
+ ) {
2507
+ // The column links carry their own trailing
2508
+ // slash (`"/customers/"`); an explicit url
2509
+ // may not. Normalise rather than trust it.
2510
+ navigate(
2511
+ `${detailUrl.replace(
2512
+ /\/+$/,
2513
+ ''
2514
+ )}/${newId}`
2515
+ );
2516
+ } else if (detailUrl) {
2517
+ console.warn(
2518
+ 'openOnCreate: the create response carried no record id, staying on the list.',
2519
+ res.data
2520
+ );
2521
+ }
2522
+ }
2523
+
2319
2524
  if (formSettings.redirect && res.data.data?.id) {
2320
2525
  navigate(
2321
2526
  `${formSettings.redirect}${res.data.data.id}`
@@ -3901,6 +4106,17 @@ function Form({
3901
4106
  <div
3902
4107
  className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
3903
4108
  >
4109
+ <button
4110
+ className={styles.btnSecondary}
4111
+ onClick={(e) => {
4112
+ e.preventDefault();
4113
+ guardedClose();
4114
+ }}
4115
+ type="button"
4116
+ data-type="cancel"
4117
+ >
4118
+ Cancel
4119
+ </button>
3904
4120
  <button
3905
4121
  className={styles.btn}
3906
4122
  onClick={handleSubmit}
@@ -3965,6 +4181,17 @@ function Form({
3965
4181
  <div
3966
4182
  className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
3967
4183
  >
4184
+ <button
4185
+ className={styles.btnSecondary}
4186
+ onClick={(e) => {
4187
+ e.preventDefault();
4188
+ guardedClose();
4189
+ }}
4190
+ type="button"
4191
+ data-type="cancel"
4192
+ >
4193
+ Cancel
4194
+ </button>
3968
4195
  <button
3969
4196
  className={styles.btn}
3970
4197
  onClick={handleSubmit}
@@ -350,12 +350,12 @@ function MultiSelect({
350
350
  ? style.multi_select_height
351
351
  : isTouchDevice()
352
352
  ? '44px'
353
- : '50px', // Apple's recommended minimum touch target size
353
+ : 'var(--field-height, 51px)', // Apple's recommended minimum touch target size
354
354
  height: 'auto', // Allow height to grow with content
355
355
  }),
356
356
  valueContainer: (base) => ({
357
357
  ...base,
358
- padding: '2px 8px',
358
+ padding: '0 1rem',
359
359
  display: 'flex',
360
360
  alignItems: 'flex-start',
361
361
  flexWrap: 'wrap',
@@ -30,7 +30,7 @@ import CustomFetch from './Fetch';
30
30
  import Notification from './Notification';
31
31
  import SwitchAccount from './SwitchAccount';
32
32
  import BusinessCardOcr from './BusinessCardOcr';
33
- import { isNavActive } from './navActive';
33
+ import { isNavActive, bestMatchingUrl, cleanUrl } from './navActive';
34
34
  import styles from './styles/Navigation.module.scss';
35
35
 
36
36
  // Opt-in icons for the sidebar (simple/cms) layouts: a nav entry names one
@@ -348,8 +348,9 @@ function Navigation({
348
348
  const renderSetting = (n) => {
349
349
  const iconComponents = SETTING_ICONS;
350
350
 
351
- // Check if the setting's URL matches the current page
352
- const isActive = n.url && n.url === `/${currentPage}`;
351
+ // Same rule as renderRailSetting: an item stays current on the pages
352
+ // beneath it, so the cog remains lit across /settings/*.
353
+ const isActive = n.url && isNavActive(n.url, [], currentPage);
353
354
 
354
355
  // Add active class if the setting is active
355
356
  const activeClass = isActive ? styles.active : '';
@@ -456,7 +457,13 @@ function Navigation({
456
457
  * stylesheet to place. See Notification.module.scss / SwitchAccount.
457
458
  */
458
459
  const renderRailSetting = (n) => {
459
- const isActive = n.url && n.url === `/${currentPage}`;
460
+ // `isNavActive`, not an exact string match. The cog points at
461
+ // `/settings`, and the settings hub deep-links its panels as
462
+ // `/settings/integrations`, `/settings/vault` and so on — an equality
463
+ // test lights the cog on the hub's index and then drops it the moment
464
+ // you open a section, which is exactly when you most want to know
465
+ // where you are.
466
+ const isActive = n.url && isNavActive(n.url, [], currentPage);
460
467
  const activeClass = isActive ? styles.active : '';
461
468
  const label = actionLabel(n);
462
469
  // A CSS string, quoted and escaped, for `content`.
@@ -598,7 +605,11 @@ function Navigation({
598
605
  <Link
599
606
  to={child.url}
600
607
  className={
601
- isNavActive(child.url, [], currentPage)
608
+ cleanUrl(child.url) ===
609
+ bestMatchingUrl(
610
+ n.children.map((one) => one.url),
611
+ currentPage
612
+ )
602
613
  ? styles.active
603
614
  : ''
604
615
  }
@@ -636,27 +647,36 @@ function Navigation({
636
647
  );
637
648
  };
638
649
 
650
+ /**
651
+ * Every permission this user holds, however they hold it.
652
+ *
653
+ * A UNION, and that is the whole point. This used to return direct
654
+ * permissions if there were ANY, and only fall back to role permissions
655
+ * otherwise — so granting one permission directly to a user who held
656
+ * nineteen through a role left them with exactly one, and every gated item
657
+ * in the navigation disappeared. Spatie treats the two as additive and so
658
+ * must this.
659
+ *
660
+ * It also read `roles[0]` alone, which silently dropped the permissions of
661
+ * every role after the first.
662
+ */
639
663
  const getUserPermissions = () => {
640
- // First check if userProfile has direct permissions
641
- if (
642
- userProfile.permissions &&
643
- Array.isArray(userProfile.permissions) &&
644
- userProfile.permissions.length > 0
645
- ) {
646
- return userProfile.permissions.map((permission) => permission.name);
647
- }
648
-
649
- // Fall back to role-based permissions if direct permissions don't exist
650
- if (
651
- userProfile.roles &&
652
- userProfile.roles.length > 0 &&
653
- userProfile.roles[0].permissions.length > 0
654
- ) {
655
- return userProfile.roles[0].permissions.map(
656
- (permission) => permission.name
657
- );
658
- }
659
- return [];
664
+ const names = (list) =>
665
+ Array.isArray(list)
666
+ ? list
667
+ .map((entry) =>
668
+ typeof entry === 'string' ? entry : entry?.name
669
+ )
670
+ .filter(Boolean)
671
+ : [];
672
+
673
+ const direct = names(userProfile?.permissions);
674
+
675
+ const viaRoles = Array.isArray(userProfile?.roles)
676
+ ? userProfile.roles.flatMap((role) => names(role?.permissions))
677
+ : [];
678
+
679
+ return [...new Set([...direct, ...viaRoles])];
660
680
  };
661
681
 
662
682
  /**
@@ -1,6 +1,25 @@
1
1
  import React, { useState, useEffect, useRef } from 'react';
2
2
  import styles from './styles/TableFilter.module.scss';
3
3
 
4
+ /**
5
+ * Is this rail a flat list of tabs, or a two-level menu?
6
+ *
7
+ * A flat rail is one row of labels, so it can be laid out as a horizontal tab
8
+ * strip; a rail with expandable parents has a submenu under each item and has
9
+ * no single row to pan. Exported because GenericDetail has to answer the same
10
+ * question one level up — it decides whether the tabs sit ABOVE the content
11
+ * panel or beside it, and that has to agree with what this component actually
12
+ * renders, or a nested rail ends up in a strip-shaped container.
13
+ */
14
+ export const isFlatFilterList = (filters) =>
15
+ !(filters || []).some(
16
+ (filter) =>
17
+ filter &&
18
+ filter.isParent === true &&
19
+ Array.isArray(filter.children) &&
20
+ filter.children.length > 0
21
+ );
22
+
4
23
  function TableFilter({
5
24
  filters,
6
25
  setFilters,
@@ -14,9 +33,14 @@ function TableFilter({
14
33
  // treatment narrow viewports already get for a flat rail (see the
15
34
  // `.flatFilter` block in the stylesheet), promoted to an opt-in.
16
35
  //
17
- // A vertical rail spends a fifth of the page on navigation, which pays for
18
- // itself at seven tabs and does not at two. Pages say which they are; the
19
- // default is unchanged.
36
+ // A vertical rail spends a fifth of the page on navigation. Detail pages
37
+ // now ask for the strip unconditionally (see GenericDetail); index pages
38
+ // and Profile still get the rail, so the default here is unchanged.
39
+ //
40
+ // In this variant the strip also carries real tab semantics — tablist /
41
+ // tab / aria-selected, one tab stop for the whole strip, and arrow keys to
42
+ // move between tabs — because a horizontal row of labels with one marked
43
+ // is what a screen reader and a keyboard both expect a tablist to be.
20
44
  variant,
21
45
  }) {
22
46
  const [visibleChildren, setVisibleChildren] = useState({});
@@ -525,6 +549,22 @@ function TableFilter({
525
549
  const childClass = (child) =>
526
550
  `${styles.childLink} ${styles[child.class] || ''}`;
527
551
 
552
+ const isSelected = d.show === true;
553
+
554
+ // Tab semantics only in the underline strip, where the markup really is
555
+ // a tablist: one tab stop for the row, arrow keys between tabs (see
556
+ // `handleStripKeyDown`), `aria-selected` on the chosen one. Elsewhere
557
+ // the rail is a list of links and `aria-current` is the honest word for
558
+ // "this is the one you are on" — announcing a half-implemented tablist
559
+ // to a screen reader is worse than announcing nothing.
560
+ const a11y = isUnderline
561
+ ? {
562
+ role: 'tab',
563
+ 'aria-selected': isSelected ? 'true' : 'false',
564
+ tabIndex: isSelected ? 0 : -1,
565
+ }
566
+ : { 'aria-current': isSelected ? 'page' : undefined };
567
+
528
568
  if (type === 'simple') {
529
569
  return (
530
570
  <li className={styles[d.class]}>
@@ -533,8 +573,9 @@ function TableFilter({
533
573
  data-filtertype="parent"
534
574
  data-url={d.url || ''}
535
575
  data-value={d.hasOwnProperty('value') ? d.value : ''}
536
- data-active={d.show === true ? 'true' : undefined}
576
+ data-active={isSelected ? 'true' : undefined}
537
577
  onClick={filterTable}
578
+ {...a11y}
538
579
  >
539
580
  {d.label}
540
581
  </button>
@@ -549,9 +590,10 @@ function TableFilter({
549
590
  data-filtertype="parent"
550
591
  data-url={d.url || ''}
551
592
  data-value={d.hasOwnProperty('value') ? d.value : ''}
552
- data-active={d.show === true ? 'true' : undefined}
593
+ data-active={isSelected ? 'true' : undefined}
553
594
  onClick={filterTable}
554
595
  className={parentClass}
596
+ {...a11y}
555
597
  >
556
598
  {d.label}
557
599
  {isParent && d.children && d.children.length > 0 && (
@@ -642,13 +684,7 @@ function TableFilter({
642
684
  // is only styled inside that media query, and `.mobileToggle` is
643
685
  // `display: none` at every width above it, so leaving it out of the tree
644
686
  // for a flat rail is invisible on desktop and tablet.
645
- const isFlatStrip = !(filters || []).some(
646
- (filter) =>
647
- filter &&
648
- filter.isParent === true &&
649
- Array.isArray(filter.children) &&
650
- filter.children.length > 0
651
- );
687
+ const isFlatStrip = isFlatFilterList(filters);
652
688
 
653
689
  const activeId = activeFilter ? activeFilter.id : null;
654
690
 
@@ -674,6 +710,127 @@ function TableFilter({
674
710
  // expandable parents has no single row to lay out.
675
711
  const isUnderline = variant === 'underline' && isFlatStrip;
676
712
 
713
+ /**
714
+ * Which edges of the strip have tabs hidden past them: 'none' | 'start' |
715
+ * 'end' | 'both'.
716
+ *
717
+ * The strip hides its scrollbar (see `@mixin tab-strip`), which is right —
718
+ * a scrollbar under a two-tab row is noise — but it leaves a strip that is
719
+ * three tabs wider than its box looking exactly like a strip that fits.
720
+ * The stylesheet reads this off `data-overflow` and fades the affected
721
+ * edge, so the cut-off tab says out loud that it is cut off.
722
+ */
723
+ const [stripOverflow, setStripOverflow] = useState('none');
724
+
725
+ useEffect(() => {
726
+ if (!isFlatStrip) return undefined;
727
+
728
+ const list = listRef.current;
729
+
730
+ if (!list || typeof list.addEventListener !== 'function') {
731
+ return undefined;
732
+ }
733
+
734
+ const measure = () => {
735
+ const max = list.scrollWidth - list.clientWidth;
736
+
737
+ // A sub-pixel difference is a rounding artefact, not a hidden tab.
738
+ if (!(max > 1)) {
739
+ setStripOverflow('none');
740
+ return;
741
+ }
742
+
743
+ const atStart = list.scrollLeft <= 1;
744
+ const atEnd = list.scrollLeft >= max - 1;
745
+
746
+ setStripOverflow(
747
+ atStart && atEnd
748
+ ? 'none'
749
+ : atStart
750
+ ? 'end'
751
+ : atEnd
752
+ ? 'start'
753
+ : 'both'
754
+ );
755
+ };
756
+
757
+ measure();
758
+
759
+ list.addEventListener('scroll', measure, { passive: true });
760
+
761
+ if (typeof window !== 'undefined' && window.addEventListener) {
762
+ window.addEventListener('resize', measure);
763
+ }
764
+
765
+ return () => {
766
+ list.removeEventListener('scroll', measure);
767
+
768
+ if (typeof window !== 'undefined' && window.removeEventListener) {
769
+ window.removeEventListener('resize', measure);
770
+ }
771
+ };
772
+ // `filters` because adding or removing a tab changes whether the row
773
+ // overflows; `activeId` because selecting a tab scrolls it into view.
774
+ }, [isFlatStrip, filters, activeId]);
775
+
776
+ /**
777
+ * Arrow-key movement between tabs.
778
+ *
779
+ * A tablist is one tab stop, not one per tab — `renderContent` gives the
780
+ * selected tab `tabIndex=0` and the rest `-1` — so without this the strip
781
+ * would be reachable by keyboard and impossible to move around in. Home
782
+ * and End jump to the ends. Activation follows focus, which is the
783
+ * expected behaviour for a tablist whose panels are already loaded.
784
+ */
785
+ const handleStripKeyDown = (e) => {
786
+ if (!isUnderline) return;
787
+
788
+ const step =
789
+ e.key === 'ArrowRight'
790
+ ? 1
791
+ : e.key === 'ArrowLeft'
792
+ ? -1
793
+ : e.key === 'Home'
794
+ ? 'first'
795
+ : e.key === 'End'
796
+ ? 'last'
797
+ : null;
798
+
799
+ if (step === null) return;
800
+
801
+ const list = listRef.current;
802
+
803
+ if (!list || typeof list.querySelectorAll !== 'function') return;
804
+
805
+ const items = Array.from(
806
+ list.querySelectorAll(':scope > li > [data-filtertype]')
807
+ );
808
+
809
+ if (items.length === 0) return;
810
+
811
+ const current = items.indexOf(e.target);
812
+
813
+ if (current === -1) return;
814
+
815
+ e.preventDefault();
816
+
817
+ const next =
818
+ step === 'first'
819
+ ? 0
820
+ : step === 'last'
821
+ ? items.length - 1
822
+ : (current + step + items.length) % items.length;
823
+
824
+ const target = items[next];
825
+
826
+ if (!target) return;
827
+
828
+ if (typeof target.focus === 'function') target.focus();
829
+ // Goes through the same `filterTable` path a mouse click does, rather
830
+ // than a second copy of the selection logic.
831
+ if (typeof target.click === 'function') target.click();
832
+ };
833
+
677
834
  return (
678
835
  <div
679
836
  className={`${styles.tableFilterContainer} ${
@@ -701,6 +858,9 @@ function TableFilter({
701
858
  className={`${styles.tableFilter} ${
702
859
  mobileMenuOpen ? styles.mobileOpen : ''
703
860
  }`}
861
+ role={isUnderline ? 'tablist' : undefined}
862
+ data-overflow={isFlatStrip ? stripOverflow : undefined}
863
+ onKeyDown={isUnderline ? handleStripKeyDown : undefined}
704
864
  >
705
865
  {filters.map((item, key) => (
706
866
  <React.Fragment key={'table-filter-' + key}>