@visns-studio/visns-components 6.24.3 → 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.
- package/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +220 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- package/src/index.js +32 -0
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* badges={{ tickets: 4 }} -> "4", "Tickets, 4 open"
|
|
13
13
|
* badges={{ tickets: { count: 4, label: 'unread' } }}
|
|
14
14
|
* -> "4", "Tickets, 4 unread"
|
|
15
|
+
* badges={{ tickets: { count: 4, tone: 'danger' } }}
|
|
16
|
+
* -> "4", on crimson
|
|
15
17
|
*
|
|
16
18
|
* The object form exists because the pill itself is only a number and the
|
|
17
19
|
* accessible name has to say what the number IS — a screen reader announcing
|
|
@@ -23,6 +25,46 @@
|
|
|
23
25
|
/** The noun the accessible name falls back to. See the note above. */
|
|
24
26
|
export const DEFAULT_BADGE_NOUN = 'open';
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The severities a badge can wear, ORDERED — mildest first, worst last.
|
|
30
|
+
*
|
|
31
|
+
* The order is the point of the array rather than incidental to it:
|
|
32
|
+
* `navBadge()` rolls a group up by index, so a fourth step later means putting
|
|
33
|
+
* a name in the right place here and changing nothing else. They are named as
|
|
34
|
+
* severities and not as colours ('danger', never 'red') because the stylesheet
|
|
35
|
+
* owns what each one looks like, and a project that recolours the bar must not
|
|
36
|
+
* have to restate its data to do it.
|
|
37
|
+
*
|
|
38
|
+
* A tone says how BAD the number is; it never says what the number counts —
|
|
39
|
+
* the count and its noun already do that. It is deliberately absent from the
|
|
40
|
+
* accessible name: see badgeAriaLabel.
|
|
41
|
+
*/
|
|
42
|
+
export const BADGE_TONES = ['ok', 'warn', 'danger'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The tone a badge has when the consumer published none.
|
|
46
|
+
*
|
|
47
|
+
* EVERY badge carries a tone, the plain-number form included, so the pill has
|
|
48
|
+
* one shape to render and no caller has to guard for a missing field — the
|
|
49
|
+
* same bargain `display` is on, and for the same reason it had to be made
|
|
50
|
+
* twice before it stuck.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_BADGE_TONE = 'ok';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The published tone, or the default.
|
|
56
|
+
*
|
|
57
|
+
* Anything unrecognised falls back rather than passing through: the value ends
|
|
58
|
+
* up in a CSS-module class name, and a typo'd tone would resolve to no class
|
|
59
|
+
* at all — a pill with no ground and no ink, which is a worse failure than
|
|
60
|
+
* merely being the wrong colour.
|
|
61
|
+
*/
|
|
62
|
+
const toneOf = (raw) => (BADGE_TONES.includes(raw) ? raw : DEFAULT_BADGE_TONE);
|
|
63
|
+
|
|
64
|
+
/** The worse of two tones, by their position in BADGE_TONES. */
|
|
65
|
+
const worseTone = (a, b) =>
|
|
66
|
+
BADGE_TONES.indexOf(a) >= BADGE_TONES.indexOf(b) ? a : b;
|
|
67
|
+
|
|
26
68
|
/**
|
|
27
69
|
* Above this the pill reads `99+`.
|
|
28
70
|
*
|
|
@@ -65,6 +107,9 @@ const normalise = (raw) => {
|
|
|
65
107
|
// off `count` while the pill printed an undefined `display`.
|
|
66
108
|
display: badgeDisplay(whole),
|
|
67
109
|
noun: (isObject && raw.label) || DEFAULT_BADGE_NOUN,
|
|
110
|
+
// Only the object form can carry one — a bare number is a count and
|
|
111
|
+
// says nothing about severity, so it is always the default.
|
|
112
|
+
tone: toneOf(isObject ? raw.tone : undefined),
|
|
68
113
|
};
|
|
69
114
|
};
|
|
70
115
|
|
|
@@ -98,7 +143,7 @@ export function badgeFor(badges, id) {
|
|
|
98
143
|
* reader cannot act on — because the row it belongs to is not in their menu —
|
|
99
144
|
* is worse than no number: they would go looking for work that is not theirs.
|
|
100
145
|
*
|
|
101
|
-
* @returns {{count: number, display: string, noun: string}|null}
|
|
146
|
+
* @returns {{count: number, display: string, noun: string, tone: string}|null}
|
|
102
147
|
*/
|
|
103
148
|
export function navBadge(badges, nav) {
|
|
104
149
|
if (!badges || !nav) {
|
|
@@ -128,6 +173,15 @@ export function navBadge(badges, nav) {
|
|
|
128
173
|
// a group whose children count different nouns cannot be summarised
|
|
129
174
|
// in one word anyway, and in practice one child carries the badge.
|
|
130
175
|
noun: parts[0].noun,
|
|
176
|
+
// The WORST tone under it, not the first one and not an average. The
|
|
177
|
+
// parent is a closed dropdown: the reader cannot see which child is on
|
|
178
|
+
// fire, so the only useful thing the heading can tell them is that one
|
|
179
|
+
// of them is. Rolling up the mildest — or the first contributor's, as
|
|
180
|
+
// the noun does — would hide the one row that needed opening.
|
|
181
|
+
tone: parts.reduce(
|
|
182
|
+
(worst, part) => worseTone(worst, part.tone),
|
|
183
|
+
DEFAULT_BADGE_TONE
|
|
184
|
+
),
|
|
131
185
|
};
|
|
132
186
|
}
|
|
133
187
|
|
|
@@ -140,6 +194,14 @@ export function navBadge(badges, nav) {
|
|
|
140
194
|
*
|
|
141
195
|
* The REAL count, not the capped display: "Tickets, 340 open" is useful and
|
|
142
196
|
* "Tickets, 99+ open" is a rendering artefact.
|
|
197
|
+
*
|
|
198
|
+
* The TONE is not spoken, and that is a decision rather than an omission.
|
|
199
|
+
* There is no honest word for it: 'danger' is a class name, and "Tickets, 4
|
|
200
|
+
* open, danger" is speech nobody wrote. A consumer that has a real word for
|
|
201
|
+
* why its badge is red already has somewhere to put it — `label`, which is the
|
|
202
|
+
* noun this name is built from, so `{ count: 4, label: 'over a week', tone:
|
|
203
|
+
* 'danger' }` reads "Tickets, 4 over a week" and the colour is the same fact
|
|
204
|
+
* for people who can see it.
|
|
143
205
|
*/
|
|
144
206
|
export function badgeAriaLabel(label, badge) {
|
|
145
207
|
if (!badge || !label) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mobile drawer's rows, derived from the navigation tree the bar draws.
|
|
3
|
+
*
|
|
4
|
+
* Split out of Navigation.jsx for the same reason navActive.js and
|
|
5
|
+
* navBadges.js were: what the drawer shows is a handful of judgement calls,
|
|
6
|
+
* every one of them testable without a DOM, a router or a stylesheet.
|
|
7
|
+
* Navigation.jsx renders what this file decides and decides nothing itself.
|
|
8
|
+
*
|
|
9
|
+
* THE ONE IDEA HERE IS THAT THE DRAWER IS FLAT. The header bar hangs a group's
|
|
10
|
+
* pages off a dropdown, which is a hover affordance wearing a keyboard escape
|
|
11
|
+
* hatch — on a phone there is no hover, the group heading has no page of its
|
|
12
|
+
* own to navigate to, and a menu that only opens on a gesture the device does
|
|
13
|
+
* not have is a menu nobody can reach. So a group becomes a heading followed
|
|
14
|
+
* by its children as ordinary rows: everything the reader may open is visible
|
|
15
|
+
* the moment the drawer is, and nothing in the drawer is a disclosure control.
|
|
16
|
+
*
|
|
17
|
+
* The cost is length — the drawer is as tall as the whole menu — and that is
|
|
18
|
+
* the trade being made deliberately. A scroll is cheaper than a tap that does
|
|
19
|
+
* nothing.
|
|
20
|
+
*
|
|
21
|
+
* VISIBILITY IS NOT RE-DECIDED HERE. `permission`, `hidden` and the group
|
|
22
|
+
* cascade are all computed by Navigation's own effect and arrive on the nodes;
|
|
23
|
+
* this only reads them, with exactly the tests the two rendering paths in
|
|
24
|
+
* Navigation.jsx already use, so a drawer can never show a page the bar would
|
|
25
|
+
* have withheld.
|
|
26
|
+
*/
|
|
27
|
+
// The extension is not decoration: this module is imported directly by
|
|
28
|
+
// `node --test`, which resolves ESM specifiers to the letter, and Vite
|
|
29
|
+
// resolves an explicit extension just as happily as a bare one.
|
|
30
|
+
import { isNavActive, bestMatchingUrl, cleanUrl } from './navActive.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The test the header applies to a TOP-LEVEL entry.
|
|
34
|
+
*
|
|
35
|
+
* `permissionKey === ''` is a deliberate second door: an entry that names no
|
|
36
|
+
* permission at all is public, and the effect that stamps `permission` may not
|
|
37
|
+
* have run on the very first render. Both are copied verbatim from the bar's
|
|
38
|
+
* own filter rather than restated in a tidier form — the two must agree, and
|
|
39
|
+
* the cheapest way to guarantee that is for them to be the same expression.
|
|
40
|
+
*/
|
|
41
|
+
const showsInBar = (nav) =>
|
|
42
|
+
!!nav &&
|
|
43
|
+
nav.hidden !== true &&
|
|
44
|
+
(nav.permission === true || nav.permissionKey === '');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The test the header applies to a DROPDOWN child, which is stricter: a child
|
|
48
|
+
* has to have been granted, not merely not withheld (see `renderChildren`).
|
|
49
|
+
*/
|
|
50
|
+
const showsInDropdown = (child) =>
|
|
51
|
+
!!child && child.hidden !== true && child.permission === true;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} DrawerRow
|
|
55
|
+
* @property {'link'|'heading'} kind a heading is inert — see below.
|
|
56
|
+
* @property {string} id the nav id, for the badge lookup.
|
|
57
|
+
* @property {string} label what the row reads as.
|
|
58
|
+
* @property {string} [url] links only.
|
|
59
|
+
* @property {string} [target] links only; passed through untouched.
|
|
60
|
+
* @property {number} depth 0 top level, 1 inside a group.
|
|
61
|
+
* @property {boolean} active this is the page being viewed.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Flatten a navigation tree into drawer rows.
|
|
66
|
+
*
|
|
67
|
+
* Four shapes go in and three come out:
|
|
68
|
+
*
|
|
69
|
+
* - a leaf (url, no children) -> one link row.
|
|
70
|
+
* - a group (children, no url) -> a HEADING row, then its children.
|
|
71
|
+
* - both (url AND children) -> a LINK row, then its children. The
|
|
72
|
+
* parent has a page of its own and the drawer is the one surface where
|
|
73
|
+
* that page is otherwise unreachable, since there is no dropdown to open
|
|
74
|
+
* and no hover to open it with.
|
|
75
|
+
* - neither (no url, no children) -> a heading with nothing under it,
|
|
76
|
+
* which is exactly what the bar draws for it: an inert <span>. Rendering
|
|
77
|
+
* nothing instead would be a quieter lie about the config.
|
|
78
|
+
*
|
|
79
|
+
* ACTIVE IS PER ROW, NOT PER BRANCH. A parent that owns children is matched on
|
|
80
|
+
* its OWN url only (`isNavActive(url, [], …)`, no child urls) — in the bar it
|
|
81
|
+
* takes the highlight for anything underneath it because the children are
|
|
82
|
+
* hidden inside a closed menu, but here the child row is right there and
|
|
83
|
+
* lighting both would say the reader is standing in two places at once.
|
|
84
|
+
* Children use `bestMatchingUrl` across their siblings, the same rule and the
|
|
85
|
+
* same call the dropdown uses, so `/reports` and `/reports/prebuilt` cannot
|
|
86
|
+
* both claim the page.
|
|
87
|
+
*
|
|
88
|
+
* @param {Array} navigations Navigation's `navData.navigations`.
|
|
89
|
+
* @param {string} currentPage the path being viewed, with or without its
|
|
90
|
+
* leading slash (Navigation passes it without).
|
|
91
|
+
* @returns {DrawerRow[]}
|
|
92
|
+
*/
|
|
93
|
+
export function drawerRows(navigations, currentPage) {
|
|
94
|
+
const rows = [];
|
|
95
|
+
|
|
96
|
+
(Array.isArray(navigations) ? navigations : []).forEach((nav) => {
|
|
97
|
+
if (!showsInBar(nav)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const children = (nav.children || []).filter(showsInDropdown);
|
|
102
|
+
// Computed once per group rather than once per child: it is a scan of
|
|
103
|
+
// every sibling url, and it answers the same question for all of them.
|
|
104
|
+
const bestChild = children.length
|
|
105
|
+
? bestMatchingUrl(
|
|
106
|
+
children.map((child) => child.url),
|
|
107
|
+
currentPage
|
|
108
|
+
)
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
if (nav.url) {
|
|
112
|
+
rows.push({
|
|
113
|
+
kind: 'link',
|
|
114
|
+
id: nav.id,
|
|
115
|
+
label: nav.label,
|
|
116
|
+
url: nav.url,
|
|
117
|
+
target: nav.target,
|
|
118
|
+
depth: 0,
|
|
119
|
+
active: isNavActive(nav.url, [], currentPage),
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
rows.push({
|
|
123
|
+
kind: 'heading',
|
|
124
|
+
id: nav.id,
|
|
125
|
+
label: nav.label,
|
|
126
|
+
depth: 0,
|
|
127
|
+
active: false,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
children.forEach((child) => {
|
|
132
|
+
rows.push({
|
|
133
|
+
kind: 'link',
|
|
134
|
+
id: child.id,
|
|
135
|
+
label: child.label,
|
|
136
|
+
url: child.url,
|
|
137
|
+
target: child.target,
|
|
138
|
+
depth: 1,
|
|
139
|
+
active: bestChild !== null && cleanUrl(child.url) === bestChild,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default drawerRows;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
Loader2,
|
|
19
19
|
MessageSquarePlus,
|
|
20
20
|
Send,
|
|
21
|
+
ShieldCheck,
|
|
21
22
|
Unplug,
|
|
22
23
|
User,
|
|
23
24
|
X,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
resolveAnnotations,
|
|
48
49
|
segmentCount,
|
|
49
50
|
statusLabel,
|
|
51
|
+
threadCanReply,
|
|
50
52
|
threadDisplayName,
|
|
51
53
|
threadSubtitle,
|
|
52
54
|
upsertMessage,
|
|
@@ -383,7 +385,14 @@ const SmsThreadPanel = ({
|
|
|
383
385
|
|
|
384
386
|
const counts = segmentCount(body);
|
|
385
387
|
const overLimit = counts.segments > MAX_SEGMENTS;
|
|
386
|
-
|
|
388
|
+
|
|
389
|
+
// A sender-ID thread (`Apple`, `ANZ`, a short code) receives and can never
|
|
390
|
+
// answer. The server refuses the send with a 422 of its own; this is what
|
|
391
|
+
// stops anybody getting that far, and it gates `canSend` as well as the
|
|
392
|
+
// footer so a keyboard shortcut cannot slip past the missing button.
|
|
393
|
+
const canReply = threadCanReply(thread);
|
|
394
|
+
const canSend =
|
|
395
|
+
canReply && body.trim() !== '' && !overLimit && !sending && Boolean(threadId);
|
|
387
396
|
|
|
388
397
|
const send = useCallback(
|
|
389
398
|
(text) => {
|
|
@@ -990,12 +999,31 @@ const SmsThreadPanel = ({
|
|
|
990
999
|
</div>
|
|
991
1000
|
|
|
992
1001
|
{/*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
1002
|
+
* One footer, three things it can be.
|
|
1003
|
+
*
|
|
1004
|
+
* Receive-only wins outright: the compose box is REPLACED rather
|
|
1005
|
+
* than disabled, because a dead Send button beside a live textarea
|
|
1006
|
+
* reads as something being broken, where a sentence says why there
|
|
1007
|
+
* is nothing to press — the only thing a reader wants to know when
|
|
1008
|
+
* a two-factor code has just landed in front of them.
|
|
1009
|
+
*
|
|
1010
|
+
* Otherwise, while selecting, the action bar stands where the
|
|
1011
|
+
* composer does. Both at once would be a pane with two footers and
|
|
1012
|
+
* two primary buttons, and picking messages out of a conversation
|
|
1013
|
+
* is not something anybody does halfway through writing a reply.
|
|
997
1014
|
*/}
|
|
998
|
-
{
|
|
1015
|
+
{!canReply ? (
|
|
1016
|
+
<div className={styles.receiveOnly}>
|
|
1017
|
+
<div className={styles.banner}>
|
|
1018
|
+
<ShieldCheck size={15} strokeWidth={2} aria-hidden="true" />
|
|
1019
|
+
<span>
|
|
1020
|
+
<strong>{threadDisplayName(thread)}</strong> is a sender ID,
|
|
1021
|
+
not a phone number — messages from it are one-way and
|
|
1022
|
+
cannot be replied to.
|
|
1023
|
+
</span>
|
|
1024
|
+
</div>
|
|
1025
|
+
</div>
|
|
1026
|
+
) : selecting ? (
|
|
999
1027
|
<div className={styles.selectBar}>
|
|
1000
1028
|
<span className={styles.selectCount}>
|
|
1001
1029
|
{selectedIds.length === 0
|
|
@@ -645,6 +645,21 @@ export const threadDisplayName = (thread) => {
|
|
|
645
645
|
);
|
|
646
646
|
};
|
|
647
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Can this thread be answered, or does it only ever receive?
|
|
650
|
+
*
|
|
651
|
+
* A thread opened by an inbound message from an alphanumeric sender ID -
|
|
652
|
+
* `Apple` for a two-factor code, `ANZ`, a courier's short code - has no handset
|
|
653
|
+
* behind it. The SERVER decides this and sends `can_reply`; deriving it here
|
|
654
|
+
* from the shape of `external_number` would be a second definition of the rule,
|
|
655
|
+
* and the derived one is always the one that goes wrong.
|
|
656
|
+
*
|
|
657
|
+
* `undefined` means an older backend that predates the field, and that is read
|
|
658
|
+
* as repliable: a missing key must not silently take the compose box away from
|
|
659
|
+
* every ordinary conversation.
|
|
660
|
+
*/
|
|
661
|
+
export const threadCanReply = (thread) => thread?.can_reply !== false;
|
|
662
|
+
|
|
648
663
|
/** The number under the name, only when it is not already the name. */
|
|
649
664
|
export const threadSubtitle = (thread) => {
|
|
650
665
|
if (!thread) return '';
|
|
@@ -242,6 +242,45 @@
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Route-mounted screen (`ClientAuthScreen`)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// The split layout below is deliberately sized by its parent — that is what
|
|
249
|
+
// makes it embeddable in a page that keeps its own header and footer. A router
|
|
250
|
+
// route element has no such parent: GenericAuth renders the client sign-in
|
|
251
|
+
// straight into the app's mount node, which is a plain auto-height block, so
|
|
252
|
+
// `height: 100%` resolved against nothing and the whole two-pane screen sat in
|
|
253
|
+
// a band at the top of the page with the page background below it.
|
|
254
|
+
//
|
|
255
|
+
// The staff screens never showed this because `.auth` is `position: fixed;
|
|
256
|
+
// inset: 0` — it takes the viewport regardless of where it is mounted. This is
|
|
257
|
+
// the same guarantee expressed as flow layout rather than as a fixed overlay,
|
|
258
|
+
// so the screen still scrolls if its contents outgrow the viewport.
|
|
259
|
+
//
|
|
260
|
+
// Only GenericAuth's client auth routes wrap themselves in this. A consumer
|
|
261
|
+
// embedding ClientAuth inside its own chrome gets the section, unchanged.
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
.screen {
|
|
265
|
+
display: flex;
|
|
266
|
+
flex-direction: column;
|
|
267
|
+
width: 100%;
|
|
268
|
+
// 100vh first for anything without dynamic viewport units; browsers that
|
|
269
|
+
// understand dvh take the second and stop the mobile URL bar cropping the
|
|
270
|
+
// bottom of the form.
|
|
271
|
+
min-height: 100vh;
|
|
272
|
+
min-height: 100dvh;
|
|
273
|
+
|
|
274
|
+
// The card layout carries its own `min-height: 100vh` for the case where it
|
|
275
|
+
// is mounted bare. Inside the screen that is one viewport height too many —
|
|
276
|
+
// on a phone the screen is 100dvh and the card 100vh, and the difference is
|
|
277
|
+
// a scrollbar over nothing. Let the flex column give it the height instead.
|
|
278
|
+
> .container {
|
|
279
|
+
flex: 1 1 auto;
|
|
280
|
+
min-height: 0;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
245
284
|
// ---------------------------------------------------------------------------
|
|
246
285
|
// Split layout (`layout="split"`)
|
|
247
286
|
// ---------------------------------------------------------------------------
|
|
@@ -177,10 +177,22 @@
|
|
|
177
177
|
z-index: 1000;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/* The wide dialog: 90% of the glass, never past 1400px, and never narrower
|
|
181
|
+
than 600px — except that a 600px floor on a 390px phone is not a floor, it
|
|
182
|
+
is a 210px overflow with the dialog's right-hand edge and its close button
|
|
183
|
+
off the screen. `min-width` outranks `width` and `max-width` both, so the
|
|
184
|
+
flat 600 won every time it was wrong.
|
|
185
|
+
*
|
|
186
|
+
* `min(600px, 90vw)` keeps the floor doing its job on anything wide enough to
|
|
187
|
+
* honour it and hands the decision back to `width` on anything narrower — and
|
|
188
|
+
* 90vw is not a new number, it is the width this rule already asks for, so the
|
|
189
|
+
* floor can never outrank the line above it again. Restated verbatim in
|
|
190
|
+
* GenericIndex.module.scss and GenericDetail.module.scss; the three are one
|
|
191
|
+
* dialog wearing three hashed class names. */
|
|
180
192
|
.modalWide {
|
|
181
193
|
width: 90vw;
|
|
182
194
|
max-width: 1400px;
|
|
183
|
-
min-width: 600px;
|
|
195
|
+
min-width: min(600px, 90vw);
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
.modal {
|
|
@@ -1581,10 +1593,26 @@
|
|
|
1581
1593
|
}
|
|
1582
1594
|
|
|
1583
1595
|
/* Tablet mode is a bench at arm's length worked with gloves on, so the
|
|
1584
|
-
controls step up to the 44px tap-target floor.
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1596
|
+
controls step up to the 44px tap-target floor. The grid's tablet rows are
|
|
1597
|
+
52px, so 44px still clears the row edges.
|
|
1598
|
+
|
|
1599
|
+
Written once as a mixin and emitted twice, because there are now TWO signals
|
|
1600
|
+
that mean "a finger is driving this" and they must not be allowed to drift:
|
|
1601
|
+
|
|
1602
|
+
body.tablet-mode the explicit per-view opt-in, the signal the package
|
|
1603
|
+
already styles against in global-datagrid.css. It now
|
|
1604
|
+
means "treat this as touch even on a mouse".
|
|
1605
|
+
(pointer: coarse) the primary pointing device is a finger. Nobody has to
|
|
1606
|
+
set it and no view config can forget it, which is the
|
|
1607
|
+
whole point — the opt-in only ever reached the handful
|
|
1608
|
+
of views whose JSON carried it, and every phone opening
|
|
1609
|
+
every other grid got 32px targets.
|
|
1610
|
+
|
|
1611
|
+
`pointer` reports the PRIMARY device: a touchscreen laptop answers `fine`
|
|
1612
|
+
and is unaffected. `utils/useDensity.js` ORs the same two signals for the
|
|
1613
|
+
geometry the grid has to compute in JS; these two blocks and that hook are
|
|
1614
|
+
one decision expressed in the only two places it can be. */
|
|
1615
|
+
@mixin touch-tree-controls {
|
|
1588
1616
|
.treeControlBtn {
|
|
1589
1617
|
width: var(--grid-tree-control-size-tablet, 44px);
|
|
1590
1618
|
height: var(--grid-tree-control-size-tablet, 44px);
|
|
@@ -1613,6 +1641,14 @@
|
|
|
1613
1641
|
}
|
|
1614
1642
|
}
|
|
1615
1643
|
|
|
1644
|
+
:global(body.tablet-mode) {
|
|
1645
|
+
@include touch-tree-controls;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
@media (pointer: coarse) {
|
|
1649
|
+
@include touch-tree-controls;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1616
1652
|
/* =========================================================================
|
|
1617
1653
|
treeGroupBy nesting: accent bar and member indent
|
|
1618
1654
|
|
|
@@ -2141,6 +2177,32 @@
|
|
|
2141
2177
|
cursor: default;
|
|
2142
2178
|
}
|
|
2143
2179
|
|
|
2180
|
+
/* The pager, under a finger.
|
|
2181
|
+
*
|
|
2182
|
+
* 28px is a mouse target. The first and last page arrows sit side by side with
|
|
2183
|
+
* about 4px between them, and on a phone the thumb covers both — which is why
|
|
2184
|
+
* paging a grid on a phone lands on the wrong page roughly as often as the
|
|
2185
|
+
* right one. 44px is the floor, and it is affordable here because the bar
|
|
2186
|
+
* already wraps (`flex-wrap: wrap`, above): the buttons take a second line
|
|
2187
|
+
* rather than pushing the record count off the side.
|
|
2188
|
+
*
|
|
2189
|
+
* The row is the only part of the grid's own chrome that needed this. The
|
|
2190
|
+
* COLUMN FILTER ROW could not be given the same treatment from here — Inovua
|
|
2191
|
+
* measures its header height in JS from the theme's 32px and a 44px input
|
|
2192
|
+
* inside it is clipped, not accommodated — so the filters step up only as far
|
|
2193
|
+
* as the cell the theme already reserves for them (global-datagrid.css). */
|
|
2194
|
+
@media (pointer: coarse) {
|
|
2195
|
+
.dataGridBottomContainer {
|
|
2196
|
+
gap: 0.5rem;
|
|
2197
|
+
padding: 0.5rem;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
.dataGridBottomContainer button {
|
|
2201
|
+
min-width: 44px;
|
|
2202
|
+
height: 44px;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2144
2206
|
// -- empty state -------------------------------------------------------------
|
|
2145
2207
|
|
|
2146
2208
|
.noDataMessage {
|
|
@@ -2152,3 +2214,94 @@
|
|
|
2152
2214
|
color: var(--dg-muted);
|
|
2153
2215
|
font-size: 0.875rem;
|
|
2154
2216
|
}
|
|
2217
|
+
|
|
2218
|
+
/* ==========================================================================
|
|
2219
|
+
CARD MODE — the two controls that only exist below 640px
|
|
2220
|
+
--------------------------------------------------------------------------
|
|
2221
|
+
Sort, and the Cards/Table toggle. Icon squares rather than labelled buttons
|
|
2222
|
+
because they share a 340px row with Add New, where three labelled buttons
|
|
2223
|
+
wrap onto two lines.
|
|
2224
|
+
|
|
2225
|
+
`--control-height` is the same token the search field and Add New beside
|
|
2226
|
+
them resolve, so the row keeps one height; it is 2.75rem under a coarse
|
|
2227
|
+
pointer, which is also the 44px minimum a finger needs. There is no separate
|
|
2228
|
+
touch rule here because there is nothing to correct.
|
|
2229
|
+
========================================================================== */
|
|
2230
|
+
.cardControl {
|
|
2231
|
+
display: inline-flex;
|
|
2232
|
+
align-items: center;
|
|
2233
|
+
justify-content: center;
|
|
2234
|
+
width: var(--control-height, 34px);
|
|
2235
|
+
height: var(--control-height, 34px);
|
|
2236
|
+
padding: 0;
|
|
2237
|
+
color: var(--paragraph-color, #443c2b);
|
|
2238
|
+
background: var(--surface-color, #fff);
|
|
2239
|
+
border: 1px solid var(--dg-line, rgba(43, 43, 43, 0.14));
|
|
2240
|
+
border-radius: var(--btn-br, var(--radius-sm, 4px));
|
|
2241
|
+
cursor: pointer;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/* The sort sheet's list. One row per sortable field, both directions drawn —
|
|
2245
|
+
a single button that toggles makes the reader press it to find out which
|
|
2246
|
+
way it is about to go. */
|
|
2247
|
+
.sortSheet {
|
|
2248
|
+
display: flex;
|
|
2249
|
+
flex-direction: column;
|
|
2250
|
+
padding: var(--spacing-sm, 0.5rem) 0;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
.sortSheetEmpty {
|
|
2254
|
+
margin: 0;
|
|
2255
|
+
padding: var(--spacing-md, 1rem);
|
|
2256
|
+
color: var(--muted-color, #6b7280);
|
|
2257
|
+
font-size: var(--font-size-sm, 0.8125rem);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
.sortSheetRow {
|
|
2261
|
+
display: flex;
|
|
2262
|
+
gap: var(--spacing-sm, 0.5rem);
|
|
2263
|
+
align-items: center;
|
|
2264
|
+
justify-content: space-between;
|
|
2265
|
+
min-height: 2.75rem;
|
|
2266
|
+
padding: 0 var(--spacing-md, 1rem);
|
|
2267
|
+
border-bottom: 1px solid var(--border-color, #e1e1e1);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
.sortSheetLabel {
|
|
2271
|
+
display: inline-flex;
|
|
2272
|
+
gap: 0.375rem;
|
|
2273
|
+
align-items: center;
|
|
2274
|
+
overflow: hidden;
|
|
2275
|
+
min-width: 0;
|
|
2276
|
+
color: var(--paragraph-color, #443c2b);
|
|
2277
|
+
font-size: var(--font-size-sm, 0.8125rem);
|
|
2278
|
+
white-space: nowrap;
|
|
2279
|
+
text-overflow: ellipsis;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
.sortSheetDirections {
|
|
2283
|
+
display: inline-flex;
|
|
2284
|
+
flex: 0 0 auto;
|
|
2285
|
+
gap: 0.25rem;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
.sortSheetDirection {
|
|
2289
|
+
display: inline-flex;
|
|
2290
|
+
align-items: center;
|
|
2291
|
+
justify-content: center;
|
|
2292
|
+
/* 2.75rem, not the control height: this is a bare icon in a list on a
|
|
2293
|
+
touch screen, and it has nothing beside it to borrow a hit area from. */
|
|
2294
|
+
width: 2.75rem;
|
|
2295
|
+
height: 2.75rem;
|
|
2296
|
+
padding: 0;
|
|
2297
|
+
color: var(--muted-color, #6b7280);
|
|
2298
|
+
background: none;
|
|
2299
|
+
border: 0;
|
|
2300
|
+
border-radius: var(--radius-sm, 4px);
|
|
2301
|
+
cursor: pointer;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
.sortSheetDirectionOn {
|
|
2305
|
+
color: var(--primary-color, #1b3933);
|
|
2306
|
+
background: var(--primary-color-lighter, rgba(27, 57, 51, 0.08));
|
|
2307
|
+
}
|
|
@@ -124,8 +124,14 @@
|
|
|
124
124
|
min-height: var(--field-height, 50px);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/* The same four-track grid Form.module.scss draws, and the same container
|
|
128
|
+
declaration, so a form assembled out of THIS module's classes collapses the
|
|
129
|
+
way one assembled out of Form's does. See the long note over
|
|
130
|
+
Form.module.scss's `.formcontainer` for why the query asks the panel rather
|
|
131
|
+
than the window, and for the one consequence of `container-type`. */
|
|
127
132
|
.formcontainer {
|
|
128
133
|
width: 100%;
|
|
134
|
+
container-type: inline-size;
|
|
129
135
|
|
|
130
136
|
form {
|
|
131
137
|
width: 100%;
|
|
@@ -309,6 +315,43 @@
|
|
|
309
315
|
width: 100%;
|
|
310
316
|
}
|
|
311
317
|
|
|
318
|
+
/* ----------------------------------------------------------------------------
|
|
319
|
+
* The field sizes, when the panel runs out of room.
|
|
320
|
+
*
|
|
321
|
+
* These are the OTHER HALF of the collapse declared over `.formcontainer` in
|
|
322
|
+
* Form.module.scss: that file narrows the track list, this one re-states what
|
|
323
|
+
* a "half" and a "quarter" mean against the narrower list. They have to be two
|
|
324
|
+
* files because CSS modules hash class names per module and Field.jsx writes
|
|
325
|
+
* these two classes while Form.jsx owns the container. Keep the breakpoints in
|
|
326
|
+
* step; there is no way to share them.
|
|
327
|
+
*
|
|
328
|
+
* The queries are unnamed, so each resolves against the nearest ancestor
|
|
329
|
+
* container — `.formcontainer`, whichever module declared it (Form's,
|
|
330
|
+
* GenericDynamic's, or this file's). A form with no container ancestor at all
|
|
331
|
+
* simply keeps the four tracks, which is the pre-collapse behaviour and a safe
|
|
332
|
+
* floor rather than a broken one.
|
|
333
|
+
*
|
|
334
|
+
* `grid-column: 1 / -1` and NOT `span 2` at the one-track step: `span 2` on a
|
|
335
|
+
* single-column grid does not clamp, it CREATES a second implicit column — the
|
|
336
|
+
* row would silently go back to two-up at exactly the width where two-up stops
|
|
337
|
+
* fitting.
|
|
338
|
+
* --------------------------------------------------------------------------*/
|
|
339
|
+
|
|
340
|
+
/* Two tracks: a half is the whole row, a quarter is half of it. */
|
|
341
|
+
@container (max-width: 768px) {
|
|
342
|
+
.halfItem {
|
|
343
|
+
grid-column: 1 / -1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* One track: everything is the row. */
|
|
348
|
+
@container (max-width: 640px) {
|
|
349
|
+
.halfItem,
|
|
350
|
+
.qtrItem {
|
|
351
|
+
grid-column: 1 / -1;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
312
355
|
input:not(:placeholder-shown) + .fi__span,
|
|
313
356
|
textarea:not(:placeholder-shown) + .fi__span,
|
|
314
357
|
select:not(:placeholder-shown) + .fi__span {
|
|
@@ -1333,7 +1376,15 @@ input[type='file']:hover {
|
|
|
1333
1376
|
grid-template-columns: auto 1fr auto; /* Consistent grid for images */
|
|
1334
1377
|
}
|
|
1335
1378
|
|
|
1336
|
-
/* Responsive Design
|
|
1379
|
+
/* Responsive Design — the file-upload field's own previews.
|
|
1380
|
+
*
|
|
1381
|
+
* Deliberately still VIEWPORT queries while the grid collapse above is a
|
|
1382
|
+
* container query, and the two are not in conflict: the grid asks how wide its
|
|
1383
|
+
* panel is because that is what decides how many fields fit beside each other,
|
|
1384
|
+
* and these ask how big the SCREEN is because a thumbnail is sized against the
|
|
1385
|
+
* reader's eye and their thumb, not against the panel it happens to sit in.
|
|
1386
|
+
* The 768 here and the 768 above therefore agree by coincidence rather than by
|
|
1387
|
+
* contract; changing one does not oblige the other. */
|
|
1337
1388
|
@media (max-width: 768px) {
|
|
1338
1389
|
.image-preview {
|
|
1339
1390
|
max-width: 150px;
|