@hanzo/build 0.2.56 → 0.2.57
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/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/panel.d.ts +3 -1
- package/lib/panel.js +11 -10
- package/lib/summary.d.ts +50 -0
- package/lib/summary.js +60 -0
- package/lib/tabs.d.ts +12 -1
- package/lib/tabs.js +37 -4
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -45,4 +45,5 @@ export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useCh
|
|
|
45
45
|
export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar, type PromptOptions } from './prompt.tsx';
|
|
46
46
|
export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker, type Artifact, type Listen } from './reply.tsx';
|
|
47
47
|
export { frames, Page, Panel, PanelToggle, type Kind, type PageProps, type PanelProps, type Suggestion } from './panel.tsx';
|
|
48
|
+
export { kinds, Summary, useKinds, type Entry, type Section } from './summary.tsx';
|
|
48
49
|
export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown, type Deck, type Tab, type Tabs } from './tabs.ts';
|
package/lib/index.js
CHANGED
|
@@ -40,4 +40,5 @@ export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useCh
|
|
|
40
40
|
export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar } from './prompt.js';
|
|
41
41
|
export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker } from './reply.js';
|
|
42
42
|
export { frames, Page, Panel, PanelToggle } from './panel.js';
|
|
43
|
+
export { kinds, Summary, useKinds } from './summary.js';
|
|
43
44
|
export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown } from './tabs.js';
|
package/lib/panel.d.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface PageProps extends Acts {
|
|
|
63
63
|
onForward?: () => void;
|
|
64
64
|
/** The title the page gave itself. */
|
|
65
65
|
onTitle?: (title: string) => void;
|
|
66
|
+
/** Pin the tab, or let it go. */
|
|
67
|
+
onPin?: (on: boolean) => void;
|
|
66
68
|
suggested?: readonly Suggestion[];
|
|
67
69
|
}
|
|
68
70
|
/**
|
|
@@ -73,7 +75,7 @@ export interface PageProps extends Acts {
|
|
|
73
75
|
* an answer wrote runs without `allow-same-origin`, never with our storage. A
|
|
74
76
|
* remote page takes its own origin, and withholding same-origin breaks it.
|
|
75
77
|
*/
|
|
76
|
-
export declare function Page({ tab, onGo, onBack, onForward, onTitle, suggested, ...acts }: PageProps): import("react").JSX.Element;
|
|
78
|
+
export declare function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested, ...acts }: PageProps): import("react").JSX.Element;
|
|
77
79
|
export interface PanelProps extends Acts {
|
|
78
80
|
side: Deck;
|
|
79
81
|
kinds: readonly Kind[];
|
package/lib/panel.js
CHANGED
|
@@ -21,7 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
* the panel is a sheet over the page instead (`sheet`).
|
|
22
22
|
*/
|
|
23
23
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
24
|
-
import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
|
|
24
|
+
import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Pin, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
|
|
25
25
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
|
|
26
26
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
27
27
|
import { createPortal } from 'react-dom';
|
|
@@ -163,7 +163,7 @@ function Skills({ skills, here }) {
|
|
|
163
163
|
* page itself for a frame it may reach — and one that needs a page is dim on
|
|
164
164
|
* an empty tab.
|
|
165
165
|
*/
|
|
166
|
-
function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, }) {
|
|
166
|
+
function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, onPin, }) {
|
|
167
167
|
const shown = here ? { id: tab.id, ...here } : null;
|
|
168
168
|
const none = !shown;
|
|
169
169
|
const pick = () => {
|
|
@@ -197,6 +197,7 @@ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser
|
|
|
197
197
|
acts.skills?.length ? _jsx(Skills, { skills: acts.skills, here: here }, "skills") : null,
|
|
198
198
|
_jsx(Row, { label: "Open in a new window", disabled: none, onSelect: () => popOut(tab) }, "out"),
|
|
199
199
|
_jsx(Row, { label: "Copy link", disabled: none || !tab.url, onSelect: () => tab.url && void navigator.clipboard?.writeText(tab.url) }, "copy"),
|
|
200
|
+
onPin ? _jsx(Row, { label: tab.pinned ? 'Unpin tab' : 'Pin tab', onSelect: () => onPin(!tab.pinned) }, "pin") : null,
|
|
200
201
|
].filter(Boolean);
|
|
201
202
|
const page = [
|
|
202
203
|
browser?.find ? _jsx(Row, { label: "Find in page", disabled: none, onSelect: () => shown && browser.find?.(shown) }, "find") : null,
|
|
@@ -223,7 +224,7 @@ function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser
|
|
|
223
224
|
* an answer wrote runs without `allow-same-origin`, never with our storage. A
|
|
224
225
|
* remote page takes its own origin, and withholding same-origin breaks it.
|
|
225
226
|
*/
|
|
226
|
-
export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ...acts }) {
|
|
227
|
+
export function Page({ tab, onGo, onBack, onForward, onTitle, onPin, suggested = [], ...acts }) {
|
|
227
228
|
const Browse = useBrowse();
|
|
228
229
|
const browser = useBrowser();
|
|
229
230
|
const frame = useRef(null);
|
|
@@ -288,10 +289,10 @@ export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ..
|
|
|
288
289
|
fontSize: 14,
|
|
289
290
|
// The resting bar reads centred, as a browser's new tab does; typed in, it reads from the left.
|
|
290
291
|
textAlign: typing || typed ? 'left' : 'center',
|
|
291
|
-
} }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
|
|
292
|
+
} }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser, onPin: onPin })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
|
|
292
293
|
}
|
|
293
294
|
const DRAG = 'text/x-hanzo-tab';
|
|
294
|
-
/** One tab in the strip: its mark, its title, and its cross. */
|
|
295
|
+
/** One tab in the strip: its mark, its title, and its cross, or its pin when it is pinned. */
|
|
295
296
|
function Leaf({ tab, on, side, index, label, icon }) {
|
|
296
297
|
return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$1", pl: "$2.5", pr: "$1", height: 30,
|
|
297
298
|
// As wide as a title wants up to a browser tab's width, sharing the strip
|
|
@@ -299,7 +300,7 @@ function Leaf({ tab, on, side, index, label, icon }) {
|
|
|
299
300
|
flexBasis: 236, shrink: 1, minW: 56, maxW: 236, rounded: 8, borderWidth: 1, borderColor: on ? '$borderColor' : 'transparent', bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover' }, ...{
|
|
300
301
|
// A middle press shuts any tab, as a browser's does; the chosen one also has its cross.
|
|
301
302
|
onAuxClick: (e) => {
|
|
302
|
-
if (e.button !== 1)
|
|
303
|
+
if (e.button !== 1 || tab.pinned)
|
|
303
304
|
return;
|
|
304
305
|
e.preventDefault();
|
|
305
306
|
side.shut(tab.id);
|
|
@@ -337,13 +338,13 @@ function Leaf({ tab, on, side, index, label, icon }) {
|
|
|
337
338
|
queueMicrotask(() => document.getElementById(`side-tab-${next.id}`)?.focus());
|
|
338
339
|
return;
|
|
339
340
|
}
|
|
340
|
-
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
341
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && !tab.pinned) {
|
|
341
342
|
e.preventDefault?.();
|
|
342
343
|
side.shut(tab.id);
|
|
343
344
|
}
|
|
344
|
-
}, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), _jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
|
|
345
|
+
}, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), tab.pinned ? (_jsx(XStack, { render: "button", role: "button", "aria-label": `Unpin ${label}`, tabIndex: -1, onPress: () => side.unpin(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.55, hoverStyle: { bg: '$hover', opacity: 1 }, children: _jsx(Pin, { size: 12 }) })) : (_jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
|
|
345
346
|
// The chosen tab's cross: the others keep their room for their titles when the strip is shared.
|
|
346
|
-
display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) })] }));
|
|
347
|
+
display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) }))] }));
|
|
347
348
|
}
|
|
348
349
|
/** The panel: the strip, and the chosen tab. */
|
|
349
350
|
export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }) {
|
|
@@ -366,7 +367,7 @@ export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = '
|
|
|
366
367
|
{ key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
|
|
367
368
|
] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(Maximize2, { size: 14 }) }), _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
|
|
368
369
|
// What a tab lays over itself (a door's "Connecting…") stays inside it, under the strip.
|
|
369
|
-
position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
|
|
370
|
+
position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), onPin: (on) => (on ? side.pin(tab.id) : side.unpin(tab.id)), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
|
|
370
371
|
if (!sheet || typeof document === 'undefined')
|
|
371
372
|
return body;
|
|
372
373
|
// A phone: a sheet from the foot of the window over a dim, the same panel in it.
|
package/lib/summary.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A surface's summary: what it made and what went into it, as sections of
|
|
3
|
+
* rows, each one press from a tab beside it. Chat draws its Outputs and Sources
|
|
4
|
+
* here; Studio its artifacts. The rows are the caller's: this draws them and
|
|
5
|
+
* knows nothing of a conversation.
|
|
6
|
+
*
|
|
7
|
+
* `Summary` is the popover off the header's outline mark: each section's
|
|
8
|
+
* heading with what it adds at its end, its leading action (Chat's Create file
|
|
9
|
+
* or site), its rows, and one quiet line when it has none. `useKinds` makes
|
|
10
|
+
* each section a kind of tab for `Panel`, the same rows in the panel.
|
|
11
|
+
*/
|
|
12
|
+
import { type ReactNode } from 'react';
|
|
13
|
+
import type { Kind } from './panel.tsx';
|
|
14
|
+
/** One row: what it is called, its mark, and what pressing it opens. */
|
|
15
|
+
export interface Entry {
|
|
16
|
+
id: string;
|
|
17
|
+
title: string;
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
/** A quieter second line: what a lookup asked, what came back. */
|
|
20
|
+
detail?: string;
|
|
21
|
+
open?: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** A section: its heading, its rows, and what it says with none. */
|
|
24
|
+
export interface Section {
|
|
25
|
+
id: string;
|
|
26
|
+
title: string;
|
|
27
|
+
entries: readonly Entry[];
|
|
28
|
+
/** The line drawn when it has no rows; none, and it draws only its heading. */
|
|
29
|
+
empty?: string;
|
|
30
|
+
/** At the heading's end: what adds to it (Chat's `+` on Sources). */
|
|
31
|
+
action?: ReactNode;
|
|
32
|
+
/** A row before the others that does something rather than opens something. */
|
|
33
|
+
lead?: {
|
|
34
|
+
title: string;
|
|
35
|
+
run: () => void;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The summary, from the outline mark in a header. `onOpen` runs after a row is
|
|
40
|
+
* pressed, for the host to show the panel the row opened a tab in.
|
|
41
|
+
*/
|
|
42
|
+
export declare function Summary({ sections, onOpen, label }: {
|
|
43
|
+
sections: readonly Section[];
|
|
44
|
+
onOpen?: () => void;
|
|
45
|
+
label?: string;
|
|
46
|
+
}): import("react").JSX.Element;
|
|
47
|
+
/** Each section as a kind of tab for `Panel`: the same rows, with their second lines. */
|
|
48
|
+
export declare function kinds(sections: readonly Section[], icons?: Record<string, ReactNode>): Kind[];
|
|
49
|
+
/** `kinds`, kept while the sections are the same. */
|
|
50
|
+
export declare function useKinds(sections: readonly Section[], icons?: Record<string, ReactNode>): Kind[];
|
package/lib/summary.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A surface's summary: what it made and what went into it, as sections of
|
|
4
|
+
* rows, each one press from a tab beside it. Chat draws its Outputs and Sources
|
|
5
|
+
* here; Studio its artifacts. The rows are the caller's: this draws them and
|
|
6
|
+
* knows nothing of a conversation.
|
|
7
|
+
*
|
|
8
|
+
* `Summary` is the popover off the header's outline mark: each section's
|
|
9
|
+
* heading with what it adds at its end, its leading action (Chat's Create file
|
|
10
|
+
* or site), its rows, and one quiet line when it has none. `useKinds` makes
|
|
11
|
+
* each section a kind of tab for `Panel`, the same rows in the panel.
|
|
12
|
+
*/
|
|
13
|
+
import { useMemo, useState } from 'react';
|
|
14
|
+
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
15
|
+
import { ListTodo } from '@hanzogui/lucide-icons-2';
|
|
16
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@hanzo/ui';
|
|
17
|
+
/** A row of the summary: its mark and its name, one press from a tab. */
|
|
18
|
+
function Line({ icon, children, detail, onPress, quiet = false }) {
|
|
19
|
+
return (_jsxs(XStack, { render: onPress ? 'button' : undefined, role: onPress ? 'button' : undefined, onPress: onPress, items: "center", gap: "$2.5", px: "$2", minH: 30, width: "100%", rounded: "$3", cursor: onPress ? 'pointer' : undefined, hoverStyle: onPress ? { bg: '$hover' } : undefined, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [icon ? (_jsx(XStack, { width: 16, justify: "center", shrink: 0, opacity: 0.75, children: icon })) : null, _jsxs(YStack, { flex: 1, minW: 0, py: detail ? '$1' : 0, children: [_jsx(SizableText, { fontSize: 13, color: quiet ? '$soft' : '$ink', numberOfLines: 1, children: children }), detail ? (_jsx(SizableText, { fontSize: 12, color: "$soft", numberOfLines: 2, children: detail })) : null] })] }));
|
|
20
|
+
}
|
|
21
|
+
/** A section's heading, with what it adds at its end. */
|
|
22
|
+
const Head = ({ children, action }) => (_jsxs(XStack, { items: "center", justify: "space-between", px: "$2", pt: "$1.5", pb: "$1", minH: 28, children: [_jsx(SizableText, { fontSize: 13, color: "$soft", children: children }), action] }));
|
|
23
|
+
/** One section's rows; `done` runs after a row is pressed (the popover puts itself away). */
|
|
24
|
+
function Rows({ section, done, details = false }) {
|
|
25
|
+
const { entries, empty, lead } = section;
|
|
26
|
+
return (_jsxs(_Fragment, { children: [lead ? (_jsx(Line, { quiet: true, onPress: () => {
|
|
27
|
+
done?.();
|
|
28
|
+
lead.run();
|
|
29
|
+
}, children: lead.title })) : null, entries.map((e) => (_jsx(Line, { icon: e.icon, detail: details ? e.detail : undefined, onPress: e.open
|
|
30
|
+
? () => {
|
|
31
|
+
done?.();
|
|
32
|
+
e.open?.();
|
|
33
|
+
}
|
|
34
|
+
: undefined, children: e.title }, e.id))), !entries.length && empty ? _jsx(Line, { quiet: true, children: empty }) : null] }));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The summary, from the outline mark in a header. `onOpen` runs after a row is
|
|
38
|
+
* pressed, for the host to show the panel the row opened a tab in.
|
|
39
|
+
*/
|
|
40
|
+
export function Summary({ sections, onOpen, label = 'Summary' }) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const done = () => {
|
|
43
|
+
setOpen(false);
|
|
44
|
+
onOpen?.();
|
|
45
|
+
};
|
|
46
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { children: _jsx(XStack, { render: "button", "data-slot": "summary-toggle", "aria-label": label, "aria-haspopup": "dialog", "aria-expanded": open, ...{ title: label }, width: 32, height: 32, rounded: "$3", items: "center", justify: "center", cursor: "pointer", bg: open ? '$hover' : 'transparent', hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: _jsx(ListTodo, { size: 16, "aria-hidden": true }) }) }), _jsx(PopoverContent, { solid: true, align: "end", p: 6, width: 300, children: _jsx(YStack, { "data-slot": "summary", role: "dialog", "aria-label": label, gap: 1, self: "stretch", width: "100%", maxH: "min(70dvh, 520px)", overflowY: "auto", overflowX: "hidden", children: sections.map((s, i) => (_jsxs(YStack, { gap: 1, children: [i ? _jsx(XStack, { height: 1, bg: "$borderColor", my: "$1.5", mx: "$1" }) : null, _jsx(Head, { action: s.action, children: s.title }), _jsx(Rows, { section: s, done: done })] }, s.id))) }) })] }));
|
|
47
|
+
}
|
|
48
|
+
/** Each section as a kind of tab for `Panel`: the same rows, with their second lines. */
|
|
49
|
+
export function kinds(sections, icons = {}) {
|
|
50
|
+
return sections.map((s) => ({
|
|
51
|
+
id: s.id,
|
|
52
|
+
label: s.title,
|
|
53
|
+
icon: icons[s.id],
|
|
54
|
+
render: () => (_jsxs(YStack, { gap: 1, children: [_jsx(Head, { action: s.action, children: s.title }), _jsx(Rows, { section: s, details: true })] })),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
/** `kinds`, kept while the sections are the same. */
|
|
58
|
+
export function useKinds(sections, icons) {
|
|
59
|
+
return useMemo(() => kinds(sections, icons), [sections, icons]);
|
|
60
|
+
}
|
package/lib/tabs.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface Tab {
|
|
|
11
11
|
/** The addresses a page came from, oldest first, and those Back stepped over, nearest first. */
|
|
12
12
|
back?: string[];
|
|
13
13
|
ahead?: string[];
|
|
14
|
+
/** Leads the strip, and closes only on purpose. */
|
|
15
|
+
pinned?: boolean;
|
|
14
16
|
}
|
|
15
17
|
/** The tabs in a scope, in order, and the chosen one. */
|
|
16
18
|
export interface Tabs {
|
|
@@ -26,8 +28,12 @@ export declare function opened(was: Tabs, tab: Omit<Tab, 'id'> & {
|
|
|
26
28
|
}): Tabs;
|
|
27
29
|
/** A tab shut: the one to its right is chosen, else the one to its left, as a browser does. */
|
|
28
30
|
export declare function shut(was: Tabs, id: string): Tabs;
|
|
29
|
-
/** A tab moved to `to`, the others closing up around it. */
|
|
31
|
+
/** A tab moved to `to`, the others closing up around it, and kept in its group: pinned or not. */
|
|
30
32
|
export declare function moved(was: Tabs, id: string, to: number): Tabs;
|
|
33
|
+
/** A tab pinned, or let go: it joins the end of the pinned tabs, or leads the rest. */
|
|
34
|
+
export declare function pinned(was: Tabs, id: string, on: boolean): Tabs;
|
|
35
|
+
/** Every tab closed but the pinned ones and those `keep` names; the chosen one stays if it is left. */
|
|
36
|
+
export declare function cleared(was: Tabs, keep?: (t: Tab) => boolean): Tabs;
|
|
31
37
|
export interface Deck extends Tabs {
|
|
32
38
|
open: (tab: Omit<Tab, 'id'> & {
|
|
33
39
|
id?: string;
|
|
@@ -35,6 +41,11 @@ export interface Deck extends Tabs {
|
|
|
35
41
|
pick: (id: string) => void;
|
|
36
42
|
shut: (id: string) => void;
|
|
37
43
|
move: (id: string, to: number) => void;
|
|
44
|
+
/** Pin a tab: it leads the strip and closes only on purpose. */
|
|
45
|
+
pin: (id: string) => void;
|
|
46
|
+
unpin: (id: string) => void;
|
|
47
|
+
/** Close every tab but the pinned ones and those `keep` names. */
|
|
48
|
+
clear: (keep?: (t: Tab) => boolean) => void;
|
|
38
49
|
/** Point a page somewhere else: where it was goes on its Back, and Forward is forgotten. */
|
|
39
50
|
go: (id: string, url: string) => void;
|
|
40
51
|
/** A page's Back and Forward. */
|
package/lib/tabs.js
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* A page keeps the addresses it went through (`back`, `ahead`), so its bar's
|
|
16
16
|
* Back and Forward walk them as a browser's do.
|
|
17
17
|
*
|
|
18
|
+
* A PINNED tab leads the strip and closes only on purpose: its own close (`shut`)
|
|
19
|
+
* takes it, and nothing that closes tabs in bulk (`clear`) does. Pinned tabs
|
|
20
|
+
* keep their order among themselves, the others theirs; a tab moved stays in
|
|
21
|
+
* its group.
|
|
22
|
+
*
|
|
18
23
|
*/
|
|
19
24
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
20
25
|
export const PAGE = 'page';
|
|
@@ -53,7 +58,7 @@ export function read(scope) {
|
|
|
53
58
|
if (raw) {
|
|
54
59
|
const v = JSON.parse(raw);
|
|
55
60
|
// An object URL dies with the document that minted it: a page holding only one is not kept.
|
|
56
|
-
const tabs = (Array.isArray(v.tabs) ? v.tabs.filter(isTab) : []).filter((t) => t.kind !== PAGE || t.body !== undefined || !t.url?.startsWith('blob:'));
|
|
61
|
+
const tabs = ranked((Array.isArray(v.tabs) ? v.tabs.filter(isTab) : []).filter((t) => t.kind !== PAGE || t.body !== undefined || !t.url?.startsWith('blob:')));
|
|
57
62
|
held = { tabs, at: tabs.some((t) => t.id === v.at) ? v.at : (tabs[0]?.id ?? null) };
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -74,6 +79,10 @@ function write(scope, next) {
|
|
|
74
79
|
}
|
|
75
80
|
announce();
|
|
76
81
|
}
|
|
82
|
+
/** Pinned tabs first, each group in the order it had. */
|
|
83
|
+
function ranked(tabs) {
|
|
84
|
+
return [...tabs.filter((t) => t.pinned), ...tabs.filter((t) => !t.pinned)];
|
|
85
|
+
}
|
|
77
86
|
let minted = 0;
|
|
78
87
|
const mint = () => `p${Date.now().toString(36)}${(minted++).toString(36)}`;
|
|
79
88
|
/** A tab opened: a kind already open is chosen, a page already open on the same thing is chosen. */
|
|
@@ -82,7 +91,7 @@ export function opened(was, tab) {
|
|
|
82
91
|
if (same)
|
|
83
92
|
return { ...was, at: same.id };
|
|
84
93
|
const id = tab.id ?? (tab.kind === PAGE ? mint() : tab.kind);
|
|
85
|
-
return { tabs: [...was.tabs, { ...tab, id }], at: id };
|
|
94
|
+
return { tabs: ranked([...was.tabs, { ...tab, id }]), at: id };
|
|
86
95
|
}
|
|
87
96
|
/** A tab shut: the one to its right is chosen, else the one to its left, as a browser does. */
|
|
88
97
|
export function shut(was, id) {
|
|
@@ -94,16 +103,37 @@ export function shut(was, id) {
|
|
|
94
103
|
return { ...was, tabs };
|
|
95
104
|
return { tabs, at: (tabs[gone] ?? tabs[gone - 1])?.id ?? null };
|
|
96
105
|
}
|
|
97
|
-
/** A tab moved to `to`, the others closing up around it. */
|
|
106
|
+
/** A tab moved to `to`, the others closing up around it, and kept in its group: pinned or not. */
|
|
98
107
|
export function moved(was, id, to) {
|
|
99
108
|
const from = was.tabs.findIndex((t) => t.id === id);
|
|
100
109
|
if (from < 0)
|
|
101
110
|
return was;
|
|
102
111
|
const tabs = was.tabs.slice();
|
|
103
112
|
const [tab] = tabs.splice(from, 1);
|
|
104
|
-
tabs.
|
|
113
|
+
const pins = tabs.filter((t) => t.pinned).length;
|
|
114
|
+
const [low, high] = tab.pinned ? [0, pins] : [pins, tabs.length];
|
|
115
|
+
tabs.splice(Math.max(low, Math.min(high, to)), 0, tab);
|
|
105
116
|
return { ...was, tabs };
|
|
106
117
|
}
|
|
118
|
+
/** A tab pinned, or let go: it joins the end of the pinned tabs, or leads the rest. */
|
|
119
|
+
export function pinned(was, id, on) {
|
|
120
|
+
const tab = was.tabs.find((t) => t.id === id);
|
|
121
|
+
if (!tab || Boolean(tab.pinned) === on)
|
|
122
|
+
return was;
|
|
123
|
+
const rest = was.tabs.filter((t) => t.id !== id);
|
|
124
|
+
const pins = rest.filter((t) => t.pinned);
|
|
125
|
+
const others = rest.filter((t) => !t.pinned);
|
|
126
|
+
const plain = { ...tab };
|
|
127
|
+
delete plain.pinned;
|
|
128
|
+
return { ...was, tabs: on ? [...pins, { ...tab, pinned: true }, ...others] : [...pins, plain, ...others] };
|
|
129
|
+
}
|
|
130
|
+
/** Every tab closed but the pinned ones and those `keep` names; the chosen one stays if it is left. */
|
|
131
|
+
export function cleared(was, keep = () => false) {
|
|
132
|
+
const tabs = was.tabs.filter((t) => t.pinned || keep(t));
|
|
133
|
+
if (tabs.length === was.tabs.length)
|
|
134
|
+
return was;
|
|
135
|
+
return { tabs, at: tabs.some((t) => t.id === was.at) ? was.at : (tabs[0]?.id ?? null) };
|
|
136
|
+
}
|
|
107
137
|
/** How many addresses a page's Back keeps. */
|
|
108
138
|
const DEPTH = 50;
|
|
109
139
|
/** A page pointed at `url`, where it was on its Back. */
|
|
@@ -136,6 +166,9 @@ export function useDeck(scope, seed) {
|
|
|
136
166
|
pick: (id) => change((was) => ({ ...was, at: id })),
|
|
137
167
|
shut: (id) => change((was) => shut(was, id)),
|
|
138
168
|
move: (id, to) => change((was) => moved(was, id, to)),
|
|
169
|
+
pin: (id) => change((was) => pinned(was, id, true)),
|
|
170
|
+
unpin: (id) => change((was) => pinned(was, id, false)),
|
|
171
|
+
clear: (keep) => change((was) => cleared(was, keep)),
|
|
139
172
|
go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? went(t, url) : t)) })),
|
|
140
173
|
back: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'back') : t)) })),
|
|
141
174
|
forward: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'forward') : t)) })),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/build",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.57",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
|