@joewinke/jatui 0.42.0 → 0.44.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 +24 -3
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/dist/actions/dismissOutside.d.ts +52 -0
- package/dist/actions/dismissOutside.js +80 -0
- package/dist/components/FileDropzone.svelte +165 -3
- package/dist/components/FileDropzone.svelte.d.ts +9 -0
- package/dist/components/ImageReveal.svelte +353 -0
- package/dist/components/ImageReveal.svelte.d.ts +31 -0
- package/dist/components/TextInput.svelte +71 -20
- package/dist/components/TextInput.svelte.d.ts +7 -0
- package/dist/components/ThumbnailGrid.svelte +106 -1
- package/dist/components/ThumbnailGrid.svelte.d.ts +7 -0
- package/dist/components/basic/Alert.svelte +113 -16
- package/dist/components/basic/Alert.svelte.d.ts +17 -1
- package/dist/components/basic/Checkbox.svelte +53 -1
- package/dist/components/basic/Checkbox.svelte.d.ts +6 -0
- package/dist/components/basic/RadioGroup.svelte +59 -3
- package/dist/components/basic/RadioGroup.svelte.d.ts +15 -0
- package/dist/components/basic/Slider.svelte +257 -9
- package/dist/components/basic/Slider.svelte.d.ts +14 -2
- package/dist/components/basic/sliderMath.d.ts +29 -0
- package/dist/components/basic/sliderMath.js +64 -0
- package/dist/components/field/FieldHint.svelte +29 -0
- package/dist/components/field/FieldHint.svelte.d.ts +17 -0
- package/dist/components/field/FieldLabel.svelte +100 -0
- package/dist/components/field/FieldLabel.svelte.d.ts +29 -0
- package/dist/components/form-cards/CheckGlyph.svelte +138 -0
- package/dist/components/form-cards/CheckGlyph.svelte.d.ts +29 -0
- package/dist/components/form-cards/CheckboxCard.svelte +120 -0
- package/dist/components/form-cards/CheckboxCard.svelte.d.ts +33 -0
- package/dist/components/form-cards/ChoiceCard.svelte +156 -0
- package/dist/components/form-cards/ChoiceCard.svelte.d.ts +42 -0
- package/dist/components/form-cards/SwitchCard.svelte +114 -0
- package/dist/components/form-cards/SwitchCard.svelte.d.ts +28 -0
- package/dist/components/imageReveal.d.ts +51 -0
- package/dist/components/imageReveal.js +83 -0
- package/dist/components/toast/ToastHost.svelte +56 -3
- package/dist/components/toast/ToastHost.svelte.d.ts +9 -1
- package/dist/components/toast/ToastItem.svelte +75 -23
- package/dist/components/toast/ToastItem.svelte.d.ts +2 -0
- package/dist/components/uploadProgress.d.ts +30 -0
- package/dist/components/uploadProgress.js +47 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +13 -1
- package/dist/stores/toast.d.ts +35 -0
- package/dist/stores/toast.js +15 -1
- package/dist/styles/motion.css +72 -0
- package/dist/styles/rail.css +51 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
|
+
import { clampProgress, dashOffset, isInFlight, progressPercent } from './uploadProgress.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Generic thumbnail item — the decoupled chimaro shape. Unlike chimaro's
|
|
@@ -13,6 +14,13 @@
|
|
|
13
14
|
url?: string | null;
|
|
14
15
|
/** Alt text / display label. */
|
|
15
16
|
alt: string;
|
|
17
|
+
/**
|
|
18
|
+
* Upload progress, 0–100. While below 100 a ring draws clockwise around
|
|
19
|
+
* the tile and a tiny % sits in the remove-× corner; at 100 the %
|
|
20
|
+
* cross-fades into the × (same slot, no layout shift). Omit for a
|
|
21
|
+
* plain tile.
|
|
22
|
+
*/
|
|
23
|
+
progress?: number | null;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
interface Props {
|
|
@@ -91,6 +99,8 @@
|
|
|
91
99
|
: 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100'
|
|
92
100
|
);
|
|
93
101
|
|
|
102
|
+
const hiddenWhileUploading = 'pointer-events-none opacity-0';
|
|
103
|
+
|
|
94
104
|
function shortLabel(alt: string): string {
|
|
95
105
|
return alt.length > 14 ? `${alt.slice(0, 14)}…` : alt;
|
|
96
106
|
}
|
|
@@ -98,6 +108,8 @@
|
|
|
98
108
|
|
|
99
109
|
<div class="{isGrid ? 'grid gap-2' : 'flex flex-wrap items-center gap-2'} {gridCols ?? ''} {className}">
|
|
100
110
|
{#each visible as item (item.id)}
|
|
111
|
+
{@const progressValue = clampProgress(item.progress)}
|
|
112
|
+
{@const uploading = isInFlight(item.progress)}
|
|
101
113
|
<div class="group relative">
|
|
102
114
|
<button
|
|
103
115
|
type="button"
|
|
@@ -120,10 +132,49 @@
|
|
|
120
132
|
{/if}
|
|
121
133
|
</button>
|
|
122
134
|
|
|
135
|
+
{#if progressValue !== null}
|
|
136
|
+
<!-- Upload ring: drawn ON the tile border (overflow visible, so the
|
|
137
|
+
2px stroke straddles the edge); a sibling of the tile button so
|
|
138
|
+
the progressbar role is not flattened by the button. -->
|
|
139
|
+
<span
|
|
140
|
+
class="jatui-thumb-progress pointer-events-none absolute inset-0"
|
|
141
|
+
class:is-uploading={uploading}
|
|
142
|
+
role={uploading ? 'progressbar' : undefined}
|
|
143
|
+
aria-label={uploading ? `Uploading ${item.alt}` : undefined}
|
|
144
|
+
aria-valuemin={uploading ? 0 : undefined}
|
|
145
|
+
aria-valuemax={uploading ? 100 : undefined}
|
|
146
|
+
aria-valuenow={uploading ? progressPercent(progressValue) : undefined}
|
|
147
|
+
aria-hidden={uploading ? undefined : 'true'}
|
|
148
|
+
>
|
|
149
|
+
<svg class="jatui-thumb-ring absolute inset-0 h-full w-full" aria-hidden="true">
|
|
150
|
+
<rect class="jatui-thumb-track" width="100%" height="100%" rx="4" />
|
|
151
|
+
<rect
|
|
152
|
+
class="jatui-thumb-fill"
|
|
153
|
+
width="100%"
|
|
154
|
+
height="100%"
|
|
155
|
+
rx="4"
|
|
156
|
+
pathLength="1"
|
|
157
|
+
stroke-dasharray="1"
|
|
158
|
+
stroke-dashoffset={dashOffset(progressValue, 1)}
|
|
159
|
+
/>
|
|
160
|
+
</svg>
|
|
161
|
+
<span
|
|
162
|
+
class="jatui-thumb-pct absolute -top-1 -right-1 z-20 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-0.5 text-primary-content tabular-nums"
|
|
163
|
+
aria-hidden="true"
|
|
164
|
+
>
|
|
165
|
+
{progressPercent(progressValue)}
|
|
166
|
+
</span>
|
|
167
|
+
</span>
|
|
168
|
+
{/if}
|
|
169
|
+
|
|
123
170
|
{#if removable && onRemove}
|
|
124
171
|
<button
|
|
125
172
|
type="button"
|
|
126
|
-
class="absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content {
|
|
173
|
+
class="jatui-thumb-remove absolute -top-1 -right-1 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-error text-error-content {uploading
|
|
174
|
+
? hiddenWhileUploading
|
|
175
|
+
: removeVisibility}"
|
|
176
|
+
tabindex={uploading ? -1 : undefined}
|
|
177
|
+
aria-hidden={uploading ? 'true' : undefined}
|
|
127
178
|
onclick={(e) => {
|
|
128
179
|
e.stopPropagation();
|
|
129
180
|
onRemove(item);
|
|
@@ -162,3 +213,57 @@
|
|
|
162
213
|
</button>
|
|
163
214
|
{/if}
|
|
164
215
|
</div>
|
|
216
|
+
|
|
217
|
+
<style>
|
|
218
|
+
/* Upload ring + % badge (jatui-bns3w). Opacity/stroke only, on motion
|
|
219
|
+
tokens — under prefers-reduced-motion the tokens collapse to 0ms, so
|
|
220
|
+
the % → × swap is instant. */
|
|
221
|
+
.jatui-thumb-ring {
|
|
222
|
+
overflow: visible;
|
|
223
|
+
transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.jatui-thumb-progress:not(.is-uploading) .jatui-thumb-ring {
|
|
227
|
+
opacity: 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.jatui-thumb-track,
|
|
231
|
+
.jatui-thumb-fill {
|
|
232
|
+
fill: none;
|
|
233
|
+
stroke-width: 2;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.jatui-thumb-track {
|
|
237
|
+
stroke: color-mix(in oklch, var(--color-base-content) 15%, transparent);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.jatui-thumb-fill {
|
|
241
|
+
stroke: var(--color-primary);
|
|
242
|
+
stroke-linecap: round;
|
|
243
|
+
transition: stroke-dashoffset var(--motion-move, 300ms) var(--motion-glide, ease-out);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.jatui-thumb-pct {
|
|
247
|
+
font-size: 0.5rem;
|
|
248
|
+
line-height: 1;
|
|
249
|
+
font-weight: 600;
|
|
250
|
+
transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.jatui-thumb-progress:not(.is-uploading) .jatui-thumb-pct {
|
|
254
|
+
opacity: 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.jatui-thumb-remove {
|
|
258
|
+
transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@media (prefers-reduced-motion: reduce) {
|
|
262
|
+
.jatui-thumb-ring,
|
|
263
|
+
.jatui-thumb-fill,
|
|
264
|
+
.jatui-thumb-pct,
|
|
265
|
+
.jatui-thumb-remove {
|
|
266
|
+
transition: none;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
</style>
|
|
@@ -11,6 +11,13 @@ export interface ThumbnailItem {
|
|
|
11
11
|
url?: string | null;
|
|
12
12
|
/** Alt text / display label. */
|
|
13
13
|
alt: string;
|
|
14
|
+
/**
|
|
15
|
+
* Upload progress, 0–100. While below 100 a ring draws clockwise around
|
|
16
|
+
* the tile and a tiny % sits in the remove-× corner; at 100 the %
|
|
17
|
+
* cross-fades into the × (same slot, no layout shift). Omit for a
|
|
18
|
+
* plain tile.
|
|
19
|
+
*/
|
|
20
|
+
progress?: number | null;
|
|
14
21
|
}
|
|
15
22
|
interface Props {
|
|
16
23
|
items: ThumbnailItem[];
|
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { motionScale } from '../../motion/transitions';
|
|
4
|
+
import CloseButton from '../CloseButton.svelte';
|
|
5
|
+
|
|
2
6
|
interface Props {
|
|
3
7
|
type?: 'info' | 'success' | 'warning' | 'error';
|
|
4
8
|
title?: string;
|
|
5
9
|
class?: string;
|
|
6
|
-
children?:
|
|
10
|
+
children?: Snippet;
|
|
11
|
+
/** Show a Dismiss (×) button. The alert exits, then `onClose` fires. */
|
|
12
|
+
dismissible?: boolean;
|
|
13
|
+
/** Fired after the dismiss exit transition has finished. */
|
|
14
|
+
onClose?: () => void;
|
|
15
|
+
/** Call-to-action area (buttons/links). Full-width in `variant="card"`. */
|
|
16
|
+
action?: Snippet;
|
|
17
|
+
/**
|
|
18
|
+
* Delay (ms) before the alert enters — lets surrounding UI settle first.
|
|
19
|
+
* Reduced motion: it still waits, then appears without animating.
|
|
20
|
+
*/
|
|
21
|
+
introDelay?: number;
|
|
22
|
+
/** 'card' = vertical, sidebar-density layout: icon on top, title, text, CTA. */
|
|
23
|
+
variant?: 'default' | 'card';
|
|
24
|
+
/** Accessible label for the dismiss button. */
|
|
25
|
+
dismissLabel?: string;
|
|
7
26
|
}
|
|
8
27
|
|
|
9
|
-
let {
|
|
28
|
+
let {
|
|
29
|
+
type = 'info',
|
|
30
|
+
title,
|
|
31
|
+
class: className = '',
|
|
32
|
+
children,
|
|
33
|
+
dismissible = false,
|
|
34
|
+
onClose,
|
|
35
|
+
action,
|
|
36
|
+
introDelay = 0,
|
|
37
|
+
variant = 'default',
|
|
38
|
+
dismissLabel = 'Dismiss'
|
|
39
|
+
}: Props = $props();
|
|
10
40
|
|
|
11
41
|
const typeClass = $derived(
|
|
12
42
|
{ info: 'alert-info', success: 'alert-success', warning: 'alert-warning', error: 'alert-error' }[type]
|
|
@@ -18,19 +48,86 @@
|
|
|
18
48
|
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
|
19
49
|
error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z'
|
|
20
50
|
};
|
|
51
|
+
|
|
52
|
+
// introDelay: start hidden, enter after the delay (the `in:` transition only
|
|
53
|
+
// plays on a toggle, never on first render — so no delay means no intro).
|
|
54
|
+
let visible = $state(introDelay <= 0);
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (visible || introDelay <= 0) return;
|
|
57
|
+
const t = setTimeout(() => (visible = true), introDelay);
|
|
58
|
+
return () => clearTimeout(t);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function dismiss() {
|
|
62
|
+
visible = false;
|
|
63
|
+
}
|
|
21
64
|
</script>
|
|
22
65
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
{#snippet closeButton(extra: string)}
|
|
67
|
+
<CloseButton
|
|
68
|
+
label={dismissLabel}
|
|
69
|
+
size="xs"
|
|
70
|
+
shape="square"
|
|
71
|
+
class="jatui-alert-close {extra}"
|
|
72
|
+
onclick={dismiss}
|
|
73
|
+
/>
|
|
74
|
+
{/snippet}
|
|
75
|
+
|
|
76
|
+
<!-- `{#if visible}` wraps each element directly: transitions are local, so
|
|
77
|
+
they only play when their IMMEDIATE block toggles. -->
|
|
78
|
+
{#if variant === 'card'}
|
|
79
|
+
{#if visible}
|
|
80
|
+
<div
|
|
81
|
+
class="alert {typeClass} jatui-alert-card relative flex flex-col items-start gap-2 text-start {dismissible ? 'pe-10' : ''} {className}"
|
|
82
|
+
role={type === 'error' ? 'alert' : 'status'}
|
|
83
|
+
in:motionScale={{ start: 0.95 }}
|
|
84
|
+
out:motionScale={{ start: 0.85 }}
|
|
85
|
+
onoutroend={() => onClose?.()}
|
|
86
|
+
>
|
|
87
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
88
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icons[type]} />
|
|
89
|
+
</svg>
|
|
90
|
+
<div class="w-full min-w-0">
|
|
91
|
+
{#if title}
|
|
92
|
+
<h3 class="font-bold">{title}</h3>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if children}<div class="text-sm opacity-80">{@render children()}</div>{/if}
|
|
95
|
+
</div>
|
|
96
|
+
{#if action}
|
|
97
|
+
<div class="jatui-alert-action jatui-alert-action--full w-full">{@render action()}</div>
|
|
98
|
+
{/if}
|
|
99
|
+
{#if dismissible}{@render closeButton('absolute top-2 end-2')}{/if}
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
102
|
+
{:else}
|
|
103
|
+
{#if visible}
|
|
104
|
+
<div
|
|
105
|
+
class="alert {typeClass} {className}"
|
|
106
|
+
role={type === 'error' ? 'alert' : 'status'}
|
|
107
|
+
in:motionScale={{ start: 0.95 }}
|
|
108
|
+
out:motionScale={{ start: 0.85 }}
|
|
109
|
+
onoutroend={() => onClose?.()}
|
|
110
|
+
>
|
|
111
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
112
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icons[type]} />
|
|
113
|
+
</svg>
|
|
114
|
+
<div>
|
|
115
|
+
{#if title}
|
|
116
|
+
<h3 class="font-bold">{title}</h3>
|
|
117
|
+
{/if}
|
|
118
|
+
{#if children}{@render children()}{/if}
|
|
119
|
+
</div>
|
|
120
|
+
{#if action}
|
|
121
|
+
<div class="jatui-alert-action flex flex-wrap items-center gap-2">{@render action()}</div>
|
|
122
|
+
{/if}
|
|
123
|
+
{#if dismissible}{@render closeButton('')}{/if}
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
{/if}
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
/* Full-width CTA in the card layout: stretch any direct button/link child. */
|
|
130
|
+
.jatui-alert-action--full > :global(*) {
|
|
131
|
+
width: 100%;
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
interface Props {
|
|
2
3
|
type?: 'info' | 'success' | 'warning' | 'error';
|
|
3
4
|
title?: string;
|
|
4
5
|
class?: string;
|
|
5
|
-
children?:
|
|
6
|
+
children?: Snippet;
|
|
7
|
+
/** Show a Dismiss (×) button. The alert exits, then `onClose` fires. */
|
|
8
|
+
dismissible?: boolean;
|
|
9
|
+
/** Fired after the dismiss exit transition has finished. */
|
|
10
|
+
onClose?: () => void;
|
|
11
|
+
/** Call-to-action area (buttons/links). Full-width in `variant="card"`. */
|
|
12
|
+
action?: Snippet;
|
|
13
|
+
/**
|
|
14
|
+
* Delay (ms) before the alert enters — lets surrounding UI settle first.
|
|
15
|
+
* Reduced motion: it still waits, then appears without animating.
|
|
16
|
+
*/
|
|
17
|
+
introDelay?: number;
|
|
18
|
+
/** 'card' = vertical, sidebar-density layout: icon on top, title, text, CTA. */
|
|
19
|
+
variant?: 'default' | 'card';
|
|
20
|
+
/** Accessible label for the dismiss button. */
|
|
21
|
+
dismissLabel?: string;
|
|
6
22
|
}
|
|
7
23
|
declare const Alert: import("svelte").Component<Props, {}, "">;
|
|
8
24
|
type Alert = ReturnType<typeof Alert>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
import CheckGlyph from '../form-cards/CheckGlyph.svelte';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Any other native attribute (aria-label, aria-describedby, data-*, title,
|
|
@@ -25,6 +26,12 @@
|
|
|
25
26
|
id?: string;
|
|
26
27
|
onchange?: (checked: boolean) => void;
|
|
27
28
|
class?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Draw the box with the shared `CheckGlyph` (the same one `CheckboxCard`
|
|
31
|
+
* uses) and draw the tick on when the user checks it. The native input
|
|
32
|
+
* stays in the DOM, visually hidden, so keys, forms and AT are unchanged.
|
|
33
|
+
*/
|
|
34
|
+
animated?: boolean;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
let {
|
|
@@ -38,9 +45,14 @@
|
|
|
38
45
|
id,
|
|
39
46
|
onchange,
|
|
40
47
|
class: className = '',
|
|
48
|
+
animated = false,
|
|
41
49
|
...rest
|
|
42
50
|
}: Props = $props();
|
|
43
51
|
|
|
52
|
+
// Only animate the tick after the user has changed the box, so a box that
|
|
53
|
+
// renders pre-checked shows its tick at rest instead of drawing it on load.
|
|
54
|
+
let interacted = $state(false);
|
|
55
|
+
|
|
44
56
|
const sizeClass = $derived({ xs: 'checkbox-xs', sm: 'checkbox-sm', md: '', lg: 'checkbox-lg' }[size]);
|
|
45
57
|
const variantClass = $derived(
|
|
46
58
|
{
|
|
@@ -68,11 +80,32 @@
|
|
|
68
80
|
|
|
69
81
|
function handleChange(e: Event) {
|
|
70
82
|
checked = (e.target as HTMLInputElement).checked;
|
|
83
|
+
interacted = true;
|
|
71
84
|
onchange?.(checked);
|
|
72
85
|
}
|
|
73
86
|
</script>
|
|
74
87
|
|
|
75
|
-
{#if
|
|
88
|
+
{#if animated}
|
|
89
|
+
<label
|
|
90
|
+
class="jatui-checkbox-target jatui-checkbox-animated {label ? 'jatui-checkbox-row' : 'jatui-checkbox-bare'} {className}"
|
|
91
|
+
class:jatui-checkbox-disabled={disabled}
|
|
92
|
+
for={inputId}
|
|
93
|
+
>
|
|
94
|
+
<input
|
|
95
|
+
{...rest}
|
|
96
|
+
bind:this={inputEl}
|
|
97
|
+
type="checkbox"
|
|
98
|
+
{name}
|
|
99
|
+
id={inputId}
|
|
100
|
+
class="jatui-checkbox-native"
|
|
101
|
+
checked={checked}
|
|
102
|
+
{disabled}
|
|
103
|
+
onchange={handleChange}
|
|
104
|
+
/>
|
|
105
|
+
<CheckGlyph {checked} {indeterminate} {disabled} {size} {variant} animate={interacted} />
|
|
106
|
+
{#if label}<span class="label-text">{label}</span>{/if}
|
|
107
|
+
</label>
|
|
108
|
+
{:else if label}
|
|
76
109
|
<label class="jatui-checkbox-target jatui-checkbox-row" for={inputId}>
|
|
77
110
|
<input
|
|
78
111
|
{...rest}
|
|
@@ -114,6 +147,25 @@
|
|
|
114
147
|
display: flex;
|
|
115
148
|
gap: 0.5rem;
|
|
116
149
|
}
|
|
150
|
+
/* animated: the native input is visually hidden but stays focusable */
|
|
151
|
+
.jatui-checkbox-animated {
|
|
152
|
+
position: relative;
|
|
153
|
+
}
|
|
154
|
+
.jatui-checkbox-native {
|
|
155
|
+
position: absolute;
|
|
156
|
+
width: 1px;
|
|
157
|
+
height: 1px;
|
|
158
|
+
margin: 0;
|
|
159
|
+
opacity: 0;
|
|
160
|
+
pointer-events: none;
|
|
161
|
+
}
|
|
162
|
+
.jatui-checkbox-animated:has(.jatui-checkbox-native:focus-visible) :global(.jatui-check-glyph) {
|
|
163
|
+
outline: 2px solid color-mix(in oklch, var(--color-primary) 65%, transparent);
|
|
164
|
+
outline-offset: 2px;
|
|
165
|
+
}
|
|
166
|
+
.jatui-checkbox-disabled {
|
|
167
|
+
cursor: not-allowed;
|
|
168
|
+
}
|
|
117
169
|
.jatui-checkbox-bare {
|
|
118
170
|
display: inline-flex;
|
|
119
171
|
justify-content: center;
|
|
@@ -19,6 +19,12 @@ interface Props extends Omit<HTMLInputAttributes, 'type' | 'checked' | 'size' |
|
|
|
19
19
|
id?: string;
|
|
20
20
|
onchange?: (checked: boolean) => void;
|
|
21
21
|
class?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Draw the box with the shared `CheckGlyph` (the same one `CheckboxCard`
|
|
24
|
+
* uses) and draw the tick on when the user checks it. The native input
|
|
25
|
+
* stays in the DOM, visually hidden, so keys, forms and AT are unchanged.
|
|
26
|
+
*/
|
|
27
|
+
animated?: boolean;
|
|
22
28
|
}
|
|
23
29
|
declare const Checkbox: import("svelte").Component<Props, {}, "checked">;
|
|
24
30
|
type Checkbox = ReturnType<typeof Checkbox>;
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import ChoiceCard from '../form-cards/ChoiceCard.svelte';
|
|
4
|
+
|
|
2
5
|
export interface RadioOption {
|
|
3
6
|
value: string;
|
|
4
7
|
label: string;
|
|
5
8
|
disabled?: boolean;
|
|
9
|
+
/** Secondary line — shown by `variant="card"`. */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Leading icon — shown by `variant="card"`. */
|
|
12
|
+
icon?: Snippet;
|
|
6
13
|
}
|
|
7
14
|
|
|
8
15
|
interface Props {
|
|
@@ -14,6 +21,16 @@
|
|
|
14
21
|
orientation?: 'horizontal' | 'vertical';
|
|
15
22
|
onchange?: (value: string) => void;
|
|
16
23
|
class?: string;
|
|
24
|
+
/**
|
|
25
|
+
* `card` renders each option as a ChoiceCard (title + description +
|
|
26
|
+
* icon, whole card clickable). The radios stay native, so arrow keys
|
|
27
|
+
* still rove inside the group.
|
|
28
|
+
*/
|
|
29
|
+
display?: 'default' | 'card';
|
|
30
|
+
/** Accessible name for the group (`aria-label`). */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Where the radio dot sits on a card. Default `start`. */
|
|
33
|
+
controlPosition?: 'start' | 'end';
|
|
17
34
|
}
|
|
18
35
|
|
|
19
36
|
let {
|
|
@@ -24,7 +41,10 @@
|
|
|
24
41
|
variant = 'primary',
|
|
25
42
|
orientation = 'vertical',
|
|
26
43
|
onchange,
|
|
27
|
-
class: className = ''
|
|
44
|
+
class: className = '',
|
|
45
|
+
display = 'default',
|
|
46
|
+
label,
|
|
47
|
+
controlPosition = 'start'
|
|
28
48
|
}: Props = $props();
|
|
29
49
|
|
|
30
50
|
const sizeClass = $derived({ xs: 'radio-xs', sm: 'radio-sm', md: '', lg: 'radio-lg' }[size]);
|
|
@@ -42,7 +62,15 @@
|
|
|
42
62
|
);
|
|
43
63
|
const radioClass = $derived(['radio', sizeClass, variantClass].filter(Boolean).join(' '));
|
|
44
64
|
const groupClass = $derived(
|
|
45
|
-
[
|
|
65
|
+
[
|
|
66
|
+
'flex',
|
|
67
|
+
orientation === 'horizontal'
|
|
68
|
+
? display === 'card'
|
|
69
|
+
? 'flex-row flex-wrap gap-3'
|
|
70
|
+
: 'flex-row flex-wrap gap-4'
|
|
71
|
+
: 'flex-col gap-2',
|
|
72
|
+
className
|
|
73
|
+
].join(' ')
|
|
46
74
|
);
|
|
47
75
|
|
|
48
76
|
function handleChange(e: Event, optValue: string) {
|
|
@@ -51,9 +79,36 @@
|
|
|
51
79
|
}
|
|
52
80
|
</script>
|
|
53
81
|
|
|
54
|
-
<div class={groupClass} role="radiogroup">
|
|
82
|
+
<div class={groupClass} role="radiogroup" aria-label={label}>
|
|
55
83
|
{#each options as opt}
|
|
56
84
|
{@const inputId = `${name}-${opt.value}`}
|
|
85
|
+
{#if display === 'card'}
|
|
86
|
+
<ChoiceCard
|
|
87
|
+
title={opt.label}
|
|
88
|
+
description={opt.description}
|
|
89
|
+
icon={opt.icon}
|
|
90
|
+
disabled={opt.disabled}
|
|
91
|
+
size={size === 'xs' || size === 'sm' ? 'sm' : 'md'}
|
|
92
|
+
{controlPosition}
|
|
93
|
+
for={inputId}
|
|
94
|
+
descriptionId={opt.description ? `${inputId}-desc` : undefined}
|
|
95
|
+
class={orientation === 'horizontal' ? 'min-w-48 flex-1' : ''}
|
|
96
|
+
>
|
|
97
|
+
{#snippet control()}
|
|
98
|
+
<input
|
|
99
|
+
type="radio"
|
|
100
|
+
id={inputId}
|
|
101
|
+
{name}
|
|
102
|
+
value={opt.value}
|
|
103
|
+
class={radioClass}
|
|
104
|
+
checked={value === opt.value}
|
|
105
|
+
disabled={opt.disabled}
|
|
106
|
+
aria-describedby={opt.description ? `${inputId}-desc` : undefined}
|
|
107
|
+
onchange={(e) => handleChange(e, opt.value)}
|
|
108
|
+
/>
|
|
109
|
+
{/snippet}
|
|
110
|
+
</ChoiceCard>
|
|
111
|
+
{:else}
|
|
57
112
|
<label class="flex cursor-pointer items-center gap-2" for={inputId}>
|
|
58
113
|
<input
|
|
59
114
|
type="radio"
|
|
@@ -67,5 +122,6 @@
|
|
|
67
122
|
/>
|
|
68
123
|
<span class="label-text">{opt.label}</span>
|
|
69
124
|
</label>
|
|
125
|
+
{/if}
|
|
70
126
|
{/each}
|
|
71
127
|
</div>
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
export interface RadioOption {
|
|
2
3
|
value: string;
|
|
3
4
|
label: string;
|
|
4
5
|
disabled?: boolean;
|
|
6
|
+
/** Secondary line — shown by `variant="card"`. */
|
|
7
|
+
description?: string;
|
|
8
|
+
/** Leading icon — shown by `variant="card"`. */
|
|
9
|
+
icon?: Snippet;
|
|
5
10
|
}
|
|
6
11
|
interface Props {
|
|
7
12
|
value?: string;
|
|
@@ -12,6 +17,16 @@ interface Props {
|
|
|
12
17
|
orientation?: 'horizontal' | 'vertical';
|
|
13
18
|
onchange?: (value: string) => void;
|
|
14
19
|
class?: string;
|
|
20
|
+
/**
|
|
21
|
+
* `card` renders each option as a ChoiceCard (title + description +
|
|
22
|
+
* icon, whole card clickable). The radios stay native, so arrow keys
|
|
23
|
+
* still rove inside the group.
|
|
24
|
+
*/
|
|
25
|
+
display?: 'default' | 'card';
|
|
26
|
+
/** Accessible name for the group (`aria-label`). */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** Where the radio dot sits on a card. Default `start`. */
|
|
29
|
+
controlPosition?: 'start' | 'end';
|
|
15
30
|
}
|
|
16
31
|
declare const RadioGroup: import("svelte").Component<Props, {}, "value">;
|
|
17
32
|
type RadioGroup = ReturnType<typeof RadioGroup>;
|