@visns-studio/visns-components 6.4.3 → 6.5.0

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.
@@ -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;
@@ -1837,9 +1837,19 @@ input.nativeDateInput[type] {
1837
1837
  input.nativeDateFilled[type] {
1838
1838
  padding-right: 2.75rem;
1839
1839
 
1840
- &::-webkit-calendar-picker-indicator {
1841
- -webkit-appearance: none;
1842
- display: none;
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;
1843
1853
  }
1844
1854
  }
1845
1855
 
@@ -1848,6 +1858,11 @@ input.nativeDateFilled[type] {
1848
1858
  top: 50%;
1849
1859
  right: 0.4rem;
1850
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
+ }
1851
1866
  display: flex;
1852
1867
  align-items: center;
1853
1868
  justify-content: center;
@@ -1867,3 +1882,10 @@ input.nativeDateFilled[type] {
1867
1882
  outline: none;
1868
1883
  }
1869
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
+ }
@@ -870,6 +870,140 @@
870
870
  }
871
871
  }
872
872
 
873
+ /* ----------------------------------------------------------------------------
874
+ Sidebar-owned account actions — opt-in
875
+ ----------------------------------------------------------------------------
876
+ `navConfig.actionsPlacement: 'sidebar'` moves the notification bell, the
877
+ profile link, the logout button and anything else a project mounts as a
878
+ "setting" out of the 50px content header and pins them to the bottom of the
879
+ rail, which was empty space. The page then gets the full height of the
880
+ glass. Nothing below applies unless the consumer publishes that key.
881
+ ------------------------------------------------------------------------- */
882
+ .aside--cms--rail {
883
+ /* Three bands: the logo plate, the nav list that takes what's left and
884
+ scrolls, and the actions pinned under it. */
885
+ display: flex;
886
+ flex-direction: column;
887
+
888
+ /* The rail clips itself so a long nav list can't spill; the panels that
889
+ open from the action rows — the notification list, the account
890
+ switcher — would be cut off by that. The nav list keeps its own
891
+ scroll container, so nothing else depended on this. */
892
+ overflow: visible;
893
+
894
+ .awrap {
895
+ /* `min-height: 0` or the nav list refuses to shrink below its
896
+ content and pushes the actions off the bottom of the rail. */
897
+ flex: 1 1 auto;
898
+ min-height: 0;
899
+ display: flex;
900
+ flex-direction: column;
901
+
902
+ .navwrap {
903
+ flex: 1 1 auto;
904
+ min-height: 0;
905
+ /* The fixed `calc(100vh - 50px)` assumed the rail's only other
906
+ band was the logo plate. The column measures it now. */
907
+ max-height: none;
908
+ }
909
+ }
910
+ }
911
+
912
+ .rail-actions {
913
+ flex: 0 0 auto;
914
+ position: relative;
915
+ /* Above the content header's sticky 995 so a panel opening from a row
916
+ is never painted underneath it. */
917
+ z-index: 996;
918
+ /* Reads as a footer to the list rather than one more entry in it. */
919
+ border-top: 1px solid rgba(var(--tertiary-color-rgb), 0.14);
920
+ padding: var(--padding) 0;
921
+
922
+ > ul {
923
+ list-style: none;
924
+ margin: 0;
925
+ padding: 0;
926
+ display: flex;
927
+ flex-direction: column;
928
+ width: 100%;
929
+
930
+ > li {
931
+ /* Same gutter as `.app-nav .nav-item`, so the rows line up with
932
+ the navigation above them to the pixel. */
933
+ width: 100%;
934
+ padding: 0.15rem 0.45rem;
935
+ box-sizing: border-box;
936
+ /* The positioning context for the panels a mounted component
937
+ opens — they anchor to the nearest positioned ancestor. */
938
+ position: relative;
939
+ }
940
+
941
+ // A row wears the rail's own language: the nav item's padding,
942
+ // radius and hover tint. Depth-limited on purpose — the panels these
943
+ // components open live deeper inside the same `li` and must keep
944
+ // their own styling.
945
+ > li > a,
946
+ > li > button,
947
+ > li > div > a,
948
+ > li > div > button {
949
+ width: 100%;
950
+ display: flex;
951
+ flex-wrap: nowrap;
952
+ align-items: center;
953
+ gap: 0.55rem;
954
+ box-sizing: border-box;
955
+ padding: 0.5rem 1rem;
956
+ margin: 0;
957
+ line-height: 1.25;
958
+ text-align: left;
959
+ text-decoration: none;
960
+ color: var(--tertiary-color);
961
+ background: var(--nav-bg);
962
+ border: none;
963
+ outline: none;
964
+ border-radius: var(--br);
965
+ font: inherit;
966
+ cursor: pointer;
967
+ appearance: none;
968
+ transition: all 0.65s cubic-bezier(0.25, 0.8, 0.25, 1);
969
+
970
+ svg {
971
+ flex: 0 0 auto;
972
+ color: var(--secondary-color);
973
+ }
974
+ }
975
+
976
+ > li > a:hover,
977
+ > li > button:hover,
978
+ > li > div > a:hover,
979
+ > li > div > button:hover,
980
+ > li > a.active {
981
+ background: rgba(var(--tertiary-color-rgb), 0.05);
982
+ color: var(--secondary-color);
983
+ }
984
+
985
+ // Components mounted here wrap their trigger in a div of their own
986
+ // (`.notificationContainer`, the switcher's wrapper). Left as an
987
+ // inline-ish block it would collapse the row.
988
+ > li > div {
989
+ width: 100%;
990
+ position: relative;
991
+ }
992
+
993
+ // A mounted component draws its own trigger and takes no label from
994
+ // us, so the row publishes one as `--nav-action-label` and the
995
+ // trigger wears it. Empty by default, so a component that already
996
+ // labels itself is untouched. (The notification bell sets its own
997
+ // fallback text — see Notification.module.scss.)
998
+ > li > div > a::after {
999
+ content: var(--nav-action-label, '');
1000
+ flex: 1 1 auto;
1001
+ min-width: 0;
1002
+ text-align: left;
1003
+ }
1004
+ }
1005
+ }
1006
+
873
1007
  .content {
874
1008
  margin-left: var(--sidebar-width);
875
1009
  flex-grow: 1;
@@ -952,6 +1086,21 @@
952
1086
  }
