@mythicalos/preact-ui 0.2.1 → 0.3.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
 
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)
package/dist/hooks.js CHANGED
@@ -64,10 +64,22 @@ export function usePoll(fn, ms, opts = {}) {
64
64
  let timer;
65
65
  let stopped = false;
66
66
  let failures = 0; // consecutive failed reads — drives the ×2 backoff ladder (reset on success)
67
+ // true from the moment a tick fires its fetch until that fetch settles — the ONE in-flight
68
+ // read this call site is allowed to have. The visibility handler below must never start a
69
+ // second tick while this is true; it lets the in-flight tick's own onSettled → schedule()
70
+ // continue the single chain instead (a prior version fired an immediate tick on visibility
71
+ // return unconditionally, which could open a second concurrent chain).
72
+ let pending = false;
67
73
  const isHidden = () => typeof document !== "undefined" && document.hidden === true;
68
74
  const schedule = () => {
69
75
  if (!alive || stopped)
70
76
  return;
77
+ // Clear any already-armed timer before arming a new one — defense in depth so a stray
78
+ // double-settle can never leave an earlier timer orphaned (never cleared, still firing).
79
+ if (timer) {
80
+ clearTimeout(timer);
81
+ timer = undefined;
82
+ }
71
83
  // U7 pause: while the document is hidden no timer is armed — the
72
84
  // visibilitychange handler below refreshes immediately on return.
73
85
  if (isHidden())
@@ -77,26 +89,31 @@ export function usePoll(fn, ms, opts = {}) {
77
89
  // diff r3-F1: every response routes through runPollTick — stamped with the epoch at fire
78
90
  // time, applied only while that epoch is still live. A stale settle leaves loading alone
79
91
  // (the reset already set it for the new epoch) but still rearms via schedule()'s own guards.
80
- const tick = () => runPollTick({
81
- fetch: () => fnRef.current(),
82
- guard,
83
- isAlive: () => alive && !stopped,
84
- isHidden,
85
- onSuccess: (v) => {
86
- failures = 0;
87
- setData(v);
88
- setError(undefined);
89
- },
90
- onFailure: (message) => {
91
- failures += 1;
92
- setError(message);
93
- },
94
- onSettled: (applied) => {
95
- if (applied)
96
- setLoading(false);
97
- schedule();
98
- },
99
- });
92
+ const tick = () => {
93
+ pending = true;
94
+ return runPollTick({
95
+ fetch: () => fnRef.current(),
96
+ guard,
97
+ isAlive: () => alive && !stopped,
98
+ isHidden,
99
+ onSuccess: (v) => {
100
+ failures = 0;
101
+ setData(v);
102
+ setError(undefined);
103
+ },
104
+ onFailure: (message) => {
105
+ failures += 1;
106
+ setError(message);
107
+ },
108
+ onSettled: (applied) => {
109
+ if (applied)
110
+ setLoading(false);
111
+ schedule();
112
+ },
113
+ }).finally(() => {
114
+ pending = false;
115
+ });
116
+ };
100
117
  const onVisibility = () => {
101
118
  if (!alive || stopped)
102
119
  return;
@@ -104,8 +121,10 @@ export function usePoll(fn, ms, opts = {}) {
104
121
  clearTimeout(timer);
105
122
  timer = undefined;
106
123
  }
107
- if (!isHidden())
108
- void tick(); // immediate refresh on visibility return
124
+ // Immediate refresh on visibility return — but only when idle. A tick already in flight
125
+ // keeps its place as the single chain: its own onSettled → schedule() picks up from here.
126
+ if (!isHidden() && !pending)
127
+ void tick();
109
128
  };
110
129
  if (typeof document !== "undefined")
111
130
  document.addEventListener("visibilitychange", onVisibility);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Button, buttonClass, type ButtonProps, type BtnVariant, type BtnState } from "./Button.js";
2
- export { Input, Toggle, Checkbox, type InputProps, type ToggleProps, type CheckboxProps } from "./Input.js";
2
+ export { Input, Toggle, Checkbox, REVEAL_SHOW_LABEL, REVEAL_HIDE_LABEL, REVEAL_LABELS, type InputProps, type RevealLabels, type ToggleProps, type CheckboxProps, } from "./Input.js";
3
3
  export { MaskedSecretInput, type MaskedSecretInputProps } from "./MaskedSecretInput.js";
4
4
  export { EmptyState, type EmptyStateProps } from "./EmptyState.js";
5
5
  export { Scrim, ConfirmDialog, typedNameMatches, BULLET_ICON, type DialogBullet, type ConfirmDialogProps, } from "./ConfirmDialog.js";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // SearchInput, Banner, Gauge — Task 6). Serve `@mythicalos/ui-core/styles.css` (after
5
5
  // `@mythicalos/tokens`) so these components' classes resolve — this package ships no CSS of its own.
6
6
  export { Button, buttonClass } from "./Button.js";
7
- export { Input, Toggle, Checkbox } from "./Input.js";
7
+ export { Input, Toggle, Checkbox, REVEAL_SHOW_LABEL, REVEAL_HIDE_LABEL, REVEAL_LABELS, } from "./Input.js";
8
8
  export { MaskedSecretInput } from "./MaskedSecretInput.js";
9
9
  export { EmptyState } from "./EmptyState.js";
10
10
  export { Scrim, ConfirmDialog, typedNameMatches, BULLET_ICON, } from "./ConfirmDialog.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mythicalos/preact-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "mythicalOS thin Preact 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 React sibling can never drift. Apache-2.0.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@mythicalos/ui-core": "^0.1.1"
38
+ "@mythicalos/ui-core": "^0.2.0"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.build.json",