@wippy-fe/vue-host 0.0.34 → 0.0.36

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.
@@ -0,0 +1,80 @@
1
+ import { LayoutSnapshot, SizeValue, PanelTarget } from '@wippy-fe/proxy';
2
+ import { Ref, ComputedRef, MaybeRefOrGetter } from 'vue';
3
+
4
+ /**
5
+ * Vue 3 reactive bindings for `host.layout.*`. Use from any component
6
+ * inside a Wippy iframe (managed-layout or compat). In compat mode
7
+ * `snapshot.value` stays `null` and mutations are silent no-ops.
8
+ *
9
+ * Boot-time seed comes from `host.layout.snapshot` (sync cache,
10
+ * populated from `AppContext.layout` in the child's `__WIPPY_INITIAL_CONFIG__`).
11
+ * Subsequent updates arrive via the `@layout-change` proxy topic —
12
+ * `events.ts` is the single writer that updates the cache and then
13
+ * fires the event, so reads here are always fresh.
14
+ *
15
+ * ```vue
16
+ * <script setup lang="ts">
17
+ * import { useWippyLayout } from '@wippy-fe/vue-host'
18
+ *
19
+ * const layout = useWippyLayout()
20
+ * // layout.snapshot — Ref<LayoutSnapshot | null>
21
+ * // layout.activeBreakpoint — ComputedRef<string>
22
+ * // layout.panels — ComputedRef<Record<string, unknown>>
23
+ * // layout.isManaged — ComputedRef<boolean>
24
+ *
25
+ * layout.resizePanel('files', '400px')
26
+ * layout.collapsePanel('sidebar')
27
+ * </script>
28
+ * ```
29
+ */
30
+ interface UseWippyLayout {
31
+ /** Live layout snapshot — null outside a managed-layout host. */
32
+ snapshot: Ref<LayoutSnapshot | null>;
33
+ /** Currently-active breakpoint name. Empty string when no snapshot. */
34
+ activeBreakpoint: ComputedRef<string>;
35
+ /** Live per-panel declaration map. Includes `route`, `props`, etc. */
36
+ panels: ComputedRef<Record<string, unknown>>;
37
+ /** True when a managed-layout host is driving this iframe. */
38
+ isManaged: ComputedRef<boolean>;
39
+ resizePanel: (panelId: string, size: SizeValue) => void;
40
+ collapsePanel: (panelId: string) => void;
41
+ expandPanel: (panelId: string) => void;
42
+ movePanel: (panelId: string, target: PanelTarget) => void;
43
+ removePanel: (panelId: string) => void;
44
+ closeModal: (modalId: string) => void;
45
+ removeFloating: (floatingId: string) => void;
46
+ }
47
+ declare function useWippyLayout(): UseWippyLayout;
48
+ /**
49
+ * Reactive ref to a single panel's live def. Returns `null` when the
50
+ * panel doesn't exist in the current snapshot (removed or not declared).
51
+ *
52
+ * `panelId` accepts a static string, a ref, or a getter — so a consumer
53
+ * passing `() => props.id` or a `Ref<string>` will see the panel def
54
+ * change reactively when the id input changes.
55
+ *
56
+ * Common use: read `panel.value?.route` inside an editor component to
57
+ * drive its internal router from the authoritative layout state.
58
+ */
59
+ declare function useWippyPanel(panelId: MaybeRefOrGetter<string>): ComputedRef<Record<string, unknown> | null>;
60
+ /**
61
+ * Reactive active-breakpoint ref. Updates off both `@layout-change`
62
+ * and `@layout-breakpoint`; the latter fires immediately on each
63
+ * transition (no 50ms debounce), so UI that reacts to breakpoint
64
+ * transitions is responsive.
65
+ */
66
+ declare function useWippyBreakpoint(): Ref<string>;
67
+ /**
68
+ * Reactive ref to the main panel's current route. Derived by walking
69
+ * the snapshot's active-breakpoint tree to find the slot with
70
+ * `main: true` and reading `panels[id].route`. Returns empty string
71
+ * when there's no main panel or no route set.
72
+ *
73
+ * Note: the active breakpoint may not have a main panel (e.g. an `sm`
74
+ * layout that drops the main entirely for a mobile-only view). In that
75
+ * case this ref returns ''. Bind to `useWippyPanel(<id>).value?.route`
76
+ * for a specific panel's route across breakpoints.
77
+ */
78
+ declare function useWippyMainRoute(): ComputedRef<string>;
79
+
80
+ export { type UseWippyLayout, useWippyBreakpoint, useWippyLayout, useWippyMainRoute, useWippyPanel };
package/dist/index.js ADDED
@@ -0,0 +1,93 @@
1
+ // src/useWippyLayout.ts
2
+ import { host, on } from "@wippy-fe/proxy";
3
+ import { computed, shallowRef, toValue } from "vue";
4
+ var sharedSnapshot = null;
5
+ var sharedBreakpoint = null;
6
+ function getSharedSnapshot() {
7
+ if (sharedSnapshot)
8
+ return sharedSnapshot;
9
+ const ref = shallowRef(host.layout.snapshot);
10
+ on("@layout-change", (next) => {
11
+ ref.value = next;
12
+ });
13
+ sharedSnapshot = ref;
14
+ return ref;
15
+ }
16
+ function getSharedBreakpoint() {
17
+ if (sharedBreakpoint)
18
+ return sharedBreakpoint;
19
+ const ref = shallowRef(host.layout.snapshot?.activeBreakpoint ?? "");
20
+ on("@layout-change", (snap) => {
21
+ if (snap?.activeBreakpoint)
22
+ ref.value = snap.activeBreakpoint;
23
+ });
24
+ on("@layout-breakpoint", ({ name }) => {
25
+ ref.value = name;
26
+ });
27
+ sharedBreakpoint = ref;
28
+ return ref;
29
+ }
30
+ function useWippyLayout() {
31
+ const snapshot = getSharedSnapshot();
32
+ return {
33
+ snapshot,
34
+ activeBreakpoint: computed(() => snapshot.value?.activeBreakpoint ?? ""),
35
+ panels: computed(() => snapshot.value?.panels ?? {}),
36
+ isManaged: computed(() => snapshot.value !== null),
37
+ resizePanel: (id, size) => host.layout.resizePanel(id, size),
38
+ collapsePanel: (id) => host.layout.collapsePanel(id),
39
+ expandPanel: (id) => host.layout.expandPanel(id),
40
+ movePanel: (id, t) => host.layout.movePanel(id, t),
41
+ removePanel: (id) => host.layout.removePanel(id),
42
+ closeModal: (id) => host.layout.closeModal(id),
43
+ removeFloating: (id) => host.layout.removeFloating(id)
44
+ };
45
+ }
46
+ function useWippyPanel(panelId) {
47
+ const snapshot = getSharedSnapshot();
48
+ return computed(() => {
49
+ const id = toValue(panelId);
50
+ const panels = snapshot.value?.panels;
51
+ return panels?.[id] ?? null;
52
+ });
53
+ }
54
+ function useWippyBreakpoint() {
55
+ return getSharedBreakpoint();
56
+ }
57
+ function useWippyMainRoute() {
58
+ const snapshot = getSharedSnapshot();
59
+ return computed(() => {
60
+ const snap = snapshot.value;
61
+ if (!snap)
62
+ return "";
63
+ const layouts = snap.layouts;
64
+ const tree = layouts[snap.activeBreakpoint ?? "default"] ?? layouts.default;
65
+ const mainId = findMainPanel(tree);
66
+ if (!mainId)
67
+ return "";
68
+ const panelDef = snap.panels[mainId];
69
+ return panelDef?.route ?? "";
70
+ });
71
+ }
72
+ function findMainPanel(node) {
73
+ if (!node || typeof node !== "object")
74
+ return null;
75
+ const n = node;
76
+ if (typeof n.panel === "string" && n.main === true)
77
+ return n.panel;
78
+ if (Array.isArray(n.children)) {
79
+ for (const child of n.children) {
80
+ const found = findMainPanel(child);
81
+ if (found !== null)
82
+ return found;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ export {
88
+ useWippyBreakpoint,
89
+ useWippyLayout,
90
+ useWippyMainRoute,
91
+ useWippyPanel
92
+ };
93
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useWippyLayout.ts"],"sourcesContent":["import type { LayoutSnapshot, PanelTarget, SizeValue } from '@wippy-fe/proxy'\nimport type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue'\nimport { host, on } from '@wippy-fe/proxy'\nimport { computed, shallowRef, toValue } from 'vue'\n\n/**\n * Vue 3 reactive bindings for `host.layout.*`. Use from any component\n * inside a Wippy iframe (managed-layout or compat). In compat mode\n * `snapshot.value` stays `null` and mutations are silent no-ops.\n *\n * Boot-time seed comes from `host.layout.snapshot` (sync cache,\n * populated from `AppContext.layout` in the child's `__WIPPY_INITIAL_CONFIG__`).\n * Subsequent updates arrive via the `@layout-change` proxy topic —\n * `events.ts` is the single writer that updates the cache and then\n * fires the event, so reads here are always fresh.\n *\n * ```vue\n * <script setup lang=\"ts\">\n * import { useWippyLayout } from '@wippy-fe/vue-host'\n *\n * const layout = useWippyLayout()\n * // layout.snapshot — Ref<LayoutSnapshot | null>\n * // layout.activeBreakpoint — ComputedRef<string>\n * // layout.panels — ComputedRef<Record<string, unknown>>\n * // layout.isManaged — ComputedRef<boolean>\n *\n * layout.resizePanel('files', '400px')\n * layout.collapsePanel('sidebar')\n * </script>\n * ```\n */\nexport interface UseWippyLayout {\n /** Live layout snapshot — null outside a managed-layout host. */\n snapshot: Ref<LayoutSnapshot | null>\n /** Currently-active breakpoint name. Empty string when no snapshot. */\n activeBreakpoint: ComputedRef<string>\n /** Live per-panel declaration map. Includes `route`, `props`, etc. */\n panels: ComputedRef<Record<string, unknown>>\n /** True when a managed-layout host is driving this iframe. */\n isManaged: ComputedRef<boolean>\n\n resizePanel: (panelId: string, size: SizeValue) => void\n collapsePanel: (panelId: string) => void\n expandPanel: (panelId: string) => void\n movePanel: (panelId: string, target: PanelTarget) => void\n removePanel: (panelId: string) => void\n closeModal: (modalId: string) => void\n removeFloating: (floatingId: string) => void\n}\n\n/**\n * Module-scoped shared snapshot ref + subscription. Every hook in this\n * package reads from the same `ShallowRef`, so a component using\n * `useWippyLayout()` + `useWippyPanel('a')` + `useWippyBreakpoint()`\n * pays for ONE `@layout-change` subscription, not three.\n *\n * The subscription is installed lazily on first call and lives for the\n * iframe's lifetime — no per-component cleanup needed (one closure on\n * a process-wide bus is cheap; in compat-mode hosts this never fires).\n */\nlet sharedSnapshot: ShallowRef<LayoutSnapshot | null> | null = null\nlet sharedBreakpoint: ShallowRef<string> | null = null\n\nfunction getSharedSnapshot(): ShallowRef<LayoutSnapshot | null> {\n if (sharedSnapshot)\n return sharedSnapshot\n const ref = shallowRef<LayoutSnapshot | null>(host.layout.snapshot)\n on('@layout-change', (next) => {\n ref.value = next\n })\n sharedSnapshot = ref\n return ref\n}\n\nfunction getSharedBreakpoint(): ShallowRef<string> {\n if (sharedBreakpoint)\n return sharedBreakpoint\n const ref = shallowRef<string>(host.layout.snapshot?.activeBreakpoint ?? '')\n on('@layout-change', (snap) => {\n if (snap?.activeBreakpoint)\n ref.value = snap.activeBreakpoint\n })\n on('@layout-breakpoint', ({ name }) => {\n ref.value = name\n })\n sharedBreakpoint = ref\n return ref\n}\n\nexport function useWippyLayout(): UseWippyLayout {\n const snapshot = getSharedSnapshot()\n\n return {\n snapshot,\n activeBreakpoint: computed(() => snapshot.value?.activeBreakpoint ?? ''),\n panels: computed(() => (snapshot.value?.panels ?? {}) as Record<string, unknown>),\n isManaged: computed(() => snapshot.value !== null),\n resizePanel: (id, size) => host.layout.resizePanel(id, size),\n collapsePanel: id => host.layout.collapsePanel(id),\n expandPanel: id => host.layout.expandPanel(id),\n movePanel: (id, t) => host.layout.movePanel(id, t),\n removePanel: id => host.layout.removePanel(id),\n closeModal: id => host.layout.closeModal(id),\n removeFloating: id => host.layout.removeFloating(id),\n }\n}\n\n/**\n * Reactive ref to a single panel's live def. Returns `null` when the\n * panel doesn't exist in the current snapshot (removed or not declared).\n *\n * `panelId` accepts a static string, a ref, or a getter — so a consumer\n * passing `() => props.id` or a `Ref<string>` will see the panel def\n * change reactively when the id input changes.\n *\n * Common use: read `panel.value?.route` inside an editor component to\n * drive its internal router from the authoritative layout state.\n */\nexport function useWippyPanel(\n panelId: MaybeRefOrGetter<string>,\n): ComputedRef<Record<string, unknown> | null> {\n const snapshot = getSharedSnapshot()\n return computed(() => {\n const id = toValue(panelId)\n const panels = snapshot.value?.panels as Record<string, unknown> | undefined\n return (panels?.[id] as Record<string, unknown> | undefined) ?? null\n })\n}\n\n/**\n * Reactive active-breakpoint ref. Updates off both `@layout-change`\n * and `@layout-breakpoint`; the latter fires immediately on each\n * transition (no 50ms debounce), so UI that reacts to breakpoint\n * transitions is responsive.\n */\nexport function useWippyBreakpoint(): Ref<string> {\n return getSharedBreakpoint()\n}\n\n/**\n * Reactive ref to the main panel's current route. Derived by walking\n * the snapshot's active-breakpoint tree to find the slot with\n * `main: true` and reading `panels[id].route`. Returns empty string\n * when there's no main panel or no route set.\n *\n * Note: the active breakpoint may not have a main panel (e.g. an `sm`\n * layout that drops the main entirely for a mobile-only view). In that\n * case this ref returns ''. Bind to `useWippyPanel(<id>).value?.route`\n * for a specific panel's route across breakpoints.\n */\nexport function useWippyMainRoute(): ComputedRef<string> {\n const snapshot = getSharedSnapshot()\n return computed(() => {\n const snap = snapshot.value\n if (!snap)\n return ''\n const layouts = snap.layouts as Record<string, unknown>\n const tree = layouts[snap.activeBreakpoint ?? 'default'] ?? layouts.default\n const mainId = findMainPanel(tree)\n if (!mainId)\n return ''\n const panelDef = (snap.panels as Record<string, { route?: string }>)[mainId]\n return panelDef?.route ?? ''\n })\n}\n\nfunction findMainPanel(node: unknown): string | null {\n if (!node || typeof node !== 'object')\n return null\n const n = node as { panel?: string, main?: boolean, children?: unknown[] }\n if (typeof n.panel === 'string' && n.main === true)\n return n.panel\n if (Array.isArray(n.children)) {\n for (const child of n.children) {\n const found = findMainPanel(child)\n if (found !== null)\n return found\n }\n }\n return null\n}\n"],"mappings":";AAEA,SAAS,MAAM,UAAU;AACzB,SAAS,UAAU,YAAY,eAAe;AAyD9C,IAAI,iBAA2D;AAC/D,IAAI,mBAA8C;AAElD,SAAS,oBAAuD;AAC9D,MAAI;AACF,WAAO;AACT,QAAM,MAAM,WAAkC,KAAK,OAAO,QAAQ;AAClE,KAAG,kBAAkB,CAAC,SAAS;AAC7B,QAAI,QAAQ;AAAA,EACd,CAAC;AACD,mBAAiB;AACjB,SAAO;AACT;AAEA,SAAS,sBAA0C;AACjD,MAAI;AACF,WAAO;AACT,QAAM,MAAM,WAAmB,KAAK,OAAO,UAAU,oBAAoB,EAAE;AAC3E,KAAG,kBAAkB,CAAC,SAAS;AAC7B,QAAI,MAAM;AACR,UAAI,QAAQ,KAAK;AAAA,EACrB,CAAC;AACD,KAAG,sBAAsB,CAAC,EAAE,KAAK,MAAM;AACrC,QAAI,QAAQ;AAAA,EACd,CAAC;AACD,qBAAmB;AACnB,SAAO;AACT;AAEO,SAAS,iBAAiC;AAC/C,QAAM,WAAW,kBAAkB;AAEnC,SAAO;AAAA,IACL;AAAA,IACA,kBAAkB,SAAS,MAAM,SAAS,OAAO,oBAAoB,EAAE;AAAA,IACvE,QAAQ,SAAS,MAAO,SAAS,OAAO,UAAU,CAAC,CAA6B;AAAA,IAChF,WAAW,SAAS,MAAM,SAAS,UAAU,IAAI;AAAA,IACjD,aAAa,CAAC,IAAI,SAAS,KAAK,OAAO,YAAY,IAAI,IAAI;AAAA,IAC3D,eAAe,QAAM,KAAK,OAAO,cAAc,EAAE;AAAA,IACjD,aAAa,QAAM,KAAK,OAAO,YAAY,EAAE;AAAA,IAC7C,WAAW,CAAC,IAAI,MAAM,KAAK,OAAO,UAAU,IAAI,CAAC;AAAA,IACjD,aAAa,QAAM,KAAK,OAAO,YAAY,EAAE;AAAA,IAC7C,YAAY,QAAM,KAAK,OAAO,WAAW,EAAE;AAAA,IAC3C,gBAAgB,QAAM,KAAK,OAAO,eAAe,EAAE;AAAA,EACrD;AACF;AAaO,SAAS,cACd,SAC6C;AAC7C,QAAM,WAAW,kBAAkB;AACnC,SAAO,SAAS,MAAM;AACpB,UAAM,KAAK,QAAQ,OAAO;AAC1B,UAAM,SAAS,SAAS,OAAO;AAC/B,WAAQ,SAAS,EAAE,KAA6C;AAAA,EAClE,CAAC;AACH;AAQO,SAAS,qBAAkC;AAChD,SAAO,oBAAoB;AAC7B;AAaO,SAAS,oBAAyC;AACvD,QAAM,WAAW,kBAAkB;AACnC,SAAO,SAAS,MAAM;AACpB,UAAM,OAAO,SAAS;AACtB,QAAI,CAAC;AACH,aAAO;AACT,UAAM,UAAU,KAAK;AACrB,UAAM,OAAO,QAAQ,KAAK,oBAAoB,SAAS,KAAK,QAAQ;AACpE,UAAM,SAAS,cAAc,IAAI;AACjC,QAAI,CAAC;AACH,aAAO;AACT,UAAM,WAAY,KAAK,OAA8C,MAAM;AAC3E,WAAO,UAAU,SAAS;AAAA,EAC5B,CAAC;AACH;AAEA,SAAS,cAAc,MAA8B;AACnD,MAAI,CAAC,QAAQ,OAAO,SAAS;AAC3B,WAAO;AACT,QAAM,IAAI;AACV,MAAI,OAAO,EAAE,UAAU,YAAY,EAAE,SAAS;AAC5C,WAAO,EAAE;AACX,MAAI,MAAM,QAAQ,EAAE,QAAQ,GAAG;AAC7B,eAAW,SAAS,EAAE,UAAU;AAC9B,YAAM,QAAQ,cAAc,KAAK;AACjC,UAAI,UAAU;AACZ,eAAO;AAAA,IACX;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
package/package.json CHANGED
@@ -1,18 +1,31 @@
1
1
  {
2
2
  "name": "@wippy-fe/vue-host",
3
- "version": "0.0.34",
4
- "description": "Vue 3 reactive bindings for the Wippy host.* proxy API. Hooks for layout state, panel live state, breakpoints, and per-panel routes — inside a Wippy iframe.",
3
+ "version": "0.0.36",
4
+ "description": "Vue 3 reactive bindings for the Wippy host.* proxy API. Hooks for layout state, panel live state, breakpoints, and per-panel routes - inside a Wippy iframe.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
- "main": "src/index.ts",
8
- "types": "src/index.ts",
7
+ "sideEffects": false,
8
+ "main": "dist/index.js",
9
+ "module": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
9
18
  "files": [
10
- "src/",
11
19
  "package.json",
20
+ "dist/",
12
21
  "README.md"
13
22
  ],
14
23
  "peerDependencies": {
15
24
  "vue": "^3.5.0",
16
- "@wippy-fe/proxy": "0.0.34"
25
+ "@wippy-fe/proxy": "^0.0.36"
26
+ },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "clean": "rimraf dist"
17
30
  }
18
31
  }
package/src/index.ts DELETED
@@ -1,7 +0,0 @@
1
- export {
2
- useWippyBreakpoint,
3
- useWippyLayout,
4
- useWippyMainRoute,
5
- useWippyPanel,
6
- } from './useWippyLayout'
7
- export type { UseWippyLayout } from './useWippyLayout'
@@ -1,399 +0,0 @@
1
- /**
2
- * @vitest-environment jsdom
3
- */
4
- import type { LayoutSnapshot } from '@wippy-fe/proxy'
5
- import { mount } from '@vue/test-utils'
6
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
- import { defineComponent, h, isRef, ref } from 'vue'
8
-
9
- // Mock @wippy-fe/proxy with a controllable host + on(). The shared
10
- // state in useWippyLayout reads `host.layout.snapshot` ONCE at first
11
- // hook call (per iframe lifetime — and per Vitest module), so we
12
- // pre-seed the snapshot, then drive updates by firing registered
13
- // `@layout-change` listeners.
14
-
15
- const mockState = {
16
- snapshot: null as LayoutSnapshot | null,
17
- listeners: new Map<string, Set<(payload: any) => void>>(),
18
- hostLayoutMethods: {
19
- resizePanel: vi.fn(),
20
- collapsePanel: vi.fn(),
21
- expandPanel: vi.fn(),
22
- movePanel: vi.fn(),
23
- removePanel: vi.fn(),
24
- closeModal: vi.fn(),
25
- removeFloating: vi.fn(),
26
- },
27
- }
28
-
29
- function fire(topic: string, payload: any) {
30
- const set = mockState.listeners.get(topic)
31
- if (set) {
32
- for (const cb of set) cb(payload)
33
- }
34
- }
35
-
36
- function listenerCount(topic: string): number {
37
- return mockState.listeners.get(topic)?.size ?? 0
38
- }
39
-
40
- vi.mock('@wippy-fe/proxy', () => ({
41
- host: {
42
- layout: {
43
- get snapshot() {
44
- return mockState.snapshot
45
- },
46
- ...mockState.hostLayoutMethods,
47
- },
48
- },
49
- on: (topic: string, cb: (payload: any) => void) => {
50
- let set = mockState.listeners.get(topic)
51
- if (!set) {
52
- set = new Set()
53
- mockState.listeners.set(topic, set)
54
- }
55
- set.add(cb)
56
- return () => {
57
- set!.delete(cb)
58
- }
59
- },
60
- }))
61
-
62
- // Import AFTER the mock — the hooks resolve `@wippy-fe/proxy` at module-load.
63
- const {
64
- useWippyLayout,
65
- useWippyPanel,
66
- useWippyBreakpoint,
67
- useWippyMainRoute,
68
- } = await import('./useWippyLayout')
69
-
70
- function makeSnapshot(over: Partial<LayoutSnapshot> = {}): LayoutSnapshot {
71
- return {
72
- layouts: { default: { direction: 'horizontal', children: [] } },
73
- panels: {},
74
- activeBreakpoint: 'default',
75
- ...over,
76
- }
77
- }
78
-
79
- beforeEach(() => {
80
- // Reset all mock counters; the shared snapshot ref persists across
81
- // tests by design (it's installed once on first hook call).
82
- for (const fn of Object.values(mockState.hostLayoutMethods)) fn.mockReset()
83
- })
84
-
85
- afterEach(() => {
86
- // Reset mock state for next test
87
- mockState.snapshot = null
88
- })
89
-
90
- describe('useWippyLayout', () => {
91
- it('seeds snapshot.value from host.layout.snapshot at first call', () => {
92
- const initial = makeSnapshot({ activeBreakpoint: 'md' })
93
- mockState.snapshot = initial
94
-
95
- let captured: any
96
- const Comp = defineComponent({
97
- setup() {
98
- const layout = useWippyLayout()
99
- captured = layout
100
- return () => h('div')
101
- },
102
- })
103
- const w = mount(Comp)
104
-
105
- // First call seeds. Snapshot ref tracks shared state from this
106
- // point — even if mockState.snapshot changes, the ref doesn't
107
- // until @layout-change fires.
108
- expect(captured.snapshot.value).toBeDefined()
109
- w.unmount()
110
- })
111
-
112
- it('updates snapshot when @layout-change fires', () => {
113
- let captured: any
114
- const Comp = defineComponent({
115
- setup() {
116
- captured = useWippyLayout()
117
- return () => h('div')
118
- },
119
- })
120
- const w = mount(Comp)
121
-
122
- const next = makeSnapshot({ activeBreakpoint: 'sm' })
123
- fire('@layout-change', next)
124
- expect(captured.snapshot.value).toBe(next)
125
- expect(captured.activeBreakpoint.value).toBe('sm')
126
- w.unmount()
127
- })
128
-
129
- it('isManaged tracks whether snapshot is null', () => {
130
- let captured: any
131
- const Comp = defineComponent({
132
- setup() {
133
- captured = useWippyLayout()
134
- return () => h('div')
135
- },
136
- })
137
- const w = mount(Comp)
138
-
139
- fire('@layout-change', null)
140
- expect(captured.isManaged.value).toBe(false)
141
- fire('@layout-change', makeSnapshot())
142
- expect(captured.isManaged.value).toBe(true)
143
- w.unmount()
144
- })
145
-
146
- it('mutation methods call host.layout.* through', () => {
147
- let captured: any
148
- const Comp = defineComponent({
149
- setup() {
150
- captured = useWippyLayout()
151
- return () => h('div')
152
- },
153
- })
154
- const w = mount(Comp)
155
-
156
- captured.resizePanel('files', '300px')
157
- captured.collapsePanel('nav')
158
- captured.expandPanel('nav')
159
- captured.movePanel('a', { position: 'right' })
160
- captured.removePanel('x')
161
- captured.closeModal('m')
162
- captured.removeFloating('f')
163
-
164
- expect(mockState.hostLayoutMethods.resizePanel).toHaveBeenCalledWith('files', '300px')
165
- expect(mockState.hostLayoutMethods.collapsePanel).toHaveBeenCalledWith('nav')
166
- expect(mockState.hostLayoutMethods.expandPanel).toHaveBeenCalledWith('nav')
167
- expect(mockState.hostLayoutMethods.movePanel).toHaveBeenCalledWith('a', { position: 'right' })
168
- expect(mockState.hostLayoutMethods.removePanel).toHaveBeenCalledWith('x')
169
- expect(mockState.hostLayoutMethods.closeModal).toHaveBeenCalledWith('m')
170
- expect(mockState.hostLayoutMethods.removeFloating).toHaveBeenCalledWith('f')
171
- w.unmount()
172
- })
173
- })
174
-
175
- describe('useWippyPanel', () => {
176
- it('returns panel def from snapshot.panels', () => {
177
- let panelRef: any
178
- const Comp = defineComponent({
179
- setup() {
180
- panelRef = useWippyPanel('editor')
181
- return () => h('div')
182
- },
183
- })
184
- const w = mount(Comp)
185
-
186
- fire('@layout-change', makeSnapshot({
187
- panels: { editor: { kind: 'page', id: 'editor', route: '/foo' } },
188
- }))
189
- expect(panelRef.value).toMatchObject({ kind: 'page', id: 'editor', route: '/foo' })
190
- w.unmount()
191
- })
192
-
193
- it('returns null when panel id is not in snapshot', () => {
194
- let panelRef: any
195
- const Comp = defineComponent({
196
- setup() {
197
- panelRef = useWippyPanel('ghost')
198
- return () => h('div')
199
- },
200
- })
201
- const w = mount(Comp)
202
-
203
- fire('@layout-change', makeSnapshot({ panels: {} }))
204
- expect(panelRef.value).toBeNull()
205
- w.unmount()
206
- })
207
-
208
- it('reacts to a Ref<string> id input', () => {
209
- const id = ref('a')
210
- let panelRef: any
211
- const Comp = defineComponent({
212
- setup() {
213
- panelRef = useWippyPanel(id)
214
- return () => h('div')
215
- },
216
- })
217
- const w = mount(Comp)
218
-
219
- fire('@layout-change', makeSnapshot({
220
- panels: {
221
- a: { kind: 'page', id: 'a', route: '/aa' },
222
- b: { kind: 'page', id: 'b', route: '/bb' },
223
- },
224
- }))
225
- expect(panelRef.value).toMatchObject({ id: 'a' })
226
- id.value = 'b'
227
- expect(panelRef.value).toMatchObject({ id: 'b' })
228
- w.unmount()
229
- })
230
-
231
- it('reacts to a getter id input', () => {
232
- const id = ref('a')
233
- let panelRef: any
234
- const Comp = defineComponent({
235
- setup() {
236
- panelRef = useWippyPanel(() => id.value)
237
- return () => h('div')
238
- },
239
- })
240
- const w = mount(Comp)
241
-
242
- fire('@layout-change', makeSnapshot({
243
- panels: {
244
- a: { kind: 'page', id: 'a' },
245
- b: { kind: 'page', id: 'b' },
246
- },
247
- }))
248
- expect(panelRef.value?.id).toBe('a')
249
- id.value = 'b'
250
- expect(panelRef.value?.id).toBe('b')
251
- w.unmount()
252
- })
253
- })
254
-
255
- describe('useWippyBreakpoint', () => {
256
- it('returns a ref', () => {
257
- let bp: any
258
- const Comp = defineComponent({
259
- setup() {
260
- bp = useWippyBreakpoint()
261
- return () => h('div')
262
- },
263
- })
264
- const w = mount(Comp)
265
- expect(isRef(bp)).toBe(true)
266
- w.unmount()
267
- })
268
-
269
- it('updates from @layout-breakpoint event', () => {
270
- let bp: any
271
- const Comp = defineComponent({
272
- setup() {
273
- bp = useWippyBreakpoint()
274
- return () => h('div')
275
- },
276
- })
277
- const w = mount(Comp)
278
- fire('@layout-breakpoint', { name: 'lg', width: 1200 })
279
- expect(bp.value).toBe('lg')
280
- w.unmount()
281
- })
282
-
283
- it('also updates from @layout-change activeBreakpoint', () => {
284
- let bp: any
285
- const Comp = defineComponent({
286
- setup() {
287
- bp = useWippyBreakpoint()
288
- return () => h('div')
289
- },
290
- })
291
- const w = mount(Comp)
292
- fire('@layout-change', makeSnapshot({ activeBreakpoint: 'sm' }))
293
- expect(bp.value).toBe('sm')
294
- w.unmount()
295
- })
296
- })
297
-
298
- describe('useWippyMainRoute', () => {
299
- it('walks active-breakpoint tree to find main panel', () => {
300
- let route: any
301
- const Comp = defineComponent({
302
- setup() {
303
- route = useWippyMainRoute()
304
- return () => h('div')
305
- },
306
- })
307
- const w = mount(Comp)
308
-
309
- fire('@layout-change', makeSnapshot({
310
- activeBreakpoint: 'default',
311
- layouts: {
312
- default: {
313
- direction: 'horizontal',
314
- children: [
315
- { panel: 'sidebar', size: '200px' },
316
- { panel: 'editor', size: '1fr', main: true },
317
- ] as any,
318
- },
319
- },
320
- panels: {
321
- sidebar: { kind: 'component', tagName: 'x' },
322
- editor: { kind: 'page', id: 'e1', route: '/edit' },
323
- },
324
- }))
325
- expect(route.value).toBe('/edit')
326
- w.unmount()
327
- })
328
-
329
- it('returns empty string when no main panel in active layout', () => {
330
- let route: any
331
- const Comp = defineComponent({
332
- setup() {
333
- route = useWippyMainRoute()
334
- return () => h('div')
335
- },
336
- })
337
- const w = mount(Comp)
338
- fire('@layout-change', makeSnapshot({
339
- layouts: {
340
- default: { direction: 'horizontal', children: [{ panel: 'a', size: '1fr' }] },
341
- },
342
- panels: { a: { kind: 'page', id: 'a' } },
343
- }))
344
- expect(route.value).toBe('')
345
- w.unmount()
346
- })
347
-
348
- it('finds main inside a nested tree', () => {
349
- let route: any
350
- const Comp = defineComponent({
351
- setup() {
352
- route = useWippyMainRoute()
353
- return () => h('div')
354
- },
355
- })
356
- const w = mount(Comp)
357
- fire('@layout-change', makeSnapshot({
358
- layouts: {
359
- default: {
360
- direction: 'horizontal',
361
- children: [
362
- {
363
- direction: 'vertical',
364
- children: [{ panel: 'editor', size: '1fr', main: true }],
365
- } as any,
366
- ],
367
- },
368
- },
369
- panels: { editor: { kind: 'page', id: 'e', route: '/deep' } },
370
- }))
371
- expect(route.value).toBe('/deep')
372
- w.unmount()
373
- })
374
- })
375
-
376
- describe('subscription sharing', () => {
377
- it('multiple hooks across multiple components share ONE @layout-change listener', () => {
378
- const before = listenerCount('@layout-change')
379
-
380
- const Comp = defineComponent({
381
- setup() {
382
- useWippyLayout()
383
- useWippyPanel('a')
384
- useWippyMainRoute()
385
- return () => h('div')
386
- },
387
- })
388
- const w1 = mount(Comp)
389
- const w2 = mount(Comp)
390
-
391
- // Module-scope shared snapshot — same listener regardless of how
392
- // many hooks / components consume it.
393
- expect(listenerCount('@layout-change') - before).toBeLessThanOrEqual(2)
394
- // (At most: snapshot listener + breakpoint listener, both module-scoped.)
395
-
396
- w1.unmount()
397
- w2.unmount()
398
- })
399
- })
@@ -1,181 +0,0 @@
1
- import type { LayoutSnapshot, PanelTarget, SizeValue } from '@wippy-fe/proxy'
2
- import type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue'
3
- import { host, on } from '@wippy-fe/proxy'
4
- import { computed, shallowRef, toValue } from 'vue'
5
-
6
- /**
7
- * Vue 3 reactive bindings for `host.layout.*`. Use from any component
8
- * inside a Wippy iframe (managed-layout or compat). In compat mode
9
- * `snapshot.value` stays `null` and mutations are silent no-ops.
10
- *
11
- * Boot-time seed comes from `host.layout.snapshot` (sync cache,
12
- * populated from `AppContext.layout` in the child's `__WIPPY_INITIAL_CONFIG__`).
13
- * Subsequent updates arrive via the `@layout-change` proxy topic —
14
- * `events.ts` is the single writer that updates the cache and then
15
- * fires the event, so reads here are always fresh.
16
- *
17
- * ```vue
18
- * <script setup lang="ts">
19
- * import { useWippyLayout } from '@wippy-fe/vue-host'
20
- *
21
- * const layout = useWippyLayout()
22
- * // layout.snapshot — Ref<LayoutSnapshot | null>
23
- * // layout.activeBreakpoint — ComputedRef<string>
24
- * // layout.panels — ComputedRef<Record<string, unknown>>
25
- * // layout.isManaged — ComputedRef<boolean>
26
- *
27
- * layout.resizePanel('files', '400px')
28
- * layout.collapsePanel('sidebar')
29
- * </script>
30
- * ```
31
- */
32
- export interface UseWippyLayout {
33
- /** Live layout snapshot — null outside a managed-layout host. */
34
- snapshot: Ref<LayoutSnapshot | null>
35
- /** Currently-active breakpoint name. Empty string when no snapshot. */
36
- activeBreakpoint: ComputedRef<string>
37
- /** Live per-panel declaration map. Includes `route`, `props`, etc. */
38
- panels: ComputedRef<Record<string, unknown>>
39
- /** True when a managed-layout host is driving this iframe. */
40
- isManaged: ComputedRef<boolean>
41
-
42
- resizePanel: (panelId: string, size: SizeValue) => void
43
- collapsePanel: (panelId: string) => void
44
- expandPanel: (panelId: string) => void
45
- movePanel: (panelId: string, target: PanelTarget) => void
46
- removePanel: (panelId: string) => void
47
- closeModal: (modalId: string) => void
48
- removeFloating: (floatingId: string) => void
49
- }
50
-
51
- /**
52
- * Module-scoped shared snapshot ref + subscription. Every hook in this
53
- * package reads from the same `ShallowRef`, so a component using
54
- * `useWippyLayout()` + `useWippyPanel('a')` + `useWippyBreakpoint()`
55
- * pays for ONE `@layout-change` subscription, not three.
56
- *
57
- * The subscription is installed lazily on first call and lives for the
58
- * iframe's lifetime — no per-component cleanup needed (one closure on
59
- * a process-wide bus is cheap; in compat-mode hosts this never fires).
60
- */
61
- let sharedSnapshot: ShallowRef<LayoutSnapshot | null> | null = null
62
- let sharedBreakpoint: ShallowRef<string> | null = null
63
-
64
- function getSharedSnapshot(): ShallowRef<LayoutSnapshot | null> {
65
- if (sharedSnapshot)
66
- return sharedSnapshot
67
- const ref = shallowRef<LayoutSnapshot | null>(host.layout.snapshot)
68
- on('@layout-change', (next) => {
69
- ref.value = next
70
- })
71
- sharedSnapshot = ref
72
- return ref
73
- }
74
-
75
- function getSharedBreakpoint(): ShallowRef<string> {
76
- if (sharedBreakpoint)
77
- return sharedBreakpoint
78
- const ref = shallowRef<string>(host.layout.snapshot?.activeBreakpoint ?? '')
79
- on('@layout-change', (snap) => {
80
- if (snap?.activeBreakpoint)
81
- ref.value = snap.activeBreakpoint
82
- })
83
- on('@layout-breakpoint', ({ name }) => {
84
- ref.value = name
85
- })
86
- sharedBreakpoint = ref
87
- return ref
88
- }
89
-
90
- export function useWippyLayout(): UseWippyLayout {
91
- const snapshot = getSharedSnapshot()
92
-
93
- return {
94
- snapshot,
95
- activeBreakpoint: computed(() => snapshot.value?.activeBreakpoint ?? ''),
96
- panels: computed(() => (snapshot.value?.panels ?? {}) as Record<string, unknown>),
97
- isManaged: computed(() => snapshot.value !== null),
98
- resizePanel: (id, size) => host.layout.resizePanel(id, size),
99
- collapsePanel: id => host.layout.collapsePanel(id),
100
- expandPanel: id => host.layout.expandPanel(id),
101
- movePanel: (id, t) => host.layout.movePanel(id, t),
102
- removePanel: id => host.layout.removePanel(id),
103
- closeModal: id => host.layout.closeModal(id),
104
- removeFloating: id => host.layout.removeFloating(id),
105
- }
106
- }
107
-
108
- /**
109
- * Reactive ref to a single panel's live def. Returns `null` when the
110
- * panel doesn't exist in the current snapshot (removed or not declared).
111
- *
112
- * `panelId` accepts a static string, a ref, or a getter — so a consumer
113
- * passing `() => props.id` or a `Ref<string>` will see the panel def
114
- * change reactively when the id input changes.
115
- *
116
- * Common use: read `panel.value?.route` inside an editor component to
117
- * drive its internal router from the authoritative layout state.
118
- */
119
- export function useWippyPanel(
120
- panelId: MaybeRefOrGetter<string>,
121
- ): ComputedRef<Record<string, unknown> | null> {
122
- const snapshot = getSharedSnapshot()
123
- return computed(() => {
124
- const id = toValue(panelId)
125
- const panels = snapshot.value?.panels as Record<string, unknown> | undefined
126
- return (panels?.[id] as Record<string, unknown> | undefined) ?? null
127
- })
128
- }
129
-
130
- /**
131
- * Reactive active-breakpoint ref. Updates off both `@layout-change`
132
- * and `@layout-breakpoint`; the latter fires immediately on each
133
- * transition (no 50ms debounce), so UI that reacts to breakpoint
134
- * transitions is responsive.
135
- */
136
- export function useWippyBreakpoint(): Ref<string> {
137
- return getSharedBreakpoint()
138
- }
139
-
140
- /**
141
- * Reactive ref to the main panel's current route. Derived by walking
142
- * the snapshot's active-breakpoint tree to find the slot with
143
- * `main: true` and reading `panels[id].route`. Returns empty string
144
- * when there's no main panel or no route set.
145
- *
146
- * Note: the active breakpoint may not have a main panel (e.g. an `sm`
147
- * layout that drops the main entirely for a mobile-only view). In that
148
- * case this ref returns ''. Bind to `useWippyPanel(<id>).value?.route`
149
- * for a specific panel's route across breakpoints.
150
- */
151
- export function useWippyMainRoute(): ComputedRef<string> {
152
- const snapshot = getSharedSnapshot()
153
- return computed(() => {
154
- const snap = snapshot.value
155
- if (!snap)
156
- return ''
157
- const layouts = snap.layouts as Record<string, unknown>
158
- const tree = layouts[snap.activeBreakpoint ?? 'default'] ?? layouts.default
159
- const mainId = findMainPanel(tree)
160
- if (!mainId)
161
- return ''
162
- const panelDef = (snap.panels as Record<string, { route?: string }>)[mainId]
163
- return panelDef?.route ?? ''
164
- })
165
- }
166
-
167
- function findMainPanel(node: unknown): string | null {
168
- if (!node || typeof node !== 'object')
169
- return null
170
- const n = node as { panel?: string, main?: boolean, children?: unknown[] }
171
- if (typeof n.panel === 'string' && n.main === true)
172
- return n.panel
173
- if (Array.isArray(n.children)) {
174
- for (const child of n.children) {
175
- const found = findMainPanel(child)
176
- if (found !== null)
177
- return found
178
- }
179
- }
180
- return null
181
- }