@cahyo-dimas/freeday 1.53.0 → 1.54.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 CHANGED
@@ -3,6 +3,48 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.54.0] — 2026-08-25
7
+ ### Added
8
+ - **`<FdyAppShell>` — the shell becomes the eleventh typed component**, in Vue, React and Blazor
9
+ (`NEXT-UP.md` #8, stage 2). One model: `navOpen` means *the nav is visible to the reader*, and the
10
+ kit owns the mapping — above the nav breakpoint a hidden nav is `--nav-collapsed`, below it a
11
+ visible nav is `--nav-open`. An app never reasons about the viewport to answer a question about
12
+ its own UI. Vue `v-model:navOpen` · React `navOpen`/`onNavOpenChange` · Blazor `@bind-NavOpen`.
13
+ - **The prop is optional in all three, and that is the design.** Unbound, the shell starts from the
14
+ viewport: a column on a wide screen, hidden on a narrow one. A caller cannot express that as a
15
+ single initial value before it knows the viewport, so `undefined` (Vue/React) and `null` (Blazor)
16
+ mean "you decide". Bound, the caller wins and is pushed down on mount.
17
+ - **`adapters/core/app-shell.js`** (+ `.d.ts`) — the Tab trap, `inert` bookkeeping and focus restore
18
+ shared by the Vue and React wrappers, following `adapters/core/table-model.js`. `NAV_QUERY` is
19
+ built from `tokens/breakpoints.mjs` rather than repeating `721`, so the JS and `app-shell.css`
20
+ cannot drift. Blazor does not use it: `freeday-blazor.js` says the enhancers stay the source of
21
+ truth, so the Blazor shell renders `data-fdy-app` and BINDS to `freeday-app-shell.js`. Two
22
+ implementations, not four, and a guard holds them to one contract.
23
+ - **`fdy-app-nav`** — the enhancer now emits a bubbling CustomEvent (`detail {visible}`) on every
24
+ real change, and takes `FreedayAppShell.setVisible(root, visible)` / `isVisible(root)` from
25
+ outside. That is what the Blazor binding rides on, and what any vanilla app persisting a collapsed
26
+ preference needed. The event fires for viewport-driven changes too — narrowing hides a nav that
27
+ was a visible column, and a bound value that stayed `true` would describe a panel nobody can see.
28
+ Setting what is already set announces nothing, so a bound host cannot loop on its own echo.
29
+ ### Docs
30
+ - `COMPONENTS.md` §App shell carries the typed-wrapper line and the `navOpen` contract; `USAGE.md`
31
+ now tells you to take the behaviour, not just the frame. The parity claim moves from 10/10 to
32
+ 11/11 in `README.md`, `README.id.md`, `CLAUDE.md`, `docs/agent-onboarding.md` and `NEXT-UP.md`.
33
+ - `NEXT-UP.md` #8 is struck through rather than deleted, and its number kept: the CHANGELOG, the
34
+ design spec and two guard headers already say "NEXT-UP #8", so renumbering the rows under it would
35
+ quietly break references that are already written down.
36
+ ### Added — guards
37
+ - `browser/adapter.mjs` runs one description of the shell contract against BOTH typed stacks —
38
+ viewport default, focus into the panel, the Tab cycle, Escape returning focus, `inert`, and a nav
39
+ link closing the overlay. Verified by sabotage: with `applyShellState` and the focus calls stubbed
40
+ out, React fails three of three and Vue two of three, the third being the sidebar click path the
41
+ sabotage did not touch.
42
+ - `browser/app-shell.mjs` gains the host-binding pair (`fdy-app-nav` ordering, `setVisible` doing the
43
+ whole job and staying silent on a no-op) and the path only Blazor takes: a shell that does not
44
+ exist at `DOMContentLoaded`, hydrated afterwards by handing `initAll` the component's own root —
45
+ which is the element itself, not a descendant of it.
46
+ - `dotnet build` on the Blazor RCL is clean (0 warnings, 0 errors).
47
+
6
48
  ## [1.53.0] — 2026-08-24
7
49
  ### Added
8
50
  - **`freeday-app-shell.js` — the shell finally ships its own behaviour** (`NEXT-UP.md` #8, reported
package/COMPONENTS.md CHANGED
@@ -114,6 +114,8 @@ is a no-op.
114
114
  # Shell & composition
115
115
 
116
116
  ## App shell — `.fdy-app`
117
+ > **Typed wrapper: `<FdyAppShell>`** — Vue (`v-model:navOpen`) · React (`navOpen`/`onNavOpenChange`) · Blazor (`@bind-NavOpen`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
118
+
117
119
  The frame every application lives in. Do not hand-roll one from flexbox: the responsive sidebar,
118
120
  off-canvas drawer and backdrop are built in.
119
121
 
@@ -135,8 +137,21 @@ off-canvas drawer and backdrop are built in.
135
137
  - **Crossing the breakpoint is part of the contract**: open the overlay, then widen the window, and
136
138
  `--nav-open` is dropped and `inert` removed. Leaving them set is how a page becomes permanently
137
139
  unreachable.
140
+ - **`navOpen` is one idea, in both modes.** The typed wrappers expose "is the nav visible?" and the
141
+ kit maps it: above the breakpoint a hidden nav is `--nav-collapsed`, below it a visible nav is
142
+ `--nav-open`. Leave the prop unbound (`undefined` in Vue/React, `null` in Blazor) and the shell
143
+ starts from the viewport — a column on a wide screen, hidden on a narrow one. That default is why
144
+ the prop is optional: a caller cannot express it as a single initial value before it knows the
145
+ viewport.
146
+ - **Binding your own state to the vanilla shell:** it emits a bubbling `fdy-app-nav` CustomEvent
147
+ (`detail {visible}`) on every real change — including the ones a viewport change causes, since a
148
+ bound value that stayed `true` while the nav went off-canvas would be describing a panel nobody
149
+ can see. Drive it with `FreedayAppShell.setVisible(root, visible)` rather than the class names;
150
+ setting what is already set announces nothing, so a bound host cannot loop on its own echo.
151
+ `FreedayAppShell.isVisible(root)` reads it back.
138
152
  - Skip the enhancer and nothing breaks: the classes still mean what they always meant and the
139
- behaviour is yours to write. `FreedayAppShell.init(root)` for markup mounted later.
153
+ behaviour is yours to write. `FreedayAppShell.init(root)` for markup mounted later — hand it the
154
+ shell root itself, which is what the Blazor bridge does after its first render.
140
155
 
141
156
  **The nesting is fixed, not free-form:** `.fdy-app` is a flex **row** of `[__sidebar | __content]`,
142
157
  and `__content` is the column holding `__topbar` + `__main` — it exists to give the sticky topbar a
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.53.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.53.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.54.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.54.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -178,7 +178,7 @@ const root = useRef<HTMLDivElement>(null);
178
178
  useFreeday(root); // event fdy-* bubbling → listen di root
179
179
  ```
180
180
  ```razor
181
- @* Blazor — RCL Freeday.Blazor (net8.0): komponen native typed <FdyX> dengan @bind, 10/10 parity *@
181
+ @* Blazor — RCL Freeday.Blazor (net8.0): komponen native typed <FdyX> dengan @bind, 11/11 parity *@
182
182
  @using Freeday.Blazor
183
183
  <FdyCombo TValue="string" @bind-Value="_status" Options="_statusOptions" AriaLabelledby="lbl" />
184
184
  <FdyTable TRow="Invoice" Columns="_cols" Rows="_rows" RowKey="@(i => i.Code)" PageSize="10" />
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.53.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.53.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.54.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.54.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -177,7 +177,7 @@ const root = useRef<HTMLDivElement>(null);
177
177
  useFreeday(root); // fdy-* events bubble → listen at the root
178
178
  ```
179
179
  ```razor
180
- @* Blazor — Freeday.Blazor RCL (net8.0): native typed <FdyX> with @bind, 10/10 parity *@
180
+ @* Blazor — Freeday.Blazor RCL (net8.0): native typed <FdyX> with @bind, 11/11 parity *@
181
181
  @using Freeday.Blazor
182
182
  <FdyCombo TValue="string" @bind-Value="_status" Options="_statusOptions" AriaLabelledby="lbl" />
183
183
  <FdyTable TRow="Invoice" Columns="_cols" Rows="_rows" RowKey="@(i => i.Code)" PageSize="10" />
package/USAGE.md CHANGED
@@ -162,8 +162,12 @@ buttons with exactly one `aria-pressed="true"` **is** the segmented control; don
162
162
 
163
163
  1. **Shell:** every application starts inside **`.fdy-app`** — a flex row of `__sidebar` (with
164
164
  `__brand` + `.fdy-nav`) and `__content` (which holds `__topbar` + `__main`, plus `__navtoggle`
165
- and `__backdrop`). The nesting is fixed; don't hand-roll a shell from flexbox the toggle and
166
- backdrop plumbing are already there. Skeleton: `COMPONENTS.md` §App shell; a working screen:
165
+ and `__backdrop`). The nesting is fixed; don't hand-roll a shell from flexbox. **Take the
166
+ behaviour too** `<FdyAppShell>` in Vue/React/Blazor, or `data-fdy-app` plus
167
+ `freeday-app-shell.js` in plain markup. Until 1.53.0 the kit shipped the classes and left every
168
+ consumer to write the toggle, Escape, focus trap, focus restore and `inert` themselves, and the
169
+ two copies in this repo's own docs had already drifted apart; a nav panel you cannot escape from
170
+ with the keyboard is the usual result. Skeleton: `COMPONENTS.md` §App shell; a working screen:
167
171
  `docs/reference-screen.html`.
168
172
  2. **Page:** wrap the screen body in **`.fdy-page`** (vertical section rhythm), opening with a
169
173
  **`.fdy-page__header`** (eyebrow + `.fdy-title-page` + `.fdy-page__desc` on the left, the one
@@ -0,0 +1,50 @@
1
+ @namespace Freeday.Blazor
2
+ @inherits FreedayComponentBase<FdyAppShell>
3
+
4
+ @* Blazor wrapper over freeday's .fdy-app shell. Unlike FdyModal and FdyDrawer, which drive a native
5
+ <dialog> through the bridge, this one hands the whole behaviour to freeday-app-shell.js via
6
+ `data-fdy-app` and binds to it: the enhancer announces changes as a bubbling `fdy-app-nav` event
7
+ and accepts them through FreedayAppShell.setVisible. That is the bridge's own doctrine — the
8
+ enhancers stay the source of truth — and it keeps the focus trap from existing a third time. *@
9
+ <div @ref="Root" class="fdy-app" data-fdy-app>
10
+ @SkipContent
11
+
12
+ <aside class="fdy-app__sidebar">
13
+ @BrandContent
14
+ @NavContent
15
+ </aside>
16
+
17
+ <div class="fdy-app__content">
18
+ <header class="fdy-app__topbar">
19
+ <button class="fdy-app__navtoggle" type="button" aria-label="@ToggleLabel">
20
+ @if (ToggleIcon is not null)
21
+ {
22
+ @ToggleIcon
23
+ }
24
+ else
25
+ {
26
+ <text>&#9776;</text>
27
+ }
28
+ </button>
29
+
30
+ <h1 class="fdy-app__title">
31
+ @if (TitleContent is not null)
32
+ {
33
+ @TitleContent
34
+ }
35
+ else
36
+ {
37
+ @Title
38
+ }
39
+ </h1>
40
+
41
+ @TopbarContent
42
+ </header>
43
+
44
+ <main class="fdy-app__main">
45
+ @ChildContent
46
+ </main>
47
+ </div>
48
+
49
+ <div class="fdy-app__backdrop"></div>
50
+ </div>
@@ -0,0 +1,114 @@
1
+ using Microsoft.AspNetCore.Components;
2
+ using Microsoft.JSInterop;
3
+
4
+ namespace Freeday.Blazor;
5
+
6
+ public partial class FdyAppShell
7
+ {
8
+ /// <summary>
9
+ /// Whether the nav is visible to the reader — one idea, in both modes: above the nav breakpoint
10
+ /// a hidden nav is a collapsed column, below it a visible nav is an overlay. The kit owns that
11
+ /// mapping so an app never reasons about the viewport to answer a question about its own UI.
12
+ /// <para>
13
+ /// Leave it <c>null</c> (the default) and the shell keeps its own, starting from the viewport:
14
+ /// a column on a wide screen, hidden on a narrow one. That default is why this is nullable —
15
+ /// a caller cannot express it as a single initial value before it knows the viewport. Bind it
16
+ /// (<c>@bind-NavOpen</c>) when the app wants to drive it from a menu or a saved preference.
17
+ /// </para>
18
+ /// </summary>
19
+ [Parameter] public bool? NavOpen { get; set; }
20
+ [Parameter] public EventCallback<bool?> NavOpenChanged { get; set; }
21
+
22
+ /// <summary>Plain-text page title; ignored when <see cref="TitleContent"/> is supplied.</summary>
23
+ [Parameter] public string Title { get; set; } = string.Empty;
24
+ [Parameter] public RenderFragment? TitleContent { get; set; }
25
+
26
+ /// <summary>The brand block at the top of the sidebar.</summary>
27
+ [Parameter] public RenderFragment? BrandContent { get; set; }
28
+
29
+ /// <summary>The navigation itself — normally a <c>.fdy-nav</c> list.</summary>
30
+ [Parameter] public RenderFragment? NavContent { get; set; }
31
+
32
+ /// <summary>Topbar actions, rendered after the title.</summary>
33
+ [Parameter] public RenderFragment? TopbarContent { get; set; }
34
+
35
+ /// <summary>The skip link, if the app ships one. Rendered as the shell's first child.</summary>
36
+ [Parameter] public RenderFragment? SkipContent { get; set; }
37
+
38
+ /// <summary>The page itself.</summary>
39
+ [Parameter] public RenderFragment? ChildContent { get; set; }
40
+
41
+ /// <summary>Custom glyph for the nav toggle; a hamburger is used when null.</summary>
42
+ [Parameter] public RenderFragment? ToggleIcon { get; set; }
43
+
44
+ [Parameter] public string ToggleLabel { get; set; } = "Toggle navigation";
45
+
46
+ private int _navToken;
47
+ private bool? _lastNavOpen;
48
+
49
+ protected override async ValueTask HydrateAsync()
50
+ {
51
+ await JS.InvokeVoidAsync("FreedayBlazor.initAll", Root);
52
+ _navToken = await JS.InvokeAsync<int>(
53
+ "FreedayBlazor.on", Root, "fdy-app-nav", Self, nameof(OnNavChanged));
54
+
55
+ if (NavOpen is bool wanted)
56
+ {
57
+ // A caller that bound a value is authoritative — push it down.
58
+ await JS.InvokeVoidAsync("FreedayAppShell.setVisible", Root, wanted);
59
+ _lastNavOpen = wanted;
60
+ }
61
+ else
62
+ {
63
+ // Nobody bound one, so adopt what the shell decided from the viewport. Without this the
64
+ // bound value would claim `false` next to a nav that is plainly a visible column.
65
+ bool visible = await JS.InvokeAsync<bool>("FreedayAppShell.isVisible", Root);
66
+ _lastNavOpen = visible;
67
+ await SetNavOpenAsync(visible);
68
+ }
69
+ }
70
+
71
+ protected override async Task OnParametersSetAsync()
72
+ {
73
+ // Only reconcile once the shell is wired, and only when the caller actually changed it —
74
+ // echoing our own event back down would fight the enhancer for the same state.
75
+ if (_navToken != 0 && NavOpen is bool wanted && wanted != _lastNavOpen)
76
+ {
77
+ _lastNavOpen = wanted;
78
+ await JS.InvokeVoidAsync("FreedayAppShell.setVisible", Root, wanted);
79
+ }
80
+ }
81
+
82
+ /// <summary>Invoked by the bridge when the shell's nav visibility changes for any reason —
83
+ /// the toggle, Esc, the backdrop, following a nav link, or the viewport crossing the
84
+ /// breakpoint.</summary>
85
+ [JSInvokable]
86
+ public async Task OnNavChanged(NavDetail detail)
87
+ {
88
+ _lastNavOpen = detail.Visible;
89
+ await SetNavOpenAsync(detail.Visible);
90
+ }
91
+
92
+ private async Task SetNavOpenAsync(bool visible)
93
+ {
94
+ if (NavOpenChanged.HasDelegate)
95
+ {
96
+ await NavOpenChanged.InvokeAsync(visible);
97
+ }
98
+ else
99
+ {
100
+ NavOpen = visible;
101
+ StateHasChanged();
102
+ }
103
+ }
104
+
105
+ protected override async ValueTask CleanupAsync()
106
+ {
107
+ if (_navToken != 0)
108
+ {
109
+ await JS.InvokeVoidAsync("FreedayBlazor.off", _navToken);
110
+ }
111
+ }
112
+
113
+ public sealed record NavDetail(bool Visible);
114
+ }
@@ -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 — framework-agnostic app-shell behaviour (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, 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
+ }
@@ -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
+ }
@@ -41,6 +41,7 @@ export { FdyTable, type FdyTableProps } from './components/FdyTable';
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';
@@ -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>
@@ -40,6 +40,7 @@ export { default as FdyTable } from './components/FdyTable.vue';
40
40
  export { default as FdyTableFooter } from './components/FdyTableFooter.vue';
41
41
  export { default as FdyModal } from './components/FdyModal.vue';
42
42
  export { default as FdyDrawer } from './components/FdyDrawer.vue';
43
+ export { default as FdyAppShell } from './components/FdyAppShell.vue';
43
44
 
44
45
  /** Controlled data-table types (shared, framework-agnostic core). */
45
46
  export type {
@@ -11,3 +11,4 @@ export { default as FdyTable } from './components/FdyTable.vue';
11
11
  export { default as FdyTableFooter } from './components/FdyTableFooter.vue';
12
12
  export { default as FdyModal } from './components/FdyModal.vue';
13
13
  export { default as FdyDrawer } from './components/FdyDrawer.vue';
14
+ export { default as FdyAppShell } from './components/FdyAppShell.vue';
@@ -14,7 +14,12 @@
14
14
  * - `__content` is inert only while the nav is an OPEN OVERLAY, so Tab cannot wander behind the
15
15
  * backdrop.
16
16
  *
17
- * Emits nothing; the classes are the state. FreedayAppShell.init(root) for late-mounted markup.
17
+ * Emits a bubbling `fdy-app-nav` CustomEvent (detail {visible}) whenever the nav's visibility
18
+ * changes, and takes `FreedayAppShell.setVisible(root, visible)` from outside. Those two exist for
19
+ * the same reason the other enhancers have them: a host that keeps its own state — the Blazor
20
+ * wrapper binding @bind-NavOpen, an app persisting the collapsed preference — has to be able to
21
+ * hear the change and to drive it, without owning the behaviour twice.
22
+ * FreedayAppShell.init(root) for late-mounted markup.
18
23
  */
19
24
  (function () {
20
25
  'use strict';
@@ -51,6 +56,7 @@
51
56
 
52
57
  var mqWide = window.matchMedia(WIDE);
53
58
  var restoreTo = null;
59
+ var lastVisible = null;
54
60
 
55
61
  function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
56
62
  function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
@@ -63,6 +69,12 @@
63
69
  toggle.setAttribute('aria-expanded', String(visible));
64
70
  setInert(sidebar, !visible);
65
71
  setInert(content, !mqWide.matches && visible);
72
+ /* Announce only real changes. The first sync() runs at init to describe the state the markup
73
+ arrived in, which is not something a host asked for and must not look like one. */
74
+ if (lastVisible !== null && visible !== lastVisible) {
75
+ app.dispatchEvent(new CustomEvent('fdy-app-nav', { bubbles: true, detail: { visible: visible } }));
76
+ }
77
+ lastVisible = visible;
66
78
  }
67
79
 
68
80
  function open() {
@@ -147,6 +159,23 @@
147
159
  });
148
160
 
149
161
  sync();
162
+
163
+ /* The same handle the other enhancers expose (`_fdyCombo` and friends): a host that binds its
164
+ own state needs to drive this without reaching for the class names the kit reserves. */
165
+ app._fdyAppShell = {
166
+ isVisible: navVisible,
167
+ setVisible: function (visible) {
168
+ if (visible === navVisible()) return;
169
+ if (mqWide.matches) {
170
+ app.classList.toggle('fdy-app--nav-collapsed', !visible);
171
+ sync();
172
+ } else if (visible) {
173
+ open();
174
+ } else {
175
+ close();
176
+ }
177
+ },
178
+ };
150
179
  }
151
180
 
152
181
  function initShells(context) {
@@ -163,5 +192,16 @@
163
192
  initShells();
164
193
  }
165
194
 
166
- window.FreedayAppShell = { init: initShells, initAll: initShells };
195
+ window.FreedayAppShell = {
196
+ init: initShells,
197
+ initAll: initShells,
198
+ /* Both take the shell root. A missing or un-initialised root is a no-op rather than a throw:
199
+ a host may race the enhancer on first render, and a crash there is worse than a late sync. */
200
+ setVisible: function (root, visible) {
201
+ if (root && root._fdyAppShell) root._fdyAppShell.setVisible(visible === true);
202
+ },
203
+ isVisible: function (root) {
204
+ return !!(root && root._fdyAppShell && root._fdyAppShell.isVisible());
205
+ },
206
+ };
167
207
  })();
