stimeo-ui 0.11.0 → 0.12.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/cable/index.js +23 -2
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/count_up_controller.d.ts +12 -10
  5. package/dist/controllers/count_up_controller.js +74 -35
  6. package/dist/controllers/count_up_controller.js.map +1 -1
  7. package/dist/controllers/intersection_controller.d.ts +20 -7
  8. package/dist/controllers/intersection_controller.js +55 -8
  9. package/dist/controllers/intersection_controller.js.map +1 -1
  10. package/dist/controllers/lazy_frame_controller.d.ts +31 -9
  11. package/dist/controllers/lazy_frame_controller.js +81 -19
  12. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  13. package/dist/controllers/pointer_drag_controller.js +4 -0
  14. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  15. package/dist/controllers/reading_progress_controller.d.ts +18 -9
  16. package/dist/controllers/reading_progress_controller.js +177 -6
  17. package/dist/controllers/reading_progress_controller.js.map +1 -1
  18. package/dist/controllers/scrollspy_controller.js +13 -2
  19. package/dist/controllers/scrollspy_controller.js.map +1 -1
  20. package/dist/controllers/smart_sticky_header_controller.d.ts +17 -8
  21. package/dist/controllers/smart_sticky_header_controller.js +52 -10
  22. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  23. package/dist/controllers/sortable_controller.d.ts +38 -8
  24. package/dist/controllers/sortable_controller.js +241 -67
  25. package/dist/controllers/sortable_controller.js.map +1 -1
  26. package/dist/controllers/sticky_observer_controller.js +13 -2
  27. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  28. package/dist/index.js +486 -145
  29. package/dist/index.js.map +1 -1
  30. package/dist/inspector/cli.js +1 -0
  31. package/dist/inspector/cli.js.map +1 -1
  32. package/dist/inspector/cli_bin.js +1 -0
  33. package/dist/inspector/cli_bin.js.map +1 -1
  34. package/dist/inspector/examples.json +1 -1
  35. package/dist/inspector/manifest.json +8 -28
  36. package/package.json +1 -1
@@ -1,19 +1,32 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/smart_sticky_header_controller.ts
4
+ var DEFAULT_OFFSET = 80;
4
5
  var SmartStickyHeaderController = class extends Controller {
5
6
  static values = {
6
7
  containerSelector: { type: String, default: "" },
7
- offset: { type: Number, default: 80 },
8
+ offset: { type: Number, default: DEFAULT_OFFSET },
8
9
  tolerance: { type: Number, default: 4 }
9
10
  };
10
11
  static events = ["change"];
12
+ #connected = false;
11
13
  #frame = null;
12
14
  /** The scroll source resolved at connect — disconnect must unbind the SAME node. */
13
15
  #scrollerEl = window;
16
+ /** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
17
+ #containerSelector = "";
14
18
  #lastY = 0;
15
19
  /** Last published state, so `change` fires only on transitions. */
16
20
  #hidden = null;
21
+ /**
22
+ * The depth that never hides. A declaration that is not a finite number reads
23
+ * as the default, so the comparison path never sees `NaN` — which would
24
+ * answer `false` to every comparison and hide the header inside the very
25
+ * zone the value exists to protect.
26
+ */
27
+ get #offset() {
28
+ return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
29
+ }
17
30
  #onScroll = () => {
