@cahyo-dimas/freeday 1.52.1 → 1.53.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,46 @@
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.53.0] — 2026-08-24
7
+ ### Added
8
+ - **`freeday-app-shell.js` — the shell finally ships its own behaviour** (`NEXT-UP.md` #8, reported
9
+ twice). `.fdy-app` shipped `__navtoggle`, `__backdrop` and the `--nav-open` / `--nav-collapsed`
10
+ classes with **zero JS**, and `COMPONENTS.md` told every consumer to wire it in two sentences that
11
+ never mentioned Escape, focus, `inert` or focus restore. Follow that exactly and you get an
12
+ off-canvas overlay that cannot be closed from the keyboard and lets Tab wander behind the
13
+ backdrop. Opt in with `data-fdy-app`; the markup is otherwise untouched, so an existing shell
14
+ gains the behaviour by adding one attribute and deleting its own copy.
15
+ Owned now: the toggle in both modes, Escape, backdrop click, closing when a `.fdy-nav__item` is
16
+ followed, focus into the panel and back to `__navtoggle` on close, `inert` on `__content` while
17
+ the overlay is open, and a Tab trap inside the panel.
18
+ ### Fixed
19
+ - **A hidden nav was still a tabbable nav**, in both modes and from the beginning. Collapsed at
20
+ ≥721px is `width:0;overflow:hidden`; off-canvas at ≤720px is `translateX(-100%)`. Both hide the
21
+ panel from the eye and neither hides it from the keyboard, so a nav nobody could see still
22
+ swallowed every Tab on the way into the page. `__sidebar` is now `inert` whenever the nav is not
23
+ visible — one rule for both modes rather than two patches.
24
+ - **Crossing the breakpoint with the overlay open stranded the page.** Widening the window left
25
+ `--nav-open` set and `__content` `inert`, so the page underneath could never be clicked or read
26
+ again — the same failure that produced `breakpoints.nav` in v1.20.0. The enhancer clears the
27
+ overlay state on the media-query change, and deliberately does *not* move focus while doing it: a
28
+ resize is not a user asking to go somewhere.
29
+ ### Changed
30
+ - **`docs/index.html` and `docs/reference-screen.html` stopped hand-rolling it**, which is the
31
+ proof rather than a tidy-up. The two copies had drifted: `index.html` handled Escape and
32
+ close-on-nav-click, `reference-screen.html` handled neither, and *neither* trapped focus, marked
33
+ the content `inert`, or restored focus. Two versions in one repository disagreeing is the whole
34
+ argument for the kit owning this.
35
+ ### Added — guards
36
+ - `browser/app-shell.mjs` (5 tests), each verified to fail with the enhancer switched off: collapse
37
+ and its `inert` at ≥721px, the overlay and its `inert` at ≤720px, focus entering the panel and
38
+ cycling inside it under **trusted** Tab presses, Escape returning focus to the toggle, backdrop
39
+ and nav-item dismissal, and the breakpoint crossing. One of the five originally passed against a
40
+ shell with no behaviour at all — the nav never opened, so "it is closed" was true and meaningless
41
+ — and now asserts it opened first.
42
+ - `setViewport()` in the browser harness (`Emulation.setDeviceMetricsOverride`). A responsive
43
+ contract cannot be tested at a fixed window size, and the moment a layout *crosses* a breakpoint
44
+ is exactly where its state gets stranded.
45
+
6
46
  ## [1.52.1] — 2026-08-24
7
47
  ### Docs
8
48
  - **1.51.1's close-watcher caveat named the wrong condition** (#048). It said a `showModal()` with
@@ -30,6 +70,29 @@ Semua perubahan penting dicatat di sini. Format longgar mengikuti
30
70
  - The fixture records each `cancel` event's `cancelable` flag; nothing prevents it, so the older
31
71
  tests measure what they always did.
32
72
 
