@cahyo-dimas/freeday 1.53.0 → 2.0.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.
- package/CHANGELOG.md +657 -535
- package/COMPONENTS.md +153 -135
- package/README.id.md +46 -45
- package/README.md +46 -43
- package/USAGE.md +39 -35
- package/adapters/blazor/FdyAppShell.razor +50 -0
- package/adapters/blazor/FdyAppShell.razor.cs +114 -0
- package/adapters/blazor/FdyCfl.razor.cs +1 -1
- package/adapters/blazor/FdyChart.razor +1 -1
- package/adapters/blazor/FdyChart.razor.cs +3 -3
- package/adapters/blazor/FdyChartSeries.cs +1 -1
- package/adapters/blazor/FdyCombo.razor.cs +2 -2
- package/adapters/blazor/FdyModal.razor +1 -1
- package/adapters/blazor/FdyTable.razor.cs +7 -7
- package/adapters/blazor/FdyTableFilter.razor +1 -1
- package/adapters/blazor/FdyTableFooter.razor +1 -1
- package/adapters/blazor/FdyTableFooter.razor.cs +4 -4
- package/adapters/blazor/Freeday.Blazor.csproj +1 -1
- package/adapters/blazor/FreedayComponentBase.cs +1 -1
- package/adapters/blazor/TableModel.cs +3 -3
- package/adapters/blazor/TableTypes.cs +3 -3
- package/adapters/blazor/freeday-blazor.js +5 -5
- package/adapters/core/app-shell.d.ts +30 -0
- package/adapters/core/app-shell.js +134 -0
- package/adapters/core/table-model.d.ts +2 -2
- package/adapters/core/table-model.js +8 -8
- package/adapters/react/components/FdyAppShell.tsx +159 -0
- package/adapters/react/components/FdyAutocomplete.tsx +1 -1
- package/adapters/react/components/FdyCascade.tsx +1 -1
- package/adapters/react/components/FdyCfl.tsx +11 -11
- package/adapters/react/components/FdyDateRange.tsx +2 -2
- package/adapters/react/components/FdyDatepicker.tsx +5 -5
- package/adapters/react/components/FdyDrawer.tsx +1 -1
- package/adapters/react/components/FdyModal.tsx +2 -2
- package/adapters/react/components/FdyTable.tsx +7 -7
- package/adapters/react/components/FdyTableFilter.tsx +1 -1
- package/adapters/react/components/FdyTableFooter.tsx +5 -5
- package/adapters/react/index.d.ts +2 -1
- package/adapters/react/index.js +1 -0
- package/adapters/react/useFreeday.js +3 -3
- package/adapters/vue/components/FdyAppShell.vue +160 -0
- package/adapters/vue/components/FdyAutocomplete.vue +1 -1
- package/adapters/vue/components/FdyCascade.vue +1 -1
- package/adapters/vue/components/FdyCfl.vue +8 -8
- package/adapters/vue/components/FdyChart.vue +2 -2
- package/adapters/vue/components/FdyCombo.vue +1 -1
- package/adapters/vue/components/FdyDateRange.vue +2 -2
- package/adapters/vue/components/FdyDatepicker.vue +5 -5
- package/adapters/vue/components/FdyDrawer.vue +3 -3
- package/adapters/vue/components/FdyModal.vue +3 -3
- package/adapters/vue/components/FdyTable.vue +7 -7
- package/adapters/vue/components/FdyTableFilter.vue +1 -1
- package/adapters/vue/components/FdyTableFooter.vue +5 -5
- package/adapters/vue/index.d.ts +3 -2
- package/adapters/vue/index.js +1 -0
- package/adapters/vue/useFreeday.js +3 -3
- package/dist/asset.d.ts +3 -3
- package/dist/freeday-app-shell.js +49 -9
- package/dist/freeday-autocomplete.js +2 -2
- package/dist/freeday-breakpoint.js +1 -1
- package/dist/freeday-carousel.js +5 -5
- package/dist/freeday-cascade.js +7 -7
- package/dist/freeday-cfl.js +9 -9
- package/dist/freeday-chart.js +11 -11
- package/dist/freeday-chip.js +3 -3
- package/dist/freeday-datepicker.js +11 -7
- package/dist/freeday-datetime.js +5 -5
- package/dist/freeday-drawer.js +1 -1
- package/dist/freeday-form.js +14 -14
- package/dist/freeday-mask.js +6 -6
- package/dist/freeday-menu.js +1 -1
- package/dist/freeday-number.js +3 -3
- package/dist/freeday-popover.js +3 -3
- package/dist/freeday-rating.js +2 -2
- package/dist/freeday-select.js +5 -5
- package/dist/freeday-slider.js +2 -2
- package/dist/freeday-stepper.js +7 -7
- package/dist/freeday-table.js +15 -15
- package/dist/freeday-tabs.js +1 -1
- package/dist/freeday-timepicker.js +2 -2
- package/dist/freeday-toast.js +8 -8
- package/dist/freeday-tree.js +3 -3
- package/dist/freeday-upload.js +25 -25
- package/dist/freeday.bundle.css +176 -176
- package/dist/freeday.css +170 -170
- package/dist/freeday.d.ts +2 -2
- package/dist/freeday.js +202 -158
- package/dist/freeday.tokens.css +5 -5
- package/docs/agent-onboarding.md +42 -42
- package/docs/getting-started.md +35 -35
- package/docs/integrations.md +18 -18
- package/docs/reference-screen.html +14 -14
- package/package.json +5 -4
- package/src/base.css +6 -6
- package/src/components/accordion.css +4 -4
- package/src/components/alert.css +1 -1
- package/src/components/app-shell.css +10 -10
- package/src/components/appbar.css +3 -3
- package/src/components/autocomplete.css +1 -1
- package/src/components/avatar.css +3 -3
- package/src/components/badge.css +5 -5
- package/src/components/breadcrumb.css +1 -1
- package/src/components/breakpoints.css +4 -4
- package/src/components/button.css +13 -13
- package/src/components/card.css +8 -8
- package/src/components/carousel.css +2 -2
- package/src/components/cascade.css +2 -2
- package/src/components/cfl.css +4 -4
- package/src/components/chart.css +3 -3
- package/src/components/chip.css +2 -2
- package/src/components/combo.css +3 -3
- package/src/components/composition.css +7 -7
- package/src/components/datepicker.css +6 -6
- package/src/components/datetimepicker.css +1 -1
- package/src/components/description-list.css +1 -1
- package/src/components/divider.css +1 -1
- package/src/components/drawer.css +1 -1
- package/src/components/file-upload.css +5 -5
- package/src/components/filterbar.css +7 -7
- package/src/components/form-grid.css +2 -2
- package/src/components/input-group.css +6 -6
- package/src/components/input.css +6 -6
- package/src/components/kbd.css +1 -1
- package/src/components/list.css +6 -6
- package/src/components/menu.css +3 -3
- package/src/components/modal.css +1 -1
- package/src/components/pagination.css +1 -1
- package/src/components/progress.css +1 -1
- package/src/components/rating.css +2 -2
- package/src/components/selection.css +4 -4
- package/src/components/skeleton.css +1 -1
- package/src/components/slider.css +1 -1
- package/src/components/spinner.css +1 -1
- package/src/components/states.css +1 -1
- package/src/components/stepper.css +2 -2
- package/src/components/table.css +15 -15
- package/src/components/tabs.css +3 -3
- package/src/components/timeline.css +1 -1
- package/src/components/timepicker.css +1 -1
- package/src/components/toast.css +3 -3
- package/src/components/tooltip.css +1 -1
- package/src/components/tree.css +2 -2
- package/tokens/breakpoints.d.ts +2 -2
- package/tokens/breakpoints.mjs +3 -3
- package/tokens/tokens.json +1 -1
|
@@ -5,7 +5,7 @@ namespace Freeday.Blazor;
|
|
|
5
5
|
/// <summary>Sort direction for a column.</summary>
|
|
6
6
|
public enum FdySortDir { Asc, Desc }
|
|
7
7
|
|
|
8
|
-
/// <summary>A column's comparator type
|
|
8
|
+
/// <summary>A column's comparator type, how its values are ordered when sorting.</summary>
|
|
9
9
|
public enum FdyColumnType { Text, Number, Date }
|
|
10
10
|
|
|
11
11
|
/// <summary>Cell alignment. Maps to <c>text-align</c> on the header and body cells.</summary>
|
|
@@ -23,7 +23,7 @@ public sealed record FdyPageState(int Index, int Size, int Total);
|
|
|
23
23
|
|
|
24
24
|
/// <summary>The processed page of rows (after filter/sort/paginate) plus the total row count, as
|
|
25
25
|
/// raised by <see cref="FdyTable{TRow}.Process"/>. Lets a consumer render the same processed set
|
|
26
|
-
/// somewhere else
|
|
26
|
+
/// somewhere else, a card list below the <c>md</c> breakpoint, a summary, an export, without
|
|
27
27
|
/// re-deriving the pipeline. Mirrors the <c>process</c> event in the Vue/React adapters.</summary>
|
|
28
28
|
public sealed record FdyTableProcess<TRow>(IReadOnlyList<TRow> Rows, int Total);
|
|
29
29
|
|
|
@@ -47,7 +47,7 @@ public sealed class FdyTableColumn<TRow>
|
|
|
47
47
|
/// <summary>Header label.</summary>
|
|
48
48
|
public required string Label { get; init; }
|
|
49
49
|
|
|
50
|
-
/// <summary>Render the label for assistive tech only
|
|
50
|
+
/// <summary>Render the label for assistive tech only, the header cell looks empty.
|
|
51
51
|
/// For a column of row CONTROLS (an edit button, a row menu), where a visible heading is noise
|
|
52
52
|
/// above a column of icons but the column still has to be named: a <c>th</c> with no text is
|
|
53
53
|
/// announced as nothing, and a reader tabbing the header row cannot tell what it is. The label
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Freeday
|
|
1
|
+
/* Freeday. Blazor JS interop (classic IIFE; registers window.FreedayBlazor).
|
|
2
2
|
* Consistent with the other Freeday enhancers (plain <script>, no ES module),
|
|
3
3
|
* so it serves from any static host without module-MIME strictness.
|
|
4
4
|
*
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Subscribe to a daterange's child datepicker changes, forwarding {value, role} so a Blazor
|
|
59
|
-
// wrapper can bind From/To separately
|
|
59
|
+
// wrapper can bind From/To separately, both children emit the same fdy-datepicker-change, so
|
|
60
60
|
// the originating child's data-role is what tells them apart.
|
|
61
61
|
function dateRangeOn(element, dotNetRef, methodName) {
|
|
62
62
|
var handler = function (e) {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
return token;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// Fire a .NET callback when a pointerdown lands outside `element`, or Escape is pressed
|
|
75
|
+
// Fire a .NET callback when a pointerdown lands outside `element`, or Escape is pressed,
|
|
76
76
|
// the light-dismiss primitive for Blazor-owned popovers (e.g. FdyTable's column filter, which
|
|
77
77
|
// is position:fixed in the top layer and so escapes the table's overflow). Returns an off() token.
|
|
78
78
|
function onOutside(element, dotNetRef, methodName) {
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
|
|
100
100
|
// Passthrough to the Freeday toast API.
|
|
101
101
|
function toast(options) {
|
|
102
|
-
/* The enhancer's own default is Indonesian
|
|
102
|
+
/* The enhancer's own default is Indonesian, deliberate for the raw path, wrong for a Blazor
|
|
103
103
|
* app, which COMPONENTS.md promises is English throughout. Defaulted here so the promise is
|
|
104
104
|
* kept without changing what a hand-written page gets. */
|
|
105
105
|
options = options || {};
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
if (dismissible) dotNetRef.invokeMethodAsync(dismissMethod);
|
|
141
141
|
};
|
|
142
142
|
var onClick = function (e) {
|
|
143
|
-
// The ::backdrop is not a separate element
|
|
143
|
+
// The ::backdrop is not a separate element, a click whose target is the dialog box
|
|
144
144
|
// itself (not its content) is a backdrop click.
|
|
145
145
|
if (dismissible && e.target === dialog) dotNetRef.invokeMethodAsync(dismissMethod);
|
|
146
146
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Types for the framework-agnostic app-shell behaviour (adapters/core/app-shell.js).
|
|
2
|
+
// Shared by FdyAppShell in the Vue, React and Blazor adapters.
|
|
3
|
+
|
|
4
|
+
/** The parts the shell's behaviour touches. Any of them may be absent from a partial shell. */
|
|
5
|
+
export interface FdyShellParts {
|
|
6
|
+
sidebar: HTMLElement | null;
|
|
7
|
+
content: HTMLElement | null;
|
|
8
|
+
toggle: HTMLElement | null;
|
|
9
|
+
backdrop: HTMLElement | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the DOM should reflect right now.
|
|
14
|
+
* `navVisible` is the single idea the app owns: is the nav showing? `overlay` is whether the
|
|
15
|
+
* viewport is currently below the nav breakpoint, where a visible nav covers the page.
|
|
16
|
+
*/
|
|
17
|
+
export interface FdyShellState {
|
|
18
|
+
navVisible: boolean;
|
|
19
|
+
overlay: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The media query the shell switches at, built from tokens/breakpoints.mjs (`nav`). */
|
|
23
|
+
export declare const NAV_QUERY: string;
|
|
24
|
+
|
|
25
|
+
export declare function shellParts(root: HTMLElement): FdyShellParts;
|
|
26
|
+
export declare function focusablesIn(el: HTMLElement): HTMLElement[];
|
|
27
|
+
export declare function applyShellState(root: HTMLElement, state: FdyShellState): void;
|
|
28
|
+
export declare function focusPanel(root: HTMLElement): Element | null;
|
|
29
|
+
export declare function restoreFocus(root: HTMLElement, previous: Element | null): void;
|
|
30
|
+
export declare function trapTab(root: HTMLElement, event: KeyboardEvent): boolean;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Freeday app-shell behaviour, framework-agnostic (DOM helpers, zero dependencies).
|
|
2
|
+
//
|
|
3
|
+
// The focus trap, `inert` bookkeeping and focus restore shared by FdyAppShell in the Vue, React and
|
|
4
|
+
// Blazor adapters. Kept here, plain ESM with a .d.ts sidecar, for the same reason as
|
|
5
|
+
// table-model.js: three copies of a focus trap is three chances to write it differently, and the
|
|
6
|
+
// one that gets it wrong strands a keyboard user with no way out of a nav panel.
|
|
7
|
+
//
|
|
8
|
+
// The vanilla enhancer (src/freeday-app-shell.js) deliberately does NOT import this: dist/freeday.js
|
|
9
|
+
// is a plain IIFE concatenation with no module system. That leaves exactly two implementations, and
|
|
10
|
+
// browser/adapter.mjs holds them to the same observable behaviour.
|
|
11
|
+
//
|
|
12
|
+
// State stays with the caller. These functions read and write the DOM and nothing else, so a
|
|
13
|
+
// controlled wrapper can own `navOpen` as a prop and still get the parts that are easy to forget.
|
|
14
|
+
|
|
15
|
+
import { breakpoints } from '../../tokens/breakpoints.mjs';
|
|
16
|
+
|
|
17
|
+
/** The media query the shell switches at, the same 721px app-shell.css uses. */
|
|
18
|
+
export const NAV_QUERY = `(min-width: ${breakpoints.nav}px)`;
|
|
19
|
+
|
|
20
|
+
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
|
|
21
|
+
+ ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The shell's parts, or nulls. Callers pass the root element they rendered.
|
|
25
|
+
* @param {HTMLElement} root
|
|
26
|
+
*/
|
|
27
|
+
export function shellParts(root) {
|
|
28
|
+
return {
|
|
29
|
+
sidebar: root.querySelector('.fdy-app__sidebar'),
|
|
30
|
+
content: root.querySelector('.fdy-app__content'),
|
|
31
|
+
toggle: root.querySelector('.fdy-app__navtoggle'),
|
|
32
|
+
backdrop: root.querySelector('.fdy-app__backdrop'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Focusable descendants, in tab order, that are actually rendered.
|
|
38
|
+
* getClientRects() rather than offsetParent: an overlay sidebar is position:fixed, and a fixed
|
|
39
|
+
* element reports no offsetParent at all, filtering on that calls every nav item invisible.
|
|
40
|
+
* @param {HTMLElement} el
|
|
41
|
+
* @returns {HTMLElement[]}
|
|
42
|
+
*/
|
|
43
|
+
export function focusablesIn(el) {
|
|
44
|
+
return Array.prototype.filter.call(el.querySelectorAll(FOCUSABLE), (node) => node.getClientRects().length > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reconcile the parts of the shell that are not classes: `inert` and `aria-expanded`.
|
|
49
|
+
*
|
|
50
|
+
* One rule for both modes: the sidebar is inert whenever the nav is not visible. `width:0` (collapsed)
|
|
51
|
+
* and `translateX(-100%)` (off-canvas) hide a panel from the eye and neither hides it from the
|
|
52
|
+
* keyboard, so without this a nav nobody can see still swallows Tab on the way into the page.
|
|
53
|
+
* The content is inert only while the nav is an open overlay.
|
|
54
|
+
*
|
|
55
|
+
* @param {HTMLElement} root
|
|
56
|
+
* @param {{navVisible: boolean, overlay: boolean}} state
|
|
57
|
+
*/
|
|
58
|
+
export function applyShellState(root, state) {
|
|
59
|
+
const { sidebar, content, toggle } = shellParts(root);
|
|
60
|
+
if (toggle) toggle.setAttribute('aria-expanded', String(state.navVisible));
|
|
61
|
+
setInert(sidebar, !state.navVisible);
|
|
62
|
+
setInert(content, state.overlay && state.navVisible);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Move focus into the panel, and hand back whatever had it. The caller keeps that until close.
|
|
67
|
+
* @param {HTMLElement} root
|
|
68
|
+
* @returns {Element|null} the element that was focused before
|
|
69
|
+
*/
|
|
70
|
+
export function focusPanel(root) {
|
|
71
|
+
const previous = document.activeElement;
|
|
72
|
+
const { sidebar } = shellParts(root);
|
|
73
|
+
if (sidebar === null) return previous;
|
|
74
|
+
const first = focusablesIn(sidebar)[0];
|
|
75
|
+
if (first !== undefined) {
|
|
76
|
+
first.focus();
|
|
77
|
+
} else {
|
|
78
|
+
sidebar.setAttribute('tabindex', '-1');
|
|
79
|
+
sidebar.focus();
|
|
80
|
+
}
|
|
81
|
+
return previous;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Put focus back where it came from. Anything gone, detached, or the body itself falls back to the
|
|
86
|
+
* toggle: document.body is where a keyboard user gets stranded with nothing to press.
|
|
87
|
+
* @param {HTMLElement} root
|
|
88
|
+
* @param {Element|null} previous
|
|
89
|
+
*/
|
|
90
|
+
export function restoreFocus(root, previous) {
|
|
91
|
+
const { toggle } = shellParts(root);
|
|
92
|
+
const usable = previous !== null && previous !== document.body && document.contains(previous)
|
|
93
|
+
&& typeof previous.focus === 'function';
|
|
94
|
+
const target = usable ? previous : toggle;
|
|
95
|
+
if (target !== null && typeof target.focus === 'function') target.focus();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cycle Tab inside the panel. Call from a keydown handler while the overlay is open.
|
|
100
|
+
* `inert` on the content stops Tab reaching the page; without this the focus would still walk out
|
|
101
|
+
* of the document into the browser's own chrome.
|
|
102
|
+
* @param {HTMLElement} root
|
|
103
|
+
* @param {KeyboardEvent} event
|
|
104
|
+
* @returns {boolean} true when focus was moved
|
|
105
|
+
*/
|
|
106
|
+
export function trapTab(root, event) {
|
|
107
|
+
if (event.key !== 'Tab') return false;
|
|
108
|
+
const { sidebar } = shellParts(root);
|
|
109
|
+
if (sidebar === null) return false;
|
|
110
|
+
const items = focusablesIn(sidebar);
|
|
111
|
+
if (items.length === 0) {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const first = items[0];
|
|
116
|
+
const last = items[items.length - 1];
|
|
117
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
last.focus();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (!event.shiftKey && document.activeElement === last) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
first.focus();
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function setInert(el, on) {
|
|
131
|
+
if (el === null) return;
|
|
132
|
+
if (on) el.setAttribute('inert', '');
|
|
133
|
+
else el.removeAttribute('inert');
|
|
134
|
+
}
|
|
@@ -9,7 +9,7 @@ export interface FdySortState {
|
|
|
9
9
|
dir: FdySortDir;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/** A column's comparator type
|
|
12
|
+
/** A column's comparator type, how its values are ordered when sorting. */
|
|
13
13
|
export type FdyColumnType = 'text' | 'number' | 'date';
|
|
14
14
|
|
|
15
15
|
/** Cell alignment. Maps to `text-align` on the header and body cells. */
|
|
@@ -25,7 +25,7 @@ export interface FdyTableColumn<T> {
|
|
|
25
25
|
/** Header label. */
|
|
26
26
|
label: string;
|
|
27
27
|
/**
|
|
28
|
-
* Render the label for assistive tech only
|
|
28
|
+
* Render the label for assistive tech only, the header cell looks empty.
|
|
29
29
|
*
|
|
30
30
|
* For a column of row CONTROLS (an edit button, a row menu), where a visible
|
|
31
31
|
* heading is noise above a column of icons but the column still has to be
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Freeday
|
|
1
|
+
// Freeday, framework-agnostic table model (pure functions, zero dependencies).
|
|
2
2
|
//
|
|
3
3
|
// The sort / filter / paginate logic shared by adapters/vue/components/FdyTable.vue and
|
|
4
|
-
// adapters/react/components/FdyTable.tsx. Kept here
|
|
4
|
+
// adapters/react/components/FdyTable.tsx. Kept here, plain ESM with a .d.ts sidecar, so the
|
|
5
5
|
// two adapters can't drift and so the logic is unit-testable under `node --test`
|
|
6
6
|
// (test/table-model.test.mjs) without a framework runtime. Every function is pure: inputs are
|
|
7
7
|
// never mutated (rows are sliced before sorting), so it is safe to call on each render.
|
|
@@ -34,15 +34,15 @@ function localDay(d) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* @param {unknown} v @returns {string} ISO calendar day (yyyy-mm-dd)
|
|
37
|
+
* @param {unknown} v @returns {string} ISO calendar day (yyyy-mm-dd), lexicographically comparable
|
|
38
38
|
*
|
|
39
39
|
* Two bugs lived here, both of which let a column SORT by date correctly and
|
|
40
|
-
* FILTER by date wrongly
|
|
40
|
+
* FILTER by date wrongly, the worst pairing, because a working sort is what
|
|
41
41
|
* persuades you the header understands dates.
|
|
42
42
|
*
|
|
43
43
|
* 1. It sliced instead of parsing, while `toTime` (which the date SORT uses)
|
|
44
|
-
* parses. A column whose `value` returns a formatted date
|
|
45
|
-
* to render one
|
|
44
|
+
* parses. A column whose `value` returns a formatted date, the normal way
|
|
45
|
+
* to render one, gave `"18 Mar 2024"`, sliced to `"18 Mar 202"`, and
|
|
46
46
|
* compared as text against `"2024-03-18"`: every row failed, silently.
|
|
47
47
|
* 2. A `Date` went through `toISOString`, which is UTC. At UTC+7 a date picked
|
|
48
48
|
* as the 18th is `2024-03-17T17:00Z`, so filtering from the 18th dropped it.
|
|
@@ -155,7 +155,7 @@ export function paginate(rows, pageIndex, pageSize) {
|
|
|
155
155
|
return rows.slice(start, start + pageSize);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
/** Distinct non-empty cell texts for a column, naturally sorted
|
|
158
|
+
/** Distinct non-empty cell texts for a column, naturally sorted, the source for an enum filter. */
|
|
159
159
|
export function distinctValues(rows, column) {
|
|
160
160
|
const seen = new Set();
|
|
161
161
|
const out = [];
|
|
@@ -173,7 +173,7 @@ export function distinctValues(rows, column) {
|
|
|
173
173
|
* The page to land on when the page SIZE changes: whichever page still holds the first row you were
|
|
174
174
|
* already looking at.
|
|
175
175
|
*
|
|
176
|
-
* The two obvious answers are both wrong. Jumping to page 1 throws away your place on a long list
|
|
176
|
+
* The two obvious answers are both wrong. Jumping to page 1 throws away your place on a long list,
|
|
177
177
|
* you asked to see more rows, not to start over. Keeping the same INDEX can land past the end: page
|
|
178
178
|
* 5 of 5 at twenty rows is page 2 of 2 at fifty, and index 4 is nowhere. Anchoring on the first
|
|
179
179
|
* visible row is the only one that always resolves, and it is what the reader expects: the row they
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
NAV_QUERY,
|
|
5
|
+
applyShellState,
|
|
6
|
+
focusPanel,
|
|
7
|
+
restoreFocus,
|
|
8
|
+
trapTab,
|
|
9
|
+
} from '../../core/app-shell.js';
|
|
10
|
+
|
|
11
|
+
// A React wrapper over freeday's `.fdy-app` shell (src/components/app-shell.css). React port of
|
|
12
|
+
// adapters/vue/components/FdyAppShell.vue, sharing adapters/core/app-shell.js so the two cannot
|
|
13
|
+
// drift into two different focus traps.
|
|
14
|
+
//
|
|
15
|
+
// ONE model: `navOpen` means "the nav is visible to the reader". Above the nav breakpoint a hidden
|
|
16
|
+
// nav is `--nav-collapsed`, below it a visible nav is `--nav-open`, the kit owns that mapping so an
|
|
17
|
+
// app never reasons about the viewport to answer a question about its own UI.
|
|
18
|
+
//
|
|
19
|
+
// Omit `navOpen` and the component keeps its own, defaulting BY VIEWPORT: a column on a wide screen,
|
|
20
|
+
// hidden on a narrow one. That default is why the prop is optional, a parent cannot express it as a
|
|
21
|
+
// single initial value before it knows the viewport.
|
|
22
|
+
|
|
23
|
+
export interface FdyAppShellProps {
|
|
24
|
+
navOpen?: boolean;
|
|
25
|
+
onNavOpenChange?: (open: boolean) => void;
|
|
26
|
+
title?: ReactNode;
|
|
27
|
+
toggleLabel?: string;
|
|
28
|
+
toggleIcon?: ReactNode;
|
|
29
|
+
skip?: ReactNode;
|
|
30
|
+
brand?: ReactNode;
|
|
31
|
+
nav?: ReactNode;
|
|
32
|
+
topbar?: ReactNode;
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
|
|
37
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const restoreToRef = useRef<Element | null>(null);
|
|
39
|
+
/* Set while a viewport change is driving the state, so the effect below reconciles `inert` and
|
|
40
|
+
the classes but leaves FOCUS alone: a resize is not a reader asking to go somewhere. */
|
|
41
|
+
const fromResizeRef = useRef<boolean>(false);
|
|
42
|
+
const [overlay, setOverlay] = useState<boolean>(false);
|
|
43
|
+
const [uncontrolled, setUncontrolled] = useState<boolean>(true);
|
|
44
|
+
|
|
45
|
+
const controlled: boolean = props.navOpen !== undefined;
|
|
46
|
+
const navVisible: boolean = controlled ? props.navOpen === true : uncontrolled;
|
|
47
|
+
|
|
48
|
+
const setVisible = useCallback((next: boolean): void => {
|
|
49
|
+
if (!controlled) setUncontrolled(next);
|
|
50
|
+
if (props.onNavOpenChange !== undefined) props.onNavOpenChange(next);
|
|
51
|
+
}, [controlled, props.onNavOpenChange]);
|
|
52
|
+
|
|
53
|
+
/* The media listener reads these through refs and subscribes ONCE. Depending on navVisible would
|
|
54
|
+
resubscribe on every toggle and re-run the handler, and its "narrowing with the nav visible
|
|
55
|
+
hides it" rule would then fire on an ordinary open, closing the panel in the same tick the
|
|
56
|
+
reader opened it. */
|
|
57
|
+
const navVisibleRef = useRef<boolean>(navVisible);
|
|
58
|
+
navVisibleRef.current = navVisible;
|
|
59
|
+
const setVisibleRef = useRef<(next: boolean) => void>(setVisible);
|
|
60
|
+
setVisibleRef.current = setVisible;
|
|
61
|
+
|
|
62
|
+
// The media query owns `overlay`, and the two directions are not symmetrical. Narrowing with the
|
|
63
|
+
// nav visible would drop an overlay panel over a page nobody asked to leave, so it is hidden;
|
|
64
|
+
// widening is harmless, since a visible nav simply becomes the column again.
|
|
65
|
+
useEffect((): (() => void) => {
|
|
66
|
+
const media: MediaQueryList = window.matchMedia(NAV_QUERY);
|
|
67
|
+
const onChange = (): void => {
|
|
68
|
+
const nowOverlay: boolean = !media.matches;
|
|
69
|
+
setOverlay((was: boolean): boolean => {
|
|
70
|
+
if (was !== nowOverlay) fromResizeRef.current = true;
|
|
71
|
+
return nowOverlay;
|
|
72
|
+
});
|
|
73
|
+
if (nowOverlay && navVisibleRef.current) setVisibleRef.current(false);
|
|
74
|
+
};
|
|
75
|
+
// Mount: adopt the viewport without the hide-on-narrow side effect, nothing is open yet.
|
|
76
|
+
setOverlay(!media.matches);
|
|
77
|
+
if (!controlled) setUncontrolled(media.matches);
|
|
78
|
+
media.addEventListener('change', onChange);
|
|
79
|
+
return (): void => media.removeEventListener('change', onChange);
|
|
80
|
+
}, [controlled]);
|
|
81
|
+
|
|
82
|
+
// Focus moves only after the class change has been painted, or the panel is still off-canvas and
|
|
83
|
+
// the browser refuses to focus what it cannot lay out.
|
|
84
|
+
useEffect((): void => {
|
|
85
|
+
const el: HTMLDivElement | null = rootRef.current;
|
|
86
|
+
if (el === null) return;
|
|
87
|
+
applyShellState(el, { navVisible, overlay });
|
|
88
|
+
if (fromResizeRef.current) {
|
|
89
|
+
fromResizeRef.current = false;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!overlay) return;
|
|
93
|
+
if (navVisible) restoreToRef.current = focusPanel(el);
|
|
94
|
+
else restoreFocus(el, restoreToRef.current);
|
|
95
|
+
}, [navVisible, overlay]);
|
|
96
|
+
|
|
97
|
+
useEffect((): (() => void) => {
|
|
98
|
+
const onKeydown = (e: KeyboardEvent): void => {
|
|
99
|
+
const el: HTMLDivElement | null = rootRef.current;
|
|
100
|
+
if (el === null || !overlay || !navVisible) return;
|
|
101
|
+
if (e.key === 'Escape') {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
setVisible(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
trapTab(el, e);
|
|
107
|
+
};
|
|
108
|
+
document.addEventListener('keydown', onKeydown);
|
|
109
|
+
return (): void => document.removeEventListener('keydown', onKeydown);
|
|
110
|
+
}, [overlay, navVisible, setVisible]);
|
|
111
|
+
|
|
112
|
+
const shellClass: string = overlay
|
|
113
|
+
? (navVisible ? 'fdy-app fdy-app--nav-open' : 'fdy-app')
|
|
114
|
+
: (navVisible ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed');
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div ref={rootRef} className={shellClass}>
|
|
118
|
+
{props.skip}
|
|
119
|
+
|
|
120
|
+
<aside
|
|
121
|
+
className="fdy-app__sidebar"
|
|
122
|
+
onClick={(e): void => {
|
|
123
|
+
// Following a link in an overlay nav means "take me there", the panel must not stay over
|
|
124
|
+
// the page it was just asked for. On a wide viewport the nav is a column: nothing to close.
|
|
125
|
+
if (!overlay || !navVisible) return;
|
|
126
|
+
const target: HTMLElement | null = e.target as HTMLElement | null;
|
|
127
|
+
if (target !== null && target.closest('.fdy-nav__item') !== null) setVisible(false);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{props.brand}
|
|
131
|
+
{props.nav}
|
|
132
|
+
</aside>
|
|
133
|
+
|
|
134
|
+
<div className="fdy-app__content">
|
|
135
|
+
<header className="fdy-app__topbar">
|
|
136
|
+
<button
|
|
137
|
+
className="fdy-app__navtoggle"
|
|
138
|
+
type="button"
|
|
139
|
+
aria-label={props.toggleLabel ?? 'Toggle navigation'}
|
|
140
|
+
onClick={(): void => setVisible(!navVisible)}
|
|
141
|
+
>{props.toggleIcon ?? '☰'}</button>
|
|
142
|
+
|
|
143
|
+
<h1 className="fdy-app__title">{props.title}</h1>
|
|
144
|
+
|
|
145
|
+
{props.topbar}
|
|
146
|
+
</header>
|
|
147
|
+
|
|
148
|
+
<main className="fdy-app__main">{props.children}</main>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
className="fdy-app__backdrop"
|
|
153
|
+
onClick={(): void => {
|
|
154
|
+
if (overlay && navVisible) setVisible(false);
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -3,7 +3,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { usePopover } from '../usePopover';
|
|
4
4
|
|
|
5
5
|
// A controlled React port of freeday's autocomplete (src/freeday-autocomplete.js +
|
|
6
|
-
// autocomplete.css): a WAI-ARIA APG *editable* combobox
|
|
6
|
+
// autocomplete.css): a WAI-ARIA APG *editable* combobox, a text input filters a listbox of
|
|
7
7
|
// options as you type; picking one fills the input. `value` + `onChange` in place of the
|
|
8
8
|
// enhancer's DOM mutation, so React owns the input. Filtering mirrors the enhancer exactly
|
|
9
9
|
// (case-insensitive substring on the trimmed query); pass server-filtered `options` and it
|
|
@@ -3,7 +3,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { usePopover } from '../usePopover';
|
|
4
4
|
|
|
5
5
|
// A controlled React port of freeday's cascade select (src/freeday-cascade.js + cascade.css):
|
|
6
|
-
// a hierarchical drill-down picker showing one level at a time
|
|
6
|
+
// a hierarchical drill-down picker showing one level at a time, branches drill in, a back
|
|
7
7
|
// control ascends, a leaf selects and the value is the leaf's value (the display is the full
|
|
8
8
|
// path). The enhancer's data model is a hidden nested <ul>; here it is a typed tree, which is
|
|
9
9
|
// what a framework app actually has. `value` + `onChange` in place of the DOM mutation.
|
|
@@ -21,7 +21,7 @@ export interface CflPage<Row> {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface FdyCflProps<Row extends Record<string, unknown>> {
|
|
24
|
-
/** Single: `Row | null`. With `multiple`, an array
|
|
24
|
+
/** Single: `Row | null`. With `multiple`, an array, `Row[] | null`, where null and [] both mean
|
|
25
25
|
* nothing picked. The enhancer has had `data-fdy-cfl-multiple` all along; this is the typed
|
|
26
26
|
* wrappers catching up (#019). */
|
|
27
27
|
value: Row | Row[] | null;
|
|
@@ -30,7 +30,7 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
|
|
|
30
30
|
columns: ReadonlyArray<CflColumn<Row>>;
|
|
31
31
|
display: (row: Row) => string;
|
|
32
32
|
rowKey: (row: Row) => string;
|
|
33
|
-
/** Advisory only
|
|
33
|
+
/** Advisory only, the caller's `fetchPage` owns paging; kept for API documentation. */
|
|
34
34
|
pageSize?: number;
|
|
35
35
|
/** The dialog's heading. Default 'Choose data', matching the Blazor adapter's `Title`. */
|
|
36
36
|
title?: string;
|
|
@@ -81,7 +81,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
81
81
|
const rowId = (index: number): string => `${baseId}-row-${index}`;
|
|
82
82
|
|
|
83
83
|
/* Unsetting is not "picking nothing": it must not touch the dialog, and focus must land on a
|
|
84
|
-
control that still exists
|
|
84
|
+
control that still exists, the trigger beside it, since this button disappears with the value. */
|
|
85
85
|
const clearLabelText: string = props.clearLabel ?? 'Clear selection';
|
|
86
86
|
const clearValue = (): void => {
|
|
87
87
|
setPicked([]);
|
|
@@ -106,7 +106,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
106
106
|
|
|
107
107
|
// Out-of-order guard: every fetch takes a monotonically increasing token; a resolved or
|
|
108
108
|
// rejected page is applied only if it still owns the latest token. A newer search, a
|
|
109
|
-
// load-more, a dialog close, or an unmount all bump it
|
|
109
|
+
// load-more, a dialog close, or an unmount all bump it, this keeps a slow stale response
|
|
110
110
|
// from overwriting fresh results (or a closed/unmounted component's state).
|
|
111
111
|
const reqIdRef = useRef<number>(0);
|
|
112
112
|
// In-memory cache keyed by `${query}::${page}`, cleared on open and on close.
|
|
@@ -117,7 +117,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
117
117
|
|
|
118
118
|
const isDisabled: boolean = props.disabled === true;
|
|
119
119
|
const isReadonly: boolean = props.readonly === true;
|
|
120
|
-
/* `display()` takes one row, so in multi the field states HOW MANY
|
|
120
|
+
/* `display()` takes one row, so in multi the field states HOW MANY, naming one of six would be a
|
|
121
121
|
lie, and naming all six does not fit a control that is 22rem wide. */
|
|
122
122
|
const currentRows: Row[] = Array.isArray(props.value)
|
|
123
123
|
? props.value
|
|
@@ -137,7 +137,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
137
137
|
? props.display(props.value as Row)
|
|
138
138
|
: '';
|
|
139
139
|
// The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
|
|
140
|
-
// search input's aria refs on rows existing
|
|
140
|
+
// search input's aria refs on rows existing, otherwise they'd dangle during loading/empty/error.
|
|
141
141
|
const hasRows: boolean = rows.length > 0;
|
|
142
142
|
const controlsId: string | undefined = hasRows ? resultsId : undefined;
|
|
143
143
|
const activeDescendant: string | undefined = hasRows && activeIndex >= 0 ? rowId(activeIndex) : undefined;
|
|
@@ -156,7 +156,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
156
156
|
try {
|
|
157
157
|
const cached: CflPage<Row> | undefined = cacheRef.current.get(key);
|
|
158
158
|
const res: CflPage<Row> = cached ?? (await props.fetchPage(targetQuery, targetPage));
|
|
159
|
-
if (token !== reqIdRef.current) return; // stale
|
|
159
|
+
if (token !== reqIdRef.current) return; // stale, a newer request has started
|
|
160
160
|
if (cached === undefined) cacheRef.current.set(key, res);
|
|
161
161
|
const copy: Row[] = res.rows.slice(); // never mutate the caller's array
|
|
162
162
|
setRows((prev: Row[]): Row[] => (append ? prev.concat(copy) : copy));
|
|
@@ -171,7 +171,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Mirrors Vue's `retry()`: only `lastPage`/`lastAppend` are frozen from the failed request
|
|
174
|
+
// Mirrors Vue's `retry()`: only `lastPage`/`lastAppend` are frozen from the failed request,
|
|
175
175
|
// the query is re-read live so a retry after editing the search box (within the debounce
|
|
176
176
|
// window) uses what's currently typed, not a stale snapshot.
|
|
177
177
|
function retry(): void {
|
|
@@ -203,7 +203,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
203
203
|
setActiveIndex(clamped);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
// Scroll the active row into view once it (re)renders
|
|
206
|
+
// Scroll the active row into view once it (re)renders. React's analogue of Vue's
|
|
207
207
|
// `nextTick(() => scrollIntoView(...))`; fires for both keyboard nav and row hover, same as
|
|
208
208
|
// the Vue source's `setActive`.
|
|
209
209
|
useEffect((): void => {
|
|
@@ -225,7 +225,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
225
225
|
);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
/* A click means "tick this" in multi and "this is my answer" in single
|
|
228
|
+
/* A click means "tick this" in multi and "this is my answer" in single, the whole difference. */
|
|
229
229
|
function onRowClick(row: Row): void {
|
|
230
230
|
if (isMultiple) togglePick(row);
|
|
231
231
|
else commit(row);
|
|
@@ -321,7 +321,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
// Unmount safety: bump the token so any in-flight fetch's setState is dropped, and cancel a
|
|
324
|
-
// pending debounce
|
|
324
|
+
// pending debounce, mirrors Vue's onBeforeUnmount plus the same token trick onClose uses.
|
|
325
325
|
useEffect((): (() => void) => {
|
|
326
326
|
return (): void => {
|
|
327
327
|
reqIdRef.current++;
|
|
@@ -5,7 +5,7 @@ import { FdyDatepicker } from './FdyDatepicker';
|
|
|
5
5
|
// two linked FdyDatepickers where the end can't precede the start (start.max = end, end.min = start).
|
|
6
6
|
// `value` + `onChange` in place of Vue's `v-model`; this composes the single-date picker rather than
|
|
7
7
|
// re-implementing the calendar. Note: the vanilla enhancer's cross-calendar `.in-range` day shading
|
|
8
|
-
// is not reproduced (each picker is independent)
|
|
8
|
+
// is not reproduced (each picker is independent), the min/max linkage is what keeps the range valid.
|
|
9
9
|
|
|
10
10
|
export interface DateRangeValue {
|
|
11
11
|
start: string | null;
|
|
@@ -19,7 +19,7 @@ export interface FdyDateRangeProps {
|
|
|
19
19
|
max?: string;
|
|
20
20
|
locale?: string;
|
|
21
21
|
disabled?: boolean;
|
|
22
|
-
/** Locked/view mode for both pickers
|
|
22
|
+
/** Locked/view mode for both pickers, focusable, values shown, but can't be opened or changed. */
|
|
23
23
|
readonly?: boolean;
|
|
24
24
|
invalid?: boolean;
|
|
25
25
|
describedby?: string;
|
|
@@ -55,7 +55,7 @@ export interface FdyDatepickerProps {
|
|
|
55
55
|
ariaLabelledby?: string;
|
|
56
56
|
/** Show a clear (×) button in the trigger when a date is set, so an optional date can be unset. Calls onChange('') to reset. Off by default. */
|
|
57
57
|
clearable?: boolean;
|
|
58
|
-
/** aria-label for the previous-month nav button. Default 'Previous month'
|
|
58
|
+
/** aria-label for the previous-month nav button. Default 'Previous month', override for non-English UIs (month/weekday names already follow `locale`). */
|
|
59
59
|
/** aria-label for the title button that drills to the month grid. Default 'Choose month'. */
|
|
60
60
|
chooseMonthLabel?: string;
|
|
61
61
|
/** aria-labels for the year arrows shown in the month grid. Defaults 'Previous year' / 'Next year'. */
|
|
@@ -132,7 +132,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
132
132
|
const pendingFocusRef = useRef<string | null>(null);
|
|
133
133
|
|
|
134
134
|
const [open, setOpen] = useState<boolean>(false);
|
|
135
|
-
/* 'days' | 'months'
|
|
135
|
+
/* 'days' | 'months', the calendar drills one level up instead of growing furniture beside the
|
|
136
136
|
title. Before this the only pointer route to another month was one click per month. */
|
|
137
137
|
const [mode, setMode] = useState<'days' | 'months' | 'years'>('days');
|
|
138
138
|
const [focusMonth, setFocusMonth] = useState<number>(0);
|
|
@@ -224,7 +224,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
224
224
|
return false;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/** A month is only unreachable when the WHOLE month falls outside min/max
|
|
227
|
+
/** A month is only unreachable when the WHOLE month falls outside min/max, a range that sits
|
|
228
228
|
* inside one month disables both ends while the middle is perfectly selectable. */
|
|
229
229
|
function isMonthDisabled(year: number, month: number): boolean {
|
|
230
230
|
const first: Date = new Date(year, month, 1);
|
|
@@ -429,7 +429,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
429
429
|
closePanel(true);
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
-
// Clear (reset to empty). Calls onChange('')
|
|
432
|
+
// Clear (reset to empty). Calls onChange(''), parseISO('') is null, so the placeholder shows again.
|
|
433
433
|
function clearValue(): void {
|
|
434
434
|
props.onChange('');
|
|
435
435
|
setOpen(false);
|
|
@@ -506,7 +506,7 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
|
|
|
506
506
|
}
|
|
507
507
|
|
|
508
508
|
// Close when focus leaves the control entirely (e.g. Shift+Tab off the trigger).
|
|
509
|
-
/* A null relatedTarget means focus fell to <body
|
|
509
|
+
/* A null relatedTarget means focus fell to <body>, which is what happens when the element the
|
|
510
510
|
user just pressed is REMOVED by the click it triggered (drilling into the month grid replaces
|
|
511
511
|
the grid, and with it the cell that had focus). That is not focus leaving the control, and
|
|
512
512
|
closing on it made the panel vanish mid-navigation. A pointer that really lands outside is
|
|
@@ -2,7 +2,7 @@ import type { JSX, ReactNode } from 'react';
|
|
|
2
2
|
import { useEffect, useId, useRef } from 'react';
|
|
3
3
|
|
|
4
4
|
// A controlled React wrapper over freeday's `.fdy-drawer` native <dialog> side panel
|
|
5
|
-
// (src/components/drawer.css). React port of adapters/vue/components/FdyDrawer.vue
|
|
5
|
+
// (src/components/drawer.css). React port of adapters/vue/components/FdyDrawer.vue, the same
|
|
6
6
|
// controlled glue as FdyModal (guarded showModal()/close(), onCancel + preventDefault so Esc routes
|
|
7
7
|
// through app state, backdrop-click via `event.target === dialogEl`) applied to a drawer that anchors
|
|
8
8
|
// left (default) or right. Native <dialog> supplies the focus trap, focus restore, top-layer stacking
|