@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
@@ -3,7 +3,7 @@ import { useEffect, useId, useRef } from 'react';
3
3
 
4
4
  // A controlled React wrapper over freeday's `.fdy-modal` native <dialog> (src/components/modal.css).
5
5
  // React port of adapters/vue/components/FdyModal.vue: reconciles the reactive `open` boolean with a
6
- // DOM element whose open/close is a method call showModal()/close() guarded against the
6
+ // DOM element whose open/close is a method call, showModal()/close() guarded against the
7
7
  // already-open/closed cases (showModal() on an open dialog throws), onCancel + preventDefault so Esc
8
8
  // routes through app state instead of closing the DOM behind its back, and backdrop-click detection
9
9
  // via `event.target === dialogEl`. Native <dialog> already gives the focus trap, focus restore,
@@ -44,7 +44,7 @@ export function FdyModal(props: FdyModalProps): JSX.Element {
44
44
  if (dismissible) props.onClose();
45
45
  }}
46
46
  onClick={(e): void => {
47
- // The ::backdrop is not a separate element target === the dialog box means a backdrop click.
47
+ // The ::backdrop is not a separate element, target === the dialog box means a backdrop click.
48
48
  if (dismissible && e.target === dialogRef.current) props.onClose();
49
49
  }}
50
50
  >
@@ -45,14 +45,14 @@ export interface FdyTableProps<Row extends object> {
45
45
  /** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
46
46
  pageSize?: number;
47
47
  /**
48
- * Controlled client-side page index (0-based). Provide it with `pageSize`, without `page` to
48
+ * Controlled client-side page index (0-based). Provide it, with `pageSize`, without `page`, to
49
49
  * own the page while the table keeps doing filter/sort/paginate. This is what lets an EXTERNAL
50
50
  * pager drive the table: a responsive screen that hides `.fdy-datatable` below `md` and renders a
51
51
  * card list from `onProcess` can render one pager for both breakpoints and point it here.
52
52
  * Omit for the internal index (unchanged default).
53
53
  */
54
54
  /** Withhold the table's own footer (pager + range) so the screen can render one. Server mode had
55
- * no way to do this: the app owns the page there ANYWAY, and was still handed a second control
55
+ * no way to do this: the app owns the page there ANYWAY, and was still handed a second control,
56
56
  * a responsive list that shows a table at one breakpoint and cards at another ended up with the
57
57
  * kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
58
58
  pager?: boolean;
@@ -61,13 +61,13 @@ export interface FdyTableProps<Row extends object> {
61
61
  * (unchanged default). Every back office has one, and a table that renders two thirds of its own
62
62
  * footer forces the app to rebuild all three to add the last (#008).
63
63
  *
64
- * Server mode reports the pick through `onPageChange` same callback as a page click, with a new
64
+ * Server mode reports the pick through `onPageChange`, same callback as a page click, with a new
65
65
  * `size`. Client mode keeps it internally and also calls `onPageSizeChange`, so the control works
66
66
  * with nothing wired and can still be persisted.
67
67
  */
68
68
  pageSizes?: readonly number[];
69
69
  /** Client mode with `pageSizes`: the reader picked a new rows-per-page. The table has already
70
- * applied it this is for a caller that wants to persist the choice. */
70
+ * applied it, this is for a caller that wants to persist the choice. */
71
71
  onPageSizeChange?: (size: number) => void;
72
72
  pageIndex?: number;
73
73
  /** Client mode with `pageIndex` provided: the table asks for a new 0-based index (pager click, a
@@ -86,7 +86,7 @@ export interface FdyTableProps<Row extends object> {
86
86
  rowClass?: (row: Row) => string | undefined;
87
87
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
88
88
  onRowActivate?: (row: Row) => void;
89
- /** Called with the processed page of rows (after filter/sort/paginate) plus the total row count
89
+ /** Called with the processed page of rows (after filter/sort/paginate) plus the total row count,
90
90
  * in BOTH modes, whenever they change. Lets a consumer render the SAME processed set elsewhere
91
91
  * (a `< md` card list, a "selected" summary, export-to-CSV) without re-deriving the pipeline. */
92
92
  onProcess?: (result: { rows: Row[]; total: number }) => void;
@@ -105,7 +105,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
105
105
  const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
106
106
  const [internalPageIndex, setInternalPageIndex] = useState<number>(0);
107
107
  /* Client-mode rows-per-page. `pageSize` is a plain prop with no callback, so a footer control that
108
- * only reported would do nothing in the app that wired nothing the table applies the pick itself
108
+ * only reported would do nothing in the app that wired nothing, the table applies the pick itself
109
109
  * and reports it. `prevPageSize` is React's adjust-state-during-render idiom: an explicit change to
110
110
  * the prop wins back, because the parent said something newer. */
111
111
  const [internalPageSize, setInternalPageSize] = useState<number | null>(null);
@@ -238,7 +238,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
238
238
  .join(' ');
239
239
  return cls === '' ? undefined : cls;
240
240
  }
