@mythicalos/preact-ui 0.2.2 → 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 +36 -1
- package/dist/Input.d.ts +53 -0
- package/dist/Input.js +112 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
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
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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/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.
|
|
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.
|
|
38
|
+
"@mythicalos/ui-core": "^0.2.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc -p tsconfig.build.json",
|