@visns-studio/visns-components 6.24.4 → 6.25.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 (43) hide show
  1. package/package.json +4 -2
  2. package/src/components/Autocomplete.jsx +189 -119
  3. package/src/components/DataGrid.jsx +472 -31
  4. package/src/components/Navigation.jsx +475 -51
  5. package/src/components/auth/ClientAuthFrame.jsx +5 -0
  6. package/src/components/auth/ClientAuthScreen.jsx +29 -0
  7. package/src/components/columns/ColumnRenderers.jsx +3 -46
  8. package/src/components/columns/StackedRow.jsx +186 -0
  9. package/src/components/controls/DataGridSearch.jsx +110 -2
  10. package/src/components/controls/DataGridSortSheet.jsx +155 -0
  11. package/src/components/generic/GenericAuth.jsx +50 -18
  12. package/src/components/generic/GenericDashboard.jsx +20 -1
  13. package/src/components/generic/GenericDetail.jsx +446 -259
  14. package/src/components/mapboxSearchBox.js +640 -0
  15. package/src/components/navBadges.js +63 -1
  16. package/src/components/navDrawer.js +147 -0
  17. package/src/components/sms/SmsThreadPanel.jsx +34 -6
  18. package/src/components/sms/smsHelpers.js +15 -0
  19. package/src/components/styles/ClientAuth.module.scss +39 -0
  20. package/src/components/styles/DataGrid.module.scss +158 -5
  21. package/src/components/styles/Field.module.scss +52 -1
  22. package/src/components/styles/Form.module.scss +82 -0
  23. package/src/components/styles/GenericClientPortal.module.scss +72 -20
  24. package/src/components/styles/GenericDashboard.module.scss +50 -0
  25. package/src/components/styles/GenericDetail.module.scss +63 -1
  26. package/src/components/styles/GenericDynamic.module.scss +23 -0
  27. package/src/components/styles/GenericFormBuilder.module.scss +11 -0
  28. package/src/components/styles/GenericIndex.module.scss +6 -1
  29. package/src/components/styles/Navigation.module.scss +460 -7
  30. package/src/components/styles/Sms.module.scss +92 -0
  31. package/src/components/styles/StackedRow.module.scss +182 -0
  32. package/src/components/styles/TicketConversation.module.scss +76 -0
  33. package/src/components/styles/Vault.module.scss +192 -0
  34. package/src/components/styles/density.css +10 -0
  35. package/src/components/styles/global-datagrid.css +163 -0
  36. package/src/components/styles/global.css +20 -0
  37. package/src/components/tickets/TicketConversation.jsx +13 -8
  38. package/src/components/utils/ConfirmDialog.js +22 -3
  39. package/src/components/utils/cardLayout.js +666 -0
  40. package/src/components/utils/contactChannels.js +130 -0
  41. package/src/components/utils/editPlacement.js +95 -0
  42. package/src/components/utils/useDensity.js +303 -7
  43. package/src/index.js +32 -0
@@ -17,6 +17,11 @@ import AuthBrandPanel from './AuthBrandPanel';
17
17
  * the other. A SECTION rather than a screen, sized by its parent —
18
18
  * the consumer keeps its own header and footer and hands this the
19
19
  * space between, which is why there is no `100vh` in its styles.
20
+ *
21
+ * A parent that has no height of its own to give — a bare router route element,
22
+ * say — therefore collapses the split to the height of the form. Wrap the step
23
+ * in `ClientAuthScreen` when it owns the page; that is what GenericAuth's
24
+ * client auth routes do.
20
25
  */