241
- // Enter/Space activate only when the row itself is focused a control inside a cell keeps its own
241
+ // Enter/Space activate only when the row itself is focused, a control inside a cell keeps its own
242
242
  // event (the `event.target !== event.currentTarget` guard). Click relies on inner controls calling
243
243
  // stopPropagation, matching the pattern consumers hand-roll today.
244
244
  function onRowKeydown(e: React.KeyboardEvent<HTMLTableRowElement>, row: Row): void {
@@ -7,7 +7,7 @@ import type { FdyColumnFilter, FdyColumnFilterType } from '../../core/table-mode
7
7
  // Internal to FdyTable: one column's header funnel button + its type-aware filter popover
8
8
  // (text / enum / number / date) over freeday's `.fdy-table__filterbtn` + `.fdy-filter*` classes.
9
9
  // React port of adapters/vue/components/FdyTableFilter.vue. Reuses usePopover so the panel escapes
10
- // the table's `overflow:hidden` via the top layer. Purely controlled renders the current
10
+ // the table's `overflow:hidden` via the top layer. Purely controlled, renders the current
11
11
  // `filter`, emits the next one (or null to clear); the parent owns where it goes. Not exported.
12
12
 
13
13
  export interface FdyTableFilterProps {
@@ -8,19 +8,19 @@ import { FdyCombo } from './FdyCombo';
8
8
  * The band under a table: what you are looking at, how much of it you see, where you are.
9
9
  *
10
10
  * `FdyTable` renders this itself, so most screens never import it. It is exported for the one shape
11
- * that cannot use the table's own a RESPONSIVE list, where a `.fdy-datatable` at `lg` and a
11
+ * that cannot use the table's own, a RESPONSIVE list, where a `.fdy-datatable` at `lg` and a
12
12
  * `.fdy-list` below it are two renderings of ONE page of rows. A footer inside the table is inside
13
13
  * the half that is hidden on a phone, so those screens need to render it once, outside both
14
14
  * (improvement notes #005 and #008, from IDU_EMATE_APPL_WEB).
15
15
  *
16
- * It owns nothing. `page` in, `onPageChange` out the same contract as the table's server mode.
16
+ * It owns nothing. `page` in, `onPageChange` out, the same contract as the table's server mode.
17
17
  */
18
18
  export interface FdyTableFooterProps {
19
19
  /** The page being shown. `size` drives the range AND the rows-per-page control's value. */
20
20
  page: FdyPageState;
21
21
  onPageChange?: (page: FdyPageState) => void;
22
22
  /**
23
- * Offer a rows-per-page control. Omit for none the footer is then range + pager, exactly as
23
+ * Offer a rows-per-page control. Omit for none; the footer is then range + pager, exactly as
24
24
  * before. Picking a size calls `onPageChange` with the new `size` and the index that still holds
25
25
  * the row you were looking at.
26
26
  */
@@ -29,7 +29,7 @@ export interface FdyTableFooterProps {
29
29
 
30
30
  export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
31
31
  const { index, size, total } = props.page;
32
- /* The combo is labelled by the visible word beside it a <label htmlFor> cannot reach inside a
32
+ /* The combo is labelled by the visible word beside it, a <label htmlFor> cannot reach inside a
33
33
  * component, and an aria-label would leave that word attached to nothing. */
34
34
  const sizeLabelId: string = useId();
35
35
 
@@ -41,7 +41,7 @@ export function FdyTableFooter(props: FdyTableFooterProps): JSX.Element | null {
41
41
 
42
42
  const hasPager: boolean = size > 0 && totalPages > 1;
43
43
  const sizes: readonly number[] = props.pageSizes ?? [];
44
- /* One page and no size control means there is nothing here to say the table has always withheld
44
+ /* One page and no size control means there is nothing here to say, the table has always withheld
45
45
  * the whole band in that case, and this is where that decision now lives. */
46
46
  if (!hasPager && sizes.length === 0) return null;
47
47
 
@@ -36,11 +36,12 @@ export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components
36
36
  export { FdyCfl, type FdyCflProps, type CflColumn, type CflPage } from './components/FdyCfl';
37
37
  export { FdyChart, type FdyChartProps, type FdyChartSeries } from './components/FdyChart';
38
38
  export { FdyTable, type FdyTableProps } from './components/FdyTable';
39
- /** The table's own footer, standalone for a responsive list whose table is hidden at some
39
+ /** The table's own footer, standalone, for a responsive list whose table is hidden at some
40
40
  * breakpoints and so cannot host it. */
41
41
  export { FdyTableFooter, type FdyTableFooterProps } from './components/FdyTableFooter';
42
42
  export { FdyModal, type FdyModalProps } from './components/FdyModal';
43
43
  export { FdyDrawer, type FdyDrawerProps } from './components/FdyDrawer';
44
+ export { FdyAppShell, type FdyAppShellProps } from './components/FdyAppShell';
44
45
 
45
46
  /** Controlled data-table types (shared, framework-agnostic core). */
46
47
  export type {
@@ -11,3 +11,4 @@ export { FdyTable } from './components/FdyTable.tsx';
11
11
  export { FdyTableFooter } from './components/FdyTableFooter.tsx';
12
12
  export { FdyModal } from './components/FdyModal.tsx';
13
13
  export { FdyDrawer } from './components/FdyDrawer.tsx';
14
+ export { FdyAppShell } from './components/FdyAppShell.tsx';
@@ -1,4 +1,4 @@
1
- /* Freeday React adapter (thin). The enhancers stay the source of truth; this
1
+ /* Freeday. React adapter (thin). The enhancers stay the source of truth; this
2
2
  * hook re-runs every registered window.Freeday*.initAll() over a subtree after
3
3
  * React commits, so [data-fdy-*] markup rendered by React gets enhanced (and
4
4
  * re-enhanced after updates). Enhancers are idempotent, so the repeated calls
@@ -9,12 +9,12 @@
9
9
  * useFreeday(root); // scopes init to this component's subtree
10
10
  * // <div ref={root}> ...[data-fdy-*] markup... </div>
11
11
  *
12
- * The ref may also sit ON the widget itself <div ref={root} data-fdy-menu> which is what you
12
+ * The ref may also sit ON the widget itself, <div ref={root} data-fdy-menu>, which is what you
13
13
  * write when the component's root element IS the widget. initAll() matches its own root as
14
14
  * well as descendants, so both shapes work. (Before 1.23.0 only the wrapping shape did, and
15
15
  * the other failed silently: querySelectorAll never matches its root.)
16
16
  *
17
- * Events are native bubbling CustomEvents add listeners on the root (they
17
+ * Events are native bubbling CustomEvents, add listeners on the root (they
18
18
  * bubble) and read event.detail. See index.d.ts for the detail types.
19
19
  */
20
20
  import { useEffect, useCallback } from 'react';
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue';
3
+ import {
4
+ NAV_QUERY,
5
+ applyShellState,
6
+ focusPanel,
7
+ restoreFocus,
8
+ trapTab,
9
+ } from '../../core/app-shell.js';
10
+
11
+ // A Vue wrapper over freeday's `.fdy-app` shell (src/components/app-shell.css) that ships the
12
+ // behaviour the CSS cannot: Escape, backdrop, focus into the panel and back, `inert`, and a Tab
13
+ // trap. The DOM work lives in adapters/core/app-shell.js so this component, its React twin and the
14
+ // Blazor bridge cannot drift into three different focus traps.
15
+ //
16
+ // ONE model: `navOpen` means "the nav is visible to the reader". The two state classes are the
17
+ // kit's business, above the nav breakpoint a hidden nav is `--nav-collapsed`, below it a visible
18
+ // nav is `--nav-open`, so an app never reasons about the viewport to answer a question about its
19
+ // own UI.
20
+ //
21
+ // Leave `navOpen` unbound and the component keeps its own: visible on a wide viewport, hidden on a
22
+ // narrow one, which is the right default and one an app cannot express in a single initial value.
23
+ // Bind it (`v-model:navOpen`) when the app wants to drive it, a menu item, a persisted preference.
24
+
25
+ const props = withDefaults(defineProps<{
26
+ navOpen?: boolean;
27
+ title?: string;
28
+ toggleLabel?: string;
29
+ }>(), { navOpen: undefined, title: '', toggleLabel: 'Toggle navigation' });
30
+
31
+ const emit = defineEmits<{
32
+ 'update:navOpen': [boolean];
33
+ }>();
34
+
35
+ const root: Ref<HTMLElement | null> = ref(null);
36
+ const overlay: Ref<boolean> = ref(false);
37
+ const uncontrolled: Ref<boolean> = ref(true);
38
+ const restoreTo: Ref<Element | null> = ref(null);
39
+ let media: MediaQueryList | null = null;
40
+ /* Set while a viewport change is driving the state, so the watcher below reconciles `inert` and the
41
+ classes but leaves FOCUS alone: a resize is not a reader asking to go somewhere. */
42
+ let fromResize: boolean = false;
43
+
44
+ const navVisible: ComputedRef<boolean> = computed((): boolean =>
45
+ props.navOpen === undefined ? uncontrolled.value : props.navOpen,
46
+ );
47
+
48
+ const shellClass: ComputedRef<string> = computed((): string => {
49
+ if (overlay.value) return navVisible.value ? 'fdy-app fdy-app--nav-open' : 'fdy-app';
50
+ return navVisible.value ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed';
51
+ });
52
+
53
+ function setVisible(next: boolean): void {
54
+ uncontrolled.value = next;
55
+ emit('update:navOpen', next);
56
+ }
57
+
58
+ /* Focus is moved AFTER the class change has been rendered, or the panel is still off-canvas and
59
+ the browser refuses to focus what it cannot lay out. */
60
+ watch([navVisible, overlay], ([visible, isOverlay]: [boolean, boolean]): void => {
61
+ const el: HTMLElement | null = root.value;
62
+ if (el === null) return;
63
+ applyShellState(el, { navVisible: visible, overlay: isOverlay });
64
+ if (fromResize) {
65
+ fromResize = false;
66
+ return;
67
+ }
68
+ if (!isOverlay) return;
69
+ if (visible) restoreTo.value = focusPanel(el);
70
+ else restoreFocus(el, restoreTo.value);
71
+ }, { flush: 'post' });
72
+
73
+ function onToggle(): void {
74
+ setVisible(!navVisible.value);
75
+ }
76
+
77
+ function onBackdrop(): void {
78
+ if (overlay.value && navVisible.value) setVisible(false);
79
+ }
80
+
81
+ /* Following a link in an overlay nav means "take me there", the panel must not stay over the page
82
+ it was just asked for. On a wide viewport the nav is a column and clicking it changes nothing. */
83
+ function onSidebarClick(e: MouseEvent): void {
84
+ if (!overlay.value || !navVisible.value) return;
85
+ const target: HTMLElement | null = e.target as HTMLElement | null;
86
+ if (target !== null && target.closest('.fdy-nav__item') !== null) setVisible(false);
87
+ }
88
+
89
+ function onKeydown(e: KeyboardEvent): void {
90
+ const el: HTMLElement | null = root.value;
91
+ if (el === null || !overlay.value || !navVisible.value) return;
92
+ if (e.key === 'Escape') {
93
+ e.preventDefault();
94
+ setVisible(false);
95
+ return;
96
+ }
97
+ trapTab(el, e);
98
+ }
99
+
100
+ /* Both directions matter, and they are not symmetrical.
101
+ Narrowing with the nav visible would drop an overlay panel over a page nobody asked to leave, so
102
+ the nav is hidden. Widening is harmless, a visible nav simply becomes the column again, and the
103
+ watcher clears the `inert` the overlay had put on the content. */
104
+ function onMediaChange(): void {
105
+ const nowOverlay: boolean = media !== null && !media.matches;
106
+ if (nowOverlay === overlay.value) return;
107
+ fromResize = true;
108
+ overlay.value = nowOverlay;
109
+ if (nowOverlay && navVisible.value) setVisible(false);
110
+ }
111
+
112
+ onMounted((): void => {
113
+ media = window.matchMedia(NAV_QUERY);
114
+ overlay.value = !media.matches;
115
+ uncontrolled.value = media.matches;
116
+ media.addEventListener('change', onMediaChange);
117
+ document.addEventListener('keydown', onKeydown);
118
+ const el: HTMLElement | null = root.value;
119
+ if (el !== null) applyShellState(el, { navVisible: navVisible.value, overlay: overlay.value });
120
+ });
121
+
122
+ onBeforeUnmount((): void => {
123
+ if (media !== null) media.removeEventListener('change', onMediaChange);
124
+ document.removeEventListener('keydown', onKeydown);
125
+ });
126
+ </script>
127
+
128
+ <template>
129
+ <div ref="root" :class="shellClass">
130
+ <slot name="skip" />
131
+
132
+ <aside class="fdy-app__sidebar" @click="onSidebarClick">
133
+ <slot name="brand" />
134
+ <slot name="nav" />
135
+ </aside>
136
+
137
+ <div class="fdy-app__content">
138
+ <header class="fdy-app__topbar">
139
+ <button
140
+ class="fdy-app__navtoggle"
141
+ type="button"
142
+ :aria-label="toggleLabel"
143
+ @click="onToggle"
144
+ ><slot name="toggle-icon">&#9776;</slot></button>
145
+
146
+ <h1 class="fdy-app__title">
147
+ <slot name="title">{{ title }}</slot>
148
+ </h1>
149
+
150
+ <slot name="topbar" />
151
+ </header>
152
+
153
+ <main class="fdy-app__main">
154
+ <slot />
155
+ </main>
156
+ </div>
157
+
158
+ <div class="fdy-app__backdrop" @click="onBackdrop" />
159
+ </div>
160
+ </template>
@@ -3,7 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch, type
3
3
  import { usePopover } from '../usePopover';
4
4
 
5
5
  // A controlled Vue 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. A real `v-model` in place of the enhancer's
8
8
  // DOM mutation, so Vue owns the input. Filtering mirrors the enhancer exactly (case-insensitive
9
9
  // substring on the trimmed query); pass server-filtered `options` and it stays a no-op re-filter.
@@ -25,7 +25,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch, type
25
25
  import { usePopover } from '../usePopover';
26
26
 
27
27
  // A controlled Vue port of freeday's cascade select (src/freeday-cascade.js + cascade.css):
28
- // a hierarchical drill-down picker showing one level at a time branches drill in, a back
28
+ // a hierarchical drill-down picker showing one level at a time, branches drill in, a back
29
29
  // control ascends, a leaf selects and the value is the leaf's value (the display is the full
30
30
  // path). The enhancer's data model is a hidden nested <ul>; here it is a typed tree, which is
31
31
  // what a framework app actually has. A real `v-model` in place of the DOM mutation.
@@ -9,7 +9,7 @@ import { computed, nextTick, onBeforeUnmount, ref, useId, type ComputedRef, type
9
9
  // that: a real `v-model:Row|null` over a native <dialog> whose rows come from an async
10
10
  // `fetchPage(query, page)`. It re-implements the enhancer's open/close + search + dense
11
11
  // sticky-header results + single-commit-on-click + keyboard, and adds the pieces a server
12
- // picker needs loading/empty/error states, retry, pagination, an out-of-order guard, and
12
+ // picker needs, loading/empty/error states, retry, pagination, an out-of-order guard, and
13
13
  // an optional in-memory cache. The enhancer is NOT mounted here (it would fight Vue's DOM).
14
14
 
15
15
  interface CflColumn {
@@ -23,7 +23,7 @@ interface CflPage {
23
23
  }
24
24
 
25
25
  const props = defineProps<{
26
- /** Single: `Row | null`. With `multiple`, an array `Row[] | null`, where null and [] both mean
26
+ /** Single: `Row | null`. With `multiple`, an array, `Row[] | null`, where null and [] both mean
27
27
  * nothing picked. The enhancer has had `data-fdy-cfl-multiple` all along; this is the typed
28
28
  * wrappers catching up (#019). */
29
29
  modelValue: Row | Row[] | null;
@@ -31,7 +31,7 @@ const props = defineProps<{
31
31
  columns: ReadonlyArray<CflColumn>;
32
32
  display: (row: Row) => string;
33
33
  rowKey: (row: Row) => string;
34
- /** Advisory only the caller's `fetchPage` owns paging; kept for API documentation. */
34
+ /** Advisory only, the caller's `fetchPage` owns paging; kept for API documentation. */
35
35
  pageSize?: number;
36
36
  /** The dialog's heading. Default 'Choose data', matching the Blazor adapter's `Title`. */
37
37
  title?: string;
@@ -108,7 +108,7 @@ const showClear: ComputedRef<boolean> = computed(
108
108
  );
109
109
  const clearLabelText: ComputedRef<string> = computed((): string => props.clearLabel ?? 'Clear selection');
110
110
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
111
- /* `display()` takes one row, so in multi the field states HOW MANY naming one of six would be a
111
+ /* `display()` takes one row, so in multi the field states HOW MANY, naming one of six would be a
112
112
  lie, and naming all six does not fit a control that is 22rem wide. */
113
113
  const currentRows: ComputedRef<Row[]> = computed((): Row[] =>
114
114
  Array.isArray(props.modelValue) ? props.modelValue : props.modelValue !== null ? [props.modelValue as Row] : [],
@@ -119,7 +119,7 @@ const displayValue: ComputedRef<string> = computed((): string => {
119
119
  return props.modelValue !== null ? props.display(props.modelValue as Row) : '';
120
120
  });
121
121
  // The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
122
- // search input's aria refs on rows existing otherwise they'd dangle during loading/empty/error.
122
+ // search input's aria refs on rows existing, otherwise they'd dangle during loading/empty/error.
123
123
  const hasRows: ComputedRef<boolean> = computed((): boolean => rows.value.length > 0);
124
124
  const controlsId: ComputedRef<string | undefined> = computed((): string | undefined =>
125
125
  hasRows.value ? resultsId : undefined,
@@ -156,7 +156,7 @@ async function loadPage(targetPage: number, append: boolean): Promise<void> {
156
156
  try {
157
157
  const cached: CflPage | undefined = cache.get(key);
158
158
  const res: CflPage = cached ?? (await props.fetchPage(q, targetPage));
159
- if (token !== reqToken) return; // stale a newer request has started
159
+ if (token !== reqToken) return; // stale, a newer request has started
160
160
  if (cached === undefined) cache.set(key, res);
161
161
  const copy: Row[] = res.rows.slice(); // never mutate the caller's array
162
162
  rows.value = append ? rows.value.concat(copy) : copy;
@@ -220,7 +220,7 @@ function togglePick(row: Row): void {
220
220
  else picked.value = picked.value.filter((_: Row, i: number): boolean => i !== at);
221
221
  }
222
222
 
223
- /* A click means "tick this" in multi and "this is my answer" in single the whole difference. */
223
+ /* A click means "tick this" in multi and "this is my answer" in single, the whole difference. */
224
224
  function onRowClick(row: Row): void {
225
225
  if (props.multiple === true) togglePick(row);
226
226
  else commit(row);
@@ -238,7 +238,7 @@ function commit(row: Row | null): void {
238
238
  closeDialog();
239
239
  }
240
240
 
241
- /* Unsetting is not "picking nothing" it must not open or close the dialog, and it must leave focus
241
+ /* Unsetting is not "picking nothing", it must not open or close the dialog, and it must leave focus
242
242
  on a control that still exists, so focus returns to the trigger beside it. */
243
243
  function clearValue(): void {
244
244
  picked.value = [];
@@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick, type Comput
4
4
  // A Vue-native wrapper over freeday's zero-dependency chart renderer (freeday-chart.js).
5
5
  // Unlike the input components, charts are not form controls, so re-implementing them in Vue
6
6
  // would only duplicate the SVG maths. Instead this thin wrapper binds the renderer's data-*
7
- // API reactively and re-invokes window.FreedayChart.update() whenever the data changes giving
7
+ // API reactively and re-invokes window.FreedayChart.update() whenever the data changes, giving
8
8
  // reactive, typed charts (a drop-in replacement for hand-rolled Chart.js wrappers) with no
9
9
  // external dependency. Because the renderer paints with CSS var()/--chart-* tokens, charts also
10
10
  // recolour on data-theme change for free. Requires the freeday enhancers (dist/freeday.js) to be
@@ -16,7 +16,7 @@ interface FdyChartSeries {
16
16
  role?: string;
17
17
  }
18
18
 
19
- // Note: no `ariaLabel` prop pass `aria-label` (and any other native attribute) directly; it
19
+ // Note: no `ariaLabel` prop, pass `aria-label` (and any other native attribute) directly; it
20
20
  // falls through onto the root element. `role="img"` is set below. Give every chart an aria-label.
21
21
  const props = defineProps<{
22
22
  type: 'line' | 'area' | 'bar' | 'sparkline' | 'donut';
@@ -220,7 +220,7 @@ onBeforeUnmount((): void => {
220
220
  <ul :id="listboxId" ref="listboxEl" class="fdy-combo__listbox" role="listbox" popover="manual" :hidden="!open">
221
221
  <!-- @mousedown.prevent keeps focus on the button: the option <li> isn't focusable, so a plain
222
222
  mousedown moves focus out of the combo, fires @focusout, and closes the list before the click
223
- lands mouse-select would silently do nothing. Same pattern as FdyDatepicker/FdyAutocomplete. -->
223
+ lands, mouse-select would silently do nothing. Same pattern as FdyDatepicker/FdyAutocomplete. -->
224
224
  <li
225
225
  v-for="(opt, i) in options"
226
226
  :id="optionId(i)"
@@ -13,7 +13,7 @@ import FdyDatepicker from './FdyDatepicker.vue';
13
13
  // two linked FdyDatepickers where the end can't precede the start (start.max = end, end.min =
14
14
  // start). A real `v-model:DateRangeValue` that composes the single-date picker rather than
15
15
  // re-implementing the calendar. The vanilla enhancer's cross-calendar `.in-range` day shading is
16
- // not reproduced (each picker is independent) the min/max linkage is what keeps the range valid.
16
+ // not reproduced (each picker is independent), the min/max linkage is what keeps the range valid.
17
17
 
18
18
  const props = defineProps<{
19
19
  modelValue: DateRangeValue;
@@ -21,7 +21,7 @@ const props = defineProps<{
21
21
  max?: string;
22
22
  locale?: string;
23
23
  disabled?: boolean;
24
- /** Locked/view mode for both pickers focusable, values shown, but can't be opened or changed. */
24
+ /** Locked/view mode for both pickers, focusable, values shown, but can't be opened or changed. */
25
25
  readonly?: boolean;
26
26
  invalid?: boolean;
27
27
  describedby?: string;
@@ -39,7 +39,7 @@ const props = defineProps<{
39
39
  ariaLabelledby?: string;
40
40
  /** Show a clear (×) button in the trigger when a date is set, so an optional date can be unset. Emits `''` via update:modelValue + change. Off by default. */
41
41
  clearable?: boolean;
42
- /** aria-label for the previous-month nav button. Default 'Previous month' override for non-English UIs (month/weekday names already follow `locale`). */
42
+ /** aria-label for the previous-month nav button. Default 'Previous month', override for non-English UIs (month/weekday names already follow `locale`). */
43
43
  prevMonthLabel?: string;
44
44
  /** aria-label for the next-month nav button. Default 'Next month'. */
45
45
  nextMonthLabel?: string;
@@ -145,7 +145,7 @@ const effectiveFocusDate: ComputedRef<Date> = computed((): Date => {
145
145
  return new Date(view.value.getFullYear(), view.value.getMonth(), Math.min(f.getDate(), daysInMonth));
146
146
  });
147
147
 
148
- /* 'days' | 'months' the calendar drills one level up instead of growing furniture beside the title.
148
+ /* 'days' | 'months', the calendar drills one level up instead of growing furniture beside the title.
149
149
  Before this the only pointer route to another month was one click per month: August 2026 to March
150
150
  2022 is fifty-three of them, and the Shift+PageUp jump had no affordance at all. */
151
151
  const mode: Ref<'days' | 'months' | 'years'> = ref('days');
@@ -212,7 +212,7 @@ interface YearCell {
212
212
  focusable: boolean;
213
213
  }
214
214
 
215
- /** A year is unreachable only when EVERY month in it falls outside min/max the same rule
215
+ /** A year is unreachable only when EVERY month in it falls outside min/max, the same rule
216
216
  * isMonthDisabled applies one level down, and for the same reason. */
217
217
  function isYearDisabled(year: number): boolean {
218
218
  if (minDate.value !== null && startOfDay(new Date(year, 11, 31)).getTime() < startOfDay(minDate.value).getTime()) return true;
@@ -411,7 +411,7 @@ function pick(d: Date): void {
411
411
  closePanel(true);
412
412
  }
413
413
 
414
- // Clear (reset to empty). Emits '' parseISO('') is null, so the placeholder shows again.
414
+ // Clear (reset to empty). Emits '', parseISO('') is null, so the placeholder shows again.
415
415
  function clearValue(): void {
416
416
  emit('update:modelValue', '');
417
417
  emit('change', '');
@@ -496,7 +496,7 @@ function onDocPointerDown(e: MouseEvent): void {
496
496
  }
497
497
 
498
498
  // Close when focus leaves the control entirely (e.g. Shift+Tab off the trigger).
499
- /* A null relatedTarget means focus fell to <body> which is what happens when the element the
499
+ /* A null relatedTarget means focus fell to <body>, which is what happens when the element the
500
500
  * user just pressed is REMOVED by the click it triggered (drilling into the month grid replaces
501
501
  * the grid, and with it the cell that had focus). That is not focus leaving the control, and
502
502
  * closing on it made the panel vanish mid-navigation. A pointer that really lands outside is
@@ -2,15 +2,15 @@
2
2
  import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
3
3
 
4
4
  // A controlled Vue wrapper over freeday's `.fdy-drawer` native <dialog> side panel
5
- // (src/components/drawer.css). Same controlled contract and glue as FdyModal showModal()/close()
5
+ // (src/components/drawer.css). Same controlled contract and glue as FdyModal, showModal()/close()
6
6
  // guarded, @cancel.prevent so Esc routes through app state, backdrop-click via `event.target ===
7
- // dialogEl` applied to a drawer that anchors left (default) or right. Native <dialog> supplies the
7
+ // dialogEl`, applied to a drawer that anchors left (default) or right. Native <dialog> supplies the
8
8
  // focus trap, focus restore, top-layer stacking and inert background; `dismissible` (default true)
9
9
  // gates Esc + backdrop dismissal.
10
10
 
11
11
  // dismissible MUST go through withDefaults: Vue's boolean-cast gives an omitted Boolean prop
12
12
  // `false`, not `undefined`, so a bare `props.dismissible !== false` would make an un-annotated
13
- // drawer non-dismissible (no Esc, no backdrop, no close button) the opposite of the default.
13
+ // drawer non-dismissible (no Esc, no backdrop, no close button), the opposite of the default.
14
14
  const props = withDefaults(defineProps<{
15
15
  open: boolean;
16
16
  title: string;
@@ -8,11 +8,11 @@ import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } fr
8
8
  // (showModal() on an open dialog throws), @cancel.prevent so Esc routes through app state instead of
9
9
  // closing the DOM behind its back, and backdrop-click detection via `event.target === dialogEl`.
10
10
  // Native <dialog> already provides the focus trap, focus restore, top-layer stacking and inert
11
- // background the wrapper only avoids breaking them. `dismissible` (default true) gates Esc + backdrop.
11
+ // background, the wrapper only avoids breaking them. `dismissible` (default true) gates Esc + backdrop.
12
12
 
13
13
  // dismissible MUST go through withDefaults: Vue's boolean-cast gives an omitted Boolean prop
14
14
  // `false`, not `undefined`, so a bare `props.dismissible !== false` would make an un-annotated
15
- // modal non-dismissible (no Esc, no backdrop, no close button) the opposite of the default.
15
+ // modal non-dismissible (no Esc, no backdrop, no close button), the opposite of the default.
16
16
  const props = withDefaults(defineProps<{
17
17
  open: boolean;
18
18
  title: string;
@@ -46,7 +46,7 @@ onMounted((): void => sync(props.open));
46
46
  function onCancel(): void {
47
47
  if (dismissible.value) emit('close');
48
48
  }
49
- // The ::backdrop is not a separate element a click whose target is the dialog box itself (not its
49
+ // The ::backdrop is not a separate element, a click whose target is the dialog box itself (not its
50
50
  // content) is a backdrop click.
51
51
  function onClick(e: MouseEvent): void {
52
52
  if (dismissible.value && e.target === dialogEl.value) emit('close');
@@ -45,14 +45,14 @@ const props = withDefaults(defineProps<{
45
45
  /** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
46
46
  pageSize?: number;
47
47
  /**
48
- * Controlled client-side page index (0-based). Provide it with `pageSize`, without `page` to
48
+ * Controlled client-side page index (0-based). Provide it, with `pageSize`, without `page`, to
49
49
  * own the page while the table keeps doing filter/sort/paginate. This is what lets an EXTERNAL
50
50
  * pager drive the table: a responsive screen that hides `.fdy-datatable` below `md` and renders a
51
51
  * card list from the `process` event can render one pager for both breakpoints and point it here.
52
52
  * Omit for the internal index (unchanged default).
53
53
  */
54
54
  /** Withhold the table's own footer (pager + range) so the screen can render one. Server mode had
55
- * no way to do this: the app owns the page there ANYWAY, and was still handed a second control
55
+ * no way to do this: the app owns the page there ANYWAY, and was still handed a second control,
56
56
  * a responsive list that shows a table at one breakpoint and cards at another ended up with the
57
57
  * kit's pager stacked under its own. Client mode's counterpart is `pageIndex`. Default true. */
58
58
  pager?: boolean;
@@ -61,7 +61,7 @@ const props = withDefaults(defineProps<{
61
61
  * (unchanged default). Every back office has one, and a table that renders two thirds of its own
62
62
  * footer forces the app to rebuild all three to add the last (#008).
63
63
  *
64
- * Server mode reports the pick through `update:page` same event as a page click, with a new
64
+ * Server mode reports the pick through `update:page`, same event as a page click, with a new
65
65
  * `size`. Client mode keeps it internally and also emits `update:pageSize`, so the control works
66
66
  * with nothing wired and can still be persisted.
67
67
  */
@@ -86,11 +86,11 @@ const emit = defineEmits<{
86
86
  * a reset to 0 after sort/filter, or a clamp when filtering shrank the set). */
87
87
  'update:pageIndex': [index: number];
88
88
  /** Client mode with `pageSizes`: the reader picked a new rows-per-page. The table has already
89
- * applied it this is for a caller that wants to persist the choice. */
89
+ * applied it, this is for a caller that wants to persist the choice. */
90
90
  'update:pageSize': [size: number];
91
91
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
92
92
  'row-activate': [row: Row];
93
- /** The processed page of rows (after filter/sort/paginate) plus the total row count fires in
93
+ /** The processed page of rows (after filter/sort/paginate) plus the total row count, fires in
94
94
  * BOTH modes whenever they change. Lets a consumer render the SAME processed set elsewhere
95
95
  * (a `< md` card list, a "selected" summary, export-to-CSV) without re-deriving the pipeline. */
96
96
  'process': [result: { rows: Row[]; total: number }];
@@ -100,7 +100,7 @@ const internalSort: Ref<FdySortState | null> = ref(null);
100
100
  const internalFilters: Ref<FdyFilterMap> = ref({});
101
101
  const internalPageIndex: Ref<number> = ref(0);
102
102
  /* Client-mode rows-per-page. `pageSize` is a plain prop with no event, so a footer control that only
103
- * emitted would do nothing in the app that wired nothing the table applies the pick itself and
103
+ * emitted would do nothing in the app that wired nothing, the table applies the pick itself and
104
104
  * reports it. An explicit change to the prop wins back: the parent said something newer. */
105
105
  const internalPageSize: Ref<number | null> = ref(null);
106
106
  watch((): number | undefined => props.pageSize, (): void => { internalPageSize.value = null; });
@@ -250,7 +250,7 @@ function rowClasses(row: Row): Array<string | undefined> {
250
250
  function onRowClick(row: Row): void {
251
251
  if (props.rowActivatable === true) emit('row-activate', row);
252
252
  }
253
- // Enter/Space activate only when the row itself is focused a control inside a cell keeps its own
253
+ // Enter/Space activate only when the row itself is focused, a control inside a cell keeps its own
254
254
  // event (the `event.target !== event.currentTarget` guard). Click relies on inner controls calling
255
255
  // stopPropagation, matching the pattern consumers hand-roll today.
256
256
  function onRowKeydown(e: KeyboardEvent, row: Row): void {
@@ -7,7 +7,7 @@ import type { FdyColumnFilter, FdyColumnFilterType } from '../../core/table-mode
7
7
  // Internal to FdyTable: one column's header funnel button + its type-aware filter popover
8
8
  // (text / enum / number / date) over freeday's `.fdy-table__filterbtn` + `.fdy-filter*` classes.
9
9
  // Reuses usePopover so the panel escapes the table's `overflow:hidden` via the top layer. Purely
10
- // controlled it renders the current `filter` and emits the next one (or null to clear); the
10
+ // controlled, it renders the current `filter` and emits the next one (or null to clear); the
11
11
  // parent owns where that goes (client state or an `update:filters` emit). Not exported.
12
12
 
13
13
  const props = defineProps<{