@placeholderco/placeholder-ui 2.0.0 → 2.1.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.
@@ -0,0 +1,957 @@
1
+ <script module lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export type ColorFormat = 'hex' | 'rgb' | 'hsl';
5
+
6
+ export interface ColorPickerProps {
7
+ /** HTML name attribute for the input (falls back to label, then auto-generated) */
8
+ name?: string;
9
+ /** Label text displayed above the picker */
10
+ label?: string;
11
+ /** Custom ID for the input element */
12
+ inputId?: string;
13
+ /** Placeholder text when no colour is set */
14
+ placeholder?: string;
15
+ /** Mark field as required (shows asterisk) */
16
+ required?: boolean;
17
+ /** Disable the picker */
18
+ disabled?: boolean;
19
+ /** CSS classes for the container element */
20
+ containerClass?: string;
21
+ /** CSS classes for the input element */
22
+ class?: string;
23
+ /**
24
+ * Selected colour (bindable). Always stored as a hex string, e.g. `#1e3a5f`,
25
+ * or `#1e3a5f80` when `allowAlpha` is set and the colour is not fully opaque.
26
+ */
27
+ value?: string;
28
+ /** Allow selecting transparency; value becomes 8-digit hex when alpha < 1 */
29
+ allowAlpha?: boolean;
30
+ /** Preset swatches shown below the picker area */
31
+ swatches?: string[];
32
+ /** Hide the editable text input beside the swatch */
33
+ hideInput?: boolean;
34
+ /** Hide the eyedropper button (only shown when the browser supports it) */
35
+ hideEyeDropper?: boolean;
36
+ /** Which text format to show in the popup's readout */
37
+ format?: ColorFormat;
38
+ /** Show error state styling */
39
+ showError?: boolean;
40
+ /** Error message to display */
41
+ errorText?: string;
42
+ /** Position of the tooltip */
43
+ tooltipLocation?: 'top' | 'bottom' | 'left' | 'right';
44
+ /** Rich tooltip content using a Svelte snippet */
45
+ tooltipContent?: Snippet;
46
+ /** Simple tooltip text */
47
+ tooltipText?: string;
48
+ /** Callback when the colour changes (fires on every drag step) */
49
+ onchange?: (value: string) => void;
50
+ }
51
+
52
+ export type Rgb = { r: number; g: number; b: number; a: number };
53
+ export type Hsv = { h: number; s: number; v: number; a: number };
54
+
55
+ const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
56
+
57
+ /** Parse a hex string (#rgb, #rgba, #rrggbb, #rrggbbaa) into RGB(A). Returns undefined if invalid. */
58
+ export function parseHex(input: string | undefined): Rgb | undefined {
59
+ if (!input) return undefined;
60
+ let hex = input.trim().replace(/^#/, '');
61
+ if (!/^[0-9a-f]{3,8}$/i.test(hex)) return undefined;
62
+ if (hex.length === 3 || hex.length === 4) {
63
+ hex = hex
64
+ .split('')
65
+ .map((c) => c + c)
66
+ .join('');
67
+ }
68
+ if (hex.length !== 6 && hex.length !== 8) return undefined;
69
+ const r = parseInt(hex.slice(0, 2), 16);
70
+ const g = parseInt(hex.slice(2, 4), 16);
71
+ const b = parseInt(hex.slice(4, 6), 16);
72
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
73
+ return { r, g, b, a };
74
+ }
75
+
76
+ /** Format RGB(A) as a hex string. Alpha is only appended when < 1. */
77
+ export function toHex({ r, g, b, a }: Rgb): string {
78
+ const p = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0');
79
+ const base = `#${p(r)}${p(g)}${p(b)}`;
80
+ return a < 1 ? `${base}${p(a * 255)}` : base;
81
+ }
82
+
83
+ export function rgbToHsv({ r, g, b, a }: Rgb): Hsv {
84
+ const rn = r / 255,
85
+ gn = g / 255,
86
+ bn = b / 255;
87
+ const max = Math.max(rn, gn, bn);
88
+ const min = Math.min(rn, gn, bn);
89
+ const d = max - min;
90
+ let h = 0;
91
+ if (d !== 0) {
92
+ if (max === rn) h = ((gn - bn) / d) % 6;
93
+ else if (max === gn) h = (bn - rn) / d + 2;
94
+ else h = (rn - gn) / d + 4;
95
+ h *= 60;
96
+ if (h < 0) h += 360;
97
+ }
98
+ const s = max === 0 ? 0 : d / max;
99
+ return { h, s, v: max, a };
100
+ }
101
+
102
+ export function hsvToRgb({ h, s, v, a }: Hsv): Rgb {
103
+ const c = v * s;
104
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
105
+ const m = v - c;
106
+ let rn = 0,
107
+ gn = 0,
108
+ bn = 0;
109
+ if (h < 60) [rn, gn, bn] = [c, x, 0];
110
+ else if (h < 120) [rn, gn, bn] = [x, c, 0];
111
+ else if (h < 180) [rn, gn, bn] = [0, c, x];
112
+ else if (h < 240) [rn, gn, bn] = [0, x, c];
113
+ else if (h < 300) [rn, gn, bn] = [x, 0, c];
114
+ else [rn, gn, bn] = [c, 0, x];
115
+ return { r: (rn + m) * 255, g: (gn + m) * 255, b: (bn + m) * 255, a };
116
+ }
117
+
118
+ export function rgbToHsl({ r, g, b, a }: Rgb): { h: number; s: number; l: number; a: number } {
119
+ const rn = r / 255,
120
+ gn = g / 255,
121
+ bn = b / 255;
122
+ const max = Math.max(rn, gn, bn);
123
+ const min = Math.min(rn, gn, bn);
124
+ const l = (max + min) / 2;
125
+ const d = max - min;
126
+ let h = 0,
127
+ s = 0;
128
+ if (d !== 0) {
129
+ s = d / (1 - Math.abs(2 * l - 1));
130
+ if (max === rn) h = ((gn - bn) / d) % 6;
131
+ else if (max === gn) h = (bn - rn) / d + 2;
132
+ else h = (rn - gn) / d + 4;
133
+ h *= 60;
134
+ if (h < 0) h += 360;
135
+ }
136
+ return { h, s, l, a };
137
+ }
138
+
139
+ /** Format a colour in the requested CSS notation. */
140
+ export function formatColor(rgb: Rgb, format: ColorFormat): string {
141
+ const r = Math.round(rgb.r),
142
+ g = Math.round(rgb.g),
143
+ b = Math.round(rgb.b);
144
+ const alpha = Math.round(rgb.a * 100) / 100;
145
+ switch (format) {
146
+ case 'rgb':
147
+ return rgb.a < 1 ? `rgba(${r}, ${g}, ${b}, ${alpha})` : `rgb(${r}, ${g}, ${b})`;
148
+ case 'hsl': {
149
+ const { h, s, l } = rgbToHsl(rgb);
150
+ const hs = Math.round(h),
151
+ ss = Math.round(s * 100),
152
+ ls = Math.round(l * 100);
153
+ return rgb.a < 1 ? `hsla(${hs}, ${ss}%, ${ls}%, ${alpha})` : `hsl(${hs}, ${ss}%, ${ls}%)`;
154
+ }
155
+ default:
156
+ return toHex(rgb);
157
+ }
158
+ }
159
+
160
+ /** Parse any of hex / rgb() / hsl() into RGB(A). Returns undefined if invalid. */
161
+ export function parseColor(input: string | undefined): Rgb | undefined {
162
+ if (!input) return undefined;
163
+ const s = input.trim();
164
+ const hex = parseHex(s);
165
+ if (hex) return hex;
166
+
167
+ const rgbMatch = s.match(
168
+ /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i
169
+ );
170
+ if (rgbMatch) {
171
+ const a = parseAlpha(rgbMatch[4]);
172
+ return {
173
+ r: clamp(+rgbMatch[1], 0, 255),
174
+ g: clamp(+rgbMatch[2], 0, 255),
175
+ b: clamp(+rgbMatch[3], 0, 255),
176
+ a
177
+ };
178
+ }
179
+
180
+ const hslMatch = s.match(
181
+ /^hsla?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i
182
+ );
183
+ if (hslMatch) {
184
+ const h = ((+hslMatch[1] % 360) + 360) % 360;
185
+ const sl = clamp(+hslMatch[2] / 100, 0, 1);
186
+ const l = clamp(+hslMatch[3] / 100, 0, 1);
187
+ const a = parseAlpha(hslMatch[4]);
188
+ // hsl -> hsv
189
+ const v = l + sl * Math.min(l, 1 - l);
190
+ const sv = v === 0 ? 0 : 2 * (1 - l / v);
191
+ return hsvToRgb({ h, s: sv, v, a });
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ function parseAlpha(raw: string | undefined): number {
197
+ if (raw === undefined) return 1;
198
+ if (raw.endsWith('%')) return clamp(parseFloat(raw) / 100, 0, 1);
199
+ return clamp(parseFloat(raw), 0, 1);
200
+ }
201
+ </script>
202
+
203
+ <script lang="ts">
204
+ import { fade } from 'svelte/transition';
205
+ import FormGroup from './FormGroup.svelte';
206
+ import ActionIcon from '../ui/ActionIcon.svelte';
207
+ import { clickOutside } from '../util/ClickOutside.js';
208
+ import { iconX, iconPalette } from '../icon/index.js';
209
+
210
+ const autoName = `colorpicker-${crypto.randomUUID()}`;
211
+
212
+ let {
213
+ name,
214
+ label = '',
215
+ inputId = undefined,
216
+ placeholder = 'Select colour',
217
+ required = false,
218
+ disabled = false,
219
+ containerClass = '',
220
+ class: classes = '',
221
+ value = $bindable(''),
222
+ allowAlpha = false,
223
+ swatches = [],
224
+ hideInput = false,
225
+ hideEyeDropper = false,
226
+ format = 'hex',
227
+ showError = false,
228
+ errorText = '',
229
+ tooltipLocation = 'top',
230
+ tooltipContent = undefined,
231
+ tooltipText = undefined,
232
+ onchange = undefined
233
+ }: ColorPickerProps = $props();
234
+
235
+ let resolvedName = $derived((name || label || autoName).replace(/[^a-zA-Z0-9_\-:.]/g, '_'));
236
+
237
+ let id = $derived.by(() => {
238
+ if (inputId) return inputId;
239
+ if (label) return `colorpicker-${label.toLowerCase().replace(/ /g, '-')}`;
240
+ return Math.random().toString(36).substring(2, 8);
241
+ });
242
+
243
+ // --- Internal colour state -------------------------------------------------
244
+ // HSV is the working model so that hue is preserved while the colour is black/white/grey.
245
+ let hsv: Hsv = $state({ h: 210, s: 0.68, v: 0.37, a: 1 });
246
+ let lastEmitted = '';
247
+
248
+ // Sync incoming value -> hsv (only when it changed externally).
249
+ $effect(() => {
250
+ if (value === lastEmitted) return;
251
+ const parsed = parseColor(value);
252
+ if (parsed) {
253
+ hsv = rgbToHsv(allowAlpha ? parsed : { ...parsed, a: 1 });
254
+ }
255
+ lastEmitted = value;
256
+ });
257
+
258
+ const rgb = $derived(hsvToRgb(hsv));
259
+ const hex = $derived(toHex(rgb));
260
+ const hexOpaque = $derived(toHex({ ...rgb, a: 1 }));
261
+ const readout = $derived(formatColor(rgb, format));
262
+ const hasValue = $derived(!!parseColor(value));
263
+
264
+ function commit() {
265
+ const next = toHex(allowAlpha ? rgb : { ...rgb, a: 1 });
266
+ lastEmitted = next;
267
+ value = next;
268
+ onchange?.(next);
269
+ }
270
+
271
+ function setFromRgb(next: Rgb | undefined) {
272
+ if (!next) return;
273
+ hsv = rgbToHsv(allowAlpha ? next : { ...next, a: 1 });
274
+ commit();
275
+ }
276
+
277
+ function clear() {
278
+ lastEmitted = '';
279
+ value = '';
280
+ onchange?.('');
281
+ }
282
+
283
+ // --- Text input ------------------------------------------------------------
284
+ let textValue = $state('');
285
+ let textFocused = $state(false);
286
+
287
+ $effect(() => {
288
+ if (!textFocused) textValue = hasValue ? hex : '';
289
+ });
290
+
291
+ function onTextInput() {
292
+ const parsed = parseColor(textValue);
293
+ if (parsed) setFromRgb(parsed);
294
+ }
295
+
296
+ function onTextBlur() {
297
+ textFocused = false;
298
+ if (textValue.trim() === '') {
299
+ clear();
300
+ return;
301
+ }
302
+ const parsed = parseColor(textValue);
303
+ if (parsed) setFromRgb(parsed);
304
+ else textValue = hasValue ? hex : '';
305
+ }
306
+
307
+ // --- Popup -----------------------------------------------------------------
308
+ let open = $state(false);
309
+ let dropdownPosition: 'above' | 'below' = $state('below');
310
+ let triggerEl: HTMLElement | undefined = $state();
311
+
312
+ function checkDropdownPosition() {
313
+ if (!triggerEl) return;
314
+ const rect = triggerEl.getBoundingClientRect();
315
+ const spaceBelow = window.innerHeight - rect.bottom;
316
+ dropdownPosition = spaceBelow < 320 && rect.top > 320 ? 'above' : 'below';
317
+ }
318
+
319
+ function toggle() {
320
+ if (disabled) return;
321
+ if (!open) checkDropdownPosition();
322
+ open = !open;
323
+ }
324
+
325
+ function onClickOutside(event: MouseEvent) {
326
+ if (!open) return;
327
+ if (triggerEl?.contains(event.target as Node)) return;
328
+ open = false;
329
+ }
330
+
331
+ function onKeydown(e: KeyboardEvent) {
332
+ if (e.key === 'Escape' && open) {
333
+ open = false;
334
+ e.stopPropagation();
335
+ }
336
+ }
337
+
338
+ // --- Pointer dragging ------------------------------------------------------
339
+ type DragKind = 'sv' | 'hue' | 'alpha';
340
+
341
+ function drag(node: HTMLElement, kind: DragKind) {
342
+ function update(e: PointerEvent) {
343
+ const rect = node.getBoundingClientRect();
344
+ const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
345
+ const y = clamp((e.clientY - rect.top) / rect.height, 0, 1);
346
+ if (kind === 'sv') hsv = { ...hsv, s: x, v: 1 - y };
347
+ else if (kind === 'hue') hsv = { ...hsv, h: x * 360 };
348
+ else hsv = { ...hsv, a: x };
349
+ commit();
350
+ }
351
+ function down(e: PointerEvent) {
352
+ if (disabled || e.button !== 0) return;
353
+ e.preventDefault();
354
+ node.setPointerCapture(e.pointerId);
355
+ node.focus();
356
+ update(e);
357
+ node.addEventListener('pointermove', update);
358
+ node.addEventListener('pointerup', up, { once: true });
359
+ node.addEventListener('pointercancel', up, { once: true });
360
+ }
361
+ function up(e: PointerEvent) {
362
+ node.removeEventListener('pointermove', update);
363
+ node.releasePointerCapture(e.pointerId);
364
+ }
365
+ node.addEventListener('pointerdown', down);
366
+ return {
367
+ destroy() {
368
+ node.removeEventListener('pointerdown', down);
369
+ }
370
+ };
371
+ }
372
+
373
+ function onSvKeydown(e: KeyboardEvent) {
374
+ const step = e.shiftKey ? 0.1 : 0.01;
375
+ let handled = true;
376
+ switch (e.key) {
377
+ case 'ArrowLeft':
378
+ hsv = { ...hsv, s: clamp(hsv.s - step, 0, 1) };
379
+ break;
380
+ case 'ArrowRight':
381
+ hsv = { ...hsv, s: clamp(hsv.s + step, 0, 1) };
382
+ break;
383
+ case 'ArrowUp':
384
+ hsv = { ...hsv, v: clamp(hsv.v + step, 0, 1) };
385
+ break;
386
+ case 'ArrowDown':
387
+ hsv = { ...hsv, v: clamp(hsv.v - step, 0, 1) };
388
+ break;
389
+ default:
390
+ handled = false;
391
+ }
392
+ if (handled) {
393
+ e.preventDefault();
394
+ commit();
395
+ }
396
+ }
397
+
398
+ function onSliderKeydown(e: KeyboardEvent, kind: 'hue' | 'alpha') {
399
+ const dir =
400
+ e.key === 'ArrowRight' || e.key === 'ArrowUp'
401
+ ? 1
402
+ : e.key === 'ArrowLeft' || e.key === 'ArrowDown'
403
+ ? -1
404
+ : 0;
405
+ if (!dir && e.key !== 'Home' && e.key !== 'End') return;
406
+ e.preventDefault();
407
+ if (kind === 'hue') {
408
+ const step = e.shiftKey ? 10 : 1;
409
+ let h = e.key === 'Home' ? 0 : e.key === 'End' ? 360 : hsv.h + dir * step;
410
+ hsv = { ...hsv, h: ((h % 360) + 360) % 360 };
411
+ } else {
412
+ const step = e.shiftKey ? 0.1 : 0.01;
413
+ const a = e.key === 'Home' ? 0 : e.key === 'End' ? 1 : hsv.a + dir * step;
414
+ hsv = { ...hsv, a: clamp(a, 0, 1) };
415
+ }
416
+ commit();
417
+ }
418
+
419
+ // --- EyeDropper ------------------------------------------------------------
420
+ type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
421
+ const eyeDropperSupported = $derived(
422
+ typeof window !== 'undefined' && 'EyeDropper' in window && !hideEyeDropper
423
+ );
424
+
425
+ async function pickFromScreen() {
426
+ try {
427
+ const Ctor = (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper;
428
+ const result = await new Ctor().open();
429
+ setFromRgb(parseHex(result.sRGBHex));
430
+ } catch {
431
+ // user cancelled
432
+ }
433
+ }
434
+
435
+ const hueColor = $derived(toHex(hsvToRgb({ h: hsv.h, s: 1, v: 1, a: 1 })));
436
+ </script>
437
+
438
+ <div class="colorpicker-container {containerClass}" onkeydown={onKeydown} role="presentation">
439
+ <FormGroup {label} {required} {id} {tooltipLocation} {tooltipContent} {tooltipText}>
440
+ <div class="colorpicker">
441
+ <div
442
+ class="trigger {classes}"
443
+ class:disabled
444
+ class:show-error={showError}
445
+ class:open
446
+ class:no-input={hideInput}
447
+ bind:this={triggerEl}
448
+ >
449
+ <button
450
+ type="button"
451
+ class="swatch-button"
452
+ aria-label={hasValue
453
+ ? `Selected colour ${hex}. Open colour picker`
454
+ : 'Open colour picker'}
455
+ aria-expanded={open}
456
+ aria-haspopup="dialog"
457
+ {disabled}
458
+ onclick={toggle}
459
+ >
460
+ <span class="checker">
461
+ {#if hasValue}
462
+ <span class="swatch" style:background={hex}></span>
463
+ {:else}
464
+ <span class="swatch empty"></span>
465
+ {/if}
466
+ </span>
467
+ </button>
468
+
469
+ {#if hideInput}
470
+ <button type="button" class="readout-button" {disabled} onclick={toggle}>
471
+ {hasValue ? readout : placeholder}
472
+ </button>
473
+ {:else}
474
+ <input
475
+ {id}
476
+ name={resolvedName}
477
+ class="text-input"
478
+ type="text"
479
+ autocomplete="off"
480
+ spellcheck="false"
481
+ {placeholder}
482
+ {disabled}
483
+ {required}
484
+ bind:value={textValue}
485
+ onfocus={() => (textFocused = true)}
486
+ oninput={onTextInput}
487
+ onblur={onTextBlur}
488
+ onkeydown={(e) => {
489
+ if (e.key === 'Enter') {
490
+ onTextBlur();
491
+ textFocused = true;
492
+ }
493
+ }}
494
+ />
495
+ {/if}
496
+
497
+ {#if hasValue && !disabled}
498
+ <div class="clear-button">
499
+ <ActionIcon
500
+ variant="secondary-subtle"
501
+ svg={iconX}
502
+ size="0.75rem"
503
+ ariaLabel="Clear colour"
504
+ onclick={(e: MouseEvent) => {
505
+ clear();
506
+ e.stopPropagation();
507
+ }}
508
+ />
509
+ </div>
510
+ {/if}
511
+ </div>
512
+
513
+ {#if open}
514
+ <div
515
+ class="panel {dropdownPosition}"
516
+ role="dialog"
517
+ aria-label="Colour picker"
518
+ transition:fade={{ duration: 150 }}
519
+ use:clickOutside={onClickOutside}
520
+ >
521
+ <div
522
+ class="sv-area"
523
+ style:background-color={hueColor}
524
+ role="slider"
525
+ tabindex="0"
526
+ aria-label="Saturation and brightness"
527
+ aria-valuemin={0}
528
+ aria-valuemax={100}
529
+ aria-valuenow={Math.round(hsv.v * 100)}
530
+ aria-valuetext={`Saturation ${Math.round(hsv.s * 100)}%, brightness ${Math.round(hsv.v * 100)}%`}
531
+ use:drag={'sv'}
532
+ onkeydown={onSvKeydown}
533
+ >
534
+ <div
535
+ class="handle"
536
+ style:left={`${hsv.s * 100}%`}
537
+ style:top={`${(1 - hsv.v) * 100}%`}
538
+ style:background={hexOpaque}
539
+ ></div>
540
+ </div>
541
+
542
+ <div class="controls">
543
+ {#if eyeDropperSupported}
544
+ <ActionIcon
545
+ svg={iconPalette}
546
+ size="1.125rem"
547
+ ariaLabel="Pick colour from screen"
548
+ tooltip="Pick from screen"
549
+ onclick={pickFromScreen}
550
+ />
551
+ {/if}
552
+ <div class="sliders">
553
+ <div
554
+ class="slider hue"
555
+ role="slider"
556
+ tabindex="0"
557
+ aria-label="Hue"
558
+ aria-valuemin={0}
559
+ aria-valuemax={360}
560
+ aria-valuenow={Math.round(hsv.h)}
561
+ use:drag={'hue'}
562
+ onkeydown={(e) => onSliderKeydown(e, 'hue')}
563
+ >
564
+ <div
565
+ class="handle"
566
+ style:left={`${(hsv.h / 360) * 100}%`}
567
+ style:background={hueColor}
568
+ ></div>
569
+ </div>
570
+ {#if allowAlpha}
571
+ <div
572
+ class="slider alpha checker"
573
+ role="slider"
574
+ tabindex="0"
575
+ aria-label="Opacity"
576
+ aria-valuemin={0}
577
+ aria-valuemax={100}
578
+ aria-valuenow={Math.round(hsv.a * 100)}
579
+ use:drag={'alpha'}
580
+ onkeydown={(e) => onSliderKeydown(e, 'alpha')}
581
+ >
582
+ <div
583
+ class="alpha-track"
584
+ style:background={`linear-gradient(to right, transparent, ${hexOpaque})`}
585
+ ></div>
586
+ <div class="handle" style:left={`${hsv.a * 100}%`} style:background={hex}></div>
587
+ </div>
588
+ {/if}
589
+ </div>
590
+ </div>
591
+
592
+ <div class="readout" title={readout}>
593
+ <span class="checker readout-swatch"
594
+ ><span class="swatch" style:background={hex}></span></span
595
+ >
596
+ <code>{readout}</code>
597
+ </div>
598
+
599
+ {#if swatches.length}
600
+ <div class="swatches" role="listbox" aria-label="Preset colours">
601
+ {#each swatches as swatch (swatch)}
602
+ {@const parsed = parseColor(swatch)}
603
+ {#if parsed}
604
+ {@const swatchHex = toHex(parsed)}
605
+ <button
606
+ type="button"
607
+ class="preset checker"
608
+ class:selected={swatchHex.toLowerCase() === hex.toLowerCase()}
609
+ role="option"
610
+ aria-selected={swatchHex.toLowerCase() === hex.toLowerCase()}
611
+ aria-label={swatch}
612
+ title={swatch}
613
+ onclick={() => setFromRgb(parsed)}
614
+ >
615
+ <span class="swatch" style:background={swatchHex}></span>
616
+ </button>
617
+ {/if}
618
+ {/each}
619
+ </div>
620
+ {/if}
621
+ </div>
622
+ {/if}
623
+ </div>
624
+ {#if showError && errorText}
625
+ <div class="text-error">{errorText}</div>
626
+ {/if}
627
+ </FormGroup>
628
+ </div>
629
+
630
+ <style>
631
+ .colorpicker {
632
+ position: relative;
633
+ }
634
+
635
+ /* ---- Trigger --------------------------------------------------------- */
636
+ .trigger {
637
+ display: flex;
638
+ align-items: center;
639
+ width: 100%;
640
+ border: 1px solid var(--border-color);
641
+ background-color: var(--input-bg-color);
642
+ color: var(--text-color);
643
+ border-radius: 0.25rem;
644
+ box-sizing: border-box;
645
+ position: relative;
646
+ transition: border-color 0.15s ease;
647
+ }
648
+
649
+ .trigger:focus-within,
650
+ .trigger.open {
651
+ border-color: var(--accent-color);
652
+ }
653
+
654
+ .trigger.show-error {
655
+ border-color: var(--danger-text);
656
+ }
657
+
658
+ .trigger.disabled {
659
+ opacity: 0.5;
660
+ cursor: not-allowed;
661
+ background: var(--border-color);
662
+ }
663
+
664
+ .swatch-button {
665
+ flex: 0 0 auto;
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ padding: 0.25rem 0.5rem;
670
+ background: none;
671
+ border: none;
672
+ border-right: 1px solid var(--border-color);
673
+ cursor: pointer;
674
+ line-height: 0;
675
+ }
676
+
677
+ .swatch-button:disabled {
678
+ cursor: not-allowed;
679
+ }
680
+
681
+ .swatch-button:focus-visible {
682
+ outline: 2px solid var(--accent-color);
683
+ outline-offset: -2px;
684
+ border-radius: 0.25rem;
685
+ }
686
+
687
+ .checker {
688
+ display: inline-block;
689
+ border-radius: 0.25rem;
690
+ overflow: hidden;
691
+ background-color: #fff;
692
+ background-image:
693
+ linear-gradient(45deg, #ccc 25%, transparent 25%),
694
+ linear-gradient(-45deg, #ccc 25%, transparent 25%),
695
+ linear-gradient(45deg, transparent 75%, #ccc 75%),
696
+ linear-gradient(-45deg, transparent 75%, #ccc 75%);
697
+ background-size: 8px 8px;
698
+ background-position:
699
+ 0 0,
700
+ 0 4px,
701
+ 4px -4px,
702
+ -4px 0;
703
+ }
704
+
705
+ .swatch-button .checker {
706
+ width: 1.5rem;
707
+ height: 1.5rem;
708
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
709
+ }
710
+
711
+ .swatch {
712
+ display: block;
713
+ width: 100%;
714
+ height: 100%;
715
+ }
716
+
717
+ .swatch.empty {
718
+ background:
719
+ linear-gradient(
720
+ to top left,
721
+ transparent calc(50% - 1px),
722
+ var(--danger-text) calc(50% - 1px),
723
+ var(--danger-text) calc(50% + 1px),
724
+ transparent calc(50% + 1px)
725
+ ),
726
+ var(--input-bg-color);
727
+ }
728
+
729
+ .text-input,
730
+ .readout-button {
731
+ flex: 1 1 auto;
732
+ min-width: 0;
733
+ font-size: 1rem;
734
+ line-height: 1.5rem;
735
+ padding: 0.25rem 0.5rem;
736
+ padding-right: 1.75rem;
737
+ border: none;
738
+ background: transparent;
739
+ color: var(--text-color);
740
+ font-family: var(--ui-font-family, inherit);
741
+ text-align: left;
742
+ }
743
+
744
+ .text-input:focus {
745
+ outline: none;
746
+ }
747
+
748
+ .text-input::placeholder,
749
+ .readout-button:empty {
750
+ color: var(--placeholder-color);
751
+ }
752
+
753
+ .readout-button {
754
+ cursor: pointer;
755
+ }
756
+
757
+ .readout-button:disabled {
758
+ cursor: not-allowed;
759
+ }
760
+
761
+ .clear-button {
762
+ position: absolute;
763
+ right: 0.5rem;
764
+ top: 50%;
765
+ transform: translateY(-50%);
766
+ line-height: 0;
767
+ }
768
+
769
+ .text-error {
770
+ color: var(--danger-text);
771
+ font-size: 0.875rem;
772
+ margin-top: 0.25rem;
773
+ }
774
+
775
+ /* ---- Panel ----------------------------------------------------------- */
776
+ .panel {
777
+ position: absolute;
778
+ z-index: 10;
779
+ left: 0;
780
+ width: 16rem;
781
+ padding: 0.75rem;
782
+ display: flex;
783
+ flex-direction: column;
784
+ gap: 0.75rem;
785
+ user-select: none;
786
+ background-color: var(--paper-body-bg);
787
+ border: 1px solid var(--border-color);
788
+ border-radius: 0.375rem;
789
+ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
790
+ }
791
+
792
+ .panel.below {
793
+ top: calc(100% + 0.25rem);
794
+ }
795
+
796
+ .panel.above {
797
+ bottom: calc(100% + 0.25rem);
798
+ }
799
+
800
+ .sv-area {
801
+ position: relative;
802
+ width: 100%;
803
+ height: 9rem;
804
+ border-radius: 0.375rem;
805
+ cursor: crosshair;
806
+ touch-action: none;
807
+ background-image:
808
+ linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent);
809
+ }
810
+
811
+ .sv-area:focus-visible,
812
+ .slider:focus-visible {
813
+ outline: 2px solid var(--accent-color);
814
+ outline-offset: 2px;
815
+ }
816
+
817
+ .handle {
818
+ position: absolute;
819
+ width: 0.875rem;
820
+ height: 0.875rem;
821
+ border-radius: 50%;
822
+ border: 2px solid #fff;
823
+ box-shadow:
824
+ 0 0 0 1px rgba(0, 0, 0, 0.4),
825
+ 0 1px 3px rgba(0, 0, 0, 0.3);
826
+ transform: translate(-50%, -50%);
827
+ pointer-events: none;
828
+ box-sizing: border-box;
829
+ }
830
+
831
+ .controls {
832
+ display: flex;
833
+ align-items: center;
834
+ gap: 0.5rem;
835
+ }
836
+
837
+ .sliders {
838
+ flex: 1 1 auto;
839
+ display: flex;
840
+ flex-direction: column;
841
+ gap: 0.625rem;
842
+ min-width: 0;
843
+ }
844
+
845
+ .slider {
846
+ position: relative;
847
+ height: 0.75rem;
848
+ border-radius: 0.375rem;
849
+ cursor: pointer;
850
+ touch-action: none;
851
+ }
852
+
853
+ .slider .handle {
854
+ top: 50%;
855
+ }
856
+
857
+ .slider.hue {
858
+ background: linear-gradient(
859
+ to right,
860
+ #f00 0%,
861
+ #ff0 17%,
862
+ #0f0 33%,
863
+ #0ff 50%,
864
+ #00f 67%,
865
+ #f0f 83%,
866
+ #f00 100%
867
+ );
868
+ }
869
+
870
+ .slider.alpha {
871
+ overflow: visible;
872
+ background-size: 8px 8px;
873
+ }
874
+
875
+ .alpha-track {
876
+ position: absolute;
877
+ inset: 0;
878
+ border-radius: 0.375rem;
879
+ }
880
+
881
+ .readout {
882
+ display: flex;
883
+ align-items: center;
884
+ gap: 0.5rem;
885
+ min-width: 0;
886
+ }
887
+
888
+ .readout-swatch {
889
+ flex: 0 0 auto;
890
+ width: 1.25rem;
891
+ height: 1.25rem;
892
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
893
+ }
894
+
895
+ .readout code {
896
+ flex: 1 1 auto;
897
+ min-width: 0;
898
+ overflow: hidden;
899
+ text-overflow: ellipsis;
900
+ white-space: nowrap;
901
+ font-size: 0.8125rem;
902
+ color: var(--text-color);
903
+ background: rgba(var(--ui-primary-rgbc), 0.06);
904
+ padding: 0.125rem 0.375rem;
905
+ border-radius: 0.25rem;
906
+ }
907
+
908
+ .swatches {
909
+ display: flex;
910
+ flex-wrap: wrap;
911
+ gap: 0.375rem;
912
+ }
913
+
914
+ .preset {
915
+ width: 1.5rem;
916
+ height: 1.5rem;
917
+ padding: 0;
918
+ border: none;
919
+ cursor: pointer;
920
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
921
+ transition: transform 0.1s ease;
922
+ }
923
+
924
+ .preset:hover {
925
+ transform: scale(1.1);
926
+ }
927
+
928
+ .preset.selected {
929
+ outline: 2px solid var(--accent-color);
930
+ outline-offset: 1px;
931
+ }
932
+
933
+ .preset:focus-visible {
934
+ outline: 2px solid var(--accent-color);
935
+ outline-offset: 1px;
936
+ }
937
+
938
+ /* ---- Dark mode ------------------------------------------------------- */
939
+ :global(.dark) .checker {
940
+ background-color: #444;
941
+ background-image:
942
+ linear-gradient(45deg, #666 25%, transparent 25%),
943
+ linear-gradient(-45deg, #666 25%, transparent 25%),
944
+ linear-gradient(45deg, transparent 75%, #666 75%),
945
+ linear-gradient(-45deg, transparent 75%, #666 75%);
946
+ }
947
+
948
+ :global(.dark) .swatch-button .checker,
949
+ :global(.dark) .readout-swatch,
950
+ :global(.dark) .preset {
951
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15);
952
+ }
953
+
954
+ :global(.dark) .readout code {
955
+ background: rgba(var(--ui-accent-rgbc), 0.1);
956
+ }
957
+ </style>