@visns-studio/visns-components 6.23.2 → 6.24.1

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.23.2",
96
+ "version": "6.24.1",
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) => ({
@@ -43,6 +43,7 @@ import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
43
43
  import SelectFilterEditor from './columns/SelectFilterEditor';
44
44
  import { toast } from 'react-toastify';
45
45
  import fetchUtil from '../utils/fetchUtil';
46
+ import { resolveFormVariant } from '../utils/formLayout';
46
47
  import { confirmDialog } from './utils/ConfirmDialog';
47
48
  import {
48
49
  useDensity,
@@ -6781,10 +6782,14 @@ const DataGrid = forwardRef(
6781
6782
  /* A record form is the case a side sheet is for: the list
6782
6783
  stays visible behind it, a long form scrolls naturally
6783
6784
  instead of stretching the dialog, and the width is fixed
6784
- so the fields keep one measure on every monitor. A view
6785
- that genuinely wants the centred dialog sets
6786
- `form.layout: "modal"`. */
6787
- variant={formData.layout === 'modal' ? 'modal' : 'sheet'}
6785
+ so the fields keep one measure on every monitor. That is
6786
+ the library default, so forms land in the sheet unless
6787
+ something says otherwise. A whole project can flip to the
6788
+ centred dialog with `setDefaultFormLayout('modal')` at
6789
+ bootstrap, and a single view that wants the other
6790
+ presentation overrides it with `form.layout: "modal"` or
6791
+ `form.layout: "sheet"`. */
6792
+ variant={resolveFormVariant(formData.layout)}
6788
6793
  size={formData.modalSize || 'medium'}
6789
6794
  >
6790
6795
  <Form
@@ -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(
@@ -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
@@ -47,6 +47,7 @@ import { saveAs } from 'file-saver';
47
47
  import { toast } from 'react-toastify';
48
48
  import imageCompression from 'browser-image-compression';
49
49
  import fetchUtil from '../../utils/fetchUtil';
50
+ import { resolveFormVariant } from '../../utils/formLayout';
50
51
  import {
51
52
  CheckCircle as CircleCheck,
52
53
  Copy,
@@ -4267,7 +4268,7 @@ function GenericDetail({
4267
4268
  title={formData.title || 'Edit Details'}
4268
4269
  closeOnDocumentClick={false}
4269
4270
  verticalAlign={formData.verticalAlign || 'center'}
4270
- variant={formData.layout === 'modal' ? 'modal' : 'sheet'}
4271
+ variant={resolveFormVariant(formData.layout)}
4271
4272
  size={formData.modalSize || 'medium'}
4272
4273
  >
4273
4274
  <Form
@@ -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;
@@ -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);
package/src/index.js CHANGED
@@ -14,6 +14,14 @@ import {
14
14
  SECONDARY_GRID_HEIGHT,
15
15
  } from './components/utils/useDensity';
16
16
  import { readBuildEnv, buildEnv } from './components/utils/buildEnv';
17
+ // Project-wide default for how record CRUD forms are presented — the side
18
+ // sheet unless an app calls setDefaultFormLayout('modal') at bootstrap. A
19
+ // form config's own `layout` still wins.
20
+ import {
21
+ setDefaultFormLayout,
22
+ getDefaultFormLayout,
23
+ resolveFormVariant,
24
+ } from './utils/formLayout';
17
25
 
18
26
  /** CRM Components */
19
27
  import AsyncSelect from './components/AsyncSelect';
@@ -478,6 +486,7 @@ export {
478
486
  GenericQuote,
479
487
  GenericReport,
480
488
  GenericSort,
489
+ getDefaultFormLayout,
481
490
  GroupedReportRenderer,
482
491
  hasChallengeState,
483
492
  ImpersonateGate,
@@ -523,6 +532,7 @@ export {
523
532
  resolveAuthEndpoints,
524
533
  resolveClientPaths,
525
534
  resolveClientProtocol,
535
+ resolveFormVariant,
526
536
  // Passkeys (WebAuthn). Exported so a consuming app can build its own
527
537
  // enrolment screen against the same marshalling the login screen uses.
528
538
  base64UrlToBytes,
@@ -536,6 +546,7 @@ export {
536
546
  SectionTypeSelector,
537
547
  SelectList,
538
548
  Select,
549
+ setDefaultFormLayout,
539
550
  showConfirmDialog,
540
551
  SortableList,
541
552
  StagePopupModal,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Project-wide default for how record CRUD forms are presented.
3
+ *
4
+ * StandardModal can draw a form either as a centred dialog (`variant="modal"`)
5
+ * or as a right-side slide-in sheet (`variant="sheet"`). The library ships with
6
+ * the sheet as its default so existing projects keep the presentation they
7
+ * already have. A consuming app that prefers the classic centred dialog calls
8
+ * `setDefaultFormLayout('modal')` once at bootstrap (before the first render)
9
+ * and every DataGrid / GenericDetail form follows.
10
+ *
11
+ * An individual form config still wins in both directions: set
12
+ * `layout: 'modal'` or `layout: 'sheet'` on the form and that form ignores the
13
+ * project default.
14
+ */
15
+
16
+ /** The two presentations StandardModal understands. */
17
+ const VALID_LAYOUTS = ['modal', 'sheet'];
18
+
19
+ /** Library default — the side sheet, unless a project opts out. */
20
+ let defaultFormLayout = 'sheet';
21
+
22
+ /** Warn at most once so a bad bootstrap call is not a console flood. */
23
+ let warnedInvalidLayout = false;
24
+
25
+ /**
26
+ * Set the project-wide default presentation for record forms.
27
+ * @param {'modal'|'sheet'} layout - Presentation to use when a form config does
28
+ * not specify its own `layout`.
29
+ * @returns {void} Anything other than 'modal' or 'sheet' is ignored.
30
+ */
31
+ export const setDefaultFormLayout = (layout) => {
32
+ if (!VALID_LAYOUTS.includes(layout)) {
33
+ if (!warnedInvalidLayout) {
34
+ warnedInvalidLayout = true;
35
+ // eslint-disable-next-line no-console
36
+ console.warn(
37
+ `setDefaultFormLayout: expected 'modal' or 'sheet', received ${JSON.stringify(layout)}. Keeping '${defaultFormLayout}'.`
38
+ );
39
+ }
40
+ return;
41
+ }
42
+
43
+ defaultFormLayout = layout;
44
+ };
45
+
46
+ /**
47
+ * Read the current project-wide default.
48
+ * @returns {'modal'|'sheet'} The default presentation for record forms.
49
+ */
50
+ export const getDefaultFormLayout = () => defaultFormLayout;
51
+
52
+ /**
53
+ * Resolve the StandardModal variant for one form.
54
+ * @param {string} [layout] - The form config's own `layout`, if it has one.
55
+ * @returns {'modal'|'sheet'} The form's own layout when it is valid, otherwise
56
+ * the project-wide default.
57
+ */
58
+ export const resolveFormVariant = (layout) =>
59
+ (VALID_LAYOUTS.includes(layout) ? layout : defaultFormLayout);