@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 +105 -0
- package/COMPONENTS.md +30 -2
- package/README.id.md +2 -2
- package/README.md +2 -2
- package/USAGE.md +6 -2
- package/adapters/blazor/FdyAppShell.razor +50 -0
- package/adapters/blazor/FdyAppShell.razor.cs +114 -0
- package/adapters/core/app-shell.d.ts +30 -0
- package/adapters/core/app-shell.js +134 -0
- package/adapters/react/components/FdyAppShell.tsx +159 -0
- package/adapters/react/index.d.ts +1 -0
- package/adapters/react/index.js +1 -0
- package/adapters/vue/components/FdyAppShell.vue +160 -0
- package/adapters/vue/index.d.ts +1 -0
- package/adapters/vue/index.js +1 -0
- package/dist/freeday-app-shell.js +207 -0
- package/dist/freeday.js +208 -0
- package/docs/agent-onboarding.md +1 -1
- package/docs/reference-screen.html +5 -16
- package/package.json +2 -2
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
|
-
-
|
|
126
|
-
|
|
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
|
[](https://cahyo-dimas.github.io/freeday-ui-kit/)
|
|
8
|
-
[](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,
|
|
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
|
[](https://cahyo-dimas.github.io/freeday-ui-kit/)
|
|
8
|
-
[](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,
|
|
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
|
|
166
|
-
|
|
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>☰</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
|
+
}
|