@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.
|
|
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": [
|
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}
|
|
@@ -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;
|