953
1087
  }
954
1088
 
1089
+ /* With the actions in the rail and no search field configured, the only
1090
+ thing left in the header is the drawer's hamburger — which exists below
1091
+ 1024px and nowhere else. The bar stays in the markup so the drawer keeps
1092
+ the 50px anchor it offsets itself by, and desktop simply drops it. */
1093
+ .content .content-header--mobile {
1094
+ display: none;
1095
+ }
1096
+
1097
+ /* Nothing sits above the page any more, so the main container is the
1098
+ viewport. Without this it is only as tall as its content and the page
1099
+ background stops short on a short screen. */
1100
+ .content--full {
1101
+ min-height: 100vh;
1102
+ }
1103
+
955
1104
  /* Rendered only while the mobile drawer is open; the phone block below
956
1105
  displays it. Desktop never opens the drawer, but the guard costs nothing. */
957
1106
  .drawer-backdrop {
@@ -1016,6 +1165,41 @@
1016
1165
  }
1017
1166
  }
1018
1167
 
1168
+ /* The account actions sit at the foot of the drawer, above the home
1169
+ indicator, at the same touch size as the nav rows above them. */
1170
+ .rail-actions {
1171
+ padding: 0.35rem 0 calc(0.35rem + env(safe-area-inset-bottom, 0px));
1172
+
1173
+ > ul > li {
1174
+ padding: 0.2rem 0.65rem;
1175
+ }
1176
+
1177
+ // Depth-limited: the panels these rows open live deeper in the same
1178
+ // `li` and size their own icons.
1179
+ > ul > li > a,
1180
+ > ul > li > button,
1181
+ > ul > li > div > a,
1182
+ > ul > li > div > button {
1183
+ padding: 0.85rem 1rem;
1184
+ font-size: 1rem;
1185
+ line-height: 1.3;
1186
+
1187
+ > svg {
1188
+ width: 20px;
1189
+ height: 20px;
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ .content .content-header--mobile {
1195
+ display: flex;
1196
+ }
1197
+
1198
+ .content--full {
1199
+ /* The 50px bar is still there below this width. */
1200
+ min-height: calc(100vh - 50px);
1201
+ }
1202
+
1019
1203
  .drawer-backdrop {
1020
1204
  display: block;
1021
1205
  position: fixed;