phonux 0.1.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.
- package/DESIGN.md +985 -0
- package/LICENSE +21 -0
- package/Panel.d.ts +76 -0
- package/Panel.js +45 -0
- package/PanelFrame.d.ts +154 -0
- package/PanelFrame.js +160 -0
- package/PanelRow.d.ts +46 -0
- package/PanelRow.js +98 -0
- package/PanelRowSlot.d.ts +74 -0
- package/PanelRowSlot.js +68 -0
- package/PhoneDetectPrompt.d.ts +18 -0
- package/PhoneDetectPrompt.js +52 -0
- package/README.md +86 -0
- package/Workspace.d.ts +65 -0
- package/Workspace.js +47 -0
- package/defaultTheme.d.ts +10 -0
- package/defaultTheme.js +26 -0
- package/directionalTransition.d.ts +33 -0
- package/directionalTransition.js +24 -0
- package/dragToClose.d.ts +60 -0
- package/dragToClose.js +151 -0
- package/fakeHost.d.ts +33 -0
- package/fakeHost.js +85 -0
- package/hostApi.d.ts +247 -0
- package/hostApi.js +54 -0
- package/index.d.ts +46 -0
- package/index.js +30 -0
- package/package.json +30 -0
- package/panelCapacity.d.ts +15 -0
- package/panelCapacity.js +19 -0
- package/panelRowEntry.d.ts +37 -0
- package/panelRowEntry.js +11 -0
- package/panelRowLayout.d.ts +57 -0
- package/panelRowLayout.js +52 -0
- package/panelRowOrder.d.ts +46 -0
- package/panelRowOrder.js +105 -0
- package/panelTiers.d.ts +24 -0
- package/panelTiers.js +29 -0
- package/panelWindow.d.ts +170 -0
- package/panelWindow.js +243 -0
- package/panels/usePanelClosing.d.ts +58 -0
- package/panels/usePanelClosing.js +140 -0
- package/panels/usePanelManager.d.ts +74 -0
- package/panels/usePanelManager.js +403 -0
- package/panels/useProvidePanels.d.ts +82 -0
- package/panels/useProvidePanels.js +142 -0
- package/panels/useRowScrollGesture.d.ts +2 -0
- package/panels/useRowScrollGesture.js +70 -0
- package/panels/useWorkspacePersistence.d.ts +14 -0
- package/panels/useWorkspacePersistence.js +74 -0
- package/phoneModels.d.ts +26 -0
- package/phoneModels.js +43 -0
- package/snapshots.d.ts +58 -0
- package/snapshots.js +22 -0
- package/viewRegistry.d.ts +23 -0
- package/viewRegistry.js +30 -0
- package/viewState.d.ts +32 -0
- package/viewState.js +135 -0
- package/windowOverlay.d.ts +87 -0
- package/windowOverlay.js +137 -0
- package/workspaceState.d.ts +63 -0
- package/workspaceState.js +95 -0
package/windowOverlay.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* WindowOverlay: how a view shows something over the WINDOW (a dialog, a full-screen viewer, a toast) with no
|
|
4
|
+
* host wiring and no Portal. It registers its children with a store, and the host's two OverlaySlots render
|
|
5
|
+
* them. Usage, why content is hoisted, and the numbered contract the content must keep (the layer adds no
|
|
6
|
+
* wrapper, so it cannot enforce it): DESIGN.md, "`WindowOverlay`: window-level content with no Portal".
|
|
7
|
+
* Not to be confused with Panel's `overlay` prop, which decorates ONE view and is clipped to it.
|
|
8
|
+
*/
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { Fragment, createContext, useContext, useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from 'react';
|
|
11
|
+
/**
|
|
12
|
+
* The z-index each slot's content applies to ITS OWN root. WindowOverlay adds no wrapper element,
|
|
13
|
+
* so it cannot set it for you. dialog 2000 clears the host's 1400 drag strip (the Closed Pages viewer);
|
|
14
|
+
* toast 1400 is MUI's snackbar band (the undo toast).
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export const OVERLAY_Z = { dialog: 2000, toast: 1400 };
|
|
18
|
+
const EMPTY = Object.freeze([]);
|
|
19
|
+
/** External store, NOT React state: a registration re-renders neither the host nor any view. @public */
|
|
20
|
+
export class OverlayStore {
|
|
21
|
+
entries = new Map(); // Map keeps FIRST-insertion position across overwrites
|
|
22
|
+
listeners = new Set();
|
|
23
|
+
slots = new Map();
|
|
24
|
+
warned = new Set();
|
|
25
|
+
snap = { dialog: EMPTY, toast: EMPTY };
|
|
26
|
+
/** Count of listener notifications (test seam: pins the same-node short-circuit). */
|
|
27
|
+
notifications = 0;
|
|
28
|
+
set(id, entry) {
|
|
29
|
+
const prev = this.entries.get(id);
|
|
30
|
+
if (prev && prev.slot === entry.slot && prev.node === entry.node)
|
|
31
|
+
return;
|
|
32
|
+
this.entries.set(id, entry);
|
|
33
|
+
this.rebuild(entry.slot);
|
|
34
|
+
if (prev && prev.slot !== entry.slot)
|
|
35
|
+
this.rebuild(prev.slot);
|
|
36
|
+
this.emit();
|
|
37
|
+
}
|
|
38
|
+
remove(id) {
|
|
39
|
+
const prev = this.entries.get(id);
|
|
40
|
+
if (!prev)
|
|
41
|
+
return;
|
|
42
|
+
this.entries.delete(id);
|
|
43
|
+
this.rebuild(prev.slot);
|
|
44
|
+
this.emit();
|
|
45
|
+
}
|
|
46
|
+
mountSlot(name) {
|
|
47
|
+
this.slots.set(name, (this.slots.get(name) ?? 0) + 1);
|
|
48
|
+
return () => void this.slots.set(name, (this.slots.get(name) ?? 1) - 1);
|
|
49
|
+
}
|
|
50
|
+
hasSlot(name) {
|
|
51
|
+
return (this.slots.get(name) ?? 0) > 0;
|
|
52
|
+
}
|
|
53
|
+
/** True once per slot name per store: the caller logs the "no slot" error only the first time. */
|
|
54
|
+
firstWarning(name) {
|
|
55
|
+
if (this.warned.has(name))
|
|
56
|
+
return false;
|
|
57
|
+
this.warned.add(name);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
subscribe = (l) => {
|
|
61
|
+
this.listeners.add(l);
|
|
62
|
+
return () => void this.listeners.delete(l);
|
|
63
|
+
};
|
|
64
|
+
read(slot) {
|
|
65
|
+
return this.snap[slot];
|
|
66
|
+
}
|
|
67
|
+
/** Ids in registration order across both slots (test seam). */
|
|
68
|
+
ids() {
|
|
69
|
+
return [...this.entries.keys()];
|
|
70
|
+
}
|
|
71
|
+
rebuild(slot) {
|
|
72
|
+
const items = [];
|
|
73
|
+
for (const [id, e] of this.entries)
|
|
74
|
+
if (e.slot === slot)
|
|
75
|
+
items.push({ id, node: e.node });
|
|
76
|
+
this.snap = { ...this.snap, [slot]: items.length ? items : EMPTY };
|
|
77
|
+
}
|
|
78
|
+
emit() {
|
|
79
|
+
this.notifications += 1;
|
|
80
|
+
for (const l of [...this.listeners])
|
|
81
|
+
l();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const StoreContext = createContext(null);
|
|
85
|
+
const noopSubscribe = () => () => { };
|
|
86
|
+
/**
|
|
87
|
+
* Rendered by HostProvider, so a host adds nothing. The store is never part of the app-supplied Host bag
|
|
88
|
+
* (nothing about an overlay is app-specific). `store` is a TEST SEAM: a test passes its own to read `notifications`/`ids()`.
|
|
89
|
+
* @public
|
|
90
|
+
*/
|
|
91
|
+
export function OverlayProvider({ children, store: injected }) {
|
|
92
|
+
const [store] = useState(() => injected ?? new OverlayStore()); // pure constructor: StrictMode's double init is harmless
|
|
93
|
+
return _jsx(StoreContext.Provider, { value: store, children: children }); // stable value: never re-renders consumers
|
|
94
|
+
}
|
|
95
|
+
/** Mounted by the HOST once per slot. Emits NO element: hoisted overlays become its parent's direct children. @public */
|
|
96
|
+
export const OverlaySlot = React.memo(function OverlaySlot({ name }) {
|
|
97
|
+
const store = useContext(StoreContext);
|
|
98
|
+
useLayoutEffect(() => store?.mountSlot(name), [store, name]);
|
|
99
|
+
// A slot outside the provider sees an empty store forever: a host bug that would otherwise be silent.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (store === null)
|
|
102
|
+
console.error(`OverlaySlot name="${name}": rendered outside <OverlayProvider> (HostProvider renders it), so nothing hoisted will ever show here.`);
|
|
103
|
+
}, [store, name]);
|
|
104
|
+
const items = useSyncExternalStore(store ? store.subscribe : noopSubscribe, () => (store ? store.read(name) : EMPTY), () => EMPTY);
|
|
105
|
+
return _jsx(_Fragment, { children: items.map((i) => _jsx(Fragment, { children: i.node }, i.id)) });
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Render this anywhere in a view, at any depth. Its children appear in the matching OverlaySlot.
|
|
109
|
+
* With no provider, or under renderToStaticMarkup (no effects), the children render IN PLACE instead.
|
|
110
|
+
* @public
|
|
111
|
+
*/
|
|
112
|
+
export function WindowOverlay({ slot = 'dialog', open = true, children }) {
|
|
113
|
+
const store = useContext(StoreContext);
|
|
114
|
+
// getServerSnapshot -> false (static markup); a client render with a provider -> true on the first pass.
|
|
115
|
+
const hoisting = useSyncExternalStore(noopSubscribe, () => store !== null, () => false);
|
|
116
|
+
const id = useId();
|
|
117
|
+
const active = hoisting && open && store !== null;
|
|
118
|
+
// Every commit: `children` is a fresh element each render. The store skips an identical node.
|
|
119
|
+
useLayoutEffect(() => {
|
|
120
|
+
if (active)
|
|
121
|
+
store.set(id, { slot, node: children });
|
|
122
|
+
});
|
|
123
|
+
// Unregister on unmount, close and slot change. Order against the set-effect above is not load-bearing:
|
|
124
|
+
// React runs every cleanup of a commit, StrictMode's simulated remount included, before any setup.
|
|
125
|
+
useLayoutEffect(() => () => {
|
|
126
|
+
store?.remove(id);
|
|
127
|
+
}, [store, id, active, slot]);
|
|
128
|
+
// Passive on purpose: by now every slot's layout effect has run, so "no slot" really means none.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (active && !store.hasSlot(slot) && store.firstWarning(slot)) {
|
|
131
|
+
console.error(`WindowOverlay: no <OverlaySlot name="${slot}"> is mounted, so this overlay will never be shown.`);
|
|
132
|
+
}
|
|
133
|
+
}, [active, store, slot]);
|
|
134
|
+
if (!open)
|
|
135
|
+
return null;
|
|
136
|
+
return active ? null : _jsx(_Fragment, { children: children });
|
|
137
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace state as a saved file: the JSON shape, its guard, and the pure pair that turns the panel list into
|
|
3
|
+
* that shape and back. DESIGN.md ("Workspace state persistence") holds the rules; this file holds no React and
|
|
4
|
+
* no host code.
|
|
5
|
+
*/
|
|
6
|
+
import type { FormFactor, JsonValue } from './hostApi.js';
|
|
7
|
+
import { type PanelWindow } from './panelWindow.js';
|
|
8
|
+
/**
|
|
9
|
+
* One saved panel. `id` only names this panel inside its own file, so an `anchor` can point at another entry;
|
|
10
|
+
* it is replaced on restore. Never saved: `hostData`, `size`, `locked`, `collapsible`, `anchoredEnter` and the
|
|
11
|
+
* host's root column.
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
export type WorkspacePanelState = {
|
|
15
|
+
id: string;
|
|
16
|
+
view: string;
|
|
17
|
+
url: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
params?: JsonValue;
|
|
20
|
+
listed?: boolean;
|
|
21
|
+
live: boolean;
|
|
22
|
+
anchor: {
|
|
23
|
+
panel: string;
|
|
24
|
+
position: 'left' | 'right';
|
|
25
|
+
priority: number;
|
|
26
|
+
};
|
|
27
|
+
formFactor?: FormFactor;
|
|
28
|
+
permanent?: boolean;
|
|
29
|
+
layer?: 'base' | 'raised';
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The saved workspace: a version and one entry per saved panel, in row order. The host's root column is not in it.
|
|
33
|
+
* @public
|
|
34
|
+
*/
|
|
35
|
+
export type WorkspaceState = {
|
|
36
|
+
version: 1;
|
|
37
|
+
panels: WorkspacePanelState[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* What a host implements to keep the workspace between launches. `load` resolves the saved state, or `null` when
|
|
41
|
+
* there is none. `save` is fire-and-forget and must not throw.
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
export type WorkspaceStorage = {
|
|
45
|
+
load(): Promise<WorkspaceState | null>;
|
|
46
|
+
save(state: WorkspaceState): void;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Whether `value` is a workspace file this version understands: version 1, a `panels` array of well-typed entries
|
|
50
|
+
* with unique ids. Unknown extra keys are accepted (a later optional field is additive); `params` is not deep-checked.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isWorkspaceState(value: unknown): value is WorkspaceState;
|
|
53
|
+
/**
|
|
54
|
+
* The saved form of `panels`: every entry but the root, with only the documented keys. `permanent` is derived
|
|
55
|
+
* (`!collapsible`) because PanelWindow has no such field, and an absent optional stays absent so no key holds
|
|
56
|
+
* `undefined`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function projectWorkspaceState(panels: readonly PanelWindow[], rootId: string): WorkspaceState;
|
|
59
|
+
/**
|
|
60
|
+
* The panels `state` describes, in row order and without the root, each under a fresh id from `freshId`. `hostData`
|
|
61
|
+
* is left for the caller: only the host knows what a new panel carries.
|
|
62
|
+
*/
|
|
63
|
+
export declare function hydrateWorkspaceState(state: WorkspaceState, freshId: () => string, rootId: string): PanelWindow[];
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { computeRowOrder, deriveAnchorsFromOrder } from './panelRowOrder.js';
|
|
2
|
+
import { appendPanel } from './panelWindow.js';
|
|
3
|
+
const FORM_FACTORS = ['phone', 'tablet', 'desktop'];
|
|
4
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
const isOptional = (value, type) => value === undefined || typeof value === type;
|
|
6
|
+
function isSavedPanel(value) {
|
|
7
|
+
if (!isRecord(value))
|
|
8
|
+
return false;
|
|
9
|
+
const { anchor } = value;
|
|
10
|
+
return (typeof value.id === 'string' &&
|
|
11
|
+
typeof value.view === 'string' &&
|
|
12
|
+
typeof value.url === 'string' &&
|
|
13
|
+
typeof value.live === 'boolean' &&
|
|
14
|
+
isOptional(value.title, 'string') &&
|
|
15
|
+
isOptional(value.listed, 'boolean') &&
|
|
16
|
+
isOptional(value.permanent, 'boolean') &&
|
|
17
|
+
(value.formFactor === undefined || FORM_FACTORS.includes(value.formFactor)) &&
|
|
18
|
+
(value.layer === undefined || value.layer === 'base' || value.layer === 'raised') &&
|
|
19
|
+
isRecord(anchor) &&
|
|
20
|
+
typeof anchor.panel === 'string' &&
|
|
21
|
+
(anchor.position === 'left' || anchor.position === 'right') &&
|
|
22
|
+
typeof anchor.priority === 'number' &&
|
|
23
|
+
Number.isFinite(anchor.priority));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether `value` is a workspace file this version understands: version 1, a `panels` array of well-typed entries
|
|
27
|
+
* with unique ids. Unknown extra keys are accepted (a later optional field is additive); `params` is not deep-checked.
|
|
28
|
+
*/
|
|
29
|
+
export function isWorkspaceState(value) {
|
|
30
|
+
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.panels))
|
|
31
|
+
return false;
|
|
32
|
+
const panels = value.panels;
|
|
33
|
+
if (!panels.every(isSavedPanel))
|
|
34
|
+
return false;
|
|
35
|
+
return new Set(panels.map((p) => p.id)).size === panels.length;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The saved form of `panels`: every entry but the root, with only the documented keys. `permanent` is derived
|
|
39
|
+
* (`!collapsible`) because PanelWindow has no such field, and an absent optional stays absent so no key holds
|
|
40
|
+
* `undefined`.
|
|
41
|
+
*/
|
|
42
|
+
export function projectWorkspaceState(panels, rootId) {
|
|
43
|
+
const positional = new Map(deriveAnchorsFromOrder(panels, rootId).map((p) => [p.id, p.anchor]));
|
|
44
|
+
return {
|
|
45
|
+
version: 1,
|
|
46
|
+
panels: panels
|
|
47
|
+
.filter((p) => p.id !== rootId)
|
|
48
|
+
.map((p) => {
|
|
49
|
+
const anchor = p.anchor ?? positional.get(p.id);
|
|
50
|
+
return {
|
|
51
|
+
id: p.id,
|
|
52
|
+
view: p.view,
|
|
53
|
+
url: p.url,
|
|
54
|
+
...(p.title !== undefined ? { title: p.title } : {}),
|
|
55
|
+
...(p.params !== undefined ? { params: p.params } : {}),
|
|
56
|
+
...(p.listed !== undefined ? { listed: p.listed } : {}),
|
|
57
|
+
live: p.live,
|
|
58
|
+
anchor: { panel: anchor.panel, position: anchor.position, priority: anchor.priority },
|
|
59
|
+
...(p.formFactor !== undefined ? { formFactor: p.formFactor } : {}),
|
|
60
|
+
...(!p.collapsible ? { permanent: true } : {}),
|
|
61
|
+
...(p.layer !== undefined ? { layer: p.layer } : {}),
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The panels `state` describes, in row order and without the root, each under a fresh id from `freshId`. `hostData`
|
|
68
|
+
* is left for the caller: only the host knows what a new panel carries.
|
|
69
|
+
*/
|
|
70
|
+
export function hydrateWorkspaceState(state, freshId, rootId) {
|
|
71
|
+
// Every entry's id is drawn before anything else, so a saved id that happens to equal a fresh one is never reused.
|
|
72
|
+
const fresh = state.panels.map(() => freshId());
|
|
73
|
+
const freshOf = new Map(state.panels.map((s, i) => [s.id, fresh[i]]));
|
|
74
|
+
// One id nothing else holds stands in for every anchor target the file does not contain: a saved id kept as it
|
|
75
|
+
// is could equal a fresh one (both count up from 1 each launch) and attach the panel to a stranger.
|
|
76
|
+
let missing;
|
|
77
|
+
const targetOf = (saved) => {
|
|
78
|
+
if (saved === rootId)
|
|
79
|
+
return rootId;
|
|
80
|
+
const mapped = freshOf.get(saved);
|
|
81
|
+
if (mapped !== undefined)
|
|
82
|
+
return mapped;
|
|
83
|
+
missing ??= freshId();
|
|
84
|
+
return missing;
|
|
85
|
+
};
|
|
86
|
+
const built = state.panels.map((s, i) => {
|
|
87
|
+
// appendPanel owns the creation rules (permanent forces listed false and phone, size comes from the tier).
|
|
88
|
+
const entry = appendPanel([], { url: s.url, view: s.view, title: s.title, params: s.params, listed: s.listed, layer: s.layer, formFactor: s.formFactor, permanent: s.permanent }, fresh[i], rootId)[0];
|
|
89
|
+
// A permanent column is never parked, so one saved as parked would never render and never be revealed.
|
|
90
|
+
const live = s.permanent === true ? true : s.live;
|
|
91
|
+
return { ...entry, live, anchor: { panel: targetOf(s.anchor.panel), position: s.anchor.position, priority: s.anchor.priority } };
|
|
92
|
+
});
|
|
93
|
+
// One pass over the whole set: placing entries one at a time would rewrite an anchor that names a later entry.
|
|
94
|
+
return computeRowOrder(built, rootId);
|
|
95
|
+
}
|