@visns-studio/visns-components 6.4.2 → 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.2",
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": [
@@ -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}
@@ -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
+ }
@@ -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;