@visns-studio/visns-components 6.32.3 → 6.34.3

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 (53) hide show
  1. package/README.md +483 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +274 -15
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +11 -4
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/Reset.jsx +1 -1
  13. package/src/components/auth/TwoFactorAuth.jsx +1 -1
  14. package/src/components/auth/Verify.jsx +2 -2
  15. package/src/components/auth/authEndpoints.js +43 -0
  16. package/src/components/auth/passkeyClient.js +102 -0
  17. package/src/components/auth/passkeyPrompt.js +408 -0
  18. package/src/components/auth/profileLayout.js +45 -0
  19. package/src/components/controls/DataGridSearch.jsx +16 -2
  20. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  21. package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
  22. package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
  23. package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
  24. package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
  25. package/src/components/emailCampaigns/EmailLists.jsx +505 -0
  26. package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
  27. package/src/components/generic/ActionButtons.jsx +1 -1
  28. package/src/components/generic/GenericAuth.jsx +197 -21
  29. package/src/components/generic/GenericDetail.jsx +95 -3
  30. package/src/components/generic/GenericMain.jsx +7 -0
  31. package/src/components/generic/StandardModal.jsx +71 -158
  32. package/src/components/sms/SmsCampaigns.jsx +2517 -0
  33. package/src/components/sms/SmsInbox.jsx +20 -5
  34. package/src/components/sms/SmsLineSettings.jsx +2 -5
  35. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  36. package/src/components/sms/smsEndpoints.js +23 -0
  37. package/src/components/styles/DataGrid.module.scss +22 -0
  38. package/src/components/styles/EmailCampaigns.module.scss +922 -0
  39. package/src/components/styles/Form.module.scss +100 -2
  40. package/src/components/styles/Navigation.module.scss +322 -5
  41. package/src/components/styles/Notification.module.scss +140 -8
  42. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  43. package/src/components/styles/Profile.module.scss +157 -0
  44. package/src/components/styles/Sms.module.scss +484 -4
  45. package/src/components/styles/StandardModal.module.scss +273 -0
  46. package/src/components/styles/Vault.module.scss +5 -3
  47. package/src/components/styles/global.css +38 -7
  48. package/src/components/utils/navCollapsed.js +201 -0
  49. package/src/components/utils/rowActionSettings.js +76 -0
  50. package/src/components/utils/usePasskeysEnabled.js +84 -0
  51. package/src/components/utils/useShellLayout.js +131 -0
  52. package/src/index.js +107 -1
  53. package/src/utils/rememberTab.js +70 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The three authenticated passkey requests, written once.
