phoenix_duskmoon 10.0.3 → 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 +38 -0
- package/assets/css/phoenix_duskmoon.css +1 -0
- package/assets/css/split-pane.css +42 -0
- package/assets/js/hooks/index.d.ts +20 -1
- package/assets/js/hooks/index.js +11 -5
- package/assets/js/hooks/left_menu.js +220 -0
- package/assets/js/hooks/left_menu_navigation.js +100 -0
- package/assets/js/hooks/split_pane.js +125 -0
- package/package.json +1 -1
- package/priv/static/assets/css/{app-49d183cdbcc6cccb6e9eadbec4fff01c.css → app-f3e09a20a7d107eb69615e2cb40305fe.css} +1 -1
- package/priv/static/assets/css/{app-49d183cdbcc6cccb6e9eadbec4fff01c.css.gz → app-f3e09a20a7d107eb69615e2cb40305fe.css.gz} +0 -0
- package/priv/static/assets/css/app.css +1 -1
- package/priv/static/assets/css/app.css.gz +0 -0
- package/priv/static/cache_manifest.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
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
|
+
|
|
17
|
+
# [10.0.4](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.3...v10.0.4) (2026-10-16)
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Extend `dm_left_menu` with a collapsible icon rail, fixed header/footer, native
|
|
22
|
+
link tooltips and optional saved collapse preference.
|
|
23
|
+
- Own persistent sidebar LiveViews and active-page synchronization through the
|
|
24
|
+
`sticky` data API, preserving scroll and group state across LiveView navigation,
|
|
25
|
+
browser history and reconnects. Document CSS sticky positioning and reload limits.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Use the public left-menu component for the Storybook Components gallery,
|
|
30
|
+
retaining all 68 destinations and their active states through LiveView navigation.
|
|
31
|
+
- Keep the sidebar toggle visible below the app bar on narrow and short viewports,
|
|
32
|
+
and handle pagination, chip removal, chat sends and theme selection in the demos.
|
|
33
|
+
|
|
34
|
+
### Validation
|
|
35
|
+
|
|
36
|
+
- Cover component data contracts, persistent-child lifecycle, hook synchronization,
|
|
37
|
+
accessible icon rails, tooltips, every gallery route and interactive demo events.
|
|
38
|
+
|
|
1
39
|
# [10.0.3](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.2...v10.0.3) (2026-10-15)
|
|
2
40
|
|
|
3
41
|
### Fixed
|
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,9 @@ export type HookEventPayload = unknown;
|
|
|
3
3
|
export type HookEventCallback = (reply: unknown, ref: number) => void;
|
|
4
4
|
|
|
5
5
|
/** The LiveView-owned context supplied as `this` to hook callbacks. */
|
|
6
|
-
export interface LiveViewHookContext<
|
|
6
|
+
export interface LiveViewHookContext<
|
|
7
|
+
ElementType extends HTMLElement = HTMLElement,
|
|
8
|
+
> {
|
|
7
9
|
readonly el: ElementType;
|
|
8
10
|
handleEvent(
|
|
9
11
|
event: string,
|
|
@@ -25,7 +27,9 @@ export interface LiveViewHookContext<ElementType extends HTMLElement = HTMLEleme
|
|
|
25
27
|
/** A Phoenix LiveView hook definition object. */
|
|
26
28
|
export interface LiveViewHook<ElementType extends HTMLElement = HTMLElement> {
|
|
27
29
|
mounted?(this: LiveViewHookContext<ElementType>): void;
|
|
30
|
+
beforeUpdate?(this: LiveViewHookContext<ElementType>): void;
|
|
28
31
|
updated?(this: LiveViewHookContext<ElementType>): void;
|
|
32
|
+
reconnected?(this: LiveViewHookContext<ElementType>): void;
|
|
29
33
|
destroyed?(this: LiveViewHookContext<ElementType>): void;
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -46,3 +50,18 @@ export declare const PageHeader: LiveViewHook;
|
|
|
46
50
|
|
|
47
51
|
/** Synchronizes an explicitly controlled native popover with LiveView state. */
|
|
48
52
|
export declare const DuskmoonPopover: LiveViewHook<HTMLElement>;
|
|
53
|
+
|
|
54
|
+
/** Preserves left-menu collapse, group, and scroll state across LiveView patches. */
|
|
55
|
+
export declare const DuskmoonLeftMenu: LiveViewHook<HTMLElement>;
|
|
56
|
+
|
|
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. */
|
|
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>;
|
package/assets/js/hooks/index.js
CHANGED
|
@@ -23,13 +23,19 @@
|
|
|
23
23
|
* - Spotlight: Keyboard shortcut (Cmd/Ctrl+K) for spotlight search
|
|
24
24
|
* - PageHeader: Intersection observer for scroll-based effects
|
|
25
25
|
* - DuskmoonPopover: Optional server-controlled native popover state
|
|
26
|
+
* - DuskmoonLeftMenu: Collapsible sidebar with persistent group and scroll state
|
|
27
|
+
* - DuskmoonLeftMenuNavigation: Active-page synchronization for sticky left menus
|
|
28
|
+
* - DuskmoonSplitPane: Accessible client-owned split-pane resizing
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
31
|
// Core hooks for custom element integration
|
|
29
|
-
export { WebComponentHook, FormElementHook } from
|
|
32
|
+
export { WebComponentHook, FormElementHook } from "../phoenix_duskmoon.js";
|
|
30
33
|
|
|
31
34
|
// Component-specific hooks
|
|
32
|
-
export { ThemeSwitcher } from
|
|
33
|
-
export { Spotlight } from
|
|
34
|
-
export { PageHeader } from
|
|
35
|
-
export { DuskmoonPopover } from
|
|
35
|
+
export { ThemeSwitcher } from "./theme_switcher.js";
|
|
36
|
+
export { Spotlight } from "./spotlight.js";
|
|
37
|
+
export { PageHeader } from "./page_header.js";
|
|
38
|
+
export { DuskmoonPopover } from "./duskmoon_popover.js";
|
|
39
|
+
export { DuskmoonLeftMenu } from "./left_menu.js";
|
|
40
|
+
export { DuskmoonLeftMenuNavigation } from "./left_menu_navigation.js";
|
|
41
|
+
export { DuskmoonSplitPane } from "./split_pane.js";
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const navigationKinds = new Set(["redirect", "patch", "initial"]);
|
|
2
|
+
|
|
3
|
+
/** Collapse integration, group state, and scroll restoration for dm_left_menu. */
|
|
4
|
+
export const DuskmoonLeftMenu = {
|
|
5
|
+
mounted() {
|
|
6
|
+
this.menuWindow = this.el.ownerDocument.defaultView;
|
|
7
|
+
this.collapsed = this.el.dataset.collapsed === "true";
|
|
8
|
+
this.lastServerCollapsed = this.el.dataset.serverCollapsed;
|
|
9
|
+
this.groupStates = new Map();
|
|
10
|
+
this.navigationScrollTop = null;
|
|
11
|
+
this.scrollTop = this.menuScroller().scrollTop;
|
|
12
|
+
this.captureGroups();
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const stored =
|
|
16
|
+
this.lastServerCollapsed === undefined && this.el.dataset.storageKey
|
|
17
|
+
? this.menuWindow.localStorage.getItem(this.el.dataset.storageKey)
|
|
18
|
+
: null;
|
|
19
|
+
if (stored === "true" || stored === "false") {
|
|
20
|
+
this.collapsed = stored === "true";
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// Storage can be disabled; in-page toggling remains available.
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.handleMenuClick = (event) => {
|
|
27
|
+
const toggle = event.target.closest?.("[data-left-menu-toggle]");
|
|
28
|
+
if (!toggle || !this.el.contains(toggle)) return;
|
|
29
|
+
this.setMenuCollapsed(!this.collapsed);
|
|
30
|
+
try {
|
|
31
|
+
if (
|
|
32
|
+
this.el.dataset.serverCollapsed === undefined &&
|
|
33
|
+
this.el.dataset.storageKey
|
|
34
|
+
) {
|
|
35
|
+
this.menuWindow.localStorage.setItem(
|
|
36
|
+
this.el.dataset.storageKey,
|
|
37
|
+
String(this.collapsed),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Persistence is optional.
|
|
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);
|
|
59
|
+
};
|
|
60
|
+
this.handleNavigationStart = ({ detail }) => {
|
|
61
|
+
if (!navigationKinds.has(detail?.kind)) return;
|
|
62
|
+
if (this.navigationScrollTop !== null) return;
|
|
63
|
+
this.navigationScrollTop = this.menuScroller().scrollTop;
|
|
64
|
+
};
|
|
65
|
+
this.handleNavigationStop = ({ detail }) => {
|
|
66
|
+
if (!navigationKinds.has(detail?.kind)) return;
|
|
67
|
+
if (this.navigationScrollTop === null) return;
|
|
68
|
+
this.restoreMenuScroll(this.navigationScrollTop);
|
|
69
|
+
// The new root's initial patch can still follow redirect stop.
|
|
70
|
+
if (detail.kind !== "redirect") this.navigationScrollTop = null;
|
|
71
|
+
};
|
|
72
|
+
this.el.addEventListener("click", this.handleMenuClick);
|
|
73
|
+
this.el.addEventListener(
|
|
74
|
+
"duskmoon:left-menu:set-collapsed",
|
|
75
|
+
this.handleSetCollapsed,
|
|
76
|
+
);
|
|
77
|
+
this.menuWindow.addEventListener(
|
|
78
|
+
"phx:page-loading-start",
|
|
79
|
+
this.handleNavigationStart,
|
|
80
|
+
);
|
|
81
|
+
this.menuWindow.addEventListener(
|
|
82
|
+
"phx:page-loading-stop",
|
|
83
|
+
this.handleNavigationStop,
|
|
84
|
+
);
|
|
85
|
+
this.applyMenuState();
|
|
86
|
+
},
|
|
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
|
+
|
|
107
|
+
menuScroller() {
|
|
108
|
+
return this.el.querySelector("[data-left-menu-scroll]");
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
captureGroups() {
|
|
112
|
+
for (const group of this.el.querySelectorAll("details[id]")) {
|
|
113
|
+
this.groupStates.set(group.id, group.open);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
applyMenuState() {
|
|
118
|
+
this.el.dataset.collapsed = String(this.collapsed);
|
|
119
|
+
for (const toggle of this.el.querySelectorAll("[data-left-menu-toggle]")) {
|
|
120
|
+
toggle.setAttribute("aria-expanded", String(!this.collapsed));
|
|
121
|
+
const label = this.collapsed
|
|
122
|
+
? this.el.dataset.expandLabel
|
|
123
|
+
: this.el.dataset.collapseLabel;
|
|
124
|
+
toggle.setAttribute("aria-label", label);
|
|
125
|
+
toggle.setAttribute("title", label);
|
|
126
|
+
}
|
|
127
|
+
const ids = new Set();
|
|
128
|
+
for (const group of this.el.querySelectorAll("details[id]")) {
|
|
129
|
+
ids.add(group.id);
|
|
130
|
+
if (!this.groupStates.has(group.id)) {
|
|
131
|
+
this.groupStates.set(group.id, group.open);
|
|
132
|
+
}
|
|
133
|
+
group.open = this.collapsed || this.groupStates.get(group.id);
|
|
134
|
+
}
|
|
135
|
+
for (const id of this.groupStates.keys()) {
|
|
136
|
+
if (!ids.has(id)) this.groupStates.delete(id);
|
|
137
|
+
}
|
|
138
|
+
this.applyMenuTooltips();
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
applyMenuTooltips() {
|
|
142
|
+
for (const link of this.el.querySelectorAll("[data-left-menu-tooltip]")) {
|
|
143
|
+
const tooltipId = link.dataset.leftMenuTooltip;
|
|
144
|
+
const anchor = link.dataset.leftMenuAnchor;
|
|
145
|
+
const title = link.dataset.leftMenuTooltipTitle;
|
|
146
|
+
const descriptions = new Set(
|
|
147
|
+
(link.getAttribute("aria-describedby") || "")
|
|
148
|
+
.split(/\s+/)
|
|
149
|
+
.filter(Boolean),
|
|
150
|
+
);
|
|
151
|
+
const anchors = new Set(
|
|
152
|
+
link.style
|
|
153
|
+
.getPropertyValue("anchor-name")
|
|
154
|
+
.split(",")
|
|
155
|
+
.map((value) => value.trim())
|
|
156
|
+
.filter(Boolean),
|
|
157
|
+
);
|
|
158
|
+
if (this.collapsed) {
|
|
159
|
+
link.setAttribute("interestfor", tooltipId);
|
|
160
|
+
descriptions.add(tooltipId);
|
|
161
|
+
if (anchor) anchors.add(anchor);
|
|
162
|
+
if (title) link.setAttribute("title", title);
|
|
163
|
+
} else {
|
|
164
|
+
if (link.getAttribute("interestfor") === tooltipId)
|
|
165
|
+
link.removeAttribute("interestfor");
|
|
166
|
+
descriptions.delete(tooltipId);
|
|
167
|
+
anchors.delete(anchor);
|
|
168
|
+
if (link.getAttribute("title") === title) link.removeAttribute("title");
|
|
169
|
+
const surface = this.el.ownerDocument.getElementById(tooltipId);
|
|
170
|
+
if (surface?.hidePopover && surface.matches(":popover-open"))
|
|
171
|
+
surface.hidePopover();
|
|
172
|
+
}
|
|
173
|
+
if (descriptions.size)
|
|
174
|
+
link.setAttribute("aria-describedby", [...descriptions].join(" "));
|
|
175
|
+
else link.removeAttribute("aria-describedby");
|
|
176
|
+
if (anchors.size)
|
|
177
|
+
link.style.setProperty("anchor-name", [...anchors].join(", "));
|
|
178
|
+
else link.style.removeProperty("anchor-name");
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
restoreMenuScroll(top) {
|
|
183
|
+
this.menuScroller().scrollTo({ top, behavior: "instant" });
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
beforeUpdate() {
|
|
187
|
+
if (!this.collapsed) this.captureGroups();
|
|
188
|
+
this.scrollTop = this.navigationScrollTop ?? this.menuScroller().scrollTop;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
updated() {
|
|
192
|
+
this.applyMenuState();
|
|
193
|
+
this.syncServerCollapsed();
|
|
194
|
+
this.restoreMenuScroll(this.navigationScrollTop ?? this.scrollTop);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
reconnected() {
|
|
198
|
+
this.applyMenuState();
|
|
199
|
+
this.syncServerCollapsed(true);
|
|
200
|
+
if (this.navigationScrollTop !== null) {
|
|
201
|
+
this.restoreMenuScroll(this.navigationScrollTop);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
destroyed() {
|
|
206
|
+
this.el.removeEventListener("click", this.handleMenuClick);
|
|
207
|
+
this.el.removeEventListener(
|
|
208
|
+
"duskmoon:left-menu:set-collapsed",
|
|
209
|
+
this.handleSetCollapsed,
|
|
210
|
+
);
|
|
211
|
+
this.menuWindow.removeEventListener(
|
|
212
|
+
"phx:page-loading-start",
|
|
213
|
+
this.handleNavigationStart,
|
|
214
|
+
);
|
|
215
|
+
this.menuWindow.removeEventListener(
|
|
216
|
+
"phx:page-loading-stop",
|
|
217
|
+
this.handleNavigationStop,
|
|
218
|
+
);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Synchronizes the page's active item with its sticky dm_left_menu child. */
|
|
2
|
+
export const DuskmoonLeftMenuNavigation = {
|
|
3
|
+
mounted() {
|
|
4
|
+
this.navigationDestroyed = false;
|
|
5
|
+
this.lastNavigation = null;
|
|
6
|
+
this.navigationRequest = null;
|
|
7
|
+
this.lastCollapse = null;
|
|
8
|
+
this.syncActiveNav();
|
|
9
|
+
this.syncCollapse();
|
|
10
|
+
},
|
|
11
|
+
updated() {
|
|
12
|
+
this.syncActiveNav();
|
|
13
|
+
this.syncCollapse();
|
|
14
|
+
},
|
|
15
|
+
reconnected() {
|
|
16
|
+
this.lastNavigation = null;
|
|
17
|
+
this.navigationRequest = null;
|
|
18
|
+
this.lastCollapse = null;
|
|
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
|
+
);
|
|
46
|
+
},
|
|
47
|
+
syncActiveNav() {
|
|
48
|
+
if (this.navigationDestroyed) return;
|
|
49
|
+
const { sidebarId, activeNav } = this.el.dataset;
|
|
50
|
+
const target = sidebarId
|
|
51
|
+
? this.el.ownerDocument.getElementById(sidebarId)
|
|
52
|
+
: null;
|
|
53
|
+
if (!target || typeof activeNav !== "string") {
|
|
54
|
+
this.lastNavigation = null;
|
|
55
|
+
this.navigationRequest = null;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
this.lastNavigation?.sidebarId === sidebarId &&
|
|
60
|
+
this.lastNavigation.target === target &&
|
|
61
|
+
this.lastNavigation.activeNav === activeNav
|
|
62
|
+
)
|
|
63
|
+
return;
|
|
64
|
+
this.lastNavigation = { sidebarId, target, activeNav };
|
|
65
|
+
const request = {};
|
|
66
|
+
this.navigationRequest = request;
|
|
67
|
+
const failed = () => {
|
|
68
|
+
if (!this.navigationDestroyed && this.navigationRequest === request) {
|
|
69
|
+
this.lastNavigation = null;
|
|
70
|
+
this.navigationRequest = null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
Promise.resolve(
|
|
75
|
+
this.pushEventTo(target, "set_active_nav", { active_nav: activeNav }),
|
|
76
|
+
)
|
|
77
|
+
.then((results) => {
|
|
78
|
+
if (
|
|
79
|
+
!Array.isArray(results) ||
|
|
80
|
+
!results.length ||
|
|
81
|
+
results.some(
|
|
82
|
+
(result) =>
|
|
83
|
+
result?.status !== "fulfilled" ||
|
|
84
|
+
result.value?.reply?.status !== "ok",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
failed();
|
|
88
|
+
})
|
|
89
|
+
.catch(failed);
|
|
90
|
+
} catch {
|
|
91
|
+
failed();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
destroyed() {
|
|
95
|
+
this.navigationDestroyed = true;
|
|
96
|
+
this.lastNavigation = null;
|
|
97
|
+
this.navigationRequest = null;
|
|
98
|
+
this.lastCollapse = null;
|
|
99
|
+
},
|
|
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
|
+
};
|