duet-ui 0.1.0 → 0.2.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/README.md +34 -7
- package/dist/chrome.d.ts +21 -7
- package/dist/chrome.js +53 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +35 -8
package/README.md
CHANGED
|
@@ -19,15 +19,20 @@ On the closed Duo, Safari keeps a narrow column beside the page for its own butt
|
|
|
19
19
|
## Usage
|
|
20
20
|
|
|
21
21
|
```tsx
|
|
22
|
-
|
|
23
|
-
import "duet-ui
|
|
22
|
+
"use client";
|
|
23
|
+
import { Duet, type DuetItem } from "duet-ui";
|
|
24
|
+
|
|
25
|
+
const TABS = [
|
|
26
|
+
{ id: "home", label: "Home", icon: <House size={24} /> },
|
|
27
|
+
{ id: "search", label: "Search", icon: <Search size={24} /> },
|
|
28
|
+
] as const satisfies readonly DuetItem[];
|
|
24
29
|
|
|
25
30
|
export function App() {
|
|
26
|
-
const [tab, setTab] = useState("home");
|
|
31
|
+
const [tab, setTab] = useState<(typeof TABS)[number]["id"]>("home");
|
|
27
32
|
return (
|
|
28
33
|
<Duet
|
|
29
34
|
tabs={{ items: TABS, selected: tab, onSelect: setTab }}
|
|
30
|
-
actions={[{ id: "share", label: "Share", icon: <Share />, onPress: share }]}
|
|
35
|
+
actions={[{ id: "share", label: "Share", icon: <Share size={22} />, onPress: share }]}
|
|
31
36
|
minimizeOnScroll
|
|
32
37
|
>
|
|
33
38
|
<Page tab={tab} />
|
|
@@ -36,7 +41,9 @@ export function App() {
|
|
|
36
41
|
}
|
|
37
42
|
```
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
That is the whole setup. The styles come with the component, and Duet adds `viewport-fit=cover` and `shrink-to-fit=no` to the viewport meta itself (the safe areas only reach the page with the first; the second keeps Safari from zooming a split-view pane out). Each tab keeps its own scroll position, and tapping the selected tab scrolls back to the top. On other phones and browsers Duet falls back to the `inline` strip.
|
|
45
|
+
|
|
46
|
+
Icons are yours, any library: 24pt in tabs, 21 to 22pt on round buttons matches the system. `as const` types the tab ids, so `onSelect` takes `setTab` without a cast.
|
|
40
47
|
|
|
41
48
|
## Modes
|
|
42
49
|
|
|
@@ -60,7 +67,27 @@ Each mode has a band the controls may use, and Duet keeps every control inside i
|
|
|
60
67
|
|
|
61
68
|
### Only on the Duo
|
|
62
69
|
|
|
63
|
-
`useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals.
|
|
70
|
+
`useDuet().duo` is true on an iPhone Duo in any posture, Safari or Home Screen, and false on other phones and desktops. It works outside `<Duet>`, so an existing app can switch to a Duo layout on it: render `<Duet>` only when it is true. `isDuo(signals)` is the same check for your own signals.
|
|
71
|
+
|
|
72
|
+
The server can't tell, so `<DuoCookie>` tells it: it keeps a `duet-duo` cookie in step with the browser, and a new device reloads once into the right layout. In the Next.js App Router:
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// app/page.tsx, a server component
|
|
76
|
+
import { cookies } from "next/headers";
|
|
77
|
+
import { DuoCookie } from "duet-ui";
|
|
78
|
+
|
|
79
|
+
export default async function Page() {
|
|
80
|
+
const duo = (await cookies()).get("duet-duo")?.value === "1";
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<DuoCookie rendered={duo} />
|
|
84
|
+
{duo ? <DuoApp /> : <DesktopApp />}
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Pass `onChange={() => router.refresh()}` from a client component to re-render without a reload.
|
|
64
91
|
|
|
65
92
|
### In-app preview
|
|
66
93
|
|
|
@@ -74,7 +101,7 @@ Links opened from social and mail apps land in the in-app Safari preview (`SFSaf
|
|
|
74
101
|
| `leading` | Its own group under back, like SwiftUI `.topBarLeading` |
|
|
75
102
|
| `actions` | Like `.primaryAction`; collapses into "…" when it would reach the tabs. Items with `destructive: true` only ever show in "…", last, in red |
|
|
76
103
|
| `primary` | The one main action (compose, new). Narrow: just above the tabs. Wide: under back, at the top. Folds into "…" last, only when the band has no room for both |
|
|
77
|
-
| `tabs` | `{ items, selected, onSelect, search? }`. `search` adds a search tab as the last item |
|
|
104
|
+
| `tabs` | `{ items, selected, onSelect, search? }`. `search` adds a search tab as the last item. Each tab keeps its scroll position |
|
|
78
105
|
| `searchButton` | A round search button at the bottom when there are no tabs |
|
|
79
106
|
| `close` | A sheet or card is open: the strip holds only a close button until it is cleared |
|
|
80
107
|
| `accessory` | A view docked by the tabs, like `.tabViewBottomAccessory` (a mini player) |
|
package/dist/chrome.d.ts
CHANGED
|
@@ -13,7 +13,11 @@ export interface DuetItem {
|
|
|
13
13
|
/** Deletes or discards: lives only in "…", last, in red, after a separator (blueprint). */
|
|
14
14
|
destructive?: boolean;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
/** A tab: an item whose id is one of the app's tab ids. */
|
|
17
|
+
export type DuetTab<T extends string = string> = DuetItem & {
|
|
18
|
+
id: T;
|
|
19
|
+
};
|
|
20
|
+
export interface DuetProps<T extends string = string> {
|
|
17
21
|
/** Back button, top of the strip. */
|
|
18
22
|
back?: false | {
|
|
19
23
|
label?: string;
|
|
@@ -27,12 +31,14 @@ export interface DuetProps {
|
|
|
27
31
|
/** The screen's main action (New chat, Compose), a tinted round button: by the thumb above the tabs where one
|
|
28
32
|
* pane shows, at the top under back where two do. */
|
|
29
33
|
primary?: DuetItem;
|
|
30
|
-
/** Tab pill at the bottom of the strip. `search` adds a `role: .search` tab as the last item.
|
|
34
|
+
/** Tab pill at the bottom of the strip. `search` adds a `role: .search` tab as the last item. The ids are typed
|
|
35
|
+
* from `items`: declare them `as const` and `onSelect` gets the union, so `setTab` fits without a cast. Each tab
|
|
36
|
+
* keeps its own scroll position, and tapping the selected tab scrolls it to the top, like a native tab bar. */
|
|
31
37
|
tabs?: {
|
|
32
|
-
items:
|
|
33
|
-
selected:
|
|
34
|
-
onSelect: (id:
|
|
35
|
-
search?:
|
|
38
|
+
items: readonly DuetTab<T>[];
|
|
39
|
+
selected: NoInfer<T>;
|
|
40
|
+
onSelect: (id: NoInfer<T>) => void;
|
|
41
|
+
search?: DuetTab<T>;
|
|
36
42
|
};
|
|
37
43
|
/** Searchable without tabs: a round search button at the bottom of the strip. */
|
|
38
44
|
searchButton?: () => void;
|
|
@@ -56,4 +62,12 @@ export interface DuetProps {
|
|
|
56
62
|
children?: ReactNode;
|
|
57
63
|
}
|
|
58
64
|
export declare function useDuet(): Context;
|
|
59
|
-
|
|
65
|
+
/** Tells the server whether this browser is an iPhone Duo, so it can render the Duo layout from the first byte.
|
|
66
|
+
* The server can't tell on its own, so it renders what the `duet-duo` cookie says (`"1"` on a Duo) and passes that
|
|
67
|
+
* as `rendered`. When the browser disagrees, this sets the cookie and calls `onChange`, by default a reload, so a
|
|
68
|
+
* new device switches layout once and never again. Renders nothing; works outside `<Duet>`. */
|
|
69
|
+
export declare function DuoCookie({ rendered, onChange }: {
|
|
70
|
+
rendered: boolean;
|
|
71
|
+
onChange?: (duo: boolean) => void;
|
|
72
|
+
}): null;
|
|
73
|
+
export declare function Duet<T extends string = string>(props: DuetProps<T>): import("react").JSX.Element;
|
package/dist/chrome.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
4
4
|
import { ArrowLeftRight, ChevronLeft, Ellipsis, Search, X } from "./icons.js";
|
|
5
5
|
import { DUO, INLINE_WIDTH, ISLAND, MORPH, isSplit, magnet, plan, stable } from "./geometry.js";
|
|
6
6
|
import { GlassProvider } from "./glassyui/glass-provider.js";
|
|
@@ -120,6 +120,23 @@ const ssr = { mode: "column", duo: true, layout: "narrow", side: "right", width:
|
|
|
120
120
|
export function useDuet() {
|
|
121
121
|
return useSyncExternalStore(subscribe, read, () => ssr);
|
|
122
122
|
}
|
|
123
|
+
/** Tells the server whether this browser is an iPhone Duo, so it can render the Duo layout from the first byte.
|
|
124
|
+
* The server can't tell on its own, so it renders what the `duet-duo` cookie says (`"1"` on a Duo) and passes that
|
|
125
|
+
* as `rendered`. When the browser disagrees, this sets the cookie and calls `onChange`, by default a reload, so a
|
|
126
|
+
* new device switches layout once and never again. Renders nothing; works outside `<Duet>`. */
|
|
127
|
+
export function DuoCookie({ rendered, onChange = () => location.reload() }) {
|
|
128
|
+
const { duo } = useDuet();
|
|
129
|
+
// Before hydration useDuet() answers for the server (a Duo); only the browser's own answer counts.
|
|
130
|
+
const hydrated = useSyncExternalStore(noop, () => true, () => false);
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (!hydrated || duo === rendered)
|
|
133
|
+
return;
|
|
134
|
+
document.cookie = duo ? "duet-duo=1; path=/; max-age=31536000; samesite=lax" : "duet-duo=; path=/; max-age=0";
|
|
135
|
+
onChange(duo);
|
|
136
|
+
}, [hydrated, duo, rendered]); // eslint-disable-line react-hooks/exhaustive-deps -- onChange may be a fresh arrow each render
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const noop = () => () => { };
|
|
123
140
|
function useMinimized(on, scroller) {
|
|
124
141
|
const [min, setMin] = useState(false);
|
|
125
142
|
useEffect(() => {
|
|
@@ -153,13 +170,19 @@ function useMinimized(on, scroller) {
|
|
|
153
170
|
let tapSide = "right";
|
|
154
171
|
/** Without shrink-to-fit=no Safari zooms a split pane out to fit the layer's overflow, which reads as another
|
|
155
172
|
* window size and flips the mode. It applies even when set late, but must land before the first column paint. */
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
if (meta
|
|
159
|
-
meta
|
|
173
|
+
function viewport(key, value) {
|
|
174
|
+
let meta = document.querySelector("meta[name=viewport]");
|
|
175
|
+
if (!meta) {
|
|
176
|
+
meta = document.createElement("meta");
|
|
177
|
+
meta.name = "viewport";
|
|
178
|
+
meta.content = "width=device-width,initial-scale=1";
|
|
179
|
+
document.head.appendChild(meta);
|
|
180
|
+
}
|
|
181
|
+
if (!meta.content.includes(key))
|
|
182
|
+
meta.content += `,${key}=${value}`;
|
|
160
183
|
}
|
|
161
184
|
if (typeof document !== "undefined")
|
|
162
|
-
|
|
185
|
+
viewport("shrink-to-fit", "no");
|
|
163
186
|
function useColumnLayer(on, split, column) {
|
|
164
187
|
const refs = [useRef(null), useRef(null)];
|
|
165
188
|
useEffect(() => {
|
|
@@ -277,7 +300,10 @@ function useSideDrag(side, nav, onStart) {
|
|
|
277
300
|
}, [side, nav]); // eslint-disable-line react-hooks/exhaustive-deps -- onStart only resets menu and scrub state
|
|
278
301
|
}
|
|
279
302
|
// --- rendering ----------------------------------------------------------------------------------
|
|
280
|
-
|
|
303
|
+
// useLayoutEffect warns during SSR on React 18.
|
|
304
|
+
const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
305
|
+
export function Duet(props) {
|
|
306
|
+
const p = props; // tab ids are only typed at the boundary
|
|
281
307
|
const ctx = useDuet();
|
|
282
308
|
const scroller = useRef(null);
|
|
283
309
|
const minimized = useMinimized(!!p.minimizeOnScroll, scroller);
|
|
@@ -348,7 +374,24 @@ export function Duet(p) {
|
|
|
348
374
|
return () => removeEventListener("pointerdown", close);
|
|
349
375
|
}, [menu, nav, nav2]); // eslint-disable-line react-hooks/exhaustive-deps -- setMenu only reads menu
|
|
350
376
|
useSideDrag(ctx.mode === "inline" ? ctx.side : null, nav, () => { setMenu(null); setScrub(null); });
|
|
377
|
+
// The safe areas only reach the page with viewport-fit=cover. Set on mount, not on import, so a page that only
|
|
378
|
+
// imports DuoCookie keeps its own viewport on other phones.
|
|
379
|
+
useLayout(() => viewport("viewport-fit", "cover"), []);
|
|
351
380
|
const selected = p.tabs?.selected;
|
|
381
|
+
// Each tab keeps its own scroll position in the inner scroller; the selected tab again scrolls to the top.
|
|
382
|
+
const scrolls = useRef(new Map());
|
|
383
|
+
const select = (id) => {
|
|
384
|
+
const el = scroller.current;
|
|
385
|
+
if (id === selected)
|
|
386
|
+
el?.scrollTo({ top: 0, behavior: "smooth" });
|
|
387
|
+
else {
|
|
388
|
+
if (el && selected !== undefined)
|
|
389
|
+
scrolls.current.set(selected, el.scrollTop);
|
|
390
|
+
p.tabs.onSelect(id);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
useLayout(() => { if (scroller.current && selected !== undefined)
|
|
394
|
+
scroller.current.scrollTop = scrolls.current.get(selected) ?? 0; }, [selected]);
|
|
352
395
|
const badge = (a) => a.badge !== undefined && a.badge !== "" && _jsx("span", { className: "duet-badge", children: a.badge });
|
|
353
396
|
const tint = (a) => a.tint ? { "--duet-tint": a.tint } : undefined;
|
|
354
397
|
const item = (a) => _jsxs("button", { className: "duet-item", "aria-label": a.label, onClick: a.onPress, children: [a.icon, badge(a)] }, a.id);
|
|
@@ -372,7 +415,7 @@ export function Duet(p) {
|
|
|
372
415
|
const glideTo = (id) => {
|
|
373
416
|
if (id !== selected)
|
|
374
417
|
settle();
|
|
375
|
-
|
|
418
|
+
select(id);
|
|
376
419
|
};
|
|
377
420
|
// Scrub: holding or dragging on the full pill swells it and shows labels; a lens follows the finger over the
|
|
378
421
|
// tabs and the tab under it lights up; lifting selects it. A quick tap just glides the lens to the tab.
|
|
@@ -416,7 +459,7 @@ export function Duet(p) {
|
|
|
416
459
|
setScrub(null);
|
|
417
460
|
settle();
|
|
418
461
|
if (t)
|
|
419
|
-
|
|
462
|
+
select(t.id);
|
|
420
463
|
}
|
|
421
464
|
else if (tapped) {
|
|
422
465
|
const t = tabItems[tabAt(e.currentTarget, e.clientY)];
|
|
@@ -448,7 +491,7 @@ export function Duet(p) {
|
|
|
448
491
|
const close = kind === "more" && !morph, rolling = menu !== kind;
|
|
449
492
|
return (_jsxs(GlassSurface, { preset: "control", className: "duet-glass duet-menu", role: "menu", "data-style": morph ? "morph" : undefined, style: morph ? morphAt(anchor, items.length) : menuAt(anchor, items.length + (close ? 1 : 0)), "data-closing": rolling || undefined, onAnimationEnd: (e) => { if (rolling && (e.animationName === "duet-roll" || e.animationName === "duet-unmorph"))
|
|
450
493
|
setClosing(null); }, children: [items.map((a, i) => (_jsxs("button", { role: radio ? "menuitemradio" : "menuitem", "aria-checked": radio ? a.id === selected : undefined, "aria-label": a.label, className: "duet-menu-item", "data-on": (radio && a.id === selected) || undefined, "data-destructive": a.destructive || undefined, "data-sep": (a.destructive && i > 0 && !items[i - 1].destructive) || undefined, "data-tint": a.tint ? "" : undefined, style: { ...tint(a), ...(rolling ? undefined : { animationDelay: `${morph ? 0.12 + 0.04 * i : 0.14 + 0.06 * (items.length - i)}s` }) }, onClick: () => { setMenu(null); if (radio)
|
|
451
|
-
|
|
494
|
+
select(a.id);
|
|
452
495
|
else
|
|
453
496
|
a.onPress?.(); }, children: [a.icon, morph && _jsx("span", { className: "duet-menu-label", children: a.label }), badge(a)] }, a.id))), close && _jsx("button", { className: "duet-menu-item", "aria-label": "Close", onClick: () => setMenu(null), children: _jsx(X, { size: 20 }) })] }));
|
|
454
497
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Duet, useDuet, type DuetProps, type DuetItem } from "./chrome.tsx";
|
|
1
|
+
export { Duet, DuoCookie, useDuet, type DuetProps, type DuetItem, type DuetTab } from "./chrome.tsx";
|
|
2
2
|
export { bleed, column } from "./layout.ts";
|
|
3
3
|
export { DuetPanes, DuetPaneBar, usePanes, type DuetPanesProps, type DuetPaneBarProps } from "./duet-panes.tsx";
|
|
4
4
|
export { panes, backFrom, type PaneWindow } from "./panes.ts";
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "duet-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Native-feeling controls for React web apps on the foldable iPhone: the side strip, Safari's column, split view and Home Screen apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"sideEffects": [
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"*.css"
|
|
9
|
+
],
|
|
8
10
|
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./geometry": {
|
|
16
|
+
"types": "./dist/geometry.d.ts",
|
|
17
|
+
"default": "./dist/geometry.js"
|
|
18
|
+
},
|
|
11
19
|
"./styles.css": "./dist/duet.css"
|
|
12
20
|
},
|
|
13
|
-
"files": [
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
14
26
|
"scripts": {
|
|
15
27
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/copy-css.mjs",
|
|
16
28
|
"prepack": "pnpm build && node scripts/pack-docs.mjs",
|
|
17
|
-
"test": "node --test test
|
|
29
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
18
30
|
"typecheck": "tsc --noEmit"
|
|
19
31
|
},
|
|
20
32
|
"peerDependencies": {
|
|
@@ -28,8 +40,23 @@
|
|
|
28
40
|
"react-dom": "19.3.0",
|
|
29
41
|
"typescript": "^5.9.0"
|
|
30
42
|
},
|
|
31
|
-
"keywords": [
|
|
32
|
-
|
|
43
|
+
"keywords": [
|
|
44
|
+
"react",
|
|
45
|
+
"nextjs",
|
|
46
|
+
"iphone",
|
|
47
|
+
"foldable",
|
|
48
|
+
"ios",
|
|
49
|
+
"safari",
|
|
50
|
+
"pwa",
|
|
51
|
+
"liquid-glass",
|
|
52
|
+
"tab-bar",
|
|
53
|
+
"navigation"
|
|
54
|
+
],
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/JayFarei/duet.git",
|
|
58
|
+
"directory": "packages/duet-ui"
|
|
59
|
+
},
|
|
33
60
|
"homepage": "https://github.com/JayFarei/duet#readme",
|
|
34
61
|
"bugs": "https://github.com/JayFarei/duet/issues",
|
|
35
62
|
"author": "JayFarei"
|