@cahyo-dimas/freeday 1.52.1 → 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,88 @@
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
+
48
+ ## [1.53.0] — 2026-08-24
49
+ ### Added
50
+ - **`freeday-app-shell.js` — the shell finally ships its own behaviour** (`NEXT-UP.md` #8, reported
51
+ twice). `.fdy-app` shipped `__navtoggle`, `__backdrop` and the `--nav-open` / `--nav-collapsed`
52
+ classes with **zero JS**, and `COMPONENTS.md` told every consumer to wire it in two sentences that
53
+ never mentioned Escape, focus, `inert` or focus restore. Follow that exactly and you get an
54
+ off-canvas overlay that cannot be closed from the keyboard and lets Tab wander behind the
55
+ backdrop. Opt in with `data-fdy-app`; the markup is otherwise untouched, so an existing shell
56
+ gains the behaviour by adding one attribute and deleting its own copy.
57
+ Owned now: the toggle in both modes, Escape, backdrop click, closing when a `.fdy-nav__item` is
58
+ followed, focus into the panel and back to `__navtoggle` on close, `inert` on `__content` while
59
+ the overlay is open, and a Tab trap inside the panel.
60
+ ### Fixed
61
+ - **A hidden nav was still a tabbable nav**, in both modes and from the beginning. Collapsed at
62
+ ≥721px is `width:0;overflow:hidden`; off-canvas at ≤720px is `translateX(-100%)`. Both hide the
63
+ panel from the eye and neither hides it from the keyboard, so a nav nobody could see still
64
+ swallowed every Tab on the way into the page. `__sidebar` is now `inert` whenever the nav is not
65
+ visible — one rule for both modes rather than two patches.
66
+ - **Crossing the breakpoint with the overlay open stranded the page.** Widening the window left
67
+ `--nav-open` set and `__content` `inert`, so the page underneath could never be clicked or read
68
+ again — the same failure that produced `breakpoints.nav` in v1.20.0. The enhancer clears the
69
+ overlay state on the media-query change, and deliberately does *not* move focus while doing it: a
70
+ resize is not a user asking to go somewhere.
71
+ ### Changed
72
+ - **`docs/index.html` and `docs/reference-screen.html` stopped hand-rolling it**, which is the
73
+ proof rather than a tidy-up. The two copies had drifted: `index.html` handled Escape and
74
+ close-on-nav-click, `reference-screen.html` handled neither, and *neither* trapped focus, marked
75
+ the content `inert`, or restored focus. Two versions in one repository disagreeing is the whole
76
+ argument for the kit owning this.
77
+ ### Added — guards
78
+ - `browser/app-shell.mjs` (5 tests), each verified to fail with the enhancer switched off: collapse
79
+ and its `inert` at ≥721px, the overlay and its `inert` at ≤720px, focus entering the panel and
80
+ cycling inside it under **trusted** Tab presses, Escape returning focus to the toggle, backdrop
81
+ and nav-item dismissal, and the breakpoint crossing. One of the five originally passed against a
82
+ shell with no behaviour at all — the nav never opened, so "it is closed" was true and meaningless
83
+ — and now asserts it opened first.
84
+ - `setViewport()` in the browser harness (`Emulation.setDeviceMetricsOverride`). A responsive
85
+ contract cannot be tested at a fixed window size, and the moment a layout *crosses* a breakpoint
86
+ is exactly where its state gets stranded.
87
+
6
88
  ## [1.52.1] — 2026-08-24
7
89
  ### Docs
8
90
  - **1.51.1's close-watcher caveat named the wrong condition** (#048). It said a `showModal()` with
@@ -30,6 +112,29 @@ Semua perubahan penting dicatat di sini. Format longgar mengikuti
30
112
  - The fixture records each `cancel` event's `cancelable` flag; nothing prevents it, so the older
31
113
  tests measure what they always did.
32
114
 
