phoenix_duskmoon 10.0.4 → 10.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ # [10.0.5](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.4...v10.0.5) (2026-10-18)
2
+
3
+ ### Added
4
+
5
+ - Add `dm_split_pane` and the public `DuskmoonSplitPane` hook for pointer and
6
+ keyboard resizing, accessible separator ranges, container-aware bounds and
7
+ stacked panes on narrow containers, resolving [#203](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/issues/203).
8
+
9
+ ### Fixed
10
+
11
+ - Expose opt-in left-menu user collapse events and an external state-setting
12
+ event. Support host-owned collapse state without remounting sticky menus or
13
+ replaying stale state during active-page updates, resolving [#204](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/issues/204).
14
+ - Keep session storage in the host application and preserve the default
15
+ client-owned and optional localStorage behavior.
16
+
1
17
  # [10.0.4](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.3...v10.0.4) (2026-10-16)
2
18
 
3
19
  ### Added
@@ -12,6 +12,7 @@
12
12
  @import "@duskmoon-dev/core/themes/moonlight";
13
13
  @import "@duskmoon-dev/core/components";
14
14
  @import "./element-theme-bridge.css";
15
+ @import "./split-pane.css";
15
16
 
16
17
  /* CSS Art — decorative/animated visual effects from @duskmoon-dev/css-art */
17
18
  @import "@duskmoon-dev/css-art/dist/art/atom.css";
@@ -0,0 +1,42 @@
1
+ /* Native HEEx layout and interaction states; Core owns the divider appearance. */
2
+ @layer components {
3
+ .dm-split-pane {
4
+ display: grid;
5
+ grid-template-columns: minmax(0, 1fr);
6
+ min-width: 0;
7
+ min-height: 0;
8
+ }
9
+
10
+ .dm-split-pane[data-split-pane-stacked="false"] {
11
+ grid-template-columns: var(--dm-split-pane-width) 16px minmax(0, 1fr);
12
+ }
13
+
14
+ .dm-split-pane-primary,
15
+ .dm-split-pane-secondary {
16
+ min-width: 0;
17
+ min-height: 0;
18
+ overflow: auto;
19
+ }
20
+
21
+ .dm-split-pane > .dm-split-pane-handle {
22
+ width: 16px;
23
+ margin: 0;
24
+ cursor: col-resize;
25
+ touch-action: none;
26
+ user-select: none;
27
+ }
28
+
29
+ .dm-split-pane > .dm-split-pane-handle[hidden] {
30
+ display: none;
31
+ }
32
+
33
+ .dm-split-pane-handle:focus-visible {
34
+ outline: 2px solid var(--color-primary);
35
+ outline-offset: -2px;
36
+ }
37
+
38
+ .dm-split-pane[data-split-pane-dragging] {
39
+ cursor: col-resize;
40
+ user-select: none;
41
+ }
42
+ }
@@ -54,5 +54,14 @@ export declare const DuskmoonPopover: LiveViewHook<HTMLElement>;
54
54
  /** Preserves left-menu collapse, group, and scroll state across LiveView patches. */
55
55
  export declare const DuskmoonLeftMenu: LiveViewHook<HTMLElement>;
56
56
 
57
- /** Synchronizes active-page state with the persistent sticky left-menu child. */
57
+ /** User collapse_event detail emitted by dm_left_menu. */
58
+ export interface LeftMenuCollapseDetail {
59
+ id: string;
60
+ collapsed: boolean;
61
+ }
62
+
63
+ /** Synchronizes active-page and controlled collapse state with the sticky left-menu child. */
58
64
  export declare const DuskmoonLeftMenuNavigation: LiveViewHook<HTMLElement>;
65
+
66
+ /** Pointer and keyboard pane sizing retained across LiveView patches. */
67
+ export declare const DuskmoonSplitPane: LiveViewHook<HTMLElement>;
@@ -25,6 +25,7 @@
25
25
  * - DuskmoonPopover: Optional server-controlled native popover state
26
26
  * - DuskmoonLeftMenu: Collapsible sidebar with persistent group and scroll state
27
27
  * - DuskmoonLeftMenuNavigation: Active-page synchronization for sticky left menus
28
+ * - DuskmoonSplitPane: Accessible client-owned split-pane resizing
28
29
  */
29
30
 
30
31
  // Core hooks for custom element integration
@@ -37,3 +38,4 @@ export { PageHeader } from "./page_header.js";
37
38
  export { DuskmoonPopover } from "./duskmoon_popover.js";
38
39
  export { DuskmoonLeftMenu } from "./left_menu.js";
39
40
  export { DuskmoonLeftMenuNavigation } from "./left_menu_navigation.js";
41
+ export { DuskmoonSplitPane } from "./split_pane.js";
@@ -1,19 +1,21 @@
1
1
  const navigationKinds = new Set(["redirect", "patch", "initial"]);
2
2
 
3
- /** Client-owned collapse, group, and scroll state for dm_left_menu. */
3
+ /** Collapse integration, group state, and scroll restoration for dm_left_menu. */
4
4
  export const DuskmoonLeftMenu = {
5
5
  mounted() {
6
6
  this.menuWindow = this.el.ownerDocument.defaultView;
7
7
  this.collapsed = this.el.dataset.collapsed === "true";
8
+ this.lastServerCollapsed = this.el.dataset.serverCollapsed;
8
9
  this.groupStates = new Map();
9
10
  this.navigationScrollTop = null;
10
11
  this.scrollTop = this.menuScroller().scrollTop;
11
12
  this.captureGroups();
12
13
 
13
14
  try {
14
- const stored = this.el.dataset.storageKey
15
- ? this.menuWindow.localStorage.getItem(this.el.dataset.storageKey)
16
- : null;
15
+ const stored =
16
+ this.lastServerCollapsed === undefined && this.el.dataset.storageKey
17
+ ? this.menuWindow.localStorage.getItem(this.el.dataset.storageKey)
18
+ : null;
17
19
  if (stored === "true" || stored === "false") {
18
20
  this.collapsed = stored === "true";
19
21
  }
@@ -24,11 +26,12 @@ export const DuskmoonLeftMenu = {
24
26
  this.handleMenuClick = (event) => {
25
27
  const toggle = event.target.closest?.("[data-left-menu-toggle]");
26
28
  if (!toggle || !this.el.contains(toggle)) return;
27
- if (!this.collapsed) this.captureGroups();
28
- this.collapsed = !this.collapsed;
29
- this.applyMenuState();
29
+ this.setMenuCollapsed(!this.collapsed);
30
30
  try {
31
- if (this.el.dataset.storageKey) {
31
+ if (
32
+ this.el.dataset.serverCollapsed === undefined &&
33
+ this.el.dataset.storageKey
34
+ ) {
32
35
  this.menuWindow.localStorage.setItem(
33
36
  this.el.dataset.storageKey,
34
37
  String(this.collapsed),
@@ -37,6 +40,22 @@ export const DuskmoonLeftMenu = {
37
40
  } catch {
38
41
  // Persistence is optional.
39
42
  }
43
+ if (this.el.dataset.collapseEvent) {
44
+ this.el.dispatchEvent(
45
+ new this.menuWindow.CustomEvent(this.el.dataset.collapseEvent, {
46
+ bubbles: true,
47
+ detail: { id: this.el.id, collapsed: this.collapsed },
48
+ }),
49
+ );
50
+ }
51
+ };
52
+ this.handleSetCollapsed = (event) => {
53
+ if (
54
+ event.target !== this.el ||
55
+ typeof event.detail?.collapsed !== "boolean"
56
+ )
57
+ return;
58
+ this.setMenuCollapsed(event.detail.collapsed);
40
59
  };
41
60
  this.handleNavigationStart = ({ detail }) => {
42
61
  if (!navigationKinds.has(detail?.kind)) return;
@@ -51,6 +70,10 @@ export const DuskmoonLeftMenu = {
51
70
  if (detail.kind !== "redirect") this.navigationScrollTop = null;
52
71
  };
53
72
  this.el.addEventListener("click", this.handleMenuClick);
73
+ this.el.addEventListener(
74
+ "duskmoon:left-menu:set-collapsed",
75
+ this.handleSetCollapsed,
76
+ );
54
77
  this.menuWindow.addEventListener(
55
78
  "phx:page-loading-start",
56
79
  this.handleNavigationStart,
@@ -62,6 +85,25 @@ export const DuskmoonLeftMenu = {
62
85
  this.applyMenuState();
63
86
  },
64
87
 
88
+ setMenuCollapsed(collapsed) {
89
+ const scrollTop = this.navigationScrollTop ?? this.menuScroller().scrollTop;
90
+ if (!this.collapsed && collapsed) this.captureGroups();
91
+ this.collapsed = collapsed;
92
+ this.applyMenuState();
93
+ this.restoreMenuScroll(scrollTop);
94
+ },
95
+
96
+ syncServerCollapsed(force = false) {
97
+ const value = this.el.dataset.serverCollapsed;
98
+ if (
99
+ (value !== "true" && value !== "false") ||
100
+ (!force && value === this.lastServerCollapsed)
101
+ )
102
+ return;
103
+ this.lastServerCollapsed = value;
104
+ this.setMenuCollapsed(value === "true");
105
+ },
106
+
65
107
  menuScroller() {
66
108
  return this.el.querySelector("[data-left-menu-scroll]");
67
109
  },
@@ -148,11 +190,13 @@ export const DuskmoonLeftMenu = {
148
190
 
149
191
  updated() {
150
192
  this.applyMenuState();
193
+ this.syncServerCollapsed();
151
194
  this.restoreMenuScroll(this.navigationScrollTop ?? this.scrollTop);
152
195
  },
153
196
 
154
197
  reconnected() {
155
198
  this.applyMenuState();
199
+ this.syncServerCollapsed(true);
156
200
  if (this.navigationScrollTop !== null) {
157
201
  this.restoreMenuScroll(this.navigationScrollTop);
158
202
  }
@@ -160,6 +204,10 @@ export const DuskmoonLeftMenu = {
160
204
 
161
205
  destroyed() {
162
206
  this.el.removeEventListener("click", this.handleMenuClick);
207
+ this.el.removeEventListener(
208
+ "duskmoon:left-menu:set-collapsed",
209
+ this.handleSetCollapsed,
210
+ );
163
211
  this.menuWindow.removeEventListener(
164
212
  "phx:page-loading-start",
165
213
  this.handleNavigationStart,
@@ -4,15 +4,45 @@ export const DuskmoonLeftMenuNavigation = {
4
4
  this.navigationDestroyed = false;
5
5
  this.lastNavigation = null;
6
6
  this.navigationRequest = null;
7
+ this.lastCollapse = null;
7
8
  this.syncActiveNav();
9
+ this.syncCollapse();
8
10
  },
9
11
  updated() {
10
12
  this.syncActiveNav();
13
+ this.syncCollapse();
11
14
  },
12
15
  reconnected() {
13
16
  this.lastNavigation = null;
14
17
  this.navigationRequest = null;
18
+ this.lastCollapse = null;
15
19
  this.syncActiveNav();
20
+ this.syncCollapse();
21
+ },
22
+ syncCollapse() {
23
+ if (this.navigationDestroyed) return;
24
+ const { sidebarId, collapseState } = this.el.dataset;
25
+ const target = sidebarId
26
+ ? this.el.ownerDocument.getElementById(sidebarId)
27
+ : null;
28
+ if (!target || (collapseState !== "true" && collapseState !== "false")) {
29
+ this.lastCollapse = null;
30
+ return;
31
+ }
32
+ if (
33
+ this.lastCollapse?.target === target &&
34
+ this.lastCollapse.collapseState === collapseState
35
+ )
36
+ return;
37
+ this.lastCollapse = { target, collapseState };
38
+ target.dispatchEvent(
39
+ new target.ownerDocument.defaultView.CustomEvent(
40
+ "duskmoon:left-menu:set-collapsed",
41
+ {
42
+ detail: { collapsed: collapseState === "true" },
43
+ },
44
+ ),
45
+ );
16
46
  },
17
47
  syncActiveNav() {
18
48
  if (this.navigationDestroyed) return;
@@ -65,5 +95,6 @@ export const DuskmoonLeftMenuNavigation = {
65
95
  this.navigationDestroyed = true;
66
96
  this.lastNavigation = null;
67
97
  this.navigationRequest = null;
98
+ this.lastCollapse = null;
68
99
  },
69
100
  };
@@ -0,0 +1,125 @@
1
+ /** Client-owned horizontal pane sizing; no resize events are sent to LiveView. */
2
+ export const DuskmoonSplitPane = {
3
+ mounted() {
4
+ this.splitWindow = this.el.ownerDocument.defaultView;
5
+ this.preferredWidth = Number(this.el.dataset.initialWidth);
6
+ this.onSplitPointerDown = (event) => {
7
+ const handle = this.splitHandle();
8
+ if (
9
+ this.splitDrag || this.splitStacked || event.button !== 0 ||
10
+ !event.isPrimary || event.target.closest?.("[data-split-pane-handle]") !== handle
11
+ ) return;
12
+ event.preventDefault();
13
+ handle.focus({ preventScroll: true });
14
+ this.splitDrag = {
15
+ pointerId: event.pointerId,
16
+ x: event.clientX,
17
+ width: this.currentWidth,
18
+ preferredWidth: this.preferredWidth,
19
+ direction: this.splitWindow.getComputedStyle(this.el).direction === "rtl" ? -1 : 1,
20
+ handle,
21
+ };
22
+ handle.setPointerCapture(event.pointerId);
23
+ this.el.dataset.splitPaneDragging = "true";
24
+ };
25
+ this.onSplitPointerMove = (event) => {
26
+ const drag = this.splitDrag;
27
+ if (!drag || event.pointerId !== drag.pointerId) return;
28
+ this.setSplitWidth(drag.width + (event.clientX - drag.x) * drag.direction);
29
+ };
30
+ this.onSplitPointerUp = (event) => {
31
+ if (event.pointerId === this.splitDrag?.pointerId) this.finishSplitDrag(false);
32
+ };
33
+ this.onSplitPointerCancel = (event) => {
34
+ if (event.pointerId === this.splitDrag?.pointerId) this.finishSplitDrag(true);
35
+ };
36
+ this.onSplitKeyDown = (event) => {
37
+ if (this.splitStacked || event.target !== this.splitHandle()) return;
38
+ const direction = this.splitWindow.getComputedStyle(this.el).direction === "rtl" ? -1 : 1;
39
+ const widths = {
40
+ ArrowLeft: this.currentWidth - 10 * direction,
41
+ ArrowRight: this.currentWidth + 10 * direction,
42
+ Home: this.splitMin,
43
+ End: this.splitMax,
44
+ };
45
+ if (!Object.hasOwn(widths, event.key)) return;
46
+ event.preventDefault();
47
+ this.finishSplitDrag(false);
48
+ this.setSplitWidth(widths[event.key]);
49
+ };
50
+ this.splitListeners = {
51
+ pointerdown: this.onSplitPointerDown,
52
+ pointermove: this.onSplitPointerMove,
53
+ pointerup: this.onSplitPointerUp,
54
+ pointercancel: this.onSplitPointerCancel,
55
+ lostpointercapture: this.onSplitPointerCancel,
56
+ keydown: this.onSplitKeyDown,
57
+ };
58
+ for (const [type, listener] of Object.entries(this.splitListeners)) {
59
+ this.el.addEventListener(type, listener);
60
+ }
61
+ this.splitObserver = new this.splitWindow.ResizeObserver(() => this.applySplitPane());
62
+ this.splitObserver.observe(this.el);
63
+ this.applySplitPane();
64
+ },
65
+
66
+ splitHandle() {
67
+ return this.el.querySelector(":scope > [data-split-pane-handle]");
68
+ },
69
+
70
+ setSplitWidth(width) {
71
+ this.preferredWidth = Math.max(this.splitMin, Math.min(this.splitMax, Math.round(width)));
72
+ this.applySplitPane();
73
+ },
74
+
75
+ applySplitPane() {
76
+ const { minWidth, maxWidth, minSecondaryWidth, stackAt } = this.el.dataset;
77
+ const style = this.splitWindow.getComputedStyle(this.el);
78
+ const available = this.el.clientWidth - (parseFloat(style.paddingLeft) || 0) -
79
+ (parseFloat(style.paddingRight) || 0);
80
+ this.splitMin = Number(minWidth);
81
+ const availableMax = Math.floor(available - 16 - Number(minSecondaryWidth));
82
+ this.splitMax = Math.max(this.splitMin, Math.min(Number(maxWidth), availableMax));
83
+ this.splitStacked = available < Number(stackAt) || availableMax < this.splitMin;
84
+ if (this.splitStacked && this.splitDrag) this.finishSplitDrag(true);
85
+ this.currentWidth = Math.max(this.splitMin, Math.min(this.splitMax, this.preferredWidth));
86
+ this.el.style.setProperty("--dm-split-pane-width", `${this.currentWidth}px`);
87
+ this.el.dataset.splitPaneStacked = String(this.splitStacked);
88
+ const handle = this.splitHandle();
89
+ handle.hidden = this.splitStacked;
90
+ handle.tabIndex = this.splitStacked ? -1 : 0;
91
+ handle.setAttribute("aria-valuemin", String(this.splitMin));
92
+ handle.setAttribute("aria-valuemax", String(this.splitMax));
93
+ handle.setAttribute("aria-valuenow", String(this.currentWidth));
94
+ handle.setAttribute("aria-valuetext", `${this.currentWidth} pixels`);
95
+ },
96
+
97
+ finishSplitDrag(cancelled, refresh = true) {
98
+ const drag = this.splitDrag;
99
+ if (!drag) return;
100
+ this.splitDrag = null;
101
+ if (cancelled) this.preferredWidth = drag.preferredWidth;
102
+ delete this.el.dataset.splitPaneDragging;
103
+ if (drag.handle.hasPointerCapture(drag.pointerId)) {
104
+ drag.handle.releasePointerCapture(drag.pointerId);
105
+ }
106
+ if (refresh) this.applySplitPane();
107
+ },
108
+
109
+ beforeUpdate() {
110
+ // Release capture before morphing, retaining the latest client-owned width.
111
+ this.finishSplitDrag(false);
112
+ },
113
+
114
+ updated() {
115
+ this.applySplitPane();
116
+ },
117
+
118
+ destroyed() {
119
+ this.finishSplitDrag(false, false);
120
+ this.splitObserver.disconnect();
121
+ for (const [type, listener] of Object.entries(this.splitListeners)) {
122
+ this.el.removeEventListener(type, listener);
123
+ }
124
+ },
125
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_duskmoon",
3
- "version": "10.0.4",
3
+ "version": "10.0.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./priv/static/assets/js/phoenix_duskmoon.js",