package/dist/freeday.js CHANGED
@@ -16,7 +16,12 @@
16
16
  * - `__content` is inert only while the nav is an OPEN OVERLAY, so Tab cannot wander behind the
17
17
  * backdrop.
18
18
  *
19
- * Emits nothing; the classes are the state. FreedayAppShell.init(root) for late-mounted markup.
19
+ * Emits a bubbling `fdy-app-nav` CustomEvent (detail {visible}) whenever the nav's visibility
20
+ * changes, and takes `FreedayAppShell.setVisible(root, visible)` from outside. Those two exist for
21
+ * the same reason the other enhancers have them: a host that keeps its own state — the Blazor
22
+ * wrapper binding @bind-NavOpen, an app persisting the collapsed preference — has to be able to
23
+ * hear the change and to drive it, without owning the behaviour twice.
24
+ * FreedayAppShell.init(root) for late-mounted markup.
20
25
  */
21
26
  (function () {
22
27
  'use strict';
@@ -53,6 +58,7 @@
53
58
 
54
59
  var mqWide = window.matchMedia(WIDE);
55
60
  var restoreTo = null;
61
+ var lastVisible = null;
56
62
 
57
63
  function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
58
64
  function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
@@ -65,6 +71,12 @@
65
71
  toggle.setAttribute('aria-expanded', String(visible));
66
72
  setInert(sidebar, !visible);
67
73
  setInert(content, !mqWide.matches && visible);
74
+ /* Announce only real changes. The first sync() runs at init to describe the state the markup
75
+ arrived in, which is not something a host asked for and must not look like one. */
76
+ if (lastVisible !== null && visible !== lastVisible) {
77
+ app.dispatchEvent(new CustomEvent('fdy-app-nav', { bubbles: true, detail: { visible: visible } }));
78
+ }
79
+ lastVisible = visible;
68
80
  }
69
81
 
70
82
  function open() {
@@ -149,6 +161,23 @@
149
161
  });
