@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.
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 +220 -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
@@ -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
- const canSend = body.trim() !== '' && !overLimit && !sending && Boolean(threadId);
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
- * While selecting, the action bar stands where the composer does.
994
- * Both at once would be a pane with two footers and two primary
995
- * buttons, and picking messages out of a conversation is not
996
- * something anybody does halfway through writing a reply.
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
- {selecting ? (
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. Scoped to body.tablet-mode,
1585
- the same signal the package already styles against in global-datagrid.css.
1586
- The grid's tablet rows are 52px, so 44px still clears the row edges. */
1587
- :global(body.tablet-mode) {
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;