@mythicalos/preact-ui 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ import "@mythicalos/ui-core/styles.css"; // this package ships NO CSS of its own
34
34
 
35
35
  - `Button` — 5 variants × 6 states, loading spinner, inert+aria-busy.
36
36
  - `Input`, `Toggle`, `Checkbox` — inputs with the "unconfigured is a valid state" neutral empty.
37
+ `<Input type="password" revealable>` adds a show/hide toggle inside the field (see below).
37
38
  - `MaskedSecretInput` — presence-only secret slots; the value never round-trips to the UI.
38
39
  - `EmptyState` — the designed empty moments (spine motif; never an error tone).
39
40
  - `ConfirmDialog` (+ `typedNameMatches`), `Scrim` — modal danger confirms (Esc cancels, safe action
@@ -51,11 +52,45 @@ import "@mythicalos/ui-core/styles.css"; // this package ships NO CSS of its own
51
52
  `statusLineClass`, `bannerClass`, `BANNER_ICON`, `gaugeTone`, `gaugeGeom`, `nextPollDelay`,
52
53
  `makePollEpochGuard`, `runPollTick`, `POLL_JITTER_RATIO`, `POLL_BACKOFF_CAP_MS`.
53
54
 
55
+ ## `revealable` — the show/hide affordance for a secret field (0.3.0)
56
+
57
+ ```tsx
58
+ <Input label="UI token" type="password" revealable mono placeholder="paste your token…" />
59
+ ```
60
+
61
+ Opt-in and inert everywhere else: without `revealable`, or on any type other than `password`, the
62
+ markup is byte-identical to what this component rendered before the prop existed. When it is on:
63
+
64
+ - the field starts **hidden** — revealing is always an explicit act, and no consumer can force it
65
+ open (the state is held inside the component);
66
+ - the toggle is a real `<button type="button">` in normal tab order, no `tabindex`, with
67
+ `aria-pressed` for the state and an `aria-label` for the action (`Show token` / `Hide token`);
68
+ - revealing swaps only the input's own `type`; the value is never copied into any other node,
69
+ attribute or `aria-*` string;
70
+ - the field carries `autocomplete="new-password"`, not the atom's usual `off` — browsers largely
71
+ ignore `off` on a password field and will autofill it and offer to save it as a login;
72
+ - the component never takes an id from preact's shared `useId` sequence, so installing this
73
+ version cannot renumber the ids of anything else in your tree;
74
+ - `revealLabels={{ show, hide }}` renames the toggle for a field that is not a token (the default
75
+ wording is `Show token` / `Hide token`);
76
+ - because a `<button>` may not sit inside a `<label>`, this path pairs label and control by
77
+ `for`/`id` instead of wrapping (an `id` is generated if you don't pass one);
78
+ - flipping `revealable` or `type` re-renders the field, it does not remount it — a focused input
79
+ keeps its focus, caret and selection — and any such flip returns the field to hidden, so a
80
+ secret revealed before the flip is never in the clear after it;
81
+ - the generated id is namespaced (`mythicalos-input-N`). It is only ever used to pair this
82
+ field's own label with its own input, so both sides always agree; pass `id` yourself if you
83
+ server-render and want a specific one.
84
+
85
+ Requires `@mythicalos/ui-core` **≥ 0.2.0** for the toggle's styles (`.input-reveal`,
86
+ `.input-reveal__btn`).
87
+
54
88
  ## Styles
55
89
 
56
90
  This package ships **no stylesheet**. Serve `@mythicalos/ui-core/styles.css` (after
57
91
  `@mythicalos/tokens`) — it carries every class this package's components emit, including the 7 new
58
- atoms (`.my-chip`, `.my-status`, `.my-card`, `.my-avatar`, `.my-search`, `.my-banner`, `.my-gauge`).
92
+ atoms (`.my-chip`, `.my-status`, `.my-card`, `.my-avatar`, `.my-search`, `.my-banner`, `.my-gauge`)
93
+ and the reveal affordance (`.input-reveal`).
59
94
 
60
95
  ## Provenance
61
96
 
@@ -0,0 +1,75 @@
1
+ /** @jsxImportSource preact */
2
+ import type { ComponentChildren } from "preact";
3
+ 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";
4
+ 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, };
5
+ export interface FileScopePickerProps {
6
+ /** The offered scopes. With fewer than two the picker renders a STATIC label — there is nothing
7
+ * to choose, so no control is offered. */
8
+ options: readonly FileScopeOption[];
9
+ activeKey: string;
10
+ /** The active scope's tree mode — drives the trigger glyph (⌂ all-mounts · ◈ project). */
11
+ mode: FileTreeMode;
12
+ open: boolean;
13
+ onToggle?: (next: boolean) => void;
14
+ onSelect?: (key: string) => void;
15
+ /** Fired on Escape so the caller can close AND restore focus to wherever it wants it. */
16
+ onDismiss?: () => void;
17
+ label?: string;
18
+ }
19
+ /**
20
+ * A plain disclosure of buttons — deliberately NOT an ARIA menu/listbox: those roles promise
21
+ * arrow-key behavior this does not implement, and claiming them would be a lie to a screen reader.
22
+ * Each option is a natively Tab/Enter-operable button.
23
+ */
24
+ export declare function FileScopePicker(props: FileScopePickerProps): import("preact").JSX.Element;
25
+ export interface FileTreeProps {
26
+ mode: FileTreeMode;
27
+ roots: readonly FileTreeRootSpec[];
28
+ /** Every known directory listing, keyed by `nodeId(rootKey, relPath)`. An absent key reads as
29
+ * "not fetched yet" and draws a Loading… row, never an empty one. */
30
+ dirs: Readonly<Record<string, DirState>>;
31
+ expanded: ReadonlySet<string>;
32
+ selectedId?: string | null;
33
+ /** Git marks keyed by node id. An absent entry renders NO mark — never a fabricated clean. */
34
+ marks?: Readonly<Record<string, GitMark>>;
35
+ title?: string;
36
+ /** The header count. Defaults to the honest count of files actually listed so far. */
37
+ count?: number;
38
+ branch?: string;
39
+ /** The scope picker (or anything else) for the rail header. */
40
+ scope?: ComponentChildren;
41
+ onToggleDir?: (node: {
42
+ rootKey: string;
43
+ relPath: string;
44
+ id: string;
45
+ }) => void;
46
+ onSelectFile?: (node: {
47
+ rootKey: string;
48
+ relPath: string;
49
+ id: string;
50
+ name: string;
51
+ }) => void;
52
+ class?: string;
53
+ }
54
+ export declare function FileTree(props: FileTreeProps): import("preact").JSX.Element;
55
+ export interface FilePreviewProps {
56
+ /** The previewed file's name. Render this component only when a file IS selected; the
57
+ * no-selection placeholder belongs to the page, not to the component. */
58
+ name: string;
59
+ /** The breadcrumb head — the card: the breadcrumb ALWAYS includes the root. */
60
+ rootLabel: string;
61
+ relPath: string;
62
+ state: FilePreviewState;
63
+ /** The header pill. `null` (the default) renders NO pill — never a fabricated "unchanged". */
64
+ badge?: PreviewBadgeState;
65
+ /** Listing-derived fallbacks for the header, used where the state carries none. */
66
+ bytes?: number;
67
+ mtime?: number;
68
+ /** Injected for deterministic relative times. */
69
+ now?: number;
70
+ /** Renders markdown to nodes. Absent ⇒ a `.md` file falls back to plain mono text rather than
71
+ * this component inventing a renderer (or, worse, injecting raw HTML). */
72
+ renderMarkdown?: (text: string) => ComponentChildren;
73
+ class?: string;
74
+ }
75
+ export declare function FilePreview(props: FilePreviewProps): import("preact").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/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
+ /**
5
+ * A plain disclosure of buttons — deliberately NOT an ARIA menu/listbox: those roles promise
6
+ * arrow-key behavior this does not implement, and claiming them would be a lie to a screen reader.
7
+ * Each option is a natively Tab/Enter-operable button.
8
+ */
9
+ export function FileScopePicker(props) {
10
+ const { options, activeKey, mode, open, onToggle, onSelect, onDismiss, label } = props;
11
+ const active = options.find((o) => o.key === activeKey);
12
+ const text = label ?? active?.label ?? "";
13
+ const glyph = scopeGlyph(mode);
14
+ const selectable = options.length > 1;
15
+ return (_jsxs("div", { class: "my-files__scope", onKeyDown: (e) => {
16
+ if (e.key === "Escape" && open)
17
+ onDismiss?.();
18
+ }, children: [selectable ? (_jsxs("button", { type: "button", class: "my-files__scope-btn", "aria-expanded": open, onClick: () => onToggle?.(!open), children: [_jsx("span", { class: "my-files__scope-glyph", "aria-hidden": "true", children: glyph }), _jsx("span", { class: "my-files__scope-label", children: text }), _jsx("span", { class: "my-files__scope-caret", "aria-hidden": "true", children: SCOPE_CARET })] })) : (_jsxs("div", { class: "my-files__scope-static", children: [_jsx("span", { class: "my-files__scope-glyph", "aria-hidden": "true", children: glyph }), _jsx("span", { class: "my-files__scope-label", children: text })] })), open && selectable ? (_jsx("div", { class: "my-files__scope-menu", children: options.map((o) => (_jsxs("div", { children: [_jsxs("button", { type: "button", class: scopeItemClass(o.key === activeKey), "aria-current": o.key === activeKey, onClick: () => onSelect?.(o.key), children: [_jsx("span", { class: "my-files__scope-it-name", children: o.label }), _jsx("span", { class: "my-files__scope-it-count", children: o.count })] }), o.dividerAfter === true ? _jsx("div", { class: "my-files__scope-divider", "aria-hidden": "true" }) : null] }, o.key))) })) : null] }));
19
+ }
20
+ export function FileTree(props) {
21
+ const { mode, roots, dirs, expanded, selectedId = null, marks, title = "Files", count, branch, scope, onToggleDir, onSelectFile } = props;
22
+ const rows = deriveFileTreeRows({ mode, roots, dirs, expanded, selectedId, marks });
23
+ // the rendered-row count — provably what the operator sees (countLoadedFiles is a
24
+ // different, cache-wide number; see its doc)
25
+ const total = count ?? countFileRows(rows);
26
+ return (_jsxs("div", { class: props.class === undefined ? "my-files__rail" : `my-files__rail ${props.class}`, children: [_jsxs("div", { class: "my-files__hd", children: [_jsxs("div", { class: "my-files__ti", children: [_jsx("span", { children: title }), _jsxs("span", { class: "my-files__count", children: ["\u00B7 ", total] }), branch === undefined ? null : (_jsxs("span", { class: "my-files__branch", children: [_jsx("span", { class: "my-files__branch-dot", "aria-hidden": "true" }), branch] }))] }), scope] }), _jsx("div", { class: "my-files__tree", children: rows.map((row) => renderRow(row, onToggleDir, onSelectFile)) })] }));
27
+ }
28
+ function renderRow(row, onToggleDir, onSelectFile) {
29
+ if (row.type === "note") {
30
+ return (_jsx("div", { class: row.className, children: row.text }, row.id));
31
+ }
32
+ if (row.type === "dir") {
33
+ // The WHOLE ROW is the hit target — the chevron is the affordance, not a separate control.
34
+ return (_jsxs("button", { type: "button", class: row.className, "aria-expanded": row.open, onClick: () => onToggleDir?.({ rootKey: row.rootKey, relPath: row.relPath, id: row.id }), children: [_jsx("span", { class: "my-files__chev", "aria-hidden": "true", children: row.chevron }), _jsx("span", { class: "my-files__glyph", "aria-hidden": "true", children: row.glyph }), _jsx("span", { class: "my-files__name", children: row.name }), row.badges.map((b) => (_jsx("span", { class: badgeClass(b.tone), children: b.text }, b.text)))] }, row.id));
35
+ }
36
+ return (_jsxs("button", { type: "button", class: row.className, "aria-current": row.selected, onClick: () => onSelectFile?.({ rootKey: row.rootKey, relPath: row.relPath, id: row.id, name: row.name }), children: [_jsx("span", { class: "my-files__spacer", "aria-hidden": "true" }), _jsx("span", { class: "my-files__fglyph", "aria-hidden": "true", children: row.glyph }), _jsx("span", { class: "my-files__fname", children: row.name }), row.mark === null ? null : (
37
+ // role="img" so the aria-label IS the accessible name — a bare "M" would otherwise be
38
+ // announced as the opaque glyph rather than "modified".
39
+ _jsx("span", { class: gitMarkClass(row.mark), role: "img", title: gitMarkLabel(row.mark), "aria-label": gitMarkLabel(row.mark), children: row.mark }))] }, row.id));
40
+ }
41
+ export function FilePreview(props) {
42
+ const { name, rootLabel, relPath, state, badge = null, now, renderMarkdown } = props;
43
+ const meta = previewMeta(state, { bytes: props.bytes, mtime: props.mtime });
44
+ const pill = previewBadge(badge);
45
+ return (_jsxs("div", { class: props.class === undefined ? "my-files__prev" : `my-files__prev ${props.class}`, children: [_jsxs("div", { class: "my-files__prev-hd", children: [_jsxs("div", { class: "my-files__prev-main", children: [_jsx("div", { class: "my-files__crumb", children: buildBreadcrumb(rootLabel, relPath) }), _jsxs("div", { class: "my-files__name-row", children: [_jsx("b", { class: "my-files__prev-name", children: name }), pill === null ? null : _jsx("span", { class: previewBadgeClass(pill.tone), children: pill.text })] })] }), _jsxs("div", { class: "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", { class: "my-files__prev-bd", children: previewBody(name, state, renderMarkdown) })] }));
46
+ }
47
+ /** The body for the current state. Every non-`text` branch is a note — including all four honest
48
+ * states, each with its own distinct class and none styled as a failure. */
49
+ function previewBody(name, state, renderMarkdown) {
50
+ // ONE decision, made in ui-core — this binding never re-derives it.
51
+ const mode = previewBodyMode(name, state, renderMarkdown !== undefined);
52
+ if (mode === "note")
53
+ return _jsx("div", { class: previewNoteClass(state) ?? "", children: previewNoteText(state) });
54
+ // "note" covers every non-text state, so the text branch is what remains
55
+ const text = state.text;
56
+ if (mode === "markdown")
57
+ return _jsx("div", { class: "my-files__md", children: renderMarkdown(text) });
58
+ return _jsx("pre", { class: "my-files__pre", children: text });
59
+ }
@@ -0,0 +1,18 @@
1
+ /** @jsxImportSource preact */
2
+ 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";
3
+ 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, };
4
+ export interface GitChipProps {
5
+ /** The reported status. Absent — `undefined` OR `null`, so a "no data yet" slot can be passed
6
+ * straight through — ⇒ the honest unavailable arm, never a fabricated clean tree. */
7
+ status?: GitStatus | null;
8
+ /** Human copy for why there is no status. The product maps its own reason codes to this
9
+ * sentence; the atom carries no reason vocabulary of its own. */
10
+ unavailableNote?: string;
11
+ /** The first read has not returned yet — only used when there is no `status` and no note. */
12
+ loading?: boolean;
13
+ /** The shown status is RETAINED while the live read is failing: stale, not current. Flagged on
14
+ * both arms. */
15
+ stale?: boolean;
16
+ class?: string;
17
+ }
18
+ export declare function GitChip(props: GitChipProps): import("preact").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
+ /** @jsxImportSource preact */
3
+ // @mythicalos/preact-ui — the git status chip (ds/components-git-chip.html): a bordered inline
4
+ // mono chip carrying `⎇ <branch>` and honest counter flags (warn-soft behind/uncommitted,
5
+ // error-soft unpushed, ok-soft clean).
6
+ //
7
+ // Extracted from the reference implementation in the reference product's session detail pane. Its
8
+ // `available:false` reason ENUM and copy table were one daemon's wire vocabulary; here the product
9
+ // maps its own reason to a sentence and passes it as `unavailableNote`. The honesty contract is
10
+ // design, not product, and is preserved by `@mythicalos/ui-core`'s `gitFlags`/`gitBranchLabel`:
11
+ // an absent status renders the unavailable arm, `null` behind/unpushed OMIT their flag (never
12
+ // "0 behind"), a `null` branch reads "detached HEAD", and a status that never reported its counters
13
+ // is NEVER collapsed into a green "clean ✓".
14
+ 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";
15
+ 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, };
16
+ export function GitChip(props) {
17
+ const { status, stale = false, class: cls = "" } = props;
18
+ const staleMark = stale ? (_jsx("span", { class: GIT_CHIP_PARTS.stale, title: GIT_STALE_TITLE, children: GIT_STALE_LABEL })) : null;
19
+ if (hasGitStatus(status)) {
20
+ const flags = gitFlags(status);
21
+ return (_jsxs("span", { class: `${gitChipClass({ stale })} ${cls}`, children: [_jsxs("span", { class: GIT_CHIP_PARTS.branch, children: [GIT_BRANCH_GLYPH, " ", gitBranchLabel(status.branch)] }), flags.length > 0 ? (_jsx("span", { class: GIT_CHIP_PARTS.flags, children: flags.map((f) => (_jsx("span", { class: gitFlagClass(f.tone), children: f.label }, f.label))) })) : null, staleMark] }));
22
+ }
23
+ const note = gitChipNote({ unavailableNote: props.unavailableNote, loading: props.loading });
24
+ return (_jsxs("span", { class: `${gitChipClass({ unavailable: true, stale })} ${cls}`, children: [_jsxs("span", { class: GIT_CHIP_PARTS.branch, title: note, children: [GIT_BRANCH_GLYPH, " ", GIT_BRANCH_UNKNOWN] }), _jsx("span", { class: GIT_CHIP_PARTS.note, children: note }), staleMark] }));
25
+ }
package/dist/Input.d.ts CHANGED
@@ -12,9 +12,62 @@ export interface InputProps {
12
12
  dirty?: boolean;
13
13
  type?: string;
14
14
  id?: string;
15
+ /**
16
+ * Opt-in show/hide affordance for a secret field. Honored only for `type="password"`; every
17
+ * other type (and the default, `revealable` absent/false) renders exactly what it always did.
18
+ * The field starts hidden — revealing is always an explicit act.
19
+ */
20
+ revealable?: boolean;
21
+ /**
22
+ * Overrides the reveal toggle's accessible name for a field that is not a token — e.g.
23
+ * `{ show: "Show API key", hide: "Hide API key" }`. Defaults to the token wording.
24
+ */
25
+ revealLabels?: RevealLabels;
15
26
  onInput?: (value: string) => void;
16
27
  onKeyDown?: (e: JSX.TargetedKeyboardEvent<HTMLInputElement>) => void;
17
28
  }
29
+ export interface RevealLabels {
30
+ show: string;
31
+ hide: string;
32
+ }
33
+ /** The reveal toggle's default accessible name, per state. Exported for the tests + any consumer
34
+ * that needs to find the control by name. */
35
+ export declare const REVEAL_SHOW_LABEL = "Show token";
36
+ export declare const REVEAL_HIDE_LABEL = "Hide token";
37
+ export declare const REVEAL_LABELS: RevealLabels;
38
+ export interface RevealToggleProps {
39
+ /** True while the secret is shown in the clear. */
40
+ revealed: boolean;
41
+ disabled?: boolean;
42
+ labels?: RevealLabels;
43
+ onToggle: () => void;
44
+ }
45
+ export declare function RevealToggle({ revealed, disabled, labels, onToggle }: RevealToggleProps): JSX.Element;
46
+ export interface InputBodyProps extends InputProps {
47
+ /** Reveal state, lifted out of `Input` so a DOM-free test can render BOTH states. */
48
+ revealed: boolean;
49
+ onToggleReveal: () => void;
50
+ /** Fallback id for the reveal path's explicit label/control pairing (see below). */
51
+ autoId: string;
52
+ }
53
+ /**
54
+ * `Input`'s hook-free body — exactly the markup `Input` renders, with the reveal state passed in
55
+ * instead of held. Split out for the same reason `SwitcherPanel` is in the shell package: this
56
+ * codebase's bun:test environment has no DOM, so a state-driven variant can only be reached by
57
+ * rendering the hook-free piece directly, and the toggle's real `onClick` closure can only be
58
+ * invoked by calling this as a plain function. Not part of the package's barrel.
59
+ */
60
+ export declare function InputBody(props: InputBodyProps): JSX.Element;
61
+ /** True when the field is a secret that opted into the reveal affordance. */
62
+ export declare function isRevealMode(props: Pick<InputProps, "revealable" | "type">): boolean;
63
+ /**
64
+ * The reveal state to render this pass.
65
+ *
66
+ * ANY change of the field's mode clears it. A field that stops being a revealable secret and
67
+ * later becomes one again must come back hidden: the alternative is a password rendered in the
68
+ * clear that no one asked to see, on a render the user never touched.
69
+ */
70
+ export declare function resolveRevealed(stored: boolean, storedMode: boolean, mode: boolean): boolean;
18
71
  export declare function Input(props: InputProps): JSX.Element;
19
72
  export interface ToggleProps {
20
73
  on: boolean;
package/dist/Input.js CHANGED
@@ -1,5 +1,37 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "preact/jsx-runtime";
2
- export function Input(props) {
2
+ import { useRef, useState } from "preact/hooks";
3
+ /** The reveal toggle's default accessible name, per state. Exported for the tests + any consumer
4
+ * that needs to find the control by name. */
5
+ export const REVEAL_SHOW_LABEL = "Show token";
6
+ export const REVEAL_HIDE_LABEL = "Hide token";
7
+ export const REVEAL_LABELS = { show: REVEAL_SHOW_LABEL, hide: REVEAL_HIDE_LABEL };
8
+ /**
9
+ * The show/hide button `Input` renders inside a `revealable` password field. A real
10
+ * `<button type="button">` — focusable in source order, no tabindex games — carrying the
11
+ * state on `aria-pressed` and the action in its `aria-label`. It never receives, holds or
12
+ * renders the field's value.
13
+ */
14
+ /** A caller-supplied name, or the default when it is not a usable one. Runtime-checked: this is a
15
+ * public prop of a published package, and a blank or missing override would leave the toggle
16
+ * named only by its two-letter visible text — or not at all. */
17
+ function revealName(supplied, fallback) {
18
+ return typeof supplied === "string" && supplied.trim().length > 0 ? supplied : fallback;
19
+ }
20
+ export function RevealToggle({ revealed, disabled, labels, onToggle }) {
21
+ const names = {
22
+ show: revealName(labels?.show, REVEAL_SHOW_LABEL),
23
+ hide: revealName(labels?.hide, REVEAL_HIDE_LABEL),
24
+ };
25
+ return (_jsx("button", { type: "button", class: "input-reveal__btn", "aria-label": revealed ? names.hide : names.show, "aria-pressed": revealed ? "true" : "false", disabled: disabled, onClick: disabled ? undefined : onToggle, children: revealed ? "hide" : "show" }));
26
+ }
27
+ /**
28
+ * `Input`'s hook-free body — exactly the markup `Input` renders, with the reveal state passed in
29
+ * instead of held. Split out for the same reason `SwitcherPanel` is in the shell package: this
30
+ * codebase's bun:test environment has no DOM, so a state-driven variant can only be reached by
31
+ * rendering the hook-free piece directly, and the toggle's real `onClick` closure can only be
32
+ * invoked by calling this as a plain function. Not part of the package's barrel.
33
+ */
34
+ export function InputBody(props) {
3
35
  const cls = ["input"];
4
36
  if (props.error)
5
37
  cls.push("is-err");
@@ -9,15 +41,89 @@ export function Input(props) {
9
41
  cls.push("is-dirty");
10
42
  if (props.readOnly)
11
43
  cls.push("readonly-input");
12
- const field = (_jsxs(_Fragment, { children: [_jsx("input", { id: props.id, type: props.type ?? "text", class: cls.join(" "), value: props.value ?? "", placeholder: props.placeholder, disabled: props.disabled, readonly: props.readOnly,
13
- // Local single-user config UI: these are identifiers/paths/config values (slugs, names,
14
- // model ids, urls), never autofill targets kill browser autocomplete, the annoying
15
- // first-letter auto-capitalize, autocorrect, and spellcheck squiggles on all of them.
16
- autocomplete: "off", autocapitalize: "off", autocorrect: "off", spellcheck: false, "aria-invalid": props.error ? "true" : undefined, onInput: props.onInput ? (e) => props.onInput(e.target.value) : undefined, onKeyDown: props.onKeyDown }), props.help && !props.error ? _jsx("div", { class: "help", children: props.help }) : null, props.error ? (_jsxs("div", { class: "emsg", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0" }), _jsx("span", { children: props.error })] })) : null] }));
44
+ const type = props.type ?? "text";
45
+ const reveal = isRevealMode(props);
46
+ // The reveal path needs an id on the input: its toggle is a <button>, and a <button> is a
47
+ // labelable element HTML forbids one inside a <label> that isn't labelling it, and the
48
+ // accessible-name computation would fold the button's own name ("Show token") into the
49
+ // input's. So that path swaps the wrapping-<label> form for an explicit for/id pairing.
50
+ // Every other path is untouched.
51
+ const id = props.id ?? (reveal ? props.autoId : undefined);
52
+ const control = (_jsx("input", { id: id,
53
+ // The ONLY thing revealing changes: password ⇄ text on the field itself. The value is
54
+ // never copied anywhere else in the tree.
55
+ type: reveal && props.revealed ? "text" : type, class: cls.join(" "), value: props.value ?? "", placeholder: props.placeholder, disabled: props.disabled, readonly: props.readOnly,
56
+ // Local single-user config UI: these are identifiers/paths/config values (slugs, names,
57
+ // model ids, urls), never autofill targets — kill browser autocomplete, the annoying
58
+ // first-letter auto-capitalize, autocorrect, and spellcheck squiggles on all of them.
59
+ // On the reveal path the field is a declared secret, and browsers/password managers largely
60
+ // IGNORE autocomplete="off" on type=password — they will autofill it and offer to save it
61
+ // as a login credential. `new-password` is what actually suppresses both, and is already
62
+ // this package's policy for credential entry (see MaskedSecretInput).
63
+ autocomplete: reveal ? "new-password" : "off", autocapitalize: "off", autocorrect: "off", spellcheck: false, "aria-invalid": props.error ? "true" : undefined, onInput: props.onInput ? (e) => props.onInput(e.target.value) : undefined, onKeyDown: props.onKeyDown }));
64
+ const field = (_jsxs(_Fragment, { children: [reveal ? (_jsxs("div", { class: "input-reveal", children: [control, _jsx(RevealToggle, { revealed: props.revealed, disabled: props.disabled, labels: props.revealLabels, onToggle: props.onToggleReveal })] })) : (control), props.help && !props.error ? _jsx("div", { class: "help", children: props.help }) : null, props.error ? (_jsxs("div", { class: "emsg", children: [_jsx("span", { "aria-hidden": "true", children: "\u26A0" }), _jsx("span", { children: props.error })] })) : null] }));
17
65
  if (!props.label)
18
66
  return field;
67
+ if (reveal) {
68
+ return (_jsxs("div", { class: "field", children: [_jsx("label", { class: "field-label", for: id, children: props.label }), field] }));
69
+ }
19
70
  return (_jsxs("label", { class: "field", children: [_jsx("span", { class: "field-label", children: props.label }), field] }));
20
71
  }
72
+ /**
73
+ * Source of the reveal path's fallback field id.
74
+ *
75
+ * Deliberately NOT `useId`: that draws from preact's shared per-render id sequence, so taking one
76
+ * here would renumber the ids of every OTHER component in a consumer's tree — an invisible
77
+ * regression for an input that asked for none of this.
78
+ *
79
+ * A module counter is enough because this id never leaves the component. Its only job is to pair
80
+ * THIS instance's `<label for>` with THIS instance's `<input id>`, and both sides are the same
81
+ * value from the same render, so they cannot disagree with each other — which is the only
82
+ * property that has to hold.
83
+ *
84
+ * It follows that the counter's one give — a server render and its hydration draw from different
85
+ * counters, so the vdom's id and the hydrated DOM's id differ — is inert: preact does not rewrite
86
+ * attributes while hydrating, and any later patch moves both halves together. Reaching for
87
+ * `useId` to "fix" that would reintroduce a real, demonstrated regression (it renumbers every
88
+ * OTHER component's ids in the consumer's tree) in exchange for a mismatch nothing reads. Pass
89
+ * `id` yourself if you server-render and want a specific one.
90
+ */
91
+ let revealIdSeq = 0;
92
+ /** True when the field is a secret that opted into the reveal affordance. */
93
+ export function isRevealMode(props) {
94
+ return props.revealable === true && (props.type ?? "text") === "password";
95
+ }
96
+ /**
97
+ * The reveal state to render this pass.
98
+ *
99
+ * ANY change of the field's mode clears it. A field that stops being a revealable secret and
100
+ * later becomes one again must come back hidden: the alternative is a password rendered in the
101
+ * clear that no one asked to see, on a render the user never touched.
102
+ */
103
+ export function resolveRevealed(stored, storedMode, mode) {
104
+ return storedMode === mode ? stored : false;
105
+ }
106
+ export function Input(props) {
107
+ const mode = isRevealMode(props);
108
+ // `Input` always renders the same child component and always runs the same hooks, so flipping
109
+ // `revealable` or `type` re-renders the field instead of remounting it — a focused input keeps
110
+ // its focus, caret and selection. That makes clearing the reveal on a mode change this
111
+ // component's own job (see resolveRevealed), which is why the flag is a ref read during render
112
+ // rather than plain state: it has to be cleared on the very render that changes the mode.
113
+ //
114
+ // It is held here and never lifted into the props either way: a revealed secret must not
115
+ // survive a parent's re-render decision, and no consumer can force the field open.
116
+ const revealed = useRef(false);
117
+ const lastMode = useRef(mode);
118
+ const [, bumpRender] = useState(0);
119
+ revealed.current = resolveRevealed(revealed.current, lastMode.current, mode);
120
+ lastMode.current = mode;
121
+ const [autoId] = useState(() => `mythicalos-input-${++revealIdSeq}`);
122
+ return (_jsx(InputBody, { ...props, revealed: revealed.current, onToggleReveal: () => {
123
+ revealed.current = !revealed.current;
124
+ bumpRender((n) => n + 1);
125
+ }, autoId: autoId }));
126
+ }
21
127
  export function Toggle(props) {
22
128
  const cls = ["tog"];
23
129
  if (!props.on)
@@ -0,0 +1,50 @@
1
+ /** @jsxImportSource preact */
2
+ import type { ComponentChildren, Ref } from "preact";
3
+ import { type PopoverIds, type PopoverItem, type PopoverPosition } from "@mythicalos/ui-core/logic";
4
+ export { POPOVER_CARET, POPOVER_CHECK, POPOVER_CLASS, POPOVER_EMPTY_VALUE, popoverItemClass, popoverPanelClass, popoverTriggerClass, popoverTriggerText, resolvePopoverPosition, type PopoverItem, type PopoverPosition, } from "@mythicalos/ui-core/logic";
5
+ export interface PopoverProps {
6
+ /** The menu rows, in display order. Exactly one may carry `selected`. */
7
+ items: PopoverItem[];
8
+ /** Picking a row: closes the popover, returns focus to the trigger, then calls this. */
9
+ onSelect: (key: string, item: PopoverItem) => void;
10
+ /** Static prefix on the trigger, e.g. `"review lane:"`. */
11
+ label?: string;
12
+ /** Trigger value when nothing is selected (default: the em-dash placeholder). */
13
+ placeholder?: string;
14
+ /** Optional panel heading. */
15
+ title?: string;
16
+ /** Optional second heading line — the honest framing/caveat line. */
17
+ caption?: string;
18
+ /** Optional content below a divider (e.g. a "Manage projects →" action). Rows in here are the
19
+ * caller's own elements, and the arrow/Home/End roving deliberately does NOT reach them — a
20
+ * footer input keeps its own caret keys. Escape still closes from inside the footer. */
21
+ footer?: ComponentChildren;
22
+ disabled?: boolean;
23
+ /** Base for the trigger/panel id pair. Defaults to a generated one. */
24
+ id?: string;
25
+ /** Longer description for the trigger (native tooltip). Not the accessible NAME — the trigger's
26
+ * visible label + value already provide that. */
27
+ triggerTitle?: string;
28
+ onOpenChange?: (open: boolean) => void;
29
+ }
30
+ export interface PopoverPanelProps {
31
+ ids: PopoverIds;
32
+ pos: PopoverPosition;
33
+ items: PopoverItem[];
34
+ onPick: (item: PopoverItem) => void;
35
+ title?: string;
36
+ caption?: string;
37
+ footer?: ComponentChildren;
38
+ panelRef?: Ref<HTMLDivElement>;
39
+ }
40
+ /**
41
+ * The OPEN panel, deliberately hook-free (same testability split as the shell's `SwitcherPanel`):
42
+ * `preact-render-to-string` never runs effects or dispatches events, so the panel markup — and a
43
+ * row's REAL onClick closure, reachable off the returned vnode tree — would otherwise be
44
+ * unreachable from a DOM-free test. Exported from this module for tests; NOT part of the package
45
+ * barrel. `panelRef` rather than `ref` because plain Preact does not forward `ref` to a function
46
+ * component, and the React sibling keeps the same prop name so the two stay symmetric.
47
+ */
48
+ export declare function PopoverPanel(props: PopoverPanelProps): import("preact").JSX.Element;
49
+ /** A single-select dropdown popover anchored to its own chip trigger. */
50
+ export declare function Popover(props: PopoverProps): import("preact").JSX.Element;