@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.
- package/CLAUDE.md +23 -5
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/dist/actions/scrollEdge.d.ts +55 -0
- package/dist/actions/scrollEdge.js +144 -0
- package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
- package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
- package/dist/components/BottomSheet.svelte +7 -6
- package/dist/components/BottomSheet.svelte.d.ts +2 -0
- package/dist/components/CloseButton.svelte +75 -0
- package/dist/components/CloseButton.svelte.d.ts +29 -0
- package/dist/components/Drawer.svelte +10 -11
- package/dist/components/Drawer.svelte.d.ts +2 -8
- package/dist/components/IconButton.svelte +200 -0
- package/dist/components/IconButton.svelte.d.ts +46 -0
- package/dist/components/LinkButton.svelte +117 -0
- package/dist/components/LinkButton.svelte.d.ts +32 -0
- package/dist/components/MediaLightbox.svelte +2 -7
- package/dist/components/Modal.svelte +2 -10
- package/dist/components/PinInput.svelte +94 -31
- package/dist/components/PinInput.svelte.d.ts +4 -0
- package/dist/components/TextArea.svelte +67 -5
- package/dist/components/TextArea.svelte.d.ts +4 -6
- package/dist/components/ThemeSelector.svelte +29 -9
- package/dist/components/ThemeSelector.svelte.d.ts +6 -0
- package/dist/components/ThemeToggle.svelte +28 -3
- package/dist/components/ThemeToggle.svelte.d.ts +9 -0
- package/dist/components/basic/Breadcrumb.svelte +226 -11
- package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
- package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
- package/dist/components/basic/breadcrumbCollapse.js +12 -0
- package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
- package/dist/index.d.ts +14 -1
- package/dist/index.js +10 -1
- package/dist/styles/tokens.css +80 -1
- package/dist/utils/pinInput.d.ts +33 -0
- package/dist/utils/pinInput.js +63 -0
- package/dist/utils/textareaRows.d.ts +23 -0
- package/dist/utils/textareaRows.js +13 -0
- package/dist/utils/themeWipe.d.ts +36 -17
- package/dist/utils/themeWipe.js +145 -35
- 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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
124
|
+
if (i > 0) {
|
|
125
|
+
const next = [...cells];
|
|
126
|
+
next[i - 1] = '';
|
|
127
|
+
commit(next, i - 1);
|
|
128
|
+
}
|
|
86
129
|
} else {
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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">;
|