115
+ ### Fixed — the kit's own suite
116
+ - **The browser guards did not run where the releases are made.** CI ran the 59 unit tests and
117
+ published; the 58 browser tests across 18 specs — pixel paint order for stacked overlays, chart
118
+ accessible subtrees, the user-activation matrix above — ran only when someone remembered to run
119
+ them locally. Automating the release through OIDC in 1.52.0 removed the someone. `ci.yml` now runs
120
+ both suites on every push and is *called* by `publish.yml` (`needs: test`), so the step list that
121
+ guards a release cannot drift from the one that runs during development, and a tag with red guards
122
+ never reaches npm.
123
+ - **A job that merely ran the command would have been green while running nothing.** Every browser
124
+ spec skips itself when no Chrome is found, and a skipped suite still exits 0. So the job checks
125
+ the binary before it starts and then parses its own summary, failing on any skip — "the job was
126
+ green" and "the guards ran" were separate facts, and only one of them was enforceable. Both
127
+ demonstrated on real runs: the assertion green at 58/0, and a deliberately broken `CHROME_BIN`
128
+ red.
129
+ - **The harness can now drive any installed Chrome.** `chrome-headless-shell` is headless by
130
+ construction; an ordinary Chrome binary is not and, on a machine with no display, looks for one
131
+ and dies. It now gets `--headless=new` (and `--no-sandbox` only under `CI`), which is what lets
132
+ `CHROME_BIN=<some other Chrome>` compare engines — the manoeuvre that settled #048. CI runs the
133
+ runner's Chrome stable, local runs Chromium 133, so both engines `COMPONENTS.md` names are
134
+ genuinely exercised.
135
+ - Spec: `docs/superpowers/specs/2026-08-24-browser-guards-in-ci-design.md`. No shipped file changed —
136
+ `browser/` and `.github/` are not in the published tarball, so there is no release for this.
137
+
33
138
  ## [1.52.0] — 2026-08-24
34
139
  ### Fixed
35
140
  - **A chart's legend, bar values and donut centre were exposed to assistive tech after all** (#047).
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
 
@@ -122,8 +124,34 @@ off-canvas drawer and backdrop are built in.
122
124
  - Modifiers: `--nav-open` (mobile drawer open, ≤720px) · `--nav-collapsed` (collapse to zero width,
123
125
  ≥721px) · `--static` (embed the shell in a page instead of filling the viewport)
124
126
  - Also: `.fdy-skip` — the skip-to-content link, first child of the shell.
125
- - JS: none. Toggle `--nav-collapsed` (≥721px) or `--nav-open` (≤720px) on the root from
126
- `__navtoggle`'s click; clear `--nav-open` when `__backdrop` is clicked.
127
+ - **Behaviour: `freeday-app-shell.js`.** Opt in with `data-fdy-app` on the root — the markup below
128
+ is unchanged. It owns the toggle in both modes plus everything an overlay needs that hand-rolling
129
+ reliably forgets: Escape, backdrop click, closing when a `.fdy-nav__item` is followed, focus moved
130
+ into the panel on open and returned to `__navtoggle` on close, `inert` on `__content` while the
131
+ overlay is up, and a Tab trap inside the panel. `aria-expanded` on `__navtoggle` answers "is the
132
+ nav showing?" in both modes, so you never read the state classes yourself.
133
+ - **A hidden nav is not a tabbable nav.** `__sidebar` is `inert` whenever the nav is not visible —
134
+ collapsed at ≥721px (`width:0`) or off-canvas at ≤720px (`translateX(-100%)`). Both hide it from
135
+ the eye; neither hides it from the keyboard, so without this a nav nobody can see still swallows
136
+ Tab on the way to the page.
137
+ - **Crossing the breakpoint is part of the contract**: open the overlay, then widen the window, and
138
+ `--nav-open` is dropped and `inert` removed. Leaving them set is how a page becomes permanently
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.
152
+ - Skip the enhancer and nothing breaks: the classes still mean what they always meant and the
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.
127
155
 
128
156
  **The nesting is fixed, not free-form:** `.fdy-app` is a flex **row** of `[__sidebar | __content]`,
129
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.52.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.52.1)
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.52.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.52.1)
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
+ }