18
31
  if (this.#frame !== null) return;
19
32
  this.#frame = requestAnimationFrame(() => {
@@ -26,15 +39,25 @@ var SmartStickyHeaderController = class extends Controller {
26
39
  * hold while focus *stays* inside is the `#apply` hide invariant.
27
40
  */
28
41
  #onFocusin = () => this.#apply(false);
42
+ /** Validates `containerSelector` once so connect never parses a selector that throws. */
43
+ containerSelectorValueChanged() {
44
+ this.#containerSelector = this.#validSelector(this.containerSelectorValue);
45
+ }
46
+ /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
47
+ offsetValueChanged() {
48
+ if (this.#connected) this.#measure();
49
+ }
29
50
  connect() {
30
51
  this.#hidden = null;
31
52
  this.#scrollerEl = this.#resolveScroller();
32
53
  this.#lastY = this.#scrollY;
33
54
  this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
34
55
  this.element.addEventListener("focusin", this.#onFocusin);
35
- this.#apply(false);
56
+ this.#apply(false, false);
57
+ this.#connected = true;
36
58
  }
37
59
  disconnect() {
60
+ this.#connected = false;
38
61
  this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
39
62
  this.element.removeEventListener("focusin", this.#onFocusin);
40
63
  if (this.#frame !== null) cancelAnimationFrame(this.#frame);
@@ -42,32 +65,51 @@ var SmartStickyHeaderController = class extends Controller {
42
65
  }
43
66
  /** Resolves the scroll source: the `containerSelector` match, else the window. */
44
67
  #resolveScroller() {
45
- if (this.containerSelectorValue) {
46
- const container = document.querySelector(this.containerSelectorValue);
68
+ if (this.#containerSelector) {
69
+ const container = document.querySelector(this.#containerSelector);
47
70
  if (container) return container;
48
71
  }
49
72
  return window;
50
73
  }
74
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
75
+ #validSelector(declared) {
76
+ if (declared.length > 0) {
77
+ try {
78
+ this.element.matches(declared);
79
+ return declared;
80
+ } catch {
81
+ }
82
+ }
83
+ return "";
84
+ }
51
85
  get #scrollY() {
52
86
  const scroller = this.#scrollerEl;
53
87
  return scroller === window ? window.scrollY : scroller.scrollTop;
54
88
  }
55
89
  #measure() {
56
90
  const y = this.#scrollY;
91
+ if (y <= this.#offset) {
92
+ this.#lastY = y;
93
+ this.#apply(false);
94
+ return;
95
+ }
57
96
  const delta = y - this.#lastY;
58
97
  if (Math.abs(delta) < this.toleranceValue) return;
59
98
  this.#lastY = y;
60
- if (y <= this.offsetValue) this.#apply(false);
61
- else if (delta > 0) this.#apply(true);
62
- else this.#apply(false);
99
+ this.#apply(delta > 0);
63
100
  }
64
- /** Reflects the state onto the hook and emits `change` on transitions. */
65
- #apply(hidden) {
101
+ /**
102
+ * Reflects the state onto the hook and emits `change` on transitions.
103
+ *
104
+ * @param notify - whether a transition announces itself. The reflection
105
+ * `connect()` performs is the current state, not a change.
106
+ */
107
+ #apply(hidden, notify = true) {
66
108
  if (hidden && this.element.contains(document.activeElement)) return;
67
109
  if (hidden === this.#hidden) return;
68
110
  this.#hidden = hidden;
69
111
  this.element.setAttribute("data-header-hidden", hidden ? "true" : "false");
70
- this.dispatch("change", { detail: { hidden } });
112
+ if (notify) this.dispatch("change", { detail: { hidden } });
71
113
  }
72
114
  };
73
115
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/smart_sticky_header_controller.ts"],"names":[],"mappings":";;;AAiCO,IAAM,2BAAA,GAAN,cAA0C,UAAA,CAAwB;AAAA,EACvE,OAAgB,MAAA,GAAS;AAAA,IACvB,iBAAA,EAAmB,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IAC/C,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACpC,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA;AAAE,GACxC;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAMzB,MAAA,GAAwB,IAAA;AAAA;AAAA,EAExB,WAAA,GAAoC,MAAA;AAAA,EACpC,MAAA,GAAS,CAAA;AAAA;AAAA,EAET,OAAA,GAA0B,IAAA;AAAA,EAEjB,YAAY,MAAY;AAC/B,IAAA,IAAI,IAAA,CAAK,WAAW,IAAA,EAAM;AAC1B,IAAA,IAAA,CAAK,MAAA,GAAS,sBAAsB,MAAM;AACxC,MAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AACd,MAAA,IAAA,CAAK,QAAA,EAAS;AAAA,IAChB,CAAC,CAAA;AAAA,EACH,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMS,UAAA,GAAa,MAAY,IAAA,CAAK,MAAA,CAAO,KAAK,CAAA;AAAA,EAE1C,OAAA,GAAgB;AAGvB,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,IAAA,CAAK,WAAA,GAAc,KAAK,gBAAA,EAAiB;AACzC,IAAA,IAAA,CAAK,SAAS,IAAA,CAAK,QAAA;AACnB,IAAA,IAAA,CAAK,WAAA,CAAY,iBAAiB,QAAA,EAAU,IAAA,CAAK,WAAW,EAAE,OAAA,EAAS,MAAM,CAAA;AAC7E,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,SAAA,EAAW,IAAA,CAAK,UAAU,CAAA;AACxD,IAAA,IAAA,CAAK,OAAO,KAAK,CAAA;AAAA,EACnB;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,WAAA,CAAY,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AAC7D,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,SAAA,EAAW,IAAA,CAAK,UAAU,CAAA;AAC3D,IAAA,IAAI,IAAA,CAAK,MAAA,KAAW,IAAA,EAAM,oBAAA,CAAqB,KAAK,MAAM,CAAA;AAC1D,IAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AAAA,EAChB;AAAA;AAAA,EAGA,gBAAA,GAAyC;AACvC,IAAA,IAAI,KAAK,sBAAA,EAAwB;AAC/B,MAAA,MAAM,SAAA,GAAY,QAAA,CAAS,aAAA,CAA2B,IAAA,CAAK,sBAAsB,CAAA;AACjF,MAAA,IAAI,WAAW,OAAO,SAAA;AAAA,IACxB;AACA,IAAA,OAAO,MAAA;AAAA,EACT;AAAA,EAEA,IAAI,QAAA,GAAmB;AACrB,IAAA,MAAM,WAAW,IAAA,CAAK,WAAA;AAEtB,IAAA,OAAO,QAAA,KAAa,MAAA,GAAS,MAAA,CAAO,OAAA,GAAW,QAAA,CAAyB,SAAA;AAAA,EAC1E;AAAA,EAEA,QAAA,GAAiB;AACf,IAAA,MAAM,IAAI,IAAA,CAAK,QAAA;AACf,IAAA,MAAM,KAAA,GAAQ,IAAI,IAAA,CAAK,MAAA;AACvB,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,KAAK,CAAA,GAAI,KAAK,cAAA,EAAgB;AAC3C,IAAA,IAAA,CAAK,MAAA,GAAS,CAAA;AAEd,IAAA,IAAI,CAAA,IAAK,IAAA,CAAK,WAAA,EAAa,IAAA,CAAK,OAAO,KAAK,CAAA;AAAA,SAAA,IACnC,KAAA,GAAQ,CAAA,EAAG,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAAA,SAC/B,IAAA,CAAK,OAAO,KAAK,CAAA;AAAA,EACxB;AAAA;AAAA,EAGA,OAAO,MAAA,EAAuB;AAM5B,IAAA,IAAI,UAAU,IAAA,CAAK,OAAA,CAAQ,QAAA,CAAS,QAAA,CAAS,aAAa,CAAA,EAAG;AAC7D,IAAA,IAAI,MAAA,KAAW,KAAK,OAAA,EAAS;AAC7B,IAAA,IAAA,CAAK,OAAA,GAAU,MAAA;AACf,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,oBAAA,EAAsB,MAAA,GAAS,SAAS,OAAO,CAAA;AACzE,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,MAAA,IAAU,CAAA;AAAA,EAChD;AACF","file":"smart_sticky_header_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **smart sticky header**: hides the header on scroll-down and\n * reveals it on scroll-up, published as a `data-header-hidden` hook the\n * consumer's CSS translates away (`stimeo--sticky-observer` detects *stuck*,\n * but has no direction sense; this adds it). Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--smart-sticky-header`):\n * <header data-controller=\"stimeo--smart-sticky-header\"\n * style=\"position: sticky; top: 0;\">…</header>\n *\n * The scroll source is the window by default; inside an overflow container,\n * point `containerSelector` at it (the header usually sits inside it too).\n * <!-- [data-header-hidden=\"true\"] { transform: translateY(-100%); } -->\n *\n * Scrolling down past `offset` px sets `data-header-hidden=\"true\"`; any\n * scroll-up (beyond the `tolerance` jitter guard) or returning above `offset`\n * reveals it again. Focus reaching the header always reveals it, and while\n * focus stays inside the header a scroll-down never hides it — a keyboard\n * user must be able to see where focus went AND keep seeing the element that\n * owns it (WCAG 2.4.7 / 2.4.11).\n *\n * `change` dispatches `{ hidden }`.\n *\n * @remarks\n * Behavior only — `position: sticky`, the translate animation, and\n * reduced-motion handling are the consumer's CSS (`prefers-reduced-motion`\n * should disable the transition, not the behavior). Scroll work is\n * rAF-throttled; listeners and any pending frame are released on\n * `disconnect()`, and `connect()` resets the transient hook (a Turbo cache\n * snapshot must not restore a hidden header at scroll top).\n */\nexport class SmartStickyHeaderController extends Controller<HTMLElement> {\n static override values = {\n containerSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 80 },\n tolerance: { type: Number, default: 4 },\n };\n static events = [\"change\"] as const;\n\n declare containerSelectorValue: string;\n declare offsetValue: number;\n declare toleranceValue: number;\n\n #frame: number | null = null;\n /** The scroll source resolved at connect — disconnect must unbind the SAME node. */\n #scrollerEl: HTMLElement | Window = window;\n #lastY = 0;\n /** Last published state, so `change` fires only on transitions. */\n #hidden: boolean | null = null;\n\n readonly #onScroll = (): void => {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n this.#measure();\n });\n };\n\n /**\n * Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the\n * hold while focus *stays* inside is the `#apply` hide invariant.\n */\n readonly #onFocusin = (): void => this.#apply(false);\n\n override connect(): void {\n // The hook is scroll-derived: recompute from the live scroll position\n // instead of trusting a cached snapshot (which may say hidden at y=0).\n this.#hidden = null;\n this.#scrollerEl = this.#resolveScroller();\n this.#lastY = this.#scrollY;\n this.#scrollerEl.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.element.addEventListener(\"focusin\", this.#onFocusin);\n this.#apply(false);\n }\n\n override disconnect(): void {\n this.#scrollerEl.removeEventListener(\"scroll\", this.#onScroll);\n this.element.removeEventListener(\"focusin\", this.#onFocusin);\n if (this.#frame !== null) cancelAnimationFrame(this.#frame);\n this.#frame = null;\n }\n\n /** Resolves the scroll source: the `containerSelector` match, else the window. */\n #resolveScroller(): HTMLElement | Window {\n if (this.containerSelectorValue) {\n const container = document.querySelector<HTMLElement>(this.containerSelectorValue);\n if (container) return container;\n }\n return window;\n }\n\n get #scrollY(): number {\n const scroller = this.#scrollerEl;\n // Identity check, not `instanceof Window` — cross-realm/test DOMs fail it.\n return scroller === window ? window.scrollY : (scroller as HTMLElement).scrollTop;\n }\n\n #measure(): void {\n const y = this.#scrollY;\n const delta = y - this.#lastY;\n if (Math.abs(delta) < this.toleranceValue) return;\n this.#lastY = y;\n\n if (y <= this.offsetValue) this.#apply(false);\n else if (delta > 0) this.#apply(true);\n else this.#apply(false);\n }\n\n /** Reflects the state onto the hook and emits `change` on transitions. */\n #apply(hidden: boolean): void {\n // Invariant: a header holding focus is never hidden — `focusin` revealed\n // it, and no transition may slide the focus owner away mid-interaction\n // (WCAG 2.4.7 / 2.4.11). Vetoed here at the single state-transition choke\n // point (not in each caller), so any future hide path — a timer, an\n // action — cannot bypass it. Reveals are never vetoed.\n if (hidden && this.element.contains(document.activeElement)) return;\n if (hidden === this.#hidden) return;\n this.#hidden = hidden;\n this.element.setAttribute(\"data-header-hidden\", hidden ? \"true\" : \"false\");\n this.dispatch(\"change\", { detail: { hidden } });\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/controllers/smart_sticky_header_controller.ts"],"names":[],"mappings":"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","file":"smart_sticky_header_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** The depth that never hides, used when `offset` is not a finite number. */\nconst DEFAULT_OFFSET = 80;\n\n/**\n * Headless **smart sticky header**: hides the header on scroll-down and\n * reveals it on scroll-up, published as a `data-header-hidden` hook the\n * consumer's CSS translates away (`stimeo--sticky-observer` detects *stuck*,\n * but has no direction sense; this adds it). Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--smart-sticky-header`):\n * <header data-controller=\"stimeo--smart-sticky-header\"\n * style=\"position: sticky; top: 0;\">…</header>\n *\n * The scroll source is the window by default; inside an overflow container,\n * point `containerSelector` at it (the header usually sits inside it too). A\n * declaration that cannot be parsed as a selector reads as the window, so a\n * typo costs the container binding rather than the whole header.\n * <!-- [data-header-hidden=\"true\"] { transform: translateY(-100%); } -->\n *\n * Scrolling down past `offset` px sets `data-header-hidden=\"true\"`, and a\n * scroll-up beyond the `tolerance` jitter guard reveals it again. Within the\n * `offset` zone the header is always revealed, decided ahead of that jitter\n * guard: a header stranded off-screen near the top cannot be scrolled back\n * into view. Focus reaching the header always reveals it, and while focus\n * stays inside the header a scroll-down never hides it — a keyboard user must\n * be able to see where focus went AND keep seeing the element that owns it\n * (WCAG 2.4.7 / 2.4.11).\n *\n * `change` dispatches `{ hidden }` on transitions only: the reflection\n * `connect()` performs is the current state rather than a change.\n *\n * @remarks\n * Behavior only — `position: sticky`, the translate animation, and\n * reduced-motion handling are the consumer's CSS (`prefers-reduced-motion`\n * should disable the transition, not the behavior). Scroll work is\n * rAF-throttled; listeners and any pending frame are released on\n * `disconnect()`, and `connect()` resets the transient hook (a Turbo cache\n * snapshot must not restore a hidden header at scroll top).\n */\nexport class SmartStickyHeaderController extends Controller<HTMLElement> {\n static override values = {\n containerSelector: { type: String, default: \"\" },\n offset: { type: Number, default: DEFAULT_OFFSET },\n tolerance: { type: Number, default: 4 },\n };\n static events = [\"change\"] as const;\n\n declare containerSelectorValue: string;\n declare offsetValue: number;\n declare toleranceValue: number;\n\n #connected = false;\n #frame: number | null = null;\n /** The scroll source resolved at connect — disconnect must unbind the SAME node. */\n #scrollerEl: HTMLElement | Window = window;\n /** The validated `containerSelector`, or `\"\"` when the declaration cannot be parsed. */\n #containerSelector = \"\";\n #lastY = 0;\n /** Last published state, so `change` fires only on transitions. */\n #hidden: boolean | null = null;\n\n /**\n * The depth that never hides. A declaration that is not a finite number reads\n * as the default, so the comparison path never sees `NaN` — which would\n * answer `false` to every comparison and hide the header inside the very\n * zone the value exists to protect.\n */\n get #offset(): number {\n return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;\n }\n\n readonly #onScroll = (): void => {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n this.#measure();\n });\n };\n\n /**\n * Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the\n * hold while focus *stays* inside is the `#apply` hide invariant.\n */\n readonly #onFocusin = (): void => this.#apply(false);\n\n /** Validates `containerSelector` once so connect never parses a selector that throws. */\n containerSelectorValueChanged(): void {\n this.#containerSelector = this.#validSelector(this.containerSelectorValue);\n }\n\n /** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */\n offsetValueChanged(): void {\n if (this.#connected) this.#measure();\n }\n\n override connect(): void {\n // The hook is scroll-derived: recompute from the live scroll position\n // instead of trusting a cached snapshot (which may say hidden at y=0).\n this.#hidden = null;\n this.#scrollerEl = this.#resolveScroller();\n this.#lastY = this.#scrollY;\n this.#scrollerEl.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.element.addEventListener(\"focusin\", this.#onFocusin);\n this.#apply(false, false);\n this.#connected = true;\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#scrollerEl.removeEventListener(\"scroll\", this.#onScroll);\n this.element.removeEventListener(\"focusin\", this.#onFocusin);\n if (this.#frame !== null) cancelAnimationFrame(this.#frame);\n this.#frame = null;\n }\n\n /** Resolves the scroll source: the `containerSelector` match, else the window. */\n #resolveScroller(): HTMLElement | Window {\n if (this.#containerSelector) {\n const container = document.querySelector<HTMLElement>(this.#containerSelector);\n if (container) return container;\n }\n return window;\n }\n\n /** Returns `declared` when it parses as a selector, and `\"\"` when it does not. */\n #validSelector(declared: string): string {\n if (declared.length > 0) {\n try {\n this.element.matches(declared);\n return declared;\n } catch {\n // Unparsable selector: fall through to the default below.\n }\n }\n return \"\";\n }\n\n get #scrollY(): number {\n const scroller = this.#scrollerEl;\n // Identity check, not `instanceof Window` — cross-realm/test DOMs fail it.\n return scroller === window ? window.scrollY : (scroller as HTMLElement).scrollTop;\n }\n\n #measure(): void {\n const y = this.#scrollY;\n // The offset zone decides before the jitter guard: a move small enough to\n // be jitter can still be the one that re-enters the zone, and a header\n // left hidden there cannot be scrolled back into view.\n if (y <= this.#offset) {\n this.#lastY = y;\n this.#apply(false);\n return;\n }\n\n const delta = y - this.#lastY;\n if (Math.abs(delta) < this.toleranceValue) return;\n this.#lastY = y;\n this.#apply(delta > 0);\n }\n\n /**\n * Reflects the state onto the hook and emits `change` on transitions.\n *\n * @param notify - whether a transition announces itself. The reflection\n * `connect()` performs is the current state, not a change.\n */\n #apply(hidden: boolean, notify = true): void {\n // Invariant: a header holding focus is never hidden — `focusin` revealed\n // it, and no transition may slide the focus owner away mid-interaction\n // (WCAG 2.4.7 / 2.4.11). Vetoed here at the single state-transition choke\n // point (not in each caller), so any future hide path — a timer, an\n // action — cannot bypass it. Reveals are never vetoed.\n if (hidden && this.element.contains(document.activeElement)) return;\n if (hidden === this.#hidden) return;\n this.#hidden = hidden;\n this.element.setAttribute(\"data-header-hidden\", hidden ? \"true\" : \"false\");\n if (notify) this.dispatch(\"change\", { detail: { hidden } });\n }\n}\n"]}
@@ -7,7 +7,7 @@ import { Controller } from '@hotwired/stimulus';
7
7
  * layer keeps its job: `pointer-drag` (on every item) emits the normalized drag
8
8
  * signal with its built-in keyboard alternative, `roving` (on the list) keeps
9
9
  * the handles a single Tab stop, and this controller interprets the signal —
10
- * live-reordering the DOM, announcing each step through a `status` live region,
10
+ * live-reordering the DOM, handing each step to the page's shared announcer,
11
11
  * and reporting the final `reorder`. No dedicated APG pattern exists for
12
12
  * drag-and-drop; the keyboard model (grab → arrows → drop / Escape) comes from
13
13
  * `pointer-drag` and the announcements make it non-visually trackable
@@ -27,7 +27,6 @@ import { Controller } from '@hotwired/stimulus';
27
27
  * </li>
28
28
  * …
29
29
  * </ul>
30
- * <span role="status" data-stimeo--sortable-target="status"></span>
31
30
  * </div>
32
31
  *
33
32
  * The composed values must follow the sort axis: roving's `orientation`
@@ -41,10 +40,11 @@ import { Controller } from '@hotwired/stimulus';
41
40
  * `dir="rtl"` reverses both, since DOM order then runs right-to-left while
42
41
  * `pointer-drag` reports physical coordinates. Dropping dispatches
43
42
  * `reorder` (`{ item, from, to }`, zero-based) when the position changed;
44
- * Escape / `pointercancel` restores the pickup position. Every step is mirrored
45
- * into the `status` live region (localizable via `data-grabbed` / `data-moved`
46
- * / `data-dropped` / `data-canceled` templates with `%{name}` / `%{position}` /
47
- * `%{total}` placeholders; terse English is the fallback).
43
+ * Escape / `pointercancel` restores the pickup position. Every step is handed to
44
+ * the page's shared `stimeo--announcer` as the consumer's own wording —
45
+ * `announceGrabbedText` / `announceMovedText` / `announceDroppedText` /
46
+ * `announceCanceledText`, with `{name}` / `{position}` / `{total}` placeholders —
47
+ * and an unset one announces nothing.
48
48
  *
49
49
  * @remarks
50
50
  * Behavior only — the ghost/placeholder/drop-hint visuals are the consumer's
@@ -67,16 +67,46 @@ declare class SortableController extends Controller<HTMLElement> {
67
67
  type: StringConstructor;
68
68
  default: string;
69
69
  };
70
+ announceGrabbedText: {
71
+ type: StringConstructor;
72
+ default: string;
73
+ };
74
+ announceMovedText: {
75
+ type: StringConstructor;
76
+ default: string;
77
+ };
78
+ announceDroppedText: {
79
+ type: StringConstructor;
80
+ default: string;
81
+ };
82
+ announceCanceledText: {
83
+ type: StringConstructor;
84
+ default: string;
85
+ };
70
86
  };
71
87
  static events: readonly ["reorder"];
72
88
  readonly hasListTarget: boolean;
73
89
  readonly listTarget: HTMLElement;
74
90
  readonly itemTargets: HTMLElement[];
75
- readonly hasStatusTarget: boolean;
76
- readonly statusTarget: HTMLElement;
77
91
  orientationValue: string;
92
+ announceGrabbedTextValue: string;
93
+ announceMovedTextValue: string;
94
+ announceDroppedTextValue: string;
95
+ announceCanceledTextValue: string;
78
96
  connect(): void;
79
97
  disconnect(): void;
98
+ /**
99
+ * Ends a session whose item left the item set.
100
+ *
101
+ * The controller's own reorder detaches and reattaches the item inside one
102
+ * mutation batch, so the loss is only real once the batch has settled — the
103
+ * check therefore runs a microtask later and asks whether the item is a target
104
+ * again. Without it a deleted row (a broadcast that drops it from the board)
105
+ * or a morph that strips the item's target attribute would hold the
106
+ * one-at-a-time session for the rest of the page's life, with the root hook
107
+ * stuck on and every later grab refused with no way out.
108
+ */
109
+ itemTargetDisconnected(item: HTMLElement): void;
80
110
  }