73
+ ### Fixed — the kit's own suite
74
+ - **The browser guards did not run where the releases are made.** CI ran the 59 unit tests and
75
+ published; the 58 browser tests across 18 specs — pixel paint order for stacked overlays, chart
76
+ accessible subtrees, the user-activation matrix above — ran only when someone remembered to run
77
+ them locally. Automating the release through OIDC in 1.52.0 removed the someone. `ci.yml` now runs
78
+ both suites on every push and is *called* by `publish.yml` (`needs: test`), so the step list that
79
+ guards a release cannot drift from the one that runs during development, and a tag with red guards
80
+ never reaches npm.
81
+ - **A job that merely ran the command would have been green while running nothing.** Every browser
82
+ spec skips itself when no Chrome is found, and a skipped suite still exits 0. So the job checks
83
+ the binary before it starts and then parses its own summary, failing on any skip — "the job was
84
+ green" and "the guards ran" were separate facts, and only one of them was enforceable. Both
85
+ demonstrated on real runs: the assertion green at 58/0, and a deliberately broken `CHROME_BIN`
86
+ red.
87
+ - **The harness can now drive any installed Chrome.** `chrome-headless-shell` is headless by
88
+ construction; an ordinary Chrome binary is not and, on a machine with no display, looks for one
89
+ and dies. It now gets `--headless=new` (and `--no-sandbox` only under `CI`), which is what lets
90
+ `CHROME_BIN=<some other Chrome>` compare engines — the manoeuvre that settled #048. CI runs the
91
+ runner's Chrome stable, local runs Chromium 133, so both engines `COMPONENTS.md` names are
92
+ genuinely exercised.
93
+ - Spec: `docs/superpowers/specs/2026-08-24-browser-guards-in-ci-design.md`. No shipped file changed —
94
+ `browser/` and `.github/` are not in the published tarball, so there is no release for this.
95
+
33
96
  ## [1.52.0] — 2026-08-24
34
97
  ### Fixed
35
98
  - **A chart's legend, bar values and donut centre were exposed to assistive tech after all** (#047).
package/COMPONENTS.md CHANGED
@@ -122,8 +122,21 @@ off-canvas drawer and backdrop are built in.
122
122
  - Modifiers: `--nav-open` (mobile drawer open, ≤720px) · `--nav-collapsed` (collapse to zero width,
123
123
  ≥721px) · `--static` (embed the shell in a page instead of filling the viewport)
124
124
  - 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.
125
+ - **Behaviour: `freeday-app-shell.js`.** Opt in with `data-fdy-app` on the root — the markup below
126
+ is unchanged. It owns the toggle in both modes plus everything an overlay needs that hand-rolling
127
+ reliably forgets: Escape, backdrop click, closing when a `.fdy-nav__item` is followed, focus moved
128
+ into the panel on open and returned to `__navtoggle` on close, `inert` on `__content` while the
129
+ overlay is up, and a Tab trap inside the panel. `aria-expanded` on `__navtoggle` answers "is the
130
+ nav showing?" in both modes, so you never read the state classes yourself.
131
+ - **A hidden nav is not a tabbable nav.** `__sidebar` is `inert` whenever the nav is not visible —
132
+ collapsed at ≥721px (`width:0`) or off-canvas at ≤720px (`translateX(-100%)`). Both hide it from
133
+ the eye; neither hides it from the keyboard, so without this a nav nobody can see still swallows
134
+ Tab on the way to the page.
135
+ - **Crossing the breakpoint is part of the contract**: open the overlay, then widen the window, and
136
+ `--nav-open` is dropped and `inert` removed. Leaving them set is how a page becomes permanently
137
+ unreachable.
138
+ - 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.
127
140
 
128
141
  **The nesting is fixed, not free-form:** `.fdy-app` is a flex **row** of `[__sidebar | __content]`,
