@keenmate/pure-css 1.0.0-rc03 → 1.0.0-rc05

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Pure CSS — Navbar Dropdown (touch support for hover dropdowns)
3
+ *
4
+ * The navbar dropdowns (`.pc-navmenu__item--has-dropdown`) open on
5
+ * `:hover` — which never fires on touch. When the dropdown parent is ALSO a
6
+ * real link (e.g. Components → /components/overview), a tap just follows the
7
+ * href and the submenu can never be seen. This module fixes that on touch
8
+ * devices with the "first tap opens, second tap navigates" model:
9
+ *
10
+ * - 1st tap on the parent's own link → open its submenu (navigation
11
+ * suppressed), closing any sibling.
12
+ * - 2nd tap on the SAME (now-open) → follow the link normally.
13
+ * parent
14
+ * - A parent whose href is "#"/empty → pure toggle (never navigates); the
15
+ * (a bare toggler, e.g. "More ›") 2nd tap closes it.
16
+ * - Tap outside / Escape → close all open.
17
+ *
18
+ * It only engages on `(hover: none)` devices — desktop / hover-capable input
19
+ * keeps the plain CSS hover-reveal and normal link navigation, untouched.
20
+ * Nested parents work too (the tapped item and its ancestors stay open).
21
+ *
22
+ * Deliberately skipped:
23
+ * - `.pc-navmenu__more-menu` — the collapse "More" panel flattens submenus to
24
+ * always-visible inline links (fit.js + _navbar-elements.scss),
25
+ * so there's nothing to toggle there.
26
+ * - `.pc-navmenu__item--more` — the collapse "More" trigger owns its own
27
+ * click handling.
28
+ *
29
+ * Pairs with the CSS in `_navbar-elements.scss`, where the hover-reveal is
30
+ * gated behind `@media (hover: hover)` and an `.is-open` class reveal (toggled
31
+ * here) drives the open state on touch.
32
+ *
33
+ * No configuration; auto-inits. Public API (pureCss.components.navDropdown):
34
+ * closeAll() — close every open navbar dropdown
35
+ */
36
+ (function () {
37
+ 'use strict';
38
+
39
+ var OPEN = 'is-open';
40
+ var PARENT = '.pc-navmenu__item--has-dropdown';
41
+
42
+ // Only take over on devices without hover — evaluated per-interaction so a
43
+ // hybrid device (touch + mouse) that reports hover keeps native navigation.
44
+ function noHover() {
45
+ return !!(window.matchMedia && window.matchMedia('(hover: none)').matches);
46
+ }
47
+
48
+ // The parent li's OWN dropdown (direct child), not a descendant submenu.
49
+ function directSubmenu(li) {
50
+ for (var c = li.firstElementChild; c; c = c.nextElementSibling) {
51
+ if (c.classList && c.classList.contains('pc-navmenu__dropdown')) return c;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // The parent li's OWN toggle link (direct child <a>).
57
+ function directLink(li) {
58
+ for (var c = li.firstElementChild; c; c = c.nextElementSibling) {
59
+ if (c.tagName && c.tagName.toLowerCase() === 'a') return c;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function setExpanded(li, on) {
65
+ var a = directLink(li);
66
+ if (a) a.setAttribute('aria-expanded', on ? 'true' : 'false');
67
+ }
68
+
69
+ function openItem(li) {
70
+ // Close everything that isn't `li` or one of its ancestors, so opening a
71
+ // nested submenu keeps its parents open but folds away sibling branches.
72
+ var open = document.querySelectorAll(PARENT + '.' + OPEN);
73
+ for (var i = 0; i < open.length; i++) {
74
+ if (open[i] === li || open[i].contains(li)) continue;
75
+ open[i].classList.remove(OPEN);
76
+ setExpanded(open[i], false);
77
+ }
78
+ li.classList.add(OPEN);
79
+ setExpanded(li, true);
80
+ }
81
+
82
+ function closeItem(li) {
83
+ li.classList.remove(OPEN);
84
+ setExpanded(li, false);
85
+ }
86
+
87
+ function closeAll() {
88
+ var open = document.querySelectorAll(PARENT + '.' + OPEN);
89
+ for (var i = 0; i < open.length; i++) closeItem(open[i]);
90
+ }
91
+
92
+ function onClick(e) {
93
+ var link = e.target.closest ? e.target.closest('a') : null;
94
+
95
+ // A tap that isn't on a dropdown parent's own link: if it's fully
96
+ // outside any dropdown, close everything; otherwise (a submenu child
97
+ // link, or non-link chrome inside a dropdown) leave it to navigate.
98
+ if (!link || link.parentNode == null) {
99
+ if (!e.target.closest || !e.target.closest(PARENT)) closeAll();
100
+ return;
101
+ }
102
+
103
+ var li = link.parentNode;
104
+ var isToggle = li.classList &&
105
+ li.classList.contains('pc-navmenu__item--has-dropdown') &&
106
+ directLink(li) === link && // the parent's OWN link, not a child
107
+ !link.closest('.pc-navmenu__more-menu') && // not the flattened panel
108
+ !li.classList.contains('pc-navmenu__item--more'); // not the More trigger
109
+
110
+ if (!isToggle) {
111
+ // Clicked a normal link (incl. submenu children): close open menus
112
+ // unless the click is inside one that should stay while navigating.
113
+ if (!link.closest(PARENT)) closeAll();
114
+ return;
115
+ }
116
+
117
+ if (!directSubmenu(li)) return; // parent flagged has-dropdown but empty
118
+ if (!noHover()) return; // hover device → native nav + CSS hover
119
+
120
+ var href = link.getAttribute('href') || '';
121
+ var togglerOnly = href === '' || href === '#' || href.charAt(href.length - 1) === '#';
122
+
123
+ if (!li.classList.contains(OPEN)) {
124
+ e.preventDefault(); // 1st tap → open, don't navigate
125
+ openItem(li);
126
+ } else if (togglerOnly) {
127
+ e.preventDefault(); // bare toggler → 2nd tap closes
128
+ closeItem(li);
129
+ }
130
+ // else: open + real href → fall through, browser follows the link.
131
+ }
132
+
133
+ function onKey(e) {
134
+ if (e.key === 'Escape' || e.keyCode === 27) closeAll();
135
+ }
136
+
137
+ // A11y: advertise the popup + initial collapsed state on each parent's own
138
+ // link (skipping the flattened panel and the collapse trigger).
139
+ function init(scope) {
140
+ var parents = (scope || document).querySelectorAll(PARENT);
141
+ for (var i = 0; i < parents.length; i++) {
142
+ var li = parents[i];
143
+ if (li.closest('.pc-navmenu__more-menu')) continue;
144
+ if (li.classList.contains('pc-navmenu__item--more')) continue;
145
+ var a = directLink(li);
146
+ if (a && directSubmenu(li)) {
147
+ a.setAttribute('aria-haspopup', 'true');
148
+ if (!a.hasAttribute('aria-expanded')) a.setAttribute('aria-expanded', 'false');
149
+ }
150
+ }
151
+ }
152
+
153
+ document.addEventListener('click', onClick);
154
+ document.addEventListener('keydown', onKey);
155
+
156
+ var pa = (window.pureCss = window.pureCss || {});
157
+ (pa.components = pa.components || {}).navDropdown = { closeAll: closeAll, init: init };
158
+
159
+ if (document.readyState === 'loading') {
160
+ document.addEventListener('DOMContentLoaded', function () { init(); });
161
+ } else {
162
+ init();
163
+ }
164
+ })();
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Pure CSS — shared foundation runtime namespace (window.pureCss)
3
+ *
4
+ * ONE global for the foundation layer. The app-shell engines (fit,
5
+ * navbar-dropdown, sidebar-resize, container-breakpoint) and every downstream
6
+ * framework hang their public handle, cross-component signals, and diagnostics
7
+ * off this object instead of scattering `window.PaThing` globals.
8
+ *
9
+ * This is the FOUNDATION half of what used to be pure-admin.js. It installs the
10
+ * parts that must exist exactly once — the event bus, the debug registry, the
11
+ * viewport source, the OS colour-scheme source, capability-first device
12
+ * classification, the overlay primitives, the open-menu registry, the
13
+ * components registry + initAll, and the FOUNDATION config defaults
14
+ * (mobileBreakpoint, typingDebounceDelay, tabletMinShortSide, fit,
15
+ * containerBreakpoint — the ones the moved engines read).
16
+ *
17
+ * pure-admin.js (the admin facade) ADOPTS these buses by reference when it
18
+ * loads on top, and layers the admin-only config defaults (transition, toast,
19
+ * severity) onto the shared config. When pure-admin is absent (pure-css loaded
20
+ * standalone) this runtime stands on its own.
21
+ *
22
+ * Load-order-safe by construction: this file, and every engine, opens with
23
+ *
24
+ * var pc = (window.pureCss = window.pureCss || {});
25
+ *
26
+ * so the namespace exists no matter which script parses first.
27
+ *
28
+ * Surface:
29
+ * pureCss.events on(topic,fn)->off / once / off / emit(topic,payload)
30
+ * pureCss.viewport { width, height, orientation } live snapshot; emits
31
+ * 'viewport:resize' (rAF-throttled) + 'viewport:orientation'
32
+ * pureCss.colorScheme { mode: 'light'|'dark' } live OS preference; emits
33
+ * 'colorscheme:change'
34
+ * pureCss.device { class: 'mobile'|'tablet'|'desktop', isTouchPrimary }
35
+ * pureCss.overlay lockBodyScroll() -> release; observeKeyboardInset(el)
36
+ * pureCss.config shared UI-behavior baseline (foundation half)
37
+ * pureCss.components per-component handles ({init, initAll, …}); initAll(scope)
38
+ * pureCss.menus open-menu coordination registry (register/closeOthers)
39
+ * pureCss.debug enable/disable/isEnabled/log/aspects
40
+ */
41
+ (function () {
42
+ 'use strict';
43
+
44
+ var pa = (window.pureCss = window.pureCss || {});
45
+ pa.components = pa.components || {};
46
+
47
+ // --- events: a tiny topic bus -----------------------------------------
48
+ if (!pa.events) {
49
+ var topics = {}; // { [topic]: Set<fn> }
50
+ pa.events = {
51
+ on: function (topic, fn) {
52
+ (topics[topic] || (topics[topic] = new Set())).add(fn);
53
+ return function off() { if (topics[topic]) topics[topic].delete(fn); };
54
+ },
55
+ once: function (topic, fn) {
56
+ var off = pa.events.on(topic, function (payload) { off(); fn(payload); });
57
+ return off;
58
+ },
59
+ off: function (topic, fn) { if (topics[topic]) topics[topic].delete(fn); },
60
+ emit: function (topic, payload) {
61
+ if (!topics[topic]) return;
62
+ topics[topic].forEach(function (fn) {
63
+ try { fn(payload); } catch (e) { pa.debug.log('events', 'listener threw for', topic, e); }
64
+ });
65
+ },
66
+ // Introspection for the future debug console.
67
+ topics: function () { return Object.keys(topics); },
68
+ listenerCount: function (topic) { return topics[topic] ? topics[topic].size : 0; }
69
+ };
70
+ }
71
+
72
+ // --- debug: per-aspect toggles + log --------------------------------------
73
+ if (!pa.debug) {
74
+ var enabled = {}; // { [aspect]: true }
75
+ var known = {}; // every aspect name ever seen (for aspects())
76
+ pa.debug = {
77
+ enable: function (aspect) { enabled[aspect] = true; known[aspect] = true; },
78
+ disable: function (aspect) { enabled[aspect] = false; },
79
+ isEnabled: function (aspect) { known[aspect] = true; return enabled[aspect] === true; },
80
+ log: function (aspect) {
81
+ known[aspect] = true;
82
+ if (enabled[aspect] !== true) return;
83
+ var args = Array.prototype.slice.call(arguments, 1);
84
+ console.log.apply(console, ['[pc:' + aspect + ']'].concat(args));
85
+ },
86
+ // { aspect: enabled } for every aspect that's been enabled or probed.
87
+ aspects: function () {
88
+ var out = {};
89
+ Object.keys(known).forEach(function (k) { out[k] = enabled[k] === true; });
90
+ return out;
91
+ }
92
+ };
93
+ }
94
+
95
+ // --- viewport: the single owner of window-level resize/orientation --------
96
+ if (!pa.viewport) {
97
+ var vp = pa.viewport = { width: 0, height: 0, orientation: 'landscape' };
98
+ var raf = null;
99
+ function measure() {
100
+ vp.width = window.innerWidth;
101
+ vp.height = window.innerHeight;
102
+ // CSS-parity: "portrait" == taller-than-wide, device-agnostic.
103
+ vp.orientation = vp.height >= vp.width ? 'portrait' : 'landscape';
104
+ }
105
+ measure();
106
+ window.addEventListener('resize', function () {
107
+ if (raf) return; // coalesce a burst of resizes into one frame
108
+ raf = requestAnimationFrame(function () {
109
+ raf = null;
110
+ measure();
111
+ pa.events.emit('viewport:resize', vp);
112
+ });
113
+ }, { passive: true });
114
+
115
+ // Orientation via matchMedia — well-supported and fires on desktop pivot too.
116
+ var mq = window.matchMedia('(orientation: portrait)');
117
+ var onOrient = function () { measure(); pa.events.emit('viewport:orientation', vp); };
118
+ if (mq.addEventListener) mq.addEventListener('change', onOrient);
119
+ else if (mq.addListener) mq.addListener(onOrient); // Safari <14
120
+ }
121
+
122
+ // --- colorScheme: the single owner of the OS light/dark preference --------
123
+ if (!pa.colorScheme) {
124
+ var cs = pa.colorScheme = { mode: 'light' };
125
+ var csmq = window.matchMedia('(prefers-color-scheme: dark)');
126
+ var readScheme = function () { cs.mode = csmq.matches ? 'dark' : 'light'; };
127
+ readScheme();
128
+ var onScheme = function () { readScheme(); pa.events.emit('colorscheme:change', cs); };
129
+ if (csmq.addEventListener) csmq.addEventListener('change', onScheme);
130
+ else if (csmq.addListener) csmq.addListener(onScheme); // Safari <14
131
+ }
132
+
133
+ // --- config: the shared UI-behavior baseline (foundation half) ------------
134
+ // The engines that moved here (fit, container-breakpoint) + the shell pieces
135
+ // (device classification, sidebar-resize, search debounce) read these. The
136
+ // admin facade layers transition/toast/severity on top of this same object.
137
+ if (!pa.config) pa.config = {};
138
+ (function initConfig(cfg) {
139
+ // Read a numeric CSS variable off :root (px / ms / unitless → number).
140
+ function readCssNumber(name, fallback) {
141
+ try {
142
+ var raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
143
+ var n = parseFloat(raw);
144
+ return isNaN(n) ? fallback : n;
145
+ } catch (e) { return fallback; }
146
+ }
147
+
148
+ // Fill only the keys a consumer hasn't already set (shallow).
149
+ function fillDefaults(target, defs) {
150
+ Object.keys(defs).forEach(function (k) {
151
+ if (target[k] === undefined) target[k] = defs[k];
152
+ });
153
+ }
154
+
155
+ // mobileBreakpoint (px) — SINGLE-SOURCED from SCSS $mobile-breakpoint via the
156
+ // --pc-mobile-breakpoint CSS var. Honour an explicit consumer override; else
157
+ // derive from CSS; else 768.
158
+ if (cfg.mobileBreakpoint == null) {
159
+ cfg.mobileBreakpoint = readCssNumber('--pc-mobile-breakpoint', 768);
160
+ }
161
+
162
+ // typingDebounceDelay (ms) — debounce for search / autocomplete / filter inputs.
163
+ if (cfg.typingDebounceDelay == null) {
164
+ cfg.typingDebounceDelay = 300;
165
+ }
166
+
167
+ // tabletMinShortSide (px) — the phone/tablet boundary applied to the SHORTER
168
+ // viewport side (Material sw600dp), consumed by pureCss.device.
169
+ if (cfg.tabletMinShortSide == null) {
170
+ cfg.tabletMinShortSide = 600;
171
+ }
172
+
173
+ // fit.* — the Fit engine (fit.js). defaultPriority is the priority a slot
174
+ // falls back to when it declares data-pc-fit but no data-pc-fit-priority.
175
+ cfg.fit = cfg.fit || {};
176
+ fillDefaults(cfg.fit, {
177
+ defaultPriority: 0
178
+ });
179
+
180
+ // containerBreakpoint.* — the Container Breakpoint engine. hysteresis is the
181
+ // dead-band applied at every threshold; hiddenClass is the utility class the
182
+ // engine toggles on [data-pc-show] descendants to hide them outside their mode(s).
183
+ cfg.containerBreakpoint = cfg.containerBreakpoint || {};
184
+ fillDefaults(cfg.containerBreakpoint, {
185
+ hysteresis: 1,
186
+ hiddenClass: 'd-none'
187
+ });
188
+ })(pa.config);
189
+
190
+ // --- device: capability-first device classification -----------------------
191
+ // Capability decides FIRST: a non-touch-primary pointer (a real mouse) is
192
+ // ALWAYS 'desktop' at any width. Only touch-primary devices consult the size
193
+ // line: SHORTER viewport side below config.tabletMinShortSide => 'mobile',
194
+ // else 'tablet'. Feature detection (matchMedia), not UA sniffing.
195
+ if (!pa.device) {
196
+ var dev = pa.device = { class: 'desktop', isTouchPrimary: false };
197
+ var coarseMq = window.matchMedia('(pointer: coarse)');
198
+ var hoverMq = window.matchMedia('(hover: hover)');
199
+ var classifyDevice = function () {
200
+ var touchPrimary = coarseMq.matches && !hoverMq.matches;
201
+ dev.isTouchPrimary = touchPrimary;
202
+ if (!touchPrimary) return 'desktop';
203
+ var shortSide = Math.min(window.innerWidth, window.innerHeight);
204
+ var line = (pa.config && pa.config.tabletMinShortSide) || 600;
205
+ return shortSide < line ? 'mobile' : 'tablet';
206
+ };
207
+ var reclassify = function () {
208
+ var next = classifyDevice();
209
+ if (next === dev.class) return;
210
+ dev.class = next;
211
+ pa.events.emit('device:change', dev);
212
+ };
213
+ dev.class = classifyDevice();
214
+ pa.events.on('viewport:resize', reclassify);
215
+ pa.events.on('viewport:orientation', reclassify);
216
+ if (coarseMq.addEventListener) {
217
+ coarseMq.addEventListener('change', reclassify);
218
+ hoverMq.addEventListener('change', reclassify);
219
+ } else if (coarseMq.addListener) { // Safari <14
220
+ coarseMq.addListener(reclassify);
221
+ hoverMq.addListener(reclassify);
222
+ }
223
+ }
224
+
225
+ // --- overlay: fullscreen-sheet primitives (scroll lock + keyboard inset) ---
226
+ if (!pa.overlay) {
227
+ var lockCount = 0;
228
+ var stashedOverflow = null;
229
+ pa.overlay = {
230
+ // Ref-counted body-scroll lock. Returns an idempotent release fn.
231
+ lockBodyScroll: function () {
232
+ if (lockCount === 0) {
233
+ stashedOverflow = document.body.style.overflow;
234
+ document.body.style.overflow = 'hidden';
235
+ }
236
+ lockCount++;
237
+ var released = false;
238
+ return function () {
239
+ if (released) return;
240
+ released = true;
241
+ lockCount = Math.max(0, lockCount - 1);
242
+ if (lockCount === 0 && stashedOverflow !== null) {
243
+ document.body.style.overflow = stashedOverflow;
244
+ stashedOverflow = null;
245
+ }
246
+ };
247
+ },
248
+ // Pin a fixed fullscreen panel's bottom edge above the soft keyboard by
249
+ // tracking window.visualViewport. Returns a cleanup. No-op where
250
+ // visualViewport is unavailable.
251
+ observeKeyboardInset: function (panel) {
252
+ var vv = window.visualViewport;
253
+ if (!vv || !panel) return function () {};
254
+ var raf = null;
255
+ var apply = function () {
256
+ raf = null;
257
+ panel.style.height = vv.height + 'px';
258
+ panel.style.top = vv.offsetTop + 'px';
259
+ };
260
+ var schedule = function () {
261
+ if (raf) return;
262
+ raf = requestAnimationFrame(apply);
263
+ };
264
+ vv.addEventListener('resize', schedule);
265
+ vv.addEventListener('scroll', schedule);
266
+ schedule();
267
+ return function () {
268
+ vv.removeEventListener('resize', schedule);
269
+ vv.removeEventListener('scroll', schedule);
270
+ panel.style.height = '';
271
+ panel.style.top = '';
272
+ };
273
+ }
274
+ };
275
+ }
276
+
277
+ // --- menus: open-menu coordination ----------------------------------------
278
+ if (!pa.menus) {
279
+ pa.menus = {
280
+ closers: [],
281
+ register: function (fn) { this.closers.push(fn); return fn; },
282
+ closeOthers: function (self) {
283
+ this.closers.forEach(function (fn) {
284
+ if (fn !== self) { try { fn(); } catch (e) { /* ignore */ } }
285
+ });
286
+ pa.events.emit('menu:opened', { id: self && self.paMenuId });
287
+ }
288
+ };
289
+ }
290
+
291
+ // --- components.initAll: init every registered component under a scope -----
292
+ if (!pa.components.initAll) {
293
+ pa.components.initAll = function (scope) {
294
+ Object.keys(pa.components).forEach(function (name) {
295
+ if (name === 'initAll') return;
296
+ var c = pa.components[name];
297
+ if (!c) return;
298
+ try {
299
+ if (typeof c.initAll === 'function') c.initAll(scope);
300
+ else if (typeof c.init === 'function') c.init(scope);
301
+ } catch (e) { pa.debug.log('components', name + '.initAll threw', e); }
302
+ });
303
+ };
304
+ }
305
+ })();
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Pure CSS — Sidebar Resize
3
+ *
4
+ * Drag-to-resize the sidebar. Ships in the pure-css foundation (next to fit.js /
5
+ * navbar-dropdown.js) so consumers don't have to vendor it out of the demo:
6
+ * `--pc-local-sidebar-width` is documented in _sidebar.scss as "modified by JS
7
+ * for resize", and the `.pc-sidebar-resize` handle + `--pc-local-sidebar-min-
8
+ * width` / `--pc-local-sidebar-max-width` bounds are all core CSS — the driving
9
+ * script belongs here too.
10
+ *
11
+ * The drag bounds and rem<->px conversion are READ FROM CSS, not hardcoded:
12
+ * --pc-local-sidebar-min-width (default 18rem) — smallest drag width
13
+ * --pc-local-sidebar-max-width (default 50rem) — largest drag width
14
+ * and rem is converted using the actual root font-size (this framework uses a
15
+ * 10px base, but a consumer/theme that changes it — or the min/max vars — stays
16
+ * consistent with the drag limits automatically). Earlier versions baked in
17
+ * 180/500/288 px and a `/10` rem factor, which silently drifted from these vars.
18
+ *
19
+ * Activate by putting `.pc-layout__sidebar--resizable` on the sidebar (the demo
20
+ * settings panel toggles it). The `.pc-sidebar-resize` handle is created on the
21
+ * fly; double-click it to reset to the stylesheet default.
22
+ *
23
+ * Public API (pureCss.components.sidebarResize): init, reset, setWidth, getWidth.
24
+ * Emits `sidebar:resize` ({width}) on the pureCss event bus at drag end.
25
+ */
26
+ (function () {
27
+ 'use strict';
28
+
29
+ var STORAGE_KEY = 'sidebar-width'; // persisted in px
30
+
31
+ // Below this width the sidebar is an overlay drawer — resize is meaningless.
32
+ // Single-sourced from window.pureCss.config.mobileBreakpoint (which derives
33
+ // from the --pc-mobile-breakpoint CSS var / SCSS $mobile-breakpoint), so this
34
+ // guard and the CSS `.pc-sidebar-resize { display: none }` mobile rule can't
35
+ // drift apart. Falls back to 768 if the namespace isn't present yet.
36
+ function mobileMax() {
37
+ var pa = window.pureCss;
38
+ return (pa && pa.config && pa.config.mobileBreakpoint) || 768;
39
+ }
40
+
41
+ var sidebar = null;
42
+ var resizeHandle = null;
43
+ var isResizing = false;
44
+ var startX = 0;
45
+ var startWidth = 0;
46
+ var bounds = { min: 0, max: 0 };
47
+ var initialized = false;
48
+ var rafId = null;
49
+
50
+ // --- CSS-variable helpers ------------------------------------------------
51
+ // Root font-size in px (this framework uses 10px; read it so a changed base
52
+ // doesn't break the px<->rem conversion).
53
+ function rootFontSize() {
54
+ return parseFloat(getComputedStyle(document.documentElement).fontSize) || 10;
55
+ }
56
+
57
+ // Read a length CSS variable off :root and return it in px. Handles rem, px,
58
+ // and bare numbers (treated as px); falls back to fallbackPx if unset.
59
+ function readPxVar(name, fallbackPx) {
60
+ var raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
61
+ if (!raw) return fallbackPx;
62
+ var n = parseFloat(raw);
63
+ if (isNaN(n)) return fallbackPx;
64
+ if (raw.indexOf('rem') !== -1) return n * rootFontSize();
65
+ return n; // px or unitless
66
+ }
67
+
68
+ // Re-read the drag bounds from CSS (called at drag start, so a runtime theme
69
+ // switch that changes the min/max is honoured without re-init).
70
+ function refreshBounds() {
71
+ bounds.min = readPxVar('--pc-local-sidebar-min-width', 180);
72
+ bounds.max = readPxVar('--pc-local-sidebar-max-width', 500);
73
+ }
74
+
75
+ // --- init ----------------------------------------------------------------
76
+ function init() {
77
+ if (initialized && resizeHandle && resizeHandle.parentNode) return;
78
+
79
+ sidebar = document.querySelector('.pc-layout__sidebar--resizable');
80
+ if (!sidebar) return;
81
+
82
+ var existingHandle = sidebar.querySelector('.pc-sidebar-resize');
83
+ if (existingHandle) {
84
+ resizeHandle = existingHandle;
85
+ initialized = true;
86
+ return;
87
+ }
88
+
89
+ resizeHandle = document.createElement('div');
90
+ resizeHandle.className = 'pc-sidebar-resize';
91
+ resizeHandle.setAttribute('title', 'Drag to resize sidebar');
92
+ sidebar.appendChild(resizeHandle);
93
+
94
+ loadSavedWidth();
95
+
96
+ resizeHandle.addEventListener('mousedown', startResize);
97
+ resizeHandle.addEventListener('touchstart', startResize, { passive: false });
98
+ resizeHandle.addEventListener('dblclick', resetWidth); // reset to default
99
+
100
+ initialized = true;
101
+ }
102
+
103
+ function loadSavedWidth() {
104
+ var saved = localStorage.getItem(STORAGE_KEY);
105
+ if (!saved) return;
106
+ var width = parseInt(saved, 10);
107
+ if (isNaN(width)) return;
108
+ refreshBounds();
109
+ if (width >= bounds.min && width <= bounds.max) setSidebarWidth(width);
110
+ }
111
+
112
+ // Write width (px) as rem on :root, using the real root font-size.
113
+ // Flag body so the tablet-band (769–1024px) width rule honours this explicit
114
+ // width instead of the min() cap — without the flag the cap clamps every
115
+ // draggable width (drag floor 18rem > 16rem cap) and the handle looks dead.
116
+ function setSidebarWidth(width) {
117
+ var rem = width / rootFontSize();
118
+ document.documentElement.style.setProperty('--pc-local-sidebar-width', rem + 'rem');
119
+ document.body.classList.add('pc-sidebar-resized');
120
+ }
121
+
122
+ function getCurrentWidth() {
123
+ return sidebar ? sidebar.offsetWidth : readPxVar('--pc-local-sidebar-width', 288);
124
+ }
125
+
126
+ function startResize(e) {
127
+ // Skip when the sidebar is collapsed or on mobile (overlay mode).
128
+ if (document.body.classList.contains('sidebar-hidden') ||
129
+ window.innerWidth <= mobileMax()) {
130
+ return;
131
+ }
132
+
133
+ e.preventDefault();
134
+ isResizing = true;
135
+ refreshBounds(); // pick up any theme-driven min/max change
136
+
137
+ startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
138
+ startWidth = getCurrentWidth();
139
+
140
+ resizeHandle.classList.add('pc-sidebar-resize--active');
141
+ document.body.classList.add('pc-sidebar-resizing');
142
+
143
+ document.addEventListener('mousemove', doResize);
144
+ document.addEventListener('mouseup', stopResize);
145
+ document.addEventListener('touchmove', doResize, { passive: false });
146
+ document.addEventListener('touchend', stopResize);
147
+ document.addEventListener('touchcancel', stopResize); // clean up if the browser cancels the touch
148
+ }
149
+
150
+ // Throttled with requestAnimationFrame.
151
+ function doResize(e) {
152
+ if (!isResizing) return;
153
+ e.preventDefault();
154
+
155
+ var currentX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
156
+ if (rafId) cancelAnimationFrame(rafId);
157
+ rafId = requestAnimationFrame(function () {
158
+ var diff = currentX - startX;
159
+ var newWidth = Math.max(bounds.min, Math.min(bounds.max, startWidth + diff));
160
+ setSidebarWidth(newWidth);
161
+ });
162
+ }
163
+
164
+ function stopResize() {
165
+ if (!isResizing) return;
166
+ isResizing = false;
167
+
168
+ if (rafId) {
169
+ cancelAnimationFrame(rafId);
170
+ rafId = null;
171
+ }
172
+
173
+ resizeHandle.classList.remove('pc-sidebar-resize--active');
174
+ document.body.classList.remove('pc-sidebar-resizing');
175
+
176
+ document.removeEventListener('mousemove', doResize);
177
+ document.removeEventListener('mouseup', stopResize);
178
+ document.removeEventListener('touchmove', doResize);
179
+ document.removeEventListener('touchend', stopResize);
180
+ document.removeEventListener('touchcancel', stopResize);
181
+
182
+ var finalWidth = getCurrentWidth();
183
+ localStorage.setItem(STORAGE_KEY, finalWidth.toString());
184
+ if (window.pureCss && window.pureCss.events) {
185
+ window.pureCss.events.emit('sidebar:resize', { width: finalWidth });
186
+ }
187
+ }
188
+
189
+ // Reset by dropping the inline override so the stylesheet default applies
190
+ // (the base $sidebar-width, or the tablet cap in the 769–1024px band).
191
+ function resetWidth() {
192
+ document.documentElement.style.removeProperty('--pc-local-sidebar-width');
193
+ document.body.classList.remove('pc-sidebar-resized');
194
+ localStorage.removeItem(STORAGE_KEY);
195
+ }
196
+
197
+ if (document.readyState === 'loading') {
198
+ document.addEventListener('DOMContentLoaded', init);
199
+ } else {
200
+ init();
201
+ }
202
+
203
+ var pa = (window.pureCss = window.pureCss || {});
204
+ (pa.components = pa.components || {}).sidebarResize = {
205
+ init: init,
206
+ reset: resetWidth,
207
+ setWidth: setSidebarWidth,
208
+ getWidth: getCurrentWidth
209
+ };
210
+ })();