81
111
 
82
112
  export { SortableController };
@@ -2,21 +2,78 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/sortable_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
5
22
  // src/utils/logical_scroll.ts
6
23
  function isRtl(element) {
7
24
  return window.getComputedStyle(element).direction === "rtl";
8
25
  }
9
26
 
27
+ // src/utils/microtask_coalescer.ts
28
+ var MicrotaskCoalescer = class {
29
+ #run;
30
+ #queued = false;
31
+ #active = false;
32
+ #generation = 0;
33
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
34
+ constructor(run) {
35
+ this.#run = run;
36
+ }
37
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
38
+ activate() {
39
+ this.#active = true;
40
+ }
41
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
42
+ cancel() {
43
+ this.#active = false;
44
+ this.#queued = false;
45
+ this.#generation += 1;
46
+ }
47
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
48
+ schedule() {
49
+ if (!this.#active || this.#queued) return;
50
+ this.#queued = true;
51
+ const generation = this.#generation;
52
+ queueMicrotask(() => {
53
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
54
+ this.#queued = false;
55
+ this.#run();
56
+ });
57
+ }
58
+ };
59
+
10
60
  // src/controllers/sortable_controller.ts
11
61
  var SortableController = class extends Controller {
12
- static targets = ["list", "item", "status"];
62
+ static targets = ["list", "item"];
13
63
  static values = {
14
- orientation: { type: String, default: "vertical" }
64
+ orientation: { type: String, default: "vertical" },
65
+ announceGrabbedText: { type: String, default: "" },
66
+ announceMovedText: { type: String, default: "" },
67
+ announceDroppedText: { type: String, default: "" },
68
+ announceCanceledText: { type: String, default: "" }
15
69
  };
16
70
  static events = ["reorder"];
17
71
  #session = null;
72
+ /** Defers the lost-item check to after the mutation batch (see below). */
73
+ #settle = new MicrotaskCoalescer(() => this.#dropLostSession());
18
74
  connect() {
19
75
  this.element.removeAttribute("data-sortable-dragging");
76
+ this.#settle.activate();
20
77
  this.element.addEventListener("stimeo--pointer-drag:start", this.#onDragStart);
21
78
  this.element.addEventListener("stimeo--pointer-drag:move", this.#onDragMove);
22
79
  this.element.addEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
@@ -27,49 +84,121 @@ var SortableController = class extends Controller {
27
84
  this.element.removeEventListener("stimeo--pointer-drag:move", this.#onDragMove);
28
85
  this.element.removeEventListener("stimeo--pointer-drag:end", this.#onDragEnd);
29
86
  this.element.removeEventListener("stimeo--pointer-drag:cancel", this.#onDragCancel);
87
+ this.#settle.cancel();
30
88
  this.#session = null;
31
89
  this.element.removeAttribute("data-sortable-dragging");
32
90
  }
33
- /** Picks the item up: remembers its origin and announces the grab. */
91
+ /**
92
+ * Ends a session whose item left the item set.
93
+ *
94
+ * The controller's own reorder detaches and reattaches the item inside one
95
+ * mutation batch, so the loss is only real once the batch has settled — the
96
+ * check therefore runs a microtask later and asks whether the item is a target
97
+ * again. Without it a deleted row (a broadcast that drops it from the board)
98
+ * or a morph that strips the item's target attribute would hold the
99
+ * one-at-a-time session for the rest of the page's life, with the root hook
100
+ * stuck on and every later grab refused with no way out.
101
+ */
102
+ itemTargetDisconnected(item) {
103
+ if (this.#session?.item !== item) return;
104
+ this.#settle.schedule();
105
+ }
106
+ #dropLostSession() {
107
+ const session = this.#session;
108
+ if (!session) return;
109
+ const items = this.#items();
110
+ if (items.includes(session.item)) return;
111
+ this.#session = null;
112
+ this.element.removeAttribute("data-sortable-dragging");
113
+ if (session.item.isConnected) this.#restore(session, items);
114
+ }
115
+ /** Picks the item up: remembers its neighbours and announces the grab. */
34
116
  #onDragStart = (event) => {
35
117
  if (this.#session) return;
36
- const item = this.#itemFor(event.target);
118
+ const items = this.#items();
119
+ const item = this.#itemFor(event.target, items);
37
120
  if (!item) return;
38
- this.#session = { item, from: this.#items().indexOf(item), lastPrimary: 0 };
121
+ const index = items.indexOf(item);
122
+ this.#session = {
123
+ item,
124
+ anchor: items[index + 1] ?? null,
125
+ predecessor: items[index - 1] ?? null,
126
+ lastPrimary: 0
127
+ };
39
128
  this.element.setAttribute("data-sortable-dragging", "true");
40
129
  this.#announce("grabbed", item);
41
130
  };
42
131
  #onDragMove = (event) => {
43
132
  const session = this.#session;
133
+ if (!session) return;
134
+ const items = this.#items();
135
+ if (this.#itemFor(event.target, items) !== session.item) return;
44
136
  const detail = event.detail;
45
- if (!session || this.#itemFor(event.target) !== session.item) return;
46
137
  if (detail.pointerType === "keyboard") {
47
- this.#stepFromKeyboard(session, detail);
138
+ this.#stepFromKeyboard(session, detail, items);
48
139
  } else {
49
- this.#followPointer(session, detail);
140
+ this.#followPointer(session, detail, items);
50
141
  }
51
142
  };
52
143
  /** Drops the item: announces, then reports `reorder` if the position changed. */
53
144
  #onDragEnd = (event) => {
54
145
  const session = this.#session;
55
- if (!session || this.#itemFor(event.target) !== session.item) return;
146
+ if (!session) return;
147
+ const items = this.#items();
148
+ if (this.#itemFor(event.target, items) !== session.item) return;
56
149
  this.#session = null;
57
150
  this.element.removeAttribute("data-sortable-dragging");
58
151
  this.#announce("dropped", session.item);
59
- const to = this.#items().indexOf(session.item);
60
- if (to !== session.from) {
61
- this.dispatch("reorder", { detail: { item: session.item, from: session.from, to } });
152
+ const to = items.indexOf(session.item);
153
+ const from = this.#pickupSlot(session, items) ?? to;
154
+ if (to !== from) {
155
+ this.dispatch("reorder", { detail: { item: session.item, from, to } });
62
156
  }
63
157
  };
64
158
  /** Restores the pickup position (Escape / OS `pointercancel`). */
65
159
  #onDragCancel = (event) => {
66
160
  const session = this.#session;
67
- if (!session || this.#itemFor(event.target) !== session.item) return;
161
+ if (!session) return;
162
+ const items = this.#items();
163
+ if (this.#itemFor(event.target, items) !== session.item) return;
68
164
  this.#session = null;
69
165
  this.element.removeAttribute("data-sortable-dragging");
70
- this.#moveTo(session.item, session.from);
166
+ this.#restore(session, items);
71
167
  this.#announce("canceled", session.item);
72
168
  };
169
+ /**
170
+ * The slot the item was picked up from, read back through the neighbours it
171
+ * had then — `null` when neither survives and the item itself is gone.
172
+ *
173
+ * The anchor is the item that followed it, so restoring means "before that one
174
+ * again". When the anchor was deleted mid-drag the predecessor answers the
175
+ * same question from the other side. With both gone the item's current slot is
176
+ * the honest answer: nothing is known to have moved, so no reorder is reported
177
+ * and a cancel leaves the item where it is.
178
+ */
179
+ #pickupSlot(session, items) {
180
+ const others = items.filter((candidate) => candidate !== session.item);
181
+ if (session.anchor) {
182
+ const at = others.indexOf(session.anchor);
183
+ if (at !== -1) return at;
184
+ }
185
+ if (session.predecessor) {
186
+ const at = others.indexOf(session.predecessor);
187
+ if (at !== -1) return at + 1;
188
+ }
189
+ const here = items.indexOf(session.item);
190
+ return here === -1 ? null : here;
191
+ }
192
+ /** Puts the item back where it was picked up from. */
193
+ #restore(session, items) {
194
+ const slot = this.#pickupSlot(session, items);
195
+ if (slot === null) return;
196
+ this.#insertAt(
197
+ session.item,
198
+ items.filter((candidate) => candidate !== session.item),
199
+ slot
200
+ );
201
+ }
73
202
  /**
74
203
  * Keyboard stepping: `pointer-drag` reports *cumulative* synthetic deltas, so
75
204
  * the difference from the last consumed value is one arrow press — its sign is
@@ -81,77 +210,116 @@ var SortableController = class extends Controller {
81
210
  * `roving` already moves focus logically, so the same arrow would send the
82
211
  * focus and the grabbed item opposite ways.
83
212
  */
84
- #stepFromKeyboard(session, detail) {
213
+ #stepFromKeyboard(session, detail, items) {
85
214
  const primary = Number(this.#isVertical ? detail.dy : detail.dx) || 0;
86
215
  const delta = primary - session.lastPrimary;
87
216
  session.lastPrimary = primary;
88
217
  if (delta === 0) return;
89
- const items = this.#items();
90
218
  const index = items.indexOf(session.item);
91
219
  const step = (delta > 0 ? 1 : -1) * (this.#isReversed ? -1 : 1);
92
220
  const next = Math.max(0, Math.min(index + step, items.length - 1));
93
221
  if (next === index) return;
94
- this.#moveTo(session.item, next);
222
+ this.#insertAt(
223
+ session.item,
224
+ items.filter((candidate) => candidate !== session.item),
225
+ next
226
+ );
95
227
  this.#announce("moved", session.item);
96
228
  }
97
229
  /**
98
230
  * Pointer following: the item moves to the slot whose siblings' midpoints the
99
- * pointer has passed (per `orientation`). Skipped when the list has no layout
100
- * geometry (every rect is zero — nothing meaningful to compare against).
231
+ * pointer has passed (per `orientation`).
232
+ *
233
+ * Only siblings that occupy space take part. A row with no layout box — a
234
+ * filtered-out item, a `display: none` ancestor, a collapsed `<details>` — is
235
+ * reported with an empty rect at the document origin, and its midpoint of `0`
236
+ * sits below every pointer position: counted, it would read as passed on the
237
+ * very first move and send the item across a slot the pointer never crossed.
238
+ * With no laid-out sibling at all there is nothing to compare against and the
239
+ * move is skipped entirely.
101
240
  */
102
- #followPointer(session, detail) {
241
+ #followPointer(session, detail, items) {
103
242
  const pointer = Number(this.#isVertical ? detail.y : detail.x) || 0;
104
- const others = this.#items().filter((item) => item !== session.item);
105
- if (others.length === 0) return;
106
- let laidOut = false;
107
- let target = 0;
243
+ const vertical = this.#isVertical;
108
244
  const reversed = this.#isReversed;
109
- for (const other of others) {
110
- const rect = other.getBoundingClientRect();
111
- if (rect.width > 0 || rect.height > 0) laidOut = true;
112
- const midpoint = this.#isVertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
113
- const precedes = reversed ? pointer < midpoint : pointer > midpoint;
114
- if (precedes) target += 1;
115
- }
116
- if (!laidOut) return;
117
- const current = this.#items().indexOf(session.item);
118
- if (target !== current) {
119
- this.#moveTo(session.item, target);
120
- this.#announce("moved", session.item);
121
- }
245
+ const here = items.indexOf(session.item);
246
+ const laidOut = [];
247
+ let target = 0;
248
+ let current = 0;
249
+ items.forEach((item, index) => {
250
+ if (item === session.item) return;
251
+ const rect = item.getBoundingClientRect();
252
+ if (rect.width === 0 && rect.height === 0) return;
253
+ if (index < here) current += 1;
254
+ laidOut.push(item);
255
+ const midpoint = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
256
+ if (reversed ? pointer < midpoint : pointer > midpoint) target += 1;
257
+ });
258
+ if (laidOut.length === 0 || target === current) return;
259
+ this.#insertAt(session.item, laidOut, target);
260
+ this.#announce("moved", session.item);
122
261
  }
123
- /** Reinserts `item` so it lands at `index` among the list's items. */
124
- #moveTo(item, index) {
125
- const others = this.#items().filter((candidate) => candidate !== item);
126
- const clamped = Math.max(0, Math.min(index, others.length));
127
- const reference = others[clamped] ?? null;
262
+ /**
263
+ * Reinserts `item` at `index` among `scope`, relative to the sibling already
264
+ * standing there.
265
+ *
266
+ * The reorder is defined against the items, not against a container: the
267
+ * neighbour's own parent is where the item belongs. A `list` target that is
268
+ * not the items' parent — or none at all, which the markup contract allows —
269
+ * therefore still lands the move in the right place instead of throwing, and
270
+ * the last slot is *after the last item* rather than after whatever else the
271
+ * container holds (a live region, a footer), which would put the row outside
272
+ * the reading order the list publishes.
273
+ */
274
+ #insertAt(item, scope, index) {
275
+ const clamped = Math.max(0, Math.min(index, scope.length));
276
+ const ahead = scope[clamped] ?? null;
277
+ const behind = ahead ? null : scope[scope.length - 1] ?? null;
278
+ const neighbour = ahead ?? behind;
279
+ if (!neighbour) return;
280
+ const parent = neighbour.parentNode;
281
+ if (!parent) return;
128
282
  const active = document.activeElement;
129
283
  const hadFocus = active instanceof HTMLElement && item.contains(active);
130
- this.#list.insertBefore(item, reference);
284
+ parent.insertBefore(item, ahead ?? neighbour.nextSibling);
131
285
  if (hadFocus) active.focus();
132
286
  }
133
287
  /**
134
- * Mirrors a step into the `status` live region. Copy is localizable through
135
- * `data-grabbed` / `data-moved` / `data-dropped` / `data-canceled` templates on
136
- * the status element (`%{name}` / `%{position}` / `%{total}` placeholders);
137
- * terse English is the fallback.
288
+ * Hands one step to the page's shared announcer.
289
+ *
290
+ * The library carries no live region and no English copy: the wording is the
291
+ * consumer's, written into `announceGrabbedText` / `announceMovedText` /
292
+ * `announceDroppedText` / `announceCanceledText` with `{name}` / `{position}` /
293
+ * `{total}` placeholders, and an unset one announces nothing.
294
+ *
295
+ * Only transitions reach here — the pickup, a step that actually changed the
296
+ * landing slot, and the single end of the session — so a pointer crossing the
297
+ * same slot twice or an arrow clamped at an end stays silent.
138
298
  */
139
299
  #announce(key, item) {
140
- if (!this.hasStatusTarget) return;
141
- const position = String(this.#items().indexOf(item) + 1);
142
- const total = String(this.#items().length);
143
- const name = this.#nameOf(item);
144
- const fallback = {
145
- grabbed: `Grabbed ${name}, position ${position} of ${total}`,
146
- moved: `${name}, position ${position} of ${total}`,
147
- dropped: `Dropped ${name} at position ${position} of ${total}`,
148
- canceled: `Reorder canceled, ${name} returned to position ${position} of ${total}`
149
- };
150
- const values = { name, position, total };
151
- const template = this.statusTarget.dataset[key];
152
- this.statusTarget.textContent = template ? template.replace(/%\{(name|position|total)\}/g, (match, token) => {
153
- return values[token] ?? match;
154
- }) : fallback[key];
300
+ const template = this.#announceTemplate(key);
301
+ if (template.length === 0) return;
302
+ const items = this.#items();
303
+ announce(
304
+ fillTemplate(template, {
305
+ name: this.#nameOf(item),
306
+ position: items.indexOf(item) + 1,
307
+ total: items.length
308
+ })
309
+ );
310
+ }
311
+ /** The consumer's wording for one step, or `""` when they authored none. */
312
+ #announceTemplate(key) {
313
+ switch (key) {
314
+ case "grabbed":
315
+ return this.announceGrabbedTextValue;
316
+ case "moved":
317
+ return this.announceMovedTextValue;
318
+ case "dropped":
319
+ return this.announceDroppedTextValue;
320
+ case "canceled":
321
+ return this.announceCanceledTextValue;
322
+ }
155
323
  }
156
324
  /** The announced item name: the authored override, else its collapsed text. */
157
325
  #nameOf(item) {
@@ -159,11 +327,17 @@ var SortableController = class extends Controller {
159
327
  if (authored) return authored;
160
328
  return (item.textContent ?? "").replace(/\s+/g, " ").trim();
161
329
  }
162
- /** Resolves the sortable item owning a bubbled `pointer-drag` event. */
163
- #itemFor(target) {
164
- const node = target;
165
- if (!node) return null;
166
- return this.#items().find((item) => item === node || item.contains(node)) ?? null;
330
+ /**
331
+ * Resolves the sortable item owning a bubbled `pointer-drag` event.
332
+ *
333
+ * `pointer-drag` dispatches on its own element, and the markup contract puts
334
+ * one on each item, so the owner is the item that **is** the target. Matching
335
+ * an ancestor instead would make a card's own inner draggable — a knob, a
336
+ * split pane, anything the primitive is composed into — drive the card.
337
+ */
338
+ #itemFor(target, items) {
339
+ if (!target) return null;
340
+ return items.find((item) => item === target) ?? null;
167
341
  }
168
342
  /** The items in live DOM order (targets re-query the DOM on every access). */
169
343
  #items() {