@visns-studio/visns-components 6.4.1 → 6.4.3
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 +1 -1
- package/src/components/DataGrid.jsx +7 -1
- package/src/components/Field.jsx +73 -1
- package/src/components/Navigation.jsx +8 -0
- package/src/components/TableFilter.jsx +67 -13
- package/src/components/columns/ColumnRenderers.jsx +10 -6
- package/src/components/styles/Field.module.scss +68 -0
- package/src/components/styles/Navigation.module.scss +43 -1
- package/src/components/styles/TableFilter.module.scss +100 -0
- package/src/components/utils/nativeDateInput.js +172 -0
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.4.
|
|
94
|
+
"version": "6.4.3",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
|
@@ -3645,7 +3645,13 @@ const DataGrid = forwardRef(
|
|
|
3645
3645
|
defaultFlex: 1,
|
|
3646
3646
|
sortable: sortable,
|
|
3647
3647
|
link: column.link ?? {},
|
|
3648
|
-
|
|
3648
|
+
// Floor every column: the datagrid's flex solver
|
|
3649
|
+
// hands the last unclamped columns the leftover
|
|
3650
|
+
// width VERBATIM, which goes negative once the
|
|
3651
|
+
// clamped columns exceed the container (narrow
|
|
3652
|
+
// screens) — columns then render a few px wide
|
|
3653
|
+
// with no horizontal scrollbar ever appearing.
|
|
3654
|
+
minWidth: column.minWidth ?? 80,
|
|
3649
3655
|
maxWidth: column.maxWidth ?? undefined,
|
|
3650
3656
|
textAlign: column.textAlign ?? undefined,
|
|
3651
3657
|
textVerticalAlign:
|
package/src/components/Field.jsx
CHANGED
|
@@ -42,6 +42,11 @@ import VisnsAutocomplete from './Autocomplete';
|
|
|
42
42
|
import VisnsDropZone from './DropZone';
|
|
43
43
|
import MultiCheckbox from './MultiCheckbox';
|
|
44
44
|
import DatePickerPortal from './utils/DatePickerPortal';
|
|
45
|
+
import {
|
|
46
|
+
fromNativeInputValue,
|
|
47
|
+
toNativeInputValue,
|
|
48
|
+
useNativeDateInput,
|
|
49
|
+
} from './utils/nativeDateInput';
|
|
45
50
|
|
|
46
51
|
import 'react-toggle/style.css';
|
|
47
52
|
import 'react-datepicker/dist/react-datepicker.css';
|
|
@@ -166,6 +171,15 @@ function Field({
|
|
|
166
171
|
const [emailSuggestions, setEmailSuggestions] = useState([]);
|
|
167
172
|
const [showEmailSuggestions, setShowEmailSuggestions] = useState(false);
|
|
168
173
|
|
|
174
|
+
/**
|
|
175
|
+
* On touch / narrow devices `date` and `datetime` fields swap
|
|
176
|
+
* react-datepicker's popup calendar for the platform's own picker — the
|
|
177
|
+
* popup is clipped by modals on a phone and its day cells are far below a
|
|
178
|
+
* comfortable tap target. Desktop rendering is untouched.
|
|
179
|
+
* See utils/nativeDateInput.js for the (identical) value contract.
|
|
180
|
+
*/
|
|
181
|
+
const nativeDateInput = useNativeDateInput();
|
|
182
|
+
|
|
169
183
|
/**
|
|
170
184
|
* `colour` field popup picker: dismiss the popover when the user clicks
|
|
171
185
|
* anywhere outside it or presses Escape. Picking a colour inside the
|
|
@@ -3093,12 +3107,70 @@ function Field({
|
|
|
3093
3107
|
}
|
|
3094
3108
|
};
|
|
3095
3109
|
|
|
3110
|
+
/**
|
|
3111
|
+
* The touch/narrow-viewport rendering of a `date` / `datetime` field: the
|
|
3112
|
+
* platform's own picker instead of react-datepicker's popup calendar.
|
|
3113
|
+
*
|
|
3114
|
+
* It is a drop-in for the DatePicker above — same error class, same
|
|
3115
|
+
* `onChangeDate(value, id)` call, same value shape in form state (a JS
|
|
3116
|
+
* `Date`, or `null` once cleared). The conversions live in
|
|
3117
|
+
* utils/nativeDateInput.js.
|
|
3118
|
+
*
|
|
3119
|
+
* The library's own clear affordance is kept (react-datepicker renders one
|
|
3120
|
+
* via `isClearable`, and iOS offers no way to empty a date input once it
|
|
3121
|
+
* has a value), so the browser's calendar glyph is hidden while it shows —
|
|
3122
|
+
* two clear/open icons side by side would be one too many. Tapping the
|
|
3123
|
+
* field still opens the native picker on every touch platform.
|
|
3124
|
+
*
|
|
3125
|
+
* @param {boolean} withTime `datetime-local` rather than `date`
|
|
3126
|
+
*/
|
|
3127
|
+
const renderNativeDateInput = (withTime) => {
|
|
3128
|
+
const value = toNativeInputValue(inputValue, withTime);
|
|
3129
|
+
|
|
3130
|
+
return (
|
|
3131
|
+
<div className={styles.nativeDateWrap}>
|
|
3132
|
+
<input
|
|
3133
|
+
type={withTime ? 'datetime-local' : 'date'}
|
|
3134
|
+
className={`${styles.nativeDateInput} ${
|
|
3135
|
+
value ? styles.nativeDateFilled : ''
|
|
3136
|
+
} ${inputClass[settings.id] || ''}`}
|
|
3137
|
+
value={value}
|
|
3138
|
+
onChange={(e) =>
|
|
3139
|
+
onChangeDate(
|
|
3140
|
+
fromNativeInputValue(
|
|
3141
|
+
e.target.value,
|
|
3142
|
+
inputValue,
|
|
3143
|
+
withTime
|
|
3144
|
+
),
|
|
3145
|
+
settings.id
|
|
3146
|
+
)
|
|
3147
|
+
}
|
|
3148
|
+
autoComplete="off"
|
|
3149
|
+
/>
|
|
3150
|
+
{value && (
|
|
3151
|
+
<button
|
|
3152
|
+
type="button"
|
|
3153
|
+
aria-label="Clear date"
|
|
3154
|
+
className={styles.nativeDateClear}
|
|
3155
|
+
onClick={() => onChangeDate(null, settings.id)}
|
|
3156
|
+
>
|
|
3157
|
+
<X size={16} />
|
|
3158
|
+
</button>
|
|
3159
|
+
)}
|
|
3160
|
+
</div>
|
|
3161
|
+
);
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3096
3164
|
const renderAdditionalContainer = () => {
|
|
3097
3165
|
switch (settings.type) {
|
|
3098
3166
|
case 'date':
|
|
3099
3167
|
case 'datetime':
|
|
3168
|
+
if (nativeDateInput) {
|
|
3169
|
+
return renderNativeDateInput(settings.type === 'datetime');
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3100
3172
|
return (
|
|
3101
|
-
<DatePickerPortal
|
|
3173
|
+
<DatePickerPortal
|
|
3102
3174
|
containerRef={containerRef}
|
|
3103
3175
|
centerScreen={settings.centerScreen}
|
|
3104
3176
|
modalContext={settings.modalContext}
|
|
@@ -734,6 +734,14 @@ function Navigation({
|
|
|
734
734
|
</nav>
|
|
735
735
|
</div>
|
|
736
736
|
</aside>
|
|
737
|
+
{isOpen ? (
|
|
738
|
+
<button
|
|
739
|
+
type="button"
|
|
740
|
+
className={styles['drawer-backdrop']}
|
|
741
|
+
aria-label="Close menu"
|
|
742
|
+
onClick={() => setOpen(false)}
|
|
743
|
+
/>
|
|
744
|
+
) : null}
|
|
737
745
|
<main className={styles.content}>
|
|
738
746
|
<div className={styles['content-header']}>
|
|
739
747
|
{navConfig.search?.enable === true && (
|
|
@@ -13,6 +13,7 @@ function TableFilter({
|
|
|
13
13
|
const [visibleChildren, setVisibleChildren] = useState({});
|
|
14
14
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
15
15
|
const isFirstRender = useRef(true);
|
|
16
|
+
const listRef = useRef(null);
|
|
16
17
|
|
|
17
18
|
// Initialize visible children for all parent categories and set checkbox function availability
|
|
18
19
|
useEffect(() => {
|
|
@@ -500,6 +501,10 @@ function TableFilter({
|
|
|
500
501
|
setMobileMenuOpen(false);
|
|
501
502
|
};
|
|
502
503
|
|
|
504
|
+
// `data-active` below marks the selected top-level item. It is what the
|
|
505
|
+
// mobile tab strip scrolls into view on mount; `filterTable` destructures
|
|
506
|
+
// only the dataset keys it names, so the extra attribute is inert to the
|
|
507
|
+
// click handling and to every existing consumer.
|
|
503
508
|
const renderContent = (d) => {
|
|
504
509
|
// Determine if this is a parent category
|
|
505
510
|
const isParent = d.isParent === true;
|
|
@@ -519,6 +524,7 @@ function TableFilter({
|
|
|
519
524
|
data-filtertype="parent"
|
|
520
525
|
data-url={d.url || ''}
|
|
521
526
|
data-value={d.hasOwnProperty('value') ? d.value : ''}
|
|
527
|
+
data-active={d.show === true ? 'true' : undefined}
|
|
522
528
|
onClick={filterTable}
|
|
523
529
|
>
|
|
524
530
|
{d.label}
|
|
@@ -534,6 +540,7 @@ function TableFilter({
|
|
|
534
540
|
data-filtertype="parent"
|
|
535
541
|
data-url={d.url || ''}
|
|
536
542
|
data-value={d.hasOwnProperty('value') ? d.value : ''}
|
|
543
|
+
data-active={d.show === true ? 'true' : undefined}
|
|
537
544
|
onClick={filterTable}
|
|
538
545
|
className={parentClass}
|
|
539
546
|
>
|
|
@@ -616,21 +623,68 @@ function TableFilter({
|
|
|
616
623
|
|
|
617
624
|
const activeLabel = activeFilter ? activeFilter.label : 'Select Option';
|
|
618
625
|
|
|
626
|
+
// A rail whose items are a flat list of tabs renders on narrow viewports as
|
|
627
|
+
// a single-row strip that pans with a thumb, instead of collapsing behind a
|
|
628
|
+
// hamburger of its own — the app shell already owns one, and the collapse
|
|
629
|
+
// cost two taps per switch. A rail with expandable parents keeps the
|
|
630
|
+
// collapse: a nested submenu has no single row to pan.
|
|
631
|
+
//
|
|
632
|
+
// Nothing here runs above the strip's breakpoint (759px, below): the class
|
|
633
|
+
// is only styled inside that media query, and `.mobileToggle` is
|
|
634
|
+
// `display: none` at every width above it, so leaving it out of the tree
|
|
635
|
+
// for a flat rail is invisible on desktop and tablet.
|
|
636
|
+
const isFlatStrip = !(filters || []).some(
|
|
637
|
+
(filter) =>
|
|
638
|
+
filter &&
|
|
639
|
+
filter.isParent === true &&
|
|
640
|
+
Array.isArray(filter.children) &&
|
|
641
|
+
filter.children.length > 0
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const activeId = activeFilter ? activeFilter.id : null;
|
|
645
|
+
|
|
646
|
+
// Bring the selected tab into view when the strip is wider than its box.
|
|
647
|
+
// Guarded on overflow so this never scrolls anything at widths where the
|
|
648
|
+
// rail is a plain vertical list.
|
|
649
|
+
useEffect(() => {
|
|
650
|
+
if (!isFlatStrip) return;
|
|
651
|
+
|
|
652
|
+
const list = listRef.current;
|
|
653
|
+
|
|
654
|
+
if (!list || typeof list.querySelector !== 'function') return;
|
|
655
|
+
if (list.scrollWidth <= list.clientWidth) return;
|
|
656
|
+
|
|
657
|
+
const active = list.querySelector('[data-active="true"]');
|
|
658
|
+
|
|
659
|
+
if (active && typeof active.scrollIntoView === 'function') {
|
|
660
|
+
active.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
661
|
+
}
|
|
662
|
+
}, [isFlatStrip, activeId]);
|
|
663
|
+
|
|
619
664
|
return (
|
|
620
|
-
<div
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
<span
|
|
631
|
-
|
|
632
|
-
|
|
665
|
+
<div
|
|
666
|
+
className={`${styles.tableFilterContainer} ${
|
|
667
|
+
isFlatStrip ? styles.flatFilter : ''
|
|
668
|
+
}`}
|
|
669
|
+
>
|
|
670
|
+
{!isFlatStrip && (
|
|
671
|
+
<div className={styles.mobileToggle} onClick={toggleMobileMenu}>
|
|
672
|
+
<span className={styles.activeFilterLabel}>
|
|
673
|
+
{activeLabel}
|
|
674
|
+
</span>
|
|
675
|
+
<span
|
|
676
|
+
className={`${styles.mobileMenuIcon} ${
|
|
677
|
+
mobileMenuOpen ? styles.open : ''
|
|
678
|
+
}`}
|
|
679
|
+
>
|
|
680
|
+
<span></span>
|
|
681
|
+
<span></span>
|
|
682
|
+
<span></span>
|
|
683
|
+
</span>
|
|
684
|
+
</div>
|
|
685
|
+
)}
|
|
633
686
|
<ul
|
|
687
|
+
ref={listRef}
|
|
634
688
|
className={`${styles.tableFilter} ${
|
|
635
689
|
mobileMenuOpen ? styles.mobileOpen : ''
|
|
636
690
|
}`}
|
|
@@ -282,8 +282,10 @@ export const renderDateColumn = ({
|
|
|
282
282
|
name: column.id,
|
|
283
283
|
filterEditor: filterEditor,
|
|
284
284
|
filterEditorProps: filterEditorProps,
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
// Config-first: these sit after the commonProps spread, so a literal
|
|
286
|
+
// here silently discards an explicit column.minWidth from the app.
|
|
287
|
+
defaultWidth: column.defaultWidth ?? 120,
|
|
288
|
+
minWidth: column.minWidth ?? 120,
|
|
287
289
|
render: ({ data }) => {
|
|
288
290
|
if (
|
|
289
291
|
data &&
|
|
@@ -343,8 +345,9 @@ export const renderDateTimeColumn = ({
|
|
|
343
345
|
name: column.id,
|
|
344
346
|
filterEditor: filterEditor,
|
|
345
347
|
filterEditorProps: filterEditorProps,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
+
// Config-first — see renderDateColumn.
|
|
349
|
+
defaultWidth: column.defaultWidth ?? 160,
|
|
350
|
+
minWidth: column.minWidth ?? 160,
|
|
348
351
|
render: ({ data }) => {
|
|
349
352
|
if (
|
|
350
353
|
data &&
|
|
@@ -2530,8 +2533,9 @@ export const renderDateRangeColumn = ({
|
|
|
2530
2533
|
name: `${column.id[0]}.${column.id[1]}.`,
|
|
2531
2534
|
filterEditor: filterEditor,
|
|
2532
2535
|
filterEditorProps: filterEditorProps,
|
|
2533
|
-
|
|
2534
|
-
|
|
2536
|
+
// Config-first — see renderDateColumn.
|
|
2537
|
+
defaultWidth: column.defaultWidth ?? 200,
|
|
2538
|
+
minWidth: column.minWidth ?? 200,
|
|
2535
2539
|
render: ({ data }) => {
|
|
2536
2540
|
let value = '';
|
|
2537
2541
|
|
|
@@ -1799,3 +1799,71 @@ input[type='file']:hover {
|
|
|
1799
1799
|
min-height: 3lh;
|
|
1800
1800
|
}
|
|
1801
1801
|
}
|
|
1802
|
+
|
|
1803
|
+
/* =========================================================================
|
|
1804
|
+
Native date input (touch / narrow viewports)
|
|
1805
|
+
|
|
1806
|
+
`date` and `datetime` fields swap react-datepicker's popup calendar for
|
|
1807
|
+
the platform picker on phones — see utils/nativeDateInput.js. The box
|
|
1808
|
+
itself is already styled by the global
|
|
1809
|
+
`input[type]:not(...)` rule that every other text field uses, so only the
|
|
1810
|
+
things that rule does not cover live here: the iOS appearance reset, a
|
|
1811
|
+
44px-class tap height, and the clear button that stands in for
|
|
1812
|
+
react-datepicker's `isClearable` cross.
|
|
1813
|
+
========================================================================= */
|
|
1814
|
+
|
|
1815
|
+
.nativeDateWrap {
|
|
1816
|
+
position: relative;
|
|
1817
|
+
width: 100%;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/* `[type]` only to clear the global input rule's specificity — the padding,
|
|
1821
|
+
border, radius and colour all still come from there. */
|
|
1822
|
+
input.nativeDateInput[type] {
|
|
1823
|
+
-webkit-appearance: none;
|
|
1824
|
+
appearance: none;
|
|
1825
|
+
display: block;
|
|
1826
|
+
width: 100%;
|
|
1827
|
+
/* iOS sizes date inputs to their content and centres nothing; a floor
|
|
1828
|
+
keeps the field the same height as its text-input neighbours and
|
|
1829
|
+
comfortably tappable. */
|
|
1830
|
+
min-height: 2.9rem;
|
|
1831
|
+
font-family: inherit;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/* Only once there is a value — and therefore a clear button — does the box
|
|
1835
|
+
need to reserve room on the right, and only then would the browser's own
|
|
1836
|
+
calendar glyph sit under ours. */
|
|
1837
|
+
input.nativeDateFilled[type] {
|
|
1838
|
+
padding-right: 2.75rem;
|
|
1839
|
+
|
|
1840
|
+
&::-webkit-calendar-picker-indicator {
|
|
1841
|
+
-webkit-appearance: none;
|
|
1842
|
+
display: none;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
.nativeDateClear {
|
|
1847
|
+
position: absolute;
|
|
1848
|
+
top: 50%;
|
|
1849
|
+
right: 0.4rem;
|
|
1850
|
+
transform: translateY(-50%);
|
|
1851
|
+
display: flex;
|
|
1852
|
+
align-items: center;
|
|
1853
|
+
justify-content: center;
|
|
1854
|
+
width: 2rem;
|
|
1855
|
+
height: 2rem;
|
|
1856
|
+
padding: 0;
|
|
1857
|
+
border: none;
|
|
1858
|
+
background: transparent;
|
|
1859
|
+
border-radius: 50%;
|
|
1860
|
+
cursor: pointer;
|
|
1861
|
+
/* Same token and weight as the datepicker's close icon */
|
|
1862
|
+
color: rgba(var(--primary-rgb, 31, 42, 55), 0.45);
|
|
1863
|
+
|
|
1864
|
+
&:hover,
|
|
1865
|
+
&:focus {
|
|
1866
|
+
color: rgba(var(--primary-rgb, 31, 42, 55), 1);
|
|
1867
|
+
outline: none;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
@@ -952,6 +952,12 @@
|
|
|
952
952
|
}
|
|
953
953
|
}
|
|
954
954
|
|
|
955
|
+
/* Rendered only while the mobile drawer is open; the phone block below
|
|
956
|
+
displays it. Desktop never opens the drawer, but the guard costs nothing. */
|
|
957
|
+
.drawer-backdrop {
|
|
958
|
+
display: none;
|
|
959
|
+
}
|
|
960
|
+
|
|
955
961
|
@media (max-width: 1024px) {
|
|
956
962
|
.hwrap {
|
|
957
963
|
grid-template-columns: auto 1fr auto;
|
|
@@ -970,7 +976,10 @@
|
|
|
970
976
|
}
|
|
971
977
|
|
|
972
978
|
.aside--cms {
|
|
973
|
-
|
|
979
|
+
/* A panel, not a full-screen sheet: the page stays visible behind
|
|
980
|
+
the backdrop, which gives tap-outside-to-close and stops a short
|
|
981
|
+
nav list reading as a vast empty void. */
|
|
982
|
+
width: min(84vw, 320px);
|
|
974
983
|
/* The content header is sticky at z-index 995, so the drawer sits
|
|
975
984
|
below it: start the drawer under the 50px header instead of
|
|
976
985
|
letting the header paint over the drawer's top row. This also
|
|
@@ -981,12 +990,45 @@
|
|
|
981
990
|
transition: all 0.55s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
982
991
|
will-change: transform;
|
|
983
992
|
z-index: 600;
|
|
993
|
+
box-shadow: 24px 0 48px rgba(0, 0, 0, 0.35);
|
|
984
994
|
|
|
985
995
|
/* The header already anchors the screen; the drawer's own logo
|
|
986
996
|
plate would render as a second 50px brand row. */
|
|
987
997
|
.logo {
|
|
988
998
|
display: none;
|
|
989
999
|
}
|
|
1000
|
+
|
|
1001
|
+
/* Phone-sized touch targets: the desktop rail's compact rows are
|
|
1002
|
+
finger-hostile at arm's length on site. */
|
|
1003
|
+
.app-nav .nav-item {
|
|
1004
|
+
padding: 0.2rem 0.65rem;
|
|
1005
|
+
|
|
1006
|
+
a {
|
|
1007
|
+
padding: 0.85rem 1rem;
|
|
1008
|
+
font-size: 1rem;
|
|
1009
|
+
line-height: 1.3;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
svg {
|
|
1013
|
+
width: 20px;
|
|
1014
|
+
height: 20px;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
.drawer-backdrop {
|
|
1020
|
+
display: block;
|
|
1021
|
+
position: fixed;
|
|
1022
|
+
top: 50px;
|
|
1023
|
+
left: 0;
|
|
1024
|
+
right: 0;
|
|
1025
|
+
bottom: 0;
|
|
1026
|
+
background: rgba(0, 0, 0, 0.45);
|
|
1027
|
+
border: none;
|
|
1028
|
+
margin: 0;
|
|
1029
|
+
padding: 0;
|
|
1030
|
+
appearance: none;
|
|
1031
|
+
z-index: 599;
|
|
990
1032
|
}
|
|
991
1033
|
|
|
992
1034
|
.aside--cms--open {
|
|
@@ -298,4 +298,104 @@
|
|
|
298
298
|
.collapsibleMenu {
|
|
299
299
|
border-left: none;
|
|
300
300
|
}
|
|
301
|
+
|
|
302
|
+
/* ---------------------------------------------------------------------
|
|
303
|
+
Flat rails: a one-row tab strip instead of the hamburger.
|
|
304
|
+
|
|
305
|
+
`.flatFilter` is set by TableFilter on any rail whose items have no
|
|
306
|
+
expandable children, and exists only inside this media query — above
|
|
307
|
+
759px the class matches nothing and the rail is byte-for-byte the
|
|
308
|
+
vertical list it has always been.
|
|
309
|
+
|
|
310
|
+
The strip pans horizontally with a thumb and marks the selection with a
|
|
311
|
+
3px accent edge rather than the full brand fill: on a phone the fill was
|
|
312
|
+
a solid band the width of the screen, which read as a header rather than
|
|
313
|
+
as one tab out of several.
|
|
314
|
+
|
|
315
|
+
These selectors are deliberately longer than the ones they override —
|
|
316
|
+
`.subactive` and `.activetab button` carry `!important`, so only a more
|
|
317
|
+
specific `!important` can take the fill back off. --------------------- */
|
|
318
|
+
|
|
319
|
+
.flatFilter .tableFilter,
|
|
320
|
+
.flatFilter .tableFilter.mobileOpen {
|
|
321
|
+
display: flex;
|
|
322
|
+
flex-wrap: nowrap;
|
|
323
|
+
align-items: stretch;
|
|
324
|
+
gap: var(--spacing-xs, 0.25rem);
|
|
325
|
+
max-height: none;
|
|
326
|
+
overflow-x: auto;
|
|
327
|
+
overflow-y: hidden;
|
|
328
|
+
/* Momentum panning on iOS. */
|
|
329
|
+
-webkit-overflow-scrolling: touch;
|
|
330
|
+
/* The scrollbar is noise on a two-tab strip; the overflowing tab is
|
|
331
|
+
the affordance. */
|
|
332
|
+
scrollbar-width: none;
|
|
333
|
+
-ms-overflow-style: none;
|
|
334
|
+
/* The dropdown drew itself a card. A strip sits on the panel it is
|
|
335
|
+
already inside. */
|
|
336
|
+
padding: 0;
|
|
337
|
+
border: none;
|
|
338
|
+
border-radius: 0;
|
|
339
|
+
margin-bottom: 0;
|
|
340
|
+
/* No baseline rule under the row: every surface this rail sits on
|
|
341
|
+
(`.grid__subnav` in Profile, GenericIndex and GenericDetail) already
|
|
342
|
+
draws its own border a few pixels below, and a second hairline
|
|
343
|
+
alongside it read as a mistake. The 3px edge under the selected tab
|
|
344
|
+
is the whole marker. */
|
|
345
|
+
box-shadow: none;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.flatFilter .tableFilter::-webkit-scrollbar {
|
|
349
|
+
display: none;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.flatFilter .tableFilter > li {
|
|
353
|
+
flex: 0 0 auto;
|
|
354
|
+
margin: 0;
|
|
355
|
+
border-bottom: none;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Inactive tabs are muted labels, not tinted blocks. `white-space: nowrap`
|
|
359
|
+
is what keeps the row one row: without it a two-word tab wraps and the
|
|
360
|
+
strip grows a second line instead of overflowing. */
|
|
361
|
+
.flatFilter .tableFilter > li > .link,
|
|
362
|
+
.flatFilter .tableFilter > li > .parentLink,
|
|
363
|
+
.flatFilter .tableFilter > li > button {
|
|
364
|
+
display: block;
|
|
365
|
+
white-space: nowrap;
|
|
366
|
+
padding: 0.75rem var(--spacing-md, 1rem);
|
|
367
|
+
border: none;
|
|
368
|
+
border-bottom: 3px solid transparent;
|
|
369
|
+
border-radius: 0;
|
|
370
|
+
background: none !important;
|
|
371
|
+
color: var(--paragraph-color, #6b7280) !important;
|
|
372
|
+
font-size: var(--font-size-sm, 0.8125rem);
|
|
373
|
+
font-weight: var(--font-weight-medium, 500);
|
|
374
|
+
cursor: pointer;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* The selected tab. `--primary-color` first because it is what the rail
|
|
378
|
+
already fills the active item with everywhere else; `--accent-color`
|
|
379
|
+
covers apps that only declare that one, and the literal keeps the edge
|
|
380
|
+
visible in an app that declares neither. */
|
|
381
|
+
.flatFilter .tableFilter > li > .subactive,
|
|
382
|
+
.flatFilter .tableFilter > li.activetab > button {
|
|
383
|
+
background: none !important;
|
|
384
|
+
color: var(--heading-color, #111) !important;
|
|
385
|
+
font-weight: var(--font-weight-semibold, 600);
|
|
386
|
+
border-bottom-color: var(
|
|
387
|
+
--primary-color,
|
|
388
|
+
var(--accent-color, #0076c0)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* Touch devices keep :hover stuck on the last tap, which on the old rule
|
|
393
|
+
tinted whichever tab was tapped last and made it compete with the
|
|
394
|
+
selected one. */
|
|
395
|
+
.flatFilter .tableFilter > li > .link:hover,
|
|
396
|
+
.flatFilter .tableFilter > li > .parentLink:hover,
|
|
397
|
+
.flatFilter .tableFilter > li > button:hover {
|
|
398
|
+
background: none !important;
|
|
399
|
+
color: var(--heading-color, #111) !important;
|
|
400
|
+
}
|
|
301
401
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import moment from 'moment';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Native date input support for touch / narrow devices.
|
|
6
|
+
*
|
|
7
|
+
* react-datepicker's popup calendar is a poor fit for a phone: inside a modal
|
|
8
|
+
* it is clipped by the dialog, the month/year dropdowns get cut off and every
|
|
9
|
+
* day cell is a sub-40px tap target. On those devices the platform's own date
|
|
10
|
+
* picker is both bigger and familiar, so `date` / `datetime` fields render an
|
|
11
|
+
* `<input type="date">` / `<input type="datetime-local">` instead.
|
|
12
|
+
*
|
|
13
|
+
* The value contract is unchanged: whatever the react-datepicker path hands to
|
|
14
|
+
* `Form.handleChangeDate` (a JS `Date`, or `null` when cleared) is exactly what
|
|
15
|
+
* the native path hands it too — see `fromNativeInputValue`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Phones and small tablets in portrait. Anything wider keeps the desktop
|
|
19
|
+
// calendar even if the browser reports a coarse pointer (e.g. a touchscreen
|
|
20
|
+
// laptop is still a mouse-and-keyboard experience for a form).
|
|
21
|
+
export const NATIVE_DATE_MAX_WIDTH = 760;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {boolean} true when the platform date picker should be preferred.
|
|
25
|
+
* SSR-safe: false when there is no `window`.
|
|
26
|
+
*/
|
|
27
|
+
export const prefersNativeDateInput = () => {
|
|
28
|
+
if (typeof window === 'undefined') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const coarsePointer =
|
|
33
|
+
typeof window.matchMedia === 'function' &&
|
|
34
|
+
window.matchMedia('(pointer: coarse)').matches;
|
|
35
|
+
|
|
36
|
+
return coarsePointer || window.innerWidth <= NATIVE_DATE_MAX_WIDTH;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hook form of `prefersNativeDateInput`, re-evaluated on resize / orientation
|
|
41
|
+
* change and on pointer-capability changes (docking a tablet to a mouse).
|
|
42
|
+
*
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export const useNativeDateInput = () => {
|
|
46
|
+
const [isNative, setIsNative] = useState(prefersNativeDateInput);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (typeof window === 'undefined') {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const update = () => setIsNative(prefersNativeDateInput());
|
|
54
|
+
|
|
55
|
+
window.addEventListener('resize', update);
|
|
56
|
+
|
|
57
|
+
const query =
|
|
58
|
+
typeof window.matchMedia === 'function'
|
|
59
|
+
? window.matchMedia('(pointer: coarse)')
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
// Safari < 14 only has the deprecated add/removeListener pair
|
|
63
|
+
if (query) {
|
|
64
|
+
if (typeof query.addEventListener === 'function') {
|
|
65
|
+
query.addEventListener('change', update);
|
|
66
|
+
} else if (typeof query.addListener === 'function') {
|
|
67
|
+
query.addListener(update);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
window.removeEventListener('resize', update);
|
|
73
|
+
|
|
74
|
+
if (query) {
|
|
75
|
+
if (typeof query.removeEventListener === 'function') {
|
|
76
|
+
query.removeEventListener('change', update);
|
|
77
|
+
} else if (typeof query.removeListener === 'function') {
|
|
78
|
+
query.removeListener(update);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return isNative;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Form state (a `Date`, or a date-ish string on a form that was hydrated from
|
|
89
|
+
* the API before Form.fetchData normalised it) -> the string an
|
|
90
|
+
* `<input type="date">` / `<input type="datetime-local">` expects.
|
|
91
|
+
*
|
|
92
|
+
* @param {Date|string|null|undefined} value
|
|
93
|
+
* @param {boolean} withTime true for `datetime-local`
|
|
94
|
+
* @returns {string} '' when there is nothing valid to show
|
|
95
|
+
*/
|
|
96
|
+
export const toNativeInputValue = (value, withTime = false) => {
|
|
97
|
+
if (
|
|
98
|
+
value === null ||
|
|
99
|
+
value === undefined ||
|
|
100
|
+
value === '' ||
|
|
101
|
+
value === 'null'
|
|
102
|
+
) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const parsed = moment(value);
|
|
107
|
+
|
|
108
|
+
if (!parsed.isValid()) {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parsed.format(
|
|
113
|
+
withTime ? 'YYYY-MM-DD[T]HH:mm' : 'YYYY-MM-DD'
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The native input's raw string -> the value `onChangeDate` already receives
|
|
119
|
+
* from react-datepicker.
|
|
120
|
+
*
|
|
121
|
+
* Two behaviours are copied deliberately from react-datepicker so the two
|
|
122
|
+
* paths cannot drift:
|
|
123
|
+
*
|
|
124
|
+
* - clearing yields `null` (react-datepicker's clear button calls
|
|
125
|
+
* `onChange(null)`), which Form's required-field check reads as empty;
|
|
126
|
+
* - picking a new day on a date-only field keeps the previously selected
|
|
127
|
+
* time of day (react-datepicker's `setSelected` does the same `setTime`
|
|
128
|
+
* on hour/minute/second), so editing the date of a timestamp column does
|
|
129
|
+
* not silently reset it to midnight. Milliseconds are dropped, again as
|
|
130
|
+
* react-datepicker does.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} raw the input element's value
|
|
133
|
+
* @param {Date|string|null} previous the current form value for the field
|
|
134
|
+
* @param {boolean} withTime true for `datetime-local`
|
|
135
|
+
* @returns {Date|null}
|
|
136
|
+
*/
|
|
137
|
+
export const fromNativeInputValue = (raw, previous, withTime = false) => {
|
|
138
|
+
if (!raw) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// `datetime-local` normally yields 'YYYY-MM-DDTHH:mm', but a non-default
|
|
143
|
+
// `step` makes browsers append seconds — accept both.
|
|
144
|
+
const parsed = withTime
|
|
145
|
+
? moment(
|
|
146
|
+
raw,
|
|
147
|
+
['YYYY-MM-DD[T]HH:mm', 'YYYY-MM-DD[T]HH:mm:ss'],
|
|
148
|
+
true
|
|
149
|
+
)
|
|
150
|
+
: moment(raw, 'YYYY-MM-DD', true);
|
|
151
|
+
|
|
152
|
+
if (!parsed.isValid()) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!withTime) {
|
|
157
|
+
const prior = previous ? moment(previous) : null;
|
|
158
|
+
|
|
159
|
+
if (prior && prior.isValid()) {
|
|
160
|
+
parsed.set({
|
|
161
|
+
hour: prior.hour(),
|
|
162
|
+
minute: prior.minute(),
|
|
163
|
+
second: prior.second(),
|
|
164
|
+
millisecond: 0,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return parsed.toDate();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export default useNativeDateInput;
|