21
26
  const ClientAuthFrame = ({
22
27
  layout = 'card',
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+
3
+ import styles from '../styles/ClientAuth.module.scss';
4
+
5
+ /**
6
+ * A client sign-in step given the whole viewport.
7
+ *
8
+ * ClientAuthFrame's `split` layout is a SECTION: it fills its parent and states
9
+ * no viewport height of its own, which is exactly what lets a consumer keep its
10
+ * own header and footer and hand the sign-in the space between. Mounted as a
11
+ * router route element there is no such parent — the route renders straight
12
+ * into the app's mount node, an auto-height block — so `height: 100%` resolved
13
+ * to the height of the form and the screen collapsed into a band at the top of
14
+ * the page.
15
+ *
16
+ * The staff screens never had the problem because their shell is
17
+ * `position: fixed; inset: 0`. This is the same promise made in flow layout, so
18
+ * a tall step two scrolls the page rather than being clipped by an overlay.
19
+ *
20
+ * Wrap a client auth step in this whenever it OWNS the page — which is what
21
+ * GenericAuth's `/{base}/login` and `/{base}/verify` routes do. Leave it off
22
+ * when embedding one inside chrome of your own; that is the case the section
23
+ * was written for.
24
+ */
25
+ const ClientAuthScreen = ({ children }) => (
26
+ <div className={styles.screen}>{children}</div>
27
+ );
28
+
29
+ export default ClientAuthScreen;
@@ -15,6 +15,9 @@ import {
15
15
  } from '../../utils/relationshipSortingUtils';
16
16
  import { formatCellContent } from '../generic/shared/formatters';
17
17
  import { arrayCountFrom } from '../utils/displayValue';
18
+ // One Australian numbering table, shared with the card row — see
19
+ // `utils/contactChannels.js` for why it is not defined here any more.
20
+ import { formatPhoneNumber } from '../utils/contactChannels';
18
21
 
19
22
  // Stage Toggle Utility Functions
20
23
  const buildToggleUrl = (urlTemplate, stageItem, rowData) => {
@@ -3170,52 +3173,6 @@ export const renderTotalColumn = () => {
3170
3173
  };
3171
3174
  };
3172
3175
 
3173
- // Phone Number formatter
3174
- const formatPhoneNumber = (phoneNumber) => {
3175
- if (!phoneNumber) return '';
3176
-
3177
- // Remove all non-digit characters
3178
- let cleanNumber = phoneNumber.replace(/\D/g, '');
3179
-
3180
- // +61 spellings collapse to the local form so every branch below applies.
3181
- if (cleanNumber.match(/^61[2-9]\d{8}$/)) {
3182
- cleanNumber = '0' + cleanNumber.slice(2);
3183
- }
3184
-
3185
- // Check if it's an Australian mobile number (starts with 04 and has 10 digits)
3186
- if (cleanNumber.match(/^04\d{8}$/)) {
3187
- // Mobile format: 0400 000 000
3188
- return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
3189
- }
3190
-
3191
- // Check if it's a 1300/1800 number (10 digits starting with 1300 or 1800)
3192
- if (cleanNumber.match(/^1[38]00\d{6}$/)) {
3193
- // 1300/1800 format: 1300 000 000
3194
- return cleanNumber.replace(/(\d{4})(\d{3})(\d{3})/, '$1 $2 $3');
3195
- }
3196
-
3197
- // Check if it's an Australian landline number (8 digits with area code, or 10 digits total)
3198
- if (cleanNumber.match(/^0[2-9]\d{8}$/)) {
3199
- // Landline format: (02) 0000 0000
3200
- return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
3201
- }
3202
-
3203
- // If it doesn't match standard Australian patterns, return as is with some basic formatting
3204
- if (cleanNumber.length >= 8) {
3205
- // Generic formatting for longer numbers
3206
- if (cleanNumber.length === 8) {
3207
- return cleanNumber.replace(/(\d{4})(\d{4})/, '$1 $2');
3208
- } else if (cleanNumber.length === 9) {
3209
- return cleanNumber.replace(/(\d{1})(\d{4})(\d{4})/, '$1 $2 $3');
3210
- } else if (cleanNumber.length === 10) {
3211
- return cleanNumber.replace(/(\d{2})(\d{4})(\d{4})/, '($1) $2 $3');
3212
- }
3213
- }
3214
-
3215
- // Return original if no formatting pattern matches
3216
- return phoneNumber;
3217
- };
3218
-
3219
3176
  // ABN formatter
3220
3177
  const formatABN = (abn) => {
3221
3178
  if (!abn) return '';
@@ -0,0 +1,186 @@
1
+ import { isBlankValue } from '../utils/displayValue';
2
+ import { channelOf, fieldKey } from '../utils/cardLayout';
3
+ import {
4
+ formatPhoneNumber,
5
+ mailtoHref,
6
+ telHref,
7
+ } from '../utils/contactChannels';
8
+ import styles from '../styles/StackedRow.module.scss';
9
+
10
+ /**
11
+ * One grid row, drawn stacked instead of spread across columns.
12
+ *
13
+ * THIS IS A CELL, not a replacement for the row. It renders inside the single
14
+ * synthetic `__card` column the DataGrid mounts below 640px, which is what
15
+ * keeps everything else — the data source, the pager, selection, and above all
16
+ * `onRowClick`'s cell-index link resolution — working with nothing rewritten.
17
+ *
18
+ * IT FORMATS NOTHING ITSELF. Every value except a phone number comes back from
19
+ * `renderCell`, which calls the descriptor the column was already built into,
20
+ * so a currency on a card and the same currency in the table are the same
21
+ * string produced by the same code. The one exception is a channel: the phone
22
+ * column renderer formats a number and produces no `tel:`, which is the entire
23
+ * point of the treatment, so the anchor is built here from the shared
24
+ * `formatPhoneNumber`.
25
+ *
26
+ * @param {object} props
27
+ * @param {object} props.row the data row
28
+ * @param {object} props.mapping from `deriveCardLayout`
29
+ * @param {(column: object, row: object) => any} props.renderCell
30
+ * @param {(row: object) => any} [props.renderActions] the rail's controls
31
+ */
32
+ const StackedRow = ({ row, mapping, renderCell, renderActions }) => {
33
+ // Belt and braces: card mode is hard-disabled for a grouped grid, and a
34
+ // tree parent is a summary with a fabricated id rather than a record. If
35
+ // either ever reaches here, draw nothing rather than a card claiming to be
36
+ // one of them.
37
+ if (!row || row.__treeParent || row.__group || !mapping) {
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * The raw field value, for the two decisions a rendered node cannot
43
+ * answer: is there anything here at all, and what goes in the `href`.
44
+ */
45
+ const rawValue = (column) => {
46
+ if (!column) {
47
+ return null;
48
+ }
49
+
50
+ const path = Array.isArray(column.id)
51
+ ? column.id
52
+ : String(column.id ?? '').split('.');
53
+
54
+ return path.reduce(
55
+ (value, segment) =>
56
+ value === null || value === undefined ? value : value[segment],
57
+ row
58
+ );
59
+ };
60
+
61
+ /** A rendered value, or `null` when the field is empty on this row. */
62
+ const cell = (column) => {
63
+ if (!column) {
64
+ return null;
65
+ }
66
+
67
+ const node = renderCell ? renderCell(column, row) : null;
68
+
69
+ return node === undefined || node === false ? null : node;
70
+ };
71
+
72
+ const title = Array.isArray(mapping.title)
73
+ ? // A NAME PAIR IS THE ONE PLACE THE RENDERERS ARE BYPASSED. Each of
74
+ // them wraps its value in a `width: 100%` ellipsis box, so two of
75
+ // them side by side each take the whole line and the surname is
76
+ // pushed off. A person's name has no formatting to lose.
77
+ mapping.title
78
+ .map((column) => rawValue(column))
79
+ .filter((value) => !isBlankValue(value))
80
+ .join(' ')
81
+ : cell(mapping.title);
82
+
83
+ const referenceValue = rawValue(mapping.reference);
84
+ const badge = cell(mapping.badge);
85
+ const subtitle = cell(mapping.subtitle);
86
+ const actions = renderActions ? renderActions(row) : null;
87
+
88
+ /**
89
+ * A channel line: the formatted number or address, as an anchor.
90
+ *
91
+ * `stopPropagation` on the anchor, not `preventDefault` — the tap has to
92
+ * reach the dialer, it just must not also open the record behind it.
93
+ */
94
+ const channelLine = (column, index) => {
95
+ const value = rawValue(column);
96
+
97
+ if (isBlankValue(value)) {
98
+ return null;
99
+ }
100
+
101
+ const kind = channelOf(column);
102
+ const href = kind === 'email' ? mailtoHref(value) : telHref(value);
103
+ const text =
104
+ kind === 'email' ? String(value).trim() : formatPhoneNumber(value);
105
+
106
+ return (
107
+ // Keyed on the INDEX as well as the field: a view can declare two
108
+ // columns with the same last id segment (brandingProfiles has
109
+ // three `['colors']` columns) and React would drop all but one.
110
+ <div className={styles.line} key={`channel-${index}-${fieldKey(column)}`}>
111
+ {href ? (
112
+ <a
113
+ className={styles.channel}
114
+ href={href}
115
+ onClick={(event) => event.stopPropagation()}
116
+ >
117
+ {text}
118
+ </a>
119
+ ) : (
120
+ <span className={styles.lineValue}>{text}</span>
121
+ )}
122
+ </div>
123
+ );
124
+ };
125
+
126
+ /**
127
+ * A meta line: the column's label, then its value.
128
+ *
129
+ * The label is carried because a bare date under a bare name is a fact
130
+ * nobody can read — "Closing 14/09/2026" and "14/09/2026" are not the same
131
+ * line. A blank field collapses to nothing and the ROW STAYS THE SAME
132
+ * HEIGHT, which is what stops a list of records looking like a ransom note.
133
+ */
134
+ const metaLine = (column, index) => {
135
+ const value = cell(column);
136
+
137
+ if (value === null || isBlankValue(rawValue(column))) {
138
+ return null;
139
+ }
140
+
141
+ return (
142
+ <div className={styles.line} key={`meta-${index}-${fieldKey(column)}`}>
143
+ {column.label ? (
144
+ <span className={styles.lineLabel}>{column.label}</span>
145
+ ) : null}
146
+ <span className={styles.lineValue}>{value}</span>
147
+ </div>
148
+ );
149
+ };
150
+
151
+ return (
152
+ <div className={styles.card}>
153
+ <div className={styles.main}>
154
+ <div className={styles.titleRow}>
155
+ {isBlankValue(referenceValue) ? null : (
156
+ <span className={styles.reference}>
157
+ {String(referenceValue)}
158
+ </span>
159
+ )}
160
+ <div className={styles.title}>{title}</div>
161
+ </div>
162
+
163
+ {subtitle === null ||
164
+ isBlankValue(rawValue(mapping.subtitle)) ? null : (
165
+ <div className={styles.subtitle}>{subtitle}</div>
166
+ )}
167
+
168
+ {(mapping.channels || []).map(channelLine)}
169
+ {(mapping.meta || []).map(metaLine)}
170
+ </div>
171
+
172
+ {badge === null && !actions ? null : (
173
+ <div className={styles.rail}>
174
+ {badge === null ? null : (
175
+ <div className={styles.badge}>{badge}</div>
176
+ )}
177
+ {actions ? (
178
+ <div className={styles.actions}>{actions}</div>
179
+ ) : null}
180
+ </div>
181
+ )}
182
+ </div>
183
+ );
184
+ };
185
+
186
+ export default StackedRow;
@@ -1,3 +1,4 @@
1
+ import { useState } from 'react';
1
2
  import { useNavigate } from 'react-router-dom';
2
3
  import { openRecordWindow } from '../utils/popupWindow';
3
4
  import { toast } from 'react-toastify';
@@ -7,9 +8,12 @@ import {
7
8
  Plus,
8
9
  Search,
9
10
  ArrowUpDown,
11
+ Rows3,
12
+ Table,
10
13
  Upload,
11
14
  } from 'lucide-react';
12
15
  import Download from '../Download';
16
+ import DataGridSortSheet from './DataGridSortSheet';
13
17
  import styles from '../styles/DataGrid.module.scss';
14
18
 
15
19
  const DataGridSearch = ({
@@ -26,8 +30,21 @@ const DataGridSearch = ({
26
30
  pageSetting,
27
31
  // Bulk upload
28
32
  onBulkUploadClick,
33
+ // Card mode. `cardCapable` is "this grid is narrow AND a mapping was
34
+ // derived" — the only condition under which either of these two controls
35
+ // means anything, so above 640px nothing below renders and the toolbar is
36
+ // the one it has always been.
37
+ cardCapable = false,
38
+ cardMode = false,
39
+ onCardModeChange,
40
+ columns,
41
+ columnsMetadata,
42
+ sortBy,
43
+ sort,
44
+ onSort,
29
45
  }) => {
30
46
  const navigate = useNavigate();
47
+ const [sortSheetOpen, setSortSheetOpen] = useState(false);
31
48
 
32
49
  const handleChangeSearch = (e) => {
33
50
  const { value } = e.target;
@@ -90,6 +107,60 @@ const DataGridSearch = ({
90
107
  navigate(sortUrl, { state: newState });
91
108
  };
92
109
 
110
+ /**
111
+ * The two card-mode controls, and they only exist below 640px.
112
+ *
113
+ * SORT replaces the job the column headers were doing before
114
+ * `showHeader={false}` took them away. THE TABLE TOGGLE is the escape
115
+ * hatch for the job they were doing that this pass does NOT replace: the
116
+ * filter row. A per-column filter needs per-column chrome and there is one
117
+ * column here, so a reader who needs to filter presses Table and gets the
118
+ * whole grid back — sideways scroll, filter row and all — for as long as
119
+ * this tab is open.
120
+ *
121
+ * Drawn as icon squares rather than labelled buttons because they sit
122
+ * beside Add New on a 340px screen, and three labelled buttons on that row
123
+ * wrap onto two lines.
124
+ */
125
+ const renderCardControls = () => {
126
+ if (!cardCapable) {
127
+ return null;
128
+ }
129
+
130
+ return (
131
+ <>
132
+ {cardMode ? (
133
+ <button
134
+ type="button"
135
+ className={styles.cardControl}
136
+ aria-label="Sort"
137
+ title="Sort"
138
+ onClick={() => setSortSheetOpen(true)}
139
+ >
140
+ <ArrowUpDown strokeWidth={2} size={16} />
141
+ </button>
142
+ ) : null}
143
+ <button
144
+ type="button"
145
+ className={styles.cardControl}
146
+ aria-pressed={!cardMode}
147
+ aria-label={cardMode ? 'Show as a table' : 'Show as cards'}
148
+ title={cardMode ? 'Show as a table' : 'Show as cards'}
149
+ onClick={() =>
150
+ onCardModeChange &&
151
+ onCardModeChange(cardMode ? 'table' : 'cards')
152
+ }
153
+ >
154
+ {cardMode ? (
155
+ <Table strokeWidth={2} size={16} />
156
+ ) : (
157
+ <Rows3 strokeWidth={2} size={16} />
158
+ )}
159
+ </button>
160
+ </>
161
+ );
162
+ };
163
+
93
164
  const renderCreateButton = () => {
94
165
  if (form.createDisable && form.createDisable === true) {
95
166
  return null;
@@ -209,16 +280,47 @@ const DataGridSearch = ({
209
280
  const isSearchEnabled = !(form.searchDisable === true);
210
281
  const isCreateDisabled = form.createDisable === true;
211
282
 
212
- // Don't render anything if both search and create are disabled
283
+ const sortSheet =
284
+ sortSheetOpen && cardMode ? (
285
+ <DataGridSortSheet
286
+ columns={columns}
287
+ columnsMetadata={columnsMetadata}
288
+ sortBy={sortBy}
289
+ sort={sort}
290
+ onSort={onSort}
291
+ onClose={() => setSortSheetOpen(false)}
292
+ />
293
+ ) : null;
294
+
295
+ // Don't render anything if both search and create are disabled — unless
296
+ // card mode is on, in which case this is the ONLY place Sort and Table can
297
+ // live and a grid without them cannot be sorted or filtered at all.
213
298
  if (!isSearchEnabled && isCreateDisabled) {
214
- return null;
299
+ if (!cardCapable) {
300
+ return null;
301
+ }
302
+
303
+ return (
304
+ <div className={styles.actionButtonsContainer}>
305
+ <div className={styles.actionButtonsStandalone}>
306
+ {renderCardControls()}
307
+ </div>
308
+ {sortSheet}
309
+ </div>
310
+ );
215
311
  }
216
312
 
217
313
  // If search is disabled but create button is enabled, render only the button with its own container
218
314
  if (!isSearchEnabled && !isCreateDisabled) {
219
315
  return (
220
316
  <div className={styles.actionButtonsContainer}>
317
+ {cardCapable ? (
318
+ <div className={styles.actionButtonsStandalone}>
319
+ {renderCardControls()}
320
+ </div>
321
+ ) : null}
221
322
  {renderCreateButton()}
323
+ {sortSheet}
222
324
  </div>
223
325
  );
224
326
  }
@@ -254,8 +356,14 @@ const DataGridSearch = ({
254
356
  />
255
357
  </div>
256
358
  <div className={styles.actionButtonsWrapper}>
359
+ {cardCapable ? (
360
+ <div className={styles.actionButtonsStandalone}>
361
+ {renderCardControls()}
362
+ </div>
363
+ ) : null}
257
364
  {!isCreateDisabled && renderCreateButton()}
258
365
  </div>
366
+ {sortSheet}
259
367
  </div>
260
368
  );
261
369
  };
@@ -0,0 +1,155 @@
1
+ import { ArrowDown, ArrowUp, Check } from 'lucide-react';
2
+ import StandardModal from '../generic/StandardModal';
3
+ import { isColumnSortable } from '../../utils/columnsMetadataUtils';
4
+ import styles from '../styles/DataGrid.module.scss';
5
+
6
+ /**
7
+ * Sorting, with the column headers gone.
8
+ *
9
+ * In card mode the grid is drawn with `showHeader={false}`, which takes the
10
+ * column headers AND the filter row away in one prop — and a header click is
11
+ * how a grid has always been sorted. It is not the only caller of
12
+ * `handleSort({ id, dir })` though: that writes `sortBy`/`sort` into
13
+ * `config.ajaxSetting`, which the controlled `sortInfo` prop reads and the next
14
+ * request carries. So this sheet is a second caller of the same function, not a
15
+ * second sort implementation, and there is no new state and no extra fetch.
16
+ *
17
+ * SORT SURVIVES THE COLUMN SET. `computedSortInfo` comes straight from the
18
+ * prop and is never validated against the mounted columns, so a grid showing
19
+ * one synthetic `__card` column still sorts on `firstname`.
20
+ *
21
+ * WHICH COLUMNS ARE OFFERED, and the two independent reasons one is not:
22
+ *
23
+ * - `sortable: false` in the view config — an accessor computed per row
24
+ * (`owner_label`), or a relation the backend cannot order by.
25
+ * - the server's own `columns_metadata`, the same answer the column headers
26
+ * ask `isColumnSortable` for. Offering a control that answers 500 is worse
27
+ * than not offering it.
28
+ *
29
+ * A column whose id is an ARRAY is a relation chain and has no single column
30
+ * name to order by, so it is offered only where the config names one in
31
+ * `sort`. That is deliberately conservative: the server's allow-list takes real
32
+ * column names, and a guess that reaches it comes back as an error the reader
33
+ * cannot act on.
34
+ *
35
+ * @param {object} props
36
+ * @param {object[]} props.columns the view config's columns
37
+ * @param {object} props.columnsMetadata the server's per-column metadata
38
+ * @param {string|null} props.sortBy currently sorted field
39
+ * @param {string|null} props.sort `'asc'` | `'desc'`
40
+ * @param {(value: {id: string, dir: number}) => void} props.onSort
41
+ * @param {() => void} props.onClose
42
+ */
43
+ const DataGridSortSheet = ({
44
+ columns,
45
+ columnsMetadata,
46
+ sortBy,
47
+ sort,
48
+ onSort,
49
+ onClose,
50
+ }) => {
51
+ const options = (columns || [])
52
+ .map((column) => {
53
+ if (!column || column.visible === false || column.type === 'action') {
54
+ return null;
55
+ }
56
+
57
+ const name =
58
+ column.sort ??
59
+ (Array.isArray(column.id) ? null : column.id ?? null);
60
+
61
+ if (!name || column.sortable === false) {
62
+ return null;
63
+ }
64
+
65
+ if (!isColumnSortable(name, columnsMetadata)) {
66
+ return null;
67
+ }
68
+
69
+ return { name: String(name), label: column.label || String(name) };
70
+ })
71
+ .filter(Boolean)
72
+ // A view can declare the same field twice (one column linking to the
73
+ // record, another to its client); one row per field is what a reader
74
+ // expects to choose from.
75
+ .filter(
76
+ (option, index, all) =>
77
+ all.findIndex((other) => other.name === option.name) === index
78
+ );
79
+
80
+ const apply = (name, dir) => {
81
+ onSort({ id: name, dir });
82
+ onClose();
83
+ };
84
+
85
+ return (
86
+ <StandardModal
87
+ isOpen
88
+ onClose={onClose}
89
+ title="Sort"
90
+ variant="sheet"
91
+ size="small"
92
+ showHeader
93
+ >
94
+ <div className={styles.sortSheet}>
95
+ {options.length === 0 ? (
96
+ <p className={styles.sortSheetEmpty}>
97
+ Nothing on this list can be sorted.
98
+ </p>
99
+ ) : (
100
+ options.map((option) => {
101
+ const active = sortBy === option.name;
102
+
103
+ return (
104
+ <div
105
+ className={styles.sortSheetRow}
106
+ key={option.name}
107
+ >
108
+ <span className={styles.sortSheetLabel}>
109
+ {active ? (
110
+ <Check size={14} strokeWidth={2.5} />
111
+ ) : null}
112
+ {option.label}
113
+ </span>
114
+ {/* Both directions are always drawn, and the
115
+ current one is marked. A single button that
116
+ toggles makes the reader press it to find
117
+ out which way it is going to go. */}
118
+ <span className={styles.sortSheetDirections}>
119
+ <button
120
+ type="button"
121
+ aria-label={`Sort by ${option.label}, ascending`}
122
+ aria-pressed={active && sort === 'asc'}
123
+ className={
124
+ active && sort === 'asc'
125
+ ? `${styles.sortSheetDirection} ${styles.sortSheetDirectionOn}`
126
+ : styles.sortSheetDirection
127
+ }
128
+ onClick={() => apply(option.name, 1)}
129
+ >
130
+ <ArrowUp size={16} strokeWidth={2} />
131
+ </button>
132
+ <button
133
+ type="button"
134
+ aria-label={`Sort by ${option.label}, descending`}
135
+ aria-pressed={active && sort === 'desc'}
136
+ className={
137
+ active && sort === 'desc'
138
+ ? `${styles.sortSheetDirection} ${styles.sortSheetDirectionOn}`
139
+ : styles.sortSheetDirection
140
+ }
141
+ onClick={() => apply(option.name, -1)}
142
+ >
143
+ <ArrowDown size={16} strokeWidth={2} />
144
+ </button>
145
+ </span>
146
+ </div>
147
+ );
148
+ })
149
+ )}
150
+ </div>
151
+ </StandardModal>
152
+ );
153
+ };
154
+
155
+ export default DataGridSortSheet;