150
162
 
151
163
  sync();
164
+
165
+ /* The same handle the other enhancers expose (`_fdyCombo` and friends): a host that binds its
166
+ own state needs to drive this without reaching for the class names the kit reserves. */
167
+ app._fdyAppShell = {
168
+ isVisible: navVisible,
169
+ setVisible: function (visible) {
170
+ if (visible === navVisible()) return;
171
+ if (mqWide.matches) {
172
+ app.classList.toggle('fdy-app--nav-collapsed', !visible);
173
+ sync();
174
+ } else if (visible) {
175
+ open();
176
+ } else {
177
+ close();
178
+ }
179
+ },
180
+ };
152
181
  }
153
182
 
154
183
  function initShells(context) {
@@ -165,7 +194,18 @@
165
194
  initShells();
166
195
  }
167
196
 
168
- window.FreedayAppShell = { init: initShells, initAll: initShells };
197
+ window.FreedayAppShell = {
198
+ init: initShells,
199
+ initAll: initShells,
200
+ /* Both take the shell root. A missing or un-initialised root is a no-op rather than a throw:
201
+ a host may race the enhancer on first render, and a crash there is worse than a late sync. */
202
+ setVisible: function (root, visible) {
203
+ if (root && root._fdyAppShell) root._fdyAppShell.setVisible(visible === true);
204
+ },
205
+ isVisible: function (root) {
206
+ return !!(root && root._fdyAppShell && root._fdyAppShell.isVisible());
207
+ },
208
+ };
169
209
  })();
