@dorsk/tsumikit 0.79.0 → 0.80.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/dist/components/atoms/Badge.svelte +1 -4
- package/dist/components/atoms/Button.svelte +303 -9
- package/dist/components/atoms/Button.svelte.d.ts +4 -1
- package/dist/components/atoms/Card.svelte +142 -0
- package/dist/components/atoms/Checkbox.svelte +177 -1
- package/dist/components/atoms/Input.svelte +35 -0
- package/dist/components/atoms/Link.svelte +61 -0
- package/dist/components/atoms/Progress.svelte +74 -1
- package/dist/components/atoms/Progress.svelte.d.ts +1 -0
- package/dist/components/atoms/Select.svelte +30 -0
- package/dist/components/atoms/Skeleton.svelte +54 -3
- package/dist/components/atoms/Skeleton.svelte.d.ts +1 -0
- package/dist/components/atoms/Spinner.svelte +152 -3
- package/dist/components/atoms/Spinner.svelte.d.ts +1 -1
- package/dist/components/atoms/Switch.svelte +135 -0
- package/dist/components/atoms/Textarea.svelte +36 -0
- package/dist/components/atoms/Thinking.svelte +130 -0
- package/dist/components/atoms/Thinking.svelte.d.ts +15 -0
- package/dist/components/molecules/CopyButton.svelte +146 -5
- package/dist/components/molecules/Field.svelte +55 -1
- package/dist/components/molecules/FileButton.svelte +4 -2
- package/dist/components/molecules/InputGroup.svelte +1 -1
- package/dist/components/molecules/LookPicker.svelte +87 -0
- package/dist/components/molecules/LookPicker.svelte.d.ts +17 -0
- package/dist/components/molecules/MotionSettings.svelte +166 -0
- package/dist/components/molecules/MotionSettings.svelte.d.ts +18 -0
- package/dist/components/molecules/RadioGroup.svelte +61 -0
- package/dist/components/molecules/Tabs.svelte +157 -4
- package/dist/fx.d.ts +149 -0
- package/dist/fx.js +111 -0
- package/dist/fx.md +58 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +8 -2
- package/dist/looks/README.md +101 -0
- package/dist/looks/depth.css +939 -0
- package/dist/looks/editorial.css +545 -0
- package/dist/looks/tactile.css +871 -0
- package/dist/pointer.d.ts +3 -0
- package/dist/pointer.js +40 -0
- package/dist/stores/fx.svelte.d.ts +17 -0
- package/dist/stores/fx.svelte.js +64 -0
- package/dist/stores/theme.svelte.d.ts +8 -2
- package/dist/stores/theme.svelte.js +33 -7
- package/dist/styles/themes.css +249 -249
- package/dist/styles/tokens.css +4 -3
- package/dist/theme-mode.d.ts +31 -0
- package/dist/theme-mode.js +19 -0
- package/package.json +3 -2
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
const labelShown = $derived(showLabel ?? box === undefined);
|
|
41
41
|
let copied = $state(false);
|
|
42
|
+
let run = $state(0);
|
|
42
43
|
let status = $state('');
|
|
43
44
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
44
45
|
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
const ok = await copyToClipboard(text);
|
|
47
48
|
playSound(ok ? 'success' : 'error', sound);
|
|
48
49
|
copied = ok;
|
|
50
|
+
if (ok) run++;
|
|
49
51
|
status = ok ? copiedLabel : 'Copy failed';
|
|
50
52
|
clearTimeout(timer);
|
|
51
53
|
timer = setTimeout(() => {
|
|
@@ -63,26 +65,33 @@
|
|
|
63
65
|
class="copy-btn {labelShown && box !== undefined ? 'copy-labelled' : ''} {klass}"
|
|
64
66
|
sound={false}
|
|
65
67
|
onclick={copy}
|
|
68
|
+
data-copied={copied || undefined}
|
|
66
69
|
aria-label={copied ? copiedLabel : label}
|
|
67
70
|
title={label}
|
|
68
71
|
>
|
|
69
|
-
<span class="glyphs" class:copied aria-hidden="true">
|
|
72
|
+
<span class="glyphs" class:copied class:tinted={variant === 'ghost' || variant === 'default'} aria-hidden="true">
|
|
70
73
|
<span class="glyph copy"><Icon name="copy" /></span>
|
|
71
74
|
<span class="glyph check"><Icon name="check" /></span>
|
|
75
|
+
<svg class="glyph tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
|
|
76
|
+
<path d="M4.5 12.5l5 5L19.5 7" pathLength="1" />
|
|
77
|
+
</svg>
|
|
78
|
+
{#key run}<span class="sparks"></span>{/key}
|
|
72
79
|
</span>
|
|
73
|
-
{#if labelShown}
|
|
80
|
+
{#if labelShown}
|
|
81
|
+
<span class="words" class:copied aria-hidden="true"><span class="word idle">{label}</span><span class="word done">{copiedLabel}</span></span>
|
|
82
|
+
{/if}
|
|
74
83
|
</Button>
|
|
75
84
|
<span class="sr-only" role="status" aria-live="polite">{status}</span>
|
|
76
85
|
|
|
77
86
|
<style>
|
|
78
|
-
/* Both glyphs share one grid cell
|
|
79
|
-
|
|
80
|
-
glyph fades out fast and back in over the base duration on revert. */
|
|
87
|
+
/* Both glyphs share one grid cell and both words another, so the swap never
|
|
88
|
+
changes the width. */
|
|
81
89
|
:global(.copy-btn.btn-box.copy-labelled) {
|
|
82
90
|
width: auto;
|
|
83
91
|
padding-inline: var(--sp-2);
|
|
84
92
|
}
|
|
85
93
|
.glyphs {
|
|
94
|
+
position: relative;
|
|
86
95
|
display: inline-grid;
|
|
87
96
|
place-items: center;
|
|
88
97
|
}
|
|
@@ -111,4 +120,136 @@
|
|
|
111
120
|
opacity var(--dur-spring-bounce) ease,
|
|
112
121
|
scale var(--dur-spring-bounce) var(--ease-spring-bounce);
|
|
113
122
|
}
|
|
123
|
+
.tick,
|
|
124
|
+
.sparks {
|
|
125
|
+
display: none;
|
|
126
|
+
}
|
|
127
|
+
.words {
|
|
128
|
+
display: inline-grid;
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
text-align: start;
|
|
131
|
+
}
|
|
132
|
+
.word {
|
|
133
|
+
grid-area: 1 / 1;
|
|
134
|
+
}
|
|
135
|
+
.done,
|
|
136
|
+
.copied .idle {
|
|
137
|
+
opacity: 0;
|
|
138
|
+
}
|
|
139
|
+
.copied .done {
|
|
140
|
+
opacity: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Morph burst: the check springs in on a turn inside a ring of eight sparks. */
|
|
144
|
+
.glyphs:not(:global([data-fx-copy]:not([data-fx-copy='burst'])) *),
|
|
145
|
+
:global([data-fx-copy='burst']) .glyphs:not(:global([data-fx-copy='burst'] [data-fx-copy]:not([data-fx-copy='burst'])) *) {
|
|
146
|
+
& .check {
|
|
147
|
+
scale: 0.4;
|
|
148
|
+
rotate: 60deg;
|
|
149
|
+
transition-property: opacity, scale, rotate;
|
|
150
|
+
transition-duration: var(--dur-exit-fast);
|
|
151
|
+
transition-timing-function: ease;
|
|
152
|
+
}
|
|
153
|
+
&.copied .check {
|
|
154
|
+
scale: 1;
|
|
155
|
+
rotate: 0deg;
|
|
156
|
+
transition-duration: var(--dur-fast), var(--dur-spring), var(--dur-spring);
|
|
157
|
+
transition-delay: calc(var(--dur-exit-fast) / 2);
|
|
158
|
+
transition-timing-function: ease, var(--ease-spring-bounce), var(--ease-out);
|
|
159
|
+
}
|
|
160
|
+
& .sparks {
|
|
161
|
+
--spark-a: color-mix(in srgb, var(--ok) 85%, transparent);
|
|
162
|
+
--spark-b: color-mix(in srgb, var(--accent) 85%, transparent);
|
|
163
|
+
position: absolute;
|
|
164
|
+
inset: -9px;
|
|
165
|
+
display: block;
|
|
166
|
+
pointer-events: none;
|
|
167
|
+
background:
|
|
168
|
+
radial-gradient(circle at 50% 4%, var(--spark-a) 1.6px, transparent 2.2px),
|
|
169
|
+
radial-gradient(circle at 83% 17%, var(--spark-b) 1.3px, transparent 1.9px),
|
|
170
|
+
radial-gradient(circle at 96% 50%, var(--spark-a) 1.6px, transparent 2.2px),
|
|
171
|
+
radial-gradient(circle at 83% 83%, var(--spark-b) 1.3px, transparent 1.9px),
|
|
172
|
+
radial-gradient(circle at 50% 96%, var(--spark-a) 1.6px, transparent 2.2px),
|
|
173
|
+
radial-gradient(circle at 17% 83%, var(--spark-b) 1.3px, transparent 1.9px),
|
|
174
|
+
radial-gradient(circle at 4% 50%, var(--spark-a) 1.6px, transparent 2.2px),
|
|
175
|
+
radial-gradient(circle at 17% 17%, var(--spark-b) 1.3px, transparent 1.9px);
|
|
176
|
+
opacity: 0;
|
|
177
|
+
}
|
|
178
|
+
&.copied .sparks {
|
|
179
|
+
animation: copy-burst calc(var(--dur-slow) + var(--dur-exit-fast)) var(--ease-out);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
@keyframes copy-burst {
|
|
183
|
+
0% {
|
|
184
|
+
opacity: 0;
|
|
185
|
+
scale: 0.35;
|
|
186
|
+
rotate: -20deg;
|
|
187
|
+
}
|
|
188
|
+
30% {
|
|
189
|
+
opacity: 1;
|
|
190
|
+
}
|
|
191
|
+
100% {
|
|
192
|
+
opacity: 0;
|
|
193
|
+
scale: 1.15;
|
|
194
|
+
rotate: 0deg;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Tick and slide: the tick draws itself while the label rolls up into its
|
|
199
|
+
copied form. */
|
|
200
|
+
:global([data-fx-copy='tick']) .glyphs:not(:global([data-fx-copy='tick'] [data-fx-copy]:not([data-fx-copy='tick'])) *) {
|
|
201
|
+
& .check {
|
|
202
|
+
display: none;
|
|
203
|
+
}
|
|
204
|
+
& .tick {
|
|
205
|
+
display: block;
|
|
206
|
+
width: 1em;
|
|
207
|
+
height: 1em;
|
|
208
|
+
opacity: 0;
|
|
209
|
+
transition: opacity var(--dur-exit-fast) ease var(--dur-instant);
|
|
210
|
+
}
|
|
211
|
+
&.copied .tick {
|
|
212
|
+
opacity: 1;
|
|
213
|
+
transition-duration: var(--dur-fast);
|
|
214
|
+
}
|
|
215
|
+
& .tick path {
|
|
216
|
+
stroke-dasharray: 1;
|
|
217
|
+
stroke-dashoffset: 1;
|
|
218
|
+
transition: stroke-dashoffset var(--dur-instant) ease var(--dur-exit-fast);
|
|
219
|
+
}
|
|
220
|
+
&.copied .tick path {
|
|
221
|
+
stroke-dashoffset: 0;
|
|
222
|
+
transition: stroke-dashoffset var(--dur-slow) var(--ease-out) calc(var(--dur-fast) / 2);
|
|
223
|
+
}
|
|
224
|
+
& .copy {
|
|
225
|
+
transition-property: opacity, scale;
|
|
226
|
+
transition-duration: var(--dur-base), var(--dur-spring);
|
|
227
|
+
transition-timing-function: ease, var(--ease-spring);
|
|
228
|
+
}
|
|
229
|
+
&.copied .copy {
|
|
230
|
+
scale: 0.6;
|
|
231
|
+
transition-duration: var(--dur-exit-fast), var(--dur-exit);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
:global([data-fx-copy='tick']) .words:not(:global([data-fx-copy='tick'] [data-fx-copy]:not([data-fx-copy='tick'])) *) {
|
|
235
|
+
& .word {
|
|
236
|
+
transition-property: opacity, translate;
|
|
237
|
+
transition-duration: var(--dur-base), var(--dur-spring);
|
|
238
|
+
transition-timing-function: ease, var(--ease-spring);
|
|
239
|
+
}
|
|
240
|
+
& .done {
|
|
241
|
+
translate: 0 100%;
|
|
242
|
+
}
|
|
243
|
+
&.copied .idle {
|
|
244
|
+
translate: 0 -100%;
|
|
245
|
+
transition-duration: var(--dur-exit-fast), var(--dur-exit);
|
|
246
|
+
}
|
|
247
|
+
&.copied .done {
|
|
248
|
+
translate: 0 0;
|
|
249
|
+
transition-delay: calc(var(--dur-exit-fast) / 2);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
.tinted.copied :is(.check, .tick) {
|
|
253
|
+
color: var(--ok);
|
|
254
|
+
}
|
|
114
255
|
</style>
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
|
|
60
60
|
<div class="field {klass}" class:field-grow={grow} class:field-no-shrink={!shrink} class:field-inline={layout === 'inline'} data-tsu="Field">
|
|
61
61
|
{#if label}
|
|
62
|
-
<label class="label" for={forId} style:width={labelWidth}>{label}</label>
|
|
62
|
+
<label class="label" for={forId} style:width={labelWidth}><span class="label-text">{label}</span></label>
|
|
63
63
|
{/if}
|
|
64
64
|
{@render children?.()}
|
|
65
65
|
{#if hint}
|
|
@@ -103,4 +103,58 @@
|
|
|
103
103
|
font-size: var(--fs-xs);
|
|
104
104
|
color: var(--danger);
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/* Spring label: over a md text field the label rests inside the control and
|
|
108
|
+
springs into its top edge on focus or once filled. */
|
|
109
|
+
:global([data-fx-focus='label']) .field:not(.field-inline):not(:global([data-fx-focus='label'] [data-fx-focus]:not([data-fx-focus='label'])) *):has(> .label + :global(input.input:not(.input-sm, .input-lg, .grouped)), > .label + :global(.textarea-wrap:not(.grouped) > .textarea:not(.textarea-sm, .textarea-lg))) {
|
|
110
|
+
--float-h: 3.25rem;
|
|
111
|
+
position: relative;
|
|
112
|
+
& > .label {
|
|
113
|
+
position: absolute;
|
|
114
|
+
inset-block-start: 0;
|
|
115
|
+
inset-inline-start: 0;
|
|
116
|
+
z-index: 1;
|
|
117
|
+
display: flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
max-width: 100%;
|
|
120
|
+
height: var(--float-h);
|
|
121
|
+
padding-inline: calc(var(--control-px-md) + var(--edge-width));
|
|
122
|
+
font-size: var(--control-fs-md);
|
|
123
|
+
pointer-events: none;
|
|
124
|
+
}
|
|
125
|
+
& .label-text {
|
|
126
|
+
overflow: hidden;
|
|
127
|
+
text-overflow: ellipsis;
|
|
128
|
+
white-space: nowrap;
|
|
129
|
+
line-height: var(--lh-none);
|
|
130
|
+
transform-origin: left center;
|
|
131
|
+
transition-property: translate, scale, color;
|
|
132
|
+
transition-duration: var(--dur-spring), var(--dur-spring), var(--dur-fast);
|
|
133
|
+
transition-timing-function: var(--ease-spring-bounce), var(--ease-spring-bounce), ease;
|
|
134
|
+
}
|
|
135
|
+
& :global(:is(.input, .textarea)) {
|
|
136
|
+
min-height: var(--float-h);
|
|
137
|
+
padding-top: 1.35rem;
|
|
138
|
+
padding-bottom: 0.4rem;
|
|
139
|
+
}
|
|
140
|
+
& :global(:is(.input, .textarea):not(:focus)::placeholder) {
|
|
141
|
+
color: transparent;
|
|
142
|
+
}
|
|
143
|
+
& :global(:is(.input, .textarea):focus) {
|
|
144
|
+
animation: none;
|
|
145
|
+
box-shadow:
|
|
146
|
+
var(--control-shadow),
|
|
147
|
+
inset 0 -1px 0 var(--focus-tint);
|
|
148
|
+
}
|
|
149
|
+
&:has(:global(:is(.input, .textarea):is(:focus, :not(:placeholder-shown), :autofill))) .label-text {
|
|
150
|
+
translate: 0 -0.7rem;
|
|
151
|
+
scale: 0.78;
|
|
152
|
+
}
|
|
153
|
+
&:has(:global(:is(.input, .textarea):focus)) > .label {
|
|
154
|
+
color: var(--accent);
|
|
155
|
+
}
|
|
156
|
+
&:has(:global([aria-invalid='true'])) > .label {
|
|
157
|
+
color: var(--danger);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
106
160
|
</style>
|
|
@@ -122,7 +122,8 @@
|
|
|
122
122
|
cursor: pointer;
|
|
123
123
|
user-select: none;
|
|
124
124
|
--icon-size: var(--control-icon-md);
|
|
125
|
-
--file-scale-dur: var(--dur-
|
|
125
|
+
--file-scale-dur: var(--dur-release);
|
|
126
|
+
--file-scale-ease: var(--ease-spring-bounce);
|
|
126
127
|
transition:
|
|
127
128
|
background var(--dur-fast) ease,
|
|
128
129
|
border-color var(--dur-fast) ease,
|
|
@@ -130,10 +131,11 @@
|
|
|
130
131
|
color var(--dur-fast) ease,
|
|
131
132
|
opacity var(--dur-fast) ease,
|
|
132
133
|
translate var(--dur-fast) var(--ease-out),
|
|
133
|
-
scale var(--file-scale-dur) var(--ease
|
|
134
|
+
scale var(--file-scale-dur) var(--file-scale-ease);
|
|
134
135
|
}
|
|
135
136
|
.file-btn:active:not(.disabled) {
|
|
136
137
|
--file-scale-dur: var(--dur-press);
|
|
138
|
+
--file-scale-ease: var(--ease-out);
|
|
137
139
|
scale: var(--press-scale);
|
|
138
140
|
}
|
|
139
141
|
.file-btn.icon-only:active:not(.disabled),
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ComponentProps } from 'svelte';
|
|
3
|
+
import SegmentedControl, { type SegmentOption } from './SegmentedControl.svelte';
|
|
4
|
+
import { theme } from '../../stores/theme.svelte';
|
|
5
|
+
import { LOOKS, type LookId } from '../../theme-mode';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
looks,
|
|
9
|
+
experimental = true,
|
|
10
|
+
experimentalLabel = 'beta',
|
|
11
|
+
label = 'Look',
|
|
12
|
+
size = 'md',
|
|
13
|
+
control,
|
|
14
|
+
block,
|
|
15
|
+
scroll,
|
|
16
|
+
sound,
|
|
17
|
+
onchange,
|
|
18
|
+
class: klass = '',
|
|
19
|
+
}: Pick<ComponentProps<typeof SegmentedControl>, 'size' | 'control' | 'block' | 'scroll' | 'sound'> & {
|
|
20
|
+
/** Restrict and order the offered looks; defaults to every registered look. */
|
|
21
|
+
looks?: readonly LookId[];
|
|
22
|
+
/** Offer looks whose status is `experimental`. */
|
|
23
|
+
experimental?: boolean;
|
|
24
|
+
/** Badge on experimental looks; empty hides it. */
|
|
25
|
+
experimentalLabel?: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
onchange?: (look: LookId) => void;
|
|
28
|
+
class?: string;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
const options = $derived(
|
|
32
|
+
(looks ?? LOOKS.map((l) => l.id)).flatMap((id) => {
|
|
33
|
+
const l = LOOKS.find((x) => x.id === id);
|
|
34
|
+
const beta = l?.status === 'experimental';
|
|
35
|
+
if (!l || (beta && !experimental)) return [];
|
|
36
|
+
return [{ value: l.id, label: l.label }];
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
const tagged = new Set<string>(LOOKS.filter((l) => l.status === 'experimental').map((l) => l.id));
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
{#snippet option(o: SegmentOption)}
|
|
43
|
+
<span class="look">
|
|
44
|
+
<span class="name">{o.label}</span>
|
|
45
|
+
{#if experimentalLabel && tagged.has(o.value)}<span class="tag">{experimentalLabel}</span>{/if}
|
|
46
|
+
</span>
|
|
47
|
+
{/snippet}
|
|
48
|
+
|
|
49
|
+
<SegmentedControl
|
|
50
|
+
{options}
|
|
51
|
+
value={theme.look}
|
|
52
|
+
onchange={(v) => {
|
|
53
|
+
theme.setLook(v);
|
|
54
|
+
onchange?.(theme.look);
|
|
55
|
+
}}
|
|
56
|
+
{label}
|
|
57
|
+
{size}
|
|
58
|
+
{control}
|
|
59
|
+
{block}
|
|
60
|
+
{scroll}
|
|
61
|
+
{sound}
|
|
62
|
+
class="look-picker {klass}"
|
|
63
|
+
{option}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
.look {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: var(--sp-1);
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
:global(.seg-block) .name {
|
|
74
|
+
min-width: 0;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
text-overflow: ellipsis;
|
|
77
|
+
}
|
|
78
|
+
.tag {
|
|
79
|
+
flex: none;
|
|
80
|
+
padding: 0 var(--sp-1);
|
|
81
|
+
border: 1px solid currentColor;
|
|
82
|
+
border-radius: var(--r-pill);
|
|
83
|
+
font-size: var(--fs-xs);
|
|
84
|
+
font-weight: var(--fw-semibold);
|
|
85
|
+
line-height: var(--lh-tight);
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ComponentProps } from 'svelte';
|
|
2
|
+
import SegmentedControl from './SegmentedControl.svelte';
|
|
3
|
+
import { type LookId } from '../../theme-mode';
|
|
4
|
+
type $$ComponentProps = Pick<ComponentProps<typeof SegmentedControl>, 'size' | 'control' | 'block' | 'scroll' | 'sound'> & {
|
|
5
|
+
/** Restrict and order the offered looks; defaults to every registered look. */
|
|
6
|
+
looks?: readonly LookId[];
|
|
7
|
+
/** Offer looks whose status is `experimental`. */
|
|
8
|
+
experimental?: boolean;
|
|
9
|
+
/** Badge on experimental looks; empty hides it. */
|
|
10
|
+
experimentalLabel?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
onchange?: (look: LookId) => void;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
declare const LookPicker: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
16
|
+
type LookPicker = ReturnType<typeof LookPicker>;
|
|
17
|
+
export default LookPicker;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ComponentProps } from 'svelte';
|
|
3
|
+
import Button from '../atoms/Button.svelte';
|
|
4
|
+
import Icon from '../atoms/Icon.svelte';
|
|
5
|
+
import Select from '../atoms/Select.svelte';
|
|
6
|
+
import Popover from './Popover.svelte';
|
|
7
|
+
import { FX, FX_FAMILIES, type FxFamily, type FxSettings } from '../../fx';
|
|
8
|
+
import { fx } from '../../stores/fx.svelte';
|
|
9
|
+
|
|
10
|
+
type TriggerChrome = Pick<
|
|
11
|
+
ComponentProps<typeof Popover>,
|
|
12
|
+
'variant' | 'tone' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
families = FX_FAMILIES,
|
|
17
|
+
size = 'md',
|
|
18
|
+
box = size,
|
|
19
|
+
placement = 'bottom-end',
|
|
20
|
+
variant,
|
|
21
|
+
tone,
|
|
22
|
+
pill,
|
|
23
|
+
control,
|
|
24
|
+
block,
|
|
25
|
+
bare,
|
|
26
|
+
hitArea,
|
|
27
|
+
disabled,
|
|
28
|
+
label = 'Motion',
|
|
29
|
+
resetLabel = 'Reset',
|
|
30
|
+
reducedMotionHint = 'Your system asks for reduced motion: effects show their static fallback.',
|
|
31
|
+
onchange,
|
|
32
|
+
class: klass = '',
|
|
33
|
+
style: styleProp = '',
|
|
34
|
+
}: TriggerChrome & {
|
|
35
|
+
/** Restrict and order the offered families. */
|
|
36
|
+
families?: readonly FxFamily[];
|
|
37
|
+
size?: 'sm' | 'md';
|
|
38
|
+
label?: string;
|
|
39
|
+
resetLabel?: string;
|
|
40
|
+
reducedMotionHint?: string;
|
|
41
|
+
onchange?: (settings: FxSettings) => void;
|
|
42
|
+
class?: string;
|
|
43
|
+
style?: string;
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
const uid = $props.id();
|
|
47
|
+
let reduced = $state(false);
|
|
48
|
+
|
|
49
|
+
$effect(() => {
|
|
50
|
+
const mq = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
51
|
+
if (!mq) return;
|
|
52
|
+
reduced = mq.matches;
|
|
53
|
+
const on = (e: MediaQueryListEvent) => (reduced = e.matches);
|
|
54
|
+
mq.addEventListener?.('change', on);
|
|
55
|
+
return () => mq.removeEventListener?.('change', on);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const offCount = $derived(families.filter((f) => fx.settings[f] === 'none').length);
|
|
59
|
+
|
|
60
|
+
function pick(family: FxFamily, value: string) {
|
|
61
|
+
fx.set(family, value);
|
|
62
|
+
onchange?.(fx.settings);
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<Popover
|
|
67
|
+
{label}
|
|
68
|
+
{placement}
|
|
69
|
+
{box}
|
|
70
|
+
{variant}
|
|
71
|
+
{tone}
|
|
72
|
+
{pill}
|
|
73
|
+
{control}
|
|
74
|
+
{block}
|
|
75
|
+
{bare}
|
|
76
|
+
{hitArea}
|
|
77
|
+
{disabled}
|
|
78
|
+
triggerClass={klass}
|
|
79
|
+
style={styleProp}
|
|
80
|
+
sound={false}
|
|
81
|
+
>
|
|
82
|
+
{#snippet trigger()}
|
|
83
|
+
<span
|
|
84
|
+
data-tsu="MotionSettings"
|
|
85
|
+
class="glyph"
|
|
86
|
+
title={offCount === families.length ? `${label}: off` : label}
|
|
87
|
+
>
|
|
88
|
+
<Icon name="zap" size={size === 'sm' ? 16 : 18} />
|
|
89
|
+
</span>
|
|
90
|
+
{/snippet}
|
|
91
|
+
<div class="panel" class:panel-sm={size === 'sm'}>
|
|
92
|
+
<div class="grid">
|
|
93
|
+
{#each families as family (family)}
|
|
94
|
+
<label class="name" for="{uid}-{family}">{FX[family].label}</label>
|
|
95
|
+
<Select
|
|
96
|
+
id="{uid}-{family}"
|
|
97
|
+
size="sm"
|
|
98
|
+
value={fx.settings[family]}
|
|
99
|
+
options={FX[family].variants.map((v) => ({ value: v.id, label: v.label }))}
|
|
100
|
+
onchange={(e) => pick(family, (e.currentTarget as HTMLSelectElement).value)}
|
|
101
|
+
/>
|
|
102
|
+
{/each}
|
|
103
|
+
</div>
|
|
104
|
+
<div class="row">
|
|
105
|
+
{#if reduced}
|
|
106
|
+
<div class="hint" aria-live="polite">{reducedMotionHint}</div>
|
|
107
|
+
{/if}
|
|
108
|
+
<Button
|
|
109
|
+
size="sm"
|
|
110
|
+
variant="ghost"
|
|
111
|
+
class="reset"
|
|
112
|
+
disabled={fx.isDefault}
|
|
113
|
+
sound={false}
|
|
114
|
+
onclick={() => {
|
|
115
|
+
fx.reset();
|
|
116
|
+
onchange?.(fx.settings);
|
|
117
|
+
}}>{resetLabel}</Button
|
|
118
|
+
>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</Popover>
|
|
122
|
+
|
|
123
|
+
<style>
|
|
124
|
+
.glyph {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
line-height: var(--lh-none);
|
|
127
|
+
}
|
|
128
|
+
.panel {
|
|
129
|
+
width: min(19rem, calc(100vw - var(--sp-6)));
|
|
130
|
+
box-sizing: border-box;
|
|
131
|
+
display: grid;
|
|
132
|
+
gap: var(--sp-2);
|
|
133
|
+
padding: var(--sp-3);
|
|
134
|
+
font-size: 0.875rem;
|
|
135
|
+
line-height: var(--lh-snug);
|
|
136
|
+
}
|
|
137
|
+
.panel-sm {
|
|
138
|
+
width: min(17rem, calc(100vw - var(--sp-6)));
|
|
139
|
+
padding: var(--sp-2) var(--sp-3);
|
|
140
|
+
font-size: 0.8125rem;
|
|
141
|
+
}
|
|
142
|
+
.grid {
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: var(--sp-1) var(--sp-3);
|
|
147
|
+
}
|
|
148
|
+
.name {
|
|
149
|
+
color: var(--text-muted);
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
}
|
|
152
|
+
.row {
|
|
153
|
+
display: flex;
|
|
154
|
+
align-items: center;
|
|
155
|
+
gap: var(--sp-2);
|
|
156
|
+
}
|
|
157
|
+
.row > :global(.reset) {
|
|
158
|
+
margin-inline-start: auto;
|
|
159
|
+
}
|
|
160
|
+
.hint {
|
|
161
|
+
flex: 1 1 0;
|
|
162
|
+
min-width: 0;
|
|
163
|
+
font-size: 0.75rem;
|
|
164
|
+
color: var(--text-muted);
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ComponentProps } from 'svelte';
|
|
2
|
+
import Popover from './Popover.svelte';
|
|
3
|
+
import { type FxFamily, type FxSettings } from '../../fx';
|
|
4
|
+
type TriggerChrome = Pick<ComponentProps<typeof Popover>, 'variant' | 'tone' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'>;
|
|
5
|
+
type $$ComponentProps = TriggerChrome & {
|
|
6
|
+
/** Restrict and order the offered families. */
|
|
7
|
+
families?: readonly FxFamily[];
|
|
8
|
+
size?: 'sm' | 'md';
|
|
9
|
+
label?: string;
|
|
10
|
+
resetLabel?: string;
|
|
11
|
+
reducedMotionHint?: string;
|
|
12
|
+
onchange?: (settings: FxSettings) => void;
|
|
13
|
+
class?: string;
|
|
14
|
+
style?: string;
|
|
15
|
+
};
|
|
16
|
+
declare const MotionSettings: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type MotionSettings = ReturnType<typeof MotionSettings>;
|
|
18
|
+
export default MotionSettings;
|
|
@@ -203,6 +203,67 @@
|
|
|
203
203
|
opacity var(--dur-spring) var(--ease-spring),
|
|
204
204
|
scale var(--dur-spring) var(--ease-spring);
|
|
205
205
|
}
|
|
206
|
+
/* Effects follow the closest data-fx-radio ancestor; spring is the default. */
|
|
207
|
+
.radio-group:not(:global([data-fx-radio]:not([data-fx-radio='spring'])) *),
|
|
208
|
+
:global([data-fx-radio='spring']) .radio-group:not(:global([data-fx-radio='spring'] [data-fx-radio]:not([data-fx-radio='spring'])) *) {
|
|
209
|
+
& .dot-ctl::after {
|
|
210
|
+
inset: 22%;
|
|
211
|
+
width: auto;
|
|
212
|
+
height: auto;
|
|
213
|
+
margin: 0;
|
|
214
|
+
opacity: 1;
|
|
215
|
+
scale: 0;
|
|
216
|
+
transition: scale var(--dur-exit-fast) var(--ease-out);
|
|
217
|
+
}
|
|
218
|
+
& input:checked + .dot-ctl::after {
|
|
219
|
+
scale: 1;
|
|
220
|
+
transition: scale var(--dur-spring) var(--ease-spring-bounce);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* Accent rises like liquid with a wobbling surface, then the pip drops in. */
|
|
225
|
+
:global([data-fx-radio='liquid']) .radio-group:not(:global([data-fx-radio='liquid'] [data-fx-radio]:not([data-fx-radio='liquid'])) *) {
|
|
226
|
+
& .dot-ctl {
|
|
227
|
+
overflow: hidden;
|
|
228
|
+
}
|
|
229
|
+
& .dot-ctl::before {
|
|
230
|
+
content: '';
|
|
231
|
+
position: absolute;
|
|
232
|
+
left: -50%;
|
|
233
|
+
top: 0;
|
|
234
|
+
width: 200%;
|
|
235
|
+
height: 200%;
|
|
236
|
+
border-radius: 42% 46% 40% 44%;
|
|
237
|
+
background: var(--accent);
|
|
238
|
+
translate: 0 50%;
|
|
239
|
+
pointer-events: none;
|
|
240
|
+
transition:
|
|
241
|
+
translate var(--dur-exit) var(--ease-in-out),
|
|
242
|
+
rotate var(--dur-exit) var(--ease-in-out);
|
|
243
|
+
}
|
|
244
|
+
& input:checked + .dot-ctl::before {
|
|
245
|
+
translate: 0 -30%;
|
|
246
|
+
rotate: 150deg;
|
|
247
|
+
transition:
|
|
248
|
+
translate var(--dur-slow) var(--ease-out),
|
|
249
|
+
rotate var(--dur-slow) var(--ease-out);
|
|
250
|
+
}
|
|
251
|
+
& .dot-ctl::after {
|
|
252
|
+
inset: 30%;
|
|
253
|
+
width: auto;
|
|
254
|
+
height: auto;
|
|
255
|
+
margin: 0;
|
|
256
|
+
background: var(--text-on-accent);
|
|
257
|
+
opacity: 1;
|
|
258
|
+
scale: 0;
|
|
259
|
+
transition: scale var(--dur-exit-fast) var(--ease-out);
|
|
260
|
+
}
|
|
261
|
+
& input:checked + .dot-ctl::after {
|
|
262
|
+
scale: 1;
|
|
263
|
+
transition: scale var(--dur-exit) var(--ease-spring-bounce) var(--dur-fast);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
206
267
|
input:focus-visible + .dot-ctl {
|
|
207
268
|
outline: var(--focus-ring);
|
|
208
269
|
outline-offset: var(--focus-ring-offset);
|