@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.
Files changed (145) hide show
  1. package/CHANGELOG.md +657 -535
  2. package/COMPONENTS.md +153 -135
  3. package/README.id.md +46 -45
  4. package/README.md +46 -43
  5. package/USAGE.md +39 -35
  6. package/adapters/blazor/FdyAppShell.razor +50 -0
  7. package/adapters/blazor/FdyAppShell.razor.cs +114 -0
  8. package/adapters/blazor/FdyCfl.razor.cs +1 -1
  9. package/adapters/blazor/FdyChart.razor +1 -1
  10. package/adapters/blazor/FdyChart.razor.cs +3 -3
  11. package/adapters/blazor/FdyChartSeries.cs +1 -1
  12. package/adapters/blazor/FdyCombo.razor.cs +2 -2
  13. package/adapters/blazor/FdyModal.razor +1 -1
  14. package/adapters/blazor/FdyTable.razor.cs +7 -7
  15. package/adapters/blazor/FdyTableFilter.razor +1 -1
  16. package/adapters/blazor/FdyTableFooter.razor +1 -1
  17. package/adapters/blazor/FdyTableFooter.razor.cs +4 -4
  18. package/adapters/blazor/Freeday.Blazor.csproj +1 -1
  19. package/adapters/blazor/FreedayComponentBase.cs +1 -1
  20. package/adapters/blazor/TableModel.cs +3 -3
  21. package/adapters/blazor/TableTypes.cs +3 -3
  22. package/adapters/blazor/freeday-blazor.js +5 -5
  23. package/adapters/core/app-shell.d.ts +30 -0
  24. package/adapters/core/app-shell.js +134 -0
  25. package/adapters/core/table-model.d.ts +2 -2
  26. package/adapters/core/table-model.js +8 -8
  27. package/adapters/react/components/FdyAppShell.tsx +159 -0
  28. package/adapters/react/components/FdyAutocomplete.tsx +1 -1
  29. package/adapters/react/components/FdyCascade.tsx +1 -1
  30. package/adapters/react/components/FdyCfl.tsx +11 -11
  31. package/adapters/react/components/FdyDateRange.tsx +2 -2
  32. package/adapters/react/components/FdyDatepicker.tsx +5 -5
  33. package/adapters/react/components/FdyDrawer.tsx +1 -1
  34. package/adapters/react/components/FdyModal.tsx +2 -2
  35. package/adapters/react/components/FdyTable.tsx +7 -7
  36. package/adapters/react/components/FdyTableFilter.tsx +1 -1
  37. package/adapters/react/components/FdyTableFooter.tsx +5 -5
  38. package/adapters/react/index.d.ts +2 -1
  39. package/adapters/react/index.js +1 -0
  40. package/adapters/react/useFreeday.js +3 -3
  41. package/adapters/vue/components/FdyAppShell.vue +160 -0
  42. package/adapters/vue/components/FdyAutocomplete.vue +1 -1
  43. package/adapters/vue/components/FdyCascade.vue +1 -1
  44. package/adapters/vue/components/FdyCfl.vue +8 -8
  45. package/adapters/vue/components/FdyChart.vue +2 -2
  46. package/adapters/vue/components/FdyCombo.vue +1 -1
  47. package/adapters/vue/components/FdyDateRange.vue +2 -2
  48. package/adapters/vue/components/FdyDatepicker.vue +5 -5
  49. package/adapters/vue/components/FdyDrawer.vue +3 -3
  50. package/adapters/vue/components/FdyModal.vue +3 -3
  51. package/adapters/vue/components/FdyTable.vue +7 -7
  52. package/adapters/vue/components/FdyTableFilter.vue +1 -1
  53. package/adapters/vue/components/FdyTableFooter.vue +5 -5
  54. package/adapters/vue/index.d.ts +3 -2
  55. package/adapters/vue/index.js +1 -0
  56. package/adapters/vue/useFreeday.js +3 -3
  57. package/dist/asset.d.ts +3 -3
  58. package/dist/freeday-app-shell.js +49 -9
  59. package/dist/freeday-autocomplete.js +2 -2
  60. package/dist/freeday-breakpoint.js +1 -1
  61. package/dist/freeday-carousel.js +5 -5
  62. package/dist/freeday-cascade.js +7 -7
  63. package/dist/freeday-cfl.js +9 -9
  64. package/dist/freeday-chart.js +11 -11
  65. package/dist/freeday-chip.js +3 -3
  66. package/dist/freeday-datepicker.js +11 -7
  67. package/dist/freeday-datetime.js +5 -5
  68. package/dist/freeday-drawer.js +1 -1
  69. package/dist/freeday-form.js +14 -14
  70. package/dist/freeday-mask.js +6 -6
  71. package/dist/freeday-menu.js +1 -1
  72. package/dist/freeday-number.js +3 -3
  73. package/dist/freeday-popover.js +3 -3
  74. package/dist/freeday-rating.js +2 -2
  75. package/dist/freeday-select.js +5 -5
  76. package/dist/freeday-slider.js +2 -2
  77. package/dist/freeday-stepper.js +7 -7
  78. package/dist/freeday-table.js +15 -15
  79. package/dist/freeday-tabs.js +1 -1
  80. package/dist/freeday-timepicker.js +2 -2
  81. package/dist/freeday-toast.js +8 -8
  82. package/dist/freeday-tree.js +3 -3
  83. package/dist/freeday-upload.js +25 -25
  84. package/dist/freeday.bundle.css +176 -176
  85. package/dist/freeday.css +170 -170
  86. package/dist/freeday.d.ts +2 -2
  87. package/dist/freeday.js +202 -158
  88. package/dist/freeday.tokens.css +5 -5
  89. package/docs/agent-onboarding.md +42 -42
  90. package/docs/getting-started.md +35 -35
  91. package/docs/integrations.md +18 -18
  92. package/docs/reference-screen.html +14 -14
  93. package/package.json +5 -4
  94. package/src/base.css +6 -6
  95. package/src/components/accordion.css +4 -4
  96. package/src/components/alert.css +1 -1
  97. package/src/components/app-shell.css +10 -10
  98. package/src/components/appbar.css +3 -3
  99. package/src/components/autocomplete.css +1 -1
  100. package/src/components/avatar.css +3 -3
  101. package/src/components/badge.css +5 -5
  102. package/src/components/breadcrumb.css +1 -1
  103. package/src/components/breakpoints.css +4 -4
  104. package/src/components/button.css +13 -13
  105. package/src/components/card.css +8 -8
  106. package/src/components/carousel.css +2 -2
  107. package/src/components/cascade.css +2 -2
  108. package/src/components/cfl.css +4 -4
  109. package/src/components/chart.css +3 -3
  110. package/src/components/chip.css +2 -2
  111. package/src/components/combo.css +3 -3
  112. package/src/components/composition.css +7 -7
  113. package/src/components/datepicker.css +6 -6
  114. package/src/components/datetimepicker.css +1 -1
  115. package/src/components/description-list.css +1 -1
  116. package/src/components/divider.css +1 -1
  117. package/src/components/drawer.css +1 -1
  118. package/src/components/file-upload.css +5 -5
  119. package/src/components/filterbar.css +7 -7
  120. package/src/components/form-grid.css +2 -2
  121. package/src/components/input-group.css +6 -6
  122. package/src/components/input.css +6 -6
  123. package/src/components/kbd.css +1 -1
  124. package/src/components/list.css +6 -6
  125. package/src/components/menu.css +3 -3
  126. package/src/components/modal.css +1 -1
  127. package/src/components/pagination.css +1 -1
  128. package/src/components/progress.css +1 -1
  129. package/src/components/rating.css +2 -2
  130. package/src/components/selection.css +4 -4
  131. package/src/components/skeleton.css +1 -1
  132. package/src/components/slider.css +1 -1
  133. package/src/components/spinner.css +1 -1
  134. package/src/components/states.css +1 -1
  135. package/src/components/stepper.css +2 -2
  136. package/src/components/table.css +15 -15
  137. package/src/components/tabs.css +3 -3
  138. package/src/components/timeline.css +1 -1
  139. package/src/components/timepicker.css +1 -1
  140. package/src/components/toast.css +3 -3
  141. package/src/components/tooltip.css +1 -1
  142. package/src/components/tree.css +2 -2
  143. package/tokens/breakpoints.d.ts +2 -2
  144. package/tokens/breakpoints.mjs +3 -3
  145. 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 how its values are ordered when sorting.</summary>
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 a card list below the <c>md</c> breakpoint, a summary, an export without
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 the header cell looks empty.
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 Blazor JS interop (classic IIFE; registers window.FreedayBlazor).
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 both children emit the same fdy-datepicker-change, so
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 deliberate for the raw path, wrong for a Blazor
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 a click whose target is the dialog box
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 how its values are ordered when sorting. */
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 the header cell looks empty.
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 framework-agnostic table model (pure functions, zero dependencies).
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 plain ESM with a .d.ts sidecar so the
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) lexicographically comparable
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 the worst pairing, because a working sort is what
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 the normal way
45
- * to render one gave `"18 Mar 2024"`, sliced to `"18 Mar 202"`, and
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 the source for an enum filter. */
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 a text input filters a listbox of
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 branches drill in, a back
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 `Row[] | null`, where null and [] both mean
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 the caller's `fetchPage` owns paging; kept for API documentation. */
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 the trigger beside it, since this button disappears with the value. */
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 this keeps a slow stale response
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 naming one of six would be a
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 otherwise they'd dangle during loading/empty/error.
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 a newer request has started
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 React's analogue of Vue's
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 the whole difference. */
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 mirrors Vue's onBeforeUnmount plus the same token trick onClose uses.
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) the min/max linkage is what keeps the range valid.
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 focusable, values shown, but can't be opened or changed. */
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' override for non-English UIs (month/weekday names already follow `locale`). */
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' the calendar drills one level up instead of growing furniture beside the
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 a range that sits
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('') parseISO('') is null, so the placeholder shows again.
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> which is what happens when the element the
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 the same
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