@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 +42 -0
- package/COMPONENTS.md +16 -1
- 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 +42 -2
- package/dist/freeday.js +42 -2
- package/docs/agent-onboarding.md +1 -1
- package/package.json +2 -2
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
|
[](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
|
+
}
|
|
@@ -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 {
|
package/adapters/react/index.js
CHANGED
|
@@ -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">☰</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>
|
package/adapters/vue/index.d.ts
CHANGED
|
@@ -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 {
|
package/adapters/vue/index.js
CHANGED
|
@@ -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
|
|
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 = {
|
|
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
|
|
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 = {
|
|
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).
|
package/docs/agent-onboarding.md
CHANGED
|
@@ -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
|
|
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.
|
|
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"
|