@youcan/celeste 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/ai/README.md +1 -1
  2. package/ai/component-registry.json +206 -3
  3. package/ai/icons.json +2 -2
  4. package/ai/tokens.json +2 -2
  5. package/dist/celeste.css +740 -380
  6. package/dist/cjs/chunks/ComboboxItem.cjs +6 -0
  7. package/dist/cjs/chunks/alpha-slider.cjs +1 -1
  8. package/dist/cjs/chunks/color-area.cjs +1 -1
  9. package/dist/cjs/chunks/color-picker.cjs +20 -25
  10. package/dist/cjs/chunks/color-swatch.cjs +4 -3
  11. package/dist/cjs/chunks/combobox-item.cjs +63 -7
  12. package/dist/cjs/chunks/combobox-list.cjs +1 -1
  13. package/dist/cjs/chunks/combobox-trigger.cjs +1 -1
  14. package/dist/cjs/chunks/combobox.cjs +2 -2
  15. package/dist/cjs/chunks/components.cjs +7 -5
  16. package/dist/cjs/chunks/gradient-picker.cjs +635 -0
  17. package/dist/cjs/chunks/text-input-affix.cjs +1 -1
  18. package/dist/cjs/chunks/text-input-button.cjs +1 -1
  19. package/dist/cjs/chunks/text-input.cjs +2 -1
  20. package/dist/cjs/chunks/textarea.cjs +1 -1
  21. package/dist/{es/chunks/use-color-model.js → cjs/chunks/tinycolor.cjs} +6 -23
  22. package/dist/cjs/gradient-picker.cjs +2 -0
  23. package/dist/cjs/gradient.cjs +71 -0
  24. package/dist/cjs/index.cjs +10 -5
  25. package/dist/cjs/use-color-model.cjs +27 -3
  26. package/dist/components/color-picker/gradient-picker.d.ts +51 -0
  27. package/dist/components/color-picker/index.d.ts +2 -0
  28. package/dist/es/chunks/ComboboxItem.js +1 -1
  29. package/dist/es/chunks/alpha-slider.js +1 -1
  30. package/dist/es/chunks/color-area.js +1 -1
  31. package/dist/es/chunks/color-picker.js +12 -17
  32. package/dist/es/chunks/color-swatch.js +2 -1
  33. package/dist/es/chunks/combobox-item.js +65 -9
  34. package/dist/es/chunks/combobox-list.js +1 -1
  35. package/dist/es/chunks/combobox-trigger.js +1 -1
  36. package/dist/es/chunks/combobox.js +2 -2
  37. package/dist/es/chunks/components.js +7 -5
  38. package/dist/es/chunks/gradient-picker.js +629 -0
  39. package/dist/es/chunks/text-input-affix.js +1 -1
  40. package/dist/es/chunks/text-input-button.js +1 -1
  41. package/dist/es/chunks/text-input.js +2 -1
  42. package/dist/es/chunks/textarea.js +1 -1
  43. package/dist/{cjs/chunks/use-color-model.cjs → es/chunks/tinycolor.js} +1 -40
  44. package/dist/es/gradient-picker.js +2 -0
  45. package/dist/es/gradient.js +69 -0
  46. package/dist/es/index.js +8 -6
  47. package/dist/es/use-color-model.js +25 -1
  48. package/dist/utils/gradient.d.ts +13 -0
  49. package/dist/utils/index.d.ts +1 -0
  50. package/package.json +3 -3
  51. package/src/components/color-picker/color-picker.vue +8 -9
  52. package/src/components/color-picker/composable/use-color-model.ts +5 -0
  53. package/src/components/color-picker/gradient-picker.vue +944 -0
  54. package/src/components/color-picker/index.ts +3 -0
  55. package/src/components/color-picker/stories/gradient-picker.stories.ts +37 -0
  56. package/src/components/combobox/combobox-item.vue +15 -7
  57. package/src/components/combobox/combobox-list.vue +2 -2
  58. package/src/components/combobox/combobox-trigger.vue +38 -0
  59. package/src/components/combobox/combobox.vue +2 -0
  60. package/src/components/text-input/stories/text-input.stories.ts +53 -3
  61. package/src/components/text-input/text-input-affix.vue +7 -3
  62. package/src/components/text-input/text-input-button.vue +6 -2
  63. package/src/components/text-input/text-input.vue +45 -34
  64. package/src/components/textarea/textarea.vue +1 -1
  65. package/src/utils/gradient.ts +90 -0
  66. package/src/utils/index.ts +1 -0
