phoenix_duskmoon 10.0.3 → 10.0.4

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,25 @@
1
+ # [10.0.4](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.3...v10.0.4) (2026-10-16)
2
+
3
+ ### Added
4
+
5
+ - Extend `dm_left_menu` with a collapsible icon rail, fixed header/footer, native
6
+ link tooltips and optional saved collapse preference.
7
+ - Own persistent sidebar LiveViews and active-page synchronization through the
8
+ `sticky` data API, preserving scroll and group state across LiveView navigation,
9
+ browser history and reconnects. Document CSS sticky positioning and reload limits.
10
+
11
+ ### Changed
12
+
13
+ - Use the public left-menu component for the Storybook Components gallery,
14
+ retaining all 68 destinations and their active states through LiveView navigation.
15
+ - Keep the sidebar toggle visible below the app bar on narrow and short viewports,
16
+ and handle pagination, chip removal, chat sends and theme selection in the demos.
17
+
18
+ ### Validation
19
+
20
+ - Cover component data contracts, persistent-child lifecycle, hook synchronization,
21
+ accessible icon rails, tooltips, every gallery route and interactive demo events.
22
+
1
23
  # [10.0.3](https://github.com/duskmoon-dev/phoenix-duskmoon-ui/compare/v10.0.2...v10.0.3) (2026-10-15)
2
24
 
3
25
  ### Fixed
