@cahyo-dimas/freeday 1.52.0 → 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 +90 -0
- package/COMPONENTS.md +28 -6
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/dist/freeday-app-shell.js +167 -0
- package/dist/freeday.js +168 -0
- package/docs/reference-screen.html +5 -16
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,96 @@
|
|
|
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
|
+
|
|
46
|
+
## [1.52.1] — 2026-08-24
|
|
47
|
+
### Docs
|
|
48
|
+
- **1.51.1's close-watcher caveat named the wrong condition** (#048). It said a `showModal()` with
|
|
49
|
+
no transient activation — "from a timer, or after an `await` that outlived the click" — has its
|
|
50
|
+
close watcher grouped with the dialog below, so one Escape closes both. A consumer audited three
|
|
51
|
+
call sites of exactly that shape and could not reproduce it. They were right, and the reason is
|
|
52
|
+
that the line is **sticky** activation, not transient: grouping needs a page that has received no
|
|
53
|
+
user input *at all*. Six seconds after a single click — transient activation long expired,
|
|
54
|
+
`navigator.userActivation.isActive === false` — two stacked overlays are still independent. So the
|
|
55
|
+
grouping is reachable in a test harness and nowhere else, which is exactly where the kit met it.
|
|
56
|
+
- **What the advice was protecting was real, but it is a different failure.** Open either overlay
|
|
57
|
+
from script and the second one's `cancel` event arrives **non-cancelable**: Escape still closes
|
|
58
|
+
only the topmost, but `preventDefault()` on it is ignored, so an "unsaved changes, stay open" guard
|
|
59
|
+
on that dialog silently stops working. Only when both overlays are opened by a real gesture is that
|
|
60
|
+
veto available. The Modal entry now says this instead.
|
|
61
|
+
- The note also proposed documenting that the typed wrappers' `@cancel.prevent` makes them immune to
|
|
62
|
+
the grouping. **Measured, they are not** — in a page with no activation the `cancel` event is not
|
|
63
|
+
cancelable at all, so preventing it does nothing and both dialogs close regardless. That claim is
|
|
64
|
+
not in the docs, and this is why.
|
|
65
|
+
### Added — guards
|
|
66
|
+
- `browser/overlay-stack.mjs` gains the whole matrix as one assertion — four ways to open two
|
|
67
|
+
overlays × what one Escape leaves open × whether it could be refused — so a row changing is
|
|
68
|
+
reported as news about the browser rather than a regression in the kit. Verified identical on
|
|
69
|
+
Chromium 133 (the suite's engine) and Chrome 151.
|
|
70
|
+
- The fixture records each `cancel` event's `cancelable` flag; nothing prevents it, so the older
|
|
71
|
+
tests measure what they always did.
|
|
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
|
+
|
|
6
96
|
## [1.52.0] — 2026-08-24
|
|
7
97
|
### Fixed
|
|
8
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
|
-
-
|
|
126
|
-
|
|
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
|
|
@@ -1061,10 +1074,19 @@ that. Escape closes the topmost first, so cancelling returns to the still-open d
|
|
|
1061
1074
|
Escape then closes the drawer. Closing them out of order is fine too: closing the drawer while the
|
|
1062
1075
|
modal is up leaves the modal open, painted and dismissible.
|
|
1063
1076
|
|
|
1064
|
-
One caveat, and it is the browser's rather than the kit's: **
|
|
1065
|
-
|
|
1066
|
-
`await` that outlived the click —
|
|
1067
|
-
|
|
1077
|
+
One caveat, and it is the browser's rather than the kit's: **the second overlay's Escape can only
|
|
1078
|
+
be refused if both were opened by a real gesture.** A `showModal()` that runs from script — a timer,
|
|
1079
|
+
or after an `await` that outlived the click — still closes only the topmost, but its `cancel` event
|
|
1080
|
+
arrives **non-cancelable**, so `preventDefault()` on it is ignored and an "unsaved changes, stay
|
|
1081
|
+
open" guard silently stops working. Routing Escape through app state instead, which is what the
|
|
1082
|
+
typed wrappers do, is unaffected: the dialog closes and your state follows it.
|
|
1083
|
+
|
|
1084
|
+
*Correction to 1.51.1, which said such a dialog has its close watcher grouped with the one below so
|
|
1085
|
+
that a single Escape closes both.* That happens only on a page that has received **no user input at
|
|
1086
|
+
all** — a test harness, never an app; six seconds after one click the overlays are still
|
|
1087
|
+
independent. Open overlays with real clicks in a test, or a stacking assertion fails for a reason
|
|
1088
|
+
that is not the kit. Both rules measured on Chromium 133 and 151, and pinned as a matrix in
|
|
1089
|
+
`browser/overlay-stack.mjs`.
|
|
1068
1090
|
|
|
1069
1091
|
## Drawer — `.fdy-drawer`
|
|
1070
1092
|
> **Typed wrapper: `<FdyDrawer>`** — Vue (`:open` + `@close`) · React (`open` + `onClose`) · Blazor (`@bind-Open`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
|
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.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
|
[](https://cahyo-dimas.github.io/freeday-ui-kit/)
|
|
8
|
-
[](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
|
|
421
|
-
//
|
|
422
|
-
//
|
|
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.
|
|
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"
|