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/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "phonux",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A phone-shaped view host for React: write a view against a small set of host APIs, register it, and render it in one HostProvider.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"react": "^19.2.8",
|
|
13
|
+
"react-dom": "^19.2.8",
|
|
14
|
+
"@mui/material": "^9.3.1",
|
|
15
|
+
"@mui/icons-material": "^9.3.1",
|
|
16
|
+
"@emotion/react": "^11.14.0",
|
|
17
|
+
"@emotion/styled": "^11.14.1",
|
|
18
|
+
"motion": "^13.3.0"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./index.d.ts",
|
|
23
|
+
"import": "./index.js"
|
|
24
|
+
},
|
|
25
|
+
"./testing": {
|
|
26
|
+
"types": "./fakeHost.d.ts",
|
|
27
|
+
"import": "./fakeHost.js"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many panels the row keeps live at once, from the stored setting, the fit count and the seated
|
|
3
|
+
* permanent-column count. Pure and free of device state, so it is testable without a window.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The live cap on how many panels (every permanent column plus web panels together) may be live at once,
|
|
7
|
+
* from the stored setting, the current fit count and how many permanent columns (`collapsible:false`, e.g.
|
|
8
|
+
* History) are seated right now. `fitCount === null` means unmeasured: no cap at all (Infinity) -- the first
|
|
9
|
+
* commit, and every jsdom test that never calls measure(). Otherwise the floor of `fixedColumnCount + 1` wins
|
|
10
|
+
* over a stored value or a fit count below it (every permanent column plus at least one web panel stays on
|
|
11
|
+
* screen even at the narrowest legal window), and a stored value above the fit count is capped at the fit
|
|
12
|
+
* count. At `fixedColumnCount === 1` (History alone) this floor is 2.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export declare function effectiveMaxPanels(stored: number | null, fitCount: number | null, fixedColumnCount: number): number;
|
package/panelCapacity.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many panels the row keeps live at once, from the stored setting, the fit count and the seated
|
|
3
|
+
* permanent-column count. Pure and free of device state, so it is testable without a window.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The live cap on how many panels (every permanent column plus web panels together) may be live at once,
|
|
7
|
+
* from the stored setting, the current fit count and how many permanent columns (`collapsible:false`, e.g.
|
|
8
|
+
* History) are seated right now. `fitCount === null` means unmeasured: no cap at all (Infinity) -- the first
|
|
9
|
+
* commit, and every jsdom test that never calls measure(). Otherwise the floor of `fixedColumnCount + 1` wins
|
|
10
|
+
* over a stored value or a fit count below it (every permanent column plus at least one web panel stays on
|
|
11
|
+
* screen even at the narrowest legal window), and a stored value above the fit count is capped at the fit
|
|
12
|
+
* count. At `fixedColumnCount === 1` (History alone) this floor is 2.
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function effectiveMaxPanels(stored, fitCount, fixedColumnCount) {
|
|
16
|
+
if (fitCount === null)
|
|
17
|
+
return Number.POSITIVE_INFINITY;
|
|
18
|
+
return Math.max(fixedColumnCount + 1, Math.min(stored ?? fitCount, fitCount));
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row's own record of one panel, and the one projection from it to what a view may see. The row needs
|
|
3
|
+
* layout fields a view must never read, so the two are separate types (see DESIGN.md, PanelSummary).
|
|
4
|
+
*/
|
|
5
|
+
import type { JsonValue, PanelSummary } from './hostApi.js';
|
|
6
|
+
import type { PanelAnchor } from './panelRowOrder.js';
|
|
7
|
+
/**
|
|
8
|
+
* One entry of a panel row: everything a view sees (PanelSummary) plus the layout fields only the row reads.
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
export interface PanelRowEntry<P = JsonValue> extends PanelSummary<P> {
|
|
12
|
+
/** The registered view that renders this entry. An unregistered name renders a placeholder, never a crash. */
|
|
13
|
+
readonly view: string;
|
|
14
|
+
/** False for an always-shown column: the row never parks it and it is never a drag source. */
|
|
15
|
+
readonly collapsible: boolean;
|
|
16
|
+
/** Where this entry sits in the row. Undefined for the row's root; any other entry without one stays where it stands. */
|
|
17
|
+
readonly anchor?: PanelAnchor;
|
|
18
|
+
/** Which of the row's two stacking tiers this entry's slot sits in. Default 'base'. */
|
|
19
|
+
readonly layer?: 'base' | 'raised';
|
|
20
|
+
/** Whether a reappearance enters from its anchor's side instead of from the right. Default false. */
|
|
21
|
+
readonly anchoredEnter?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The widths of the present panels a drag can cross, nearest first on each side. An always-shown column is
|
|
25
|
+
* included: only the dragged panel must be collapsible.
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
export interface NeighborWidths {
|
|
29
|
+
readonly left: readonly number[];
|
|
30
|
+
readonly right: readonly number[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A row entry narrowed to what a view may see. Every place that hands a panel to a view goes through this one
|
|
34
|
+
* function, so no view ever sees a layout field; `formFactor` and `size` are forwarded.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export declare const summarisePanel: ({ id, url, title, live, locked, params, formFactor, size }: PanelRowEntry) => PanelSummary;
|
package/panelRowEntry.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row entry narrowed to what a view may see. Every place that hands a panel to a view goes through this one
|
|
3
|
+
* function, so no view ever sees a layout field; `formFactor` and `size` are forwarded.
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
export const summarisePanel = ({ id, url, title, live, locked, params, formFactor, size }) => ({
|
|
7
|
+
id, url, title, live, locked, params,
|
|
8
|
+
// Absent, not undefined, when the record lacks them: a summary never lists a key its record never had.
|
|
9
|
+
...(formFactor !== undefined ? { formFactor } : {}),
|
|
10
|
+
...(size !== undefined ? { size } : {}),
|
|
11
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure row-layout arithmetic: the row's centring margin, how many live panels fit, and how wide one may be.
|
|
3
|
+
* Zero DOM imports, so test/panel-layout.test.ts runs it under plain node.
|
|
4
|
+
*/
|
|
5
|
+
/** One row's measurements: what computeRowLayout and computeMaxLiveWidth read. @public */
|
|
6
|
+
export interface RowLayoutInput {
|
|
7
|
+
/** The row container's measured width. The caller measures it, so this function touches no DOM. */
|
|
8
|
+
containerWidth: number;
|
|
9
|
+
/** The default/live phone width -- every FIXED row item is exactly this wide (a permanent column never
|
|
10
|
+
* changes form factor), and it is also the width assumed for a hypothetical slot beyond `liveWidths` --
|
|
11
|
+
* see `maxLiveThatFit`'s own doc comment. */
|
|
12
|
+
phoneWidth: number;
|
|
13
|
+
/** The row's own gap between adjacent items (PanelRowSlot's `GAP`). */
|
|
14
|
+
gap: number;
|
|
15
|
+
/** How many always-rendered (non-collapsible) slots the row holds: a plain count, so this function stays
|
|
16
|
+
* free of the caller's panel state. */
|
|
17
|
+
fixedItemCount: number;
|
|
18
|
+
/**
|
|
19
|
+
* Each LIVE collapsible panel's own width, in row order -- a 'tablet'/'desktop' panel contributes its own
|
|
20
|
+
* (wider) width instead of `phoneWidth`, so it alone lowers how many more panels fit. An empty array (no
|
|
21
|
+
* live panel known yet) still yields the full `phoneWidth`-based ceiling -- see `maxLiveThatFit`.
|
|
22
|
+
*/
|
|
23
|
+
liveWidths: readonly number[];
|
|
24
|
+
}
|
|
25
|
+
/** How many live panels fit in one row, and where that row starts so it is centered. @public */
|
|
26
|
+
export interface RowLayoutResult {
|
|
27
|
+
/**
|
|
28
|
+
* How many live panels the row can show at most: those of `liveWidths` that fit in row order, plus, when all
|
|
29
|
+
* of them fit, however many more `phoneWidth` slots fit in the budget left. With every entry exactly
|
|
30
|
+
* `phoneWidth` this does not depend on `liveWidths.length`, so `[]` (nothing live yet) gives the same ceiling.
|
|
31
|
+
* The host's panel capacity and SettingsAPI's `fitCount` both derive from this.
|
|
32
|
+
*/
|
|
33
|
+
maxLiveThatFit: number;
|
|
34
|
+
/** `liveWidths.length`, capped at `maxLiveThatFit`: the centering math's view of what renders. Which
|
|
35
|
+
* entries render is decided by `live`, not here. */
|
|
36
|
+
effectiveLiveCount: number;
|
|
37
|
+
/** The fixed items' width plus the first `effectiveLiveCount` entries' own widths (each plus one gap). */
|
|
38
|
+
rowTotalWidth: number;
|
|
39
|
+
/** The `marginLeft` that centers `rowTotalWidth` in the row's available width, floored at 0 when the fixed
|
|
40
|
+
* items alone are wider. */
|
|
41
|
+
computedRowMarginLeft: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The row's centering math. `fixedItemCount` must come from the same array the row renders, never a
|
|
45
|
+
* hand-mirrored copy of its conditions, or a drifted count silently mis-centers the row by half an item.
|
|
46
|
+
* `maxLiveThatFit` is the one "how many fit" answer, so a wider panel lowers the count instead of overflowing.
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
export declare function computeRowLayout({ containerWidth, phoneWidth, gap, fixedItemCount, liveWidths }: RowLayoutInput): RowLayoutResult;
|
|
50
|
+
/**
|
|
51
|
+
* The widest ONE live panel can be beside the fixed items and still fit: `computeRowLayout`'s live budget less
|
|
52
|
+
* that panel's gap. `panelWidthOf` caps a tablet/desktop panel at this, so its tier width is a maximum and a
|
|
53
|
+
* wide panel never overflows a narrow window. Never below `phoneWidth`: when not even a phone fits, a narrower
|
|
54
|
+
* "tablet" would not fit either. Infinity while unmeasured (`containerWidth` 0), as every reader treats 0.
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
export declare function computeMaxLiveWidth(input: Omit<RowLayoutInput, 'liveWidths'>): number;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure row-layout arithmetic: the row's centring margin, how many live panels fit, and how wide one may be.
|
|
3
|
+
* Zero DOM imports, so test/panel-layout.test.ts runs it under plain node.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* The row's centering math. `fixedItemCount` must come from the same array the row renders, never a
|
|
7
|
+
* hand-mirrored copy of its conditions, or a drifted count silently mis-centers the row by half an item.
|
|
8
|
+
* `maxLiveThatFit` is the one "how many fit" answer, so a wider panel lowers the count instead of overflowing.
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
export function computeRowLayout({ containerWidth, phoneWidth, gap, fixedItemCount, liveWidths }) {
|
|
12
|
+
const { availableForRow, fixedWidth, usedBudget } = rowBudget({ containerWidth, phoneWidth, gap, fixedItemCount });
|
|
13
|
+
// Stopping at the first width that does not fit answers "how many of THESE fit", which the host's overflow
|
|
14
|
+
// enforcement reads. Only when all fit are phoneWidth slots added: without them an empty `liveWidths` would
|
|
15
|
+
// report 0 rather than how many phone panels fit.
|
|
16
|
+
let runningWidth = 0;
|
|
17
|
+
let fittingCount = 0;
|
|
18
|
+
for (const width of liveWidths) {
|
|
19
|
+
const itemSpan = width + gap;
|
|
20
|
+
if (runningWidth + itemSpan > usedBudget)
|
|
21
|
+
break;
|
|
22
|
+
runningWidth += itemSpan;
|
|
23
|
+
fittingCount++;
|
|
24
|
+
}
|
|
25
|
+
const extraSlots = fittingCount === liveWidths.length ? Math.max(0, Math.floor((usedBudget - runningWidth) / (phoneWidth + gap))) : 0;
|
|
26
|
+
const maxLiveThatFit = Math.max(0, fittingCount + extraSlots);
|
|
27
|
+
const effectiveLiveCount = Math.min(liveWidths.length, maxLiveThatFit);
|
|
28
|
+
let effectiveLiveWidth = 0;
|
|
29
|
+
for (let i = 0; i < effectiveLiveCount; i++)
|
|
30
|
+
effectiveLiveWidth += liveWidths[i] + gap;
|
|
31
|
+
const rowTotalWidth = fixedWidth + effectiveLiveWidth;
|
|
32
|
+
const computedRowMarginLeft = Math.max(0, (availableForRow - rowTotalWidth) / 2);
|
|
33
|
+
return { maxLiveThatFit, effectiveLiveCount, rowTotalWidth, computedRowMarginLeft };
|
|
34
|
+
}
|
|
35
|
+
/** The row's width, the fixed items' share of it, and what is left for live panels (each counted with its own gap). */
|
|
36
|
+
function rowBudget({ containerWidth, phoneWidth, gap, fixedItemCount }) {
|
|
37
|
+
const availableForRow = Math.max(0, containerWidth - 2 * gap);
|
|
38
|
+
const fixedWidth = fixedItemCount * phoneWidth + (fixedItemCount - 1) * gap;
|
|
39
|
+
return { availableForRow, fixedWidth, usedBudget: availableForRow - fixedWidth };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The widest ONE live panel can be beside the fixed items and still fit: `computeRowLayout`'s live budget less
|
|
43
|
+
* that panel's gap. `panelWidthOf` caps a tablet/desktop panel at this, so its tier width is a maximum and a
|
|
44
|
+
* wide panel never overflows a narrow window. Never below `phoneWidth`: when not even a phone fits, a narrower
|
|
45
|
+
* "tablet" would not fit either. Infinity while unmeasured (`containerWidth` 0), as every reader treats 0.
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
export function computeMaxLiveWidth(input) {
|
|
49
|
+
if (input.containerWidth === 0)
|
|
50
|
+
return Number.POSITIVE_INFINITY;
|
|
51
|
+
return Math.max(input.phoneWidth, rowBudget(input).usedBudget - input.gap);
|
|
52
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row order as anchor metadata: every entry but the row's root names the entry it sits beside, the side, and
|
|
3
|
+
* how near. computeRowOrder turns anchors into an order; deriveAnchorsFromOrder turns an order back into anchors.
|
|
4
|
+
* The root's id is always a parameter, never a constant here: which entry is the root is the app's choice.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* An entry's place in the row: on `position`'s side of the entry `panel`, ranked by `priority` among the
|
|
8
|
+
* entries on that same side of that same entry. Lower is nearer; ties keep the current row order.
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
export interface PanelAnchor {
|
|
12
|
+
panel: string;
|
|
13
|
+
position: 'left' | 'right';
|
|
14
|
+
priority: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The entries in the order their anchors describe: a walk from the root, each entry's left side (farthest
|
|
18
|
+
* first), then the entry, then its right side (nearest first). Priorities come back renumbered from 0 per side
|
|
19
|
+
* of each entry, so a priority of -1 always lands nearest. An anchor naming a missing entry, or one in a cycle,
|
|
20
|
+
* falls back to the root's right side (keeping its priority) instead of recursing forever or dropping the entry.
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
export declare function computeRowOrder<T extends {
|
|
24
|
+
id: string;
|
|
25
|
+
anchor?: PanelAnchor;
|
|
26
|
+
}>(panels: readonly T[], rootId: string): T[];
|
|
27
|
+
/**
|
|
28
|
+
* Every non-root entry re-anchored to the root from its current array position, so computeRowOrder reproduces
|
|
29
|
+
* this exact order.
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
export declare function deriveAnchorsFromOrder<T extends {
|
|
33
|
+
id: string;
|
|
34
|
+
anchor?: PanelAnchor;
|
|
35
|
+
}>(panels: readonly T[], rootId: string): T[];
|
|
36
|
+
export type EnterDirection = 'left' | 'right';
|
|
37
|
+
/** The id of the entry `panel` orients its enter animation against: its anchor's, or the root's when it has none. */
|
|
38
|
+
export declare function anchorTargetOf(panel: {
|
|
39
|
+
anchor?: PanelAnchor;
|
|
40
|
+
}, rootId: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Which side a reappearing entry enters from: the side its anchor entry sits on. Both indexes must count in the
|
|
43
|
+
* SAME present list the row renders, never the raw array, which miscounts whenever an entry is parked. -1 for
|
|
44
|
+
* either (not present) falls back to 'right', the row's default; the root against itself compares equal, so 'right'.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveEnterDirection(anchorIndex: number, ownIndex: number): EnterDirection;
|
package/panelRowOrder.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row order as anchor metadata: every entry but the row's root names the entry it sits beside, the side, and
|
|
3
|
+
* how near. computeRowOrder turns anchors into an order; deriveAnchorsFromOrder turns an order back into anchors.
|
|
4
|
+
* The root's id is always a parameter, never a constant here: which entry is the root is the app's choice.
|
|
5
|
+
*/
|
|
6
|
+
const sameAnchor = (a, b) => !!a && a.panel === b.panel && a.position === b.position && a.priority === b.priority;
|
|
7
|
+
/** Spreads the whole record: every other field must survive a re-anchor untouched. */
|
|
8
|
+
const withAnchor = (p, anchor) => (sameAnchor(p.anchor, anchor) ? p : { ...p, anchor });
|
|
9
|
+
/** Every non-root entry's anchor as read off its array position alone: the root's side, 0 = nearest. With no root present, every entry reads 'right'. */
|
|
10
|
+
function positionalAnchors(panels, rootId) {
|
|
11
|
+
const root = panels.findIndex((p) => p.id === rootId);
|
|
12
|
+
const out = new Map();
|
|
13
|
+
panels.forEach((p, i) => {
|
|
14
|
+
if (p.id !== rootId)
|
|
15
|
+
out.set(p.id, i < root ? { panel: rootId, position: 'left', priority: root - 1 - i } : { panel: rootId, position: 'right', priority: i - root - 1 });
|
|
16
|
+
});
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The entries in the order their anchors describe: a walk from the root, each entry's left side (farthest
|
|
21
|
+
* first), then the entry, then its right side (nearest first). Priorities come back renumbered from 0 per side
|
|
22
|
+
* of each entry, so a priority of -1 always lands nearest. An anchor naming a missing entry, or one in a cycle,
|
|
23
|
+
* falls back to the root's right side (keeping its priority) instead of recursing forever or dropping the entry.
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
export function computeRowOrder(panels, rootId) {
|
|
27
|
+
const byId = new Map(panels.map((p) => [p.id, p]));
|
|
28
|
+
const rowIndex = new Map(panels.map((p, i) => [p.id, i]));
|
|
29
|
+
const positional = positionalAnchors(panels, rootId);
|
|
30
|
+
const declared = (p) => p.anchor ?? positional.get(p.id);
|
|
31
|
+
const fallback = new Set();
|
|
32
|
+
const visiting = new Set();
|
|
33
|
+
const resolved = new Set();
|
|
34
|
+
const resolve = (id, path) => {
|
|
35
|
+
if (resolved.has(id))
|
|
36
|
+
return;
|
|
37
|
+
if (visiting.has(id)) {
|
|
38
|
+
for (const member of path.slice(path.indexOf(id)))
|
|
39
|
+
fallback.add(member);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
visiting.add(id);
|
|
43
|
+
const target = declared(byId.get(id)).panel;
|
|
44
|
+
if (target !== rootId) {
|
|
45
|
+
if (byId.has(target))
|
|
46
|
+
resolve(target, [...path, id]);
|
|
47
|
+
else
|
|
48
|
+
fallback.add(id);
|
|
49
|
+
}
|
|
50
|
+
visiting.delete(id);
|
|
51
|
+
resolved.add(id);
|
|
52
|
+
};
|
|
53
|
+
const effective = (p) => (fallback.has(p.id) ? { panel: rootId, position: 'right', priority: declared(p).priority } : declared(p));
|
|
54
|
+
const sides = new Map();
|
|
55
|
+
for (const p of panels) {
|
|
56
|
+
if (p.id === rootId)
|
|
57
|
+
continue;
|
|
58
|
+
resolve(p.id, []);
|
|
59
|
+
}
|
|
60
|
+
for (const p of panels) {
|
|
61
|
+
if (p.id === rootId)
|
|
62
|
+
continue;
|
|
63
|
+
const { panel, position } = effective(p);
|
|
64
|
+
const side = sides.get(panel) ?? { left: [], right: [] };
|
|
65
|
+
side[position].push(p);
|
|
66
|
+
sides.set(panel, side);
|
|
67
|
+
}
|
|
68
|
+
// Nearest first; on a tie the entry nearer in the current row wins (later on the left, earlier on the right).
|
|
69
|
+
const nearestFirst = (list, position) => [...list].sort((a, b) => effective(a).priority - effective(b).priority || (position === 'right' ? 1 : -1) * (rowIndex.get(a.id) - rowIndex.get(b.id)));
|
|
70
|
+
const out = [];
|
|
71
|
+
const walk = (id, self) => {
|
|
72
|
+
const side = sides.get(id);
|
|
73
|
+
const left = nearestFirst(side?.left ?? [], 'left');
|
|
74
|
+
for (let k = left.length - 1; k >= 0; k--)
|
|
75
|
+
walk(left[k].id, withAnchor(left[k], { panel: id, position: 'left', priority: k }));
|
|
76
|
+
if (self)
|
|
77
|
+
out.push(self);
|
|
78
|
+
nearestFirst(side?.right ?? [], 'right').forEach((p, k) => walk(p.id, withAnchor(p, { panel: id, position: 'right', priority: k })));
|
|
79
|
+
};
|
|
80
|
+
walk(rootId, byId.get(rootId));
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every non-root entry re-anchored to the root from its current array position, so computeRowOrder reproduces
|
|
85
|
+
* this exact order.
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
88
|
+
export function deriveAnchorsFromOrder(panels, rootId) {
|
|
89
|
+
const positional = positionalAnchors(panels, rootId);
|
|
90
|
+
return panels.map((p) => (p.id === rootId ? p : withAnchor(p, positional.get(p.id))));
|
|
91
|
+
}
|
|
92
|
+
/** The id of the entry `panel` orients its enter animation against: its anchor's, or the root's when it has none. */
|
|
93
|
+
export function anchorTargetOf(panel, rootId) {
|
|
94
|
+
return panel.anchor?.panel ?? rootId;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Which side a reappearing entry enters from: the side its anchor entry sits on. Both indexes must count in the
|
|
98
|
+
* SAME present list the row renders, never the raw array, which miscounts whenever an entry is parked. -1 for
|
|
99
|
+
* either (not present) falls back to 'right', the row's default; the root against itself compares equal, so 'right'.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveEnterDirection(anchorIndex, ownIndex) {
|
|
102
|
+
if (anchorIndex < 0 || ownIndex < 0)
|
|
103
|
+
return 'right';
|
|
104
|
+
return anchorIndex < ownIndex ? 'left' : 'right';
|
|
105
|
+
}
|
package/panelTiers.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel tiers: each form factor's own size, and the one rule for how wide a panel is in the row. Pure, and
|
|
3
|
+
* free of live device state, so the row's state functions stay pure too.
|
|
4
|
+
*/
|
|
5
|
+
import { type FormFactor, type PanelSize } from './hostApi.js';
|
|
6
|
+
import type { PanelRowEntry } from './panelRowEntry.js';
|
|
7
|
+
/**
|
|
8
|
+
* The size every panel starts at: the phone tier at the built-in phone size.
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_PANEL_SIZE: PanelSize;
|
|
12
|
+
/**
|
|
13
|
+
* Each tier's own size. The 'phone' entry is only a placeholder: a phone panel renders at the live phone size.
|
|
14
|
+
* 'fill' is the row's own height. Frozen because every panel starts out referencing these same objects.
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export declare const TIER_SIZES: Readonly<Record<FormFactor, PanelSize>>;
|
|
18
|
+
/**
|
|
19
|
+
* A panel's own width in the row. A phone panel always takes the live `phoneWidth`, never its stored size, which
|
|
20
|
+
* is only a placeholder. A tablet or desktop panel takes its stored width, capped at `maxLiveWidth` so it always
|
|
21
|
+
* fits beside the row's fixed columns.
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
export declare function panelWidthOf(p: Pick<PanelRowEntry, 'formFactor' | 'size'>, phoneWidth: number, maxLiveWidth: number): number;
|
package/panelTiers.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel tiers: each form factor's own size, and the one rule for how wide a panel is in the row. Pure, and
|
|
3
|
+
* free of live device state, so the row's state functions stay pure too.
|
|
4
|
+
*/
|
|
5
|
+
import { DEFAULT_PHONE_SIZE } from './hostApi.js';
|
|
6
|
+
/**
|
|
7
|
+
* The size every panel starts at: the phone tier at the built-in phone size.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_PANEL_SIZE = Object.freeze({ formFactor: 'phone', width: DEFAULT_PHONE_SIZE.width, height: DEFAULT_PHONE_SIZE.height });
|
|
11
|
+
/**
|
|
12
|
+
* Each tier's own size. The 'phone' entry is only a placeholder: a phone panel renders at the live phone size.
|
|
13
|
+
* 'fill' is the row's own height. Frozen because every panel starts out referencing these same objects.
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
export const TIER_SIZES = Object.freeze({
|
|
17
|
+
phone: DEFAULT_PANEL_SIZE,
|
|
18
|
+
tablet: Object.freeze({ formFactor: 'tablet', width: 768, height: 'fill' }),
|
|
19
|
+
desktop: Object.freeze({ formFactor: 'desktop', width: 1200, height: 'fill' }),
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* A panel's own width in the row. A phone panel always takes the live `phoneWidth`, never its stored size, which
|
|
23
|
+
* is only a placeholder. A tablet or desktop panel takes its stored width, capped at `maxLiveWidth` so it always
|
|
24
|
+
* fits beside the row's fixed columns.
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
export function panelWidthOf(p, phoneWidth, maxLiveWidth) {
|
|
28
|
+
return (p.formFactor ?? 'phone') === 'phone' ? phoneWidth : Math.min(p.size?.width ?? phoneWidth, maxLiveWidth);
|
|
29
|
+
}
|
package/panelWindow.d.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `PanelWindow` model -- one browsing panel in the unified row -- and every pure state-transition
|
|
3
|
+
* function that operates on a `PanelWindow[]` array.
|
|
4
|
+
*/
|
|
5
|
+
import type { FormFactor, JsonValue, PanelSize } from './hostApi.js';
|
|
6
|
+
import type { NeighborWidths } from './panelRowEntry.js';
|
|
7
|
+
import type { PanelAnchor } from './panelRowOrder.js';
|
|
8
|
+
export type { FormFactor, JsonValue, PanelSize };
|
|
9
|
+
export type { PanelAnchor };
|
|
10
|
+
export type { NeighborWidths };
|
|
11
|
+
export { effectiveMaxPanels } from './panelCapacity.js';
|
|
12
|
+
/**
|
|
13
|
+
* One browsing panel in the unified row: either live in the row or parked, never both. Parking only flips
|
|
14
|
+
* `live`; it never removes the entry from the array, only `closePanel` does.
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export interface PanelWindow {
|
|
18
|
+
id: string;
|
|
19
|
+
url: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Host-supplied and opaque: every function here only copies it through, never reads or branches on it. Its
|
|
22
|
+
* shape is a host-side agreement `JsonValue` cannot enforce. */
|
|
23
|
+
hostData?: JsonValue;
|
|
24
|
+
/** Mounted in the row right now. `false` means parked: still in this array, just not rendered. */
|
|
25
|
+
live: boolean;
|
|
26
|
+
/** Whether DragToClose's drag-down gesture may close this panel at all. Every panel created by `appendPanel` is unlocked. */
|
|
27
|
+
locked: boolean;
|
|
28
|
+
/** Which component renders this entry (phonux's registry, resolveView) -- read ONLY by the row (PanelRow); every park/close site reads `collapsible`/`listed`/`locked` instead, never this. An open string, not a closed union: an unregistered value renders a placeholder, never a crash. */
|
|
29
|
+
view: string;
|
|
30
|
+
/** Capacity parks (an open, a revive, the row gesture, a capacity drop) may park this entry; `false` marks an always-shown column exempt from all of them. */
|
|
31
|
+
collapsible: boolean;
|
|
32
|
+
/** Appears in the public `panelList` (History's own row list, and PanelsAPI). */
|
|
33
|
+
listed: boolean;
|
|
34
|
+
/** Opaque per-panel data a create() caller supplied (PanelsAPI.create's `params`); undefined for built-ins. summarisePanel threads it to PanelSummary.params; no behavior site here reads it. */
|
|
35
|
+
params?: JsonValue;
|
|
36
|
+
/** Where this entry sits in the row (computeRowOrder). Undefined only for History, the root; a hand-built record without one reads as anchored where it already stands, and computeRowOrder writes that anchor. */
|
|
37
|
+
anchor?: PanelAnchor;
|
|
38
|
+
/** Which of the row's two stacking tiers this entry's slot sits in -- read ONLY by PanelRow's per-slot zIndex. Default 'base'. */
|
|
39
|
+
layer?: 'base' | 'raised';
|
|
40
|
+
/** Whether this entry's reappearance always resolves through the anchor formula (resolveEnterDirection) instead of unconditionally entering from the right -- read ONLY by PanelRow's enterDirection. Default false. */
|
|
41
|
+
anchoredEnter?: boolean;
|
|
42
|
+
/** This panel's own visual tier; createHistoryPanel/appendPanel always set it. Optional so a record built by hand without it still typechecks. */
|
|
43
|
+
formFactor?: FormFactor;
|
|
44
|
+
/** This panel's own rendered size (a host's panel component passes it to Panel/PanelFrame for a non-phone tier). Optional for the same reason as `formFactor`. */
|
|
45
|
+
size?: PanelSize;
|
|
46
|
+
}
|
|
47
|
+
/** History's own seeded entry: never collapsible (an always-shown column), never listed (it must not list itself), always live, locked by default (the host's close path honours `locked`); raised, so a left-exiting park slides under it; anchoredEnter, which against itself resolves 'right'; no anchor: it is the root every anchor leads to. Same default tier/size as every other entry: a permanent column carries a tier like any other. @internal */
|
|
48
|
+
export declare function createHistoryPanel(rootId: string): PanelWindow;
|
|
49
|
+
/**
|
|
50
|
+
* Per-call metadata create() may set beyond a bare url/source. No raw `collapsible`/`locked` key: `permanent`
|
|
51
|
+
* is the only door to `collapsible:false` (see `appendPanel`'s own doc comment for what it forces), and
|
|
52
|
+
* `locked` is never settable at all -- create() can never make an unclosable panel.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export interface AppendPanelOptions {
|
|
56
|
+
view?: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
params?: JsonValue;
|
|
59
|
+
/** Copied through to the new entry's `hostData` unchanged; absent means the entry carries no `hostData` key at all (never an explicit `undefined`). */
|
|
60
|
+
hostData?: JsonValue;
|
|
61
|
+
listed?: boolean;
|
|
62
|
+
/** `panel` defaults to History, `priority` to one past the far end of that side. Default \{ position: 'right' \}. */
|
|
63
|
+
anchor?: {
|
|
64
|
+
panel?: string;
|
|
65
|
+
position: 'left' | 'right';
|
|
66
|
+
priority?: number;
|
|
67
|
+
};
|
|
68
|
+
layer?: 'base' | 'raised';
|
|
69
|
+
anchoredEnter?: boolean;
|
|
70
|
+
/** See `CreatePanelOptions.permanent` (hostApi.tsx) for the full contract; `appendPanel` is what enforces it. */
|
|
71
|
+
permanent?: boolean;
|
|
72
|
+
/** The new panel's tier. Ignored for a permanent column, which is always 'phone'. Default 'phone'. */
|
|
73
|
+
formFactor?: FormFactor;
|
|
74
|
+
}
|
|
75
|
+
/** @internal */
|
|
76
|
+
export type AnchorRequest = NonNullable<AppendPanelOptions['anchor']>;
|
|
77
|
+
/** `id` re-anchored per `request` (defaults as for appendPanel), the row re-ordered to match. The SAME array for an unknown id or a `collapsible: false` entry. @internal */
|
|
78
|
+
export declare function reanchorPanel(panels: PanelWindow[], id: string, request: AnchorRequest, rootId: string): PanelWindow[];
|
|
79
|
+
/**
|
|
80
|
+
* Builds a brand-new, live 'extra' panel immutably and places it by `info.anchor` (default: right of
|
|
81
|
+
* History, past every panel already there -- the row's far right end). History is seeded directly, never
|
|
82
|
+
* through this function. `permanent` forces `collapsible:false` and `listed:false` (overriding any `info.listed`
|
|
83
|
+
* passed) since such a column must supply its own close control, never the row's trash; `locked`
|
|
84
|
+
* stays false regardless so that control keeps working with no extra plumbing (the host's close path gates
|
|
85
|
+
* only on `locked`).
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export declare function appendPanel(panels: PanelWindow[], info: {
|
|
89
|
+
url: string;
|
|
90
|
+
} & AppendPanelOptions, id: string, rootId: string): PanelWindow[];
|
|
91
|
+
/**
|
|
92
|
+
* The id of the live collapsible panel farthest (by array index) from `fromIndex`, or null with none live.
|
|
93
|
+
* Ties go to the lower index (the strict `>` keeps the first). From an append (`fromIndex` = the new length)
|
|
94
|
+
* that is the LEFT end of the live range; from an open nearest History or a revive left of the range, the
|
|
95
|
+
* RIGHT end. promoteToLive and the host's open/restore park both pick through this. `exclude`
|
|
96
|
+
* exempts the panel being opened or restored, which is already live in the array the caller passes, and any
|
|
97
|
+
* panel another action in the same update must keep live.
|
|
98
|
+
* @internal
|
|
99
|
+
*/
|
|
100
|
+
export declare function farthestLiveCollapsibleId(panels: PanelWindow[], fromIndex: number, exclude?: ReadonlySet<string>): string | null;
|
|
101
|
+
/**
|
|
102
|
+
* Which side of the row a park/reveal at `id` reads as, for the exit/enter animation: 'left' when `id`'s
|
|
103
|
+
* own array index sits below every OTHER live collapsible panel's index, 'right' otherwise (a parked panel
|
|
104
|
+
* between two live ones included). Computed fresh from array position every time, never a persisted flag,
|
|
105
|
+
* so an append's park always reads 'left' and a park for an open anchored nearest History reads 'right'.
|
|
106
|
+
* @internal
|
|
107
|
+
*/
|
|
108
|
+
export declare function sideOfLiveRange(panels: PanelWindow[], id: string): 'left' | 'right';
|
|
109
|
+
/** The row-slot index of the LEFTMOST (lowest-index) live collapsible panel, or -1 if none are live. @internal */
|
|
110
|
+
export declare function leftmostLiveIndex(panels: PanelWindow[]): number;
|
|
111
|
+
/** The row-slot index of the RIGHTMOST (highest-index) live collapsible panel, or -1 if none are live. @internal */
|
|
112
|
+
export declare function rightmostLiveIndex(panels: PanelWindow[]): number;
|
|
113
|
+
/** The first PARKED collapsible panel strictly after `index` -- a rightward gesture's own "next one to bring in", or null with nothing parked on that side. @internal */
|
|
114
|
+
export declare function firstParkedAfter(panels: PanelWindow[], index: number): PanelWindow | null;
|
|
115
|
+
/** The last PARKED collapsible panel strictly before `index` -- a leftward gesture's own "next one to bring in", or null with nothing parked on that side. @internal */
|
|
116
|
+
export declare function lastParkedBefore(panels: PanelWindow[], index: number): PanelWindow | null;
|
|
117
|
+
/**
|
|
118
|
+
* Activating ANY panel (live or parked) flips `live` in place, never moving the entry's array position.
|
|
119
|
+
* Returns `null` for an unknown id or one already live (a legitimate no-op, not an error). When promoting
|
|
120
|
+
* `id` would exceed `capacity`, demotes exactly one OTHER live panel: whichever `farthestLiveCollapsibleId`
|
|
121
|
+
* picks from `id`'s own index, the same far rule an open/restore parks by. Never demotes a
|
|
122
|
+
* `collapsible: false` entry or any id in `keep`.
|
|
123
|
+
* @internal
|
|
124
|
+
*/
|
|
125
|
+
export declare function promoteToLive(panels: PanelWindow[], id: string, capacity: number, keep?: ReadonlySet<string>): PanelWindow[] | null;
|
|
126
|
+
/**
|
|
127
|
+
* Closes one panel COMPLETELY: it leaves the array, live or parked, so it leaves the row and History
|
|
128
|
+
* together. Returns what a caller needs to log it and to undo it -- the array without it, the panel itself
|
|
129
|
+
* and the index it had -- or `null` for an unknown `id` (already closed, cleared): nothing to do, not an
|
|
130
|
+
* error. Every panel anchored to it takes over its own anchor, so they close up in the place it held.
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
export declare function closePanel(panels: PanelWindow[], id: string, rootId: string): {
|
|
134
|
+
remaining: PanelWindow[];
|
|
135
|
+
closed: PanelWindow;
|
|
136
|
+
index: number;
|
|
137
|
+
} | null;
|
|
138
|
+
/**
|
|
139
|
+
* Moves `id` one PRESENT slot (`!collapsible || live`, PanelRow's filter) toward `direction`, past
|
|
140
|
+
* whichever present panel sits there, History included; a parked panel between the two shifts one array slot,
|
|
141
|
+
* keeping firstParkedAfter/lastParkedBefore/sideOfLiveRange meaningful. Every anchor is then re-read from the
|
|
142
|
+
* new order. The SAME array for every no-op: unknown id, a `collapsible: false` source (an always-shown column
|
|
143
|
+
* is never dragged), a parked panel, or one already at that edge.
|
|
144
|
+
* @internal
|
|
145
|
+
*/
|
|
146
|
+
export declare function movePanel(panels: PanelWindow[], id: string, direction: 'left' | 'right', rootId: string): PanelWindow[];
|
|
147
|
+
/**
|
|
148
|
+
* How many movePanel hops a horizontal drag of `offsetX` px asks for (+right/-left). A hop past a neighbour
|
|
149
|
+
* moves the dragged panel by that NEIGHBOUR's span (its width plus `gap`), so each hop lands half-way across
|
|
150
|
+
* the next neighbour's span, counted from where the previous hop ended; the dragged panel's own width never
|
|
151
|
+
* enters it. Exactly half a span is a hop either way, and this never returns -0.
|
|
152
|
+
* @public
|
|
153
|
+
*/
|
|
154
|
+
export declare function reorderStepsForOffset(offsetX: number, neighbors: NeighborWidths, gap: number): number;
|
|
155
|
+
/**
|
|
156
|
+
* `id` at `next`'s tier, its `size` REPLACED by a fresh copy of that tier's TIER_SIZES entry. Returns the SAME
|
|
157
|
+
* array reference for every no-op: an unknown id, a `collapsible: false` entry (History or a permanent column,
|
|
158
|
+
* movePanel's own source guard), or a panel already at `next`. A parked panel may change tier and stays parked.
|
|
159
|
+
* @internal
|
|
160
|
+
*/
|
|
161
|
+
export declare function setFormFactor(panels: PanelWindow[], id: string, next: FormFactor): PanelWindow[];
|
|
162
|
+
/**
|
|
163
|
+
* Reverses closePanel for one panel: puts `panel` back at `index` (clamped to the current length, so a shorter
|
|
164
|
+
* array still takes it), unchanged -- a pure spread, no `hostData` reset (the host's undo does that itself: only
|
|
165
|
+
* it knows what "unmounted when it closed" means for the opaque blob). An id already present leaves `panels`
|
|
166
|
+
* unchanged (a double undo never duplicates). Live or parked is whatever `panel.live` says. Anchors are
|
|
167
|
+
* re-read from the result: its old anchor may no longer describe that index.
|
|
168
|
+
* @internal
|
|
169
|
+
*/
|
|
170
|
+
export declare function restorePanel(panels: PanelWindow[], panel: PanelWindow, index: number, rootId: string): PanelWindow[];
|