@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 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.1",
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
- minWidth: column.minWidth ?? undefined,
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:
@@ -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 className={styles.tableFilterContainer}>
621
- <div className={styles.mobileToggle} onClick={toggleMobileMenu}>
622
- <span className={styles.activeFilterLabel}>{activeLabel}</span>
623
- <span
624
- className={`${styles.mobileMenuIcon} ${
625
- mobileMenuOpen ? styles.open : ''
626
- }`}
627
- >
628
- <span></span>
629
- <span></span>
630
- <span></span>
631
- </span>
632
- </div>
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
- defaultWidth: 120, // Set default width for date columns
286
- minWidth: 120, // Set minimum width for date columns
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
- defaultWidth: 160, // Set default width for datetime columns
347
- minWidth: 160, // Set minimum width for datetime columns
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
- defaultWidth: 200, // Set default width for daterange columns
2534
- minWidth: 200, // Set minimum width for daterange columns
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
- width: 100%;
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;