@spaethtech/svelte-ui 0.21.1-dev.110.48d2aba → 0.21.1-dev.111.c0572e9

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.
@@ -2,6 +2,7 @@
2
2
  import Input from "./Input.svelte";
3
3
  import Eye from "~icons/mdi/eye";
4
4
  import EyeOff from "~icons/mdi/eye-off";
5
+ import Loading from "~icons/mdi/loading";
5
6
  import type { HTMLInputAttributes } from "svelte/elements";
6
7
  import type { Size } from "../types/sizes.js";
7
8
  import type { Responsive } from "../types/responsive.js";
@@ -20,6 +21,17 @@
20
21
  /** Shared axes — forwarded to the underlying Input. */
21
22
  variant?: Variant;
22
23
  borderless?: boolean;
24
+ /** Whether the value is shown as plain text (eye toggled on). Bindable; defaults `false`.
25
+ * Drive it to open the field already-visible, or re-hide it after a save. */
26
+ visible?: boolean;
27
+ /**
28
+ * Called when the user clicks the eye, with the state being moved TO (`!visible`).
29
+ * Return `false` (or a `Promise` resolving to `false`) to CANCEL the toggle; a returned
30
+ * `Promise` DEFERS the flip until it settles — the eye shows a busy spinner and ignores
31
+ * further clicks meanwhile, so an audited async reveal can populate `value` before it
32
+ * becomes visible (the field never shows plain text over an empty value).
33
+ */
34
+ onVisibilityChange?: (next: boolean) => void | boolean | Promise<void | boolean>;
23
35
  }
24
36
 
25
37
  let {
@@ -27,28 +39,49 @@
27
39
  valid = $bindable(true),
28
40
  touched = $bindable(false),
29
41
  element = $bindable(),
42
+ visible = $bindable(false),
43
+ onVisibilityChange,
30
44
  ...restProps
31
45
  }: Props = $props();
32
46
 
33
- let showPassword = $state(false);
47
+ // True while a deferred (async) `onVisibilityChange` is in flight — disables the icon so double
48
+ // clicks are ignored and shows a spinner instead of the eye.
49
+ let pending = $state(false);
34
50
 
