@visns-studio/visns-components 6.4.2 → 6.4.4

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.4",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -39,6 +39,7 @@ import NumberFilter from '@visns-studio/visns-datagrid-enterprise/NumberFilter';
39
39
  import SelectFilter from '@visns-studio/visns-datagrid-enterprise/SelectFilter';
40
40
  import DateFilter from '@visns-studio/visns-datagrid-enterprise/DateFilter';
41
41
  import ReactDataGrid from '@visns-studio/visns-datagrid-enterprise';
42
+ import NativeDateFilterEditor from './columns/NativeDateFilterEditor';
42
43
  import { toast } from 'react-toastify';
43
44
  import fetchUtil from '../utils/fetchUtil';
44
45
  import { confirmDialog } from './utils/ConfirmDialog';
@@ -3725,7 +3726,21 @@ const DataGrid = forwardRef(
3725
3726
  if (column.filter && column.filter.type) {
3726
3727
  switch (column.filter.type) {
3727
3728
  case 'date':
3728
- filterEditor = DateFilter;
3729
+ // Native `<input type="date">` by default
3730
+ // — the popup calendar is clipped inside a
3731
+ // scrolling grid and its day cells are far
3732
+ // below a comfortable tap target. A column
3733
+ // can opt back into it with
3734
+ // `filter: { datePicker: 'popup' }`, the
3735
+ // same escape hatch Field uses. Both
3736
+ // editors emit the identical value
3737
+ // (`dateFormat`-formatted string, `''`
3738
+ // when cleared), so the posted `where[]`
3739
+ // entry is unchanged either way.
3740
+ filterEditor =
3741
+ column.filter.datePicker === 'popup'
3742
+ ? DateFilter
3743
+ : NativeDateFilterEditor;
3729
3744
  filterEditorProps = (props, { index }) => {
3730
3745
  return {
3731
3746
  dateFormat: 'DD-MM-YYYY',
@@ -42,6 +42,10 @@ 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
+ } from './utils/nativeDateInput';
45
49
 
46
50
  import 'react-toggle/style.css';
47
51
  import 'react-datepicker/dist/react-datepicker.css';
@@ -3093,12 +3097,80 @@ function Field({
3093
3097
  }
3094
3098
  };
3095
3099
 
3100
+ /**
3101
+ * The touch/narrow-viewport rendering of a `date` / `datetime` field: the
3102
+ * platform's own picker instead of react-datepicker's popup calendar.
3103
+ *
3104
+ * It is a drop-in for the DatePicker above — same error class, same
3105
+ * `onChangeDate(value, id)` call, same value shape in form state (a JS
3106
+ * `Date`, or `null` once cleared). The conversions live in
3107
+ * utils/nativeDateInput.js.
3108
+ *
3109
+ * The library's own clear affordance is kept (react-datepicker renders one
3110
+ * via `isClearable`, and iOS offers no way to empty a date input once it
3111
+ * has a value), so the browser's calendar glyph is hidden while it shows —
3112
+ * two clear/open icons side by side would be one too many. Tapping the
3113
+ * field still opens the native picker on every touch platform.
3114
+ *
3115
+ * @param {boolean} withTime `datetime-local` rather than `date`
3116
+ */
3117
+ const renderNativeDateInput = (withTime) => {
3118
+ const value = toNativeInputValue(inputValue, withTime);
3119
+
3120
+ return (
3121
+ <div className={styles.nativeDateWrap}>
3122
+ <input
3123
+ type={withTime ? 'datetime-local' : 'date'}
3124
+ className={`${styles.nativeDateInput} ${
3125
+ value ? styles.nativeDateFilled : ''
3126
+ } ${inputClass[settings.id] || ''}`}
3127
+ value={value}
3128
+ onChange={(e) =>
3129
+ onChangeDate(
3130
+ fromNativeInputValue(
3131
+ e.target.value,
3132
+ inputValue,
3133
+ withTime
3134
+ ),
3135
+ settings.id
3136
+ )
3137
+ }
3138
+ autoComplete="off"
3139
+ />
3140
+ {/* Always mounted: inserting this node while the input is
3141
+ focused makes mobile browsers dismiss the just-opened
3142
+ native picker (the DOM mutated around the focused
3143
+ control). Visibility is CSS-only. */}
3144
+ <button
3145
+ type="button"
3146
+ aria-label="Clear date"
3147
+ aria-hidden={value ? undefined : 'true'}
3148
+ tabIndex={value ? 0 : -1}
3149
+ className={`${styles.nativeDateClear} ${
3150
+ value ? '' : styles.nativeDateClearHidden
3151
+ }`}
3152
+ onClick={() => onChangeDate(null, settings.id)}
3153
+ >
3154
+ <X size={16} />
3155
+ </button>
3156
+ </div>
3157
+ );
3158
+ };
3159
+
3096
3160
  const renderAdditionalContainer = () => {
3097
3161
  switch (settings.type) {
3098
3162
  case 'date':
3099
3163
  case 'datetime':
3164
+ // Native on every viewport — the popup calendar clips inside
3165
+ // modals and duplicates what browsers now do well themselves.
3166
+ // A field can opt back into the react-datepicker popup with
3167
+ // `datePicker: 'popup'` in its config.
3168
+ if (settings.datePicker !== 'popup') {
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}
@@ -0,0 +1,434 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import moment from 'moment';
11
+ import { X } from 'lucide-react';
12
+ import { toNativeInputValue } from '../utils/nativeDateInput';
13
+
14
+ /**
15
+ * Native (`<input type="date">`) replacement for the datagrid's popup
16
+ * calendar column filter.
17
+ *
18
+ * The grid ships `DateFilter` from `@visns-studio/visns-datagrid-*`, which
19
+ * renders an Inovua `DateInput` — a text box plus its own floating calendar.
20
+ * That popup is the last remaining non-native date picker in the app: it is
21
+ * clipped inside scrolling grids, its day cells are far below a comfortable
22
+ * tap target, and it duplicates something every browser now does well. This
23
+ * editor renders the platform control instead, on every viewport.
24
+ *
25
+ * ---------------------------------------------------------------------------
26
+ * The filter-editor contract (community-edition
27
+ * Layout/ColumnLayout/Cell/renderFilter/index.tsx -> `GenericFilter.render`):
28
+ *
29
+ * filterValue { name, operator, type, value, ... } — never mutate it
30
+ * onChange called with a WHOLE filterValue object, `value` replaced
31
+ * emptyValue the filter type's "no filter" value (`''` for `date`)
32
+ * filterDelay ms to debounce before calling onChange (grid default 250)
33
+ * render REQUIRED wrapper — the editor must return `render(node)`,
34
+ * which supplies the filter-row chrome and settings icon
35
+ * cellProps the column's props (may carry `dateFormat`)
36
+ * filterEditorProps object, or `(props, { value, index }) => object`
37
+ * ref receives the editor instance; the grid calls `setValue()`
38
+ * on it (e.g. DataGrid's dependent-filter reset) and reads
39
+ * `getInputRef()`
40
+ * theme / rtl / readOnly / disabled / style / i18n / cell / renderInPortal
41
+ *
42
+ * ---------------------------------------------------------------------------
43
+ * Value shape — byte-identical to `DateFilter`.
44
+ *
45
+ * `DateInput` emits `dateMoment.format(dateFormat)` and `''` for a cleared
46
+ * value (packages/Calendar/src/DateInput/index.tsx `format()`), so with the
47
+ * `dateFormat: 'DD-MM-YYYY'` that DataGrid configures, the grid — and
48
+ * therefore the `where[]` entry posted to the server — has always carried a
49
+ * string like `'02-12-2025'`, and `''` once cleared. This editor formats with
50
+ * the same resolved `dateFormat` and clears to the same `emptyValue`, so no
51
+ * server-side filtering changes.
52
+ */
53
+
54
+ // Moment tokens that mean "this format carries a time of day". Escaped
55
+ // literals (`[at]`) are stripped first so they cannot produce a false match.
56
+ const TIME_TOKENS = /[HhmsSAa]/;
57
+
58
+ const formatHasTime = (format) =>
59
+ typeof format === 'string' &&
60
+ TIME_TOKENS.test(format.replace(/\[[^\]]*\]/g, ''));
61
+
62
+ /**
63
+ * Resolve the display format the same way `DateFilter` does: an explicit
64
+ * `cellProps.dateFormat` wins, otherwise `filterEditorProps` — which DataGrid
65
+ * passes as a function — is consulted.
66
+ *
67
+ * @returns {string} '' when nothing is configured, which is what
68
+ * `DateInput.format()` falls back on (moment then uses its
69
+ * own ISO default).
70
+ */
71
+ const resolveDateFormat = (props, value, index) => {
72
+ const { cellProps } = props;
73
+
74
+ if (cellProps && cellProps.dateFormat !== undefined) {
75
+ return cellProps.dateFormat;
76
+ }
77
+
78
+ return resolveEditorProps(props, value, index).dateFormat || '';
79
+ };
80
+
81
+ /**
82
+ * `filterEditorProps` may be an object or, as DataGrid passes it for date
83
+ * filters, a `(props, { value, index }) => object` factory. DateFilter also
84
+ * falls back to the copy carried on `filterValue`, so this does too.
85
+ */
86
+ function resolveEditorProps(props, value, index) {
87
+ let { filterEditorProps } = props;
88
+
89
+ if (filterEditorProps === undefined) {
90
+ filterEditorProps = props.filterValue && props.filterValue.filterEditorProps;
91
+ }
92
+
93
+ if (typeof filterEditorProps === 'function') {
94
+ return filterEditorProps(props, { value, index }) || {};
95
+ }
96
+
97
+ return filterEditorProps || {};
98
+ }
99
+
100
+ /**
101
+ * A stored filter value (formatted with `dateFormat`) -> the string the
102
+ * native input wants ('YYYY-MM-DD' / 'YYYY-MM-DDTHH:mm').
103
+ */
104
+ const toInputValue = (value, dateFormat, withTime) => {
105
+ if (value === null || value === undefined || value === '') {
106
+ return '';
107
+ }
108
+
109
+ // Strict against the configured format first — '02-12-2025' parsed
110
+ // loosely would be read as an ISO-ish string and come back wrong.
111
+ let parsed = dateFormat ? moment(value, dateFormat, true) : null;
112
+
113
+ if (!parsed || !parsed.isValid()) {
114
+ parsed = moment(value);
115
+ }
116
+
117
+ if (!parsed.isValid()) {
118
+ return '';
119
+ }
120
+
121
+ return toNativeInputValue(parsed, withTime);
122
+ };
123
+
124
+ /**
125
+ * The native input's raw string -> the value the grid and the server expect.
126
+ * Cleared / partially-typed input yields `emptyValue`, which `loadData` in
127
+ * DataGrid.jsx drops from the posted `where[]` so the grid shows every row.
128
+ */
129
+ const fromInputValue = (raw, dateFormat, withTime, emptyValue) => {
130
+ if (!raw) {
131
+ return emptyValue;
132
+ }
133
+
134
+ // `datetime-local` normally yields 'YYYY-MM-DDTHH:mm', but a non-default
135
+ // `step` makes browsers append seconds — accept both.
136
+ const parsed = withTime
137
+ ? moment(raw, ['YYYY-MM-DD[T]HH:mm', 'YYYY-MM-DD[T]HH:mm:ss'], true)
138
+ : moment(raw, 'YYYY-MM-DD', true);
139
+
140
+ if (!parsed.isValid()) {
141
+ return emptyValue;
142
+ }
143
+
144
+ // `format()` with no argument is moment's ISO default — exactly what
145
+ // `DateInput.format()` produces when no dateFormat is configured.
146
+ return dateFormat ? parsed.format(dateFormat) : parsed.format();
147
+ };
148
+
149
+ const isRangeOperator = (operator) =>
150
+ operator === 'inrange' || operator === 'notinrange';
151
+
152
+ const WRAPPER_CLASS =
153
+ 'InovuaReactDataGrid__column-header__filter ' +
154
+ 'InovuaReactDataGrid__column-header__filter--date ' +
155
+ 'visns-native-date-filter';
156
+
157
+ /**
158
+ * One `<input type="date">` (or `datetime-local`) dressed as a grid filter,
159
+ * with the clear affordance the popup calendar used to provide — Chrome's
160
+ * date input has no clear button of its own.
161
+ */
162
+ const NativeDateInput = ({
163
+ inputValue,
164
+ withTime,
165
+ disabled,
166
+ readOnly,
167
+ placeholder,
168
+ style,
169
+ onValueChange,
170
+ inputRef,
171
+ ...rest
172
+ }) => (
173
+ <span
174
+ className={`${WRAPPER_CLASS}${
175
+ inputValue ? '' : ' visns-native-date-filter--empty'
176
+ }${disabled ? ' visns-native-date-filter--disabled' : ''}`}
177
+ style={{ minWidth: 0, ...style }}
178
+ >
179
+ <input
180
+ {...rest}
181
+ ref={inputRef}
182
+ type={withTime ? 'datetime-local' : 'date'}
183
+ className="visns-native-date-filter__input"
184
+ value={inputValue}
185
+ disabled={disabled}
186
+ readOnly={readOnly}
187
+ // `<input type="date">` has no placeholder of its own — the
188
+ // browser always shows its locale mask — so the configured
189
+ // "Select Date" text becomes the accessible name instead.
190
+ aria-label={placeholder || undefined}
191
+ title={placeholder || undefined}
192
+ autoComplete="off"
193
+ onChange={(event) => onValueChange(event.target.value)}
194
+ />
195
+ {inputValue && !disabled && !readOnly ? (
196
+ <button
197
+ type="button"
198
+ aria-label="Clear filter"
199
+ className="visns-native-date-filter__clear"
200
+ onClick={() => onValueChange('')}
201
+ >
202
+ <X size={14} />
203
+ </button>
204
+ ) : null}
205
+ </span>
206
+ );
207
+
208
+ const NativeDateFilterEditor = forwardRef((props, ref) => {
209
+ const {
210
+ filterValue,
211
+ onChange,
212
+ filterDelay,
213
+ emptyValue,
214
+ readOnly,
215
+ disabled,
216
+ style,
217
+ render,
218
+ } = props;
219
+
220
+ // `''` is the `date` filter type's emptyValue (community-edition
221
+ // filterTypes.ts) — fall back to it so a grid without the prop still
222
+ // clears to what DateFilter emitted.
223
+ const noValue = emptyValue === undefined ? '' : emptyValue;
224
+
225
+ const range = isRangeOperator(filterValue && filterValue.operator);
226
+
227
+ const [value, setValue] = useState(() =>
228
+ filterValue ? filterValue.value || (range ? {} : '') : ''
229
+ );
230
+
231
+ const inputRef = useRef(null);
232
+ const timerRef = useRef(null);
233
+ const propValueRef = useRef(filterValue && filterValue.value);
234
+
235
+ // Keep the emitter fresh for the debounce timer without re-arming it.
236
+ const emitRef = useRef(null);
237
+ emitRef.current = (nextValue) => {
238
+ if (onChange) {
239
+ onChange({ ...filterValue, value: nextValue });
240
+ }
241
+ };
242
+
243
+ /**
244
+ * The popup calendar only ever emitted on an explicit pick, so it never
245
+ * needed `filterDelay`. A native input emits on every segment edit — Chrome
246
+ * reports 0002-12-02, 0020-12-02, 0202-12-02 while a year is typed — so the
247
+ * delay the grid already provides is honoured here, exactly as StringFilter
248
+ * does. The posted value is unchanged; only the moment of the request moves.
249
+ */
250
+ const emit = useCallback(
251
+ (nextValue) => {
252
+ if (timerRef.current) {
253
+ clearTimeout(timerRef.current);
254
+ timerRef.current = null;
255
+ }
256
+
257
+ if (filterDelay && filterDelay >= 1) {
258
+ timerRef.current = setTimeout(() => {
259
+ timerRef.current = null;
260
+ emitRef.current(nextValue);
261
+ }, filterDelay);
262
+ return;
263
+ }
264
+
265
+ emitRef.current(nextValue);
266
+ },
267
+ [filterDelay]
268
+ );
269
+
270
+ useEffect(
271
+ () => () => {
272
+ if (timerRef.current) {
273
+ clearTimeout(timerRef.current);
274
+ }
275
+ },
276
+ []
277
+ );
278
+
279
+ // Mirrors DateFilter.componentDidUpdate: adopt the incoming value only
280
+ // when the grid itself changed it (a reset, a saved view, a dependent
281
+ // filter clear), not on every render.
282
+ const incoming = filterValue ? filterValue.value : undefined;
283
+
284
+ useEffect(() => {
285
+ if (incoming !== propValueRef.current) {
286
+ propValueRef.current = incoming;
287
+ setValue(incoming || (range ? {} : ''));
288
+ }
289
+ }, [incoming, range]);
290
+
291
+ useImperativeHandle(
292
+ ref,
293
+ () => ({
294
+ // GenericFilter -> cellInstance.setFilterValue() reaches the
295
+ // editor through this; like StringFilter/DateFilter it updates
296
+ // the displayed value without re-emitting.
297
+ setValue: (next) => {
298
+ propValueRef.current = next;
299
+ setValue(next || (range ? {} : ''));
300
+ },
301
+ getInputRef: () => inputRef.current,
302
+ }),
303
+ [range]
304
+ );
305
+
306
+ const startFormat = useMemo(
307
+ () =>
308
+ resolveDateFormat(
309
+ props,
310
+ range ? value && value.start : value,
311
+ 0
312
+ ),
313
+ // eslint-disable-next-line react-hooks/exhaustive-deps
314
+ [props.cellProps, props.filterEditorProps, value, range]
315
+ );
316
+
317
+ const endFormat = useMemo(
318
+ () => (range ? resolveDateFormat(props, value && value.end, 1) : ''),
319
+ // eslint-disable-next-line react-hooks/exhaustive-deps
320
+ [props.cellProps, props.filterEditorProps, value, range]
321
+ );
322
+
323
+ const startEditorProps = resolveEditorProps(
324
+ props,
325
+ range ? value && value.start : value,
326
+ 0
327
+ );
328
+ const endEditorProps = range
329
+ ? resolveEditorProps(props, value && value.end, 1)
330
+ : {};
331
+
332
+ // datetime-local only when the configured format actually carries a time
333
+ // — DataGrid's date filters use 'DD-MM-YYYY', which does not, so they stay
334
+ // date-only and keep emitting a date-only string.
335
+ const startWithTime = formatHasTime(startFormat);
336
+ const endWithTime = formatHasTime(endFormat);
337
+
338
+ const handleChange = useCallback(
339
+ (raw, key, dateFormat, withTime) => {
340
+ const next = fromInputValue(raw, dateFormat, withTime, noValue);
341
+
342
+ if (!key) {
343
+ if (next === value) {
344
+ return;
345
+ }
346
+
347
+ setValue(next);
348
+ propValueRef.current = next;
349
+ emit(next);
350
+ return;
351
+ }
352
+
353
+ const base = value && typeof value === 'object' ? { ...value } : {};
354
+
355
+ if (base[key] === next) {
356
+ return;
357
+ }
358
+
359
+ base[key] = next;
360
+ setValue(base);
361
+ propValueRef.current = base;
362
+ emit(base);
363
+ },
364
+ [emit, noValue, value]
365
+ );
366
+
367
+ if (!filterValue) {
368
+ return render ? render(null) : null;
369
+ }
370
+
371
+ if (range) {
372
+ const startValue = value && typeof value === 'object' ? value.start : '';
373
+ const endValue = value && typeof value === 'object' ? value.end : '';
374
+
375
+ const node = (
376
+ <div
377
+ className="visns-native-date-filter__range"
378
+ style={{ display: 'flex', minWidth: 0 }}
379
+ >
380
+ <NativeDateInput
381
+ inputValue={toInputValue(
382
+ startValue,
383
+ startFormat,
384
+ startWithTime
385
+ )}
386
+ withTime={startWithTime}
387
+ disabled={disabled}
388
+ readOnly={readOnly}
389
+ placeholder={startEditorProps.placeholder}
390
+ style={style}
391
+ inputRef={inputRef}
392
+ onValueChange={(raw) =>
393
+ handleChange(raw, 'start', startFormat, startWithTime)
394
+ }
395
+ />
396
+ <div className="InovuaReactDataGrid__column-header__filter__binary_operator_separator" />
397
+ <NativeDateInput
398
+ inputValue={toInputValue(endValue, endFormat, endWithTime)}
399
+ withTime={endWithTime}
400
+ disabled={disabled}
401
+ readOnly={readOnly}
402
+ placeholder={endEditorProps.placeholder}
403
+ style={style}
404
+ onValueChange={(raw) =>
405
+ handleChange(raw, 'end', endFormat, endWithTime)
406
+ }
407
+ />
408
+ </div>
409
+ );
410
+
411
+ return render ? render(node) : node;
412
+ }
413
+
414
+ const node = (
415
+ <NativeDateInput
416
+ inputValue={toInputValue(value, startFormat, startWithTime)}
417
+ withTime={startWithTime}
418
+ disabled={disabled}
419
+ readOnly={readOnly}
420
+ placeholder={startEditorProps.placeholder}
421
+ style={style}
422
+ inputRef={inputRef}
423
+ onValueChange={(raw) =>
424
+ handleChange(raw, null, startFormat, startWithTime)
425
+ }
426
+ />
427
+ );
428
+
429
+ return render ? render(node) : node;
430
+ });
431
+
432
+ NativeDateFilterEditor.displayName = 'NativeDateFilterEditor';
433
+
434
+ export default NativeDateFilterEditor;
@@ -1799,3 +1799,93 @@ 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
+ /* Phones: tapping the field itself opens the OS sheet, so the browser's
1841
+ calendar glyph is redundant next to our clear button. */
1842
+ @media (pointer: coarse) {
1843
+ &::-webkit-calendar-picker-indicator {
1844
+ -webkit-appearance: none;
1845
+ display: none;
1846
+ }
1847
+ }
1848
+
1849
+ /* Desktop: the indicator IS how the picker opens — keep it visible and
1850
+ reserve room for the clear button to sit beside it. */
1851
+ @media (pointer: fine) {
1852
+ padding-right: 4.4rem;
1853
+ }
1854
+ }
1855
+
1856
+ .nativeDateClear {
1857
+ position: absolute;
1858
+ top: 50%;
1859
+ right: 0.4rem;
1860
+ transform: translateY(-50%);
1861
+
1862
+ /* Sit left of the browser's calendar indicator on desktop. */
1863
+ @media (pointer: fine) {
1864
+ right: 2.3rem;
1865
+ }
1866
+ display: flex;
1867
+ align-items: center;
1868
+ justify-content: center;
1869
+ width: 2rem;
1870
+ height: 2rem;
1871
+ padding: 0;
1872
+ border: none;
1873
+ background: transparent;
1874
+ border-radius: 50%;
1875
+ cursor: pointer;
1876
+ /* Same token and weight as the datepicker's close icon */
1877
+ color: rgba(var(--primary-rgb, 31, 42, 55), 0.45);
1878
+
1879
+ &:hover,
1880
+ &:focus {
1881
+ color: rgba(var(--primary-rgb, 31, 42, 55), 1);
1882
+ outline: none;
1883
+ }
1884
+ }
1885
+
1886
+ /* Present in the DOM but inert while the field is empty — see the note in
1887
+ Field.jsx's native date markup. */
1888
+ .nativeDateClearHidden {
1889
+ visibility: hidden;
1890
+ pointer-events: none;
1891
+ }
@@ -510,3 +510,144 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
510
510
  .InovuaReactDataGrid__group-toolbar {
511
511
  display: none !important;
512
512
  }
513
+
514
+ /* =========================================================================
515
+ Native date column filters — see columns/NativeDateFilterEditor.jsx
516
+
517
+ The editor replaces Inovua's popup-calendar DateFilter with the platform's
518
+ own picker. Everything here exists to make an `<input type="date">` sit in
519
+ the filter row at exactly the size and weight of its text / dropdown
520
+ neighbours, whose look comes from the toolkit's own
521
+ `.inovua-react-toolkit-text-input--theme-default-light` rule (32px tall,
522
+ 1px #cdcecd border, 14px #555e68 text).
523
+
524
+ Height itself is not set here: the wrapper carries the grid's own
525
+ `.InovuaReactDataGrid__column-header__filter` class, which the datagrid
526
+ theme already pins to min/max-height 32px alongside every other filter.
527
+
528
+ Note: `global.css` excludes `.visns-native-date-filter__input` from the
529
+ app-wide `input[type]` rule, whose 1rem padding would otherwise burst the
530
+ 32px filter row.
531
+ ========================================================================= */
532
+ .visns-native-date-filter {
533
+ position: relative;
534
+ display: flex;
535
+ /* stretch, not center: the input has to fill the 32px the theme pins the
536
+ filter to, or the clickable area is smaller than the box it draws. */
537
+ align-items: stretch;
538
+ box-sizing: border-box;
539
+ width: 100%;
540
+ min-width: 0;
541
+ overflow: hidden;
542
+ border: 1px solid #cdcecd;
543
+ border-radius: 1px;
544
+ background: #fff;
545
+ color: #555e68;
546
+ font-size: 14px;
547
+ transition: border 0.2s, box-shadow 0.2s;
548
+ }
549
+
550
+ .visns-native-date-filter:hover {
551
+ border-color: #7986cb;
552
+ }
553
+
554
+ .visns-native-date-filter:focus-within {
555
+ border-color: #7986cb;
556
+ box-shadow: 0 0 0 2px rgba(121, 134, 203, 0.4);
557
+ }
558
+
559
+ .visns-native-date-filter--disabled {
560
+ opacity: 0.5;
561
+ }
562
+
563
+ .visns-native-date-filter--disabled:hover {
564
+ border-color: #cdcecd;
565
+ }
566
+
567
+ .visns-native-date-filter__input {
568
+ flex: 1 1 auto;
569
+ box-sizing: border-box;
570
+ width: 100%;
571
+ min-width: 0;
572
+ height: auto;
573
+ margin: 0;
574
+ padding: 0 2px 0 8px;
575
+ border: none;
576
+ outline: none;
577
+ background: none;
578
+ color: inherit;
579
+ font-family: inherit;
580
+ font-size: inherit;
581
+ line-height: 1;
582
+ -webkit-appearance: none;
583
+ appearance: none;
584
+ }
585
+
586
+ /* An empty native date input renders its locale mask (dd/mm/yyyy) at full
587
+ contrast, which reads as a value; mute it to the same placeholder weight
588
+ the toolkit's text filters use. (`:invalid` cannot express this — an empty
589
+ optional date input is valid — so the editor sets the modifier itself.) */
590
+ .visns-native-date-filter--empty .visns-native-date-filter__input {
591
+ color: rgba(85, 94, 104, 0.7);
592
+ }
593
+
594
+ /* The calendar glyph IS how the picker opens on desktop — keep it, just
595
+ shrink it into the 32px row. */
596
+ .visns-native-date-filter__input::-webkit-calendar-picker-indicator {
597
+ margin: 0;
598
+ padding: 0 2px;
599
+ opacity: 0.55;
600
+ cursor: pointer;
601
+ }
602
+
603
+ .visns-native-date-filter__input::-webkit-calendar-picker-indicator:hover {
604
+ opacity: 1;
605
+ }
606
+
607
+ /* Browser-supplied clear / spin affordances are replaced by our own button,
608
+ which is consistent across engines. */
609
+ .visns-native-date-filter__input::-webkit-clear-button,
610
+ .visns-native-date-filter__input::-webkit-inner-spin-button {
611
+ display: none;
612
+ -webkit-appearance: none;
613
+ }
614
+
615
+ .visns-native-date-filter__input::-moz-calendar-picker-indicator {
616
+ opacity: 0.55;
617
+ cursor: pointer;
618
+ }
619
+
620
+ .visns-native-date-filter__clear {
621
+ flex: none;
622
+ align-self: center;
623
+ display: flex;
624
+ align-items: center;
625
+ justify-content: center;
626
+ width: 18px;
627
+ height: 18px;
628
+ margin: 0 4px 0 0;
629
+ padding: 0;
630
+ border: none;
631
+ border-radius: 50%;
632
+ background: transparent;
633
+ color: #99a1aa;
634
+ cursor: pointer;
635
+ outline: none;
636
+ }
637
+
638
+ .visns-native-date-filter__clear:hover,
639
+ .visns-native-date-filter__clear:focus {
640
+ color: #555e68;
641
+ }
642
+
643
+ /* inrange / notinrange: two of the same input either side of the theme's
644
+ own separator. */
645
+ .visns-native-date-filter__range {
646
+ flex: 1;
647
+ min-width: 0;
648
+ align-items: center;
649
+ }
650
+
651
+ .visns-native-date-filter__range .visns-native-date-filter {
652
+ flex: 1 1 0;
653
+ }
@@ -245,7 +245,9 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
245
245
  .inovua-react-toolkit-text-input__input
246
246
  ):not(.inovua-react-toolkit-numeric-input__input):not(
247
247
  .variable-inserter-search-input
248
- ):not(.react-datepicker__month-select):not(.react-datepicker__year-select) {
248
+ ):not(.react-datepicker__month-select):not(
249
+ .react-datepicker__year-select
250
+ ):not(.visns-native-date-filter__input) {
249
251
  background: white;
250
252
  border-radius: var(--br);
251
253
  border: 1px solid rgba(var(--paragraph-color-rgb), 0.15);
@@ -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;
package/src/index.js CHANGED
@@ -38,6 +38,7 @@ import AudioPlayer from './components/controls/AudioPlayer';
38
38
  import GalleryModal from './components/modals/GalleryModal';
39
39
  import ImageModal from './components/ImageModal';
40
40
  import DatePickerPortal from './components/utils/DatePickerPortal';
41
+ import NativeDateFilterEditor from './components/columns/NativeDateFilterEditor';
41
42
  import ImportWizardModal from './components/ImportWizardModal';
42
43
  export * from './components/columns/ColumnRenderers.jsx';
43
44
 
@@ -147,6 +148,7 @@ export {
147
148
  MergeEntity,
148
149
  MultiSelect,
149
150
  MultiCheckbox,
151
+ NativeDateFilterEditor,
150
152
  Navigation,
151
153
  Notification,
152
154
  NotificationList,