129
142
  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.53.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.53.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`.
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.53.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.53.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`.
@@ -0,0 +1,167 @@
1
+ /* Freeday — app shell enhancer (optional, zero-dependency).
2
+ * Opt in with <div class="fdy-app" data-fdy-app>; the markup is otherwise unchanged.
3
+ *
4
+ * The shell has always shipped the state classes and no behaviour, which left every consumer to
5
+ * assemble the toggle, Escape, focus trap, focus restore and `inert` themselves — and the two
6
+ * hand-rolled copies in this repo's own docs already disagreed about which of those exist. Below
7
+ * 721px the sidebar is an OVERLAY (app-shell.css: position:fixed + translateX(-100%)), so it needs
8
+ * the same treatment a modal drawer gets; at or above 721px it is a column that merely collapses.
9
+ *
10
+ * One rule covers both a hidden nav and a hidden page:
11
+ * - `__sidebar` is inert whenever the nav is NOT visible. Off-canvas and collapsed panels stay in
12
+ * the tab order otherwise — translateX(-100%) and width:0 hide a thing from the eye, not from
13
+ * the keyboard, which is how a nav nobody can see still swallows Tab.
14
+ * - `__content` is inert only while the nav is an OPEN OVERLAY, so Tab cannot wander behind the
15
+ * backdrop.
16
+ *
17
+ * Emits nothing; the classes are the state. FreedayAppShell.init(root) for late-mounted markup.
18
+ */
19
+ (function () {
20
+ 'use strict';
21
+
22
+ /* Must match app-shell.css, which switches at (min-width:721px) / (max-width:720px). */
23
+ var WIDE = '(min-width: 721px)';
24
+
25
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
26
+ + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
27
+
28
+ /* getClientRects(), not offsetParent: an overlay sidebar is position:fixed, and a fixed element
29
+ reports no offsetParent at all — filtering on it would call every nav item invisible. */
30
+ function focusables(root) {
31
+ return Array.prototype.filter.call(root.querySelectorAll(FOCUSABLE), function (el) {
32
+ return el.getClientRects().length > 0;
33
+ });
34
+ }
35
+
36
+ function setInert(el, on) {
37
+ if (!el) return;
38
+ if (on) el.setAttribute('inert', '');
39
+ else el.removeAttribute('inert');
40
+ }
41
+
42
+ function initShell(app) {
43
+ if (app.dataset.fdyAppShellReady === '1') return;
44
+ app.dataset.fdyAppShellReady = '1';
45
+
46
+ var toggle = app.querySelector('.fdy-app__navtoggle');
47
+ var sidebar = app.querySelector('.fdy-app__sidebar');
48
+ if (!toggle || !sidebar) return;
49
+ var content = app.querySelector('.fdy-app__content');
50
+ var backdrop = app.querySelector('.fdy-app__backdrop');
51
+
52
+ var mqWide = window.matchMedia(WIDE);
53
+ var restoreTo = null;
54
+
55
+ function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
56
+ function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
57
+ function navVisible() { return mqWide.matches ? !isCollapsed() : isOverlayOpen(); }
58
+
59
+ /* aria-expanded answers "is the nav showing?" in BOTH modes — the two state classes are the
60
+ kit's business, not the reader's. */
61
+ function sync() {
62
+ var visible = navVisible();
63
+ toggle.setAttribute('aria-expanded', String(visible));
64
+ setInert(sidebar, !visible);
65
+ setInert(content, !mqWide.matches && visible);
66
+ }
67
+
68
+ function open() {
69
+ if (isOverlayOpen()) return;
70
+ restoreTo = document.activeElement;
71
+ app.classList.add('fdy-app--nav-open');
72
+ sync();
73
+ var first = focusables(sidebar)[0];
74
+ if (first) {
75
+ first.focus();
76
+ } else {
77
+ sidebar.setAttribute('tabindex', '-1');
78
+ sidebar.focus();
79
+ }
80
+ }
81
+
82
+ /* restoreFocus is false when the viewport closed it rather than the user — a resize must not
83
+ yank focus out from under whatever the reader was doing. */
84
+ function close(restoreFocus) {
85
+ if (!isOverlayOpen()) return;
86
+ app.classList.remove('fdy-app--nav-open');
87
+ sync();
88
+ if (restoreFocus === false) return;
89
+ /* Back to the control that opened it. document.body is where a keyboard user gets stranded,
90
+ so anything that is gone, detached or the body itself falls back to the toggle. */
91
+ var target = (restoreTo && restoreTo !== document.body && document.contains(restoreTo))
92
+ ? restoreTo : toggle;
93
+ if (typeof target.focus === 'function') target.focus();
94
+ }
95
+
96
+ toggle.addEventListener('click', function () {
97
+ if (mqWide.matches) {
98
+ app.classList.toggle('fdy-app--nav-collapsed');
99
+ sync();
100
+ } else if (isOverlayOpen()) {
101
+ close();
102
+ } else {
103
+ open();
104
+ }
105
+ });
106
+
107
+ if (backdrop) backdrop.addEventListener('click', function () { close(); });
108
+
109
+ /* Following a link inside an overlay nav means "take me there" — leaving the panel open over
110
+ the page you just asked for is the one thing every hand-rolled copy in this repo disagreed on. */
111
+ sidebar.addEventListener('click', function (e) {
112
+ if (!isOverlayOpen()) return;
113
+ if (e.target.closest && e.target.closest('.fdy-nav__item')) close();
114
+ });
115
+
116
+ document.addEventListener('keydown', function (e) {
117
+ if (!isOverlayOpen()) return;
118
+ if (e.key === 'Escape') {
119
+ e.preventDefault();
120
+ close();
121
+ return;
122
+ }
123
+ if (e.key !== 'Tab') return;
124
+ /* The trap. `inert` on the content stops Tab reaching the page, but without this the focus
125
+ would walk out of the document into the browser's own chrome instead of cycling. */
126
+ var items = focusables(sidebar);
127
+ if (items.length === 0) {
128
+ e.preventDefault();
129
+ return;
130
+ }
131
+ var first = items[0], last = items[items.length - 1];
132
+ if (e.shiftKey && document.activeElement === first) {
133
+ e.preventDefault();
134
+ last.focus();
135
+ } else if (!e.shiftKey && document.activeElement === last) {
136
+ e.preventDefault();
137
+ first.focus();
138
+ }
139
+ });
140
+
141
+ /* Crossing the breakpoint with the overlay open would otherwise leave --nav-open set and the
142
+ content inert forever: the panel becomes a static column again, and the page it is covering
143
+ can no longer be clicked or read. */
144
+ mqWide.addEventListener('change', function () {
145
+ if (mqWide.matches && isOverlayOpen()) close(false);
146
+ else sync();
147
+ });
148
+
149
+ sync();
150
+ }
151
+
152
+ function initShells(context) {
153
+ var ctx = context || document;
154
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON
155
+ the widget. */
156
+ if (ctx.matches && ctx.matches('[data-fdy-app]')) initShell(ctx);
157
+ Array.prototype.forEach.call(ctx.querySelectorAll('[data-fdy-app]'), initShell);
158
+ }
159
+
160
+ if (document.readyState === 'loading') {
161
+ document.addEventListener('DOMContentLoaded', function () { initShells(); });
162
+ } else {
163
+ initShells();
164
+ }
165
+
166
+ window.FreedayAppShell = { init: initShells, initAll: initShells };
167
+ })();
package/dist/freeday.js CHANGED
@@ -1,5 +1,173 @@
1
1
  /* Freeday enhancers — GENERATED by tokens/build.mjs. Do not edit by hand.
2
2
  * Concatenation of every src/*.js enhancer (each a self-contained IIFE). */