170
210
 
171
211
  /* Freeday — autocomplete enhancer (optional, zero-dependency).
@@ -46,7 +46,7 @@ upload, tree, tabs, menu, rating, slider, form validation, carousel, timepicker)
46
46
  wrapper — use the raw markup and hydrate it:
47
47
 
48
48
  - **Vue / React** — `useFreeday(rootRef)` from the same import path, plus `import '@cahyo-dimas/freeday'`
49
- once at app entry to register the enhancers. (The ten typed components do **not** need this: they
49
+ once at app entry to register the enhancers. (The eleven typed components do **not** need this: they
50
50
  are native Vue/React implementations of the same markup, not wrappers over the enhancer.)
51
51
  - **Blazor** — `FreedayBlazor.initAll` interop. (Here the typed components *are* thin wrappers over
52
52
  the enhancers, so the enhancer script is always required.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.53.0",
3
+ "version": "1.54.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,7 +92,7 @@
92
92
  "scripts": {
93
93
  "build": "node tokens/build.mjs",
94
94
  "test": "node --test",
95
- "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/cfl-multi.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs",
95
+ "test:browser": "node --test --test-concurrency=3 browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/cfl-multi.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs",
96
96
  "prepack": "node tokens/build.mjs",
97
97
  "version": "node tokens/build.mjs && git add dist",
98
98
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit"