@joewinke/jatui 0.39.0 → 0.40.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.
Files changed (41) hide show
  1. package/CLAUDE.md +23 -5
  2. package/THIRD_PARTY_NOTICES.md +16 -0
  3. package/dist/actions/scrollEdge.d.ts +55 -0
  4. package/dist/actions/scrollEdge.js +144 -0
  5. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
  6. package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
  7. package/dist/components/BottomSheet.svelte +7 -6
  8. package/dist/components/BottomSheet.svelte.d.ts +2 -0
  9. package/dist/components/CloseButton.svelte +75 -0
  10. package/dist/components/CloseButton.svelte.d.ts +29 -0
  11. package/dist/components/Drawer.svelte +10 -11
  12. package/dist/components/Drawer.svelte.d.ts +2 -8
  13. package/dist/components/IconButton.svelte +200 -0
  14. package/dist/components/IconButton.svelte.d.ts +46 -0
  15. package/dist/components/LinkButton.svelte +117 -0
  16. package/dist/components/LinkButton.svelte.d.ts +32 -0
  17. package/dist/components/MediaLightbox.svelte +2 -7
  18. package/dist/components/Modal.svelte +2 -10
  19. package/dist/components/PinInput.svelte +94 -31
  20. package/dist/components/PinInput.svelte.d.ts +4 -0
  21. package/dist/components/TextArea.svelte +67 -5
  22. package/dist/components/TextArea.svelte.d.ts +4 -6
  23. package/dist/components/ThemeSelector.svelte +29 -9
  24. package/dist/components/ThemeSelector.svelte.d.ts +6 -0
  25. package/dist/components/ThemeToggle.svelte +28 -3
  26. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  27. package/dist/components/basic/Breadcrumb.svelte +226 -11
  28. package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
  29. package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
  30. package/dist/components/basic/breadcrumbCollapse.js +12 -0
  31. package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
  32. package/dist/index.d.ts +14 -1
  33. package/dist/index.js +10 -1
  34. package/dist/styles/tokens.css +80 -1
  35. package/dist/utils/pinInput.d.ts +33 -0
  36. package/dist/utils/pinInput.js +63 -0
  37. package/dist/utils/textareaRows.d.ts +23 -0
  38. package/dist/utils/textareaRows.js +13 -0
  39. package/dist/utils/themeWipe.d.ts +36 -17
  40. package/dist/utils/themeWipe.js +145 -35
  41. package/package.json +1 -1
@@ -0,0 +1,200 @@
1
+ <script module lang="ts">
2
+ export type IconButtonVariant =
3
+ | 'ghost'
4
+ | 'outline'
5
+ | 'soft'
6
+ | 'primary'
7
+ | 'secondary'
8
+ | 'error'
9
+ | 'overlay';
10
+ export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ /**
15
+ * IconButton — an icon-only button with a REQUIRED accessible name.
16
+ *
17
+ * The square/circle `btn` that consumers hand-write hundreds of times
18
+ * (`btn btn-sm btn-circle btn-ghost` + an inline svg + an aria-label that is
19
+ * sometimes forgotten). `label` is required by the types, becomes the
20
+ * aria-label, and — with `tooltip` — the hover/focus hint too.
21
+ *
22
+ * <IconButton label="Edit" onclick={edit}><PencilIcon size={16} /></IconButton>
23
+ * <IconButton label="Pin" pressed={pinned} tooltip onclick={toggle}>…</IconButton>
24
+ * <IconButton label="Open" href="/x" shape="circle" variant="outline">…</IconButton>
25
+ *
26
+ * `variant="overlay"` is light-on-dark translucent chrome for controls that
27
+ * sit over media (lightboxes, video stages) where a ghost button vanishes.
28
+ *
29
+ * Idea from BoardUI's icon-button (MIT); written fresh for Svelte 5 + jatui
30
+ * tokens.
31
+ */
32
+ import type { Snippet } from 'svelte';
33
+ import type { HTMLButtonAttributes } from 'svelte/elements';
34
+ import { tooltip as tooltipAction, type TooltipOptions } from '../actions/tooltip';
35
+
36
+ interface Props
37
+ extends Omit<
38
+ HTMLButtonAttributes,
39
+ 'type' | 'disabled' | 'class' | 'onclick' | 'children' | 'aria-label'
40
+ > {
41
+ /** Accessible name — required. Rendered as aria-label (and the tooltip text). */
42
+ label: string;
43
+ variant?: IconButtonVariant;
44
+ size?: IconButtonSize;
45
+ shape?: 'square' | 'circle';
46
+ /** Render an `<a>` instead of a `<button>`. */
47
+ href?: string;
48
+ type?: 'button' | 'submit' | 'reset';
49
+ disabled?: boolean;
50
+ /** Toggle semantics — renders aria-pressed and the pressed surface. */
51
+ pressed?: boolean;
52
+ /** Show a tooltip: `true` uses `label`; a string shows that text instead (e.g. with a shortcut). */
53
+ tooltip?: boolean | string;
54
+ tooltipPlacement?: TooltipOptions['placement'];
55
+ class?: string;
56
+ onclick?: (e: MouseEvent) => void;
57
+ /** The icon. Size it to the button (16px for sm/md is the house default). */
58
+ children?: Snippet;
59
+ }
60
+
61
+ let {
62
+ label,
63
+ variant = 'ghost',
64
+ size = 'md',
65
+ shape = 'square',
66
+ href,
67
+ type = 'button',
68
+ disabled = false,
69
+ pressed,
70
+ tooltip = false,
71
+ tooltipPlacement = 'top',
72
+ class: className = '',
73
+ onclick,
74
+ children,
75
+ ...rest
76
+ }: Props = $props();
77
+
78
+ const VARIANT: Record<IconButtonVariant, string> = {
79
+ ghost: 'btn-ghost',
80
+ outline: 'btn-outline',
81
+ soft: 'btn-soft',
82
+ primary: 'btn-primary',
83
+ secondary: 'btn-secondary',
84
+ error: 'btn-error',
85
+ overlay: 'jatui-icon-btn--overlay'
86
+ };
87
+ const SIZE: Record<IconButtonSize, string> = {
88
+ xs: 'btn-xs',
89
+ sm: 'btn-sm',
90
+ md: '',
91
+ lg: 'btn-lg'
92
+ };
93
+
94
+ const classes = $derived(
95
+ [
96
+ 'btn jatui-icon-btn',
97
+ shape === 'circle' ? 'btn-circle' : 'btn-square',
98
+ VARIANT[variant] ?? 'btn-ghost',
99
+ SIZE[size],
100
+ pressed ? 'jatui-icon-btn--pressed' : '',
101
+ className
102
+ ]
103
+ .filter(Boolean)
104
+ .join(' ')
105
+ );
106
+
107
+ const ariaPressed = $derived(pressed === undefined ? undefined : pressed ? 'true' : 'false');
108
+ const tipText = $derived(typeof tooltip === 'string' ? tooltip : tooltip ? label : '');
109
+
110
+ /** use:tooltip only when asked for — no aria-describedby noise otherwise. */
111
+ function maybeTooltip(node: HTMLElement, text: string) {
112
+ let handle: ReturnType<typeof tooltipAction> | undefined;
113
+ const apply = (t: string) => {
114
+ if (t && !handle) handle = tooltipAction(node, { text: t, placement: tooltipPlacement });
115
+ else if (t && handle) handle.update?.({ text: t, placement: tooltipPlacement });
116
+ else if (!t && handle) {
117
+ handle.destroy?.();
118
+ handle = undefined;
119
+ }
120
+ };
121
+ apply(text);
122
+ return { update: apply, destroy: () => handle?.destroy?.() };
123
+ }
124
+ </script>
125
+
126
+ {#if href && !disabled}
127
+ <a
128
+ {...rest as Record<string, unknown>}
129
+ {href}
130
+ class={classes}
131
+ aria-label={label}
132
+ aria-pressed={ariaPressed}
133
+ {onclick}
134
+ use:maybeTooltip={tipText}
135
+ >
136
+ {#if children}{@render children()}{/if}
137
+ </a>
138
+ {:else}
139
+ <button
140
+ {...rest}
141
+ {type}
142
+ {disabled}
143
+ class={classes}
144
+ aria-label={label}
145
+ aria-pressed={ariaPressed}
146
+ {onclick}
147
+ use:maybeTooltip={tipText}
148
+ >
149
+ {#if children}{@render children()}{/if}
150
+ </button>
151
+ {/if}
152
+
153
+ <style>
154
+ /* Flat (no DaisyUI v5 variant shadow), press squish, token focus ring. */
155
+ .jatui-icon-btn {
156
+ --btn-shadow: 0 0 0 0 transparent;
157
+ transition:
158
+ transform 500ms var(--motion-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
159
+ background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out),
160
+ color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
161
+ }
162
+ .jatui-icon-btn:active:not(:disabled) {
163
+ transform: scale(0.92);
164
+ transition: transform var(--motion-instant, 80ms) ease-out;
165
+ }
166
+ .jatui-icon-btn:focus-visible {
167
+ outline: var(--jatui-focus-ring-width, 2px) solid
168
+ var(--jatui-focus-ring, color-mix(in oklab, var(--color-primary) 70%, transparent));
169
+ outline-offset: var(--jatui-focus-ring-offset, 2px);
170
+ }
171
+ .jatui-icon-btn--pressed:not(.btn-primary):not(.btn-secondary):not(.btn-error) {
172
+ background-color: var(
173
+ --jatui-selection-bg,
174
+ color-mix(in oklab, var(--color-primary) 14%, transparent)
175
+ );
176
+ color: var(--color-primary);
177
+ }
178
+
179
+ /* Over media: translucent light chrome that reads on any image or video. */
180
+ .jatui-icon-btn--overlay {
181
+ --btn-color: color-mix(in oklab, white 10%, transparent);
182
+ --btn-fg: color-mix(in oklab, white 95%, transparent);
183
+ --btn-border: color-mix(in oklab, white 15%, transparent);
184
+ --btn-noise: none;
185
+ background-color: color-mix(in oklab, white 10%, transparent);
186
+ border: 1px solid color-mix(in oklab, white 15%, transparent);
187
+ color: color-mix(in oklab, white 95%, transparent);
188
+ }
189
+ .jatui-icon-btn--overlay:hover {
190
+ background-color: color-mix(in oklab, white 20%, transparent);
191
+ }
192
+
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .jatui-icon-btn,
195
+ .jatui-icon-btn:active:not(:disabled) {
196
+ transform: none;
197
+ transition: none;
198
+ }
199
+ }
200
+ </style>
@@ -0,0 +1,46 @@
1
+ export type IconButtonVariant = 'ghost' | 'outline' | 'soft' | 'primary' | 'secondary' | 'error' | 'overlay';
2
+ export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
3
+ /**
4
+ * IconButton — an icon-only button with a REQUIRED accessible name.
5
+ *
6
+ * The square/circle `btn` that consumers hand-write hundreds of times
7
+ * (`btn btn-sm btn-circle btn-ghost` + an inline svg + an aria-label that is
8
+ * sometimes forgotten). `label` is required by the types, becomes the
9
+ * aria-label, and — with `tooltip` — the hover/focus hint too.
10
+ *
11
+ * <IconButton label="Edit" onclick={edit}><PencilIcon size={16} /></IconButton>
12
+ * <IconButton label="Pin" pressed={pinned} tooltip onclick={toggle}>…</IconButton>
13
+ * <IconButton label="Open" href="/x" shape="circle" variant="outline">…</IconButton>
14
+ *
15
+ * `variant="overlay"` is light-on-dark translucent chrome for controls that
16
+ * sit over media (lightboxes, video stages) where a ghost button vanishes.
17
+ *
18
+ * Idea from BoardUI's icon-button (MIT); written fresh for Svelte 5 + jatui
19
+ * tokens.
20
+ */
21
+ import type { Snippet } from 'svelte';
22
+ import type { HTMLButtonAttributes } from 'svelte/elements';
23
+ import { type TooltipOptions } from '../actions/tooltip';
24
+ interface Props extends Omit<HTMLButtonAttributes, 'type' | 'disabled' | 'class' | 'onclick' | 'children' | 'aria-label'> {
25
+ /** Accessible name — required. Rendered as aria-label (and the tooltip text). */
26
+ label: string;
27
+ variant?: IconButtonVariant;
28
+ size?: IconButtonSize;
29
+ shape?: 'square' | 'circle';
30
+ /** Render an `<a>` instead of a `<button>`. */
31
+ href?: string;
32
+ type?: 'button' | 'submit' | 'reset';
33
+ disabled?: boolean;
34
+ /** Toggle semantics — renders aria-pressed and the pressed surface. */
35
+ pressed?: boolean;
36
+ /** Show a tooltip: `true` uses `label`; a string shows that text instead (e.g. with a shortcut). */
37
+ tooltip?: boolean | string;
38
+ tooltipPlacement?: TooltipOptions['placement'];
39
+ class?: string;
40
+ onclick?: (e: MouseEvent) => void;
41
+ /** The icon. Size it to the button (16px for sm/md is the house default). */
42
+ children?: Snippet;
43
+ }
44
+ declare const IconButton: import("svelte").Component<Props, {}, "">;
45
+ type IconButton = ReturnType<typeof IconButton>;
46
+ export default IconButton;
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ /**
3
+ * LinkButton — a text action that looks like a link: no box, underline on
4
+ * hover/focus. Renders `<a>` when given `href`, otherwise a `<button>` —
5
+ * pick by what it DOES (navigates vs acts), not by how it looks.
6
+ *
7
+ * <LinkButton onclick={resend}>Resend code</LinkButton>
8
+ * <LinkButton href="/docs" tone="primary">Read the docs</LinkButton>
9
+ * <LinkButton href="https://x.dev" external>Changelog</LinkButton>
10
+ *
11
+ * `external` opens a new tab with rel="noopener noreferrer" and tells
12
+ * screen-reader users so. Idea from BoardUI's link-button (MIT).
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+
16
+ interface Props {
17
+ href?: string;
18
+ /** Ink: inherit the surrounding text, primary accent, muted, or error. */
19
+ tone?: 'inherit' | 'primary' | 'muted' | 'error';
20
+ size?: 'xs' | 'sm' | 'md';
21
+ /** Underline: on hover/focus only (default) or always. */
22
+ underline?: 'hover' | 'always';
23
+ /** Open in a new tab (adds rel=noopener and an SR-only "(opens in a new tab)"). */
24
+ external?: boolean;
25
+ type?: 'button' | 'submit' | 'reset';
26
+ disabled?: boolean;
27
+ class?: string;
28
+ onclick?: (e: MouseEvent) => void;
29
+ children?: Snippet;
30
+ [key: string]: unknown;
31
+ }
32
+
33
+ let {
34
+ href,
35
+ tone = 'primary',
36
+ size = 'sm',
37
+ underline = 'hover',
38
+ external = false,
39
+ type = 'button',
40
+ disabled = false,
41
+ class: className = '',
42
+ onclick,
43
+ children,
44
+ ...rest
45
+ }: Props = $props();
46
+
47
+ const TONE = {
48
+ inherit: '',
49
+ primary: 'link-primary',
50
+ muted: 'jatui-link-btn--muted',
51
+ error: 'link-error'
52
+ } as const;
53
+ const SIZE = { xs: 'text-xs', sm: 'text-sm', md: 'text-base' } as const;
54
+
55
+ const classes = $derived(
56
+ [
57
+ 'link jatui-link-btn',
58
+ underline === 'hover' ? 'link-hover' : '',
59
+ TONE[tone] ?? '',
60
+ SIZE[size] ?? '',
61
+ disabled ? 'jatui-link-btn--disabled' : '',
62
+ className
63
+ ]
64
+ .filter(Boolean)
65
+ .join(' ')
66
+ );
67
+ </script>
68
+
69
+ {#if href && !disabled}
70
+ <a
71
+ {...rest}
72
+ {href}
73
+ class={classes}
74
+ target={external ? '_blank' : rest.target as string | undefined}
75
+ rel={external ? 'noopener noreferrer' : rest.rel as string | undefined}
76
+ {onclick}
77
+ >
78
+ {#if children}{@render children()}{/if}
79
+ {#if external}<span class="sr-only"> (opens in a new tab)</span>{/if}
80
+ </a>
81
+ {:else}
82
+ <button {...rest} {type} {disabled} class={classes} {onclick}>
83
+ {#if children}{@render children()}{/if}
84
+ </button>
85
+ {/if}
86
+
87
+ <style>
88
+ .jatui-link-btn {
89
+ display: inline;
90
+ padding: 0;
91
+ background: none;
92
+ border: 0;
93
+ font-weight: 500;
94
+ text-underline-offset: 0.2em;
95
+ cursor: pointer;
96
+ border-radius: var(--jatui-radius-field, 0.25rem);
97
+ transition: color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
98
+ }
99
+ .jatui-link-btn:focus-visible {
100
+ outline: var(--jatui-focus-ring-width, 2px) solid
101
+ var(--jatui-focus-ring, color-mix(in oklab, var(--color-primary) 70%, transparent));
102
+ outline-offset: var(--jatui-focus-ring-offset, 2px);
103
+ text-decoration-line: underline;
104
+ }
105
+ .jatui-link-btn--muted {
106
+ color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
107
+ }
108
+ .jatui-link-btn--muted:hover {
109
+ color: var(--color-base-content);
110
+ }
111
+ .jatui-link-btn--disabled,
112
+ .jatui-link-btn:disabled {
113
+ opacity: 0.45;
114
+ cursor: not-allowed;
115
+ text-decoration: none;
116
+ }
117
+ </style>
@@ -0,0 +1,32 @@
1
+ /**
2
+ * LinkButton — a text action that looks like a link: no box, underline on
3
+ * hover/focus. Renders `<a>` when given `href`, otherwise a `<button>` —
4
+ * pick by what it DOES (navigates vs acts), not by how it looks.
5
+ *
6
+ * <LinkButton onclick={resend}>Resend code</LinkButton>
7
+ * <LinkButton href="/docs" tone="primary">Read the docs</LinkButton>
8
+ * <LinkButton href="https://x.dev" external>Changelog</LinkButton>
9
+ *
10
+ * `external` opens a new tab with rel="noopener noreferrer" and tells
11
+ * screen-reader users so. Idea from BoardUI's link-button (MIT).
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+ interface Props {
15
+ href?: string;
16
+ /** Ink: inherit the surrounding text, primary accent, muted, or error. */
17
+ tone?: 'inherit' | 'primary' | 'muted' | 'error';
18
+ size?: 'xs' | 'sm' | 'md';
19
+ /** Underline: on hover/focus only (default) or always. */
20
+ underline?: 'hover' | 'always';
21
+ /** Open in a new tab (adds rel=noopener and an SR-only "(opens in a new tab)"). */
22
+ external?: boolean;
23
+ type?: 'button' | 'submit' | 'reset';
24
+ disabled?: boolean;
25
+ class?: string;
26
+ onclick?: (e: MouseEvent) => void;
27
+ children?: Snippet;
28
+ [key: string]: unknown;
29
+ }
30
+ declare const LinkButton: import("svelte").Component<Props, {}, "">;
31
+ type LinkButton = ReturnType<typeof LinkButton>;
32
+ export default LinkButton;
@@ -24,6 +24,7 @@
24
24
  * ]} initialIndex={0} onClose={close} />
25
25
  */
26
26
 
27
+ import CloseButton from './CloseButton.svelte';
27
28
  import { fade } from 'svelte/transition';
28
29
 
29
30
  export type MediaItem = {
@@ -175,9 +176,7 @@
175
176
  >
176
177
  Download
177
178
  </a>
178
- <button type="button" class="mlb-btn mlb-close" onclick={close} aria-label="Close">
179
- ×
180
- </button>
179
+ <CloseButton label="Close" variant="overlay" shape="square" onclick={close} />
181
180
  </div>
182
181
  </div>
183
182
 
@@ -288,10 +287,6 @@
288
287
  .mlb-btn:hover {
289
288
  background: oklch(1 0 0 / 0.2);
290
289
  }
291
- .mlb-close {
292
- font-size: 1.25rem;
293
- line-height: 1;
294
- }
295
290
  .mlb-nav {
296
291
  position: absolute;
297
292
  top: 50%;
@@ -6,6 +6,7 @@
6
6
  * Converted from Svelte 4 to Svelte 5 runes.
7
7
  */
8
8
 
9
+ import CloseButton from './CloseButton.svelte';
9
10
  import { onMount, untrack } from 'svelte';
10
11
  import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
11
12
  import { fly, fade } from 'svelte/transition';
@@ -241,16 +242,7 @@
241
242
  {/if}
242
243
 
243
244
  {#if showCloseButton && dismissible}
244
- <button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={close} aria-label="Close modal">
245
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
246
- <path
247
- stroke-linecap="round"
248
- stroke-linejoin="round"
249
- stroke-width="2"
250
- d="M6 18L18 6M6 6l12 12"
251
- />
252
- </svg>
253
- </button>
245
+ <CloseButton label="Close modal" onclick={close} />
254
246
  {/if}
255
247
  </div>
256
248
  {/if}
@@ -1,4 +1,20 @@
1
1
  <script lang="ts">
2
+ /**
3
+ * PinInput — one box per character for PINs and one-time codes.
4
+ *
5
+ * OTP autofill (BoardUI wave 1, jatui-owg2z): every cell carries
6
+ * `autocomplete="one-time-code"` and accepts multi-character input (no
7
+ * maxlength), so iOS/Android SMS autofill and Chrome WebOTP — which drop
8
+ * the WHOLE code into whichever cell is focused — fill every cell from the
9
+ * first. A paste distributes from the caret cell. Logic: utils/pinInput.ts.
10
+ */
11
+ import {
12
+ applyCellInput,
13
+ cellsFromValue,
14
+ distribute,
15
+ isPinComplete
16
+ } from '../utils/pinInput';
17
+
2
18
  interface Props {
3
19
  length?: number;
4
20
  value?: string;
@@ -7,6 +23,10 @@
7
23
  error?: string;
8
24
  disabled?: boolean;
9
25
  label?: string;
26
+ /** `name` on the first cell, so a plain form post carries the code. */
27
+ name?: string;
28
+ /** Override the autocomplete token. Default `one-time-code`; use `off` for a static PIN. */
29
+ autocomplete?: HTMLInputElement['autocomplete'];
10
30
  oncomplete?: (value: string) => void;
11
31
  }
12
32
 
@@ -18,14 +38,27 @@
18
38
  error = '',
19
39
  disabled = false,
20
40
  label = '',
41
+ name,
42
+ autocomplete = 'one-time-code',
21
43
  oncomplete
22
44
  }: Props = $props();
23
45
 
24
- let cells: string[] = $state(Array(length).fill(''));
46
+ // Cells mirror `value`; an external write (reset, prefill) re-seeds them.
47
+ let cells: string[] = $state(cellsFromValue(value, length, numeric));
25
48
  let inputEls: HTMLInputElement[] = $state([]);
26
49
  let poppingCells: boolean[] = $state(Array(length).fill(false));
27
50
  let shaking = $state(false);
28
51
  let hadError = false;
52
+ let lastCompleted = '';
53
+
54
+ $effect(() => {
55
+ const v = value ?? '';
56
+ const n = length;
57
+ if (v !== cells.join('') || cells.length !== n) {
58
+ cells = cellsFromValue(v, n, numeric);
59
+ poppingCells = Array(n).fill(false);
60
+ }
61
+ });
29
62
 
30
63
  $effect(() => {
31
64
  const hasError = !!error;
@@ -48,50 +81,73 @@
48
81
  setTimeout(() => { poppingCells[i] = false; }, 350);
49
82
  }
50
83
 
51
- function onInput(i: number, e: Event) {
52
- const el = e.target as HTMLInputElement;
53
- let char = el.value;
54
-
55
- if (numeric) char = char.replace(/\D/g, '');
56
-
57
- if (char.length > 1) {
58
- // paste — split across cells
59
- const chars = char.slice(0, length - i).split('');
60
- chars.forEach((c, offset) => {
61
- if (i + offset < length) {
62
- cells[i + offset] = c;
63
- if (c) popCell(i + offset);
64
- }
65
- });
66
- const nextIdx = Math.min(i + chars.length, length - 1);
67
- inputEls[nextIdx]?.focus();
68
- } else {
69
- cells[i] = char.slice(-1);
70
- if (cells[i]) {
71
- popCell(i);
72
- inputEls[i + 1]?.focus();
84
+ function commit(next: string[], focus: number) {
85
+ next.forEach((c, i) => {
86
+ if (c && c !== cells[i]) popCell(i);
87
+ });
88
+ cells = next;
89
+ // The DOM value may hold extra characters (no maxlength, so OS autofill
90
+ // can deliver a whole code) — write each cell back to its single char.
91
+ inputEls.forEach((el, i) => {
92
+ if (el && el.value !== next[i]) el.value = next[i];
93
+ });
94
+ value = next.join('');
95
+ inputEls[focus]?.focus();
96
+ if (isPinComplete(next, length)) {
97
+ if (value !== lastCompleted) {
98
+ lastCompleted = value;
99
+ oncomplete?.(value);
73
100
  }
101
+ } else {
102
+ lastCompleted = '';
74
103
  }
104
+ }
75
105
 
76
- value = cells.join('');
77
- if (value.length === length && !value.includes('')) {
78
- oncomplete?.(value);
79
- }
106
+ function onInput(i: number, e: Event) {
107
+ const el = e.target as HTMLInputElement;
108
+ const r = applyCellInput(cells, i, el.value, { length, numeric });
109
+ commit(r.cells, r.focus);
110
+ }
111
+
112
+ function onPaste(i: number, e: ClipboardEvent) {
113
+ const text = e.clipboardData?.getData('text') ?? '';
114
+ if (!text) return;
115
+ e.preventDefault();
116
+ const r = distribute(cells, i, text, { length, numeric });
117
+ commit(r.cells, r.focus);
80
118
  }
81
119
 
82
120
  function onKeydown(i: number, e: KeyboardEvent) {
83
121
  if (e.key === 'Backspace') {
122
+ e.preventDefault();
84
123
  if (!cells[i]) {
85
- inputEls[i - 1]?.focus();
124
+ if (i > 0) {
125
+ const next = [...cells];
126
+ next[i - 1] = '';
127
+ commit(next, i - 1);
128
+ }
86
129
  } else {
87
- cells[i] = '';
88
- value = cells.join('');
130
+ const next = [...cells];
131
+ next[i] = '';
132
+ commit(next, i);
89
133
  }
134
+ } else if (e.key === 'Delete') {
90
135
  e.preventDefault();
136
+ const next = [...cells];
137
+ next[i] = '';
138
+ commit(next, i);
91
139
  } else if (e.key === 'ArrowLeft') {
140
+ e.preventDefault();
92
141
  inputEls[i - 1]?.focus();
93
142
  } else if (e.key === 'ArrowRight') {
143
+ e.preventDefault();
94
144
  inputEls[i + 1]?.focus();
145
+ } else if (e.key === 'Home') {
146
+ e.preventDefault();
147
+ inputEls[0]?.focus();
148
+ } else if (e.key === 'End') {
149
+ e.preventDefault();
150
+ inputEls[length - 1]?.focus();
95
151
  }
96
152
  }
97
153
 
@@ -111,6 +167,7 @@
111
167
 
112
168
  <div
113
169
  class="flex gap-2"
170
+ dir="ltr"
114
171
  role="group"
115
172
  aria-label={label || 'PIN input'}
116
173
  aria-describedby={error ? `${groupId}-error` : undefined}
@@ -120,7 +177,12 @@
120
177
  bind:this={inputEls[i]}
121
178
  type={masked ? 'password' : 'text'}
122
179
  inputmode={numeric ? 'numeric' : 'text'}
123
- maxlength={1}
180
+ pattern={numeric ? '[0-9]*' : undefined}
181
+ autocomplete={autocomplete as any}
182
+ name={i === 0 ? name : undefined}
183
+ dir="ltr"
184
+ spellcheck="false"
185
+ autocapitalize="off"
124
186
  value={cells[i]}
125
187
  {disabled}
126
188
  aria-label={`Digit ${i + 1} of ${length}`}
@@ -129,6 +191,7 @@
129
191
  class:pin-cell--filled={!!cells[i]}
130
192
  class:pin-cell--pop={poppingCells[i]}
131
193
  oninput={(e) => onInput(i, e)}
194
+ onpaste={(e) => onPaste(i, e)}
132
195
  onkeydown={(e) => onKeydown(i, e)}
133
196
  onfocus={(e) => onFocus(e.target as HTMLInputElement)}
134
197
  />
@@ -6,6 +6,10 @@ interface Props {
6
6
  error?: string;
7
7
  disabled?: boolean;
8
8
  label?: string;
9
+ /** `name` on the first cell, so a plain form post carries the code. */
10
+ name?: string;
11
+ /** Override the autocomplete token. Default `one-time-code`; use `off` for a static PIN. */
12
+ autocomplete?: HTMLInputElement['autocomplete'];
9
13
  oncomplete?: (value: string) => void;
10
14
  }
11
15
  declare const PinInput: import("svelte").Component<Props, {}, "value">;