@visns-studio/visns-components 6.24.0 → 6.24.2

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 CHANGED
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "name": "@visns-studio/visns-components",
96
- "version": "6.24.0",
96
+ "version": "6.24.2",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -223,7 +223,7 @@ function VisnsAsyncSelect({
223
223
  minHeight:
224
224
  style && style.multi_select_height
225
225
  ? style.multi_select_height
226
- : 'var(--field-height, 51px)',
226
+ : 'var(--field-height, 50px)',
227
227
  height: 'auto', // Allow height to grow with content
228
228
  }),
229
229
  valueContainer: (base) => ({
@@ -350,7 +350,7 @@ function MultiSelect({
350
350
  ? style.multi_select_height
351
351
  : isTouchDevice()
352
352
  ? '44px'
353
- : 'var(--field-height, 51px)', // Apple's recommended minimum touch target size
353
+ : 'var(--field-height, 50px)', // one token, same default as every native control
354
354
  height: 'auto', // Allow height to grow with content
355
355
  }),
356
356
  valueContainer: (base) => ({
@@ -26,12 +26,14 @@ import {
26
26
  Lock,
27
27
  MessageSquare,
28
28
  MessagesSquare,
29
+ TrendingUp,
29
30
  } from 'lucide-react';
30
31
  import CustomFetch from './Fetch';
31
32
  import Notification from './Notification';
32
33
  import SwitchAccount from './SwitchAccount';
33
34
  import BusinessCardOcr from './BusinessCardOcr';
34
35
  import { isNavActive, bestMatchingUrl, cleanUrl } from './navActive';
36
+ import { navBadge, badgeFor, badgeAriaLabel } from './navBadges';
35
37
  import styles from './styles/Navigation.module.scss';
36
38
 
37
39
  // Opt-in icons for the sidebar (simple/cms) layouts: a nav entry names one
@@ -51,6 +53,10 @@ const NAV_ICONS = {
51
53
  phone: Phone,
52
54
  reports: BarChart3,
53
55
  safety: Shield,
56
+ // A pipeline group — deals, subscriptions, agreements. `billing` is the
57
+ // card and means "money already owed"; this one is money still being
58
+ // chased, which is a different heading in every nav that has both.
59
+ sales: TrendingUp,
54
60
  settings: Settings,
55
61
  sites: MapPin,
56
62
  users: Users,
@@ -117,6 +123,24 @@ const actionLabel = (n) => {
117
123
  .replace(/^./, (c) => c.toUpperCase());
118
124
  };
119
125
 
126
+ /**
127
+ * The count pill on a navigation item.
128
+ *
129
+ * `aria-hidden` on purpose: the number is already in the item's accessible
130
+ * name (see badgeAriaLabel), and announcing it twice — once as the label,
131
+ * once as a stray "4" after it — is exactly the double-reading that makes
132
+ * badged navigation unpleasant with a screen reader on.
133
+ *
134
+ * Renders nothing without a badge, so every caller can hand it whatever
135
+ * navBadge() returned without guarding first.
136
+ */
137
+ const BadgePill = ({ badge }) =>
138
+ badge ? (
139
+ <span className={styles['nav-badge']} aria-hidden="true">
140
+ {badge.display}
141
+ </span>
142
+ ) : null;
143
+
120
144
  const SearchComponent = ({
121
145
  navigate,
122
146
  search,
@@ -299,6 +323,20 @@ function Navigation({
299
323
  * does not pass it.
300
324
  */
301
325
  renderers = {},
326
+ /*
327
+ * Live counts for navigation items: `{ [navId]: number }`, or the object
328
+ * form `{ [navId]: { count, label } }` when the accessible name needs a
329
+ * noun other than "open". See navBadges.js, which owns every rule about
330
+ * what renders and what does not.
331
+ *
332
+ * Empty by default and additive: an id with no entry, a zero, or a value
333
+ * that will not parse draws nothing at all, so a consumer that publishes
334
+ * nothing gets exactly the bar it had before. The map is READ here and
335
+ * never fetched — where the numbers come from, how often they refresh and
336
+ * whether this user is allowed to see them are the app's business, and a
337
+ * shared bar has no way to know any of it.
338
+ */
339
+ badges = {},
302
340
  }) {
303
341
  const [search, setSearch] = useState('');
304
342
  const [searchResultShow, setSearchResultShow] = useState(false);
@@ -429,6 +467,13 @@ function Navigation({
429
467
  const IconComponent = iconComponents[n.icon];
430
468
  const label = actionLabel(n);
431
469
 
470
+ // The same badge contract the nav items honour, keyed by the
471
+ // setting's id — a workload count reads best in this chip row,
472
+ // beside the bell that already wears one. The pill stays
473
+ // aria-hidden; the count joins the accessible name instead,
474
+ // exactly as on a nav item (see BadgePill).
475
+ const settingBadge = badgeFor(badges, n.id);
476
+
432
477
  return (
433
478
  <li>
434
479
  <Link
@@ -436,13 +481,14 @@ function Navigation({
436
481
  data-tooltip-id="system-tooltip"
437
482
  data-tooltip-place="bottom"
438
483
  data-tooltip-content={label}
439
- aria-label={label}
484
+ aria-label={badgeAriaLabel(label, settingBadge)}
440
485
  className={`${activeClass}`}
441
486
  target={n.target ? n.target : '_self'}
442
487
  >
443
488
  {IconComponent && (
444
489
  <IconComponent size={19} strokeWidth={2} />
445
490
  )}
491
+ <BadgePill badge={settingBadge} />
446
492
  </Link>
447
493
  </li>
448
494
  );
@@ -604,37 +650,65 @@ function Navigation({
604
650
  if (hasChildren) {
605
651
  return (
606
652
  <ul className={styles.navDropdown}>
607
- {n.children.map(
608
- (child, childKey) =>
609
- child.hidden !== true &&
610
- child.permission === true && (
611
- <li key={`nav-child-${childKey}`}>
612
- <Link
613
- to={child.url}
614
- className={
615
- cleanUrl(child.url) ===
616
- bestMatchingUrl(
617
- n.children.map(
618
- (one) => one.url
619
- ),
620
- currentPage
621
- )
622
- ? styles.active
623
- : ''
624
- }
625
- onClick={() => setOpen(false)}
626
- >
627
- {child.label}
628
- </Link>
629
- </li>
630
- )
631
- )}
653
+ {n.children.map((child, childKey) => {
654
+ if (
655
+ child.hidden === true ||
656
+ child.permission !== true
657
+ ) {
658
+ return null;
659
+ }
660
+
661
+ const childBadge = badgeFor(badges, child.id);
662
+
663
+ return (
664
+ <li key={`nav-child-${childKey}`}>
665
+ <Link
666
+ to={child.url}
667
+ aria-label={badgeAriaLabel(
668
+ child.label,
669
+ childBadge
670
+ )}
671
+ className={
672
+ cleanUrl(child.url) ===
673
+ bestMatchingUrl(
674
+ n.children.map(
675
+ (one) => one.url
676
+ ),
677
+ currentPage
678
+ )
679
+ ? styles.active
680
+ : ''
681
+ }
682
+ onClick={() => setOpen(false)}
683
+ >
684
+ {child.label}
685
+ <BadgePill badge={childBadge} />
686
+ </Link>
687
+ </li>
688
+ );
689
+ })}
632
690
  </ul>
633
691
  );
634
692
  }
635
693
  return null;
636
694
  };
637
695
 
696
+ /*
697
+ * The item's own count, plus its visible children's — a number
698
+ * published against a row inside a closed dropdown would otherwise be
699
+ * invisible until somebody hovered the group. navBadges.js holds the
700
+ * rule; null when there is nothing to show, which is the usual case.
701
+ */
702
+ const itemBadge = navBadge(badges, n);
703
+ const ariaLabel = badgeAriaLabel(n.label, itemBadge);
704
+ const contents = (
705
+ <>
706
+ {NavItemIcon ? <NavItemIcon size={18} /> : null}
707
+ {n.label}
708
+ <BadgePill badge={itemBadge} />
709
+ </>
710
+ );
711
+
638
712
  return (
639
713
  <li
640
714
  className={navItemClasses}
@@ -644,12 +718,51 @@ function Navigation({
644
718
  {n.url ? (
645
719
  // Close the mobile drawer when navigating; no-op for the
646
720
  // header layout where the drawer state is never opened.
647
- <Link to={n.url} onClick={() => setOpen(false)}>
648
- {NavItemIcon ? <NavItemIcon size={18} /> : null}
649
- {n.label}
721
+ <Link
722
+ to={n.url}
723
+ aria-label={ariaLabel}
724
+ onClick={() => setOpen(false)}
725
+ >
726
+ {contents}
650
727
  </Link>
728
+ ) : hasChildren ? (
729
+ /*
730
+ * A group heading — no page of its own, so there is
731
+ * nothing to link to, but it still has to be REACHABLE.
732
+ * As a <span> it was not focusable and the menu opened on
733
+ * :hover alone, which left every page inside a group
734
+ * unreachable by keyboard and untappable on a touch
735
+ * screen. A button takes focus, and the stylesheet opens
736
+ * the dropdown on :focus-within as well as :hover.
737
+ *
738
+ * No onClick and no aria-expanded: the open state lives
739
+ * in CSS, not in React (deliberately — see the discarded
740
+ * `activeDropdown` setter above, which exists only to
741
+ * toggle the scroll container's overflow), and claiming a
742
+ * state this component does not hold would be a lie to
743
+ * the screen reader rather than a help.
744
+ */
745
+ <button
746
+ type="button"
747
+ aria-haspopup="true"
748
+ aria-label={ariaLabel}
749
+ /* The menu opens on :hover OR :focus-within. A MOUSE
750
+ click leaves the button focused, so the menu it
751
+ opened stayed pinned while hovering the next group
752
+ opened that one too — two dropdowns at once. Blur
753
+ on pointer clicks only: e.detail is the click
754
+ count, 0 for keyboard "clicks", so Tab + Enter
755
+ users keep the focus that holds their menu open. */
756
+ onClick={(e) => {
757
+ if (e.detail > 0) {
758
+ e.currentTarget.blur();
759
+ }
760
+ }}
761
+ >
762
+ {contents}
763
+ </button>
651
764
  ) : (
652
- <span>{n.label}</span>
765
+ <span aria-label={ariaLabel}>{contents}</span>
653
766
  )}
654
767
  {renderChildren()}
655
768
  </li>
@@ -719,10 +832,16 @@ function Navigation({
719
832
  // says, and this state has to be reversible — switching a feature
720
833
  // back on must restore the menu item without a page reload, which
721
834
  // dropping it from the list would not.
835
+ //
836
+ // `hide: true` in the navigation config is the STATIC spelling of the
837
+ // same state — a page an app keeps routable but out of the menu (the
838
+ // same flag <Form> honours on a field). Deleting the entry would work
839
+ // too, but then the config could no longer say why it is absent.
722
840
  const isSwitchedOff = (nav) =>
723
- featureFlags !== null &&
724
- Boolean(nav.id) &&
725
- featureFlags[nav.id] === false;
841
+ nav.hide === true ||
842
+ (featureFlags !== null &&
843
+ Boolean(nav.id) &&
844
+ featureFlags[nav.id] === false);
726
845
 
727
846
  setNavData((prevNavData) => {
728
847
  const updatePermissions = (nav) => {
@@ -743,7 +862,24 @@ function Navigation({
743
862
  const updatedChildren = nav.children
744
863
  .map(updatePermissions)
745
864
  .filter(Boolean);
746
- const childPermission = updatedChildren.length > 0;
865
+ /*
866
+ * A group with nothing in it that this user may open is
867
+ * not a group, it is a dead heading — so the parent goes
868
+ * with its children.
869
+ *
870
+ * This was `updatedChildren.length > 0`, which is a test
871
+ * of whether the config declared any children at all:
872
+ * `updatePermissions` always returns an object, so the
873
+ * `.filter(Boolean)` above it never removes one and the
874
+ * length is a constant read off the JSON. Every group
875
+ * parent therefore showed for everybody, opening onto an
876
+ * empty menu — which is precisely what the cascade was
877
+ * written to prevent.
878
+ */
879
+ const childPermission = updatedChildren.some(
880
+ (child) =>
881
+ child.permission === true && child.hidden !== true
882
+ );
747
883
 
748
884
  if (nav.permissionKey) {
749
885
  const matchingPermission = permissions.includes(
@@ -1329,6 +1329,40 @@ export const renderFileColumn = ({
1329
1329
  handleDownload,
1330
1330
  styles,
1331
1331
  }) => {
1332
+ // `styles.tdaction` belongs on the WRAPPER, not on the glyph.
1333
+ //
1334
+ // The class is a 28px hit target — `display: inline-flex; width: 28px;
1335
+ // height: 28px` in DataGrid.module.scss, written so a row action is
1336
+ // something a finger can land on. Put it on an `<svg>` and those two
1337
+ // lengths do not draw a box around the icon, they ARE the icon: CSS
1338
+ // `width`/`height` outrank the `width="14"` / `height="14"` presentation
1339
+ // attributes lucide renders from `size`, so the declared 14px glyph was
1340
+ // painted at 28 — measured, not guessed — twice the size of the 13px row
1341
+ // text it sits in and twice the 18px the action column's own icons use.
1342
+ //
1343
+ // The action column never had the problem because its icons live inside
1344
+ // `.tdactions`, where `.tdactions span svg { width: 18px }` is the more
1345
+ // specific rule and wins. This column renders its icon on its own, so the
1346
+ // bare `.tdaction` was the only rule reaching it.
1347
+ //
1348
+ // Moving the class one level out is what the actions column already does:
1349
+ // the span becomes the 28px target (and picks up the hover wash and focus
1350
+ // ring that were being painted on the glyph), and the icon is free to be
1351
+ // the size it asks for. The span keeps its 10px right margin, so a row of
1352
+ // several files occupies exactly the width it did before.
1353
+ const icon = (tooltip) => (
1354
+ <Download
1355
+ data-tooltip-id="system-tooltip"
1356
+ data-tooltip-content={tooltip}
1357
+ strokeWidth={2}
1358
+ size={16}
1359
+ />
1360
+ );
1361
+
1362
+ // `inline-flex` comes from the class; declaring `display` here as well
1363
+ // would beat it from the style attribute and un-centre the glyph.
1364
+ const wrapperStyle = { marginRight: '10px' };
1365
+
1332
1366
  return {
1333
1367
  ...commonProps,
1334
1368
  name: column.id,
@@ -1341,48 +1375,30 @@ export const renderFileColumn = ({
1341
1375
  return data[column.id].map((file, index) => (
1342
1376
  <span
1343
1377
  key={index}
1378
+ className={styles.tdaction}
1344
1379
  onClick={(e) => {
1345
1380
  e.stopPropagation();
1346
1381
  e.preventDefault();
1347
1382
  handleDownload(file);
1348
1383
  }}
1349
- style={{
1350
- marginRight: '10px',
1351
- display: 'inline-block',
1352
- }}
1384
+ style={wrapperStyle}
1353
1385
  >
1354
- <Download
1355
- data-tooltip-id="system-tooltip"
1356
- data-tooltip-content={`Download File ${file.file_name}`}
1357
- strokeWidth={2}
1358
- size={14}
1359
- className={styles.tdaction}
1360
- />
1386
+ {icon(`Download File ${file.file_name}`)}
1361
1387
  </span>
1362
1388
  ));
1363
1389
  } else {
1364
1390
  // Return a single span for a single file with added margin
1365
1391
  return (
1366
1392
  <span
1393
+ className={styles.tdaction}
1367
1394
  onClick={(e) => {
1368
1395
  e.stopPropagation();
1369
1396
  e.preventDefault();
1370
1397
  handleDownload(data[column.id]);
1371
1398
  }}
1372
- style={{
1373
- marginRight: '10px',
1374
- display: 'inline-block',
1375
- }}
1399
+ style={wrapperStyle}
1376
1400
  >
1377
- <Download
1378
- data-tooltip-id="system-tooltip"
1379
- data-tooltip-content={`Download File ${
1380
- data[column.id].file_name
1381
- }`}
1382
- strokeWidth={2}
1383
- size={14}
1384
- className={styles.tdaction}
1385
- />
1401
+ {icon(`Download File ${data[column.id].file_name}`)}
1386
1402
  </span>
1387
1403
  );
1388
1404
  }
@@ -189,6 +189,68 @@ export const openedByGesture = (
189
189
  return !!node && !!node.contains && node.contains(gesture.target);
190
190
  };
191
191
 
192
+ /**
193
+ * Breathing room left between a widened menu and the edge that would clip it.
194
+ */
195
+ const MENU_GUTTER_PX = 8;
196
+
197
+ /**
198
+ * How much room the option menu has to grow into before the grid clips it, in
199
+ * pixels — written onto the combo as `--vs-filter-menu-avail` for
200
+ * `global-datagrid.css` to cap the menu's `max-width` with.
201
+ *
202
+ * The menu is a direct child of the combo, absolutely positioned, and the
203
+ * nearest ancestor that clips is `.InovuaReactDataGrid__body` (`overflow:
204
+ * hidden`, so the grid can virtualise). The stylesheet now widens the menu to
205
+ * fit its longest option instead of ellipsising it into the column's width —
206
+ * but CSS cannot ask "how far is it from this column to the right edge of the
207
+ * grid?", so a menu on a right-hand column would grow straight into that clip
208
+ * and come out sliced. This measures the answer.
209
+ *
210
+ * Purely presentational: it sets one custom property and touches nothing else.
211
+ * The option list, the value and every event are untouched, and if this never
212
+ * runs the stylesheet's own 420px / 90vw caps still apply.
213
+ *
214
+ * Measured on the COMBO, not the menu, and before the menu mounts, so the
215
+ * first painted frame is already the right width. Recomputed on every open, so
216
+ * a resized grid or a dragged column is picked up with nothing to invalidate.
217
+ */
218
+ export const measureMenuRoom = (node) => {
219
+ if (!node || typeof node.getBoundingClientRect !== 'function') {
220
+ return null;
221
+ }
222
+
223
+ const clip =
224
+ typeof node.closest === 'function'
225
+ ? node.closest('.InovuaReactDataGrid__body') ||
226
+ node.closest('.InovuaReactDataGrid')
227
+ : null;
228
+
229
+ const right = clip
230
+ ? clip.getBoundingClientRect().right
231
+ : typeof window !== 'undefined'
232
+ ? window.innerWidth
233
+ : 0;
234
+
235
+ const box = node.getBoundingClientRect();
236
+
237
+ // Never narrower than the field itself: a combo scrolled half out of view
238
+ // would otherwise be told it has no room at all. The stylesheet's
239
+ // `min-width` would fight that and win, but an honest floor is cheaper
240
+ // than relying on which cap happens to be stronger.
241
+ return Math.max(right - box.left - MENU_GUTTER_PX, box.width);
242
+ };
243
+
244
+ const applyMenuRoom = (node) => {
245
+ const room = measureMenuRoom(node);
246
+
247
+ if (room == null || !node || !node.style) {
248
+ return;
249
+ }
250
+
251
+ node.style.setProperty('--vs-filter-menu-avail', `${Math.round(room)}px`);
252
+ };
253
+
192
254
  /**
193
255
  * "Is something actually filtered here?" — `null` is the `select` filter
194
256
  * type's emptyValue, `''` is what DataGrid's dependent-filter reset writes,
@@ -360,6 +422,23 @@ const SelectFilterEditor = forwardRef((props, ref) => {
360
422
  editorProps.onExpandedChange(expanded);
361
423
  }
362
424
 
425
+ // Unconditional, and before the clear gate: every open gets
426
+ // a menu sized to the room it has, whether or not this
427
+ // particular open also clears the filter.
428
+ if (expanded) {
429
+ const instance = innerRef.current;
430
+ const combo =
431
+ instance &&
432
+ instance.getInputRef &&
433
+ instance.getInputRef();
434
+
435
+ applyMenuRoom(
436
+ combo && combo.getComboNode
437
+ ? combo.getComboNode()
438
+ : null
439
+ );
440
+ }
441
+
363
442
  clearRef.current(expanded);
364
443
  },
365
444
  }}
@@ -112,6 +112,14 @@ const GenericAuth = ({
112
112
  // Extra props handed to whatever `mainComponent` is, for a custom shell
113
113
  // that needs something GenericMain does not take.
114
114
  mainComponentProps = {},
115
+ // Live counts for navigation items, `{ tickets: 4 }`, handed down to
116
+ // Navigation through the shell. First-class rather than something to bury
117
+ // in `mainComponentProps` because it travels the same route `userProfile`
118
+ // does and belongs to the same interface — and an app whose counts are
119
+ // computed inside its own shell (the usual case, since the shell is where
120
+ // the websocket lives) simply passes its own `navBadges` from there
121
+ // instead, which lands after this one and wins.
122
+ navBadges = {},
115
123
  }) => {
116
124
  const navigate = useNavigate();
117
125
  const location = useLocation();
@@ -404,6 +412,7 @@ const GenericAuth = ({
404
412
  userProfile={userProfile}
405
413
  enforce2FA={enforce2FA}
406
414
  density={density}
415
+ navBadges={navBadges}
407
416
  {...mainComponentProps}
408
417
  />
409
418
  }
@@ -1101,7 +1101,9 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
1101
1101
  </label>
1102
1102
  <select
1103
1103
  name="year"
1104
- style={{ height: '51px' }}
1104
+ style={{
1105
+ height: 'var(--field-height, 50px)',
1106
+ }}
1105
1107
  value={
1106
1108
  filters[widget.id]?.[
1107
1109
  filter.id
@@ -38,6 +38,11 @@ const GenericMain = ({
38
38
  // Passed straight to Navigation: per-id renderers for account actions.
39
39
  // See the `renderers` note in Navigation.jsx.
40
40
  navRenderers = {},
41
+ // Passed straight to Navigation as `badges`: live counts keyed by nav id,
42
+ // `{ tickets: 4 }`. Threaded rather than fetched here for the same reason
43
+ // `navRenderers` is — the shell does not know what a project counts, who
44
+ // is allowed to see it, or how often it changes. See navBadges.js.
45
+ navBadges = {},
41
46
  }) => {
42
47
  const navigate = useNavigate();
43
48
  const location = useLocation();
@@ -151,6 +156,7 @@ const GenericMain = ({
151
156
  themeType={themeType}
152
157
  userProfile={userProfile}
153
158
  renderers={navRenderers}
159
+ badges={navBadges}
154
160
  />
155
161
  </header>
156
162
  {navigation.hasOwnProperty('quickActions') && (
@@ -182,6 +188,7 @@ const GenericMain = ({
182
188
  themeType={themeType}
183
189
  userProfile={userProfile}
184
190
  renderers={navRenderers}
191
+ badges={navBadges}
185
192
  >
186
193
  <div className={styles['content-main']} id="app">
187
194
  <div className={styles.paddingSimple}>
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The count pill a navigation item wears.
3
+ *
4
+ * Split out of Navigation.jsx for the same reason navActive.js was: the rule
5
+ * is a handful of judgement calls — what counts as "no badge", what a group
6
+ * parent shows, where the cap falls — and every one of them is testable
7
+ * without a DOM, a router or a stylesheet. Navigation.jsx renders what this
8
+ * file decides and decides nothing itself.
9
+ *
10
+ * The consumer publishes `badges` on <Navigation> as `{ [navId]: number }`:
11
+ *
12
+ * badges={{ tickets: 4 }} -> "4", "Tickets, 4 open"
13
+ * badges={{ tickets: { count: 4, label: 'unread' } }}
14
+ * -> "4", "Tickets, 4 unread"
15
+ *
16
+ * The object form exists because the pill itself is only a number and the
17
+ * accessible name has to say what the number IS — a screen reader announcing
18
+ * "Tickets, 4" has told the user nothing. `open` is the default noun because
19
+ * that is what a count on a work queue almost always means; anything else
20
+ * says so.
21
+ */
22
+
23
+ /** The noun the accessible name falls back to. See the note above. */
24
+ export const DEFAULT_BADGE_NOUN = 'open';
25
+
26
+ /**
27
+ * Above this the pill reads `99+`.
28
+ *
29
+ * Not a style choice: the bar is a fixed-height row of items and a four-digit
30
+ * pill pushes the label it belongs to out of the bar. The ACCESSIBLE name
31
+ * still carries the real figure — a reader that cannot see the pill has no
32
+ * reason to be given a rounded one.
33
+ */
34
+ export const BADGE_MAX = 99;
35
+
36
+ /**
37
+ * One badge value, whatever shape the consumer published it in.
38
+ *
39
+ * Everything that is not a positive, finite number is "no badge" and returns
40
+ * null — 0, undefined, null, NaN, a string that will not parse, a negative.
41
+ * That is deliberate and it is the common case: a count endpoint that has not
42
+ * answered yet, or has answered zero, must render nothing at all rather than
43
+ * a "0" pill announcing that there is nothing to do.
44
+ */
45
+ const normalise = (raw) => {
46
+ if (raw === null || raw === undefined) {
47
+ return null;
48
+ }
49
+
50
+ const isObject = typeof raw === 'object';
51
+ const count = Number(isObject ? raw.count : raw);
52
+
53
+ if (!Number.isFinite(count) || count <= 0) {
54
+ return null;
55
+ }
56
+
57
+ const whole = Math.floor(count);
58
+
59
+ return {
60
+ count: whole,
61
+ // EVERY badge carries what the pill prints, not only the summed one
62
+ // navBadge() builds. The settings chips feed BadgePill straight from
63
+ // badgeFor(), and the first one shipped without this line rendered a
64
+ // green circle with no number in it — the aria-label read "1 open"
65
+ // off `count` while the pill printed an undefined `display`.
66
+ display: badgeDisplay(whole),
67
+ noun: (isObject && raw.label) || DEFAULT_BADGE_NOUN,
68
+ };
69
+ };
70
+
71
+ /** What the pill prints: the count, or `99+` once it runs past the cap. */
72
+ export const badgeDisplay = (count) =>
73
+ count > BADGE_MAX ? `${BADGE_MAX}+` : String(count);
74
+
75
+ /**
76
+ * The badge for one id, or null.
77
+ *
78
+ * @param {object} badges the `{ [navId]: number }` map, or nothing.
79
+ * @param {string} id the nav entry's id.
80
+ */
81
+ export function badgeFor(badges, id) {
82
+ if (!badges || !id) {
83
+ return null;
84
+ }
85
+
86
+ return normalise(badges[id]);
87
+ }
88
+
89
+ /**
90
+ * The badge a nav ITEM shows: its own count plus its visible children's.
91
+ *
92
+ * A group parent has no page of its own, so the only honest thing for it to
93
+ * show is the sum of what is behind it — otherwise a count published against
94
+ * a child inside a closed dropdown is invisible until somebody happens to
95
+ * hover the parent, which defeats the point of a badge.
96
+ *
97
+ * Children that are hidden or not permitted are NOT counted. A number the
98
+ * reader cannot act on — because the row it belongs to is not in their menu —
99
+ * is worse than no number: they would go looking for work that is not theirs.
100
+ *
101
+ * @returns {{count: number, display: string, noun: string}|null}
102
+ */
103
+ export function navBadge(badges, nav) {
104
+ if (!badges || !nav) {
105
+ return null;
106
+ }
107
+
108
+ const found = [badgeFor(badges, nav.id)];
109
+
110
+ (nav.children || []).forEach((child) => {
111
+ if (child && child.hidden !== true && child.permission === true) {
112
+ found.push(badgeFor(badges, child.id));
113
+ }
114
+ });
115
+
116
+ const parts = found.filter(Boolean);
117
+
118
+ if (parts.length === 0) {
119
+ return null;
120
+ }
121
+
122
+ const count = parts.reduce((sum, part) => sum + part.count, 0);
123
+
124
+ return {
125
+ count,
126
+ display: badgeDisplay(count),
127
+ // The parent borrows the noun of the first thing that contributed;
128
+ // a group whose children count different nouns cannot be summarised
129
+ // in one word anyway, and in practice one child carries the badge.
130
+ noun: parts[0].noun,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * The item's accessible name, with the count folded into it.
136
+ *
137
+ * `undefined` when there is no badge, so the caller can spread it onto the
138
+ * element and leave the natural text as the name — an aria-label that merely
139
+ * repeats the visible label is noise a screen reader has to read anyway.
140
+ *
141
+ * The REAL count, not the capped display: "Tickets, 340 open" is useful and
142
+ * "Tickets, 99+ open" is a rendering artefact.
143
+ */
144
+ export function badgeAriaLabel(label, badge) {
145
+ if (!badge || !label) {
146
+ return undefined;
147
+ }
148
+
149
+ return `${label}, ${badge.count} ${badge.noun}`;
150
+ }
151
+
152
+ export default navBadge;
@@ -36,10 +36,15 @@
36
36
  box-shadow: none !important;
37
37
  }
38
38
 
39
- /* Ensure consistent height between select and button */
39
+ /* Ensure consistent height between select and button.
40
+
41
+ `3.1rem` (49.6px) was a guess at "typical input height" and it beat the
42
+ token rule further down, which is (0,10,1) to this selector's (0,3,1) — so
43
+ a dropdown with a create button beside it was the one select in the library
44
+ that did NOT follow `--field-height`. */
40
45
  .dropdownContainer:has(.buttonRight) .selectFullWidth select {
41
- height: auto;
42
- min-height: 3.1rem; /* Match typical input height */
46
+ height: var(--field-height, 50px);
47
+ min-height: var(--field-height, 50px);
43
48
  }
44
49
 
45
50
  .buttonRight {
@@ -47,7 +52,9 @@
47
52
  align-items: center;
48
53
  justify-content: center;
49
54
  min-width: 42px;
50
- min-height: 3.1rem;
55
+ /* The button is the right-hand half of the field it is joined to, so it
56
+ takes the field's height, not its own. */
57
+ min-height: var(--field-height, 50px);
51
58
  background: rgba(var(--primary-color-rgb, 27, 57, 51), 0.08);
52
59
  color: var(--primary-color);
53
60
  border: 1px solid rgba(var(--primary-color-rgb, 27, 57, 51), 0.25);
@@ -113,8 +120,8 @@
113
120
  }
114
121
 
115
122
  .dropdownContainer.hasButton .selectFullWidth select {
116
- height: auto;
117
- min-height: 3.1rem;
123
+ height: var(--field-height, 50px);
124
+ min-height: var(--field-height, 50px);
118
125
  }
119
126
 
120
127
  .formcontainer {
@@ -382,7 +389,15 @@ select:not(:placeholder-shown) + .fi__span {
382
389
  font-size: 0.85rem;
383
390
  background: white;
384
391
  border-radius: var(--br);
385
- padding: 0.85rem;
392
+ /* Was `padding: 0.85rem`, and that one declaration is what made a form
393
+ row uneven. This selector list carries three specificities: `.formItem
394
+ select` and `.formItem textarea` are (0,1,1) and BEAT the bare `select`
395
+ / `textarea` in global.css, while `.formItem input[type]:not()…` is
396
+ (0,10,1) and LOSES to the twenty-clause chain there. So the same rule
397
+ set the select's padding and not the input's — 0.85rem on one, 1rem on
398
+ the other, 42.78px beside 50px. Padding is horizontal only now, the box
399
+ is `--field-height`, and both files state the same thing. */
400
+ padding: 0 1rem;
386
401
  outline: none;
387
402
  width: 100%;
388
403
  box-sizing: border-box;
@@ -390,6 +405,23 @@ select:not(:placeholder-shown) + .fi__span {
390
405
  transition: box-shadow 0.15s linear;
391
406
  }
392
407
 
408
+ /* Everything above except the textarea is a single-line control and takes the
409
+ one height. Stated here as well as in global.css because of the specificity
410
+ split described above: without it the `select` in a form would keep sizing
411
+ itself off its own line box. */
412
+ .formItem select,
413
+ .formItem
414
+ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
415
+ [type='checkbox']
416
+ ):not([type='radio']):not(.inovua-react-toolkit-combo-box__input):not(
417
+ .inovua-react-toolkit-date-input__input
418
+ ):not(.inovua-react-toolkit-text-input__input):not(
419
+ .inovua-react-toolkit-numeric-input__input
420
+ ) {
421
+ height: var(--field-height, 50px);
422
+ min-height: var(--field-height, 50px);
423
+ }
424
+
393
425
  .formItem textarea:hover,
394
426
  .formItem select:hover,
395
427
  .formItem
@@ -415,6 +447,13 @@ select:not(:placeholder-shown) + .fi__span {
415
447
 
416
448
  .formItem textarea {
417
449
  line-height: 1.65 !important;
450
+ /* The one control that keeps vertical padding — but the amount is derived
451
+ from the same token, so its first row sits on the same line as the
452
+ single-line fields beside it instead of at an unrelated 0.85rem. The
453
+ literal is the fallback for browsers without the `lh` unit. */
454
+ height: auto;
455
+ padding: 0.85rem 1rem;
456
+ padding: calc((var(--field-height, 50px) - 1lh) / 2 - 1px) 1rem;
418
457
  }
419
458
 
420
459
  input[type='file'] {
@@ -93,7 +93,7 @@
93
93
 
94
94
  // Control styles
95
95
  .visns-select__control {
96
- min-height: var(--field-height, 51px) !important;
96
+ min-height: var(--field-height, 50px) !important;
97
97
  height: auto !important; /* Allow height to grow with content */
98
98
  overflow: visible !important;
99
99
  }
@@ -199,7 +199,7 @@
199
199
  // CSS class fixes for React Select
200
200
  .css-13cymwt-control,
201
201
  .css-t3ipsp-control {
202
- min-height: var(--field-height, 51px) !important;
202
+ min-height: var(--field-height, 50px) !important;
203
203
  height: auto !important;
204
204
  overflow: visible !important;
205
205
  }
@@ -429,7 +429,14 @@
429
429
  }
430
430
  }
431
431
 
432
- &:hover > ul {
432
+ /* `:focus-within` matches the item while the parent itself holds
433
+ focus, which is what opens the menu for a keyboard user: tab to
434
+ the group heading, the rows appear, tab again and focus moves
435
+ into them — and the menu stays open because focus is still
436
+ inside the item. Without this the dropdown was hover-only and
437
+ every page inside a group was unreachable without a mouse. */
438
+ &:hover > ul,
439
+ &:focus-within > ul {
433
440
  display: block;
434
441
  opacity: 1;
435
442
  }
@@ -553,7 +560,9 @@
553
560
  }
554
561
  }
555
562
 
556
- &:hover > ul {
563
+ /* Same keyboard path as `.nav-item` above. */
564
+ &:hover > ul,
565
+ &:focus-within > ul {
557
566
  display: block;
558
567
  opacity: 1;
559
568
  }
@@ -561,6 +570,65 @@
561
570
  }
562
571
  }
563
572
 
573
+ /* ----------------------------------------------------------------------------
574
+ Count pills
575
+ ----------------------------------------------------------------------------
576
+ `<Navigation badges={{ tickets: 4 }} />`. The pill is inline — part of the
577
+ item's own flex row rather than a floating dot pinned to a corner — because
578
+ these items are text labels of wildly different widths, and an absolutely
579
+ positioned badge on "Support Blocks" lands somewhere quite different from
580
+ one on "Zoom". Sitting in the row, it simply follows the label everywhere,
581
+ dropdown rows and the sidebar included.
582
+
583
+ The colour carries a fallback for the same reason Notification's badge
584
+ does: a `var()` with no fallback on a token the host has not declared is
585
+ invalid at computed-value time and the WHOLE declaration is discarded, so
586
+ the pill would render its white text on nothing at all — and the pill is
587
+ the only place the count appears.
588
+ ------------------------------------------------------------------------- */
589
+ .nav-badge {
590
+ /* Round at any single-digit width, and a lozenge once it reads `99+`.
591
+ SIZED TO MATCH the bell's and the SMS badge (16px box, 10px digit) —
592
+ "two unread counts side by side have to sit on the same line as each
593
+ other", per Sms.module.scss, and a 14px pill beside their 16px read as
594
+ an error rather than a choice. */
595
+ min-width: 16px;
596
+ height: 16px;
597
+ padding: 0 4px;
598
+ box-sizing: border-box;
599
+ display: inline-flex;
600
+ align-items: center;
601
+ justify-content: center;
602
+ flex: none;
603
+ border-radius: var(--radius-pill, 999px);
604
+ background-color: var(--secondary-color, #3cbf7d);
605
+ /* DARK ink, not white. The other badges are white-on-crimson; this one's
606
+ ground is the brand GREEN, and a 10px white digit on #3cbf7d has so
607
+ little contrast it reads as a solid dot — which is exactly how it was
608
+ reported: "a green circle with no number". */
609
+ color: var(--primary-color, #1b3933);
610
+ /* Never inheriting the semibold the active item puts on its label — a
611
+ badge that grows when you are standing on the page it counts reads as
612
+ a change in the number. */
613
+ font-size: 10px;
614
+ font-weight: 700;
615
+ line-height: 1;
616
+ font-variant-numeric: tabular-nums;
617
+ }
618
+
619
+ /* In a dropdown row the label is a block-level link, so the pill is pushed to
620
+ the row's trailing edge rather than butted against the text. Both themed
621
+ selectors are spelled out to match the specificity of the blocks above —
622
+ anything shorter loses to the `display: block` they already set on these
623
+ links and the rule would simply never apply. */
624
+ .app-nav .nav-item > ul li > a,
625
+ .app-nav .nav-item-alternate > ul li > a {
626
+ display: flex;
627
+ align-items: center;
628
+ justify-content: space-between;
629
+ gap: 0.5rem;
630
+ }
631
+
564
632
  .hactions {
565
633
  width: max-content;
566
634
  justify-self: end;
@@ -572,6 +640,23 @@
572
640
  padding-left: var(--spacing-md);
573
641
  margin-left: var(--spacing-sm);
574
642
 
643
+ /* A count pill on an icon chip sits on the chip's shoulder, not inline —
644
+ these are 36px targets and an inline pill would push the icon off
645
+ centre. The anchor is the chip itself, and the pill stays INSIDE its
646
+ box: the chip row is a scroll container (see the overflow comment
647
+ below), and a scroll container clips both axes — a pill overhanging
648
+ the edge by 2px rendered as a numberless green sliver. */
649
+ > ul > li > a {
650
+ position: relative;
651
+
652
+ .nav-badge {
653
+ position: absolute;
654
+ top: 1px;
655
+ right: 1px;
656
+ margin: 0;
657
+ }
658
+ }
659
+
575
660
  > ul {
576
661
  list-style: none;
577
662
  display: flex;
@@ -1079,8 +1164,20 @@
1079
1164
  opacity: 1;
1080
1165
  }
1081
1166
 
1082
- span {
1167
+ /* The group heading — an item with children and no page
1168
+ of its own. It used to be a <span>; it is a <button>
1169
+ now so it can take focus (see Navigation.jsx), and the
1170
+ button has to keep the span's rendering exactly, hence
1171
+ the reset of the three properties a UA stylesheet would
1172
+ otherwise force on it. */
1173
+ span,
1174
+ > button {
1083
1175
  display: block;
1176
+ width: 100%;
1177
+ text-align: left;
1178
+ border: none;
1179
+ font: inherit;
1180
+ cursor: pointer;
1084
1181
  color: rgba(var(--paragraph-rgb, 43, 43, 43), 0.3);
1085
1182
  box-sizing: border-box;
1086
1183
  padding: 1.25em 0.85em;
@@ -788,15 +788,188 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
788
788
  theme selector the invisible rule uses, so it lands exactly where that rule
789
789
  lands and nowhere else.
790
790
 
791
- `--primary-color` rather than one of the `--dgx-*` tokens above: those are
792
- defined on `.InovuaReactDataGrid`, and the option list is rendered outside
793
- the grid element, where they would not resolve. */
791
+ `--primary-color` rather than one of the `--dgx-*` tokens above, because
792
+ this selector is the vendored theme's, unscoped to the grid, and so also
793
+ matches a combo box that genuinely is outside `.InovuaReactDataGrid` (the
794
+ toolkit's `relativeToViewport` overlay), where a `--dgx-*` token would not
795
+ resolve. Inside a grid filter — which is where the list actually mounts,
796
+ see the block below — either would have worked. */
794
797
  .inovua-react-toolkit-combo-box__list--theme-default-light
795
798
  .inovua-react-toolkit-combo-box__list__item--active {
796
799
  border-color: color-mix(in srgb, var(--primary-color, #1b3933) 55%, transparent) !important;
797
800
  background: color-mix(in srgb, var(--primary-color, #1b3933) 12%, transparent) !important;
798
801
  }
799
802
 
803
+ /* ==========================================================================
804
+ The dropdown filter's OPTION MENU
805
+ --------------------------------------------------------------------------
806
+ Two complaints, one rule set.
807
+
808
+ 1. The options were being cut off. "Concrete, Concrete Precast & Concrete
809
+ Pumping" arrived as "CONCRETE, CONCRETE PRECAS…" and "General Building +
810
+ Construction Management" as "GENERAL BUILDING + CONSTR…", which makes two
811
+ different trades indistinguishable in the list you are choosing from.
812
+
813
+ That is not a text-length problem, it is a width problem. The vendored
814
+ base stylesheet pins the menu to the FIELD:
815
+
816
+ .inovua-react-toolkit-combo-box__list:not(…--relative-to-viewport) {
817
+ left: -1px; right: -1px; min-width: 100%; position: absolute;
818
+ }
819
+
820
+ Both edges anchored to a ~104px column means the menu can only ever be
821
+ as wide as the column, and every item carries
822
+ `…__list__item--ellipsis { overflow:hidden; white-space:nowrap;
823
+ text-overflow:ellipsis }`, so the text is trimmed to fit. Measured in a
824
+ real grid: item scrollWidth 355px inside clientWidth 102px.
825
+
826
+ Releasing the right edge and sizing to `max-content` lets the menu grow
827
+ to its widest option; `min-width` keeps it at least as wide as the field
828
+ it drops out of, and the cap keeps a pathological option (a 300-character
829
+ description) from painting a menu across the whole screen. Options longer
830
+ than the cap still ellipsize — that is the cap doing its job, not the bug
831
+ above.
832
+
833
+ 2. The menu did not look like anything else in the library. It kept the
834
+ vendored theme's 14px text, 1px radius, hard `0 0 4px rgba(0,0,0,.4)`
835
+ shadow and indigo `rgba(121,134,203,…)` highlights, while the grid around
836
+ it is 13px `--dgx-ink` on 6-8px radii and the app's own dropdowns
837
+ (react-select, `.visns-select__menu` in global.css) are a hairline
838
+ surface with `primary 8%` on the focused option. So the values below are
839
+ lifted from those two places rather than invented: the option size sits
840
+ on the grid's own type scale, the highlights are the house primary, and
841
+ the surface is the same popover treatment.
842
+
843
+ Scoping. The menu is NOT portalled — verified in a rendered grid, its parent
844
+ chain is `…__list > .inovua-react-toolkit-combo-box.InovuaReactDataGrid__column-header__filter
845
+ > …__filter-wrapper > …__column-header > … > .InovuaReactDataGrid`. It is a
846
+ direct child of the combo, which is exactly why `>` off the grid's own
847
+ filter class is enough to reach it and nothing else: a combo box anywhere
848
+ else in the app (the pagination toolbar's page-size picker, for one) carries
849
+ no `InovuaReactDataGrid__column-header__filter` and is untouched.
850
+
851
+ Because it is inside the grid element, the `--dgx-*` tokens declared on
852
+ `.InovuaReactDataGrid` DO resolve here (measured: `--dgx-ink` computes to
853
+ rgb(43,43,43) on a node appended to the menu). They are still written with
854
+ their own fallbacks so the rules survive if a future grid ever renders the
855
+ list through the toolkit's `relativeToViewport` overlay.
856
+
857
+ Presentational only. Nothing here changes what is in the list, what a click
858
+ does, or how the filter reports its value.
859
+ ========================================================================== */
860
+
861
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
862
+ > .inovua-react-toolkit-combo-box__list {
863
+ /* The width fix. `right: auto` is the load-bearing line — while both
864
+ edges are pinned the box is stretched to the column and `width` is
865
+ ignored. */
866
+ right: auto !important;
867
+ width: max-content !important;
868
+ min-width: calc(100% + 2px) !important;
869
+
870
+ /* Three caps, whichever bites first.
871
+
872
+ `420px` is the design cap — past that a menu stops being a list and
873
+ starts being a wall of text. `90vw` is the small-screen cap.
874
+
875
+ `--vs-filter-menu-avail` is the one CSS cannot work out for itself: the
876
+ distance from this column to the right edge of the grid. The menu is
877
+ absolutely positioned INSIDE `.InovuaReactDataGrid__body`, which is
878
+ `overflow: hidden`, so a menu on a right-hand column that grew past that
879
+ edge would simply be sliced off — visibly worse than the ellipsis this
880
+ change removes. The number is measured and written onto the combo by
881
+ SelectFilterEditor as the menu opens (see the comment there); when it is
882
+ absent — a combo outside a grid, or the editor not in play — the cap
883
+ falls back to `100vw` and the other two do the work. */
884
+ max-width: min(420px, 90vw, var(--vs-filter-menu-avail, 100vw)) !important;
885
+
886
+ /* The same popover surface as `.visns-select__menu`, on the grid's radius. */
887
+ padding: 4px !important;
888
+ border: 1px solid var(--dgx-line, rgba(43, 43, 43, 0.12)) !important;
889
+ border-radius: 8px !important;
890
+ background: var(--dgx-surface, var(--background-color, #fff)) !important;
891
+ box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.28) !important;
892
+ }
893
+
894
+ /* An RTL combo grows the other way, or it would run off the field it belongs
895
+ to. The toolkit marks the direction on the combo, not on the list. */
896
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box.inovua-react-toolkit-combo-box--rtl
897
+ > .inovua-react-toolkit-combo-box__list {
898
+ right: -1px !important;
899
+ left: auto !important;
900
+ }
901
+
902
+ /* One option.
903
+
904
+ `text-transform` and `letter-spacing` are declared rather than left alone
905
+ because the column header two rows up IS uppercase-with-tracking
906
+ (`…__column-header__content`), and an option list is data, not a label — it
907
+ must not inherit that treatment from a host stylesheet. Note this cannot
908
+ un-shout an option whose VALUE is stored in capitals; that is the record,
909
+ not the styling.
910
+
911
+ `border` is deliberately absent: the theme gives every item
912
+ `border: 1px solid transparent` and the keyboard-highlight rule above
913
+ colours it, so declaring a border here would silently kill arrow-key
914
+ navigation's only visual. The radius is set so that highlight is rounded. */
915
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
916
+ > .inovua-react-toolkit-combo-box__list
917
+ .inovua-react-toolkit-combo-box__list__item {
918
+ padding: 0.375rem 0.625rem !important;
919
+ border-radius: 6px !important;
920
+ color: var(--dgx-ink, var(--paragraph-color, #2b2b2b)) !important;
921
+ font-size: 0.8125rem !important;
922
+ font-weight: 400 !important;
923
+ line-height: 1.45 !important;
924
+ letter-spacing: normal !important;
925
+ text-transform: none !important;
926
+ }
927
+
928
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
929
+ > .inovua-react-toolkit-combo-box__list
930
+ .inovua-react-toolkit-combo-box__list__item:not(.inovua-react-toolkit-combo-box__list__item--disabled):hover {
931
+ background: color-mix(in srgb, var(--primary-color, #1b3933) 8%, transparent) !important;
932
+ }
933
+
934
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
935
+ > .inovua-react-toolkit-combo-box__list
936
+ .inovua-react-toolkit-combo-box__list__item--selected {
937
+ background: color-mix(in srgb, var(--primary-color, #1b3933) 16%, transparent) !important;
938
+ font-weight: 600 !important;
939
+ }
940
+
941
+ /* Group headings and the "no options" line are the menu's own chrome, so they
942
+ take the quiet label treatment the column headers use rather than looking
943
+ like options you could pick. */
944
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
945
+ > .inovua-react-toolkit-combo-box__list
946
+ .inovua-react-toolkit-combo-box__list__group,
947
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
948
+ > .inovua-react-toolkit-combo-box__list
949
+ .inovua-react-toolkit-combo-box__list__empty-text {
950
+ padding: 0.375rem 0.625rem !important;
951
+ color: var(--dgx-muted, rgba(43, 43, 43, 0.6)) !important;
952
+ font-size: 0.6875rem !important;
953
+ font-weight: 600 !important;
954
+ letter-spacing: 0.05em !important;
955
+ text-transform: uppercase !important;
956
+ }
957
+
958
+ /* The field itself was 14px while the text, numeric and date filters beside it
959
+ are 0.75rem (the rule further up). The combo's displayed value and
960
+ placeholder are SPANS, not the `<input>` that rule reaches, which is how one
961
+ control in the filter row ended up a size larger than its neighbours. */
962
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box,
963
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
964
+ .inovua-react-toolkit-combo-box__value__display-value,
965
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
966
+ .inovua-react-toolkit-combo-box__input__placeholder,
967
+ .InovuaReactDataGrid__column-header__filter.inovua-react-toolkit-combo-box
968
+ .inovua-react-toolkit-combo-box__value__tag__label {
969
+ font-size: 0.75rem !important;
970
+ letter-spacing: normal !important;
971
+ }
972
+
800
973
  /* The native date filter is the odd one out in the row.
801
974
 
802
975
  Measured on /tickets: every other filter editor is 28px tall with a 6px
@@ -39,16 +39,6 @@ select {
39
39
  font: inherit;
40
40
  }
41
41
 
42
- /* Floor under every textarea: the browser default (`rows` omitted) is two
43
- lines, which renders a multi-line field as a slit. Three lines of text
44
- plus the field's own padding is the least a free-text box can be and
45
- still read as one. A bare element selector, so any component that sets
46
- its own height with a class still wins. */
47
- textarea {
48
- min-height: 5.5em; /* ~3 lines + padding, for browsers without lh */
49
- min-height: calc(3lh + 2rem);
50
- }
51
-
52
42
  /* Global radio button styling */
53
43
  input[type='radio'],
54
44
  .radio-input {
@@ -295,7 +285,22 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
295
285
  border-radius: var(--br);
296
286
  border: 1px solid rgba(var(--paragraph-color-rgb, 43, 43, 43), 0.15);
297
287
  box-shadow: none;
298
- padding: 1rem;
288
+ /* THE height of a form control, stated once. It used to be an accident of
289
+ `padding: 1rem` plus a 0.85rem line box — which computed to 50px on a
290
+ text input, 42.78px on a `<select>` (a CSS-module rule beat this one on
291
+ padding for the bare `select` selector but not for the twenty-clause
292
+ `input` chain), 34px on a date input and 51px on react-select, four
293
+ heights in one two-column form. Now the box is the token and the padding
294
+ is horizontal only, so every control below lands on the same number and
295
+ a consumer moves all of them together by setting `--field-height` on any
296
+ ancestor.
297
+
298
+ The 50px default is not a round number picked for looks: it is what a
299
+ plain text input measured under the old `padding: 1rem` rule, so a
300
+ project that never heard of this token renders exactly as it did. */
301
+ height: var(--field-height, 50px);
302
+ min-height: var(--field-height, 50px);
303
+ padding: 0 1rem;
299
304
  outline: none;
300
305
  box-sizing: border-box;
301
306
  color: var(--paragraph-color);
@@ -309,33 +314,58 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
309
314
  }
310
315
  }
311
316
 
312
- /* Native date / time inputs are excluded from the padded rule above: 1rem of
313
- padding inside a date control made it ~48px and, in Firefox, sat the value
314
- low. They get a flat 34px control instead, matching the grid filters. */
315
- input[type='date'],
316
- input[type='datetime-local'],
317
- input[type='time'] {
317
+ /* A textarea is multi-line by definition, so it is the one control that opts
318
+ out of the fixed height it keeps the font and the horizontal padding, and
319
+ takes the vertical padding that puts its FIRST row on the same line as the
320
+ single-line fields beside it. Written twice: the `lh` unit is what makes the
321
+ centring exact, and the literal is for browsers without it. No guard against
322
+ a `--field-height` shorter than one line is needed — CSS clamps a negative
323
+ `calc()` result for padding to zero rather than dropping the declaration.
324
+
325
+ The `min-height` is a floor: the browser default (`rows` omitted) is two
326
+ lines, which renders a free-text box as a slit. */
327
+ textarea {
328
+ height: auto;
329
+ padding: 0.85rem 1rem;
330
+ padding: calc((var(--field-height, 50px) - 1lh) / 2 - 1px) 1rem;
331
+ min-height: 5.5em; /* ~3 lines + padding, for browsers without lh */
332
+ min-height: calc(3lh + 2rem);
333
+ }
334
+
335
+ /* Native date / time inputs are excluded from the chained rule above — the
336
+ `:not([type='date'])` clauses are in it — so they restate the same box here
337
+ rather than inheriting it. They used to be pinned to a flat 34px, which is
338
+ the grid filter row's height and 16px short of every other field in a form.
339
+
340
+ The grid's own filter input keeps its opt-out: it lives inside a 32px
341
+ toolbar row that `global-datagrid.css` sizes, not inside a form. */
342
+ input[type='date']:not(.visns-native-date-filter__input),
343
+ input[type='datetime-local']:not(.visns-native-date-filter__input),
344
+ input[type='time']:not(.visns-native-date-filter__input) {
318
345
  box-sizing: border-box;
319
- height: 34px;
320
- min-height: 34px;
321
- padding: 0 10px;
346
+ height: var(--field-height, 50px);
347
+ min-height: var(--field-height, 50px);
348
+ padding: 0 1rem;
322
349
  border: 1px solid rgba(var(--paragraph-color-rgb, 31, 31, 31), 0.15);
323
350
  border-radius: var(--br, 4px);
324
351
  background: #fff;
325
352
  color: var(--paragraph-color, #1f1f1f);
326
353
  font-family: inherit;
327
354
  font-size: 0.85rem;
328
- line-height: 32px;
355
+ /* Was a hardcoded 32px, one pixel under the old 34px box. A number tied to
356
+ a height this rule no longer states has to go; the value is centred by
357
+ `Form.module.scss`, which flexes these inputs. */
358
+ line-height: normal;
329
359
  font-variant-numeric: tabular-nums;
330
360
  }
331
361
 
332
362
  @-moz-document url-prefix() {
333
- input[type='date'],
334
- input[type='datetime-local'],
335
- input[type='time'] {
336
- height: 34px;
337
- padding: 0 8px;
338
- line-height: 32px;
363
+ input[type='date']:not(.visns-native-date-filter__input),
364
+ input[type='datetime-local']:not(.visns-native-date-filter__input),
365
+ input[type='time']:not(.visns-native-date-filter__input) {
366
+ height: var(--field-height, 50px);
367
+ padding: 0 1rem;
368
+ line-height: normal;
339
369
  }
340
370
  }
341
371
 
@@ -1052,10 +1082,13 @@ form div:has(.react-toggle) input[type='checkbox'] {
1052
1082
  ------------------------------------------------------------------------ */
1053
1083
 
1054
1084
  .visns-select__control {
1055
- /* Same token the native inputs below use. 51px was 3px taller than a text
1056
- input, which is exactly the kind of difference nobody can name but
1057
- everybody sees when the two sit side by side in one form. */
1058
- min-height: var(--field-height, 51px);
1085
+ /* The same token every native control above is now sized from, and the
1086
+ same 50px default: react-select renders divs, so the chained `input[type]`
1087
+ rule never reached it and its height was a separate number (51px) that
1088
+ only happened to be close. `min-height` and not `height`, because a
1089
+ multi-select has to grow as chips wrap — with one value it is exactly a
1090
+ text input, with three it is taller than one on purpose. */
1091
+ min-height: var(--field-height, 50px);
1059
1092
  border: 1px solid rgba(var(--paragraph-rgb, 43, 43, 43), 0.15) !important;
1060
1093
  border-radius: var(--br, 5px) !important;
1061
1094
  background: var(--background-color, #fff);