3
+ *
4
+ * Two screens do the same three things — the enrolment prompt that appears
5
+ * after sign-in and the passkeys tab on the profile — and the enrolment
6
+ * ceremony in particular is three ordered steps (challenge, browser, store)
7
+ * where getting the order or the field names wrong fails at the authenticator
8
+ * rather than at a lint. Two inline copies of that would drift the first time
9
+ * one of them was fixed, so there is one, and both screens call it.
10
+ *
11
+ * Errors arrive as a plain `Error` carrying the server's message, with
12
+ * CustomFetch's automatic red toast suppressed: the caller decides what a
13
+ * failure looks like, and a cancelled ceremony (see `isPasskeyCancellation`)
14
+ * is not a failure at all.
15
+ */
16
+ import CustomFetch from '../Fetch';
17
+ import { resolveAuthEndpoints, resolvePasskeyDestroyUrl } from './authEndpoints';
18
+ import { createPasskeyCredential } from './webauthn';
19
+ import { countPasskeys, defaultPasskeyName } from './passkeyPrompt';
20
+
21
+ /**
22
+ * CustomFetch as a plain promise, with its default error handling off.
23
+ *
24
+ * The same helper Login.jsx keeps for the sign-in ceremony, for the same
25
+ * reason — see the note there.
26
+ */
27
+ const request = (url, method, body) =>
28
+ new Promise((resolve, reject) => {
29
+ CustomFetch(url, method, body, resolve, (message) =>
30
+ reject(new Error(message || 'Request failed.'))
31
+ );
32
+ });
33
+
34
+ /**
35
+ * The passkeys this signed-in user holds.
36
+ *
37
+ * @param {object} [endpoints] merged over DEFAULT_AUTH_ENDPOINTS.
38
+ * @returns {Promise<{passkeys: Array, count: number|null, payload: *}>}
39
+ */
40
+ export const listPasskeys = async (endpoints) => {
41
+ const urls = resolveAuthEndpoints(endpoints);
42
+ const payload = await request(urls.passkeyIndex, 'GET');
43
+ const count = countPasskeys(payload);
44
+
45
+ return {
46
+ passkeys: Array.isArray(payload?.passkeys)
47
+ ? payload.passkeys
48
+ : Array.isArray(payload?.data)
49
+ ? payload.data
50
+ : Array.isArray(payload)
51
+ ? payload
52
+ : [],
53
+ count,
54
+ payload,
55
+ };
56
+ };
57
+
58
+ /**
59
+ * Enrol a passkey: challenge, ceremony, store.
60
+ *
61
+ * The browser's own sheet goes up in the middle step, and everything it can
62
+ * report — a dismissal, a timeout, an authenticator that refuses — comes back
63
+ * as a thrown DOMException the caller passes to `isPasskeyCancellation`.
64
+ *
65
+ * `name` becomes the credential's `alias`, which is the label the management
66
+ * list shows. Blank is allowed by the server; this sends the device name
67
+ * instead, because "This device" beside a date reads better than a date alone.
68
+ *
69
+ * @param {object} [endpoints]
70
+ * @param {{name?: string}} [options]
71
+ * @returns {Promise<*>} the server's response to the store step.
72
+ */
73
+ export const enrolPasskey = async (endpoints, { name } = {}) => {
74
+ const urls = resolveAuthEndpoints(endpoints);
75
+
76
+ const options = await request(urls.passkeyRegisterOptions, 'POST', {});
77
+ const credential = await createPasskeyCredential(options);
78
+
79
+ const alias =
80
+ typeof name === 'string' && name.trim() !== ''
81
+ ? name.trim().slice(0, 60)
82
+ : defaultPasskeyName();
83
+
84
+ return request(urls.passkeyRegister, 'POST', {
85
+ ...credential,
86
+ alias,
87
+ });
88
+ };
89
+
90
+ /**
91
+ * Remove one credential.
92
+ *
93
+ * @param {object} [endpoints]
94
+ * @param {string} id the credential id exactly as the server gave it.
95
+ */
96
+ export const deletePasskey = (endpoints, id) => {
97
+ const urls = resolveAuthEndpoints(endpoints);
98
+
99
+ return request(resolvePasskeyDestroyUrl(urls.passkeyDestroy, id), 'DELETE');
100
+ };
101
+
102
+ export default enrolPasskey;
@@ -0,0 +1,408 @@
1
+ /**
2
+ * WHETHER TO ASK THIS PERSON, ON THIS DEVICE, RIGHT NOW, TO ADD A PASSKEY.
3
+ *
4
+ * The prompt itself is four lines of dialog (see PasskeyEnrolPrompt.jsx). The
5
+ * part worth writing down — and the part that goes wrong — is the decision in
6
+ * front of it, because every way of getting it wrong is a nag:
7
+ *
8
+ * - asked on a browser that cannot do WebAuthn: an offer the person cannot
9
+ * accept, and no way to make it go away;
10
+ * - asked on every page refresh instead of once per sign-in: the same dialog
11
+ * four times in a working day;
12
+ * - asked of somebody who ALREADY has a passkey, or who has just signed in
13
+ * WITH one: the feature advertising itself to the people using it;
14
+ * - asked again after "Don't ask again", because the record was written to a
15
+ * storage that threw and nobody caught it.
16
+ *
17
+ * So the decision lives here, as one pure function over plain values, with the
18
+ * storage access beside it — and both are tested (tests/passkeyPrompt.test.mjs)
19
+ * rather than verified by signing in and out of an app.
20
+ *
21
+ * NOTHING IN THIS FILE IMPORTS ANYTHING. No React, no fetch, no stylesheet: it
22
+ * runs under `node --test` as-is, the same way authEndpoints.js and
23
+ * webauthn.js do.
24
+ */
25
+
26
+ /**
27
+ * localStorage key prefix for the per-user dismissal record.
28
+ *
29
+ * Per USER, not per app: two accounts sharing a browser are two different
30
+ * answers to "do you want a passkey on this device", and the device is what
31
+ * localStorage already scopes to. The record is `{dismissedAt, never}`.
32
+ */
33
+ export const PASSKEY_PROMPT_STORAGE_PREFIX = 'visns.passkeys.prompt.';
34
+
35
+ /** How long "Not now" holds, in days, when the host names no other number. */
36
+ export const DEFAULT_PASSKEY_SNOOZE_DAYS = 30;
37
+
38
+ /** Milliseconds in a day, so the snooze arithmetic is readable. */
39
+ const DAY_MS = 24 * 60 * 60 * 1000;
40
+
41
+ /**
42
+ * The dialog's words, all overridable through the `copy` key of the `passkeys`
43
+ * prop — the same arrangement Login and TwoFactorAuth use for theirs.
44
+ */
45
+ export const DEFAULT_PASSKEY_PROMPT_COPY = {
46
+ title: 'Sign in faster next time',
47
+ body: 'Add a passkey and this device signs you in with your fingerprint, face or screen PIN — no password to type and nothing to remember. The passkey never leaves this device.',
48
+ add: 'Add passkey',
49
+ adding: 'Waiting for your device…',
50
+ later: 'Not now',
51
+ never: "Don't ask again",
52
+ success: 'Passkey added. You can use it to sign in from now on.',
53
+ failure: 'That passkey could not be added. Please try again.',
54
+ };
55
+
56
+ /**
57
+ * The `passkeys` prop, in one shape whatever the host passed.
58
+ *
59
+ * Three spellings, because the prop grew and the two older ones are in the
60
+ * wild:
61
+ *
62
+ * undefined / false the feature is off. Nothing renders — no button on the
63
+ * login screen, no tab on the profile, no prompt. An app
64
+ * that never passed the prop is unchanged.
65
+ * true what it has always meant (the login button), PLUS the
66
+ * enrolment prompt and the management tab.
67
+ * {…} the same as `true`, with the details named. Passing an
68
+ * object is itself the opt-in, so `enabled` only has to
69
+ * be written when it is `false` — which is how a host
70
+ * keeps one config object and switches the feature with
71
+ * one flag inside it.
72
+ *
73
+ * @param {boolean|object} [passkeys]
74
+ * @returns {{enabled: boolean, promptOnLogin: boolean, snoozeDays: number, copy: object}}
75
+ */
76
+ export const resolvePasskeyOptions = (passkeys) => {
77
+ const off = {
78
+ enabled: false,
79
+ promptOnLogin: false,
80
+ snoozeDays: DEFAULT_PASSKEY_SNOOZE_DAYS,
81
+ copy: DEFAULT_PASSKEY_PROMPT_COPY,
82
+ };
83
+
84
+ if (!passkeys) {
85
+ return off;
86
+ }
87
+
88
+ if (passkeys === true || typeof passkeys !== 'object') {
89
+ return {
90
+ enabled: true,
91
+ promptOnLogin: true,
92
+ snoozeDays: DEFAULT_PASSKEY_SNOOZE_DAYS,
93
+ copy: DEFAULT_PASSKEY_PROMPT_COPY,
94
+ };
95
+ }
96
+
97
+ const enabled = passkeys.enabled !== false;
98
+ // Only the prompt is opt-OUT-able on its own: an app can offer passkeys on
99
+ // the login screen and in the profile without interrupting anybody.
100
+ const promptOnLogin = enabled && passkeys.promptOnLogin !== false;
101
+
102
+ const snoozeDays =
103
+ typeof passkeys.snoozeDays === 'number' &&
104
+ Number.isFinite(passkeys.snoozeDays) &&
105
+ passkeys.snoozeDays >= 0
106
+ ? passkeys.snoozeDays
107
+ : DEFAULT_PASSKEY_SNOOZE_DAYS;
108
+
109
+ return {
110
+ enabled,
111
+ promptOnLogin,
112
+ snoozeDays,
113
+ copy: { ...DEFAULT_PASSKEY_PROMPT_COPY, ...(passkeys.copy || {}) },
114
+ };
115
+ };
116
+
117
+ /** The localStorage key holding one user's answer. */
118
+ export const passkeyPromptStorageKey = (userId) =>
119
+ `${PASSKEY_PROMPT_STORAGE_PREFIX}${userId}`;
120
+
121
+ /**
122
+ * The storage to use: whatever was handed in, else `window.localStorage`.
123
+ *
124
+ * MERELY REACHING FOR `window.localStorage` CAN THROW — Safari with cookies
125
+ * blocked, a sandboxed iframe, a browser in a mode that disables site data —
126
+ * so even this is inside the try/catch, not only the get and set below.
127
+ *
128
+ * @param {Storage} [storage] An explicit store, which is what the tests pass.
129
+ * @returns {Storage|null}
130
+ */
131
+ const resolveStorage = (storage) => {
132
+ if (storage) {
133
+ return storage;
134
+ }
135
+
136
+ try {
137
+ if (typeof window === 'undefined') {
138
+ return null;
139
+ }
140
+
141
+ return window.localStorage || null;
142
+ } catch (error) {
143
+ return null;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * This user's dismissal record, or null.
149
+ *
150
+ * Null means "nothing is on file" AND "the file could not be read" — which are
151
+ * the same thing to the caller: with no record the prompt is allowed, and a
152
+ * browser that cannot remember a dismissal cannot be nagged by one either,
153
+ * because the prompt is shown at most once per sign-in regardless.
154
+ *
155
+ * @param {string|number} userId
156
+ * @param {Storage} [storage]
157
+ * @returns {{dismissedAt: number, never: boolean}|null}
158
+ */
159
+ export const readPasskeyPromptRecord = (userId, storage) => {
160
+ const store = resolveStorage(storage);
161
+
162
+ if (!store || userId === null || userId === undefined || userId === '') {
163
+ return null;
164
+ }
165
+
166
+ try {
167
+ const raw = store.getItem(passkeyPromptStorageKey(userId));
168
+
169
+ if (!raw) {
170
+ return null;
171
+ }
172
+
173
+ const parsed = JSON.parse(raw);
174
+
175
+ if (!parsed || typeof parsed !== 'object') {
176
+ return null;
177
+ }
178
+
179
+ return {
180
+ dismissedAt:
181
+ typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : 0,
182
+ never: parsed.never === true,
183
+ };
184
+ } catch (error) {
185
+ // Unparseable, unreadable, or a key some other version of this library
186
+ // wrote in another shape. Treated as absent rather than repaired.
187
+ return null;
188
+ }
189
+ };
190
+
191
+ /**
192
+ * Write this user's dismissal record. Silent on failure, by design: a storage
193
+ * that refuses the write must not take down the dialog that asked for it.
194
+ *
195
+ * @returns {boolean} whether it was stored.
196
+ */
197
+ export const writePasskeyPromptRecord = (userId, record, storage) => {
198
+ const store = resolveStorage(storage);
199
+
200
+ if (!store || userId === null || userId === undefined || userId === '') {
201
+ return false;
202
+ }
203
+
204
+ try {
205
+ store.setItem(
206
+ passkeyPromptStorageKey(userId),
207
+ JSON.stringify({
208
+ dismissedAt:
209
+ typeof record?.dismissedAt === 'number'
210
+ ? record.dismissedAt
211
+ : Date.now(),
212
+ never: record?.never === true,
213
+ })
214
+ );
215
+
216
+ return true;
217
+ } catch (error) {
218
+ return false;
219
+ }
220
+ };
221
+
222
+ /** "Not now" — hold the prompt back for `snoozeDays`. */
223
+ export const snoozePasskeyPrompt = (userId, { now = Date.now(), storage } = {}) =>
224
+ writePasskeyPromptRecord(userId, { dismissedAt: now, never: false }, storage);
225
+
226
+ /** "Don't ask again" — on this device, for this user, permanently. */
227
+ export const neverAskPasskeyPrompt = (userId, { now = Date.now(), storage } = {}) =>
228
+ writePasskeyPromptRecord(userId, { dismissedAt: now, never: true }, storage);
229
+
230
+ /**
231
+ * Does a stored record still hold the prompt back?
232
+ *
233
+ * `never` always does. A snooze does until `snoozeDays` have passed, counted
234
+ * from when it was taken. `snoozeDays: 0` means the snooze lasts until the
235
+ * next sign-in and no longer, which is a legitimate thing for a host to want
236
+ * and falls out of the arithmetic rather than needing a case of its own.
237
+ *
238
+ * A record with a `dismissedAt` in the future — a clock that was wrong, or has
239
+ * since been corrected — still counts as a dismissal for its window; nothing
240
+ * here tries to be cleverer than the device's own clock.
241
+ *
242
+ * @param {{dismissedAt: number, never: boolean}|null} record
243
+ * @param {{now?: number, snoozeDays?: number}} [options]
244
+ * @returns {boolean}
245
+ */
246
+ export const isPasskeyPromptBlocked = (
247
+ record,
248
+ { now = Date.now(), snoozeDays = DEFAULT_PASSKEY_SNOOZE_DAYS } = {}
249
+ ) => {
250
+ if (!record) {
251
+ return false;
252
+ }
253
+
254
+ if (record.never === true) {
255
+ return true;
256
+ }
257
+
258
+ if (typeof record.dismissedAt !== 'number' || record.dismissedAt <= 0) {
259
+ return false;
260
+ }
261
+
262
+ return now < record.dismissedAt + snoozeDays * DAY_MS;
263
+ };
264
+
265
+ /**
266
+ * How many passkeys an `index` response says this user holds.
267
+ *
268
+ * The Laravel controller answers `{error, enabled, passkeys: [...]}`, but this
269
+ * reads the two other shapes a REST list arrives in as well — a bare array,
270
+ * and `{data: [...]}` — because the endpoint is overridable and the app on the
271
+ * other end of an overridden one is not this library's.
272
+ *
273
+ * ANYTHING UNRECOGNISABLE IS `null`, NOT `0`. "I could not tell" and "this
274
+ * person has none" lead to opposite decisions, and the gate below treats only
275
+ * a confident zero as a reason to prompt.
276
+ *
277
+ * @param {*} payload
278
+ * @returns {number|null}
279
+ */
280
+ export const countPasskeys = (payload) => {
281
+ if (Array.isArray(payload)) {
282
+ return payload.length;
283
+ }
284
+
285
+ if (!payload || typeof payload !== 'object') {
286
+ return null;
287
+ }
288
+
289
+ const list = [payload.passkeys, payload.credentials, payload.data].find(
290
+ (candidate) => Array.isArray(candidate)
291
+ );
292
+
293
+ return list ? list.length : null;
294
+ };
295
+
296
+ /**
297
+ * THE DECISION. Every gate, in one place, over plain values.
298
+ *
299
+ * All of these must hold:
300
+ *
301
+ * 1. the host turned passkeys on, and left the prompt on;
302
+ * 2. the browser can do WebAuthn (`isPasskeySupported()`, passed in as
303
+ * `supported` so this stays pure);
304
+ * 3. the person signed in during THIS page's life — not refreshed a page
305
+ * that already had a session (see GenericAuth's `signInEvent`);
306
+ * 4. they did not sign in with a passkey, which would make the offer absurd;
307
+ * 5. we know who they are, because the dismissal is recorded against their
308
+ * id and a prompt nobody can dismiss is a prompt that returns forever;
309
+ * 6. they hold no passkeys yet, KNOWN to be zero — an index request that
310
+ * 404ed, failed or answered something unreadable means no prompt;
311
+ * 7. nothing on this device says they already said no.
312
+ *
313
+ * @param {object} input
314
+ * @param {object} input.options from `resolvePasskeyOptions`
315
+ * @param {boolean} input.supported `isPasskeySupported()`
316
+ * @param {boolean} input.justSignedIn a sign-in happened in this session
317
+ * @param {string} [input.signInMethod] 'password' | 'passkey' | anything
318
+ * @param {string|number} [input.userId]
319
+ * @param {number|null} [input.credentialCount] null when unknown
320
+ * @param {object|null} [input.record] from `readPasskeyPromptRecord`
321
+ * @param {number} [input.now]
322
+ * @returns {boolean}
323
+ */
324
+ export const shouldPromptForPasskey = ({
325
+ options,
326
+ supported,
327
+ justSignedIn,
328
+ signInMethod,
329
+ userId,
330
+ credentialCount,
331
+ record,
332
+ now = Date.now(),
333
+ } = {}) => {
334
+ const resolved = options || resolvePasskeyOptions(false);
335
+
336
+ if (!resolved.enabled || !resolved.promptOnLogin) {
337
+ return false;
338
+ }
339
+
340
+ if (!supported || justSignedIn !== true) {
341
+ return false;
342
+ }
343
+
344
+ if (signInMethod === 'passkey') {
345
+ return false;
346
+ }
347
+
348
+ if (userId === null || userId === undefined || userId === '') {
349
+ return false;
350
+ }
351
+
352
+ if (typeof credentialCount !== 'number' || credentialCount > 0) {
353
+ return false;
354
+ }
355
+
356
+ return !isPasskeyPromptBlocked(record, {
357
+ now,
358
+ snoozeDays: resolved.snoozeDays,
359
+ });
360
+ };
361
+
362
+ /**
363
+ * A name for the credential about to be created.
364
+ *
365
+ * It is what the person will see in a list six months from now, next to two
366
+ * others, and the honest answer is which device it is on. `userAgentData`
367
+ * gives a browser brand without parsing a user-agent string; where it is
368
+ * missing — Safari, Firefox — the generic phrase is better than a regex over
369
+ * `navigator.userAgent`, which is a well-known way to be confidently wrong.
370
+ *
371
+ * Capped at 60 characters, which is the Laravel side's `alias_max_length`
372
+ * default; a longer one would come back as a validation error.
373
+ *
374
+ * @param {object} [nav] `navigator`, injected so this can be tested.
375
+ * @returns {string}
376
+ */
377
+ export const defaultPasskeyName = (nav) => {
378
+ const source =
379
+ nav || (typeof navigator !== 'undefined' ? navigator : undefined);
380
+
381
+ const brands = source?.userAgentData?.brands;
382
+ const brand = Array.isArray(brands)
383
+ ? brands
384
+ .map((entry) => entry?.brand)
385
+ .find(
386
+ (name) =>
387
+ typeof name === 'string' &&
388
+ name !== '' &&
389
+ // The deliberately meaningless entry every Chromium
390
+ // browser includes to catch code that trusts the list.
391
+ !/not.*a.*brand/i.test(name)
392
+ )
393
+ : null;
394
+
395
+ const platform =
396
+ typeof source?.userAgentData?.platform === 'string' &&
397
+ source.userAgentData.platform !== ''
398
+ ? source.userAgentData.platform
399
+ : '';
400
+
401
+ const name = brand
402
+ ? `${brand}${platform ? ` on ${platform}` : ''}`
403
+ : 'This device';
404
+
405
+ return name.slice(0, 60);
406
+ };
407
+
408
+ export default shouldPromptForPasskey;
@@ -0,0 +1,45 @@
1
+ import { isSidebarShell } from '../utils/useShellLayout';
2
+
3
+ /**
4
+ * Where the Profile screen's tabs go.
5
+ *
6
+ * Split out of the component for the same reason `shouldUnderlineTabs` was
7
+ * split out of GenericIndex: it is the one decision here that fails silently.
8
+ * Pick the strip on a page that is already wearing a rail and you get two
9
+ * rails; pick the rail on a top-bar app and every existing deployment's
10
+ * account screen quietly changes shape. Neither shows up in a render test,
11
+ * because Profile imports stylesheets and a signature pad and the test runner
12
+ * cannot mount it — but the rule can be pinned exactly, and is (see
13
+ * tests/shellLayout.test.mjs).
14
+ *
15
+ * THE DEFAULT IS THE POINT. A host does not have to pass anything:
16
+ *
17
+ * - `layout="full"` — the header-bar shell — keeps the rail it has always
18
+ * had. The page has no other navigation column, so the rail costs nothing
19
+ * that was being used for something else.
20
+ * - `layout="cms"` / `layout="simple"` — the sidebar shells — get the strip.
21
+ * On those the app's own navigation is ALREADY a vertical rail down the
22
+ * left edge, and a second vertical rail immediately beside it is two
23
+ * columns of navigation to reach two tabs.
24
+ *
25
+ * The shell layout comes from `<html data-layout>` (GenericAuth writes it; see
26
+ * utils/useShellLayout.js), so neither consuming app has to be edited for this
27
+ * to land the right way up.
28
+ *
29
+ * `tabLayout`, when it is one of the two names, wins over all of that — the
30
+ * usual escape hatch for the page that knows better than the rule.
31
+ *
32
+ * @param {object} options
33
+ * @param {string} [options.tabLayout] 'rail' | 'underline', or nothing.
34
+ * @param {string} [options.shellLayout] 'full' | 'cms' | 'simple'.
35
+ * @returns {'rail'|'underline'}
36
+ */
37
+ export function resolveProfileTabLayout({ tabLayout, shellLayout } = {}) {
38
+ if (tabLayout === 'rail' || tabLayout === 'underline') {
39
+ return tabLayout;
40
+ }
41
+
42
+ return isSidebarShell(shellLayout) ? 'underline' : 'rail';
43
+ }
44
+
45
+ export default resolveProfileTabLayout;
@@ -174,7 +174,11 @@ const DataGridSearch = ({
174
174
  return (
175
175
  <div className={buttonClassName}>
176
176
  {form.export && form.export.url && (
177
- <button className={styles.btn} onClick={exportTrigger}>
177
+ <button
178
+ className={styles.btn}
179
+ title="Download what this grid is showing as a file"
180
+ onClick={exportTrigger}
181
+ >
178
182
  <ExternalLink
179
183
  strokeWidth={2}
180
184
  size={16}
@@ -184,7 +188,11 @@ const DataGridSearch = ({
184
188
  </button>
185
189
  )}
186
190
  {form.sort && form.sort.title && (
187
- <button className={styles.btn} onClick={sortTrigger}>
191
+ <button
192
+ className={styles.btn}
193
+ title="Choose the order the rows are listed in"
194
+ onClick={sortTrigger}
195
+ >
188
196
  <ArrowUpDown
189
197
  strokeWidth={2}
190
198
  size={16}
@@ -196,6 +204,7 @@ const DataGridSearch = ({
196
204
  {form.bulkUpload && form.bulkUpload.enabled && (
197
205
  <button
198
206
  className={styles.btn}
207
+ title="Add many records at once from a spreadsheet"
199
208
  onClick={(e) => {
200
209
  e.preventDefault();
201
210
  if (onBulkUploadClick) {
@@ -218,6 +227,10 @@ const DataGridSearch = ({
218
227
  (form.url && form.create?.title !== '') ? (
219
228
  <button
220
229
  className={styles.btn}
230
+ title={
231
+ form.create?.tooltip ||
232
+ 'Create a new record and add it to this list'
233
+ }
221
234
  onClick={(e) => {
222
235
  e.preventDefault();
223
236
 
@@ -259,6 +272,7 @@ const DataGridSearch = ({
259
272
  ) : form.url && form.modal === false ? (
260
273
  <button
261
274
  className={styles.btn}
275
+ title="Create a new record and add it to this list"
262
276
  onClick={(e) => {
263
277
  e.preventDefault();
264
278
 
@@ -118,6 +118,7 @@ const DataGridSortSheet = ({
118
118
  <span className={styles.sortSheetDirections}>
119
119
  <button
120
120
  type="button"
121
+ title={`List the rows by ${option.label}, smallest or earliest first`}
121
122
  aria-label={`Sort by ${option.label}, ascending`}
122
123
  aria-pressed={active && sort === 'asc'}
123
124
  className={
@@ -131,6 +132,7 @@ const DataGridSortSheet = ({
131
132
  </button>
132
133
  <button
133
134
  type="button"
135
+ title={`List the rows by ${option.label}, largest or latest first`}
134
136
  aria-label={`Sort by ${option.label}, descending`}
135
137
  aria-pressed={active && sort === 'desc'}
136
138
  className={