35
- function toggleVisibility() {
36
- showPassword = !showPassword;
51
+ async function toggleVisibility() {
52
+ if (pending) return;
53
+ const next = !visible;
54
+ const result = onVisibilityChange?.(next);
55
+ if (result instanceof Promise) {
56
+ pending = true;
57
+ try {
58
+ if ((await result) === false) return; // cancelled
59
+ } finally {
60
+ pending = false;
61
+ }
62
+ } else if (result === false) {
63
+ return; // cancelled synchronously
64
+ }
65
+ visible = next;
37
66
  }
38
67
  </script>
39
68
 
69
+ <!-- restProps is spread FIRST so the eye wiring below always wins (a consumer `onIconClick`/
70
+ `iconClickable` can't silently replace the toggle). `autocomplete` is then re-applied AFTER the
71
+ spread but folds in the consumer's value, so a login field's `autocomplete="current-password"`
72
+ still overrides the `"new-password"` default (per docs/components.md). -->
40
73
  <Input
74
+ {...restProps}
41
75
  bind:value
42
76
  bind:valid
43
77
  bind:touched
44
78
  bind:element
45
- type={showPassword ? "text" : "password"}
46
- autocomplete="new-password"
47
- iconClickable={true}
79
+ type={visible ? "text" : "password"}
80
+ autocomplete={restProps.autocomplete ?? "new-password"}
81
+ iconClickable={!pending}
48
82
  onIconClick={toggleVisibility}
49
- {...restProps}
50
83
  >
51
84
  {#snippet icon()}
52
- {#if showPassword}<EyeOff />{:else}<Eye />{/if}
85
+ {#if pending}<Loading class="animate-spin" />{:else if visible}<EyeOff />{:else}<Eye />{/if}
53
86
  {/snippet}
54
87
  </Input>
@@ -15,7 +15,18 @@ interface Props extends Omit<HTMLInputAttributes, "type" | "size">, FieldChromeP
15
15
  /** Shared axes — forwarded to the underlying Input. */
16
16
  variant?: Variant;
17
17
  borderless?: boolean;
18
+ /** Whether the value is shown as plain text (eye toggled on). Bindable; defaults `false`.
19
+ * Drive it to open the field already-visible, or re-hide it after a save. */
20
+ visible?: boolean;
21
+ /**
22
+ * Called when the user clicks the eye, with the state being moved TO (`!visible`).
23
+ * Return `false` (or a `Promise` resolving to `false`) to CANCEL the toggle; a returned
24
+ * `Promise` DEFERS the flip until it settles — the eye shows a busy spinner and ignores
25
+ * further clicks meanwhile, so an audited async reveal can populate `value` before it
26
+ * becomes visible (the field never shows plain text over an empty value).
27
+ */
28
+ onVisibilityChange?: (next: boolean) => void | boolean | Promise<void | boolean>;
18
29
  }
19
- declare const PasswordInput: import("svelte").Component<Props, {}, "element" | "value" | "valid" | "touched">;
30
+ declare const PasswordInput: import("svelte").Component<Props, {}, "element" | "value" | "valid" | "touched" | "visible">;
20
31
  type PasswordInput = ReturnType<typeof PasswordInput>;
21
32
  export default PasswordInput;
@@ -307,9 +307,35 @@ Thin wrappers over `Input`, each preconfigured with an MDI icon (rendered into I
307
307
 
308
308
  - **Location**: `src/lib/components/PasswordInput.svelte`
309
309
  - **Features**: built-in visibility toggle (eye / eye-off MDI icons)
310
+ - **`visible`** (bindable, default `false`): whether the value shows as plain text. Read it, or drive
311
+ it — open the field already-visible, or re-hide it after a save (`visible = false`). Previously this
312
+ was private; a consumer had to watch the input's `type` attribute via a `MutationObserver`.
313
+ - **`onVisibilityChange?: (next: boolean) => void | boolean | Promise<void | boolean>`**: fired on each
314
+ eye click with the state being moved **to**. Return `false` (or a `Promise<false>`) to **cancel** the
315
+ toggle; return a `Promise` to **defer** the flip until it settles — the eye shows a **busy spinner**
316
+ and ignores clicks meanwhile, so an async reveal can populate `value` before it becomes visible (the
317
+ field never shows plain text over an empty value).
318
+ - **Icon wiring**: a consumer `onIconClick`/`iconClickable` no longer overrides the toggle — the eye
319
+ wiring is applied after the prop spread, so the toggle always wins.
310
320
  - **`autocomplete`**: defaults to `"new-password"` (clears Chrome's "should have autocomplete"
311
321
  advisory and stops unrelated saved-credential autofill). Overridable per usage — a login field
312
- passes `autocomplete="current-password"` and it wins (spread after the default).
322
+ passes `autocomplete="current-password"` and it wins.
323
+
324
+ ```svelte
325
+ <!-- On-demand reveal: read the stored secret from an audited backend the FIRST time the eye opens,
326
+ BEFORE the field flips to plain text. Re-hide after save with `pwVisible = false`. -->
327
+ <script>
328
+ import { PasswordInput } from "@spaethtech/svelte-ui";
329
+ let value = $state("");
330
+ let pwVisible = $state(false);
331
+ let loaded = $state(false);
332
+ async function beforeShow(next) {
333
+ if (next && !loaded) { value = await fetchSecret(); loaded = true; } // Promise defers the flip
334
+ }
335
+ </script>
336
+
337
+ <PasswordInput label="Password" bind:value bind:visible={pwVisible} onVisibilityChange={beforeShow} />
338
+ ```
313
339
 
314
340
  ### EmailInput
315
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.1-dev.110.48d2aba",
3
+ "version": "0.21.1-dev.111.c0572e9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"