3
+ /* Freeday — app shell enhancer (optional, zero-dependency).
4
+ * Opt in with <div class="fdy-app" data-fdy-app>; the markup is otherwise unchanged.
5
+ *
6
+ * The shell has always shipped the state classes and no behaviour, which left every consumer to
7
+ * assemble the toggle, Escape, focus trap, focus restore and `inert` themselves — and the two
8
+ * hand-rolled copies in this repo's own docs already disagreed about which of those exist. Below
9
+ * 721px the sidebar is an OVERLAY (app-shell.css: position:fixed + translateX(-100%)), so it needs
10
+ * the same treatment a modal drawer gets; at or above 721px it is a column that merely collapses.
11
+ *
12
+ * One rule covers both a hidden nav and a hidden page:
13
+ * - `__sidebar` is inert whenever the nav is NOT visible. Off-canvas and collapsed panels stay in
14
+ * the tab order otherwise — translateX(-100%) and width:0 hide a thing from the eye, not from
15
+ * the keyboard, which is how a nav nobody can see still swallows Tab.
16
+ * - `__content` is inert only while the nav is an OPEN OVERLAY, so Tab cannot wander behind the
17
+ * backdrop.
18
+ *
19
+ * Emits nothing; the classes are the state. FreedayAppShell.init(root) for late-mounted markup.
20
+ */
21
+ (function () {
22
+ 'use strict';
23
+
24
+ /* Must match app-shell.css, which switches at (min-width:721px) / (max-width:720px). */
25
+ var WIDE = '(min-width: 721px)';
26
+
27
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
28
+ + ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
29
+
30
+ /* getClientRects(), not offsetParent: an overlay sidebar is position:fixed, and a fixed element
31
+ reports no offsetParent at all — filtering on it would call every nav item invisible. */
32
+ function focusables(root) {
33
+ return Array.prototype.filter.call(root.querySelectorAll(FOCUSABLE), function (el) {
34
+ return el.getClientRects().length > 0;
35
+ });
36
+ }
37
+
38
+ function setInert(el, on) {
39
+ if (!el) return;
40
+ if (on) el.setAttribute('inert', '');
41
+ else el.removeAttribute('inert');
42
+ }
43
+
44
+ function initShell(app) {
45
+ if (app.dataset.fdyAppShellReady === '1') return;
46
+ app.dataset.fdyAppShellReady = '1';
47
+
48
+ var toggle = app.querySelector('.fdy-app__navtoggle');
49
+ var sidebar = app.querySelector('.fdy-app__sidebar');
50
+ if (!toggle || !sidebar) return;
51
+ var content = app.querySelector('.fdy-app__content');
52
+ var backdrop = app.querySelector('.fdy-app__backdrop');
53
+
54
+ var mqWide = window.matchMedia(WIDE);
55
+ var restoreTo = null;
56
+
57
+ function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
58
+ function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
59
+ function navVisible() { return mqWide.matches ? !isCollapsed() : isOverlayOpen(); }
60
+
61
+ /* aria-expanded answers "is the nav showing?" in BOTH modes — the two state classes are the
62
+ kit's business, not the reader's. */
63
+ function sync() {
64
+ var visible = navVisible();
65
+ toggle.setAttribute('aria-expanded', String(visible));
66
+ setInert(sidebar, !visible);
67
+ setInert(content, !mqWide.matches && visible);
68
+ }
69
+
70
+ function open() {
71
+ if (isOverlayOpen()) return;
72
+ restoreTo = document.activeElement;
73
+ app.classList.add('fdy-app--nav-open');
74
+ sync();
75
+ var first = focusables(sidebar)[0];
76
+ if (first) {
77
+ first.focus();
78
+ } else {
79
+ sidebar.setAttribute('tabindex', '-1');
80
+ sidebar.focus();
81
+ }
82
+ }
83
+
84
+ /* restoreFocus is false when the viewport closed it rather than the user — a resize must not
85
+ yank focus out from under whatever the reader was doing. */
86
+ function close(restoreFocus) {
87
+ if (!isOverlayOpen()) return;
88
+ app.classList.remove('fdy-app--nav-open');
89
+ sync();
90
+ if (restoreFocus === false) return;
91
+ /* Back to the control that opened it. document.body is where a keyboard user gets stranded,
92
+ so anything that is gone, detached or the body itself falls back to the toggle. */
93
+ var target = (restoreTo && restoreTo !== document.body && document.contains(restoreTo))
94
+ ? restoreTo : toggle;
95
+ if (typeof target.focus === 'function') target.focus();
96
+ }
97
+
98
+ toggle.addEventListener('click', function () {
99
+ if (mqWide.matches) {
100
+ app.classList.toggle('fdy-app--nav-collapsed');
101
+ sync();
102
+ } else if (isOverlayOpen()) {
103
+ close();
104
+ } else {
105
+ open();
106
+ }
107
+ });
108
+
109
+ if (backdrop) backdrop.addEventListener('click', function () { close(); });
110
+
111
+ /* Following a link inside an overlay nav means "take me there" — leaving the panel open over
112
+ the page you just asked for is the one thing every hand-rolled copy in this repo disagreed on. */
113
+ sidebar.addEventListener('click', function (e) {
114
+ if (!isOverlayOpen()) return;
115
+ if (e.target.closest && e.target.closest('.fdy-nav__item')) close();
116
+ });
117
+
118
+ document.addEventListener('keydown', function (e) {
119
+ if (!isOverlayOpen()) return;
120
+ if (e.key === 'Escape') {
121
+ e.preventDefault();
122
+ close();
123
+ return;
124
+ }
125
+ if (e.key !== 'Tab') return;
126
+ /* The trap. `inert` on the content stops Tab reaching the page, but without this the focus
127
+ would walk out of the document into the browser's own chrome instead of cycling. */
128
+ var items = focusables(sidebar);
129
+ if (items.length === 0) {
130
+ e.preventDefault();
131
+ return;
132
+ }
133
+ var first = items[0], last = items[items.length - 1];
134
+ if (e.shiftKey && document.activeElement === first) {
135
+ e.preventDefault();
136
+ last.focus();
137
+ } else if (!e.shiftKey && document.activeElement === last) {
138
+ e.preventDefault();
139
+ first.focus();
140
+ }
141
+ });
142
+
143
+ /* Crossing the breakpoint with the overlay open would otherwise leave --nav-open set and the
144
+ content inert forever: the panel becomes a static column again, and the page it is covering
145
+ can no longer be clicked or read. */
146
+ mqWide.addEventListener('change', function () {
147
+ if (mqWide.matches && isOverlayOpen()) close(false);
148
+ else sync();
149
+ });
150
+
151
+ sync();
152
+ }
153
+
154
+ function initShells(context) {
155
+ var ctx = context || document;
156
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON
157
+ the widget. */
158
+ if (ctx.matches && ctx.matches('[data-fdy-app]')) initShell(ctx);
159
+ Array.prototype.forEach.call(ctx.querySelectorAll('[data-fdy-app]'), initShell);
160
+ }
161
+
162
+ if (document.readyState === 'loading') {
163
+ document.addEventListener('DOMContentLoaded', function () { initShells(); });
164
+ } else {
165
+ initShells();
166
+ }
167
+
168
+ window.FreedayAppShell = { init: initShells, initAll: initShells };
169
+ })();
170
+
3
171
  /* Freeday — autocomplete enhancer (optional, zero-dependency).
4
172
  * Editable combobox (WAI-ARIA APG): a text input filters a role="listbox" of role="option"
5
173
  * items as you type; select fills the input. Auto-inits [data-fdy-autocomplete]. Options are
@@ -33,7 +33,7 @@
33
33
  Doctrine behind the choices: USAGE.md.
34
34
  ══════════════════════════════════════════════════════════════════════════════════════════ -->
35
35
 
36
- <div class="fdy-app" id="app">
36
+ <div class="fdy-app" id="app" data-fdy-app>
37
37
  <a class="fdy-skip" href="#main">Skip to main content</a>
38
38
 
39
39
  <!-- Sidebar comes FIRST: .fdy-app is a flex row of [sidebar | content]. The brand lives in the
@@ -407,6 +407,7 @@
407
407
  </dialog>
408
408
 
409
409
  <!-- Enhancers: only the ones this screen actually uses. Zero dependencies, load order free. -->
410
+ <script src="../dist/freeday-app-shell.js"></script>
410
411
  <script src="../dist/freeday-popover.js"></script>
411
412
  <script src="../dist/freeday-select.js"></script>
412
413
  <script src="../dist/freeday-datepicker.js"></script>
@@ -417,21 +418,9 @@
417
418
  <script src="../dist/freeday-toast.js"></script>
418
419
 
419
420
  <script>
420
- // The shell's only wiring: the nav toggle. Two state classes, no library.
421
- // Above 720px the sidebar collapses to zero width (--nav-collapsed); at or below it slides in as
422
- // an off-canvas drawer (--nav-open). 720px is where app-shell.css switches keep them in step.
423
- const app = document.getElementById('app');
424
- const toggle = app.querySelector('.fdy-app__navtoggle');
425
- const wide = () => window.matchMedia('(min-width: 721px)').matches;
426
- toggle.addEventListener('click', () => {
427
- const cls = wide() ? 'fdy-app--nav-collapsed' : 'fdy-app--nav-open';
428
- const open = app.classList.toggle(cls);
429
- toggle.setAttribute('aria-expanded', String(wide() ? !open : open));
430
- });
431
- app.querySelector('.fdy-app__backdrop').addEventListener('click', () => {
432
- app.classList.remove('fdy-app--nav-open');
433
- toggle.setAttribute('aria-expanded', 'false');
434
- });
421
+ // The nav toggle is the kit's now: `data-fdy-app` on the shell is the whole wiring. What used to
422
+ // live here handled the two state classes and nothing else no Escape, no focus trap, no inert
423
+ // background, no focus restore which is exactly why the behaviour moved into the kit.
435
424
 
436
425
  // Theme + density live on <html>, set once — never per component.
437
426
  const root = document.documentElement;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.52.1",
3
+ "version": "1.53.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",
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",
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"