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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { type TargetAndTransition } from 'motion/react';
|
|
3
|
+
/**
|
|
4
|
+
* The one spacing value used everywhere a gap is needed in this row.
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
7
|
+
export declare const GAP = 16;
|
|
8
|
+
/** The timing/easing every panel slide uses. The SAME object as SLOT_TRANSITION, not a copy: a host animates
|
|
9
|
+
* its own row "the same as a panel" by this reference (test/row-shell-style.test.ts pins it).
|
|
10
|
+
* @public */
|
|
11
|
+
export declare const PANEL_TRANSITION: {
|
|
12
|
+
duration: number;
|
|
13
|
+
ease: [number, number, number, number];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* One live panel's animated slot, absolutely positioned at its own `leftOffset` so adding,
|
|
17
|
+
* removing or resizing a panel never disturbs the ones to its left; `x`, not `left`, so the
|
|
18
|
+
* browser composites it. Motion drives enter/exit (AnimatePresence keeps an outgoing node
|
|
19
|
+
* mounted through its own unmount commit, unlike a plain CSS transition), and its Web Animations
|
|
20
|
+
* API is immune to a theme's transition/animation:none reset, which must stay scoped there and never reach
|
|
21
|
+
* transform/opacity, or it would kill this silently.
|
|
22
|
+
*/
|
|
23
|
+
export interface PanelRowSlotMotion {
|
|
24
|
+
readonly initial: TargetAndTransition;
|
|
25
|
+
readonly animate: TargetAndTransition;
|
|
26
|
+
readonly exit: TargetAndTransition;
|
|
27
|
+
readonly transition: typeof PANEL_TRANSITION;
|
|
28
|
+
}
|
|
29
|
+
type ExitDirection = 'down' | 'left' | 'right';
|
|
30
|
+
/**
|
|
31
|
+
* The pure values behind one PanelRowSlot render, split out so a test can assert exact motion
|
|
32
|
+
* values without mounting a component. `exit` stays entirely local to this function: 'down' needs a second
|
|
33
|
+
* axis and its own transition, 'left' a target independent of `leftOffset`; neither fits directionalTransition's
|
|
34
|
+
* symmetric default.
|
|
35
|
+
*/
|
|
36
|
+
export declare function panelRowSlotMotion(leftOffset: number, width: number, phoneHeight: number, enterDirection: 'left' | 'right', exitDirection: ExitDirection): PanelRowSlotMotion;
|
|
37
|
+
/**
|
|
38
|
+
* One entry's animated slot in the row: its position, width, enter/exit motion and stacking.
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
export declare function PanelRowSlot({ leftOffset, width, phoneHeight, enterDirection, exitDirection, zIndex, children, }: {
|
|
42
|
+
/** This slot's own rest x position -- the caller's running sum of every PRECEDING present item's own width plus one gap. */
|
|
43
|
+
leftOffset: number;
|
|
44
|
+
/** This panel's own width (`panelWidthOf`), also this slot's enter/exit travel distance (`panelRowSlotMotion`'s `ownSpan`). */
|
|
45
|
+
width: number;
|
|
46
|
+
phoneHeight: number;
|
|
47
|
+
/** Computed by the caller (panelRowOrder.ts's `resolveEnterDirection`, or an explicit gesture side).
|
|
48
|
+
* Must be correct on this component's FIRST render after the item (re)appears -- AnimatePresence
|
|
49
|
+
* has no later render of a fresh mount to fix a wrong guess. */
|
|
50
|
+
enterDirection: 'left' | 'right';
|
|
51
|
+
/** Must be correct on this component's LAST render before it leaves the `live`-filtered array --
|
|
52
|
+
* AnimatePresence has no later render of a removed child to fix a wrong guess. */
|
|
53
|
+
exitDirection: 'down' | 'left' | 'right';
|
|
54
|
+
/**
|
|
55
|
+
* Each slot's `motion.div` carries a `transform`, so it is its OWN stacking context and a child's `zIndex`
|
|
56
|
+
* cannot compare against a SIBLING slot: stacking between slots (e.g. a raised column above a panel sliding
|
|
57
|
+
* left behind it) must be set HERE, on the slot, not on what renders inside it. Defaults to 0.
|
|
58
|
+
*/
|
|
59
|
+
zIndex?: number;
|
|
60
|
+
children: React.ReactNode;
|
|
61
|
+
}): React.JSX.Element;
|
|
62
|
+
/**
|
|
63
|
+
* The element a resize animates: an INNER box, never the slot, which owns `exit` (Motion's docs warn against
|
|
64
|
+
* `layout` and `exit` on one element). A layout animation sets the real size once and animates a transform
|
|
65
|
+
* toward it, so the view never re-lays out mid-resize; `layout="size"` because the slot's `x` already animates
|
|
66
|
+
* position. A view renders it inside its own slot, so a view that never resizes needs no wrapper.
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
export declare function PanelSizeBox({ width, height, children }: {
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
children: React.ReactNode;
|
|
73
|
+
}): React.JSX.Element;
|
|
74
|
+
export {};
|
package/PanelRowSlot.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { motion } from 'motion/react';
|
|
3
|
+
import { DRAG_CLOSE } from './dragToClose.js';
|
|
4
|
+
import { SLOT_TRANSITION, directionalTransition } from './directionalTransition.js';
|
|
5
|
+
/**
|
|
6
|
+
* The one spacing value used everywhere a gap is needed in this row.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export const GAP = 16;
|
|
10
|
+
/** The timing/easing every panel slide uses. The SAME object as SLOT_TRANSITION, not a copy: a host animates
|
|
11
|
+
* its own row "the same as a panel" by this reference (test/row-shell-style.test.ts pins it).
|
|
12
|
+
* @public */
|
|
13
|
+
export const PANEL_TRANSITION = SLOT_TRANSITION;
|
|
14
|
+
/**
|
|
15
|
+
* The pure values behind one PanelRowSlot render, split out so a test can assert exact motion
|
|
16
|
+
* values without mounting a component. `exit` stays entirely local to this function: 'down' needs a second
|
|
17
|
+
* axis and its own transition, 'left' a target independent of `leftOffset`; neither fits directionalTransition's
|
|
18
|
+
* symmetric default.
|
|
19
|
+
*/
|
|
20
|
+
export function panelRowSlotMotion(leftOffset, width, phoneHeight, enterDirection, exitDirection) {
|
|
21
|
+
// This panel's OWN footprint (its width plus one gap) is how far it must travel to clear the row edge,
|
|
22
|
+
// so the distance is per panel: a wider one travels further.
|
|
23
|
+
const ownSpan = width + GAP;
|
|
24
|
+
const rest = leftOffset;
|
|
25
|
+
// Positive distance enters from the right (directionalTransition.ts's own sign convention); a negative
|
|
26
|
+
// one enters from the left -- see panelRowOrder.ts for who decides which.
|
|
27
|
+
const enterDistance = enterDirection === 'left' ? -ownSpan : ownSpan;
|
|
28
|
+
const { initial, animate, transition } = directionalTransition('x', rest, enterDistance);
|
|
29
|
+
// 'down': a close, timed to DragToClose's exit so trash and drag closes read as one motion (DragToClose calls
|
|
30
|
+
// `onClose` after its own `y` animation, so this ramps in under a box already off-screen). 'left'/'right': a
|
|
31
|
+
// park, off the edge it was hidden past, so a close never looks like a park.
|
|
32
|
+
const exit = exitDirection === 'down'
|
|
33
|
+
? { x: rest, y: phoneHeight, opacity: 0, transition: { duration: DRAG_CLOSE.exitSeconds, ease: 'easeIn' } }
|
|
34
|
+
: exitDirection === 'right'
|
|
35
|
+
? { x: rest + ownSpan, opacity: 0 }
|
|
36
|
+
: { x: -ownSpan, opacity: 0 };
|
|
37
|
+
return { initial, animate, exit, transition };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One entry's animated slot in the row: its position, width, enter/exit motion and stacking.
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
export function PanelRowSlot({ leftOffset, width, phoneHeight, enterDirection, exitDirection, zIndex, children, }) {
|
|
44
|
+
const { initial, animate, exit, transition } = panelRowSlotMotion(leftOffset, width, phoneHeight, enterDirection, exitDirection);
|
|
45
|
+
return (_jsx(motion.div
|
|
46
|
+
// Enters from one own-width-plus-gap span beyond its destination, right or left per `enterDirection` above.
|
|
47
|
+
, {
|
|
48
|
+
// Enters from one own-width-plus-gap span beyond its destination, right or left per `enterDirection` above.
|
|
49
|
+
initial: initial, animate: animate, exit: exit, transition: transition, style: {
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
top: 0,
|
|
52
|
+
bottom: 0,
|
|
53
|
+
left: 0,
|
|
54
|
+
// Snaps, never animates: nothing paints the slot's own width (PanelSizeBox animates the visible resize).
|
|
55
|
+
width,
|
|
56
|
+
zIndex: zIndex ?? 0,
|
|
57
|
+
}, children: children }));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The element a resize animates: an INNER box, never the slot, which owns `exit` (Motion's docs warn against
|
|
61
|
+
* `layout` and `exit` on one element). A layout animation sets the real size once and animates a transform
|
|
62
|
+
* toward it, so the view never re-lays out mid-resize; `layout="size"` because the slot's `x` already animates
|
|
63
|
+
* position. A view renders it inside its own slot, so a view that never resizes needs no wrapper.
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
export function PanelSizeBox({ width, height, children }) {
|
|
67
|
+
return (_jsx(motion.div, { "data-panel-size-box": "", layout: "size", transition: PANEL_TRANSITION, style: { width, height }, children: children }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
/** @public */
|
|
3
|
+
export interface PhoneDetectPromptProps {
|
|
4
|
+
/** Non-null exactly once, right after the host first sees a phone-like USB device it has not asked about. */
|
|
5
|
+
detected: {
|
|
6
|
+
vendorId: number;
|
|
7
|
+
productId: number;
|
|
8
|
+
} | null;
|
|
9
|
+
onResize: (modelId: string) => void;
|
|
10
|
+
onDismiss: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The one-time "phone connected" notice: a non-blocking toast card, not a modal, with the model picker inline
|
|
14
|
+
* (the PHONE_MODELS list Settings uses; one click resizes). It renders only the card, un-positioned: the host
|
|
15
|
+
* places it. No Portal, because a static render (renderToStaticMarkup) has no `document` for one to target.
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export declare function PhoneDetectPrompt({ detected, onResize, onDismiss }: PhoneDetectPromptProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import Box from '@mui/material/Box';
|
|
4
|
+
import Button from '@mui/material/Button';
|
|
5
|
+
import IconButton from '@mui/material/IconButton';
|
|
6
|
+
import Typography from '@mui/material/Typography';
|
|
7
|
+
import CloseIcon from '@mui/icons-material/Close';
|
|
8
|
+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
9
|
+
import SmartphoneIcon from '@mui/icons-material/Smartphone';
|
|
10
|
+
import { PHONE_MODELS, vendorLabelForUsbVendorId } from './phoneModels.js';
|
|
11
|
+
/**
|
|
12
|
+
* The one-time "phone connected" notice: a non-blocking toast card, not a modal, with the model picker inline
|
|
13
|
+
* (the PHONE_MODELS list Settings uses; one click resizes). It renders only the card, un-positioned: the host
|
|
14
|
+
* places it. No Portal, because a static render (renderToStaticMarkup) has no `document` for one to target.
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
17
|
+
export function PhoneDetectPrompt({ detected, onResize, onDismiss }) {
|
|
18
|
+
const [modelId, setModelId] = useState(PHONE_MODELS[0]?.id ?? 'default');
|
|
19
|
+
if (!detected)
|
|
20
|
+
return null;
|
|
21
|
+
return (_jsxs(Box, { "data-phone-detect-prompt": "", role: "status", "aria-label": "Phone connected", sx: {
|
|
22
|
+
width: 'max-content',
|
|
23
|
+
maxWidth: '90vw',
|
|
24
|
+
display: 'flex',
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
gap: 1,
|
|
27
|
+
boxSizing: 'border-box',
|
|
28
|
+
bgcolor: 'background.paper',
|
|
29
|
+
border: '1px solid',
|
|
30
|
+
borderColor: 'divider',
|
|
31
|
+
borderRadius: 2.5,
|
|
32
|
+
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.45)',
|
|
33
|
+
px: 1.5,
|
|
34
|
+
py: 1,
|
|
35
|
+
}, children: [_jsx(SmartphoneIcon, { fontSize: "small", sx: { color: 'primary.main', flexShrink: 0 } }), _jsx(Typography, { variant: "caption", component: "span", color: "text.secondary", sx: { fontWeight: 600, whiteSpace: 'nowrap' }, children: `${vendorLabelForUsbVendorId(detected.vendorId)} connected` }), _jsxs(Box, { sx: { position: 'relative', display: 'flex', alignItems: 'center' }, children: [_jsx(Box, { component: "select", "data-phone-detect-select": "", "aria-label": "Phone model", value: modelId, onChange: (e) => setModelId(e.target.value), sx: (theme) => ({
|
|
36
|
+
width: 240,
|
|
37
|
+
boxSizing: 'border-box',
|
|
38
|
+
font: 'inherit',
|
|
39
|
+
fontSize: theme.typography.caption.fontSize,
|
|
40
|
+
py: 0.75,
|
|
41
|
+
pl: 1,
|
|
42
|
+
pr: 3.5,
|
|
43
|
+
borderRadius: 1.5,
|
|
44
|
+
border: '1px solid',
|
|
45
|
+
borderColor: 'divider',
|
|
46
|
+
bgcolor: 'background.default',
|
|
47
|
+
color: 'inherit',
|
|
48
|
+
appearance: 'none',
|
|
49
|
+
WebkitAppearance: 'none',
|
|
50
|
+
cursor: 'pointer',
|
|
51
|
+
}), children: PHONE_MODELS.map((m) => (_jsx("option", { value: m.id, children: m.label }, m.id))) }), _jsx(ExpandMoreIcon, { fontSize: "small", sx: { position: 'absolute', right: 6, color: 'text.disabled', pointerEvents: 'none' } })] }), _jsx(Button, { size: "small", variant: "contained", disableElevation: true, onClick: () => onResize(modelId), sx: { flexShrink: 0 }, children: "Resize" }), _jsx(IconButton, { size: "small", "aria-label": "Dismiss", onClick: onDismiss, sx: { flexShrink: 0, ml: -0.5 }, children: _jsx(CloseIcon, { fontSize: "small" }) })] }));
|
|
52
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# phonux
|
|
2
|
+
|
|
3
|
+
A phone-shaped view host for React: write a view against a small set of host
|
|
4
|
+
APIs, register it, and render it inside one `<HostProvider>`. The host decides
|
|
5
|
+
which views are live; a view never touches the desktop bridge, `localStorage`,
|
|
6
|
+
or anything else app-internal (see `DESIGN.md` in this folder for why).
|
|
7
|
+
|
|
8
|
+
Install it from npm, together with the peer dependencies listed below:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install phonux
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package also ships `DESIGN.md`, which explains the design decisions behind
|
|
15
|
+
the API.
|
|
16
|
+
|
|
17
|
+
## Peer dependencies
|
|
18
|
+
|
|
19
|
+
phonux does not bundle React, MUI, emotion or motion -- it expects the host
|
|
20
|
+
app to already have them:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled motion
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
A view reads what it needs from `useHost()` and renders a `<Panel>`:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { Panel, PanelFrame, useHost, registerView, type ViewProps } from 'phonux';
|
|
32
|
+
|
|
33
|
+
function MyView({ panel }: ViewProps) {
|
|
34
|
+
const host = useHost();
|
|
35
|
+
return (
|
|
36
|
+
<Panel title={panel.title}>
|
|
37
|
+
<PanelFrame>{host.device.size.width}px wide</PanelFrame>
|
|
38
|
+
</Panel>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
registerView('my-view', MyView);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The host mounts one `<HostProvider>`, near the app's root, with its own
|
|
46
|
+
implementation of the host APIs:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { HostProvider } from 'phonux';
|
|
50
|
+
|
|
51
|
+
function App({ host }) {
|
|
52
|
+
return (
|
|
53
|
+
<HostProvider host={host}>
|
|
54
|
+
{/* ...the row that resolves and renders registered views... */}
|
|
55
|
+
</HostProvider>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Testing a view
|
|
61
|
+
|
|
62
|
+
`phonux/testing` exports `createFakeHost()`: a recording fake of every host
|
|
63
|
+
API, so a view can be rendered with no real host behind it.
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { render } from '@testing-library/react';
|
|
67
|
+
import { HostProvider } from 'phonux';
|
|
68
|
+
import { createFakeHost } from 'phonux/testing';
|
|
69
|
+
|
|
70
|
+
render(
|
|
71
|
+
<HostProvider host={createFakeHost()}>
|
|
72
|
+
<MyView panel={{ id: '1', url: 'about:blank', live: true, locked: false }} />
|
|
73
|
+
</HostProvider>,
|
|
74
|
+
);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`createFakeHost(overrides)` replaces whole members of one API (e.g.
|
|
78
|
+
`{ panels: { panelList: [...] } }`); every call any fake receives is
|
|
79
|
+
recorded, in order, on the returned host's `.calls`.
|
|
80
|
+
|
|
81
|
+
See `DESIGN.md` in this folder for the reasoning behind the API shape, the
|
|
82
|
+
panel-view registry, and what stays out of a view's reach on purpose.
|
|
83
|
+
|
|
84
|
+
## Developing phonux
|
|
85
|
+
|
|
86
|
+
`npm ci`, then `npm run typecheck`, `npm test`, `npm run build`, `npm run check:api-report` and `npm run smoke` (the smoke installs from the registry). DESIGN.md, shipped in the package, covers the design, the release flow and how to try a change in an app before publishing.
|
package/Workspace.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace shell: the scroll container that holds the centred, animated row, plus the pure
|
|
3
|
+
* `workspaceShellStyle` derivation behind its centring math (computed synchronously on every render,
|
|
4
|
+
* never through state or an effect -- see DESIGN.md). The host measures the row itself, before building its
|
|
5
|
+
* own panels API in the same render, and passes the measurement in as props.
|
|
6
|
+
*/
|
|
7
|
+
import * as React from 'react';
|
|
8
|
+
import { type RefObject } from 'react';
|
|
9
|
+
import { type BoxProps } from '@mui/material/Box';
|
|
10
|
+
import { PANEL_TRANSITION } from './PanelRowSlot.js';
|
|
11
|
+
/** The pure half of the row shell's input: the two measured fields the host's row measurement produces,
|
|
12
|
+
* flattened here (not a nested `shell` object) so this file never imports a host measurement type.
|
|
13
|
+
* @internal */
|
|
14
|
+
export interface WorkspaceShellStyleInput {
|
|
15
|
+
/** The scroll container's measured width. 0 before the first measurement. */
|
|
16
|
+
containerWidth: number;
|
|
17
|
+
/** The margin glides once this has held still; it snaps otherwise (see the caller's own settle timer). */
|
|
18
|
+
rowResizeSettled: boolean;
|
|
19
|
+
/** The width every fixed column renders at (PhoneDeviceAPI size). */
|
|
20
|
+
phoneWidth: number;
|
|
21
|
+
/** The number of always-present columns the row renders. */
|
|
22
|
+
fixedColumnCount: number;
|
|
23
|
+
/** Each LIVE panel's own width, in row order (useProvidePanels's `liveWidths`): a parked one is not in the row. */
|
|
24
|
+
liveWidths: readonly number[];
|
|
25
|
+
}
|
|
26
|
+
/** @internal */
|
|
27
|
+
export interface WorkspaceShellStyle {
|
|
28
|
+
/** The row's centring margin, px. */
|
|
29
|
+
marginLeft: number;
|
|
30
|
+
/** Snap while the container width is still moving, glide once it has held still. */
|
|
31
|
+
transition: typeof PANEL_TRANSITION | {
|
|
32
|
+
duration: 0;
|
|
33
|
+
};
|
|
34
|
+
/** The scroll container's horizontal overflow -- always 'hidden': the row hides its far panel instead of ever scrolling or growing past what fits. */
|
|
35
|
+
overflowX: 'hidden';
|
|
36
|
+
}
|
|
37
|
+
/** Everything the markup needs from the measurement, computed synchronously while rendering (never through state or an effect).
|
|
38
|
+
* @internal */
|
|
39
|
+
export declare function workspaceShellStyle(i: WorkspaceShellStyleInput): WorkspaceShellStyle;
|
|
40
|
+
/** @public */
|
|
41
|
+
export interface WorkspaceProps extends Omit<BoxProps, 'children'> {
|
|
42
|
+
/** Attach to the scroll container: the caller's ResizeObserver measures it. */
|
|
43
|
+
overflowContainerRef: RefObject<HTMLDivElement | null>;
|
|
44
|
+
/** Attach to the row (the flex box holding the columns): the SAME ref object a caller's own
|
|
45
|
+
* `beforeRow` content may need too, so both centre against the one measured element. */
|
|
46
|
+
rowRef: RefObject<HTMLDivElement | null>;
|
|
47
|
+
containerWidth: number;
|
|
48
|
+
rowResizeSettled: boolean;
|
|
49
|
+
phoneWidth: number;
|
|
50
|
+
fixedColumnCount: number;
|
|
51
|
+
liveWidths: readonly number[];
|
|
52
|
+
/** Clearance above the row, in px. Defaults to GAP; a host with its own chrome above the scroll container (e.g. a drag strip) passes more. */
|
|
53
|
+
topPadding?: number;
|
|
54
|
+
/** Rendered as the scroll container's first child, ahead of the row -- a slot for host content (e.g. a
|
|
55
|
+
* device toast) that must sit there without being absorbed as row content via `children`. */
|
|
56
|
+
beforeRow?: React.ReactNode;
|
|
57
|
+
children: React.ReactNode;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The workspace shell: a scroll container holding the centred, animated row, with two OverlaySlots
|
|
61
|
+
* (`dialog` inside the row, `toast` as this component's own last sibling) so a view's WindowOverlay always
|
|
62
|
+
* has somewhere to render, independent of the host's own mount order.
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
export declare function Workspace(props: WorkspaceProps): React.ReactElement;
|
package/Workspace.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { motion } from 'motion/react';
|
|
3
|
+
import Box from '@mui/material/Box';
|
|
4
|
+
import { computeRowLayout } from './panelRowLayout.js';
|
|
5
|
+
import { GAP, PANEL_TRANSITION } from './PanelRowSlot.js';
|
|
6
|
+
import { OverlaySlot } from './windowOverlay.js';
|
|
7
|
+
/** Everything the markup needs from the measurement, computed synchronously while rendering (never through state or an effect).
|
|
8
|
+
* @internal */
|
|
9
|
+
export function workspaceShellStyle(i) {
|
|
10
|
+
// Calculated from known widths, never measured off the DOM. `fixedColumnCount` and `liveWidths` must come
|
|
11
|
+
// from the same array the row's JSX renders (see computeRowLayout's own doc comment for why).
|
|
12
|
+
const { computedRowMarginLeft } = computeRowLayout({
|
|
13
|
+
containerWidth: i.containerWidth,
|
|
14
|
+
phoneWidth: i.phoneWidth,
|
|
15
|
+
gap: GAP,
|
|
16
|
+
fixedItemCount: i.fixedColumnCount,
|
|
17
|
+
liveWidths: i.liveWidths,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
marginLeft: computedRowMarginLeft,
|
|
21
|
+
transition: i.rowResizeSettled ? PANEL_TRANSITION : { duration: 0 },
|
|
22
|
+
overflowX: 'hidden',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The workspace shell: a scroll container holding the centred, animated row, with two OverlaySlots
|
|
27
|
+
* (`dialog` inside the row, `toast` as this component's own last sibling) so a view's WindowOverlay always
|
|
28
|
+
* has somewhere to render, independent of the host's own mount order.
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
export function Workspace(props) {
|
|
32
|
+
const { overflowContainerRef, rowRef, containerWidth, rowResizeSettled, phoneWidth, fixedColumnCount, liveWidths, topPadding, beforeRow, children, ...rest } = props;
|
|
33
|
+
const style = workspaceShellStyle({ containerWidth, rowResizeSettled, phoneWidth, fixedColumnCount, liveWidths });
|
|
34
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Box, { ref: overflowContainerRef, ...rest, sx: {
|
|
35
|
+
position: 'relative',
|
|
36
|
+
display: 'flex',
|
|
37
|
+
height: '100%',
|
|
38
|
+
minHeight: 0,
|
|
39
|
+
pt: `${topPadding ?? GAP}px`,
|
|
40
|
+
pb: `${GAP}px`,
|
|
41
|
+
pl: `${GAP}px`,
|
|
42
|
+
pr: `${GAP}px`,
|
|
43
|
+
// Always hidden: the row hides its far panel instead of ever scrolling or growing past what fits.
|
|
44
|
+
overflowX: style.overflowX,
|
|
45
|
+
overflowY: 'auto',
|
|
46
|
+
}, children: [beforeRow, _jsxs(motion.div, { ref: rowRef, style: { display: 'flex', gap: `${GAP}px`, marginTop: 'auto', marginBottom: 'auto', marginRight: 'auto' }, animate: { marginLeft: style.marginLeft }, transition: style.transition, children: [_jsx(OverlaySlot, { name: "dialog" }), children] })] }), _jsx(OverlaySlot, { name: "toast" })] }));
|
|
47
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Theme } from '@mui/material/styles';
|
|
2
|
+
/**
|
|
3
|
+
* An optional MUI theme for a consumer that embeds phonux views and has no theme of its own; phonux never
|
|
4
|
+
* applies one implicitly. Wire both: `<ThemeProvider theme={createDefaultTheme(mode)}><CssBaseline />...`.
|
|
5
|
+
* The no-animation reset lives in MuiCssBaseline's styleOverrides, inert until CssBaseline renders
|
|
6
|
+
* (https://github.com/mui/material-ui/issues/16483). It touches only `transition`/`animation`: slides write
|
|
7
|
+
* `transform`/`opacity` directly, so an `!important` on either would freeze them silently. See DESIGN.md.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export declare function createDefaultTheme(mode: 'light' | 'dark'): Theme;
|
package/defaultTheme.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createTheme } from '@mui/material/styles';
|
|
2
|
+
/**
|
|
3
|
+
* An optional MUI theme for a consumer that embeds phonux views and has no theme of its own; phonux never
|
|
4
|
+
* applies one implicitly. Wire both: `<ThemeProvider theme={createDefaultTheme(mode)}><CssBaseline />...`.
|
|
5
|
+
* The no-animation reset lives in MuiCssBaseline's styleOverrides, inert until CssBaseline renders
|
|
6
|
+
* (https://github.com/mui/material-ui/issues/16483). It touches only `transition`/`animation`: slides write
|
|
7
|
+
* `transform`/`opacity` directly, so an `!important` on either would freeze them silently. See DESIGN.md.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export function createDefaultTheme(mode) {
|
|
11
|
+
return createTheme({
|
|
12
|
+
palette: { mode },
|
|
13
|
+
transitions: { create: () => 'none' },
|
|
14
|
+
components: {
|
|
15
|
+
MuiCssBaseline: {
|
|
16
|
+
styleOverrides: {
|
|
17
|
+
'*, *::before, *::after': {
|
|
18
|
+
transition: 'none !important',
|
|
19
|
+
animation: 'none !important',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
MuiButtonBase: { defaultProps: { disableRipple: true } },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The enter/exit timing, easing and opacity every animated slot shares, so a live panel and a History row
|
|
3
|
+
* slide with one feel. A plain function, not a hook: it holds no state and calls no hook, so a test calls it
|
|
4
|
+
* directly. Why it shares a preset and not the position math: DESIGN.md, "`directionalTransition` and the
|
|
5
|
+
* test fakes are subpaths, not part of `.`".
|
|
6
|
+
*/
|
|
7
|
+
import type { TargetAndTransition } from 'motion/react';
|
|
8
|
+
/** The one enter/exit timing/easing every animated slot in the app shares. @internal */
|
|
9
|
+
export declare const SLOT_TRANSITION: {
|
|
10
|
+
duration: number;
|
|
11
|
+
ease: [number, number, number, number];
|
|
12
|
+
};
|
|
13
|
+
/** @internal */
|
|
14
|
+
export type TransitionAxis = 'x' | 'y';
|
|
15
|
+
/** @internal */
|
|
16
|
+
export interface DirectionalTransition {
|
|
17
|
+
readonly transition: typeof SLOT_TRANSITION;
|
|
18
|
+
/** A motion.div's `initial`: `rest + distance` along `axis`, faded out. */
|
|
19
|
+
readonly initial: TargetAndTransition;
|
|
20
|
+
/** A motion.div's `animate`: `rest` along `axis`, faded in. */
|
|
21
|
+
readonly animate: TargetAndTransition;
|
|
22
|
+
/** The symmetric exit: `rest - distance` along `axis`, faded out. A caller with more than one exit
|
|
23
|
+
* reason builds its own `exit` and takes only `transition`. */
|
|
24
|
+
readonly exit: TargetAndTransition;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Enter/exit values for one card sliding along `axis`. `rest` is its settled position (0 in normal flow, as a
|
|
28
|
+
* History row; `index * slotWidth` when absolutely positioned, as a live panel). `distance` is how far past
|
|
29
|
+
* `rest` it starts when entering, and it leaves to the opposite side. It is signed: a positive `distance`
|
|
30
|
+
* enters from the positive-`axis` side (the right, or below), a negative one from the left, or above.
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export declare function directionalTransition(axis: TransitionAxis, rest: number, distance: number): DirectionalTransition;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Seconds, tuned by eye (0.26 read as too fast); one value for every slot, so no copy can drift. */
|
|
2
|
+
const TRANSITION_SEC = 0.4;
|
|
3
|
+
/** Motion wants a cubic-bezier as a fixed 4-tuple, not a plain number[]. */
|
|
4
|
+
const TRANSITION_EASE = [0.2, 0, 0, 1];
|
|
5
|
+
/** The one enter/exit timing/easing every animated slot in the app shares. @internal */
|
|
6
|
+
export const SLOT_TRANSITION = { duration: TRANSITION_SEC, ease: TRANSITION_EASE };
|
|
7
|
+
function withAxis(axis, value, opacity) {
|
|
8
|
+
return axis === 'x' ? { x: value, opacity } : { y: value, opacity };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Enter/exit values for one card sliding along `axis`. `rest` is its settled position (0 in normal flow, as a
|
|
12
|
+
* History row; `index * slotWidth` when absolutely positioned, as a live panel). `distance` is how far past
|
|
13
|
+
* `rest` it starts when entering, and it leaves to the opposite side. It is signed: a positive `distance`
|
|
14
|
+
* enters from the positive-`axis` side (the right, or below), a negative one from the left, or above.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export function directionalTransition(axis, rest, distance) {
|
|
18
|
+
return {
|
|
19
|
+
transition: SLOT_TRANSITION,
|
|
20
|
+
initial: withAxis(axis, rest + distance, 0),
|
|
21
|
+
animate: withAxis(axis, rest, 1),
|
|
22
|
+
exit: withAxis(axis, rest - distance, 0),
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dragToClose.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag a view's grab handle DOWN to close it; with `reorder`, also sideways to reorder. It knows nothing about
|
|
3
|
+
* panels or the app. onClose MUST remove the view: a close never unlocks the scroll ancestors. Also offer a
|
|
4
|
+
* button calling the same onClose (WCAG 2.5.7). The full contract: DESIGN.md, "`DragToClose`".
|
|
5
|
+
*/
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
/** The tunables, in one place. Distances in px, velocities in px/s, positive = downward. @public */
|
|
8
|
+
export declare const DRAG_CLOSE: {
|
|
9
|
+
/** Close once dragged this fraction of the view's height ... */
|
|
10
|
+
readonly distanceFraction: 0.25;
|
|
11
|
+
/** ... but never less than this: a click that slips a few px is not a close. */
|
|
12
|
+
readonly minDistancePx: 80;
|
|
13
|
+
/** A flick closes early: at least this fast at release ... */
|
|
14
|
+
readonly flickVelocity: 700;
|
|
15
|
+
/** ... and at least this far. */
|
|
16
|
+
readonly flickMinDistancePx: 40;
|
|
17
|
+
/** Released while moving back UP faster than this (negative): the user changed their mind. */
|
|
18
|
+
readonly cancelVelocity: -300;
|
|
19
|
+
/** Seconds the box takes to leave the screen. */
|
|
20
|
+
readonly exitSeconds: 0.18;
|
|
21
|
+
readonly snapBack: {
|
|
22
|
+
readonly type: "spring";
|
|
23
|
+
readonly stiffness: 520;
|
|
24
|
+
readonly damping: 42;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/** @public */
|
|
28
|
+
export interface ReleaseMeasure {
|
|
29
|
+
/** How far the box was dragged from its resting place, px (positive = down). */
|
|
30
|
+
offsetY: number;
|
|
31
|
+
/** Vertical velocity at release, px/s (positive = down). */
|
|
32
|
+
velocityY: number;
|
|
33
|
+
/** The box's own height, px (0 or NaN when unmeasured: only the minimum distance applies). */
|
|
34
|
+
height: number;
|
|
35
|
+
}
|
|
36
|
+
/** The whole close rule, pure. Never closes on an upward or zero offset, or when released moving back up. @public */
|
|
37
|
+
export declare function shouldCloseOnRelease({ offsetY, velocityY, height }: ReleaseMeasure): boolean;
|
|
38
|
+
/** Spread onto Panel's `grabProps`. @public */
|
|
39
|
+
export interface DragHandleProps {
|
|
40
|
+
onPointerDown: (event: React.PointerEvent<HTMLElement>) => void;
|
|
41
|
+
sx: {
|
|
42
|
+
cursor: 'grab';
|
|
43
|
+
touchAction: 'pan-x';
|
|
44
|
+
userSelect: 'none';
|
|
45
|
+
'&:active': {
|
|
46
|
+
cursor: 'grabbing';
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Hands the same handle's horizontal drag to the caller's own reorder (DESIGN.md, "`DragToClose`"). @public */
|
|
51
|
+
export interface ReorderHandle {
|
|
52
|
+
/** The gesture's own total horizontal offset (px, +right/-left from where the drag started), read once it has locked horizontal AND released. */
|
|
53
|
+
onDragEnd: (offsetX: number) => void;
|
|
54
|
+
}
|
|
55
|
+
/** @public */
|
|
56
|
+
export declare function DragToClose({ onClose, reorder, children, }: {
|
|
57
|
+
onClose: () => void;
|
|
58
|
+
reorder?: ReorderHandle;
|
|
59
|
+
children: (handle: DragHandleProps) => React.ReactNode;
|
|
60
|
+
}): React.ReactElement;
|