@visns-studio/visns-components 6.3.3 → 6.3.5

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
@@ -91,7 +91,7 @@
91
91
  "react-dom": "^17.0.0 || ^18.0.0"
92
92
  },
93
93
  "name": "@visns-studio/visns-components",
94
- "version": "6.3.3",
94
+ "version": "6.3.5",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -2182,6 +2182,11 @@ function Field({
2182
2182
  isEditable={settings.isEditable ?? false}
2183
2183
  creatableConfig={settings.creatableConfig || {}}
2184
2184
  editableConfig={settings.editableConfig || {}}
2185
+ // The checkbox renderer previously ignored the field's
2186
+ // read-only state, leaving `readOnlyWhen` configs
2187
+ // claiming a lock the UI didn't enforce — the same gap
2188
+ // the radio renderers had.
2189
+ disabled={isFieldReadOnly}
2185
2190
  style={style}
2186
2191
  />
2187
2192
  );
@@ -2306,6 +2311,11 @@ function Field({
2306
2311
  data-name={settings.id}
2307
2312
  type="radio"
2308
2313
  className={`${styles.fi__customcheckbox} radio-input`}
2314
+ // Radio renderers previously ignored the
2315
+ // field's read-only state, leaving
2316
+ // `readOnlyWhen` configs claiming a lock
2317
+ // the UI didn't enforce.
2318
+ disabled={isFieldReadOnly}
2309
2319
  value={option.id}
2310
2320
  checked={
2311
2321
  inputValue !== undefined &&
@@ -2336,6 +2346,11 @@ function Field({
2336
2346
  data-name={settings.id}
2337
2347
  type="radio"
2338
2348
  className={`${styles.fi__customcheckbox} radio-input`}
2349
+ // Radio renderers previously ignored the
2350
+ // field's read-only state, leaving
2351
+ // `readOnlyWhen` configs claiming a lock
2352
+ // the UI didn't enforce.
2353
+ disabled={isFieldReadOnly}
2339
2354
  value={option.id}
2340
2355
  checked={
2341
2356
  inputValue !== undefined &&
@@ -91,6 +91,56 @@ const evaluateDisableWhen = (rule, data) => {
91
91
  }
92
92
  };
93
93
 
94
+ /**
95
+ * Field visibility rules (`show`).
96
+ *
97
+ * A field carries `show: [{ id: '<other field id>', <condition> }]`, meaning
98
+ * "when MY value satisfies this condition, reveal the named field; otherwise
99
+ * hide it". With `self: true` the roles swap: the condition is read from the
100
+ * NAMED field's value and applied to the field carrying the rule.
101
+ *
102
+ * Supported conditions:
103
+ *
104
+ * { value: [...] } the value equals one of the listed literals. What
105
+ * toggles ({ value: [true] }) and radios
106
+ * ({ value: [1, 2] }) use.
107
+ *
108
+ * { minSelected: n } the value is an array holding at least n entries.
109
+ * For the multi-* types (multi-checkbox-ajax,
110
+ * multi-dropdown-ajax, …) whose value is a list of
111
+ * selected options — e.g. reveal a pair of per-item
112
+ * quantity inputs only once two boxes are ticked.
113
+ *
114
+ * Rules are evaluated when the record loads (the effect keyed on
115
+ * `fetchTrigger`) and again on every change to the field that carries them,
116
+ * so a revealed field cannot linger after the value that revealed it goes
117
+ * away. See also `required_rely` (required-ness that follows another field's
118
+ * value) and `sumEquals` (a group of numbers that must total a target).
119
+ */
120
+ const hasShowCondition = (condition) =>
121
+ !!condition &&
122
+ (condition.minSelected !== undefined ||
123
+ (Array.isArray(condition.value) && condition.value.length > 0));
124
+
125
+ const matchesShowCondition = (condition, value) => {
126
+ if (!condition) {
127
+ return false;
128
+ }
129
+
130
+ if (condition.minSelected !== undefined) {
131
+ return (
132
+ Array.isArray(value) &&
133
+ value.length >= Number(condition.minSelected)
134
+ );
135
+ }
136
+
137
+ if (Array.isArray(condition.value)) {
138
+ return condition.value.includes(value);
139
+ }
140
+
141
+ return false;
142
+ };
143
+
94
144
  function Form({
95
145
  ajaxSetting,
96
146
  api,
@@ -115,6 +165,12 @@ function Form({
115
165
  const [inputClass, setInputClass] = useState({});
116
166
  const [tableData, setTableData] = useState({ columns: [], dataSource: [] });
117
167
  const [tableProps, setTableProps] = useState({});
168
+ // A `save.return.type: 'table'` form (the report filter rows in
169
+ // GenericDetail) renders nothing at all when the query comes back empty,
170
+ // so "no rows matched" and "you have not run it yet" looked identical: an
171
+ // unchanged blank panel under a "generated successfully" toast. This
172
+ // separates the two so the empty result can say so.
173
+ const [hasQueried, setHasQueried] = useState(false);
118
174
  const [uploadProgress, setUploadProgress] = useState(0);
119
175
 
120
176
  // Group selection state management
@@ -459,18 +515,71 @@ function Form({
459
515
  });
460
516
  };
461
517
 
518
+ /**
519
+ * Re-evaluate this field's `show` rules against the value it has just
520
+ * been given. The other two change handlers do this from the DOM
521
+ * node's `data-show`, which a select / multi-checkbox never emits, so
522
+ * without this a rule on one of those types would only ever run at
523
+ * load time — the reveal would not follow the user's clicks.
524
+ */
525
+ const applyShowConditions = (newValue) => {
526
+ const sourceField = formSettings.fields.find((f) => f.id === id);
527
+ const conditions = sourceField?.show || [];
528
+
529
+ if (conditions.length === 0) {
530
+ return;
531
+ }
532
+
533
+ const _fields = [...formSettings.fields];
534
+ let changed = false;
535
+
536
+ conditions.forEach((showObject) => {
537
+ if (!showObject?.id || !hasShowCondition(showObject)) {
538
+ return;
539
+ }
540
+
541
+ const targetIndex = _fields.findIndex(
542
+ (f) => f.id === showObject.id
543
+ );
544
+
545
+ if (targetIndex < 0) {
546
+ return;
547
+ }
548
+
549
+ const shouldShow = matchesShowCondition(showObject, newValue);
550
+ const target = showObject.self
551
+ ? _fields.find((f) => f.id === id)
552
+ : _fields[targetIndex];
553
+
554
+ if (target) {
555
+ target.hide = !shouldShow;
556
+ changed = true;
557
+ }
558
+ });
559
+
560
+ if (changed && updateForm) {
561
+ updateForm((prevState) => ({
562
+ ...prevState,
563
+ fields: _fields,
564
+ }));
565
+ }
566
+ };
567
+
462
568
  // // Switch cases to handle different actions
463
569
  switch (action.action) {
464
570
  case 'select-option':
465
571
  case 'create-option': // New case to handle create-option
466
572
  case 'deselect-option': // Handle unchecking tags
467
573
  handleSelectOption();
574
+ applyShowConditions(inputValue);
468
575
  break;
469
576
  case 'clear':
470
577
  updateFormData({ [id]: '' });
578
+ applyShowConditions([]);
471
579
  break;
472
580
  case 'remove-value':
473
581
  updateFormData({ [id]: inputValue });
582
+ applyShowConditions(inputValue);
474
583
  break;
475
584
  }
476
585
  };
@@ -1297,6 +1406,83 @@ function Form({
1297
1406
  _inputClass[item.id] = styles.inputError;
1298
1407
  }
1299
1408
  }
1409
+
1410
+ /**
1411
+ * `sumEquals`: a set of numeric fields that must add
1412
+ * up to a target.
1413
+ *
1414
+ * sumEquals: {
1415
+ * fields: ['qty_a', 'qty_b'],
1416
+ * equals: 'qty_total', // field id, or a number
1417
+ * message: '…' // optional override
1418
+ * }
1419
+ *
1420
+ * Declared once, on any one of the fields in the
1421
+ * group. Every named field is marked in error so the
1422
+ * user sees which inputs to reconcile, not just a
1423
+ * message.
1424
+ *
1425
+ * The rule is skipped while its own field is hidden,
1426
+ * so a group that only appears in a revealed section
1427
+ * (see the `show` documentation above) cannot block an
1428
+ * ordinary save. While visible it also stands in for
1429
+ * `required` on the whole group: a blank member is as
1430
+ * wrong as a wrong total.
1431
+ */
1432
+ if (item.sumEquals && item.hide !== true) {
1433
+ const rule = item.sumEquals;
1434
+ const groupIds = Array.isArray(rule.fields)
1435
+ ? rule.fields
1436
+ : [];
1437
+
1438
+ if (groupIds.length > 0) {
1439
+ const labelFor = (fieldId) =>
1440
+ fields.find((f) => f.id === fieldId)
1441
+ ?.label || fieldId;
1442
+ const groupLabels = groupIds
1443
+ .map(labelFor)
1444
+ .join(' and ');
1445
+ const groupValues = groupIds.map(
1446
+ (fieldId) => formData[fieldId]
1447
+ );
1448
+ const incomplete = groupValues.some(
1449
+ (v) =>
1450
+ v === null ||
1451
+ v === undefined ||
1452
+ v === '' ||
1453
+ isNaN(Number(v))
1454
+ );
1455
+
1456
+ const target =
1457
+ typeof rule.equals === 'number'
1458
+ ? rule.equals
1459
+ : Number(formData[rule.equals]);
1460
+
1461
+ const markGroup = () =>
1462
+ groupIds.forEach((fieldId) => {
1463
+ _inputClass[fieldId] =
1464
+ styles.inputError;
1465
+ });
1466
+
1467
+ if (incomplete) {
1468
+ validation += `${groupLabels} are required.<br/>`;
1469
+ markGroup();
1470
+ } else if (!isNaN(target)) {
1471
+ const sum = groupValues.reduce(
1472
+ (total, v) => total + Number(v),
1473
+ 0
1474
+ );
1475
+
1476
+ if (sum !== target) {
1477
+ validation +=
1478
+ (rule.message ||
1479
+ `${groupLabels} must add up to ${target} - they add up to ${sum}.`) +
1480
+ '<br/>';
1481
+ markGroup();
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1300
1486
  });
1301
1487
 
1302
1488
  setInputClass(_inputClass);
@@ -2091,6 +2277,11 @@ function Form({
2091
2277
  );
2092
2278
  }
2093
2279
 
2280
+ // Set before the branch: an empty result is
2281
+ // still a result, and that is the case the
2282
+ // empty state exists for.
2283
+ setHasQueried(true);
2284
+
2094
2285
  if (
2095
2286
  res.data[formSettings.save.return.param]
2096
2287
  ?.length > 0
@@ -2665,9 +2856,9 @@ function Form({
2665
2856
  const showConditions = field.show || [];
2666
2857
 
2667
2858
  showConditions.forEach((showObject) => {
2668
- const { id, value, self } = showObject;
2859
+ const { id, self } = showObject;
2669
2860
 
2670
- if (id != '' && value.length > 0) {
2861
+ if (id != '' && hasShowCondition(showObject)) {
2671
2862
  const targetField = formSettings.fields.find(
2672
2863
  (f) => f.id === id
2673
2864
  );
@@ -2684,7 +2875,10 @@ function Form({
2684
2875
  valueChecker !== undefined &&
2685
2876
  valueChecker !== null
2686
2877
  ) {
2687
- const shouldShow = value.includes(valueChecker);
2878
+ const shouldShow = matchesShowCondition(
2879
+ showObject,
2880
+ valueChecker
2881
+ );
2688
2882
 
2689
2883
  if (self) {
2690
2884
  field.hide = !shouldShow;
@@ -3300,6 +3494,27 @@ function Form({
3300
3494
  />
3301
3495
  </div>
3302
3496
  )}
3497
+
3498
+ {/* Empty result. Only ever after a run — before that the
3499
+ panel is a filter row waiting to be used, and telling
3500
+ the user there is nothing there would be wrong. Copy is
3501
+ overridable per report via `save.return.empty`. */}
3502
+ {hasQueried &&
3503
+ formSettings?.save?.return?.type === 'table' &&
3504
+ !(tableData?.dataSource?.length > 0) && (
3505
+ <div
3506
+ className={`${styles.formItem} ${styles.fwItem} ${styles.emptyResult}`}
3507
+ >
3508
+ <p>
3509
+ {formSettings.save.return.empty?.title ||
3510
+ 'No records matched'}
3511
+ </p>
3512
+ <span>
3513
+ {formSettings.save.return.empty?.message ||
3514
+ 'Nothing came back for these filters. Widen them and run the report again.'}
3515
+ </span>
3516
+ </div>
3517
+ )}
3303
3518
  </form>
3304
3519
  <div className={styles.polActions}>
3305
3520
  {formSettings.hasOwnProperty('customButton') &&
@@ -16,6 +16,11 @@ function MultiCheckbox({
16
16
  isEditable = false,
17
17
  creatableConfig = {},
18
18
  editableConfig = {},
19
+ // The checkbox renderer used to ignore the field's read-only state, so a
20
+ // `readOnlyWhen` config claimed a lock the UI never enforced (the same
21
+ // gap the radio renderers had). Disabling the boxes also takes the
22
+ // create/edit affordances away — they mutate the option list.
23
+ disabled = false,
19
24
  }) {
20
25
  const [checkboxOptions, setCheckboxOptions] = useState([]);
21
26
  const [selectedValues, setSelectedValues] = useState([]);
@@ -75,8 +80,12 @@ function MultiCheckbox({
75
80
  const filteredOptions = checkboxOptions;
76
81
 
77
82
  const handleCheckboxChange = (option, isChecked) => {
83
+ if (disabled) {
84
+ return;
85
+ }
86
+
78
87
  let newSelected;
79
-
88
+
80
89
  if (isChecked) {
81
90
  // Add to selection
82
91
  newSelected = [...selectedValues, option];
@@ -186,7 +195,7 @@ function MultiCheckbox({
186
195
  <div className={`${styles.multiCheckbox} ${className || ''}`} style={style}>
187
196
 
188
197
  {/* Create new tag section */}
189
- {isCreatable && (
198
+ {isCreatable && !disabled && (
190
199
  <div className={styles.createSection}>
191
200
  {!isCreating ? (
192
201
  <button
@@ -294,9 +303,10 @@ function MultiCheckbox({
294
303
  checked={isSelected(option.id)}
295
304
  onChange={(e) => handleCheckboxChange(option, e.target.checked)}
296
305
  className={styles.checkbox}
306
+ disabled={disabled}
297
307
  />
298
308
  <span className={styles.labelText}>{option.label}</span>
299
- {isEditable && (
309
+ {isEditable && !disabled && (
300
310
  <button
301
311
  type="button"
302
312
  onClick={(e) => {
@@ -2679,6 +2679,22 @@ function GenericDetail({
2679
2679
  >
2680
2680
  <span>{formConfig.title}</span>
2681
2681
  </div>
2682
+ {/* Optional one-line subtitle. A
2683
+ report tab is a name and a set
2684
+ of controls with nothing saying
2685
+ what it returns; a tab that has
2686
+ something to say can now say
2687
+ it, and one that has not is
2688
+ unchanged. */}
2689
+ {formConfig.description && (
2690
+ <p
2691
+ className={
2692
+ styles.gridtxt__description
2693
+ }
2694
+ >
2695
+ {formConfig.description}
2696
+ </p>
2697
+ )}
2682
2698
  <Form
2683
2699
  key={`form-${activeTabConfig.id}`}
2684
2700
  columnId={routeParams[urlParam]}
@@ -335,43 +335,54 @@ input[type='file'] {
335
335
 
336
336
 
337
337
 
338
+ /* The form's primary action (Save / View Report / Refresh).
339
+
340
+ This was a bespoke button — 1.25em type on 0.65rem padding, its own radius
341
+ token, its own transition curve, and a hover that swapped to pale blue on
342
+ blue. Sat next to `.btn` (the shared primary) in the same action bar, so the
343
+ two controls that are meant to read as a pair were a different height, a
344
+ different type size and a different hover. It is the same button as every
345
+ other primary in the library; it just needed to say so. */
338
346
  .saveBtn {
339
- width: max-content;
340
- display: inline-block;
341
- position: relative;
342
- padding: 0.65rem 1rem;
343
- cursor: pointer;
344
- font-size: 1rem;
345
- color: var(--tertiary-color);
346
- text-decoration: none;
347
- overflow: hidden;
348
- background: var(--primary-color);
349
- border: 1px solid rgba(var(--primary-color--rgb), 1.1);
350
- border-radius: var(--br);
351
- outline: none;
352
- transition: all 0.2s cubic-bezier(0.85, 0, 0.15, 1) 0s;
353
- font-size: 1.25em;
354
-
355
- &:hover {
356
- color: var(--primary-color);
357
- background: var(--highlight-color);
358
- border: 1px solid rgba(var(--highlight-rgb), 1.05);
359
- }
347
+ @include button-primary;
360
348
  }
361
349
 
350
+ /* The inline form's action bar.
351
+
352
+ Was `position: fixed; bottom: 15px; right: 20px` with `float: left`
353
+ children: the buttons left the form entirely and parked in the corner of
354
+ the *viewport*. On a short form (the report filter rows in GenericDetail —
355
+ one row of dropdowns) that put "View Report" nowhere near the filters it
356
+ runs; on a form that returns a results grid it floated on top of the grid's
357
+ last rows and its horizontal scrollbar.
358
+
359
+ It is a footer to the form it belongs to, so it lives at the end of the
360
+ form. `sticky` keeps what the fixed positioning was actually for — the
361
+ action stays reachable while a long form or a 550px results grid is
362
+ scrolled — without detaching it from its panel. */
362
363
  .polActions {
363
- position: fixed;
364
- bottom: 15px;
365
- right: 20px;
366
- width: auto;
367
- z-index: 100;
364
+ position: sticky;
365
+ bottom: 0;
366
+ z-index: 5;
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: flex-end;
370
+ gap: var(--spacing-sm, 0.5rem);
371
+ width: 100%;
372
+ margin-top: var(--spacing-lg, 1.5rem);
373
+ padding: var(--spacing-sm, 0.5rem) 0;
374
+ /* Opaque, or the form scrolls through the bar while it is stuck. */
375
+ background: var(--surface-color, #fff);
376
+ border-top: 1px solid var(--border-color, #e1e1e1);
368
377
  }
369
378
 
370
- .polActions button {
371
- display: block;
372
- float: left;
373
- padding: 0.35em 1em;
374
- margin: 0 0.15em;
379
+ /* One primary per bar. Download / Sort / Save & Exit are alternatives to the
380
+ main action, not competitors: identical geometry, no fill. Previously every
381
+ button in here was a filled brand-blue block, so a filter row offering
382
+ "Download Report" and "View Report" gave no clue which one was the thing
383
+ you came to do. */
384
+ .polActions .btn {
385
+ @include button-secondary;
375
386
  }
376
387
 
377
388
  /* For in-place editing mode */
@@ -421,6 +432,37 @@ input[type='file'] {
421
432
  display: none;
422
433
  }
423
434
 
435
+ /* Empty result for `save.return.type: 'table'` forms. Deliberately not a
436
+ card and not an alert: an empty report is an ordinary outcome, so it is a
437
+ quiet block of centred text on the panel it would have filled, drawn with
438
+ the same hairline the rest of the page separates things with. */
439
+ .emptyResult {
440
+ display: flex;
441
+ flex-direction: column;
442
+ align-items: center;
443
+ justify-content: center;
444
+ gap: var(--spacing-xs, 0.25rem);
445
+ padding: var(--spacing-2xl, 3rem) var(--spacing-md, 1rem);
446
+ text-align: center;
447
+ border: 1px dashed var(--border-color, #e1e1e1);
448
+ border-radius: var(--radius-md, 10px);
449
+ background: var(--alternate-color, transparent);
450
+
451
+ p {
452
+ margin: 0;
453
+ font-size: var(--font-size-md, 0.9375rem);
454
+ font-weight: var(--font-weight-semibold, 600);
455
+ color: var(--heading-color, inherit);
456
+ }
457
+
458
+ span {
459
+ max-width: 42ch;
460
+ font-size: var(--font-size-sm, 0.8125rem);
461
+ line-height: 1.5;
462
+ color: var(--muted-color, #6e7276);
463
+ }
464
+ }
465
+
424
466
  .buttonContainer {
425
467
  display: flex;
426
468
  justify-content: center;
@@ -86,15 +86,31 @@
86
86
  gap: 0.5rem;
87
87
  }
88
88
 
89
+ /* The tab rail. Identical spec to `.grid__subnav` in GenericIndex.module.scss
90
+ — the two pages share one rail, so keep them in step.
91
+
92
+ It was a white panel under a 50px brand-tinted blur, holding one tinted blue
93
+ pill per tab. Every item therefore carried the same weight as the selected
94
+ one, and a seven-tab rail (the reports page) read as a stack of seven
95
+ buttons rather than as navigation with one thing chosen. 6.3's answer
96
+ everywhere else on the page is a flat surface, a hairline and quiet ink, so
97
+ that is what the rail gets: unselected tabs are plain text on the panel, and
98
+ the brand blue is spent once, on the tab you are actually on. */
89
99
  .grid__subnav {
100
+ /* `flex: 1` (basis 0) against `.grid__subcontent`'s `flex: 0 1 80%` is
101
+ left exactly as it was: `.grid__subrow` is `flex-wrap: wrap`, so a
102
+ flex-basis here large enough to overflow the row would wrap the content
103
+ panel onto its own line rather than shrink it. Only the treatment
104
+ changes below. */
90
105
  flex: 1;
91
- background: white;
106
+ background: var(--surface-color, #fff);
107
+ border: 1px solid var(--border-color, #e1e1e1);
92
108
  border-radius: var(--br);
93
109
  box-sizing: border-box;
94
- padding: 5px;
110
+ padding: var(--spacing-xs, 0.25rem);
95
111
  margin: 8px 0;
96
112
  height: auto;
97
- box-shadow: 0 10px 50px rgba(var(--primary-rgb), 0.05);
113
+ box-shadow: none;
98
114
  }
99
115
 
100
116
  .grid__subnav > ul {
@@ -108,32 +124,54 @@
108
124
 
109
125
  .grid__subnav > ul li {
110
126
  width: 100%;
111
- margin-bottom: 3px;
127
+ margin-bottom: var(--spacing-xs, 0.25rem);
128
+ }
129
+
130
+ .grid__subnav > ul li:last-child {
131
+ margin-bottom: 0;
112
132
  }
113
133
 
114
134
  .grid__subnav > ul li a {
115
135
  width: 100%;
116
- padding: 0.35rem 1rem;
136
+ /* 0.35rem was a 5px-tall hit area for a control people click all day. */
137
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
117
138
  box-sizing: border-box;
118
139
  display: block;
119
140
  text-decoration: none;
120
- background: rgba(var(--primary-rgb), 0.05);
121
- color: var(--primary-color);
122
- border-radius: 5px;
123
- transition: background 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
124
- outline: none;
141
+ background: none;
142
+ color: var(--text-color, #374151);
143
+ font-size: var(--font-size-sm, 0.8125rem);
144
+ font-weight: var(--font-weight-medium, 500);
145
+ line-height: 1.35;
146
+ border-radius: var(--radius-sm, 6px);
147
+ transition:
148
+ background-color var(--speed, 0.18s) var(--ease, ease),
149
+ color var(--speed, 0.18s) var(--ease, ease);
125
150
  cursor: pointer;
126
151
  }
127
152
 
128
153
  .grid__subnav > ul li:hover a {
129
- background: var(--primary-color);
130
- color: var(--background-color);
154
+ /* Hover is a hint, not a commitment. It used to fill solid brand blue —
155
+ indistinguishable from the selected tab, so passing the mouse across the
156
+ rail made it look as though the selection was following the cursor. */
157
+ background: var(--hover-color, rgba(0, 0, 0, 0.04));
158
+ color: var(--heading-color, inherit);
131
159
  }
132
160
 
133
161
  .grid__subnav > ul li .subactive {
134
- font-weight: 700;
162
+ font-weight: var(--font-weight-semibold, 600);
135
163
  background: var(--primary-color);
136
- color: var(--background-color);
164
+ /* Was --background-color, the page's off-white; white is what the button
165
+ and nav-pill treatments elsewhere put on this blue. */
166
+ color: #fff;
167
+ }
168
+
169
+ /* `outline: none` used to sit on the link with nothing put back, so the rail
170
+ was unusable by keyboard. The app's own ring token, same as every other
171
+ control. */
172
+ .grid__subnav > ul li a:focus-visible {
173
+ outline: none;
174
+ box-shadow: var(--focus-ring, 0 0 0 3px rgba(0, 118, 192, 0.32));
137
175
  }
138
176
 
139
177
  .grid__minheight {
@@ -146,9 +184,12 @@
146
184
 
147
185
  .grid__subcontent {
148
186
  flex: 0 1 80%;
149
- background: var(--tertiary-color);
187
+ background: var(--surface-color, #fff);
150
188
  border-radius: var(--br);
151
- border: 1px solid rgba(var(--primary-rgb), 0.05);
189
+ /* Was the brand blue at 5% — a tint so faint the panel had no edge at all
190
+ while the rail beside it did. The shared hairline, same as every other
191
+ surface on the page. */
192
+ border: 1px solid var(--border-color, #e1e1e1);
152
193
  box-sizing: border-box;
153
194
  padding: 2px;
154
195
  margin: 8px 0;
@@ -163,6 +204,24 @@
163
204
  margin: 0 0 30px 0;
164
205
  }
165
206
 
207
+ /* The tab panel's own inset.
208
+
209
+ `.gridtxt` had no rule at all, and its container (`.grid__subcontent`,
210
+ `.grid__full`) carries `padding: 2px` — so a panel's section heading and any
211
+ form inside it sat 2px from the panel border. Only the overview `<ul>` was
212
+ readable, because it padded itself. On the reports page that meant the
213
+ filter row's dropdowns and date pickers were flush against the panel edge.
214
+
215
+ The inset belongs to the panel, not to one child of it, so it is stated
216
+ here once and the `<ul>` below gives up its horizontal padding to match.
217
+ The `<ul>`'s vertical padding stays: with several heading/list pairs in one
218
+ `.gridtxt` (the overview tabs) that is what separates one section from the
219
+ next. */
220
+ .gridtxt {
221
+ padding: var(--spacing-md, 1rem) var(--spacing-lg, 1.5rem)
222
+ var(--spacing-lg, 1.5rem);
223
+ }
224
+
166
225
  .gridtxt__header {
167
226
  width: 100%;
168
227
  display: block;
@@ -192,6 +251,21 @@
192
251
  font-weight: 700;
193
252
  }
194
253
 
254
+ /* Optional one-line subtitle under a section heading (GenericDetail renders it
255
+ from a tab's `description`). Sits in the heading's own space — it belongs to
256
+ the label, not to the content — so it takes over the heading's bottom margin
257
+ rather than adding a third gap. */
258
+ .gridtxt__description {
259
+ /* Pulls back half of the heading's 1rem bottom margin, so the subtitle sits
260
+ close under its heading and the whole block is separated from the
261
+ content below rather than floating between the two. */
262
+ margin: calc(-1 * var(--spacing-sm, 0.5rem)) 0 var(--spacing-lg, 1.5rem);
263
+ max-width: 70ch;
264
+ font-size: var(--font-size-sm, 0.8125rem);
265
+ line-height: 1.55;
266
+ color: var(--muted-color, #6e7276);
267
+ }
268
+
195
269
  .gridtxt > ul {
196
270
  width: 100%;
197
271
  display: flex;
@@ -200,7 +274,9 @@
200
274
  flex-wrap: wrap;
201
275
  list-style: none;
202
276
  box-sizing: border-box;
203
- padding: 0.85rem;
277
+ /* Horizontal padding removed — `.gridtxt` owns the panel inset now (see
278
+ the note there). The vertical padding separates stacked sections. */
279
+ padding: 0.85rem 0;
204
280
  margin: 0;
205
281
  }
206
282
 
@@ -114,15 +114,19 @@
114
114
  gap: 0.5rem;
115
115
  }
116
116
 
117
+ /* The tab rail. Identical spec to `.grid__subnav` in GenericDetail.module.scss
118
+ — the two pages share one rail, so keep them in step; the reasoning for the
119
+ treatment is written out there. */
117
120
  .grid__subnav {
118
121
  flex: 1;
119
- background: white;
122
+ background: var(--surface-color, #fff);
123
+ border: 1px solid var(--border-color, #e1e1e1);
120
124
  border-radius: var(--br);
121
125
  box-sizing: border-box;
122
- padding: 5px;
126
+ padding: var(--spacing-xs, 0.25rem);
123
127
  margin: 8px 0;
124
128
  height: auto;
125
- box-shadow: 0 10px 50px rgba(var(--primary-rgb), 0.05);
129
+ box-shadow: none;
126
130
  }
127
131
 
128
132
  .grid__subnav > ul {
@@ -136,32 +140,45 @@
136
140
 
137
141
  .grid__subnav > ul li {
138
142
  width: 100%;
139
- margin-bottom: 3px;
143
+ margin-bottom: var(--spacing-xs, 0.25rem);
144
+ }
145
+
146
+ .grid__subnav > ul li:last-child {
147
+ margin-bottom: 0;
140
148
  }
141
149
 
142
150
  .grid__subnav > ul li a {
143
151
  width: 100%;
144
- padding: 0.35rem 1rem;
152
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
145
153
  box-sizing: border-box;
146
154
  display: block;
147
155
  text-decoration: none;
148
- background: rgba(var(--primary-rgb), 0.05);
149
- color: var(--primary-color);
150
- border-radius: 5px;
151
- transition: background 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
152
- outline: none;
156
+ background: none;
157
+ color: var(--text-color, #374151);
158
+ font-size: var(--font-size-sm, 0.8125rem);
159
+ font-weight: var(--font-weight-medium, 500);
160
+ line-height: 1.35;
161
+ border-radius: var(--radius-sm, 6px);
162
+ transition:
163
+ background-color var(--speed, 0.18s) var(--ease, ease),
164
+ color var(--speed, 0.18s) var(--ease, ease);
153
165
  cursor: pointer;
154
166
  }
155
167
 
156
168
  .grid__subnav > ul li:hover a {
157
- background: var(--primary-color);
158
- color: var(--tertiary-color);
169
+ background: var(--hover-color, rgba(0, 0, 0, 0.04));
170
+ color: var(--heading-color, inherit);
159
171
  }
160
172
 
161
173
  .grid__subnav > ul li .subactive {
162
- font-weight: 700;
174
+ font-weight: var(--font-weight-semibold, 600);
163
175
  background: var(--primary-color);
164
- color: var(--tertiary-color);
176
+ color: #fff;
177
+ }
178
+
179
+ .grid__subnav > ul li a:focus-visible {
180
+ outline: none;
181
+ box-shadow: var(--focus-ring, 0 0 0 3px rgba(0, 118, 192, 0.32));
165
182
  }
166
183
 
167
184
  .grid__subnav__alternate {
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The tab rail's own stylesheet, which predates the 6.3 token language: it was
3
+ * still drawing its own card (a hardcoded rgba drop shadow and radius) and its
4
+ * own type scale (0.85/0.9rem literals) around items that the host surface —
5
+ * `.grid__subnav` in GenericIndex/GenericDetail — already frames. Two nested
6
+ * cards, two shadows, and a rail whose type did not match anything else on the
7
+ * page. Everything here now reads the same tokens as the rest of the library,
8
+ * and the framing is left to the surface it sits on.
9
+ *
10
+ * Literal fallbacks throughout: a bare `var()` for a token a consuming app has
11
+ * not declared is invalid at computed-value time and the whole declaration is
12
+ * dropped, which is exactly how a rail like this loses its padding.
13
+ */
14
+
1
15
  .tableFilterContainer {
2
16
  width: 100%;
3
17
  position: relative;
@@ -9,11 +23,11 @@
9
23
  list-style: none;
10
24
  padding: 0;
11
25
  margin: 0;
12
- font-size: 0.9rem;
26
+ font-size: var(--font-size-sm, 0.8125rem);
13
27
  width: 100%;
14
- border-radius: 8px;
15
- overflow: hidden;
16
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
28
+ /* No radius, no shadow, no `overflow: hidden`. The panel around this list
29
+ supplies the surface; the clipping additionally cut the focus ring off
30
+ the first and last items. */
17
31
  }
18
32
 
19
33
  /* Parent category styling */
@@ -29,19 +43,23 @@
29
43
  display: flex;
30
44
  justify-content: space-between;
31
45
  align-items: center;
32
- padding: 8px 16px;
33
- font-weight: 600;
34
- font-size: 0.9rem;
46
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
47
+ border-radius: var(--radius-sm, 6px);
48
+ font-weight: var(--font-weight-semibold, 600);
49
+ font-size: var(--font-size-sm, 0.8125rem);
35
50
  background-color: var(--primary-color, #000);
36
- color: var(--tertiary-color, #fff);
37
- transition: all 0.2s ease;
51
+ color: #fff;
52
+ /* Was `all 0.2s`, which animates layout properties too. */
53
+ transition:
54
+ background-color var(--speed, 0.18s) var(--ease, ease),
55
+ color var(--speed, 0.18s) var(--ease, ease);
38
56
  position: relative;
39
57
  text-decoration: none;
40
58
  }
41
59
 
42
60
  .parentLink:hover {
43
- background-color: var(--secondary-color, #222) !important;
44
- color: var(--tertiary-color, #fff) !important;
61
+ background-color: var(--primary-color-darker, #222) !important;
62
+ color: #fff !important;
45
63
  text-decoration: none;
46
64
  }
47
65
 
@@ -63,7 +81,9 @@
63
81
  overflow: hidden;
64
82
  transition: max-height 0.3s ease-out, opacity 0.3s ease-out;
65
83
  opacity: 0;
66
- background-color: var(--bg-color, #f5f5f5);
84
+ /* Flush with the rail. The child links carry their own hover surface now,
85
+ so a tint behind the whole submenu only made a second panel edge. */
86
+ background: none;
67
87
  }
68
88
 
69
89
  .visibleMenu {
@@ -74,7 +94,7 @@
74
94
  .childItem {
75
95
  margin: 0;
76
96
  position: relative;
77
- border-bottom: 1px solid rgba(var(--paragraph-color-rgb, 0, 0, 0), 0.03);
97
+ border-bottom: 1px solid var(--border-color, #e1e1e1);
78
98
  }
79
99
 
80
100
  .childItem:last-child {
@@ -82,18 +102,20 @@
82
102
  }
83
103
 
84
104
  .childLink {
85
- padding: 6px 16px 6px 26px;
86
- background-color: var(--bg-color, #f5f5f5);
87
- color: var(--paragraph-color, #333);
88
- font-weight: normal;
89
- font-size: 0.85rem;
105
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem) var(--spacing-sm, 0.5rem)
106
+ var(--spacing-xl, 1.75rem);
107
+ background: none;
108
+ color: var(--text-color, #333);
109
+ font-weight: var(--font-weight-regular, 400);
110
+ font-size: var(--font-size-sm, 0.8125rem);
111
+ border-radius: var(--radius-sm, 6px);
90
112
  display: block;
91
113
  text-decoration: none;
92
114
  }
93
115
 
94
116
  .childLink:hover {
95
117
  background-color: var(--hover-color, #e8e8e8) !important;
96
- color: var(--primary-color, #000) !important;
118
+ color: var(--heading-color, #000) !important;
97
119
  text-decoration: none;
98
120
  }
99
121
 
@@ -104,37 +126,55 @@
104
126
  color: rgba(var(--paragraph-color-rgb, 153, 153, 153), 0.6);
105
127
  }
106
128
 
107
- /* Active states */
129
+ /* Active states. The brand fill is the one loud thing in the rail, and it
130
+ marks exactly one item — which is what makes it readable as a selection
131
+ rather than as decoration. */
108
132
  .subactive {
109
- font-weight: 600;
133
+ font-weight: var(--font-weight-semibold, 600);
110
134
  background: var(--primary-color, #000) !important;
111
- color: var(--tertiary-color, #fff) !important;
135
+ /* Was --tertiary-color, which several projects set to their page
136
+ off-white; white is what sits on this blue elsewhere. */
137
+ color: #fff !important;
112
138
  }
113
139
 
114
140
  .subactivechildren {
115
- font-weight: 500;
116
- background-color: var(--hover-color, #f0f0f0) !important;
141
+ font-weight: var(--font-weight-medium, 500);
142
+ background-color: var(--primary-color-light, #f0f0f0) !important;
117
143
  color: var(--primary-color, #000) !important;
118
144
  }
119
145
 
120
- /* Base link styling */
146
+ /* Base link styling. Unselected tabs are text on the panel, not tinted
147
+ blocks — see the note on `.grid__subnav` in GenericDetail.module.scss. */
121
148
  .link {
122
149
  display: block;
123
- padding: 6px 16px;
150
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
124
151
  text-decoration: none;
125
- background: var(--bg-color, #f5f5f5);
126
- color: var(--paragraph-color, #333);
127
- font-size: 0.85rem;
128
- transition: all 0.2s ease;
152
+ background: none;
153
+ color: var(--text-color, #333);
154
+ font-size: var(--font-size-sm, 0.8125rem);
155
+ font-weight: var(--font-weight-medium, 500);
156
+ border-radius: var(--radius-sm, 6px);
157
+ transition:
158
+ background-color var(--speed, 0.18s) var(--ease, ease),
159
+ color var(--speed, 0.18s) var(--ease, ease);
129
160
  cursor: pointer;
130
161
  }
131
162
 
132
163
  .link:hover {
133
164
  background: var(--hover-color, #e8e8e8) !important;
134
- color: var(--primary-color, #000) !important;
165
+ /* Was the brand blue, which on hover made an unselected tab look like the
166
+ selected one. */
167
+ color: var(--heading-color, #000) !important;
135
168
  text-decoration: none;
136
169
  }
137
170
 
171
+ .link:focus-visible,
172
+ .childLink:focus-visible,
173
+ .parentLink:focus-visible {
174
+ outline: none;
175
+ box-shadow: var(--focus-ring, 0 0 0 3px rgba(0, 118, 192, 0.32));
176
+ }
177
+
138
178
  .activetab button {
139
179
  font-weight: bold;
140
180
  background: var(--hover-color, #e0e0e0) !important;
@@ -146,19 +186,20 @@
146
186
  display: none;
147
187
  align-items: center;
148
188
  justify-content: space-between;
149
- padding: 12px 15px;
189
+ padding: var(--spacing-sm, 0.75rem) var(--spacing-md, 1rem);
150
190
  background: var(--primary-color, #000);
151
- color: var(--tertiary-color, white);
152
- border-radius: 8px;
191
+ color: #fff;
192
+ border-radius: var(--radius-sm, 6px);
153
193
  cursor: pointer;
154
- margin-bottom: 10px;
194
+ margin-bottom: var(--spacing-sm, 0.5rem);
155
195
  border: none;
156
- box-shadow: 0 1px 3px rgba(var(--paragraph-color-rgb, 0, 0, 0), 0.2);
196
+ box-shadow: var(--shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.2));
157
197
  }
158
198
 
159
199
  .activeFilterLabel {
160
- font-weight: 600;
161
- color: var(--tertiary-color, white);
200
+ font-weight: var(--font-weight-semibold, 600);
201
+ font-size: var(--font-size-sm, 0.8125rem);
202
+ color: #fff;
162
203
  }
163
204
 
164
205
  .mobileMenuIcon {
@@ -175,7 +216,7 @@
175
216
  display: block;
176
217
  height: 2px;
177
218
  width: 100%;
178
- background-color: var(--tertiary-color, white);
219
+ background-color: #fff;
179
220
  border-radius: 2px;
180
221
  transition: all 0.3s ease;
181
222
  }
@@ -206,20 +247,17 @@
206
247
  display: none; /* Hide hamburger menu on tablets */
207
248
  }
208
249
 
209
- /* Slightly adjust padding for better tablet experience */
210
- .parentLink {
211
- padding: 9px 16px;
212
- font-size: 0.9rem;
213
- }
214
-
250
+ /* Roomier tap targets. Only the padding moves: the literal 0.85/0.9rem
251
+ sizes that used to sit here re-declared the rail's type at a scale
252
+ nothing else on the page uses, so the labels changed size at 1024px for
253
+ no reason. Density is a padding question. */
254
+ .parentLink,
215
255
  .link {
216
- padding: 9px 16px;
217
- font-size: 0.85rem;
256
+ padding: 0.65rem var(--spacing-md, 1rem);
218
257
  }
219
258
 
220
259
  .childLink {
221
- padding: 7px 16px 7px 26px;
222
- font-size: 0.85rem;
260
+ padding: 0.65rem var(--spacing-md, 1rem) 0.65rem var(--spacing-xl, 1.75rem);
223
261
  }
224
262
  }
225
263
 
@@ -240,25 +278,21 @@
240
278
  display: block;
241
279
  max-height: 1000px;
242
280
  overflow-y: auto;
243
- border: 1px solid rgba(var(--paragraph-color-rgb, 0, 0, 0), 0.1);
244
- border-radius: 8px;
245
- margin-bottom: 10px;
246
- box-shadow: 0 2px 5px rgba(var(--paragraph-color-rgb, 0, 0, 0), 0.1);
247
- }
248
-
249
- .parentLink {
250
- padding: 10px 16px; /* Slightly larger touch targets for mobile */
251
- font-size: 0.9rem;
281
+ padding: var(--spacing-xs, 0.25rem);
282
+ border: 1px solid var(--border-color, #e1e1e1);
283
+ border-radius: var(--br, 8px);
284
+ margin-bottom: var(--spacing-sm, 0.5rem);
285
+ box-shadow: var(--shadow-sm, 0 2px 5px rgba(0, 0, 0, 0.1));
252
286
  }
253
287
 
288
+ /* Larger tap targets on a phone; the type scale is left alone, as above. */
289
+ .parentLink,
254
290
  .link {
255
- padding: 10px 16px; /* Slightly larger touch targets for mobile */
256
- font-size: 0.85rem;
291
+ padding: 0.75rem var(--spacing-md, 1rem);
257
292
  }
258
293
 
259
294
  .childLink {
260
- padding: 8px 16px 8px 26px;
261
- font-size: 0.85rem;
295
+ padding: 0.75rem var(--spacing-md, 1rem) 0.75rem var(--spacing-xl, 1.75rem);
262
296
  }
263
297
 
264
298
  .collapsibleMenu {