@@ -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<ElementType extends HTMLElement = HTMLElement> {
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,9 @@ 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
+ /** Synchronizes active-page state with the persistent sticky left-menu child. */
58
+ export declare const DuskmoonLeftMenuNavigation: LiveViewHook<HTMLElement>;
@@ -23,13 +23,17 @@
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
26
28
  */
27
29
 
28
30
  // Core hooks for custom element integration
29
- export { WebComponentHook, FormElementHook } from '../phoenix_duskmoon.js';
31
+ export { WebComponentHook, FormElementHook } from "../phoenix_duskmoon.js";
30
32
 
31
33
  // Component-specific hooks
32
- export { ThemeSwitcher } from './theme_switcher.js';
33
- export { Spotlight } from './spotlight.js';
34
- export { PageHeader } from './page_header.js';
35
- export { DuskmoonPopover } from './duskmoon_popover.js';
34
+ export { ThemeSwitcher } from "./theme_switcher.js";
35
+ export { Spotlight } from "./spotlight.js";
36
+ export { PageHeader } from "./page_header.js";
37
+ export { DuskmoonPopover } from "./duskmoon_popover.js";
38
+ export { DuskmoonLeftMenu } from "./left_menu.js";
39
+ export { DuskmoonLeftMenuNavigation } from "./left_menu_navigation.js";
@@ -0,0 +1,172 @@
1
+ const navigationKinds = new Set(["redirect", "patch", "initial"]);
2
+
3
+ /** Client-owned collapse, group, and scroll state 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.groupStates = new Map();
9
+ this.navigationScrollTop = null;
10
+ this.scrollTop = this.menuScroller().scrollTop;
11
+ this.captureGroups();
12
+
13
+ try {
14
+ const stored = this.el.dataset.storageKey
15
+ ? this.menuWindow.localStorage.getItem(this.el.dataset.storageKey)
16
+ : null;
17
+ if (stored === "true" || stored === "false") {
18
+ this.collapsed = stored === "true";
19
+ }
20
+ } catch {
21
+ // Storage can be disabled; in-page toggling remains available.
22
+ }
23
+
24
+ this.handleMenuClick = (event) => {
25
+ const toggle = event.target.closest?.("[data-left-menu-toggle]");
26
+ if (!toggle || !this.el.contains(toggle)) return;
27
+ if (!this.collapsed) this.captureGroups();
28
+ this.collapsed = !this.collapsed;
29
+ this.applyMenuState();
30
+ try {
31
+ if (this.el.dataset.storageKey) {
32
+ this.menuWindow.localStorage.setItem(
33
+ this.el.dataset.storageKey,
34
+ String(this.collapsed),
35
+ );
36
+ }
37
+ } catch {
38
+ // Persistence is optional.
39
+ }
40
+ };
41
+ this.handleNavigationStart = ({ detail }) => {
42
+ if (!navigationKinds.has(detail?.kind)) return;
43
+ if (this.navigationScrollTop !== null) return;
44
+ this.navigationScrollTop = this.menuScroller().scrollTop;
45
+ };
46
+ this.handleNavigationStop = ({ detail }) => {
47
+ if (!navigationKinds.has(detail?.kind)) return;
48
+ if (this.navigationScrollTop === null) return;
49
+ this.restoreMenuScroll(this.navigationScrollTop);
50
+ // The new root's initial patch can still follow redirect stop.
51
+ if (detail.kind !== "redirect") this.navigationScrollTop = null;
52
+ };
53
+ this.el.addEventListener("click", this.handleMenuClick);
54
+ this.menuWindow.addEventListener(
55
+ "phx:page-loading-start",
56
+ this.handleNavigationStart,
57
+ );
58
+ this.menuWindow.addEventListener(
59
+ "phx:page-loading-stop",
60
+ this.handleNavigationStop,
61
+ );
62
+ this.applyMenuState();
63
+ },
64
+
65
+ menuScroller() {
66
+ return this.el.querySelector("[data-left-menu-scroll]");
67
+ },
68
+
69
+ captureGroups() {
70
+ for (const group of this.el.querySelectorAll("details[id]")) {
71
+ this.groupStates.set(group.id, group.open);
72
+ }
73
+ },
74
+
75
+ applyMenuState() {
76
+ this.el.dataset.collapsed = String(this.collapsed);
77
+ for (const toggle of this.el.querySelectorAll("[data-left-menu-toggle]")) {
78
+ toggle.setAttribute("aria-expanded", String(!this.collapsed));
79
+ const label = this.collapsed
80
+ ? this.el.dataset.expandLabel
81
+ : this.el.dataset.collapseLabel;
82
+ toggle.setAttribute("aria-label", label);
83
+ toggle.setAttribute("title", label);
84
+ }
85
+ const ids = new Set();
86
+ for (const group of this.el.querySelectorAll("details[id]")) {
87
+ ids.add(group.id);
88
+ if (!this.groupStates.has(group.id)) {
89
+ this.groupStates.set(group.id, group.open);
90
+ }
91
+ group.open = this.collapsed || this.groupStates.get(group.id);
92
+ }
93
+ for (const id of this.groupStates.keys()) {
94
+ if (!ids.has(id)) this.groupStates.delete(id);
95
+ }
96
+ this.applyMenuTooltips();
97
+ },
98
+
99
+ applyMenuTooltips() {
100
+ for (const link of this.el.querySelectorAll("[data-left-menu-tooltip]")) {
101
+ const tooltipId = link.dataset.leftMenuTooltip;
102
+ const anchor = link.dataset.leftMenuAnchor;
103
+ const title = link.dataset.leftMenuTooltipTitle;
104
+ const descriptions = new Set(
105
+ (link.getAttribute("aria-describedby") || "")
106
+ .split(/\s+/)
107
+ .filter(Boolean),
108
+ );
109
+ const anchors = new Set(
110
+ link.style
111
+ .getPropertyValue("anchor-name")
112
+ .split(",")
113
+ .map((value) => value.trim())
114
+ .filter(Boolean),
115
+ );
116
+ if (this.collapsed) {
117
+ link.setAttribute("interestfor", tooltipId);
118
+ descriptions.add(tooltipId);
119
+ if (anchor) anchors.add(anchor);
120
+ if (title) link.setAttribute("title", title);
121
+ } else {
122
+ if (link.getAttribute("interestfor") === tooltipId)
123
+ link.removeAttribute("interestfor");
124
+ descriptions.delete(tooltipId);
125
+ anchors.delete(anchor);
126
+ if (link.getAttribute("title") === title) link.removeAttribute("title");
127
+ const surface = this.el.ownerDocument.getElementById(tooltipId);
128
+ if (surface?.hidePopover && surface.matches(":popover-open"))
129
+ surface.hidePopover();
130
+ }
131
+ if (descriptions.size)
132
+ link.setAttribute("aria-describedby", [...descriptions].join(" "));
133
+ else link.removeAttribute("aria-describedby");
134
+ if (anchors.size)
135
+ link.style.setProperty("anchor-name", [...anchors].join(", "));
136
+ else link.style.removeProperty("anchor-name");
137
+ }
138
+ },
139
+
140
+ restoreMenuScroll(top) {
141
+ this.menuScroller().scrollTo({ top, behavior: "instant" });
142
+ },
143
+
144
+ beforeUpdate() {
145
+ if (!this.collapsed) this.captureGroups();
146
+ this.scrollTop = this.navigationScrollTop ?? this.menuScroller().scrollTop;
147
+ },
148
+
149
+ updated() {
150
+ this.applyMenuState();
151
+ this.restoreMenuScroll(this.navigationScrollTop ?? this.scrollTop);
152
+ },
153
+
154
+ reconnected() {
155
+ this.applyMenuState();
156
+ if (this.navigationScrollTop !== null) {
157
+ this.restoreMenuScroll(this.navigationScrollTop);
158
+ }
159
+ },
160
+
161
+ destroyed() {
162
+ this.el.removeEventListener("click", this.handleMenuClick);
163
+ this.menuWindow.removeEventListener(
164
+ "phx:page-loading-start",
165
+ this.handleNavigationStart,
166
+ );
167
+ this.menuWindow.removeEventListener(
168
+ "phx:page-loading-stop",
169
+ this.handleNavigationStop,
170
+ );
171
+ },
172
+ };
@@ -0,0 +1,69 @@
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.syncActiveNav();
8
+ },
9
+ updated() {
10
+ this.syncActiveNav();
11
+ },
12
+ reconnected() {
13
+ this.lastNavigation = null;
14
+ this.navigationRequest = null;
15
+ this.syncActiveNav();
16
+ },
17
+ syncActiveNav() {
18
+ if (this.navigationDestroyed) return;
19
+ const { sidebarId, activeNav } = this.el.dataset;
20
+ const target = sidebarId
21
+ ? this.el.ownerDocument.getElementById(sidebarId)
22
+ : null;
23
+ if (!target || typeof activeNav !== "string") {
24
+ this.lastNavigation = null;
25
+ this.navigationRequest = null;
26
+ return;
27
+ }
28
+ if (
29
+ this.lastNavigation?.sidebarId === sidebarId &&
30
+ this.lastNavigation.target === target &&
31
+ this.lastNavigation.activeNav === activeNav
32
+ )
33
+ return;
34
+ this.lastNavigation = { sidebarId, target, activeNav };
35
+ const request = {};
36
+ this.navigationRequest = request;
37
+ const failed = () => {
38
+ if (!this.navigationDestroyed && this.navigationRequest === request) {
39
+ this.lastNavigation = null;
40
+ this.navigationRequest = null;
41
+ }
42
+ };
43
+ try {
44
+ Promise.resolve(
45
+ this.pushEventTo(target, "set_active_nav", { active_nav: activeNav }),
46
+ )
47
+ .then((results) => {
48
+ if (
49
+ !Array.isArray(results) ||
50
+ !results.length ||
51
+ results.some(
52
+ (result) =>
53
+ result?.status !== "fulfilled" ||
54
+ result.value?.reply?.status !== "ok",
55
+ )
56
+ )
57
+ failed();
58
+ })
59
+ .catch(failed);
60
+ } catch {
61
+ failed();
62
+ }
63
+ },
64
+ destroyed() {
65
+ this.navigationDestroyed = true;
66
+ this.lastNavigation = null;
67
+ this.navigationRequest = null;
68
+ },
69
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phoenix_duskmoon",
3
- "version": "10.0.3",
3
+ "version": "10.0.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./priv/static/assets/js/phoenix_duskmoon.js",