@blukavantui/community 1.0.1 → 1.2.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/README.md +11 -5
- package/dist/build-audit.json +234 -44
- package/dist/checkbox.css +1 -0
- package/dist/checkbox.d.ts +2 -0
- package/dist/checkbox.js +1 -0
- package/dist/chunk-2BSBXDOT.js +1 -0
- package/dist/chunk-4LHIQROI.js +0 -0
- package/dist/chunk-5ENZVZGF.js +0 -0
- package/dist/chunk-6T677NSC.js +1 -0
- package/dist/chunk-7CEOCZ5L.js +0 -0
- package/dist/chunk-7DXFNK7C.js +1 -0
- package/dist/chunk-AL2ETLJV.js +1 -0
- package/dist/chunk-BN3PDE5G.js +0 -0
- package/dist/chunk-CNIIMGT3.js +0 -0
- package/dist/chunk-CSTCEBEK.js +1 -0
- package/dist/chunk-DRZM2AL3.js +0 -0
- package/dist/chunk-EOKNLCNS.js +1 -0
- package/dist/chunk-EPPETNK6.js +1 -0
- package/dist/chunk-EPPSBHZR.js +1 -0
- package/dist/chunk-EPZGLHPD.js +0 -0
- package/dist/chunk-FNX33PDD.js +0 -0
- package/dist/{chunk-NTFHNL7T.js → chunk-G6OW4OZM.js} +1 -1
- package/dist/chunk-GS67ZBLI.js +1 -0
- package/dist/chunk-HO6CYWED.js +0 -0
- package/dist/{chunk-6EHKOVZK.js → chunk-HVR2UDFX.js} +1 -1
- package/dist/chunk-IJ62ILKE.js +0 -0
- package/dist/chunk-IKYBFMHI.js +1 -0
- package/dist/chunk-IO22GWON.js +0 -0
- package/dist/chunk-KVOA55NE.js +0 -0
- package/dist/{chunk-MZBB5ROJ.js → chunk-LF2HELEZ.js} +1 -1
- package/dist/chunk-MYVLBALF.js +1 -0
- package/dist/{chunk-UEOCR2C3.js → chunk-OLDA73XG.js} +2 -2
- package/dist/chunk-RWBLICYX.js +0 -0
- package/dist/chunk-RYD6RHRX.js +0 -0
- package/dist/chunk-T45FJNEQ.js +1 -0
- package/dist/chunk-TZSX4OTV.js +1 -0
- package/dist/chunk-UN2SLVFG.js +1 -0
- package/dist/{chunk-GK3INPAZ.js → chunk-V2YPIC6Z.js} +1 -1
- package/dist/chunk-V4ZQLQ7L.js +0 -0
- package/dist/chunk-VAM3FPCS.js +0 -0
- package/dist/chunk-VJVFHL6P.js +0 -0
- package/dist/chunk-VSTKXZYY.js +0 -0
- package/dist/chunk-W3H7XTA7.js +1 -0
- package/dist/chunk-WG4US4JH.js +1 -0
- package/dist/chunk-WGSTNXAQ.js +0 -0
- package/dist/chunk-WJHCVECU.js +1 -0
- package/dist/chunk-XVMJG26X.js +1 -0
- package/dist/chunk-Y4AXWTAD.js +0 -0
- package/dist/chunk-YCLR3IE3.js +0 -0
- package/dist/chunk-YLFXSHKZ.js +0 -0
- package/dist/chunk-ZDGZGAYG.js +0 -0
- package/dist/columns2.css +1 -0
- package/dist/columns2.d.ts +2 -0
- package/dist/columns2.js +1 -0
- package/dist/columns3.css +1 -0
- package/dist/columns3.d.ts +2 -0
- package/dist/columns3.js +1 -0
- package/dist/columns4.css +1 -0
- package/dist/columns4.d.ts +2 -0
- package/dist/columns4.js +1 -0
- package/dist/command-palette.js +1 -1
- package/dist/component-motion.css +39 -0
- package/dist/component-motion.d.ts +45 -0
- package/dist/component-motion.js +1 -0
- package/dist/content.js +1 -1
- package/dist/core.css +8 -0
- package/dist/core.js +1 -1
- package/dist/data-motion.css +4 -0
- package/dist/data-motion.d.ts +23 -0
- package/dist/data-motion.js +1 -0
- package/dist/data.css +63 -0
- package/dist/data.d.ts +1 -0
- package/dist/data.js +1 -1
- package/dist/date-picker.css +5 -5
- package/dist/date-picker.js +1 -1
- package/dist/date-range-picker.css +5 -5
- package/dist/date-range-picker.js +1 -1
- package/dist/date-time-picker.css +5 -5
- package/dist/date-time-picker.js +1 -1
- package/dist/draft-components.json +77 -0
- package/dist/drawer.js +1 -1
- package/dist/essentials.css +8 -0
- package/dist/feedback.js +1 -1
- package/dist/form-field.css +1 -0
- package/dist/form-field.d.ts +2 -0
- package/dist/form-field.js +1 -0
- package/dist/form-layout/choice.d.ts +15 -0
- package/dist/form-layout/layout.d.ts +12 -0
- package/dist/form-layout/select.d.ts +3 -0
- package/dist/form-layout/shared.d.ts +21 -0
- package/dist/form-layout/table.d.ts +20 -0
- package/dist/form-layout/text.d.ts +3 -0
- package/dist/form-layout/types.d.ts +151 -0
- package/dist/form-layout.css +62 -0
- package/dist/form-layout.d.ts +12 -0
- package/dist/form-layout.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/inputs.css +83 -20
- package/dist/inputs.d.ts +8 -0
- package/dist/inputs.js +1 -1
- package/dist/layout.css +63 -0
- package/dist/layout.d.ts +3 -0
- package/dist/layout.js +1 -1
- package/dist/modal.js +1 -1
- package/dist/motion/action-feedback.d.ts +14 -0
- package/dist/motion/border-glow.d.ts +12 -0
- package/dist/motion/button.d.ts +11 -0
- package/dist/motion/component-settings.d.ts +51 -0
- package/dist/motion/controller.d.ts +33 -0
- package/dist/motion/effects.d.ts +30 -0
- package/dist/motion/feedback.d.ts +6 -0
- package/dist/motion/loading.d.ts +9 -0
- package/dist/motion/optional.d.ts +35 -0
- package/dist/motion/photo.d.ts +7 -0
- package/dist/motion/presence.d.ts +12 -0
- package/dist/motion/scene.d.ts +10 -0
- package/dist/motion/screen.d.ts +6 -0
- package/dist/motion/scroll-reveal.d.ts +13 -0
- package/dist/motion/settings-group.d.ts +14 -0
- package/dist/motion/stagger.d.ts +12 -0
- package/dist/motion/structure.d.ts +18 -0
- package/dist/motion/tabs.d.ts +19 -0
- package/dist/motion/tokens.d.ts +11 -0
- package/dist/motion/value.d.ts +21 -0
- package/dist/motion/view-transition.d.ts +14 -0
- package/dist/motion-enhancers.d.ts +12 -0
- package/dist/motion-enhancers.js +1 -0
- package/dist/motion-settings.d.ts +3 -0
- package/dist/motion-settings.js +1 -0
- package/dist/motion.css +1076 -0
- package/dist/motion.d.ts +20 -0
- package/dist/motion.js +1 -0
- package/dist/native/date-picker.js +1 -1
- package/dist/native/date-range-picker.js +1 -1
- package/dist/native/date-time-picker.js +1 -1
- package/dist/native/form-layout.js +1 -0
- package/dist/native/time-picker.js +1 -1
- package/dist/native-components/form-layout.d.ts +1 -0
- package/dist/native-motion-components.d.ts +3 -0
- package/dist/native-motion-content.d.ts +1 -0
- package/dist/native-motion-foundations.d.ts +3 -0
- package/dist/native-motion-layout.d.ts +3 -0
- package/dist/native-motion-pilot.d.ts +34 -0
- package/dist/native-motion.d.ts +71 -0
- package/dist/notification.js +1 -1
- package/dist/pagination.js +1 -1
- package/dist/radio-button.css +1 -0
- package/dist/radio-button.d.ts +2 -0
- package/dist/radio-button.js +1 -0
- package/dist/radio-group.css +1 -0
- package/dist/radio-group.d.ts +2 -0
- package/dist/radio-group.js +1 -0
- package/dist/select.css +1 -0
- package/dist/select.d.ts +2 -0
- package/dist/select.js +1 -0
- package/dist/sidebar.js +1 -1
- package/dist/sizes.json +416 -112
- package/dist/skeleton.css +8 -0
- package/dist/styles.css +1213 -20
- package/dist/switch.css +1 -0
- package/dist/switch.d.ts +2 -0
- package/dist/switch.js +1 -0
- package/dist/table.css +1 -0
- package/dist/table.d.ts +2 -0
- package/dist/table.js +1 -0
- package/dist/text-area.css +1 -0
- package/dist/text-area.d.ts +2 -0
- package/dist/text-area.js +1 -0
- package/dist/text-input.css +1 -0
- package/dist/text-input.d.ts +2 -0
- package/dist/text-input.js +1 -0
- package/dist/time-picker.css +5 -5
- package/dist/time-picker.js +1 -1
- package/dist/tree-view.js +1 -1
- package/dist/web-motion/chip-ripple.d.ts +6 -0
- package/dist/web-motion/choice-ripple.d.ts +6 -0
- package/dist/web-motion/content.d.ts +9 -0
- package/dist/web-motion/data-rows.d.ts +12 -0
- package/dist/web-motion/fields.d.ts +6 -0
- package/dist/web-motion/navigation.d.ts +6 -0
- package/dist/web-motion/notifications.d.ts +6 -0
- package/dist/web-motion/select-ripple.d.ts +6 -0
- package/dist/web-motion/statistic.d.ts +13 -0
- package/dist/web-motion/surfaces.d.ts +10 -0
- package/dist/web-motion/tree.d.ts +6 -0
- package/docs/FORM-LAYOUT-ESSENTIALS.md +122 -0
- package/docs/PUBLISHING.md +129 -25
- package/draft-components.json +77 -0
- package/edition.json +44 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +102 -10
- package/scripts/build-motion-css.mjs +36 -0
- package/scripts/build-motion-styles.mjs +29 -0
- package/scripts/build.mjs +20 -8
- package/scripts/motion-entries.mjs +2 -0
- package/src/checkbox.ts +2 -0
- package/src/columns2.ts +2 -0
- package/src/columns3.ts +2 -0
- package/src/columns4.ts +2 -0
- package/src/component-motion.ts +101 -0
- package/src/data-motion.ts +60 -0
- package/src/data.ts +1 -0
- package/src/form-field.ts +2 -0
- package/src/form-layout/choice.ts +82 -0
- package/src/form-layout/layout.ts +40 -0
- package/src/form-layout/select.ts +97 -0
- package/src/form-layout/shared.ts +57 -0
- package/src/form-layout/table.ts +86 -0
- package/src/form-layout/text.ts +73 -0
- package/src/form-layout/types.ts +59 -0
- package/src/form-layout.ts +12 -0
- package/src/index.ts +2 -0
- package/src/inputs.ts +8 -0
- package/src/layout.ts +3 -0
- package/src/motion/action-feedback.ts +38 -0
- package/src/motion/border-glow.ts +41 -0
- package/src/motion/button.ts +56 -0
- package/src/motion/component-settings.ts +147 -0
- package/src/motion/controller.ts +191 -0
- package/src/motion/effects.ts +75 -0
- package/src/motion/feedback.ts +41 -0
- package/src/motion/loading.ts +40 -0
- package/src/motion/optional.ts +123 -0
- package/src/motion/photo.ts +19 -0
- package/src/motion/presence.ts +44 -0
- package/src/motion/scene.ts +103 -0
- package/src/motion/screen.ts +15 -0
- package/src/motion/scroll-reveal.ts +49 -0
- package/src/motion/settings-group.ts +28 -0
- package/src/motion/stagger.ts +26 -0
- package/src/motion/structure.ts +75 -0
- package/src/motion/tabs.ts +105 -0
- package/src/motion/tokens.ts +2 -0
- package/src/motion/value.ts +61 -0
- package/src/motion/view-transition.ts +57 -0
- package/src/motion-enhancers.ts +12 -0
- package/src/motion-settings.ts +3 -0
- package/src/motion.ts +20 -0
- package/src/native-components/form-layout.ts +21 -0
- package/src/native-motion-components.ts +76 -0
- package/src/native-motion-content.ts +10 -0
- package/src/native-motion-foundations.ts +56 -0
- package/src/native-motion-layout.ts +35 -0
- package/src/native-motion-pilot.ts +151 -0
- package/src/native-motion.ts +188 -0
- package/src/radio-button.ts +2 -0
- package/src/radio-group.ts +2 -0
- package/src/runtime/date-picker.js +21 -12
- package/src/runtime/date-range-picker.js +21 -12
- package/src/runtime/date-time-picker.js +21 -12
- package/src/runtime/time-picker.js +21 -12
- package/src/select.ts +2 -0
- package/src/switch.ts +2 -0
- package/src/table.ts +2 -0
- package/src/text-area.ts +2 -0
- package/src/text-input.ts +2 -0
- package/src/types.ts +0 -12
- package/src/web-motion/chip-ripple.ts +31 -0
- package/src/web-motion/choice-ripple.ts +30 -0
- package/src/web-motion/content.ts +74 -0
- package/src/web-motion/data-rows.ts +106 -0
- package/src/web-motion/fields.ts +52 -0
- package/src/web-motion/navigation.ts +62 -0
- package/src/web-motion/notifications.ts +67 -0
- package/src/web-motion/select-ripple.ts +23 -0
- package/src/web-motion/statistic.ts +23 -0
- package/src/web-motion/surfaces.ts +137 -0
- package/src/web-motion/tree.ts +44 -0
- package/styles/checkbox.css +1 -0
- package/styles/columns2.css +1 -0
- package/styles/columns3.css +1 -0
- package/styles/columns4.css +1 -0
- package/styles/component-motion.css +39 -0
- package/styles/data-motion.css +4 -0
- package/styles/date-picker.css +5 -5
- package/styles/date-range-picker.css +5 -5
- package/styles/date-time-picker.css +5 -5
- package/styles/form-field.css +1 -0
- package/styles/form-layout.css +62 -0
- package/styles/motion-optional.css +15 -0
- package/styles/motion-presets.css +92 -0
- package/styles/motion-tokens.css +6 -0
- package/styles/motion.css +125 -0
- package/styles/radio-button.css +1 -0
- package/styles/radio-group.css +1 -0
- package/styles/select.css +1 -0
- package/styles/skeleton.css +8 -0
- package/styles/switch.css +1 -0
- package/styles/table.css +1 -0
- package/styles/text-area.css +1 -0
- package/styles/text-input.css +1 -0
- package/styles/time-picker.css +5 -5
- package/test/component-motion-settings.test.mjs +35 -0
- package/test/content-overlay-motion.test.mjs +36 -0
- package/test/form-layout.test.mjs +28 -0
- package/test/isolation.test.mjs +2 -2
- package/test/motion.test.mjs +215 -0
- package/dist/chunk-2TCNAQC6.js +0 -1
- package/dist/chunk-CTLZIWMX.js +0 -1
- package/dist/chunk-MHHJZYXW.js +0 -1
- package/dist/chunk-XDYBNO4P.js +0 -1
- /package/dist/{chunk-H7ETPJVW.js → chunk-3PODK22R.js} +0 -0
- /package/dist/{chunk-SIU7DSOA.js → chunk-KBWB4OIF.js} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// docs/scripts/advanced/shared.js
|
|
2
2
|
import { state, register } from "./kernel.js";
|
|
3
3
|
var shared = state("advanced", () => ({ instances: /* @__PURE__ */ new Map(), serial: 0 }));
|
|
4
4
|
var uid = () => `blu-advanced-${++shared.serial}`;
|
|
@@ -91,7 +91,7 @@ function createAPI(kind, init2, update2) {
|
|
|
91
91
|
}, destroy }));
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
//
|
|
94
|
+
// docs/scripts/advanced/date-utils.js
|
|
95
95
|
var pad = (n) => String(n).padStart(2, "0");
|
|
96
96
|
function dateValue(d) {
|
|
97
97
|
return String(d.getFullYear()).padStart(4, "0") + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
|
|
@@ -172,7 +172,7 @@ function validateRange(start, end, options = {}) {
|
|
|
172
172
|
return "";
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
//
|
|
175
|
+
// docs/scripts/advanced/pickers.js
|
|
176
176
|
var formatOptions = ["YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY"];
|
|
177
177
|
var today = () => dateValue(/* @__PURE__ */ new Date());
|
|
178
178
|
var datePart = (value) => String(value || "").split("T")[0];
|
|
@@ -256,14 +256,15 @@ function positionPopup(s) {
|
|
|
256
256
|
const below = Math.max(0, top + height - edge - anchor.bottom - gap), above = Math.max(0, anchor.top - gap - top - edge);
|
|
257
257
|
const natural = panel.scrollHeight + 2, up = natural > below && above > below, room = Math.max(0, height - edge * 2);
|
|
258
258
|
panel.style.maxHeight = room + "px";
|
|
259
|
-
|
|
259
|
+
const fields = s.fieldRow.getBoundingClientRect();
|
|
260
|
+
panel.style.left = Math.max(left + edge, Math.min(fields.left + (fields.width - panelWidth) / 2, left + width - edge - panelWidth)) + "px";
|
|
260
261
|
const preferred = up ? anchor.top - gap - natural : anchor.bottom + gap;
|
|
261
262
|
panel.style.top = Math.max(top + edge, Math.min(preferred, top + height - edge - Math.min(natural, room))) + "px";
|
|
262
263
|
panel.dataset.placement = up ? "top" : "bottom";
|
|
263
264
|
}
|
|
264
265
|
function focusPopup(s) {
|
|
265
266
|
const timeField = s.timeOnly || s.dateTime && s.anchor === s.fields[1].input;
|
|
266
|
-
(timeField ? s.hours : s.calendar.querySelector('[data-date="' + s.focusDate + '"]') || s.monthSelect).focus();
|
|
267
|
+
(timeField ? s.hours : s.calendar.querySelector('[data-date="' + s.focusDate + '"]') || s.monthSelect).focus({ preventScroll: true });
|
|
267
268
|
}
|
|
268
269
|
function commit(s, first, end, time, keepOpen = false) {
|
|
269
270
|
const issue = validate(s, first, end, time);
|
|
@@ -287,7 +288,7 @@ function commit(s, first, end, time, keepOpen = false) {
|
|
|
287
288
|
s.inputSignature = JSON.stringify(s.range ? [s.start, s.end] : [s.value]);
|
|
288
289
|
paintInputs(s);
|
|
289
290
|
if (!keepOpen) close(s);
|
|
290
|
-
if (returnToInput) s.fields[0].input.focus();
|
|
291
|
+
if (returnToInput) s.fields[0].input.focus({ preventScroll: true });
|
|
291
292
|
s.notice.textContent = s.range ? first ? labelDate(s, first) + " to " + labelDate(s, end) : "Date range cleared" : value ? "Selected " + (s.timeOnly ? formatTime(value, s.cycle) : labelDate(s, first) + (s.hasTime ? " at " + formatTime(time, s.cycle) : "")) : "Selection cleared";
|
|
292
293
|
if (changed) {
|
|
293
294
|
if (s.range) emit(s, "Change", first, end);
|
|
@@ -391,11 +392,18 @@ function appendOption(select, value, label) {
|
|
|
391
392
|
return option;
|
|
392
393
|
}
|
|
393
394
|
function selectField(s, label, name) {
|
|
394
|
-
const wrap = node("label", "blu-picker__select-field"), caption = node("span", "", label), select = node("select", "blu-picker__input");
|
|
395
|
+
const wrap = node("label", "blu-picker__select-field"), caption = node("span", "blu-picker__select-caption", label), select = node("select", "blu-picker__input");
|
|
395
396
|
select.id = s.uid + "-" + name;
|
|
396
397
|
wrap.append(caption, select);
|
|
397
398
|
return { wrap, select };
|
|
398
399
|
}
|
|
400
|
+
function focusPopupButton(event) {
|
|
401
|
+
if (event.button !== 0) return;
|
|
402
|
+
const target = event.target.closest?.("button");
|
|
403
|
+
if (!target || target.disabled) return;
|
|
404
|
+
event.preventDefault();
|
|
405
|
+
target.focus({ preventScroll: true });
|
|
406
|
+
}
|
|
399
407
|
function focusDay(s, value) {
|
|
400
408
|
const min = datePart(s.options.Min) || "0001-01-01", max = datePart(s.options.Max) || "9999-12-31";
|
|
401
409
|
value = value < min ? min : value > max ? max : value;
|
|
@@ -403,7 +411,7 @@ function focusDay(s, value) {
|
|
|
403
411
|
s.focusDate = value;
|
|
404
412
|
s.month = value.slice(0, 7);
|
|
405
413
|
paintCalendar(s);
|
|
406
|
-
s.calendar.querySelector('[data-date="' + value + '"]')?.focus();
|
|
414
|
+
s.calendar.querySelector('[data-date="' + value + '"]')?.focus({ preventScroll: true });
|
|
407
415
|
}
|
|
408
416
|
function keyDay(s, event, value) {
|
|
409
417
|
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
@@ -441,7 +449,7 @@ function chooseDay(s, value) {
|
|
|
441
449
|
return;
|
|
442
450
|
}
|
|
443
451
|
paintCalendar(s);
|
|
444
|
-
s.calendar.querySelector('[data-date="' + value + '"]')?.focus();
|
|
452
|
+
s.calendar.querySelector('[data-date="' + value + '"]')?.focus({ preventScroll: true });
|
|
445
453
|
paintDraft(s);
|
|
446
454
|
}
|
|
447
455
|
function paintDraft(s) {
|
|
@@ -594,6 +602,7 @@ function init(s) {
|
|
|
594
602
|
attr(s.dialog, "aria-modal", "false");
|
|
595
603
|
if (s.dialog.showPopover) attr(s.dialog, "popover", "manual");
|
|
596
604
|
s.root.append(s.dialog);
|
|
605
|
+
s.dialog.addEventListener("mousedown", focusPopupButton);
|
|
597
606
|
s.title = node("h2", "blu-advanced-sr");
|
|
598
607
|
s.title.id = s.uid + "-title";
|
|
599
608
|
s.dialog.append(s.title);
|
|
@@ -606,7 +615,7 @@ function init(s) {
|
|
|
606
615
|
const month = selectField(s, "Month", "month");
|
|
607
616
|
s.monthSelect = month.select;
|
|
608
617
|
const year = node("label", "blu-picker__select-field");
|
|
609
|
-
year.append(node("span", "", "Year"));
|
|
618
|
+
year.append(node("span", "blu-picker__select-caption", "Year"));
|
|
610
619
|
s.yearInput = node("input", "blu-picker__input");
|
|
611
620
|
s.yearInput.type = "number";
|
|
612
621
|
s.yearInput.step = "1";
|
|
@@ -804,8 +813,8 @@ function update(s) {
|
|
|
804
813
|
else {
|
|
805
814
|
paintCalendar(s);
|
|
806
815
|
paintTimes(s);
|
|
807
|
-
if (focusDate) s.calendar.querySelector('[data-date="' + focusDate + '"]')?.focus();
|
|
808
|
-
else if (focusId) document.getElementById(focusId)?.focus();
|
|
816
|
+
if (focusDate) s.calendar.querySelector('[data-date="' + focusDate + '"]')?.focus({ preventScroll: true });
|
|
817
|
+
else if (focusId) document.getElementById(focusId)?.focus({ preventScroll: true });
|
|
809
818
|
positionPopup(s);
|
|
810
819
|
}
|
|
811
820
|
}
|
package/src/select.ts
ADDED
package/src/switch.ts
ADDED
package/src/table.ts
ADDED
package/src/text-area.ts
ADDED
package/src/types.ts
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {MotionController} from '../motion/controller.js';
|
|
2
|
+
import {createRipple} from '../motion/optional.js';
|
|
3
|
+
|
|
4
|
+
/** Decorate the selected chip after its real click, even if that click replaces
|
|
5
|
+
* its button. No synthesized activation or application-state change. */
|
|
6
|
+
export function enhanceChipRipple(root:HTMLElement,motion:MotionController) {
|
|
7
|
+
let dead=false,revision=0,frame=0,current:ReturnType<typeof createRipple>|undefined;
|
|
8
|
+
const click=(event:MouseEvent)=>{
|
|
9
|
+
const source=event.target instanceof Element?event.target.closest<HTMLButtonElement>('.blu-chip__select'):null;
|
|
10
|
+
if(!source||!root.contains(source)||source.disabled)return;
|
|
11
|
+
const labels=[...root.querySelectorAll<HTMLButtonElement>('.blu-chip__select')];
|
|
12
|
+
const label=source.textContent,occurrence=labels.filter(b=>b.textContent===label).indexOf(source);
|
|
13
|
+
const selected=source.getAttribute('aria-pressed');
|
|
14
|
+
const point=event.detail?{clientX:event.clientX,clientY:event.clientY}:undefined,own=++revision;
|
|
15
|
+
// Microtasks can run between capture and the target's click handler.
|
|
16
|
+
// A frame waits until the component has completed its normal selection.
|
|
17
|
+
cancelAnimationFrame(frame);frame=requestAnimationFrame(()=>{
|
|
18
|
+
frame=0;
|
|
19
|
+
if(dead||own!==revision)return;
|
|
20
|
+
current?.destroy();current=undefined;
|
|
21
|
+
const target=source.isConnected?source:[...root.querySelectorAll<HTMLButtonElement>('.blu-chip__select')].filter(b=>b.textContent===label)[occurrence];
|
|
22
|
+
if(!target||target.disabled||motion.mode!=='full')return;
|
|
23
|
+
// O11 prevents the browser's default click while still updating selection.
|
|
24
|
+
// A cancelled activation with no real selection change stays undecorated.
|
|
25
|
+
if(event.defaultPrevented&&target.getAttribute('aria-pressed')===selected)return;
|
|
26
|
+
current=createRipple(target,motion,{duration:1000,easing:'ease-out',listen:false});current.play(point);
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
root.addEventListener('click',click,true);
|
|
30
|
+
return {destroy(){if(dead)return;dead=true;++revision;cancelAnimationFrame(frame);root.removeEventListener('click',click,true);current?.destroy();}};
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {MotionController,MotionOptions} from '../motion/controller.js';
|
|
2
|
+
import {createRipple} from '../motion/optional.js';
|
|
3
|
+
|
|
4
|
+
/** One decorative pulse per native activation. Label clicks, Space and radio
|
|
5
|
+
* arrow changes keep the original input, focus, value and change events. */
|
|
6
|
+
export function enhanceChoiceRipple(root:HTMLElement,motion:MotionController,timing:MotionOptions={}) {
|
|
7
|
+
let dead=false,frame=0,current:ReturnType<typeof createRipple>|undefined;
|
|
8
|
+
const activate=(event:Event)=>{
|
|
9
|
+
const input=event.target;
|
|
10
|
+
if(!(input instanceof HTMLInputElement)||!input.matches('.blu-choice__input')||input.disabled||input.getAttribute('aria-readonly')==='true')return;
|
|
11
|
+
const name=input.name,value=input.value,type=input.type;
|
|
12
|
+
const matches=()=>[...root.querySelectorAll<HTMLInputElement>('.blu-choice__input')].filter(el=>el.name===name&&el.value===value&&el.type===type);
|
|
13
|
+
const occurrence=matches().indexOf(input),checked=input.checked;
|
|
14
|
+
cancelAnimationFrame(frame);frame=requestAnimationFrame(()=>{
|
|
15
|
+
frame=0;if(dead||event.defaultPrevented||motion.mode!=='full')return;
|
|
16
|
+
// Native parameter synchronization can replace a RadioGroup's inputs.
|
|
17
|
+
// Resolve only the same choice with its already-committed checked state.
|
|
18
|
+
const target=root.contains(input)?input:matches()[occurrence];
|
|
19
|
+
if(!target||target.disabled||target.getAttribute('aria-readonly')==='true'||target.checked!==checked)return;
|
|
20
|
+
const mark=target.nextElementSibling as HTMLElement|null;
|
|
21
|
+
if(!mark?.classList.contains('blu-choice__mark'))return;
|
|
22
|
+
current?.destroy();current=createRipple(mark,motion,{duration:1000,easing:'ease-out',...timing,listen:false});
|
|
23
|
+
current.element.classList.add('blu-motion-choice-ripple');current.play();
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
// Capture identity before application handlers can replace the input. The
|
|
27
|
+
// decorative work still waits until the event and native state commit finish.
|
|
28
|
+
root.addEventListener('click',activate,true);root.addEventListener('change',activate,true);
|
|
29
|
+
return {destroy(){if(dead)return;dead=true;cancelAnimationFrame(frame);root.removeEventListener('click',activate,true);root.removeEventListener('change',activate,true);current?.destroy();}};
|
|
30
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type {MotionController} from '../motion/controller.js';
|
|
2
|
+
import {observeSurface} from './surfaces.js';
|
|
3
|
+
import {preserveStyles} from '../motion/structure.js';
|
|
4
|
+
export type ContentMotionKind = 'profile-card'|'video-lightbox'|'video-player'|'collection-layout'|'cascader'|'transfer'|'chip'|'tree-select'|'timeline'|'skeleton'|'media-image'|'tour'|'notification-center';
|
|
5
|
+
/** DOM-only enhancement: the original component still owns data, focus and media playback. */
|
|
6
|
+
export function enhanceContentMotion(root:HTMLElement,motion:MotionController,kind:ContentMotionKind,options:{timelineKeys?:()=>string[]}={}) {
|
|
7
|
+
let dead=false,frame=0,serial=0;
|
|
8
|
+
const active=new Map<HTMLElement,number>(),identities=new WeakMap<Element,string>();
|
|
9
|
+
const identity=(el:Element)=>{let id=identities.get(el);if(!id){id='node-'+ ++serial;identities.set(el,id);}return id;};
|
|
10
|
+
const run=(el:HTMLElement,frames:Keyframe[],duration=650,fade=false)=>{const token=++serial;active.set(el,token);void motion.animate(el,frames,{duration,easing:'ease-in-out'},fade?'fade':'instant').finally(()=>{if(active.get(el)===token)active.delete(el);});};
|
|
11
|
+
type Item={el:HTMLElement;rect:DOMRect;signature:string};
|
|
12
|
+
function read(){
|
|
13
|
+
const origin=root.getBoundingClientRect();
|
|
14
|
+
const entries: [string,Item][]=[];
|
|
15
|
+
const add=(selector:string,key:(el:HTMLElement,i:number)=>string,signature:(el:HTMLElement)=>string=()=> '')=>{
|
|
16
|
+
[...root.querySelectorAll<HTMLElement>(selector)].slice(0,40).forEach((el,i)=>{const r=el.getBoundingClientRect(),shift=active.has(el)?getComputedStyle(el).translate.split(/\s+/).map(Number.parseFloat):[],rect=new DOMRect(r.left-origin.left-(shift[0]||0),r.top-origin.top-(shift[1]||0),r.width,r.height);if(rect.width&&rect.height&&!el.closest('[hidden]'))entries.push([key(el,i),{el,rect,signature:signature(el)}]);});
|
|
17
|
+
};
|
|
18
|
+
if(kind==='collection-layout')add('.blu-collection-layout__item',identity);
|
|
19
|
+
if(kind==='timeline')add('.blu-timeline__item',(el,i)=>options.timelineKeys?.()[i]||el.querySelector('h3')?.textContent||identity(el));
|
|
20
|
+
if(kind==='transfer')add('.blu-transfer__row',el=>el.dataset.key||el.querySelector('.blu-transfer__label')?.textContent||identity(el));
|
|
21
|
+
if(kind==='chip')add('.blu-chip',el=>el.querySelector('.blu-chip__select,.blu-chip__label')?.textContent||identity(el),el=>String(el.classList.contains('blu-chip--selected')));
|
|
22
|
+
if(kind==='tree-select')add('.blu-tree-select__item[data-key] > .blu-tree-select__row',el=>el.parentElement!.dataset.key!);
|
|
23
|
+
if(kind==='cascader')add('.blu-cascader__column',(_,i)=>'column-'+i,el=>el.textContent||'');
|
|
24
|
+
if(kind==='tour')add('.blu-tour__panel',()=> 'tour-step',el=>el.textContent||'');
|
|
25
|
+
if(kind==='notification-center'){add('.blu-notification-center__list',()=> 'inbox',el=>el.textContent||'');add('.blu-notification-center__item',el=>el.querySelector('[data-focus]')?.getAttribute('data-focus')||identity(el),el=>el.className);}
|
|
26
|
+
if(kind==='skeleton')add('[aria-busy]',()=> 'loading-content',el=>el.getAttribute('aria-busy')||'');
|
|
27
|
+
const unique=new Map<string,Item>(),duplicates=new Set<string>();
|
|
28
|
+
for(const [key,item] of entries){if(unique.has(key))duplicates.add(key);unique.set(key,item);}
|
|
29
|
+
for(const key of duplicates)unique.delete(key); // Ambiguous labels never identify moving records.
|
|
30
|
+
return unique;
|
|
31
|
+
}
|
|
32
|
+
let before=read();
|
|
33
|
+
const capture=()=>{if(!frame)before=read();};
|
|
34
|
+
function reconcile(){frame=0;if(dead)return;const after=read();
|
|
35
|
+
for(const [key,item] of after){const old=before.get(key);if(!old){run(item.el,[{opacity:0},{opacity:1}],450,true);continue;}
|
|
36
|
+
const x=old.rect.left-item.rect.left,y=old.rect.top-item.rect.top;
|
|
37
|
+
if(kind!=='tour'&&(Math.abs(x)>1||Math.abs(y)>1))run(item.el,[{translate:`${x}px ${y}px`},{translate:'0px 0px'}]);
|
|
38
|
+
else if(old.signature!==item.signature)run(item.el,[{opacity:.55},{opacity:1}],450,true);
|
|
39
|
+
}
|
|
40
|
+
for(const el of active.keys())if(!root.contains(el)){motion.cancel(el);active.delete(el);}
|
|
41
|
+
before=after;
|
|
42
|
+
}
|
|
43
|
+
const observer=new MutationObserver(()=>{if(!frame)frame=requestAnimationFrame(reconcile);});
|
|
44
|
+
observer.observe(root,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','class','aria-selected','aria-checked','style']});
|
|
45
|
+
for(const event of ['click','change','input','keydown','pointerdown'])root.addEventListener(event,capture,true);
|
|
46
|
+
const previousKind=root.getAttribute('data-blu-content-motion');root.setAttribute('data-blu-content-motion',kind);
|
|
47
|
+
const surface=kind==='cascader'||kind==='tree-select'?observeSurface(root,motion,kind):undefined;
|
|
48
|
+
// Media/profile dialogs retain the real content on close. It is already paused
|
|
49
|
+
// by the component, inert and inaccessible during the short cosmetic fade.
|
|
50
|
+
const layers=new Map<HTMLElement,{open:boolean;revision:number;restore:()=>void}>();
|
|
51
|
+
function overlay(){for(const layer of root.querySelectorAll<HTMLElement>('.blu-overlay,.blu-tour')){
|
|
52
|
+
// Nested components own their own dialogs. Otherwise a collection and its
|
|
53
|
+
// profile card both snapshot temporary exit styles and restore display:flex
|
|
54
|
+
// after the hidden dialog finishes closing.
|
|
55
|
+
if(layer.closest('[data-blu-content-motion]')!==root)continue;
|
|
56
|
+
let state=layers.get(layer);if(!state){state={open:false,revision:0,restore:()=>{}};layers.set(layer,state);}
|
|
57
|
+
const open=!layer.hidden;if(open===state.open)continue;state.open=open;const revision=++state.revision;state.restore();motion.cancel(layer);
|
|
58
|
+
if(open){void motion.play(layer,'fade',{duration:450});continue;}
|
|
59
|
+
if(motion.mode==='off')continue;
|
|
60
|
+
const restore=preserveStyles(layer,['display','pointer-events']),inert=layer.inert,aria=layer.getAttribute('aria-hidden');
|
|
61
|
+
let timer:ReturnType<typeof setTimeout>|undefined;
|
|
62
|
+
state.restore=()=>{clearTimeout(timer);restore();layer.inert=inert;if(aria===null)layer.removeAttribute('aria-hidden');else layer.setAttribute('aria-hidden',aria);};
|
|
63
|
+
layer.inert=true;layer.setAttribute('aria-hidden','true');layer.style.setProperty('display','flex','important');layer.style.pointerEvents='none';
|
|
64
|
+
const finish=()=>{if(!dead&&state!.revision===revision){state!.restore();state!.restore=()=>{};}};
|
|
65
|
+
timer=setTimeout(finish,(motion.resolveOptions?.({duration:450}).duration??450)+100);
|
|
66
|
+
void motion.animate(layer,[{opacity:1},{opacity:0}],{duration:450,easing:'ease-in-out'},'fade').finally(finish);
|
|
67
|
+
}}
|
|
68
|
+
const overlayObserver=new MutationObserver(overlay);
|
|
69
|
+
if(['profile-card','video-lightbox','collection-layout','media-image','tour'].includes(kind)){overlayObserver.observe(root,{subtree:true,childList:true,attributes:true,attributeFilter:['hidden']});overlay();}
|
|
70
|
+
// Playback is never delayed or restarted; only the pause/notice presentation fades.
|
|
71
|
+
const mediaFeedback=(event:Event)=>{const video=event.target as HTMLVideoElement;if(video.tagName!=='VIDEO')return;queueMicrotask(()=>{if(dead||!root.contains(video))return;const player=video.closest('.blu-video-player');const glass=player?.querySelector<HTMLElement>('.blu-video-player__pause-glass');const feedback=video.paused&&glass&&!glass.hidden&&getComputedStyle(glass).display!=='none'?glass:player?.querySelector<HTMLElement>('.blu-video-player__primary');if(feedback)run(feedback,[{opacity:.4},{opacity:1}],450,true);});};
|
|
72
|
+
for(const event of ['play','pause','waiting','loadeddata'])root.addEventListener(event,mediaFeedback,true);
|
|
73
|
+
return {capture,destroy(){if(dead)return;dead=true;observer.disconnect();overlayObserver.disconnect();cancelAnimationFrame(frame);surface?.destroy();for(const [el,state] of layers){state.revision++;state.restore();motion.cancel(el);}for(const el of active.keys())motion.cancel(el);active.clear();for(const event of ['click','change','input','keydown','pointerdown'])root.removeEventListener(event,capture,true);if(previousKind===null)root.removeAttribute('data-blu-content-motion');else root.setAttribute('data-blu-content-motion',previousKind);for(const event of ['play','pause','waiting','loadeddata'])root.removeEventListener(event,mediaFeedback,true);}};
|
|
74
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
|
|
3
|
+
export interface MotionRow { key: string; element: HTMLElement; cells: HTMLElement[]; cellKeys: string[]; }
|
|
4
|
+
interface Snapshot { row: MotionRow; top: number; left: number; width: number; height: number; text: string[]; widths: number[]; }
|
|
5
|
+
/** Presentation only. Data, selection, sorting, paging and keyboard handling stay with the component. */
|
|
6
|
+
export function enhanceDataRows(root: HTMLElement, motion: MotionController, read: () => MotionRow[]) {
|
|
7
|
+
let disposed = false, frame = 0, settle = 0, generation = 0, scrollUntil = 0, unsupportedRows = false;
|
|
8
|
+
const owned = new Set<HTMLElement>(), exits = new Map<string, { element: HTMLElement; timer: ReturnType<typeof setTimeout> }>();
|
|
9
|
+
const previousPosition = root.style.position;
|
|
10
|
+
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
|
|
11
|
+
const layer = document.createElement('div'); layer.className = 'blu-data-motion-exits'; layer.inert = true; layer.setAttribute('aria-hidden', 'true');
|
|
12
|
+
root.append(layer);
|
|
13
|
+
function snapshot() {
|
|
14
|
+
const rows = read();
|
|
15
|
+
const origin = root.getBoundingClientRect();
|
|
16
|
+
// Bound work to rendered rows. Never enumerate an entire virtual data set.
|
|
17
|
+
unsupportedRows = rows.length > 80 || new Set(rows.map(row => row.key)).size !== rows.length;
|
|
18
|
+
if (unsupportedRows) return new Map<string, Snapshot>();
|
|
19
|
+
return new Map(rows.map(row => {
|
|
20
|
+
const r = row.element.getBoundingClientRect();
|
|
21
|
+
// Read layout coordinates, excluding our in-flight visual translation.
|
|
22
|
+
const translate = owned.has(row.element) ? getComputedStyle(row.element).translate.split(/\s+/).map(Number.parseFloat) : [];
|
|
23
|
+
return [row.key, { row, top: r.top-origin.top-(translate[1]||0), left: r.left-origin.left-(translate[0]||0), width: r.width, height: r.height,
|
|
24
|
+
text: row.cells.map(cell => cell.textContent || ''), widths: row.cells.map(cell => cell.getBoundingClientRect().width) }];
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
let before = snapshot();
|
|
28
|
+
function animate(el: HTMLElement, frames: Keyframe[], duration: number, fade = false) {
|
|
29
|
+
owned.add(el);
|
|
30
|
+
void motion.animate(el, frames, { duration, easing: 'ease-in-out' }, fade ? 'fade' : 'instant').then(() => { if (!el.getAnimations().length) owned.delete(el); });
|
|
31
|
+
}
|
|
32
|
+
function clearExits() {
|
|
33
|
+
for (const { element, timer } of exits.values()) { clearTimeout(timer); motion.cancel(element); owned.delete(element); element.remove(); }
|
|
34
|
+
exits.clear();
|
|
35
|
+
}
|
|
36
|
+
function capture() {
|
|
37
|
+
if (disposed) return;
|
|
38
|
+
if (frame) { cancelAnimationFrame(frame); frame = 0; reconcile(); }
|
|
39
|
+
before = snapshot();
|
|
40
|
+
}
|
|
41
|
+
function reconcile() {
|
|
42
|
+
if (disposed) return;
|
|
43
|
+
frame = 0;
|
|
44
|
+
const current = snapshot();
|
|
45
|
+
// Grid engines make additional style/scrollbar updates after sorting. Those
|
|
46
|
+
// must not cancel an entrance or reorder that is already playing.
|
|
47
|
+
const unchanged = current.size===before.size && [...current].every(([key,item])=>{
|
|
48
|
+
const old=before.get(key);return old && old.row.element===item.row.element && Math.abs(old.top-item.top)<1 && Math.abs(old.left-item.left)<1 && old.width===item.width && old.height===item.height && old.text.join('\u0000')===item.text.join('\u0000');
|
|
49
|
+
});
|
|
50
|
+
if(unchanged)return;
|
|
51
|
+
const revision = ++generation;
|
|
52
|
+
clearTimeout(settle); clearExits();
|
|
53
|
+
for (const el of owned) { motion.cancel(el); } owned.clear();
|
|
54
|
+
if (unsupportedRows || performance.now() < scrollUntil) { before = current; return; }
|
|
55
|
+
const bounds = root.getBoundingClientRect();
|
|
56
|
+
// Large page changes use a quiet entrance instead of dozens of crossing rows.
|
|
57
|
+
const modest = current.size <= 30 && before.size <= 30;
|
|
58
|
+
for (const [key, entry] of current) {
|
|
59
|
+
const old = before.get(key), el = entry.row.element;
|
|
60
|
+
if (!old) animate(el, [{ opacity: 0 }, { opacity: 1 }], 450, true);
|
|
61
|
+
else {
|
|
62
|
+
const dy = old.top - entry.top;
|
|
63
|
+
if (modest && Math.abs(dy) > 1 && Math.abs(dy) < bounds.height) {
|
|
64
|
+
// Individual translate composes with the grid engine's own transform.
|
|
65
|
+
animate(el, [{ translate: `0 ${dy}px` }, { translate: '0 0' }], 650);
|
|
66
|
+
}
|
|
67
|
+
entry.row.cells.forEach((cell, index) => {
|
|
68
|
+
const previousIndex = old.row.cellKeys.indexOf(entry.row.cellKeys[index]);
|
|
69
|
+
if (previousIndex >= 0 && old.text[previousIndex] !== entry.text[index]) {
|
|
70
|
+
const resting = getComputedStyle(cell).backgroundColor;
|
|
71
|
+
animate(cell, [{ backgroundColor: resting }, { backgroundColor: 'rgba(105, 88, 240, .18)', offset: .35 }, { backgroundColor: resting }], 1400);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (modest && motion.mode !== 'off') for (const [key, old] of before) {
|
|
77
|
+
if (current.has(key) || !old.width || old.top + old.height < 0 || old.top > bounds.height) continue;
|
|
78
|
+
// Text-only decoration: no copied IDs, controls, handlers or live regions.
|
|
79
|
+
const ghost = document.createElement('div'); ghost.className = 'blu-data-motion-exit';
|
|
80
|
+
ghost.style.cssText = `left:${old.left}px;top:${old.top}px;width:${old.width}px;height:${old.height}px;grid-template-columns:${old.widths.map(w => w+'px').join(' ')};`;
|
|
81
|
+
for (const text of old.text) { const cell = document.createElement('span'); cell.textContent = text; ghost.append(cell); }
|
|
82
|
+
layer.append(ghost);
|
|
83
|
+
const remove = () => { if (exits.get(key)?.element !== ghost) return; clearTimeout(exits.get(key)!.timer); motion.cancel(ghost); owned.delete(ghost); ghost.remove(); exits.delete(key); };
|
|
84
|
+
exits.set(key, { element: ghost, timer: setTimeout(remove, (motion.resolveOptions?.({duration:450}).duration ?? 450) + 250) }); owned.add(ghost);
|
|
85
|
+
void motion.play(ghost, 'fade', { phase: 'exit', duration: 450, easing: 'ease-in-out' }).then(remove);
|
|
86
|
+
}
|
|
87
|
+
before = current;
|
|
88
|
+
settle = window.setTimeout(() => { if (!disposed && generation === revision) before = snapshot(); }, (motion.resolveOptions?.({duration:650}).duration ?? 650) + 50);
|
|
89
|
+
}
|
|
90
|
+
function changed(records: MutationRecord[]) {
|
|
91
|
+
if (records.every(r => layer.contains(r.target) || r.target === layer)) return;
|
|
92
|
+
if (!frame) frame = requestAnimationFrame(reconcile);
|
|
93
|
+
}
|
|
94
|
+
const observer = new MutationObserver(changed);
|
|
95
|
+
observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['row-index', 'row-id', 'style'] });
|
|
96
|
+
// Capture the current visual positions before the existing click handler rerenders.
|
|
97
|
+
root.addEventListener('click', capture, true);
|
|
98
|
+
const scrollPositions = new WeakMap<Element, string>();
|
|
99
|
+
for(const el of [root,...root.querySelectorAll('*')])scrollPositions.set(el,`${el.scrollLeft},${el.scrollTop}`);
|
|
100
|
+
const scrolling = (event:Event) => { const el=event.target;if(!(el instanceof Element))return;const position=`${el.scrollLeft},${el.scrollTop}`,previous=scrollPositions.get(el)||'0,0';scrollPositions.set(el,position);if(previous===position)return;scrollUntil = performance.now() + 150; if (frame) cancelAnimationFrame(frame); frame = 0; clearExits(); for (const el of owned) motion.cancel(el); owned.clear(); before = snapshot(); };
|
|
101
|
+
root.addEventListener('scroll', scrolling, true);
|
|
102
|
+
return {
|
|
103
|
+
capture,
|
|
104
|
+
destroy() { if (disposed) return; disposed = true; observer.disconnect(); cancelAnimationFrame(frame); clearTimeout(settle); clearExits(); for (const el of owned) motion.cancel(el); owned.clear(); root.removeEventListener('click', capture, true); root.removeEventListener('scroll', scrolling, true); layer.remove(); root.style.position = previousPosition; },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
import { createPressFeedback } from '../motion/feedback.js';
|
|
3
|
+
|
|
4
|
+
/** Adds feedback to existing controls without replacing inputs or intercepting native events. */
|
|
5
|
+
export function enhanceField(root: HTMLElement, motion: MotionController) {
|
|
6
|
+
const previous = root.getAttribute('data-blu-component-motion'); root.setAttribute('data-blu-component-motion', 'field');
|
|
7
|
+
let destroyed = false, revision = 0, errorText = '', signature = '', height = root.getBoundingClientRect().height;
|
|
8
|
+
const buttons = new Map<HTMLButtonElement, ReturnType<typeof createPressFeedback>>();
|
|
9
|
+
const originalOverflow = root.style.overflow;
|
|
10
|
+
let nudgeTarget: HTMLElement | null = null;
|
|
11
|
+
function sync(initial = false) {
|
|
12
|
+
if (destroyed) return;
|
|
13
|
+
for (const [button, press] of buttons) if (!root.contains(button)) { press.destroy(); buttons.delete(button); }
|
|
14
|
+
for (const button of Array.from(root.querySelectorAll<HTMLButtonElement>('.blu-text__action'))) if (!buttons.has(button)) buttons.set(button, createPressFeedback(button, motion));
|
|
15
|
+
const error = root.querySelector<HTMLElement>('.blu-field__error'), next = error && !error.hidden ? error.textContent || '' : '';
|
|
16
|
+
const nextSignature = [root.textContent, next, root.querySelector('textarea')?.style.height].join('|');
|
|
17
|
+
if (!initial && nextSignature === signature) return;
|
|
18
|
+
signature = nextSignature;
|
|
19
|
+
const from = root.getBoundingClientRect().height;
|
|
20
|
+
motion.cancel(root); root.style.overflow = originalOverflow;
|
|
21
|
+
const to = root.getBoundingClientRect().height, own = ++revision;
|
|
22
|
+
if (!initial && height > 0 && to > 0 && Math.abs(height - to) > 1) {
|
|
23
|
+
root.style.overflow = 'visible';
|
|
24
|
+
void motion.animate(root, [{ height: from === to ? height + 'px' : from + 'px' }, { height: to + 'px' }], { duration: 280, easing: 'ease-in-out' }).finally(() => { if (!destroyed && own === revision) root.style.overflow = originalOverflow; });
|
|
25
|
+
}
|
|
26
|
+
height = to;
|
|
27
|
+
if (!initial && next && !errorText && error) {
|
|
28
|
+
void motion.play(error, 'fade', { duration: 350 });
|
|
29
|
+
nudgeTarget = root.querySelector<HTMLElement>('.blu-text, .blu-textarea, .blu-select__trigger, .blu-radio-group, .blu-choice');
|
|
30
|
+
if (nudgeTarget) void motion.animate(nudgeTarget, [
|
|
31
|
+
{ transform: 'translateX(0)' }, { transform: 'translateX(-3px)', offset: .22 },
|
|
32
|
+
{ transform: 'translateX(2px)', offset: .5 }, { transform: 'translateX(-1px)', offset: .75 },
|
|
33
|
+
{ transform: 'translateX(0)' },
|
|
34
|
+
], { duration: 550, easing: 'ease-in-out' });
|
|
35
|
+
}
|
|
36
|
+
if (!next && nudgeTarget) { motion.cancel(nudgeTarget); nudgeTarget = null; }
|
|
37
|
+
if (!next && error) motion.cancel(error);
|
|
38
|
+
errorText = next;
|
|
39
|
+
}
|
|
40
|
+
sync(true);
|
|
41
|
+
const observer = new MutationObserver(records => {
|
|
42
|
+
if (records.some(record => record.type === 'childList' || record.attributeName !== 'style' || (record.target as Element).matches('textarea'))) sync();
|
|
43
|
+
});
|
|
44
|
+
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden', 'aria-invalid', 'data-invalid', 'disabled', 'style'] });
|
|
45
|
+
return { sync: () => sync(), destroy() {
|
|
46
|
+
if (destroyed) return; destroyed = true; ++revision; observer.disconnect();
|
|
47
|
+
motion.cancel(root); root.style.overflow = originalOverflow; if (nudgeTarget) motion.cancel(nudgeTarget);
|
|
48
|
+
const error = root.querySelector<HTMLElement>('.blu-field__error'); if (error) motion.cancel(error);
|
|
49
|
+
for (const press of buttons.values()) press.destroy(); buttons.clear();
|
|
50
|
+
if (previous === null) root.removeAttribute('data-blu-component-motion'); else root.setAttribute('data-blu-component-motion', previous);
|
|
51
|
+
} };
|
|
52
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
import { preserveStyles } from '../motion/structure.js';
|
|
3
|
+
|
|
4
|
+
/** The existing Sidebar/Pagination still owns navigation, focus and events.
|
|
5
|
+
* This marker only follows aria-current; it never intercepts links or history. */
|
|
6
|
+
export function enhanceNavigation(root: HTMLElement, motion: MotionController, kind: 'sidebar' | 'pagination' | 'tabs') {
|
|
7
|
+
const win = root.ownerDocument.defaultView!, restore = preserveStyles(root, ['position']);
|
|
8
|
+
const originalKind = root.getAttribute('data-blu-navigation-motion'); root.setAttribute('data-blu-navigation-motion', kind);
|
|
9
|
+
const marker = root.ownerDocument.createElement('span');
|
|
10
|
+
marker.className = 'blu-motion-navigation-marker'; marker.dataset.kind = kind;
|
|
11
|
+
marker.setAttribute('aria-hidden', 'true'); marker.hidden = true;
|
|
12
|
+
root.style.position = 'relative'; root.append(marker);
|
|
13
|
+
let current: HTMLElement | null = null, key = '', geometry = '', dead = false, moving = false, generation = 0;
|
|
14
|
+
let range: HTMLElement | null = null, rangeText = '';
|
|
15
|
+
const stop = () => { ++generation; moving = false; motion.cancel(marker); };
|
|
16
|
+
function place(animate = true) {
|
|
17
|
+
if (dead) return;
|
|
18
|
+
// Sidebar renders replace its children, so keep our decoration outside that render.
|
|
19
|
+
if (!root.contains(marker)) root.append(marker);
|
|
20
|
+
const next = root.querySelector<HTMLElement>(kind === 'tabs' ? '[role=tab][aria-selected=true]' : '[aria-current="page"]')
|
|
21
|
+
|| (kind === 'pagination' ? root.querySelector<HTMLElement>('.blu-pagination__current') : null);
|
|
22
|
+
if (current !== next) { if (current) resize.unobserve(current); current = next; if (current) resize.observe(current); }
|
|
23
|
+
if (!next || !next.getClientRects().length) { if (!marker.hidden) marker.hidden = true; key = ''; geometry = ''; stop(); return; }
|
|
24
|
+
const nextKey = next.dataset.navigationKey || next.getAttribute('aria-label') || next.textContent || '';
|
|
25
|
+
const from = marker.hidden ? null : marker.getBoundingClientRect();
|
|
26
|
+
const parent = root.getBoundingClientRect(), target = next.getBoundingClientRect();
|
|
27
|
+
const x = target.left - parent.left + root.scrollLeft - root.clientLeft;
|
|
28
|
+
const y = target.top - parent.top + root.scrollTop - root.clientTop;
|
|
29
|
+
const nextGeometry = [x, y, target.width, target.height].join(',');
|
|
30
|
+
if (nextGeometry === geometry && nextKey === key && !marker.hidden) return;
|
|
31
|
+
// A page-size chooser can finish its layout after the selected page changes.
|
|
32
|
+
// Continue from the visible position instead of cancelling that transition.
|
|
33
|
+
const continueMove = moving, changed = key !== nextKey;
|
|
34
|
+
stop(); if (marker.hidden) marker.hidden = false;
|
|
35
|
+
Object.assign(marker.style, {
|
|
36
|
+
left: (x + (kind === 'tabs' ? 6 : 0)) + 'px',
|
|
37
|
+
top: (y + (kind === 'sidebar' ? 8 : kind === 'tabs' ? target.height - 3 : 0)) + 'px',
|
|
38
|
+
width: (kind === 'sidebar' ? 3 : kind === 'tabs' ? Math.max(1, target.width - 12) : target.width) + 'px',
|
|
39
|
+
height: (kind === 'sidebar' ? Math.max(1, target.height - 16) : kind === 'tabs' ? 3 : target.height) + 'px',
|
|
40
|
+
});
|
|
41
|
+
if ((animate && changed || continueMove) && from) {
|
|
42
|
+
const to = marker.getBoundingClientRect();
|
|
43
|
+
const own = ++generation; moving = true;
|
|
44
|
+
void motion.animate(marker, [{ transform: `translate(${from.left-to.left}px,${from.top-to.top}px) scale(${from.width/to.width},${from.height/to.height})` }, { transform: 'none' }], { duration: kind === 'pagination' ? 750 : 450, easing: 'ease-in-out' }).finally(() => { if (own === generation) moving = false; });
|
|
45
|
+
}
|
|
46
|
+
if (kind === 'pagination') {
|
|
47
|
+
const nextRange = root.querySelector<HTMLElement>('.blu-pagination__range');
|
|
48
|
+
if (range !== nextRange && range) motion.cancel(range);
|
|
49
|
+
const text = nextRange?.textContent || '';
|
|
50
|
+
if (animate && rangeText && text !== rangeText && nextRange) void motion.animate(nextRange, [{ opacity: .35 }, { opacity: 1 }], { duration: 750, easing: 'ease-in-out' }, 'fade');
|
|
51
|
+
range = nextRange; rangeText = text;
|
|
52
|
+
}
|
|
53
|
+
key = nextKey; geometry = nextGeometry;
|
|
54
|
+
}
|
|
55
|
+
const resize = new ResizeObserver(() => place(false));
|
|
56
|
+
const observer = new MutationObserver(() => place());
|
|
57
|
+
const onScroll = () => { stop(); place(false); };
|
|
58
|
+
place(false); resize.observe(root);
|
|
59
|
+
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-current', 'aria-selected', 'hidden'] });
|
|
60
|
+
root.addEventListener('scroll', onScroll, true); win.addEventListener('resize', onScroll);
|
|
61
|
+
return { destroy() { if (dead) return; dead = true; observer.disconnect(); resize.disconnect(); stop(); if(range)motion.cancel(range); marker.remove(); restore(); if (originalKind === null) root.removeAttribute('data-blu-navigation-motion'); else root.setAttribute('data-blu-navigation-motion', originalKind); root.removeEventListener('scroll', onScroll, true); win.removeEventListener('resize', onScroll); } };
|
|
62
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { MotionController } from '../motion/controller.js';
|
|
2
|
+
|
|
3
|
+
/** Observe the existing message stack. Its runtime still owns timers, pause on
|
|
4
|
+
* hover/focus, announcements and dismissal. Exits are decorative, inert copies. */
|
|
5
|
+
export function enhanceNotifications(root: HTMLElement, motion: MotionController) {
|
|
6
|
+
const previous = root.getAttribute('data-blu-component-motion'); root.setAttribute('data-blu-component-motion', 'notifications');
|
|
7
|
+
const cards = new Map<HTMLElement, { rect: DOMRect; opacity: string }>(), ghosts = new Set<HTMLElement>();
|
|
8
|
+
let dead = false, frame = 0, generation = 0;
|
|
9
|
+
const win = root.ownerDocument.defaultView!;
|
|
10
|
+
// Some engines emit focusin/out but do not support onfocusin/out properties.
|
|
11
|
+
// Forward only in those engines; the existing handlers own the timer state.
|
|
12
|
+
type FocusCard = HTMLElement & { onfocusin?: (event: FocusEvent) => void; onfocusout?: (event: FocusEvent) => void };
|
|
13
|
+
const forwardFocus = (event: FocusEvent) => {
|
|
14
|
+
const card = (event.target as Element).closest<FocusCard>('.blu-notifications > .blu-notification');
|
|
15
|
+
if (!card || !root.contains(card)) return;
|
|
16
|
+
if (event.type === 'focusin') card.onfocusin?.call(card, event); else card.onfocusout?.call(card, event);
|
|
17
|
+
};
|
|
18
|
+
const bridgeIn = !('onfocusin' in root), bridgeOut = !('onfocusout' in root);
|
|
19
|
+
if (bridgeIn) root.addEventListener('focusin', forwardFocus);
|
|
20
|
+
if (bridgeOut) root.addEventListener('focusout', forwardFocus);
|
|
21
|
+
const measure = () => { for (const [card, state] of cards) if (card.isConnected) { state.rect = card.getBoundingClientRect(); state.opacity = win.getComputedStyle(card).opacity; } };
|
|
22
|
+
const resize = new ResizeObserver(measure);
|
|
23
|
+
function exit(card: HTMLElement, state: { rect: DOMRect; opacity: string }) {
|
|
24
|
+
if (!state.rect.width || motion.mode === 'off' || root.closest('.blu-motion-off,[data-blu-motion=off]')) return;
|
|
25
|
+
const copy = card.cloneNode(true) as HTMLElement;
|
|
26
|
+
for (const el of [copy, ...copy.querySelectorAll<HTMLElement>('*')]) {
|
|
27
|
+
for (const attr of ['id','name','role','aria-live','aria-labelledby','aria-describedby','aria-controls','tabindex','autofocus']) el.removeAttribute(attr);
|
|
28
|
+
}
|
|
29
|
+
copy.dataset.bluExitVisual = 'notification'; copy.inert = true; copy.setAttribute('aria-hidden', 'true');
|
|
30
|
+
const rect = state.rect;
|
|
31
|
+
Object.assign(copy.style, { position: 'fixed', left: rect.left + 'px', top: rect.top + 'px', width: rect.width + 'px', height: rect.height + 'px', boxSizing: 'border-box', margin: '0', zIndex: '1300', pointerEvents: 'none', animation: 'none', opacity: state.opacity });
|
|
32
|
+
root.append(copy); ghosts.add(copy);
|
|
33
|
+
void motion.animate(copy, [{ opacity: state.opacity }, { opacity: 0 }], { phase: 'exit', duration: 450, easing: 'ease-in-out' }, 'fade').then(() => { copy.remove(); ghosts.delete(copy); });
|
|
34
|
+
}
|
|
35
|
+
function sync() {
|
|
36
|
+
if (dead) return;
|
|
37
|
+
const current = Array.from(root.querySelectorAll<HTMLElement>('.blu-notifications > .blu-notification'));
|
|
38
|
+
if (current.length === cards.size && current.every(card => cards.has(card))) return;
|
|
39
|
+
const own = ++generation; win.cancelAnimationFrame(frame);
|
|
40
|
+
const tasks: Promise<unknown>[] = [];
|
|
41
|
+
for (const [card, state] of cards) if (!current.includes(card)) { motion.cancel(card); resize.unobserve(card); cards.delete(card); exit(card, state); }
|
|
42
|
+
for (const card of current) {
|
|
43
|
+
const before = cards.get(card); motion.cancel(card);
|
|
44
|
+
const rect = card.getBoundingClientRect();
|
|
45
|
+
cards.set(card, { rect, opacity: win.getComputedStyle(card).opacity }); resize.observe(card);
|
|
46
|
+
if (!before) tasks.push(motion.play(card, 'slide-up', { duration: 450, easing: 'ease-out' }));
|
|
47
|
+
else {
|
|
48
|
+
const x = before.rect.left - rect.left, y = before.rect.top - rect.top;
|
|
49
|
+
if (Math.abs(x) > .5 || Math.abs(y) > .5 || Number(before.opacity) < .99) tasks.push(motion.animate(card, [{ transform: `translate(${x}px,${y}px)`, opacity: before.opacity }, { transform: 'none', opacity: 1 }], { duration: 450, easing: 'ease-in-out' }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const track = () => { if (dead || own !== generation) return; measure(); frame = win.requestAnimationFrame(track); };
|
|
53
|
+
frame = win.requestAnimationFrame(track);
|
|
54
|
+
void Promise.all(tasks).then(() => { if (!dead && own === generation) { win.cancelAnimationFrame(frame); measure(); } });
|
|
55
|
+
}
|
|
56
|
+
const observer = new MutationObserver(sync); observer.observe(root, { childList: true, subtree: true }); sync();
|
|
57
|
+
win.addEventListener('resize', measure); win.addEventListener('scroll', measure, true);
|
|
58
|
+
return { destroy() {
|
|
59
|
+
if (dead) return; dead = true; ++generation; observer.disconnect(); resize.disconnect(); win.cancelAnimationFrame(frame);
|
|
60
|
+
win.removeEventListener('resize', measure); win.removeEventListener('scroll', measure, true);
|
|
61
|
+
if (bridgeIn) root.removeEventListener('focusin', forwardFocus);
|
|
62
|
+
if (bridgeOut) root.removeEventListener('focusout', forwardFocus);
|
|
63
|
+
for (const card of cards.keys()) motion.cancel(card); cards.clear();
|
|
64
|
+
for (const ghost of ghosts) { motion.cancel(ghost); ghost.remove(); } ghosts.clear();
|
|
65
|
+
if (previous === null) root.removeAttribute('data-blu-component-motion'); else root.setAttribute('data-blu-component-motion', previous);
|
|
66
|
+
} };
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {MotionController,MotionOptions} from '../motion/controller.js';
|
|
2
|
+
import {createRipple} from '../motion/optional.js';
|
|
3
|
+
|
|
4
|
+
/** Decorate an actual open/close or selection change, after Select has updated
|
|
5
|
+
* its label. The component continues to own focus, keyboard and value events. */
|
|
6
|
+
export function enhanceSelectRipple(root:HTMLElement,motion:MotionController,timing:MotionOptions={}) {
|
|
7
|
+
let dead=false,frame=0,current:ReturnType<typeof createRipple>|undefined;
|
|
8
|
+
const state=()=>root.querySelector('.blu-select__trigger')?.getAttribute('aria-expanded')+'|'+root.querySelector<HTMLSelectElement>('.blu-select__native')?.value;
|
|
9
|
+
const activate=(event:Event)=>{
|
|
10
|
+
if(!(event.target instanceof Element)||!event.target.closest('.blu-select'))return;
|
|
11
|
+
const trigger=root.querySelector<HTMLButtonElement>('.blu-select__trigger');
|
|
12
|
+
if(!trigger||trigger.disabled||trigger.getAttribute('aria-readonly')==='true')return;
|
|
13
|
+
if(event instanceof KeyboardEvent&&!['Enter',' ','ArrowDown','ArrowUp','Home','End'].includes(event.key))return;
|
|
14
|
+
const before=state();
|
|
15
|
+
const point=event instanceof MouseEvent&&event.detail>0&&event.target.closest('.blu-select__trigger')?{clientX:event.clientX,clientY:event.clientY}:undefined;
|
|
16
|
+
cancelAnimationFrame(frame);frame=requestAnimationFrame(()=>{
|
|
17
|
+
frame=0;if(dead||!root.contains(trigger)||trigger.disabled||motion.mode!=='full'||state()===before)return;
|
|
18
|
+
current?.destroy();current=createRipple(trigger,motion,{duration:1000,easing:'ease-out',...timing,listen:false});current.play(point);
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
root.addEventListener('click',activate,true);root.addEventListener('keydown',activate,true);
|
|
22
|
+
return {destroy(){if(dead)return;dead=true;cancelAnimationFrame(frame);root.removeEventListener('click',activate,true);root.removeEventListener('keydown',activate,true);current?.destroy();}};
|
|
23
|
+
}
|