@mythicalos/react-ui 0.1.2 → 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/dist/FileExplorer.d.ts +62 -0
- package/dist/FileExplorer.js +56 -0
- package/dist/GitChip.d.ts +17 -0
- package/dist/GitChip.js +23 -0
- package/dist/Popover.d.ts +48 -0
- package/dist/Popover.js +165 -0
- package/dist/QueuePanel.d.ts +37 -0
- package/dist/QueuePanel.js +77 -0
- package/dist/SaveBar.d.ts +18 -0
- package/dist/SaveBar.js +11 -0
- package/dist/SendBar.d.ts +24 -0
- package/dist/SendBar.js +62 -0
- package/dist/SessionCard.d.ts +41 -0
- package/dist/SessionCard.js +33 -0
- package/dist/StatTiles.d.ts +9 -0
- package/dist/StatTiles.js +18 -0
- package/dist/Terminal.d.ts +30 -0
- package/dist/Terminal.js +90 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, previewBodyMode, previewMeta, type DirState, type FilePreviewState, type FileScopeOption, type FileTreeMode, type FileTreeRootSpec, type FileTreeRow, type GitMark, type PreviewBadgeState } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export { breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, previewBodyMode, previewMeta, type DirState, type FilePreviewState, type FileScopeOption, type FileTreeMode, type FileTreeRootSpec, type FileTreeRow, type GitMark, type PreviewBadgeState, };
|
|
4
|
+
export interface FileScopePickerProps {
|
|
5
|
+
options: readonly FileScopeOption[];
|
|
6
|
+
activeKey: string;
|
|
7
|
+
mode: FileTreeMode;
|
|
8
|
+
open: boolean;
|
|
9
|
+
onToggle?: (next: boolean) => void;
|
|
10
|
+
onSelect?: (key: string) => void;
|
|
11
|
+
onDismiss?: () => void;
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
/** A plain disclosure of buttons — deliberately NOT an ARIA menu/listbox, whose roles would promise
|
|
15
|
+
* arrow-key behavior this does not implement. */
|
|
16
|
+
export declare function FileScopePicker(props: FileScopePickerProps): import("react").JSX.Element;
|
|
17
|
+
export interface FileTreeProps {
|
|
18
|
+
mode: FileTreeMode;
|
|
19
|
+
roots: readonly FileTreeRootSpec[];
|
|
20
|
+
/** Directory listings keyed by `nodeId(rootKey, relPath)`. An absent key reads as "not fetched
|
|
21
|
+
* yet" and draws a Loading… row, never an empty one. */
|
|
22
|
+
dirs: Readonly<Record<string, DirState>>;
|
|
23
|
+
expanded: ReadonlySet<string>;
|
|
24
|
+
selectedId?: string | null;
|
|
25
|
+
/** Git marks keyed by node id. An absent entry renders NO mark — never a fabricated clean. */
|
|
26
|
+
marks?: Readonly<Record<string, GitMark>>;
|
|
27
|
+
title?: string;
|
|
28
|
+
count?: number;
|
|
29
|
+
branch?: string;
|
|
30
|
+
scope?: ReactNode;
|
|
31
|
+
onToggleDir?: (node: {
|
|
32
|
+
rootKey: string;
|
|
33
|
+
relPath: string;
|
|
34
|
+
id: string;
|
|
35
|
+
}) => void;
|
|
36
|
+
onSelectFile?: (node: {
|
|
37
|
+
rootKey: string;
|
|
38
|
+
relPath: string;
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
}) => void;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function FileTree(props: FileTreeProps): import("react").JSX.Element;
|
|
45
|
+
export interface FilePreviewProps {
|
|
46
|
+
/** Render this only when a file IS selected; the no-selection placeholder belongs to the page. */
|
|
47
|
+
name: string;
|
|
48
|
+
/** The breadcrumb head — the breadcrumb ALWAYS includes the root. */
|
|
49
|
+
rootLabel: string;
|
|
50
|
+
relPath: string;
|
|
51
|
+
state: FilePreviewState;
|
|
52
|
+
/** `null` (the default) renders NO pill — never a fabricated "unchanged". */
|
|
53
|
+
badge?: PreviewBadgeState;
|
|
54
|
+
bytes?: number;
|
|
55
|
+
mtime?: number;
|
|
56
|
+
now?: number;
|
|
57
|
+
/** Absent ⇒ a `.md` file falls back to plain mono text rather than this component inventing a
|
|
58
|
+
* renderer (or injecting raw HTML). */
|
|
59
|
+
renderMarkdown?: (text: string) => ReactNode;
|
|
60
|
+
className?: string;
|
|
61
|
+
}
|
|
62
|
+
export declare function FilePreview(props: FilePreviewProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { badgeClass, breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, gitMarkClass, gitMarkLabel, previewBadge, previewBadgeClass, previewBodyMode, previewMeta, previewNoteClass, previewNoteText, scopeGlyph, SCOPE_CARET, scopeItemClass, } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export { breadcrumbSegments, buildBreadcrumb, countFileRows, countLoadedFiles, deriveFileTreeRows, formatFileSize, formatRelativeTime, previewBodyMode, previewMeta, };
|
|
4
|
+
/** A plain disclosure of buttons — deliberately NOT an ARIA menu/listbox, whose roles would promise
|
|
5
|
+
* arrow-key behavior this does not implement. */
|
|
6
|
+
export function FileScopePicker(props) {
|
|
7
|
+
const { options, activeKey, mode, open, onToggle, onSelect, onDismiss, label } = props;
|
|
8
|
+
const active = options.find((o) => o.key === activeKey);
|
|
9
|
+
const text = label ?? active?.label ?? "";
|
|
10
|
+
const glyph = scopeGlyph(mode);
|
|
11
|
+
const selectable = options.length > 1;
|
|
12
|
+
return (_jsxs("div", { className: "my-files__scope", onKeyDown: (e) => {
|
|
13
|
+
if (e.key === "Escape" && open)
|
|
14
|
+
onDismiss?.();
|
|
15
|
+
}, children: [selectable ? (_jsxs("button", { type: "button", className: "my-files__scope-btn", "aria-expanded": open, onClick: () => onToggle?.(!open), children: [_jsx("span", { className: "my-files__scope-glyph", "aria-hidden": "true", children: glyph }), _jsx("span", { className: "my-files__scope-label", children: text }), _jsx("span", { className: "my-files__scope-caret", "aria-hidden": "true", children: SCOPE_CARET })] })) : (_jsxs("div", { className: "my-files__scope-static", children: [_jsx("span", { className: "my-files__scope-glyph", "aria-hidden": "true", children: glyph }), _jsx("span", { className: "my-files__scope-label", children: text })] })), open && selectable ? (_jsx("div", { className: "my-files__scope-menu", children: options.map((o) => (_jsxs("div", { children: [_jsxs("button", { type: "button", className: scopeItemClass(o.key === activeKey), "aria-current": o.key === activeKey, onClick: () => onSelect?.(o.key), children: [_jsx("span", { className: "my-files__scope-it-name", children: o.label }), _jsx("span", { className: "my-files__scope-it-count", children: o.count })] }), o.dividerAfter === true ? _jsx("div", { className: "my-files__scope-divider", "aria-hidden": "true" }) : null] }, o.key))) })) : null] }));
|
|
16
|
+
}
|
|
17
|
+
export function FileTree(props) {
|
|
18
|
+
const { mode, roots, dirs, expanded, selectedId = null, marks, title = "Files", count, branch, scope, onToggleDir, onSelectFile } = props;
|
|
19
|
+
const rows = deriveFileTreeRows({ mode, roots, dirs, expanded, selectedId, marks });
|
|
20
|
+
// the rendered-row count — provably what the operator sees (countLoadedFiles is a
|
|
21
|
+
// different, cache-wide number; see its doc)
|
|
22
|
+
const total = count ?? countFileRows(rows);
|
|
23
|
+
return (_jsxs("div", { className: props.className === undefined ? "my-files__rail" : `my-files__rail ${props.className}`, children: [_jsxs("div", { className: "my-files__hd", children: [_jsxs("div", { className: "my-files__ti", children: [_jsx("span", { children: title }), _jsxs("span", { className: "my-files__count", children: ["\u00B7 ", total] }), branch === undefined ? null : (_jsxs("span", { className: "my-files__branch", children: [_jsx("span", { className: "my-files__branch-dot", "aria-hidden": "true" }), branch] }))] }), scope] }), _jsx("div", { className: "my-files__tree", children: rows.map((row) => renderRow(row, onToggleDir, onSelectFile)) })] }));
|
|
24
|
+
}
|
|
25
|
+
function renderRow(row, onToggleDir, onSelectFile) {
|
|
26
|
+
if (row.type === "note") {
|
|
27
|
+
return (_jsx("div", { className: row.className, children: row.text }, row.id));
|
|
28
|
+
}
|
|
29
|
+
if (row.type === "dir") {
|
|
30
|
+
// The WHOLE ROW is the hit target — the chevron is the affordance, not a separate control.
|
|
31
|
+
return (_jsxs("button", { type: "button", className: row.className, "aria-expanded": row.open, onClick: () => onToggleDir?.({ rootKey: row.rootKey, relPath: row.relPath, id: row.id }), children: [_jsx("span", { className: "my-files__chev", "aria-hidden": "true", children: row.chevron }), _jsx("span", { className: "my-files__glyph", "aria-hidden": "true", children: row.glyph }), _jsx("span", { className: "my-files__name", children: row.name }), row.badges.map((b) => (_jsx("span", { className: badgeClass(b.tone), children: b.text }, b.text)))] }, row.id));
|
|
32
|
+
}
|
|
33
|
+
return (_jsxs("button", { type: "button", className: row.className, "aria-current": row.selected, onClick: () => onSelectFile?.({ rootKey: row.rootKey, relPath: row.relPath, id: row.id, name: row.name }), children: [_jsx("span", { className: "my-files__spacer", "aria-hidden": "true" }), _jsx("span", { className: "my-files__fglyph", "aria-hidden": "true", children: row.glyph }), _jsx("span", { className: "my-files__fname", children: row.name }), row.mark === null ? null : (
|
|
34
|
+
// role="img" so the aria-label IS the accessible name — a bare "M" would otherwise be
|
|
35
|
+
// announced as the opaque glyph rather than "modified".
|
|
36
|
+
_jsx("span", { className: gitMarkClass(row.mark), role: "img", title: gitMarkLabel(row.mark), "aria-label": gitMarkLabel(row.mark), children: row.mark }))] }, row.id));
|
|
37
|
+
}
|
|
38
|
+
export function FilePreview(props) {
|
|
39
|
+
const { name, rootLabel, relPath, state, badge = null, now, renderMarkdown } = props;
|
|
40
|
+
const meta = previewMeta(state, { bytes: props.bytes, mtime: props.mtime });
|
|
41
|
+
const pill = previewBadge(badge);
|
|
42
|
+
return (_jsxs("div", { className: props.className === undefined ? "my-files__prev" : `my-files__prev ${props.className}`, children: [_jsxs("div", { className: "my-files__prev-hd", children: [_jsxs("div", { className: "my-files__prev-main", children: [_jsx("div", { className: "my-files__crumb", children: buildBreadcrumb(rootLabel, relPath) }), _jsxs("div", { className: "my-files__name-row", children: [_jsx("b", { className: "my-files__prev-name", children: name }), pill === null ? null : _jsx("span", { className: previewBadgeClass(pill.tone), children: pill.text })] })] }), _jsxs("div", { className: "my-files__meta", children: [meta.bytes === undefined ? null : _jsx("span", { children: formatFileSize(meta.bytes) }), meta.mtime === undefined ? null : _jsxs("span", { children: ["modified ", formatRelativeTime(meta.mtime, now)] })] })] }), _jsx("div", { className: "my-files__prev-bd", children: previewBody(name, state, renderMarkdown) })] }));
|
|
43
|
+
}
|
|
44
|
+
/** The body for the current state. Every non-`text` branch is a note — including all four honest
|
|
45
|
+
* states, each with its own distinct class and none styled as a failure. */
|
|
46
|
+
function previewBody(name, state, renderMarkdown) {
|
|
47
|
+
// ONE decision, made in ui-core — this binding never re-derives it.
|
|
48
|
+
const mode = previewBodyMode(name, state, renderMarkdown !== undefined);
|
|
49
|
+
if (mode === "note")
|
|
50
|
+
return _jsx("div", { className: previewNoteClass(state) ?? "", children: previewNoteText(state) });
|
|
51
|
+
// "note" covers every non-text state, so the text branch is what remains
|
|
52
|
+
const text = state.text;
|
|
53
|
+
if (mode === "markdown")
|
|
54
|
+
return _jsx("div", { className: "my-files__md", children: renderMarkdown(text) });
|
|
55
|
+
return _jsx("pre", { className: "my-files__pre", children: text });
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, type GitFlag, type GitFlagTone, type GitStatus } from "@mythicalos/ui-core/logic";
|
|
2
|
+
export { gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, type GitFlag, type GitFlagTone, type GitStatus, };
|
|
3
|
+
export interface GitChipProps {
|
|
4
|
+
/** The reported status. Absent — `undefined` OR `null`, so a "no data yet" slot can be passed
|
|
5
|
+
* straight through — ⇒ the honest unavailable arm, never a fabricated clean tree. */
|
|
6
|
+
status?: GitStatus | null;
|
|
7
|
+
/** Human copy for why there is no status. The product maps its own reason codes to this
|
|
8
|
+
* sentence; the atom carries no reason vocabulary of its own. */
|
|
9
|
+
unavailableNote?: string;
|
|
10
|
+
/** The first read has not returned yet — only used when there is no `status` and no note. */
|
|
11
|
+
loading?: boolean;
|
|
12
|
+
/** The shown status is RETAINED while the live read is failing: stale, not current. Flagged on
|
|
13
|
+
* both arms. */
|
|
14
|
+
stale?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function GitChip(props: GitChipProps): import("react").JSX.Element;
|
package/dist/GitChip.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the git status chip (ds/components-git-chip.html): a bordered inline
|
|
3
|
+
// mono chip carrying `⎇ <branch>` and honest counter flags (warn-soft behind/uncommitted,
|
|
4
|
+
// error-soft unpushed, ok-soft clean).
|
|
5
|
+
//
|
|
6
|
+
// React twin of packages/preact-ui/src/GitChip.tsx. The honesty contract lives in
|
|
7
|
+
// `@mythicalos/ui-core`'s `gitFlags`/`gitBranchLabel`: an absent status renders the unavailable
|
|
8
|
+
// arm, `null` behind/unpushed OMIT their flag (never "0 behind"), a `null` branch reads
|
|
9
|
+
// "detached HEAD", and an unreported status is never collapsed into a green "clean ✓". The product
|
|
10
|
+
// supplies the unavailable SENTENCE; the atom carries no reason vocabulary.
|
|
11
|
+
// Preact→React prop delta: `class` → `className`.
|
|
12
|
+
import { GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, } from "@mythicalos/ui-core/logic";
|
|
13
|
+
export { gitBranchLabel, gitChipClass, gitChipNote, gitFlagClass, gitFlags, hasGitStatus, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_CHIP_PARTS, GIT_CLEAN_LABEL, GIT_DETACHED_LABEL, GIT_LOADING_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, GIT_UNAVAILABLE_NOTE, };
|
|
14
|
+
export function GitChip(props) {
|
|
15
|
+
const { status, stale = false, className: cls = "" } = props;
|
|
16
|
+
const staleMark = stale ? (_jsx("span", { className: GIT_CHIP_PARTS.stale, title: GIT_STALE_TITLE, children: GIT_STALE_LABEL })) : null;
|
|
17
|
+
if (hasGitStatus(status)) {
|
|
18
|
+
const flags = gitFlags(status);
|
|
19
|
+
return (_jsxs("span", { className: `${gitChipClass({ stale })} ${cls}`, children: [_jsxs("span", { className: GIT_CHIP_PARTS.branch, children: [GIT_BRANCH_GLYPH, " ", gitBranchLabel(status.branch)] }), flags.length > 0 ? (_jsx("span", { className: GIT_CHIP_PARTS.flags, children: flags.map((f) => (_jsx("span", { className: gitFlagClass(f.tone), children: f.label }, f.label))) })) : null, staleMark] }));
|
|
20
|
+
}
|
|
21
|
+
const note = gitChipNote({ unavailableNote: props.unavailableNote, loading: props.loading });
|
|
22
|
+
return (_jsxs("span", { className: `${gitChipClass({ unavailable: true, stale })} ${cls}`, children: [_jsxs("span", { className: GIT_CHIP_PARTS.branch, title: note, children: [GIT_BRANCH_GLYPH, " ", GIT_BRANCH_UNKNOWN] }), _jsx("span", { className: GIT_CHIP_PARTS.note, children: note }), staleMark] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from "react";
|
|
2
|
+
import { type PopoverIds, type PopoverItem, type PopoverPosition } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_EMPTY_VALUE, popoverItemClass, popoverPanelClass, popoverTriggerClass, popoverTriggerText, resolvePopoverPosition, type PopoverItem, type PopoverPosition, } from "@mythicalos/ui-core/logic";
|
|
4
|
+
export interface PopoverProps {
|
|
5
|
+
/** The menu rows, in display order. Exactly one may carry `selected`. */
|
|
6
|
+
items: PopoverItem[];
|
|
7
|
+
/** Picking a row: closes the popover, returns focus to the trigger, then calls this. */
|
|
8
|
+
onSelect: (key: string, item: PopoverItem) => void;
|
|
9
|
+
/** Static prefix on the trigger, e.g. `"review lane:"`. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Trigger value when nothing is selected (default: the em-dash placeholder). */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Optional panel heading. */
|
|
14
|
+
title?: string;
|
|
15
|
+
/** Optional second heading line — the honest framing/caveat line. */
|
|
16
|
+
caption?: string;
|
|
17
|
+
/** Optional content below a divider (e.g. a "Manage projects →" action). Rows in here are the
|
|
18
|
+
* caller's own elements, and the arrow/Home/End roving deliberately does NOT reach them — a
|
|
19
|
+
* footer input keeps its own caret keys. Escape still closes from inside the footer. */
|
|
20
|
+
footer?: ReactNode;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Base for the trigger/panel id pair. Defaults to a generated one. */
|
|
23
|
+
id?: string;
|
|
24
|
+
/** Longer description for the trigger (native tooltip). Not the accessible NAME — the trigger's
|
|
25
|
+
* visible label + value already provide that. */
|
|
26
|
+
triggerTitle?: string;
|
|
27
|
+
onOpenChange?: (open: boolean) => void;
|
|
28
|
+
}
|
|
29
|
+
export interface PopoverPanelProps {
|
|
30
|
+
ids: PopoverIds;
|
|
31
|
+
pos: PopoverPosition;
|
|
32
|
+
items: PopoverItem[];
|
|
33
|
+
onPick: (item: PopoverItem) => void;
|
|
34
|
+
title?: string;
|
|
35
|
+
caption?: string;
|
|
36
|
+
footer?: ReactNode;
|
|
37
|
+
panelRef?: Ref<HTMLDivElement>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The OPEN panel, deliberately hook-free — the React twin of the Preact sibling's `PopoverPanel`,
|
|
41
|
+
* kept for the identical reason: a server-render test can exercise the panel markup (and a row's
|
|
42
|
+
* REAL onClick closure off the returned element tree) without a DOM. Exported from this module for
|
|
43
|
+
* tests; NOT part of the package barrel. `panelRef` rather than `ref` so both bindings expose the
|
|
44
|
+
* same prop name.
|
|
45
|
+
*/
|
|
46
|
+
export declare function PopoverPanel(props: PopoverPanelProps): import("react").JSX.Element;
|
|
47
|
+
/** A single-select dropdown popover anchored to its own chip trigger. */
|
|
48
|
+
export declare function Popover(props: PopoverProps): import("react").JSX.Element;
|
package/dist/Popover.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the dropdown popover (ds/components-popover, registry row `popover` v1).
|
|
3
|
+
//
|
|
4
|
+
// React twin of packages/preact-ui/src/Popover.tsx: same props, same markup, same behaviour. Like
|
|
5
|
+
// its sibling this file is WIRING ONLY — class strings, the viewport-aware flip/align geometry,
|
|
6
|
+
// the keyboard grammar, the roving-focus index arithmetic, the trigger's text composition and the
|
|
7
|
+
// ARIA maps all come from `@mythicalos/ui-core`, so the two bindings cannot drift.
|
|
8
|
+
//
|
|
9
|
+
// Preact→React notes (the only deltas, none of them behavioural):
|
|
10
|
+
// · `class` → `className`, `ComponentChildren` → `ReactNode`, `preact/hooks` → `react`.
|
|
11
|
+
// · The measurement effect runs through an isomorphic layout-effect alias. React logs a warning
|
|
12
|
+
// for `useLayoutEffect` during server rendering; Preact does not. The alias keeps the
|
|
13
|
+
// before-paint measurement in the browser (so the panel never flashes at the wrong end of the
|
|
14
|
+
// trigger) without that SSR warning.
|
|
15
|
+
// · React's `useId` produces ids containing `:`. They are only ever used as attribute VALUES
|
|
16
|
+
// (`id` / `aria-controls` / `aria-labelledby`), never inside a CSS selector, so they are safe.
|
|
17
|
+
//
|
|
18
|
+
// Documented residual (identical to the sibling): placement is re-measured on open, on resize and
|
|
19
|
+
// on scroll, but not on a mutation that resizes the open panel itself (no ResizeObserver).
|
|
20
|
+
//
|
|
21
|
+
// Consumer contract (design card, verbatim): "Ancestors must never clip it."
|
|
22
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
23
|
+
import { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_DECORATIVE_ARIA, POPOVER_DEFAULT_POSITION, POPOVER_SEPARATOR_ARIA, edgePopoverIndex, initialPopoverIndex, popoverHasSlotContent, popoverIds, popoverItemAria, popoverItemClass, popoverAppliesToFocus, popoverKeyHandled, popoverMenuAria, popoverPanelAria, popoverPanelClass, popoverPanelKeyAction, popoverTriggerAria, popoverTriggerClass, popoverTriggerKeyAction, popoverTriggerText, resolvePopoverIndex, resolvePopoverPosition, samePopoverPosition, } from "@mythicalos/ui-core/logic";
|
|
24
|
+
export { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_EMPTY_VALUE, popoverItemClass, popoverPanelClass, popoverTriggerClass, popoverTriggerText, resolvePopoverPosition, } from "@mythicalos/ui-core/logic";
|
|
25
|
+
/** `useLayoutEffect` in the browser, `useEffect` on the server — see the header note. */
|
|
26
|
+
const useIsomorphicLayoutEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
|
27
|
+
/**
|
|
28
|
+
* The OPEN panel, deliberately hook-free — the React twin of the Preact sibling's `PopoverPanel`,
|
|
29
|
+
* kept for the identical reason: a server-render test can exercise the panel markup (and a row's
|
|
30
|
+
* REAL onClick closure off the returned element tree) without a DOM. Exported from this module for
|
|
31
|
+
* tests; NOT part of the package barrel. `panelRef` rather than `ref` so both bindings expose the
|
|
32
|
+
* same prop name.
|
|
33
|
+
*/
|
|
34
|
+
export function PopoverPanel(props) {
|
|
35
|
+
return (_jsxs("div", { ref: props.panelRef, className: popoverPanelClass(props.pos), tabIndex: -1, ...popoverPanelAria(props.ids), children: [popoverHasSlotContent(props.title) || popoverHasSlotContent(props.caption) ? (_jsxs("div", { className: POPOVER_CLASS.head, children: [popoverHasSlotContent(props.title) ? (_jsx("span", { id: props.ids.title, className: POPOVER_CLASS.title, children: props.title })) : null, popoverHasSlotContent(props.caption) ? _jsx("span", { className: POPOVER_CLASS.caption, children: props.caption }) : null] })) : null, _jsx("div", { className: POPOVER_CLASS.menu, ...popoverMenuAria(props.ids, popoverHasSlotContent(props.title)), children: props.items.map((item) => (_jsxs("button", { type: "button", "data-pop-item": "", className: popoverItemClass(item), disabled: item.disabled, ...popoverItemAria(item), onClick: () => {
|
|
36
|
+
if (item.disabled)
|
|
37
|
+
return; // belt-and-braces: a native disabled button fires no click
|
|
38
|
+
props.onPick(item);
|
|
39
|
+
}, children: [_jsx("span", { className: POPOVER_CLASS.itemLabel, children: item.label }), _jsx("span", { className: POPOVER_CLASS.itemCheck, ...POPOVER_DECORATIVE_ARIA, children: item.selected ? POPOVER_CHECK : "" })] }, item.key))) }), popoverHasSlotContent(props.footer) ? (_jsxs(_Fragment, { children: [_jsx("div", { className: POPOVER_CLASS.divider, ...POPOVER_SEPARATOR_ARIA }), _jsx("div", { className: POPOVER_CLASS.foot, children: props.footer })] })) : null] }));
|
|
40
|
+
}
|
|
41
|
+
/** A single-select dropdown popover anchored to its own chip trigger. */
|
|
42
|
+
export function Popover(props) {
|
|
43
|
+
const { items, disabled = false } = props;
|
|
44
|
+
const generatedId = useId();
|
|
45
|
+
const ids = popoverIds(props.id ?? generatedId);
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const [pos, setPos] = useState(POPOVER_DEFAULT_POSITION);
|
|
48
|
+
const anchorRef = useRef(null);
|
|
49
|
+
const triggerRef = useRef(null);
|
|
50
|
+
const panelRef = useRef(null);
|
|
51
|
+
/** Row to focus on the next open (-1 ⇒ let `initialPopoverIndex` decide). */
|
|
52
|
+
const pendingFocus = useRef(-1);
|
|
53
|
+
const itemNodes = () => Array.from(panelRef.current?.querySelectorAll("[data-pop-item]") ?? []);
|
|
54
|
+
const focusRow = (i) => {
|
|
55
|
+
// preventScroll: a bare focus() asks the browser to reveal the row by scrolling every
|
|
56
|
+
// scrollable ancestor — which yanks the page out from under an open popover.
|
|
57
|
+
itemNodes()[i]?.focus({ preventScroll: true });
|
|
58
|
+
};
|
|
59
|
+
const setOpenState = (next) => {
|
|
60
|
+
setOpen(next);
|
|
61
|
+
props.onOpenChange?.(next);
|
|
62
|
+
};
|
|
63
|
+
const openAt = (index) => {
|
|
64
|
+
if (disabled)
|
|
65
|
+
return;
|
|
66
|
+
pendingFocus.current = index;
|
|
67
|
+
setPos(POPOVER_DEFAULT_POSITION); // re-measured below; never reuse a stale flip
|
|
68
|
+
setOpenState(true);
|
|
69
|
+
};
|
|
70
|
+
const closeToTrigger = () => {
|
|
71
|
+
setOpenState(false);
|
|
72
|
+
triggerRef.current?.focus({ preventScroll: true });
|
|
73
|
+
};
|
|
74
|
+
// Placement: measure once the panel is in the DOM but before paint, so it never flashes at the
|
|
75
|
+
// wrong end of the trigger. Guarded by samePopoverPosition so measure → setState can't loop.
|
|
76
|
+
useIsomorphicLayoutEffect(() => {
|
|
77
|
+
if (!open)
|
|
78
|
+
return;
|
|
79
|
+
const measure = () => {
|
|
80
|
+
const trigger = triggerRef.current;
|
|
81
|
+
const panel = panelRef.current;
|
|
82
|
+
if (!trigger || !panel)
|
|
83
|
+
return;
|
|
84
|
+
const next = resolvePopoverPosition(trigger.getBoundingClientRect(), { width: panel.offsetWidth, height: panel.offsetHeight }, { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight });
|
|
85
|
+
setPos((prev) => (samePopoverPosition(prev, next) ? prev : next));
|
|
86
|
+
};
|
|
87
|
+
measure();
|
|
88
|
+
window.addEventListener("resize", measure);
|
|
89
|
+
// capture: scroll does not bubble, so this is the only way to see a scrollable ANCESTOR move
|
|
90
|
+
// the anchor and invalidate the flip decision.
|
|
91
|
+
document.addEventListener("scroll", measure, { capture: true, passive: true });
|
|
92
|
+
return () => {
|
|
93
|
+
window.removeEventListener("resize", measure);
|
|
94
|
+
document.removeEventListener("scroll", measure, { capture: true });
|
|
95
|
+
};
|
|
96
|
+
}, [open, items.length]);
|
|
97
|
+
// a11y: on open, focus moves INTO the menu the role promises — the selected row, else the first
|
|
98
|
+
// enabled one, else the panel itself (an empty or all-disabled list), never left on the trigger.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!open)
|
|
101
|
+
return;
|
|
102
|
+
const requested = pendingFocus.current;
|
|
103
|
+
pendingFocus.current = -1;
|
|
104
|
+
const index = requested >= 0 ? requested : initialPopoverIndex(items);
|
|
105
|
+
if (index >= 0)
|
|
106
|
+
focusRow(index);
|
|
107
|
+
else
|
|
108
|
+
panelRef.current?.focus({ preventScroll: true });
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `items` is read at open time only
|
|
110
|
+
}, [open]);
|
|
111
|
+
// Outside pointerdown closes. Focus is NOT pulled back to the trigger here: the pointer is
|
|
112
|
+
// already establishing focus somewhere else, and stealing it back would fight the user.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!open)
|
|
115
|
+
return;
|
|
116
|
+
const onDown = (e) => {
|
|
117
|
+
if (anchorRef.current && !anchorRef.current.contains(e.target))
|
|
118
|
+
setOpenState(false);
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener("pointerdown", onDown, true);
|
|
121
|
+
return () => document.removeEventListener("pointerdown", onDown, true);
|
|
122
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- setOpenState is stable enough here
|
|
123
|
+
}, [open]);
|
|
124
|
+
// One handler on the anchor catches keys from the trigger AND the panel (both are inside it).
|
|
125
|
+
// The anchor is a plain <div>, but it is never itself focusable or a tab stop — every key this
|
|
126
|
+
// sees has bubbled up from a real <button>, so this is delegation, not a keyboard handler bolted
|
|
127
|
+
// onto a non-interactive element. Enter/Space are absent by design: the trigger is a real
|
|
128
|
+
// <button>, so the browser already synthesises a click from them.
|
|
129
|
+
const onKeyDown = (e) => {
|
|
130
|
+
if (disabled)
|
|
131
|
+
return;
|
|
132
|
+
if (!open) {
|
|
133
|
+
const action = popoverTriggerKeyAction(e.key);
|
|
134
|
+
if (action === null)
|
|
135
|
+
return;
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
openAt(edgePopoverIndex(items, action === "open-first" ? 1 : -1));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const action = popoverPanelKeyAction(e.key);
|
|
141
|
+
// Roving keys apply only on a row (or the panel fallback); the caller's footer content keeps
|
|
142
|
+
// its own arrows/Home/End. Escape and Tab still work from anywhere inside the popover.
|
|
143
|
+
const focused = itemNodes().indexOf(document.activeElement);
|
|
144
|
+
const onRovingSurface = focused >= 0 || document.activeElement === panelRef.current;
|
|
145
|
+
if (!popoverAppliesToFocus(action, onRovingSurface))
|
|
146
|
+
return;
|
|
147
|
+
// Tab ("dismiss") must keep its native behaviour or the popover is a focus trap; moving focus
|
|
148
|
+
// to the trigger first makes the browser continue the tab sequence from AFTER the trigger,
|
|
149
|
+
// which is where tabbing out of a menu belongs.
|
|
150
|
+
if (popoverKeyHandled(action))
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
if (action === "close" || action === "dismiss") {
|
|
153
|
+
closeToTrigger();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const next = resolvePopoverIndex(action, items, focused);
|
|
157
|
+
if (next >= 0)
|
|
158
|
+
focusRow(next);
|
|
159
|
+
};
|
|
160
|
+
const text = popoverTriggerText(items, { label: props.label, placeholder: props.placeholder });
|
|
161
|
+
return (_jsxs("div", { className: POPOVER_CLASS.anchor, ref: anchorRef, onKeyDown: onKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: popoverTriggerClass({ open, disabled }), disabled: disabled, title: props.triggerTitle, ...popoverTriggerAria(open, ids), onClick: () => (open ? closeToTrigger() : openAt(-1)), children: [text.label !== null ? _jsx("span", { className: POPOVER_CLASS.triggerLabel, children: text.label }) : null, _jsx("span", { className: POPOVER_CLASS.triggerValue, children: text.value }), _jsx("span", { className: POPOVER_CLASS.triggerCaret, ...POPOVER_DECORATIVE_ARIA, children: POPOVER_CARET })] }), open ? (_jsx(PopoverPanel, { panelRef: panelRef, ids: ids, pos: pos, items: items, title: props.title, caption: props.caption, footer: props.footer, onPick: (item) => {
|
|
162
|
+
closeToTrigger(); // a11y — selecting returns focus to the trigger, never to <body>
|
|
163
|
+
props.onSelect(item.key, item);
|
|
164
|
+
} })) : null] }));
|
|
165
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type QueueItem, type QueueSource, type QueueUnavailableReason } from "@mythicalos/ui-core/logic";
|
|
2
|
+
export { QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, unavailableText, type CancelEvent, type CancelState, type QueueItem, type QueueItemStatus, type QueueSource, type QueueUnavailableReason, type QueueView, } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export interface QueueRowProps {
|
|
4
|
+
item: QueueItem;
|
|
5
|
+
/** The row is showing its two-step confirm instead of its normal presentation. */
|
|
6
|
+
armed?: boolean;
|
|
7
|
+
/** Whether the cancel affordance may exist at all — combined with the status by ui-core. */
|
|
8
|
+
canCancel?: boolean;
|
|
9
|
+
onArm?(): void;
|
|
10
|
+
onConfirm?(): void;
|
|
11
|
+
onDisarm?(): void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One queue record. The registry atom — usable standalone, or through `QueuePanel`.
|
|
15
|
+
*
|
|
16
|
+
* INVARIANT 4 is enforced HERE, not only by the panel: `armed` is honored only when the record is
|
|
17
|
+
* genuinely cancellable (`canCancelRow`). A caller that arms a `leased`/`delivered`/`canceled` row,
|
|
18
|
+
* or arms a row whose cancellability was revoked, gets the ordinary row back — never a live
|
|
19
|
+
* "Cancel it" button for something that cannot be cancelled. That also covers the render between a
|
|
20
|
+
* revocation and the panel's disarming effect.
|
|
21
|
+
*/
|
|
22
|
+
export declare function QueueRow(props: QueueRowProps): import("react").JSX.Element;
|
|
23
|
+
export interface QueuePanelProps {
|
|
24
|
+
source: QueueSource;
|
|
25
|
+
/** Whether cancelling is possible at all right now (e.g. the viewer owns the queue). */
|
|
26
|
+
canCancel?: boolean;
|
|
27
|
+
onCancel?(id: string): void;
|
|
28
|
+
/** Changing this clears any pending confirm (e.g. the selected target changed). */
|
|
29
|
+
resetKey?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Extra detail per unavailable reason, APPENDED to this package's own sentence — never
|
|
32
|
+
* substituted for it. Two reasons therefore always render different copy, whatever the caller
|
|
33
|
+
* supplies (see ui-core's `unavailableText`).
|
|
34
|
+
*/
|
|
35
|
+
unavailableDetail?: Partial<Record<QueueUnavailableReason, string>>;
|
|
36
|
+
}
|
|
37
|
+
export declare function QueuePanel(props: QueuePanelProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the delivery queue (ds/components-terminal, spec v2). React twin of
|
|
3
|
+
// packages/preact-ui/src/QueuePanel.tsx. `QueueRow` is the registry atom; `QueuePanel` is the list
|
|
4
|
+
// that carries the source→view honesty around it.
|
|
5
|
+
//
|
|
6
|
+
// Thin binding: render + wiring only. The source resolution, every copy string, the row/badge class
|
|
7
|
+
// derivation, and the two-step cancel state machine all come from `@mythicalos/ui-core`.
|
|
8
|
+
//
|
|
9
|
+
// Honesty (binding, inherited from ui-core): the empty copy is CAPABILITY-NEUTRAL and renders ONLY
|
|
10
|
+
// on `ok` + zero items — never an operational-emptiness or store-existence claim, and never the
|
|
11
|
+
// design card's "queue empty — deliveries land here by class (ASAP interrupts · ON-DONE waits)";
|
|
12
|
+
// `loading` never renders empty; the three unavailable reasons render DISTINCTLY; `stale` is its own
|
|
13
|
+
// state, flagged as last-known data with no retry claim. The cancel affordance exists ONLY on
|
|
14
|
+
// `queued` records, only when the caller says the record is cancellable, and only while the
|
|
15
|
+
// source is FRESH — a stale (last-known) list carries no cancel controls at all.
|
|
16
|
+
import { useEffect, useReducer } from "react";
|
|
17
|
+
import { QUEUE_CANCEL_ASK, QUEUE_CANCEL_LABEL, QUEUE_CANCEL_NO, QUEUE_CANCEL_YES, QUEUE_CLASSES, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueStatusLabel, queueView, shouldDisarmCancel, } from "@mythicalos/ui-core/logic";
|
|
18
|
+
export { QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, unavailableText, } from "@mythicalos/ui-core/logic";
|
|
19
|
+
/**
|
|
20
|
+
* One queue record. The registry atom — usable standalone, or through `QueuePanel`.
|
|
21
|
+
*
|
|
22
|
+
* INVARIANT 4 is enforced HERE, not only by the panel: `armed` is honored only when the record is
|
|
23
|
+
* genuinely cancellable (`canCancelRow`). A caller that arms a `leased`/`delivered`/`canceled` row,
|
|
24
|
+
* or arms a row whose cancellability was revoked, gets the ordinary row back — never a live
|
|
25
|
+
* "Cancel it" button for something that cannot be cancelled. That also covers the render between a
|
|
26
|
+
* revocation and the panel's disarming effect.
|
|
27
|
+
*/
|
|
28
|
+
export function QueueRow(props) {
|
|
29
|
+
const { item } = props;
|
|
30
|
+
const cancellable = canCancelRow(item.status, !!props.canCancel);
|
|
31
|
+
const badge = _jsx("span", { className: queueBadgeClass(item.cls), children: queueBadgeLabel(item.cls) });
|
|
32
|
+
if (props.armed && cancellable) {
|
|
33
|
+
return (_jsxs("div", { className: queueRowClass(item.status, true), children: [badge, _jsx("span", { className: QUEUE_CLASSES.ask, children: QUEUE_CANCEL_ASK }), _jsxs("span", { className: QUEUE_CLASSES.actions, children: [_jsx("button", { type: "button", className: QUEUE_CLASSES.confirmYes, onClick: props.onConfirm, children: QUEUE_CANCEL_YES }), _jsx("button", { type: "button", className: QUEUE_CLASSES.confirmNo, onClick: props.onDisarm, children: QUEUE_CANCEL_NO })] })] }));
|
|
34
|
+
}
|
|
35
|
+
return (_jsxs("div", { className: queueRowClass(item.status, false), children: [badge, _jsx("span", { className: QUEUE_CLASSES.rowBody, children: item.body }), _jsx("span", { className: QUEUE_CLASSES.rowStatus, children: queueStatusLabel(item.status) }), cancellable ? (_jsx("button", { type: "button", className: QUEUE_CLASSES.cancel, onClick: props.onArm, children: QUEUE_CANCEL_LABEL })) : null] }));
|
|
36
|
+
}
|
|
37
|
+
export function QueuePanel(props) {
|
|
38
|
+
const { source } = props;
|
|
39
|
+
const canCancel = !!props.canCancel;
|
|
40
|
+
const [cancel, dispatch] = useReducer(cancelReducer, { armedId: null });
|
|
41
|
+
const { resetKey, onCancel } = props;
|
|
42
|
+
// A changed target clears any pending ask.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
dispatch({ type: "disarm" });
|
|
45
|
+
}, [resetKey]);
|
|
46
|
+
// An armed confirm must never outlive its cancelability: ownership loss, or the armed row leaving
|
|
47
|
+
// `queued` in the CURRENT source (leased/delivered/canceled/vanished/unavailable), force-disarms.
|
|
48
|
+
const mustDisarm = shouldDisarmCancel(cancel.armedId, canCancel, source);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (mustDisarm)
|
|
51
|
+
dispatch({ type: "disarm" });
|
|
52
|
+
}, [mustDisarm]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (cancel.armedId === null)
|
|
55
|
+
return;
|
|
56
|
+
const onKey = (e) => {
|
|
57
|
+
if (e.key === "Escape")
|
|
58
|
+
dispatch({ type: "disarm" });
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener("keydown", onKey);
|
|
61
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
62
|
+
}, [cancel.armedId]);
|
|
63
|
+
const view = queueView(source, props.unavailableDetail);
|
|
64
|
+
return (_jsxs("div", { className: QUEUE_CLASSES.panel, children: [view.kind === "state" ? _jsx("div", { className: QUEUE_CLASSES.state, children: view.copy }) : null, view.kind === "empty" ? _jsx("div", { className: QUEUE_CLASSES.empty, children: view.copy }) : null, view.kind === "list" ? (_jsxs("div", { className: QUEUE_CLASSES.list, children: [view.staleCopy !== null ? _jsx("div", { className: QUEUE_CLASSES.stale, children: view.staleCopy }) : null, view.items.map((item) => {
|
|
65
|
+
// A STALE list is last-known data, so no row in it may carry a cancel affordance: the
|
|
66
|
+
// row's real status is unknown, and offering a cancel would contradict the banner right
|
|
67
|
+
// above it. `view.fresh` is ui-core's freshness verdict, not a local guess.
|
|
68
|
+
const rowCanCancel = canCancel && view.fresh;
|
|
69
|
+
return (_jsx(QueueRow, { item: item, canCancel: rowCanCancel, armed: cancel.armedId === item.id, onArm: () => dispatch({ type: "arm", id: item.id }), onConfirm: () => {
|
|
70
|
+
// Re-checked at the moment of confirm: the intent only fires if the row is STILL
|
|
71
|
+
// fresh AND cancellable — never a stale-arm cancel.
|
|
72
|
+
if (canCancelRow(item.status, rowCanCancel))
|
|
73
|
+
onCancel?.(item.id);
|
|
74
|
+
dispatch({ type: "confirm" });
|
|
75
|
+
}, onDisarm: () => dispatch({ type: "disarm" }) }, item.id));
|
|
76
|
+
})] })) : null] }));
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, SAVE_BAR_PARTS, SAVE_BAR_SEP, saveBarClass, saveBarDirty, saveBarNote, type SaveBarNote } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export { saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, type SaveBarNote, };
|
|
4
|
+
export interface SaveBarProps {
|
|
5
|
+
/** Human labels of the dirty fields. Empty ⇒ the bar does not render. */
|
|
6
|
+
changed: readonly string[];
|
|
7
|
+
/** A save is in flight: the primary goes loading + inert. */
|
|
8
|
+
saving?: boolean;
|
|
9
|
+
/** Card copy is "Save & apply"; override per surface. */
|
|
10
|
+
saveLabel?: string;
|
|
11
|
+
discardLabel?: string;
|
|
12
|
+
onDiscard: () => void;
|
|
13
|
+
onSave: () => void;
|
|
14
|
+
/** Extra content rendered between the note and the actions (e.g. a validation hint). */
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function SaveBar(props: SaveBarProps): import("react").JSX.Element | null;
|
package/dist/SaveBar.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, SAVE_BAR_PARTS, SAVE_BAR_SEP, saveBarClass, saveBarDirty, saveBarNote, } from "@mythicalos/ui-core/logic";
|
|
3
|
+
import { Button } from "./Button.js";
|
|
4
|
+
export { saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, };
|
|
5
|
+
export function SaveBar(props) {
|
|
6
|
+
const { changed, saving = false, className: cls = "" } = props;
|
|
7
|
+
const note = saveBarNote(changed);
|
|
8
|
+
if (note.count === 0)
|
|
9
|
+
return null;
|
|
10
|
+
return (_jsxs("div", { className: `${saveBarClass()} ${cls}`, children: [_jsxs("span", { className: SAVE_BAR_PARTS.note, children: [_jsx("b", { className: SAVE_BAR_PARTS.count, children: note.countLabel }), SAVE_BAR_SEP, note.detail] }), props.children, _jsxs("span", { className: SAVE_BAR_PARTS.actions, children: [_jsx(Button, { variant: "sec", onClick: props.onDiscard, children: props.discardLabel ?? SAVE_BAR_DISCARD_LABEL }), _jsx(Button, { variant: "pri", loading: saving, onClick: props.onSave, children: props.saveLabel ?? SAVE_BAR_SAVE_LABEL })] })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type DeliveryClass } from "@mythicalos/ui-core/logic";
|
|
2
|
+
export { DEFAULT_DELIVERY_CLASS, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, resolveSend, sendPlaceholder, showDeliveryHint, type DeliveryClass, type KeyAction, } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export interface SendBarProps {
|
|
4
|
+
/** Initial selection. A manual pick then wins — a later prop change never clobbers it. */
|
|
5
|
+
defaultClass?: DeliveryClass;
|
|
6
|
+
/** A send is in flight: the send button is inert but the field stays editable. */
|
|
7
|
+
busy?: boolean;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
/** The honest reason the bar is unusable — surfaced as the placeholder. */
|
|
10
|
+
disabledReason?: string;
|
|
11
|
+
/** A persistent notice rendered below the bar (e.g. a pending stop). */
|
|
12
|
+
notice?: string;
|
|
13
|
+
/** Carried into the enabled placeholder: `Message {name}… (⏎ send · ⇧⏎ newline)`. */
|
|
14
|
+
targetName?: string;
|
|
15
|
+
/** Resolve `true` on success; the draft clears only then (text survives failures). */
|
|
16
|
+
onSend(cls: DeliveryClass, body: string): boolean | Promise<boolean>;
|
|
17
|
+
/**
|
|
18
|
+
* Called when `onSend` REJECTS. The rejection is treated as a failed send (nothing delivered, the
|
|
19
|
+
* draft kept) rather than escaping as an unhandled rejection; this is how a caller still observes
|
|
20
|
+
* it. Omitting it does not hide a success — only a resolved `true` ever counts as sent.
|
|
21
|
+
*/
|
|
22
|
+
onSendError?(error: unknown): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function SendBar(props: SendBarProps): import("react").JSX.Element;
|
package/dist/SendBar.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the delivery send bar (ds/components-terminal, spec v2). React twin of
|
|
3
|
+
// packages/preact-ui/src/SendBar.tsx. Holds the LOCAL draft + class selection and fires
|
|
4
|
+
// `onSend(cls, body)`; the caller owns the delivery call.
|
|
5
|
+
//
|
|
6
|
+
// Thin binding: render + wiring only. The placeholder composition, the send predicate, the
|
|
7
|
+
// draft-clear rule, the keyboard semantics and the hint all come from `@mythicalos/ui-core`.
|
|
8
|
+
//
|
|
9
|
+
// Honesty (binding, inherited from ui-core): the per-class hint is
|
|
10
|
+
// `ASAP takes the first turn gap · ON-DONE waits for full idle.` — the design card's older
|
|
11
|
+
// "ASAP interrupts" wording is FALSE (delivery always waits for a turn boundary) and must never be
|
|
12
|
+
// reintroduced. The hint renders ONLY where the controls are usable; a disabled bar surfaces just
|
|
13
|
+
// the caller's truthful reason.
|
|
14
|
+
//
|
|
15
|
+
// Keyboard: Enter=send, Shift/Alt-Enter=newline, IME composition suppressed (`keyAction`).
|
|
16
|
+
import { useRef, useState } from "react";
|
|
17
|
+
import { DEFAULT_DELIVERY_CLASS, DELIVERY_CLASSES, DELIVERY_HINT, SENDBAR_CLASSES, SEND_BUTTON_LABEL, canSend, clearDraftOnSend, deliveryClassButtonClass, deliveryClassLabel, keyAction, makeSendGate, resolveSend, sendBarClass, sendPlaceholder, showDeliveryHint, } from "@mythicalos/ui-core/logic";
|
|
18
|
+
export { DEFAULT_DELIVERY_CLASS, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, resolveSend, sendPlaceholder, showDeliveryHint, } from "@mythicalos/ui-core/logic";
|
|
19
|
+
export function SendBar(props) {
|
|
20
|
+
const disabled = !!props.disabled;
|
|
21
|
+
const busy = !!props.busy;
|
|
22
|
+
const [cls, setCls] = useState(props.defaultClass ?? DEFAULT_DELIVERY_CLASS);
|
|
23
|
+
const [text, setText] = useState("");
|
|
24
|
+
// Single-flight: `busy` covers a caller that tracks the request itself, but `onSend` may be async
|
|
25
|
+
// and a caller that flips `busy` a tick later (or never) would let two clicks — or a click racing
|
|
26
|
+
// an Enter — deliver the same message twice. The gate is synchronous, so the second attempt is
|
|
27
|
+
// refused before any state update lands; `sending` only exists to re-render the disabled button.
|
|
28
|
+
const gate = useRef(makeSendGate());
|
|
29
|
+
const [sending, setSending] = useState(false);
|
|
30
|
+
const locked = busy || sending;
|
|
31
|
+
const doSend = async () => {
|
|
32
|
+
if (!canSend(text, disabled, locked))
|
|
33
|
+
return;
|
|
34
|
+
if (!gate.current.tryAcquire())
|
|
35
|
+
return;
|
|
36
|
+
setSending(true);
|
|
37
|
+
const submitted = text;
|
|
38
|
+
try {
|
|
39
|
+
// a REJECTED onSend resolves to `false` here (a failed send) instead of escaping the handler
|
|
40
|
+
// as an unhandled rejection; the caller still sees it through `onSendError`
|
|
41
|
+
const sent = await resolveSend(() => props.onSend(cls, submitted), props.onSendError);
|
|
42
|
+
// Functional update: compared against the LIVE draft at resolve time, not this closure's
|
|
43
|
+
// snapshot, so text typed while the request was in flight is never erased.
|
|
44
|
+
if (sent)
|
|
45
|
+
setText((current) => (clearDraftOnSend(sent, submitted, current) ? "" : current));
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
// released on every path, so a failing send can never wedge the bar shut. The draft survives
|
|
49
|
+
// either way (it clears only on `sent === true`).
|
|
50
|
+
gate.current.release();
|
|
51
|
+
setSending(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const onKeyDown = (e) => {
|
|
55
|
+
if (keyAction(e) === "send") {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
void doSend();
|
|
58
|
+
}
|
|
59
|
+
// "newline" / "none": the textarea handles the keystroke natively.
|
|
60
|
+
};
|
|
61
|
+
return (_jsxs("div", { className: SENDBAR_CLASSES.wrap, children: [_jsxs("div", { className: sendBarClass(disabled), children: [_jsx("div", { className: SENDBAR_CLASSES.segment, role: "group", children: DELIVERY_CLASSES.map((c) => (_jsx("button", { type: "button", className: deliveryClassButtonClass(cls === c), disabled: disabled, "aria-pressed": cls === c, onClick: () => setCls(c), children: deliveryClassLabel(c) }, c))) }), _jsx("textarea", { className: SENDBAR_CLASSES.input, rows: 1, placeholder: sendPlaceholder(disabled, props.disabledReason, props.targetName), value: text, disabled: disabled, onChange: (e) => setText(e.target.value), onKeyDown: onKeyDown }), _jsx("button", { type: "button", className: SENDBAR_CLASSES.send, disabled: !canSend(text, disabled, locked), onClick: doSend, children: SEND_BUTTON_LABEL })] }), showDeliveryHint(disabled) ? _jsx("div", { className: SENDBAR_CLASSES.hint, children: DELIVERY_HINT }) : null, props.notice ? _jsx("div", { className: SENDBAR_CLASSES.notice, children: props.notice }) : null] }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, type CtxThresholds, type SessionSpine, type SessionStatusInput } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, };
|
|
4
|
+
export interface SessionCardProps {
|
|
5
|
+
/** Display name — the card's headline and the source of the avatar initial. */
|
|
6
|
+
name: string;
|
|
7
|
+
/**
|
|
8
|
+
* The meta subline, as a free list joined with ` · `. The design card's own states use it for
|
|
9
|
+
* `role · model · duration`, `role · model · queued: 1 ON-DONE` and `role · last seen 00:12 ago`
|
|
10
|
+
* alike. Absent/blank entries collapse; an all-absent list omits the line entirely.
|
|
11
|
+
*/
|
|
12
|
+
meta?: readonly (string | null | undefined)[];
|
|
13
|
+
/**
|
|
14
|
+
* What the product actually knows about the session. Every field is optional and every absent
|
|
15
|
+
* field means "not reported" — an omitted `activity` never becomes `idle`.
|
|
16
|
+
*/
|
|
17
|
+
status?: SessionStatusInput;
|
|
18
|
+
/** The product's own wording for the derived status (e.g. a wake-specific phrase). The tone,
|
|
19
|
+
* pulse and stale treatment still come from the derivation — only the words change. A blank
|
|
20
|
+
* override, and ANY override on the `unknown` status, is ignored: nothing was claimed, so there
|
|
21
|
+
* is nothing to reword (see ui-core's `sessionStatusText`). */
|
|
22
|
+
statusLabel?: string;
|
|
23
|
+
/** Context reading, 0–100. `undefined`/`null` ⇒ NOT MEASURED: no fill is drawn and the value
|
|
24
|
+
* reads `—`. It is never rendered as 0. */
|
|
25
|
+
contextPct?: number | null;
|
|
26
|
+
/** Product-tunable warn/critical thresholds (default 75/90 — tokens.css rule #4). */
|
|
27
|
+
thresholds?: CtxThresholds;
|
|
28
|
+
/** Spine strip. An unreported `distills` omits the strip entirely. */
|
|
29
|
+
spine?: SessionSpine;
|
|
30
|
+
/** The design's selected state (petrol border + ring). Independent of `stale`. */
|
|
31
|
+
selected?: boolean;
|
|
32
|
+
/** ADD the design's stale treatment (dashed border, muted values) for a reason the card cannot
|
|
33
|
+
* see. It cannot be used to REMOVE the staleness a down link implies — see `sessionCardStale`. */
|
|
34
|
+
stale?: boolean;
|
|
35
|
+
/** Makes the card a real button. Omit for a non-interactive card. */
|
|
36
|
+
onSelect?: () => void;
|
|
37
|
+
className?: string;
|
|
38
|
+
/** Extra content appended inside the card (e.g. a product's own mode glyph). */
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
export declare function SessionCard(props: SessionCardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the `session-card` atom (design registry spec v1,
|
|
3
|
+
// mythical-design/ds/components-session-card.html): avatar, name, status dot + words, a meta
|
|
4
|
+
// subline, the context meter with its fixed threshold ticks, and the optional spine strip.
|
|
5
|
+
//
|
|
6
|
+
// React twin of packages/preact-ui/src/SessionCard.tsx. All band/status/stale/class derivation,
|
|
7
|
+
// subline composition, bar geometry and spine summary comes from `@mythicalos/ui-core`'s
|
|
8
|
+
// session-card logic module — never reimplemented here — so the two bindings cannot drift. The
|
|
9
|
+
// three honesty invariants (absence is not zero · unknown is not idle · thresholds are
|
|
10
|
+
// product-tunable) are enforced there and documented at the top of that file.
|
|
11
|
+
//
|
|
12
|
+
// Preact→React prop deltas: `class` → `className` (same rename as Chip/Avatar), and the SVG
|
|
13
|
+
// presentation attributes go through React's camelCase props (`preserveAspectRatio`), which
|
|
14
|
+
// serialize back to the same DOM attributes the Preact sibling emits. No inline `style=` anywhere
|
|
15
|
+
// (CSP style-src 'self').
|
|
16
|
+
import { Fragment } from "react";
|
|
17
|
+
import { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, CTX_BAR_VIEWBOX, CTX_THRESHOLDS_DEFAULT, } from "@mythicalos/ui-core/logic";
|
|
18
|
+
import { Avatar } from "./Avatar.js";
|
|
19
|
+
export { ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionStatusText, sessionSubline, spineNodeClass, };
|
|
20
|
+
export function SessionCard(props) {
|
|
21
|
+
const { name, meta = [], status: statusInput, statusLabel, contextPct, thresholds = CTX_THRESHOLDS_DEFAULT, spine, selected = false, onSelect, className: cls = "", children, } = props;
|
|
22
|
+
const band = ctxBand(contextPct, thresholds);
|
|
23
|
+
// the band is part of the status derivation, not a second opinion on it: the design card's
|
|
24
|
+
// warn/error states replace the status line with the context claim
|
|
25
|
+
const status = sessionStatus(statusInput, band);
|
|
26
|
+
const stale = sessionCardStale(status, props.stale);
|
|
27
|
+
const bar = ctxBarGeom(contextPct, thresholds);
|
|
28
|
+
const subline = sessionSubline(meta);
|
|
29
|
+
const spineSummary = sessionSpineSummary(spine);
|
|
30
|
+
const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "my-session-card__head", children: [_jsx(Avatar, { initials: sessionAvatarInitial(name), className: "my-session-card__avatar" }), _jsxs("span", { className: "my-session-card__ident", children: [_jsxs("span", { className: "my-session-card__line1", children: [_jsx("b", { className: "my-session-card__name", children: name }), _jsxs("span", { className: sessionStatusClass(status), children: [_jsx("span", { className: "my-session-card__dot" }), _jsx("span", { className: "my-session-card__status-text", children: sessionStatusText(status, statusLabel) })] })] }), subline.length > 0 ? _jsx("span", { className: "my-session-card__meta", children: subline }) : null] })] }), _jsxs("span", { className: ctxMeterClass({ band, stale }), children: [_jsx("span", { className: "my-session-card__ctx-bar", children: _jsxs("svg", { className: "my-session-card__ctx-svg", viewBox: CTX_BAR_VIEWBOX, preserveAspectRatio: "none", "aria-hidden": "true", children: [_jsx("rect", { className: "my-session-card__ctx-track", x: "0", y: "0", width: bar.span, height: bar.height }), bar.fill === undefined ? null : (_jsx("rect", { className: "my-session-card__ctx-fill", x: "0", y: "0", width: bar.fill, height: bar.height })), bar.ticks.map((t) => (_jsx("rect", { className: "my-session-card__ctx-tick", x: t.x, y: "0", width: t.width, height: bar.height }, t.pct)))] }) }), _jsxs("span", { className: "my-session-card__ctx-legend", children: [_jsx("span", { className: "my-session-card__ctx-note", children: ctxNoteText(band, { stale }) }), _jsx("b", { className: "my-session-card__ctx-value", children: ctxValueText(contextPct, thresholds) })] })] }), spineSummary === undefined ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "my-session-card__spine", "aria-hidden": "true", children: spineSummary.nodes.map((node, i) => (_jsxs(Fragment, { children: [i > 0 ? _jsx("span", { className: "my-session-card__spine-seg" }) : null, _jsx("span", { className: spineNodeClass(node) })] }, i))) }), _jsxs("span", { className: "my-session-card__ctx-legend", children: [_jsx("span", { className: "my-session-card__spine-note", children: spineSummary.label }), _jsx("b", { className: "my-session-card__spine-value", children: spineSummary.value })] })] })), children] }));
|
|
31
|
+
const classes = sessionCardClass({ selected, stale, extra: cls });
|
|
32
|
+
return onSelect === undefined ? (_jsx("div", { className: classes, children: body })) : (_jsx("button", { type: "button", className: classes, "aria-pressed": selected, onClick: onSelect, children: body }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { STAT_TILE_EMPTY, STAT_TILE_MINUS, formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, STAT_TILE_PARTS, statTileClass, statTilesClass, type StatTile, type StatTileTone } from "@mythicalos/ui-core/logic";
|
|
2
|
+
export { formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, statTileClass, statTilesClass, STAT_TILE_PARTS, STAT_TILE_EMPTY, STAT_TILE_MINUS, type StatTile, type StatTileTone, };
|
|
3
|
+
export interface StatTilesProps {
|
|
4
|
+
/** The tiles, in display order. A tile with no `sub` renders no sub-caption — the atom never
|
|
5
|
+
* invents one (pass `STAT_TILE_EMPTY` if you want the placeholder line). */
|
|
6
|
+
tiles: readonly StatTile[];
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function StatTiles(props: StatTilesProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the stat-tile row (ds/components-stat-tiles.html): a wrapping row of
|
|
3
|
+
// bordered tiles, each an uppercase micro key, a big mono tabular-nums value and a mono
|
|
4
|
+
// sub-caption. `tone: "accent"` is the card's "brag" number; `warn`/`error` are the band tint.
|
|
5
|
+
//
|
|
6
|
+
// React twin of packages/preact-ui/src/StatTiles.tsx. Class derivation and the value formatters
|
|
7
|
+
// (with the "—" placeholder that must never become a fabricated `0`) come from
|
|
8
|
+
// `@mythicalos/ui-core`. Preact→React prop delta: `class` → `className`.
|
|
9
|
+
import { STAT_TILE_EMPTY, STAT_TILE_MINUS, formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, STAT_TILE_PARTS, statTileClass, statTilesClass, } from "@mythicalos/ui-core/logic";
|
|
10
|
+
export { formatStatCompact, formatStatCount, formatStatPercent, formatStatUsd, statTileClass, statTilesClass, STAT_TILE_PARTS, STAT_TILE_EMPTY, STAT_TILE_MINUS, };
|
|
11
|
+
export function StatTiles(props) {
|
|
12
|
+
const { className: cls = "" } = props;
|
|
13
|
+
// A JS caller can hand a non-array where the type says `StatTile[]` (an unloaded slot); render an
|
|
14
|
+
// empty row rather than crashing on `.map` — the same "absent is not a value" rule the
|
|
15
|
+
// formatters follow.
|
|
16
|
+
const tiles = Array.isArray(props.tiles) ? props.tiles : [];
|
|
17
|
+
return (_jsx("div", { className: `${statTilesClass()} ${cls}`, children: tiles.map((t, i) => (_jsxs("div", { className: statTileClass(t.tone), children: [_jsx("div", { className: STAT_TILE_PARTS.label, children: t.label }), _jsx("div", { className: STAT_TILE_PARTS.value, children: t.value }), t.sub !== undefined ? _jsx("div", { className: STAT_TILE_PARTS.sub, children: t.sub }) : null] }, `${i}:${t.label}`))) }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type TermRow, type TermSource, type TranscriptSegment } from "@mythicalos/ui-core/logic";
|
|
2
|
+
export { TERM_CLASS, TERM_CLASSES, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, type TermRow, type TermRowKind, type TermSource, type TermView, type TranscriptSegment, } from "@mythicalos/ui-core/logic";
|
|
3
|
+
export interface TerminalProps {
|
|
4
|
+
/** The sole transcript input. All six arms are rendered by ui-core's `transcriptView`. */
|
|
5
|
+
source: TermSource;
|
|
6
|
+
/** Title-line text left of ` · tail · noise {on|off}` (e.g. `jacob.jsonl`). */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** The noise FILTER: on ⇒ noise rows are HIDDEN and the title reads `noise on`. */
|
|
9
|
+
noiseFilterEnabled?: boolean;
|
|
10
|
+
/** Omit for a read-only pane — the title then renders as a caption, not a dead button. */
|
|
11
|
+
onToggleNoise?(): void;
|
|
12
|
+
/** Client-synthetic rows appended after the transcript (e.g. an interrupt marker). */
|
|
13
|
+
localRows?: readonly TermRow[];
|
|
14
|
+
/** Render only from the last `boundary` row on (the current wake). */
|
|
15
|
+
currentWakeOnly?: boolean;
|
|
16
|
+
history?: readonly TranscriptSegment[];
|
|
17
|
+
/** Shows the `wake unavailable` banner. Independent of the transcript branches. */
|
|
18
|
+
wakeUnavailable?: boolean;
|
|
19
|
+
/** The blinking stream caret. */
|
|
20
|
+
caret?: boolean;
|
|
21
|
+
/** Supplied truth only — `undefined` renders NO caption rather than guessing. */
|
|
22
|
+
turnInFlight?: boolean;
|
|
23
|
+
/** Omit to render no stop control at all. */
|
|
24
|
+
onStopTurn?(): void;
|
|
25
|
+
/** A stop is already in flight: the control is disabled and Ctrl-C is ignored. */
|
|
26
|
+
stopBusy?: boolean;
|
|
27
|
+
/** VISUAL weight for the stop control only — never a state claim. */
|
|
28
|
+
stopProminent?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function Terminal(props: TerminalProps): import("react").JSX.Element;
|
package/dist/Terminal.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// @mythicalos/react-ui — the terminal pane (ds/components-terminal, spec v2). React twin of
|
|
3
|
+
// packages/preact-ui/src/Terminal.tsx. ALWAYS heritage-dark in both themes (token rule 3): the
|
|
4
|
+
// surface is pinned by ui-core's `.my-term` block, never by a theme-conditional class here.
|
|
5
|
+
//
|
|
6
|
+
// Thin binding: render + wiring only. Every class string (`TERM_CLASSES`), every user-visible
|
|
7
|
+
// string, the six-branch source resolution, the segmentation, the row keying and the Ctrl-C
|
|
8
|
+
// predicate come from `@mythicalos/ui-core` — this file types no class literal and no copy of its
|
|
9
|
+
// own, so it and its Preact sibling cannot drift.
|
|
10
|
+
//
|
|
11
|
+
// Honesty (binding, inherited from ui-core): the wake-unavailable banner says exactly
|
|
12
|
+
// `wake unavailable` — never "reconnecting", never a retry spinner; the turn caption renders ONLY
|
|
13
|
+
// from a supplied boolean (`undefined` renders nothing, never a guessed "idle"); the six source
|
|
14
|
+
// branches keep loading honesty (`loading` never renders `(no events)`; missing/unaddressable/
|
|
15
|
+
// failed are distinct); foreign transcript segments appear ONLY behind the disclosure, each
|
|
16
|
+
// captioned and separated — never blended into the selected session's log.
|
|
17
|
+
import { useEffect, useRef, useState } from "react";
|
|
18
|
+
import { TERM_CLASS, TERM_CLASSES, TERM_NO_EVENTS_COPY, TERM_STALE_COPY, TERM_STOP_KEY_HINT, TERM_STOP_LABEL, TERM_WAKE_UNAVAILABLE_COPY, currentWakeRows, expandLabel, historySegmentCaption, historyToggleLabel, isExpandable, noiseShow, ROW_SCOPE, scopedRowKeys, segmentKeys, shouldStopOnKey, sourceRows, stopButtonClass, termRowClass, termTitleText, transcriptView, turnCaption, visibleRows, } from "@mythicalos/ui-core/logic";
|
|
19
|
+
export { TERM_CLASS, TERM_CLASSES, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, } from "@mythicalos/ui-core/logic";
|
|
20
|
+
function RowView(props) {
|
|
21
|
+
const { row } = props;
|
|
22
|
+
const label = row.label ? _jsx("span", { className: TERM_CLASSES.label, children: row.label }) : null;
|
|
23
|
+
if (!isExpandable(row)) {
|
|
24
|
+
return (_jsxs("div", { className: termRowClass(row.kind), children: [label, label ? " " : null, row.text] }));
|
|
25
|
+
}
|
|
26
|
+
return (_jsxs("div", { className: termRowClass(row.kind), children: [_jsxs("button", { type: "button", className: TERM_CLASSES.head, "aria-expanded": props.expanded, onClick: props.onToggle, children: [label, label ? " " : null, row.text, " ", _jsx("span", { className: TERM_CLASSES.expand, children: expandLabel(props.expanded) })] }), props.expanded ? _jsx("pre", { className: TERM_CLASSES.detail, children: row.detail }) : null] }));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A keyed run of rows. `scopedRowKeys` namespaces every key under its scope, so expand state can
|
|
30
|
+
* never leak between the transcript, the local rows and a history segment — not even when a caller
|
|
31
|
+
* supplies an id that looks like another scope's prefix.
|
|
32
|
+
*/
|
|
33
|
+
function RowList(props) {
|
|
34
|
+
const keys = scopedRowKeys(props.scope, props.rows);
|
|
35
|
+
return (_jsx(_Fragment, { children: props.rows.map((row, i) => {
|
|
36
|
+
const key = keys[i];
|
|
37
|
+
return _jsx(RowView, { row: row, expanded: props.expanded.has(key), onToggle: () => props.onToggle(key) }, key);
|
|
38
|
+
}) }));
|
|
39
|
+
}
|
|
40
|
+
export function Terminal(props) {
|
|
41
|
+
const { source, noiseFilterEnabled = false } = props;
|
|
42
|
+
const history = props.history ?? [];
|
|
43
|
+
const [expanded, setExpanded] = useState(new Set());
|
|
44
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
45
|
+
const paneRef = useRef(null);
|
|
46
|
+
const toggle = (key) => setExpanded((prev) => {
|
|
47
|
+
const next = new Set(prev);
|
|
48
|
+
if (next.has(key))
|
|
49
|
+
next.delete(key);
|
|
50
|
+
else
|
|
51
|
+
next.add(key);
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
const stopAvailable = props.onStopTurn !== undefined;
|
|
55
|
+
const { onStopTurn, stopBusy } = props;
|
|
56
|
+
// Focused Ctrl-C → stop-turn. The predicate (key-repeat drop, selection guard, focus guard) is
|
|
57
|
+
// ui-core's; the availability + in-flight guards live here. Effects do not run under SSR.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!stopAvailable)
|
|
60
|
+
return;
|
|
61
|
+
const onKey = (e) => {
|
|
62
|
+
if (stopBusy)
|
|
63
|
+
return;
|
|
64
|
+
const sel = window.getSelection?.()?.toString() ?? "";
|
|
65
|
+
const pane = paneRef.current;
|
|
66
|
+
const focused = !!pane && pane.contains(document.activeElement);
|
|
67
|
+
if (shouldStopOnKey(e, sel, focused)) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
onStopTurn?.();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
window.addEventListener("keydown", onKey);
|
|
73
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
74
|
+
}, [stopAvailable, onStopTurn, stopBusy]);
|
|
75
|
+
const view = transcriptView(source);
|
|
76
|
+
const showNoise = noiseShow(noiseFilterEnabled);
|
|
77
|
+
const allRows = props.currentWakeOnly ? currentWakeRows(sourceRows(source)) : sourceRows(source);
|
|
78
|
+
const rows = view.kind === "log" ? visibleRows(allRows, showNoise) : [];
|
|
79
|
+
const localRows = view.kind === "log" ? visibleRows(props.localRows ?? [], showNoise) : [];
|
|
80
|
+
const emptyLog = view.kind === "log" && rows.length === 0 && localRows.length === 0;
|
|
81
|
+
const segKeys = segmentKeys(history);
|
|
82
|
+
const titleText = termTitleText(props.name, noiseFilterEnabled);
|
|
83
|
+
const caption = turnCaption(props.turnInFlight);
|
|
84
|
+
return (_jsxs("div", { className: TERM_CLASS, children: [_jsxs("div", { className: TERM_CLASSES.titlebar, children: [_jsxs("span", { className: TERM_CLASSES.lights, "aria-hidden": "true", children: [_jsx("span", { className: TERM_CLASSES.lightRed }), _jsx("span", { className: TERM_CLASSES.lightAmber }), _jsx("span", { className: TERM_CLASSES.lightGreen })] }), props.onToggleNoise ? (_jsx("button", { type: "button", className: TERM_CLASSES.noiseToggle, "aria-pressed": noiseFilterEnabled, onClick: props.onToggleNoise, children: titleText })) : (_jsx("span", { className: TERM_CLASSES.staticTitle, children: titleText })), _jsxs("span", { className: TERM_CLASSES.titleRight, children: [caption !== null ? (props.turnInFlight ? (_jsxs("span", { className: TERM_CLASSES.turn, children: [_jsx("span", { className: TERM_CLASSES.turnDot, "aria-hidden": "true" }), caption] })) : (_jsx("span", { className: TERM_CLASSES.idle, children: caption }))) : null, stopAvailable ? (_jsxs("button", { type: "button", className: stopButtonClass(!!props.stopProminent), disabled: props.stopBusy, onClick: props.onStopTurn, children: [TERM_STOP_LABEL, " ", _jsx("span", { className: TERM_CLASSES.stopKey, children: TERM_STOP_KEY_HINT })] })) : null] })] }), _jsxs("div", { ref: paneRef, className: TERM_CLASSES.body, tabIndex: 0, children: [props.wakeUnavailable ? (_jsxs("div", { className: TERM_CLASSES.banner, children: [_jsx("span", { className: TERM_CLASSES.bannerDot, "aria-hidden": "true" }), TERM_WAKE_UNAVAILABLE_COPY] })) : null, view.kind === "log" && view.stale ? (_jsxs("div", { className: TERM_CLASSES.banner, children: [_jsx("span", { className: TERM_CLASSES.bannerDot, "aria-hidden": "true" }), TERM_STALE_COPY] })) : null, history.length > 0 ? (_jsx("button", { type: "button", className: TERM_CLASSES.more, onClick: () => setShowHistory((s) => !s), children: historyToggleLabel(showHistory, history.length) })) : null, showHistory
|
|
85
|
+
? history.map((seg, si) => (
|
|
86
|
+
// keyed by the segment's own identity, not its position, so reordering the list moves
|
|
87
|
+
// neither the rendered element nor its rows' expand state
|
|
88
|
+
_jsxs("div", { className: TERM_CLASSES.segment, children: [_jsx("div", { className: TERM_CLASSES.segmentCaption, children: seg.caption ?? historySegmentCaption(seg.id) }), _jsx(RowList, { rows: visibleRows(seg.rows, showNoise), scope: [ROW_SCOPE.history, segKeys[si]], expanded: expanded, onToggle: toggle })] }, segKeys[si])))
|
|
89
|
+
: null, view.kind === "state" ? _jsx("div", { className: TERM_CLASSES.state, children: view.copy }) : null, emptyLog ? _jsx("div", { className: TERM_CLASSES.state, children: TERM_NO_EVENTS_COPY }) : null, _jsx(RowList, { rows: rows, scope: [ROW_SCOPE.transcript], expanded: expanded, onToggle: toggle }), _jsx(RowList, { rows: localRows, scope: [ROW_SCOPE.local], expanded: expanded, onToggle: toggle }), props.caret ? _jsx("span", { className: TERM_CLASSES.caret, "aria-hidden": "true" }) : null] })] }));
|
|
90
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,3 +13,13 @@ export { StatusLine, statusLineClass, type StatusLineProps, type StatusTone } fr
|
|
|
13
13
|
export { SearchInput, type SearchInputProps } from "./SearchInput.js";
|
|
14
14
|
export { Banner, bannerClass, BANNER_ICON, type BannerProps, type BannerTone } from "./Banner.js";
|
|
15
15
|
export { Gauge, gaugeTone, gaugeGeom, type GaugeProps, type GaugeGeom, type Tone } from "./Gauge.js";
|
|
16
|
+
export { SaveBar, saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, type SaveBarProps, type SaveBarNote, } from "./SaveBar.js";
|
|
17
|
+
export { StatTiles, statTilesClass, statTileClass, STAT_TILE_PARTS, formatStatCompact, formatStatCount, formatStatUsd, formatStatPercent, STAT_TILE_EMPTY, STAT_TILE_MINUS, type StatTilesProps, type StatTile, type StatTileTone, } from "./StatTiles.js";
|
|
18
|
+
export { GitChip, gitFlags, hasGitStatus, gitBranchLabel, gitChipClass, gitFlagClass, gitChipNote, GIT_CHIP_PARTS, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_DETACHED_LABEL, GIT_CLEAN_LABEL, GIT_LOADING_NOTE, GIT_UNAVAILABLE_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, type GitChipProps, type GitStatus, type GitFlag, type GitFlagTone, } from "./GitChip.js";
|
|
19
|
+
export { Popover, POPOVER_CARET, POPOVER_CHECK, POPOVER_EMPTY_VALUE, popoverTriggerClass, popoverPanelClass, popoverItemClass, popoverTriggerText, resolvePopoverPosition, type PopoverProps, type PopoverItem, type PopoverPosition, } from "./Popover.js";
|
|
20
|
+
export { FileTree, FilePreview, FileScopePicker, deriveFileTreeRows, countFileRows, countLoadedFiles, previewMeta, previewBodyMode, buildBreadcrumb, breadcrumbSegments, formatFileSize, formatRelativeTime, type FileTreeProps, type FilePreviewProps, type FileScopePickerProps, type FileTreeMode, type FileTreeRootSpec, type FileTreeRow, type FileScopeOption, type DirState, type FilePreviewState, type PreviewBadgeState, type GitMark, } from "./FileExplorer.js";
|
|
21
|
+
export { SessionCard, ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionStatusText, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionSubline, spineNodeClass, type SessionCardProps, } from "./SessionCard.js";
|
|
22
|
+
export type { CtxThresholds, CtxBand, CtxBarTick, CtxBarGeom, SessionLifecycle, SessionActivity, SessionStatusTone, SessionStatusKey, SessionStatus, SessionStatusInput, SessionSpine, SpineNode, SpineSummary, } from "@mythicalos/ui-core/logic";
|
|
23
|
+
export { Terminal, TERM_CLASS, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, type TerminalProps, type TranscriptSegment, type TermRow, type TermRowKind, type TermSource, type TermView, } from "./Terminal.js";
|
|
24
|
+
export { QueuePanel, QueueRow, QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, type QueuePanelProps, type QueueRowProps, type CancelEvent, type CancelState, type QueueItem, type QueueItemStatus, type QueueSource, type QueueUnavailableReason, type QueueView, } from "./QueuePanel.js";
|
|
25
|
+
export { SendBar, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, sendPlaceholder, showDeliveryHint, type SendBarProps, type DeliveryClass, type KeyAction, } from "./SendBar.js";
|
package/dist/index.js
CHANGED
|
@@ -20,3 +20,17 @@ export { StatusLine, statusLineClass } from "./StatusLine.js";
|
|
|
20
20
|
export { SearchInput } from "./SearchInput.js";
|
|
21
21
|
export { Banner, bannerClass, BANNER_ICON } from "./Banner.js";
|
|
22
22
|
export { Gauge, gaugeTone, gaugeGeom } from "./Gauge.js";
|
|
23
|
+
// ── small atoms graduated out of a single product (save-bar / stat-tiles / git-chip cards) ──
|
|
24
|
+
export { SaveBar, saveBarNote, saveBarDirty, saveBarClass, SAVE_BAR_PARTS, SAVE_BAR_SEP, SAVE_BAR_DISCARD_LABEL, SAVE_BAR_SAVE_LABEL, } from "./SaveBar.js";
|
|
25
|
+
export { StatTiles, statTilesClass, statTileClass, STAT_TILE_PARTS, formatStatCompact, formatStatCount, formatStatUsd, formatStatPercent, STAT_TILE_EMPTY, STAT_TILE_MINUS, } from "./StatTiles.js";
|
|
26
|
+
export { GitChip, gitFlags, hasGitStatus, gitBranchLabel, gitChipClass, gitFlagClass, gitChipNote, GIT_CHIP_PARTS, GIT_BRANCH_GLYPH, GIT_BRANCH_UNKNOWN, GIT_DETACHED_LABEL, GIT_CLEAN_LABEL, GIT_LOADING_NOTE, GIT_UNAVAILABLE_NOTE, GIT_STALE_LABEL, GIT_STALE_TITLE, } from "./GitChip.js";
|
|
27
|
+
// ── dropdown popover (ds/components-popover — registry row `popover` v1) ──
|
|
28
|
+
export { Popover, POPOVER_CARET, POPOVER_CHECK, POPOVER_EMPTY_VALUE, popoverTriggerClass, popoverPanelClass, popoverItemClass, popoverTriggerText, resolvePopoverPosition, } from "./Popover.js";
|
|
29
|
+
// ── file explorer & markdown preview (ds/components-file-explorer) ──
|
|
30
|
+
export { FileTree, FilePreview, FileScopePicker, deriveFileTreeRows, countFileRows, countLoadedFiles, previewMeta, previewBodyMode, buildBreadcrumb, breadcrumbSegments, formatFileSize, formatRelativeTime, } from "./FileExplorer.js";
|
|
31
|
+
// ── session-card (design registry `session-card` v1) ──
|
|
32
|
+
export { SessionCard, ctxBand, ctxReading, ctxBarGeom, normalizeCtxThresholds, ctxMeterClass, ctxNoteText, ctxValueText, sessionAvatarInitial, sessionCardClass, sessionCardIsStale, sessionCardStale, sessionStatusText, sessionSpineSummary, sessionStatus, sessionStatusClass, sessionSubline, spineNodeClass, } from "./SessionCard.js";
|
|
33
|
+
// ── terminal set (ds/components-terminal v2): terminal · queue-row · send-bar ──
|
|
34
|
+
export { Terminal, TERM_CLASS, TERM_STALE_COPY, TERM_WAKE_UNAVAILABLE_COPY, transcriptView, } from "./Terminal.js";
|
|
35
|
+
export { QueuePanel, QueueRow, QUEUE_EMPTY_COPY, QUEUE_LOADING_COPY, QUEUE_STALE_COPY, QUEUE_UNAVAILABLE_COPY, canCancelRow, cancelReducer, queueBadgeClass, queueBadgeLabel, queueRowClass, queueView, shouldDisarmCancel, } from "./QueuePanel.js";
|
|
36
|
+
export { SendBar, DELIVERY_CLASSES, DELIVERY_HINT, SEND_DISABLED_FALLBACK, SEND_PLACEHOLDER, canSend, clearDraftOnSend, deliveryClassLabel, keyAction, sendPlaceholder, showDeliveryHint, } from "./SendBar.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mythicalos/react-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "mythicalOS thin React bindings over @mythicalos/ui-core — Button, Input/Toggle/Checkbox, MaskedSecretInput, EmptyState, ConfirmDialog/Scrim, Toast/ToastProvider, Chip, Card, Avatar, StatusLine, SearchInput, Banner, Gauge, and the usePoll/useInterval hooks. Render + framework wiring only — every class string, poll-scheduling math, glyph map, and text composition is derived by @mythicalos/ui-core so this binding and its Preact sibling can never drift. Apache-2.0.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@mythicalos/ui-core": "^0.
|
|
39
|
+
"@mythicalos/ui-core": "^0.3.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -p tsconfig.build.json",
|