@@ -0,0 +1,944 @@
1
+ <script setup lang="ts">
2
+ import type { GradientType } from '@/utils/gradient';
3
+ import tinycolor from 'tinycolor2';
4
+ import { computed, reactive, ref, watch } from 'vue';
5
+ import { parseGradient, stringifyGradient } from '@/utils/gradient';
6
+ import { CompactButton } from '../button';
7
+ import { Popover, PopoverContent, PopoverTrigger } from '../popover';
8
+ import { TextInput } from '../text-input';
9
+ import { Tooltip } from '../tooltip';
10
+
11
+ export interface GradientPickerProps {
12
+ modelValue?: string;
13
+ placeholder?: string;
14
+ emptyHint?: string;
15
+ noneLabel?: string;
16
+ linearLabel?: string;
17
+ radialLabel?: string;
18
+ angleLabel?: string;
19
+ colorStopLabel?: string;
20
+ addStopLabel?: string;
21
+ removeStopLabel?: string;
22
+ positionLabel?: string;
23
+ opacityLabel?: string;
24
+ hueLabel?: string;
25
+ areaLabel?: string;
26
+ eyedropperLabel?: string;
27
+ usedLabel?: string;
28
+ removeLabel?: string;
29
+ presets?: string[];
30
+ swatches?: string[];
31
+ }
32
+
33
+ export interface GradientPickerEmits {
34
+ 'update:modelValue': [value: string];
35
+ }
36
+
37
+ interface Stop { id: number; h: number; s: number; v: number; a: number; position: number }
38
+ interface State { type: GradientType; angle: number; shape?: string; stops: Stop[] }
39
+
40
+ const props = withDefaults(defineProps<GradientPickerProps>(), {
41
+ modelValue: '',
42
+ placeholder: 'No gradient chosen',
43
+ emptyHint: 'Select a gradient to begin editing',
44
+ noneLabel: 'None',
45
+ linearLabel: 'Linear gradient',
46
+ radialLabel: 'Radial gradient',
47
+ angleLabel: 'Angle',
48
+ colorStopLabel: 'Color stop',
49
+ addStopLabel: 'Add stop',
50
+ removeStopLabel: 'Remove stop',
51
+ positionLabel: 'Position',
52
+ opacityLabel: 'Opacity',
53
+ hueLabel: 'Hue',
54
+ areaLabel: 'Saturation and brightness',
55
+ eyedropperLabel: 'Pick color',
56
+ usedLabel: 'Currently used',
57
+ removeLabel: 'Remove gradient',
58
+ presets: () => [
59
+ 'linear-gradient(135deg, #e1116f 0%, #7d52f4 100%)',
60
+ 'linear-gradient(180deg, #f7f1e8 0%, #e3cfb4 100%)',
61
+ 'linear-gradient(160deg, #1f3d2b 0%, #4f7a5a 100%)',
62
+ 'linear-gradient(180deg, #3d3d3d 0%, #171717 100%)',
63
+ 'radial-gradient(#ffffff 0%, #dbe4ee 100%)',
64
+ 'linear-gradient(135deg, #e8a07a 0%, #b5532f 100%)',
65
+ 'linear-gradient(200deg, #0b3c5d 0%, #1e7f8c 100%)',
66
+ 'radial-gradient(#fff1f5 0%, #f7b5c9 100%)',
67
+ 'linear-gradient(120deg, #d6d29a 0%, #7a7a3a 100%)',
68
+ 'linear-gradient(180deg, #1b1f3b 0%, #4b3f8f 100%)',
69
+ 'linear-gradient(90deg, #ffd86b 0%, #f08a24 100%)',
70
+ ],
71
+ swatches: () => ['#ffffff', '#171717', '#f5f5f5', '#222530', '#000000', '#335cff', '#e1116f'],
72
+ });
73
+
74
+ const emit = defineEmits<GradientPickerEmits>();
75
+
76
+ const open = ref(false);
77
+ const state = ref<State | null>(null);
78
+ const selected = ref<number>();
79
+ const removing = ref<number>();
80
+ const drafts = reactive<Record<string, string>>({});
81
+ const preview = ref<HTMLElement>();
82
+ const bar = ref<HTMLElement>();
83
+ const area = ref<HTMLElement>();
84
+ const hue = ref<HTMLElement>();
85
+ const alpha = ref<HTMLElement>();
86
+ let uid = 0;
87
+
88
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
89
+ const numeric = (raw: string) => raw.trim() !== '' && !Number.isNaN(Number(raw));
90
+
91
+ function hsva(color: string) {
92
+ const parsed = tinycolor(color);
93
+ const { h, s, v } = parsed.toHsv();
94
+
95
+ return { h, s: s * 100, v: v * 100, a: parsed.getAlpha() };
96
+ }
97
+
98
+ function colorOf(stop: Omit<Stop, 'id' | 'position'>) {
99
+ const color = tinycolor({ h: stop.h, s: stop.s / 100, v: stop.v / 100, a: stop.a });
100
+
101
+ return stop.a >= 1 ? color.toHexString() : color.toRgbString();
102
+ }
103
+
104
+ function toCss(value: State, linear = false) {
105
+ return stringifyGradient({
106
+ type: linear ? 'linear' : value.type,
107
+ angle: linear ? 90 : value.angle,
108
+ shape: value.shape,
109
+ stops: value.stops.map(stop => ({ color: colorOf(stop), position: stop.position })),
110
+ });
111
+ }
112
+
113
+ function load(value?: string) {
114
+ const gradient = parseGradient(value);
115
+
116
+ state.value = gradient && {
117
+ type: gradient.type,
118
+ angle: gradient.angle,
119
+ shape: gradient.shape,
120
+ stops: gradient.stops.map(stop => ({ id: ++uid, ...hsva(stop.color), position: stop.position })),
121
+ };
122
+ selected.value = state.value?.stops[0].id;
123
+ }
124
+
125
+ const css = computed(() => (state.value ? toCss(state.value) : ''));
126
+
127
+ watch(() => props.modelValue, value => value !== css.value && load(value), { immediate: true });
128
+
129
+ const track = computed(() => (state.value ? toCss(state.value, true) : ''));
130
+ const current = computed(() => state.value?.stops.find(stop => stop.id === selected.value));
131
+ const solid = computed(() => current.value && colorOf({ ...current.value, a: 1 }));
132
+
133
+ function save() {
134
+ css.value !== props.modelValue && emit('update:modelValue', css.value);
135
+ }
136
+
137
+ function patch(changes: Partial<Stop>, keepDrafts = false) {
138
+ Object.assign(current.value!, changes);
139
+ keepDrafts || Object.keys(drafts).forEach(key => delete drafts[key]);
140
+ save();
141
+ }
142
+
143
+ function field(key: string, value: string | number, commit: (raw: string) => unknown) {
144
+ return {
145
+ value: drafts[key] ?? String(value),
146
+ input: (raw?: string | number) => {
147
+ drafts[key] = String(raw ?? '');
148
+ commit(drafts[key].trim());
149
+ },
150
+ };
151
+ }
152
+
153
+ const fields = computed(() => current.value && {
154
+ hex: field('hex', solid.value!.toUpperCase(), raw => /^#?[0-9a-f]{6}$/i.test(raw) && patch({ ...hsva(raw.replace(/^#?/, '#')), a: current.value!.a }, true)),
155
+ alpha: field('alpha', Math.round(current.value.a * 100), raw => numeric(raw) && patch({ a: clamp(Number(raw), 0, 100) / 100 }, true)),
156
+ position: field('position', current.value.position, raw => numeric(raw) && patch({ position: clamp(Math.round(Number(raw)), 0, 100) }, true)),
157
+ angle: field('angle', state.value!.angle, (raw) => {
158
+ if (numeric(raw)) {
159
+ state.value!.angle = ((Math.round(Number(raw)) % 360) + 360) % 360;
160
+ save();
161
+ }
162
+ }),
163
+ });
164
+
165
+ function drag(event: PointerEvent, move: (event: PointerEvent) => void, up?: () => void) {
166
+ event.preventDefault();
167
+ move(event);
168
+
169
+ const stop = () => {
170
+ window.removeEventListener('pointermove', move);
171
+ window.removeEventListener('pointerup', stop);
172
+ up?.();
173
+ };
174
+
175
+ window.addEventListener('pointermove', move);
176
+ window.addEventListener('pointerup', stop);
177
+ }
178
+
179
+ function keys(event: KeyboardEvent, apply: (dx: number, dy: number) => void) {
180
+ const step = event.shiftKey ? 10 : 1;
181
+ const delta = ({ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] } as Record<string, [number, number]>)[event.key];
182
+
183
+ if (delta) {
184
+ event.preventDefault();
185
+ apply(...delta);
186
+ }
187
+ }
188
+
189
+ function onHandleKey(event: KeyboardEvent, id: number) {
190
+ selected.value = id;
191
+
192
+ if (['Delete', 'Backspace'].includes(event.key) && state.value!.stops.length > 2) {
193
+ event.preventDefault();
194
+ removeStop();
195
+ }
196
+
197
+ keys(event, (dx, dy) => patch({ position: clamp(current.value!.position + dx - dy, 0, 100) }));
198
+ }
199
+
200
+ function relative(element: HTMLElement, event: PointerEvent, inset = 0) {
201
+ const rect = element.getBoundingClientRect();
202
+
203
+ return {
204
+ x: clamp((event.clientX - rect.left - inset) / (rect.width - 2 * inset), 0, 1),
205
+ y: clamp((event.clientY - rect.top - inset) / (rect.height - 2 * inset), 0, 1),
206
+ dy: event.clientY - rect.top - rect.height / 2,
207
+ };
208
+ }
209
+
210
+ function colorAt(position: number) {
211
+ const stops = [...state.value!.stops].sort((a, b) => a.position - b.position);
212
+ const after = stops.find(stop => stop.position >= position) ?? stops.at(-1)!;
213
+ const before = stops.findLast(stop => stop.position <= position) ?? stops[0];
214
+ const ratio = after.position === before.position ? 0 : (position - before.position) / (after.position - before.position);
215
+ const mixed = tinycolor.mix(colorOf({ ...before, a: 1 }), colorOf({ ...after, a: 1 }), ratio * 100).toHexString();
216
+
217
+ return { ...hsva(mixed), a: before.a + (after.a - before.a) * ratio };
218
+ }
219
+
220
+ function addAt(position: number) {
221
+ const id = ++uid;
222
+
223
+ state.value!.stops.push({ id, ...colorAt(position), position });
224
+ selected.value = id;
225
+ save();
226
+
227
+ return id;
228
+ }
229
+
230
+ function dragStop(event: PointerEvent, id: number) {
231
+ selected.value = id;
232
+ Object.keys(drafts).forEach(key => delete drafts[key]);
233
+
234
+ drag(event, (moved) => {
235
+ const { x, dy } = relative(bar.value!, moved, 10);
236
+
237
+ current.value!.position = Math.round(x * 100);
238
+ removing.value = state.value!.stops.length > 2 && Math.abs(dy) > 40 ? id : undefined;
239
+ save();
240
+ }, () => removing.value === id && removeStop());
241
+ }
242
+
243
+ function onBar(event: PointerEvent) {
244
+ event.target === event.currentTarget && dragStop(event, addAt(Math.round(relative(bar.value!, event, 10).x * 100)));
245
+ }
246
+
247
+ function onPreview(event: PointerEvent) {
248
+ state.value!.type === 'linear' && drag(event, (moved) => {
249
+ const rect = preview.value!.getBoundingClientRect();
250
+ const radians = Math.atan2(moved.clientX - rect.left - rect.width / 2, rect.top + rect.height / 2 - moved.clientY);
251
+ const degrees = (radians * 180) / Math.PI + 360;
252
+
253
+ state.value!.angle = Math.round(moved.shiftKey ? Math.round(degrees / 15) * 15 : degrees) % 360;
254
+ delete drafts.angle;
255
+ save();
256
+ });
257
+ }
258
+
259
+ function onArea(event: PointerEvent) {
260
+ drag(event, (moved) => {
261
+ const { x, y } = relative(area.value!, moved);
262
+
263
+ patch({ s: x * 100, v: (1 - y) * 100 });
264
+ });
265
+ }
266
+
267
+ function onHue(event: PointerEvent) {
268
+ drag(event, moved => patch({ h: Math.min(relative(hue.value!, moved, 12).y * 360, 359) }));
269
+ }
270
+
271
+ function onAlpha(event: PointerEvent) {
272
+ drag(event, moved => patch({ a: Math.round((1 - relative(alpha.value!, moved, 12).y) * 100) / 100 }));
273
+ }
274
+
275
+ function addStop() {
276
+ const stops = [...state.value!.stops].sort((a, b) => a.position - b.position);
277
+ const index = stops.indexOf(current.value!);
278
+ const next = stops[index + 1] ?? stops[index - 1];
279
+
280
+ addAt(Math.round((current.value!.position + next.position) / 2));
281
+ }
282
+
283
+ function removeStop() {
284
+ state.value!.stops = state.value!.stops.filter(stop => stop.id !== selected.value);
285
+ selected.value = state.value!.stops[0].id;
286
+ removing.value = undefined;
287
+ save();
288
+ }
289
+
290
+ function setType(type: GradientType) {
291
+ state.value!.type = type;
292
+ save();
293
+ }
294
+
295
+ function pick(value: string) {
296
+ load(value);
297
+ save();
298
+ }
299
+
300
+ async function sip() {
301
+ const result = await new window.EyeDropper!().open().catch(() => undefined);
302
+
303
+ result && patch({ ...hsva(result.sRGBHex), a: current.value!.a });
304
+ }
305
+
306
+ const fill = (color: string) => `linear-gradient(${color}, ${color}), repeating-conic-gradient(#e5e5e5 0 25%, #fff 0 50%) 0 0 / 8px 8px`;
307
+ const along = (fraction: number) => `calc(12px + (100% - 24px) * ${fraction})`;
308
+ const canSip = 'EyeDropper' in window;
309
+ </script>
310
+
311
+ <template>
312
+ <Popover v-model:open="open">
313
+ <PopoverTrigger as-child>
314
+ <button type="button" class="celeste-gradient-trigger">
315
+ <span
316
+ class="celeste-gradient-swatch"
317
+ :data-empty="!state"
318
+ :style="state ? { background: css } : undefined"
319
+ />
320
+ <span class="celeste-gradient-summary">
321
+ {{ state ? (state.type === 'linear' ? linearLabel : radialLabel) : placeholder }}
322
+ </span>
323
+ <i class="i-celeste-expand-up-down-line" />
324
+ </button>
325
+ </PopoverTrigger>
326
+ <PopoverContent
327
+ :dismissible="false"
328
+ :show-tail="false"
329
+ align="start"
330
+ @open-auto-focus.prevent
331
+ >
332
+ <div class="celeste-gradient-picker">
333
+ <div v-if="!state" class="celeste-gradient-section">
334
+ <span class="celeste-gradient-hint">{{ emptyHint }}</span>
335
+ <div class="celeste-gradient-presets">
336
+ <button
337
+ type="button"
338
+ class="celeste-gradient-preset"
339
+ data-empty="true"
340
+ :title="noneLabel"
341
+ :aria-label="noneLabel"
342
+ @click="open = false"
343
+ />
344
+ <button
345
+ v-for="preset in presets"
346
+ :key="preset"
347
+ type="button"
348
+ class="celeste-gradient-preset"
349
+ :style="{ background: preset }"
350
+ :aria-label="preset"
351
+ @click="pick(preset)"
352
+ />
353
+ </div>
354
+ </div>
355
+
356
+ <template v-else-if="current && fields">
357
+ <div class="celeste-gradient-section">
358
+ <div
359
+ ref="preview"
360
+ class="celeste-gradient-preview"
361
+ :data-linear="state.type === 'linear'"
362
+ @pointerdown="onPreview"
363
+ >
364
+ <span class="celeste-gradient-fill" :style="{ background: css }" />
365
+ <span v-if="state.type === 'linear'" class="celeste-gradient-ring">
366
+ <span :style="{ rotate: `${state.angle}deg` }" />
367
+ </span>
368
+ </div>
369
+
370
+ <div class="celeste-gradient-row">
371
+ <Tooltip
372
+ v-for="type in (['linear', 'radial'] as const)"
373
+ :key="type"
374
+ :title="type === 'linear' ? linearLabel : radialLabel"
375
+ size="xs"
376
+ variant="dark"
377
+ >
378
+ <button
379
+ type="button"
380
+ class="celeste-gradient-type"
381
+ :data-type="type"
382
+ :data-active="state.type === type"
383
+ :aria-label="type === 'linear' ? linearLabel : radialLabel"
384
+ @click="setType(type)"
385
+ >
386
+ <span />
387
+ </button>
388
+ </Tooltip>
389
+ <TextInput
390
+ v-if="state.type === 'linear'"
391
+ :model-value="fields.angle.value"
392
+ inputmode="numeric"
393
+ size="xs"
394
+ :aria-label="angleLabel"
395
+ class="celeste-gradient-number"
396
+ @update:model-value="fields.angle.input"
397
+ >
398
+ <template #trailingNode>
399
+ °
400
+ </template>
401
+ </TextInput>
402
+ </div>
403
+
404
+ <div
405
+ ref="bar"
406
+ class="celeste-gradient-bar"
407
+ @pointerdown="onBar"
408
+ >
409
+ <span class="celeste-gradient-fill" :style="{ background: track }" />
410
+ <button
411
+ v-for="stop in state.stops"
412
+ :key="stop.id"
413
+ type="button"
414
+ class="celeste-gradient-handle"
415
+ :style="{ left: `calc(10px + (100% - 20px) * ${stop.position / 100})`, background: fill(colorOf(stop)) }"
416
+ :data-active="stop.id === selected"
417
+ :data-removing="stop.id === removing"
418
+ :aria-label="`${colorOf(stop)} ${stop.position}%`"
419
+ @pointerdown.stop="dragStop($event, stop.id)"
420
+ @keydown="onHandleKey($event, stop.id)"
421
+ />
422
+ </div>
423
+
424
+ <div class="celeste-gradient-row">
425
+ <span class="celeste-gradient-eyebrow">{{ colorStopLabel }}</span>
426
+ <CompactButton
427
+ icon="i-celeste-add-line"
428
+ variant="ghost"
429
+ size="md"
430
+ :aria-label="addStopLabel"
431
+ @click="addStop"
432
+ />
433
+ <CompactButton
434
+ icon="i-celeste-subtract-line"
435
+ variant="ghost"
436
+ size="md"
437
+ :aria-label="removeStopLabel"
438
+ :disabled="state.stops.length <= 2"
439
+ @click="removeStop"
440
+ />
441
+ </div>
442
+
443
+ <div class="celeste-gradient-row">
444
+ <span class="celeste-gradient-label">{{ positionLabel }}</span>
445
+ <TextInput
446
+ :model-value="fields.position.value"
447
+ inputmode="numeric"
448
+ size="xs"
449
+ :aria-label="positionLabel"
450
+ class="celeste-gradient-number"
451
+ @update:model-value="fields.position.input"
452
+ >
453
+ <template #trailingNode>
454
+ %
455
+ </template>
456
+ </TextInput>
457
+ </div>
458
+
459
+ <div class="celeste-gradient-row">
460
+ <span class="celeste-gradient-current">
461
+ <span class="celeste-gradient-fill" :style="{ background: colorOf(current) }" />
462
+ </span>
463
+ <TextInput
464
+ :model-value="fields.hex.value"
465
+ size="xs"
466
+ class="celeste-gradient-hex"
467
+ @update:model-value="fields.hex.input"
468
+ >
469
+ <template v-if="canSip" #trailingNode>
470
+ <CompactButton
471
+ icon="i-celeste-sip-line"
472
+ variant="ghost"
473
+ size="md"
474
+ :aria-label="eyedropperLabel"
475
+ @click="sip"
476
+ />
477
+ </template>
478
+ </TextInput>
479
+ <TextInput
480
+ :model-value="fields.alpha.value"
481
+ inputmode="numeric"
482
+ size="xs"
483
+ :aria-label="opacityLabel"
484
+ class="celeste-gradient-number"
485
+ @update:model-value="fields.alpha.input"
486
+ >
487
+ <template #trailingNode>
488
+ %
489
+ </template>
490
+ </TextInput>
491
+ </div>
492
+
493
+ <div class="celeste-gradient-color">
494
+ <div
495
+ ref="area"
496
+ class="celeste-gradient-area"
497
+ :style="{ backgroundColor: `hsl(${current.h}, 100%, 50%)` }"
498
+ role="slider"
499
+ tabindex="0"
500
+ :aria-label="areaLabel"
501
+ :aria-valuenow="Math.round(current.s)"
502
+ :aria-valuetext="`${Math.round(current.s)}%, ${Math.round(current.v)}%`"
503
+ @pointerdown="onArea"
504
+ @keydown="keys($event, (dx, dy) => patch({ s: clamp(current!.s + dx, 0, 100), v: clamp(current!.v - dy, 0, 100) }))"
505
+ >
506
+ <span class="celeste-gradient-knob" :style="{ left: `${current.s}%`, top: `${100 - current.v}%`, background: fill(colorOf(current)) }" />
507
+ </div>
508
+ <div
509
+ ref="hue"
510
+ class="celeste-gradient-slider"
511
+ data-hue="true"
512
+ role="slider"
513
+ tabindex="0"
514
+ aria-orientation="vertical"
515
+ aria-valuemin="0"
516
+ aria-valuemax="359"
517
+ :aria-label="hueLabel"
518
+ :aria-valuenow="Math.round(current.h)"
519
+ @pointerdown="onHue"
520
+ @keydown="keys($event, (dx, dy) => patch({ h: clamp(current!.h + dx + dy, 0, 359) }))"
521
+ >
522
+ <span class="celeste-gradient-knob" :style="{ top: along(current.h / 360), background: fill(colorOf(current)) }" />
523
+ </div>
524
+ <div
525
+ ref="alpha"
526
+ class="celeste-gradient-slider"
527
+ role="slider"
528
+ tabindex="0"
529
+ aria-orientation="vertical"
530
+ aria-valuemin="0"
531
+ aria-valuemax="100"
532
+ :aria-label="opacityLabel"
533
+ :aria-valuenow="Math.round(current.a * 100)"
534
+ @pointerdown="onAlpha"
535
+ @keydown="keys($event, (dx, dy) => patch({ a: clamp(Math.round(current!.a * 100 + dx - dy), 0, 100) / 100 }))"
536
+ >
537
+ <span class="celeste-gradient-fill" :style="{ background: `linear-gradient(${solid}, transparent)` }" />
538
+ <span class="celeste-gradient-knob" :style="{ top: along(1 - current.a), background: fill(colorOf(current)) }" />
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ <div class="celeste-gradient-section celeste-gradient-used">
544
+ <span class="celeste-gradient-label">{{ usedLabel }}</span>
545
+ <div class="celeste-gradient-swatches">
546
+ <button
547
+ v-for="swatch in swatches"
548
+ :key="swatch"
549
+ type="button"
550
+ :style="{ background: swatch }"
551
+ :title="swatch"
552
+ :aria-label="swatch"
553
+ @click="patch({ ...hsva(swatch), a: current.a })"
554
+ />
555
+ </div>
556
+ </div>
557
+
558
+ <div class="celeste-gradient-footer">
559
+ <button
560
+ type="button"
561
+ class="celeste-gradient-remove"
562
+ @click="pick('')"
563
+ >
564
+ <i class="i-celeste-delete-bin-line" />
565
+ {{ removeLabel }}
566
+ </button>
567
+ </div>
568
+ </template>
569
+ </div>
570
+ </PopoverContent>
571
+ </Popover>
572
+ </template>
573
+
574
+ <style scoped lang="scss">
575
+ $checker: repeating-conic-gradient(#e5e5e5 0 25%, #fff 0 50%) 0 0 / 8px 8px;
576
+ $none:
577
+ linear-gradient(135deg, transparent 47%, var(--color-stroke-sub-300) 47% 53%, transparent 53%),
578
+ var(--color-bg-white-0);
579
+ $inset: inset 0 0 0 1px #0000001a;
580
+
581
+ .celeste-gradient-trigger {
582
+ display: flex;
583
+ align-items: center;
584
+ width: 100%;
585
+ height: 36px;
586
+ padding: 0 var(--spacing-8) 0 var(--spacing-6);
587
+ border: 1px solid var(--color-stroke-soft-200);
588
+ border-radius: var(--radius-8);
589
+ background: var(--color-bg-white-0);
590
+ box-shadow: var(--shadow-regular-xs);
591
+ color: var(--color-text-strong-950);
592
+ font: var(--label-sm);
593
+ text-align: start;
594
+ cursor: pointer;
595
+ gap: var(--spacing-8);
596
+
597
+ &:hover {
598
+ background: var(--color-bg-weak-50);
599
+ }
600
+
601
+ i {
602
+ flex: none;
603
+ width: 16px;
604
+ height: 16px;
605
+ color: var(--color-icon-sub-600);
606
+ }
607
+ }
608
+
609
+ .celeste-gradient-swatch {
610
+ flex: none;
611
+ width: 24px;
612
+ height: 24px;
613
+ border-radius: var(--radius-full);
614
+ box-shadow: $inset;
615
+
616
+ &[data-empty='true'] {
617
+ background: $none;
618
+ }
619
+ }
620
+
621
+ .celeste-gradient-summary {
622
+ flex: 1;
623
+ min-width: 0;
624
+ overflow: hidden;
625
+ text-overflow: ellipsis;
626
+ white-space: nowrap;
627
+ }
628
+
629
+ .celeste-gradient-picker {
630
+ display: flex;
631
+ flex-direction: column;
632
+ width: 304px;
633
+ max-height: var(--reka-popover-content-available-height);
634
+ overflow-y: auto;
635
+ overscroll-behavior: contain;
636
+ }
637
+
638
+ .celeste-gradient-section {
639
+ display: flex;
640
+ flex-direction: column;
641
+ padding: var(--spacing-16);
642
+ gap: var(--spacing-12);
643
+ }
644
+
645
+ .celeste-gradient-fill {
646
+ position: absolute;
647
+ inset: 0;
648
+ border-radius: inherit;
649
+ box-shadow: $inset;
650
+ pointer-events: none;
651
+ }
652
+
653
+ .celeste-gradient-hint {
654
+ color: var(--color-text-sub-600);
655
+ font: var(--paragraph-sm);
656
+ }
657
+
658
+ .celeste-gradient-presets {
659
+ display: grid;
660
+ grid-template-columns: repeat(6, 1fr);
661
+ gap: var(--spacing-8);
662
+ }
663
+
664
+ .celeste-gradient-preset {
665
+ aspect-ratio: 1;
666
+ padding: 0;
667
+ transition: box-shadow 200ms ease-out;
668
+ border: 0;
669
+ border-radius: var(--radius-full);
670
+ box-shadow: $inset;
671
+ cursor: pointer;
672
+
673
+ &[data-empty='true'] {
674
+ background: $none;
675
+ }
676
+
677
+ &:hover,
678
+ &:focus-visible {
679
+ outline: none;
680
+ box-shadow:
681
+ $inset,
682
+ 0 0 0 2px var(--color-bg-white-0),
683
+ 0 0 0 4px var(--color-primary-base);
684
+ }
685
+ }
686
+
687
+ .celeste-gradient-preview {
688
+ position: relative;
689
+ height: 144px;
690
+ border-radius: var(--radius-12);
691
+ background: $checker;
692
+ touch-action: none;
693
+
694
+ &[data-linear='true'] {
695
+ cursor: grab;
696
+ }
697
+ }
698
+
699
+ .celeste-gradient-ring {
700
+ position: absolute;
701
+ top: calc(50% - 40px);
702
+ left: calc(50% - 40px);
703
+ width: 80px;
704
+ height: 80px;
705
+ border: 2px solid #ffffffb3;
706
+ border-radius: var(--radius-full);
707
+ box-shadow: 0 0 0 1px #0000001a;
708
+ pointer-events: none;
709
+
710
+ span {
711
+ position: absolute;
712
+ inset: -2px;
713
+
714
+ &::after {
715
+ content: '';
716
+ position: absolute;
717
+ top: -6px;
718
+ left: calc(50% - 6px);
719
+ width: 12px;
720
+ height: 12px;
721
+ border-radius: var(--radius-full);
722
+ background: var(--color-static-white);
723
+ box-shadow: 0 1px 3px #0e121b40;
724
+ }
725
+ }
726
+ }
727
+
728
+ .celeste-gradient-row {
729
+ display: flex;
730
+ align-items: center;
731
+ gap: var(--spacing-8);
732
+ }
733
+
734
+ .celeste-gradient-type {
735
+ width: 40px;
736
+ height: 40px;
737
+ padding: var(--spacing-4);
738
+ border: 1px solid var(--color-stroke-soft-200);
739
+ border-radius: var(--radius-10);
740
+ background: var(--color-bg-white-0);
741
+ cursor: pointer;
742
+
743
+ &[data-active='true'] {
744
+ border-color: var(--color-primary-base);
745
+ box-shadow: 0 0 0 2px var(--color-primary-alpha-10);
746
+ }
747
+
748
+ span {
749
+ display: block;
750
+ height: 100%;
751
+ border-radius: var(--radius-6);
752
+ background: linear-gradient(var(--color-bg-white-0), var(--color-text-soft-400));
753
+ box-shadow: $inset;
754
+ }
755
+
756
+ &[data-type='radial'] span {
757
+ background: radial-gradient(var(--color-bg-white-0), var(--color-text-soft-400));
758
+ }
759
+ }
760
+
761
+ .celeste-gradient-number {
762
+ flex: none;
763
+ width: 80px;
764
+ margin-inline-start: auto;
765
+ }
766
+
767
+ .celeste-gradient-bar {
768
+ position: relative;
769
+ height: 24px;
770
+ border-radius: var(--radius-full);
771
+ background: $checker;
772
+ cursor: copy;
773
+ touch-action: none;
774
+ }
775
+
776
+ .celeste-gradient-handle {
777
+ position: absolute;
778
+ top: calc(50% - 10px);
779
+ width: 20px;
780
+ height: 20px;
781
+ margin-inline-start: -10px;
782
+ padding: 0;
783
+ border: 3px solid var(--color-static-white);
784
+ border-radius: var(--radius-full);
785
+ box-shadow:
786
+ 0 0 0 1px #0000002e,
787
+ 0 1px 3px #0e121b40;
788
+ cursor: grab;
789
+ touch-action: none;
790
+
791
+ &[data-active='true'] {
792
+ box-shadow:
793
+ 0 0 0 2px var(--color-primary-base),
794
+ 0 1px 3px #0e121b40;
795
+ }
796
+
797
+ &[data-removing='true'] {
798
+ opacity: 0.3;
799
+ }
800
+ }
801
+
802
+ .celeste-gradient-eyebrow {
803
+ flex: 1;
804
+ color: var(--color-text-soft-400);
805
+ font: var(--subheading-xxs);
806
+ letter-spacing: 0.06em;
807
+ text-transform: uppercase;
808
+ }
809
+
810
+ .celeste-gradient-label {
811
+ flex: 1;
812
+ color: var(--color-text-sub-600);
813
+ font: var(--label-xs);
814
+ }
815
+
816
+ .celeste-gradient-current {
817
+ position: relative;
818
+ flex: none;
819
+ width: 32px;
820
+ height: 32px;
821
+ border-radius: var(--radius-8);
822
+ background: $checker;
823
+ }
824
+
825
+ .celeste-gradient-hex {
826
+ flex: 1;
827
+ min-width: 0;
828
+ }
829
+
830
+ .celeste-gradient-color {
831
+ display: flex;
832
+ height: 200px;
833
+ gap: var(--spacing-8);
834
+ }
835
+
836
+ .celeste-gradient-area {
837
+ position: relative;
838
+ flex: 1;
839
+ border-radius: var(--radius-10);
840
+ background-image: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent);
841
+ cursor: crosshair;
842
+ touch-action: none;
843
+ }
844
+
845
+ .celeste-gradient-slider {
846
+ position: relative;
847
+ flex: none;
848
+ width: 24px;
849
+ border-radius: var(--radius-full);
850
+ background: $checker;
851
+ cursor: pointer;
852
+ touch-action: none;
853
+
854
+ &[data-hue='true'] {
855
+ background: linear-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
856
+ }
857
+
858
+ .celeste-gradient-knob {
859
+ left: 50%;
860
+ }
861
+ }
862
+
863
+ .celeste-gradient-area,
864
+ .celeste-gradient-slider,
865
+ .celeste-gradient-handle {
866
+ &:focus-visible {
867
+ outline: 2px solid var(--color-primary-base);
868
+ outline-offset: 2px;
869
+ }
870
+ }
871
+
872
+ .celeste-gradient-knob {
873
+ position: absolute;
874
+ width: 20px;
875
+ height: 20px;
876
+ transform: translate(-50%, -50%);
877
+ border: 3px solid var(--color-static-white);
878
+ border-radius: var(--radius-full);
879
+ box-shadow:
880
+ 0 0 0 1px #0000001f,
881
+ 0 1px 3px #0e121b40;
882
+ pointer-events: none;
883
+ }
884
+
885
+ .celeste-gradient-used {
886
+ padding-top: var(--spacing-12);
887
+ border-top: 1px solid var(--color-stroke-soft-200);
888
+ gap: var(--spacing-8);
889
+ }
890
+
891
+ .celeste-gradient-swatches {
892
+ display: flex;
893
+ flex-wrap: wrap;
894
+ gap: var(--spacing-8);
895
+
896
+ button {
897
+ width: 28px;
898
+ height: 28px;
899
+ padding: 0;
900
+ border: 0;
901
+ border-radius: var(--radius-8);
902
+ box-shadow: $inset;
903
+ cursor: pointer;
904
+
905
+ &:hover,
906
+ &:focus-visible {
907
+ outline: none;
908
+ box-shadow:
909
+ $inset,
910
+ 0 0 0 2px var(--color-bg-white-0),
911
+ 0 0 0 3px var(--color-stroke-sub-300);
912
+ }
913
+ }
914
+ }
915
+
916
+ .celeste-gradient-footer {
917
+ padding: var(--spacing-8);
918
+ border-top: 1px solid var(--color-stroke-soft-200);
919
+ }
920
+
921
+ .celeste-gradient-remove {
922
+ display: flex;
923
+ align-items: center;
924
+ width: 100%;
925
+ padding: var(--spacing-8);
926
+ border: 0;
927
+ border-radius: var(--radius-8);
928
+ background: transparent;
929
+ color: var(--color-text-sub-600);
930
+ font: var(--label-sm);
931
+ cursor: pointer;
932
+ gap: var(--spacing-8);
933
+
934
+ &:hover {
935
+ background: var(--color-bg-weak-50);
936
+ color: var(--color-text-strong-950);
937
+ }
938
+
939
+ i {
940
+ width: 20px;
941
+ height: 20px;
942
+ }
943
+ }
944
+ </style>