@heyramzi/ui 7.0.0 → 8.0.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/COMPONENTS.md +28 -17
- package/README.md +5 -1
- package/package.json +25 -15
- package/scripts/sync-shadcn.mjs +17 -13
- package/scripts/sync-transitions.mjs +11 -11
- package/src/ai/ai-button/AiButton.svelte +86 -0
- package/src/ai/ai-button/index.ts +4 -0
- package/src/ai/confirmation/Confirmation.svelte +8 -8
- package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
- package/src/ai/generation-progress/GenerationProgress.svelte +354 -0
- package/src/ai/generation-progress/index.ts +7 -0
- package/src/ai/index.ts +16 -6
- package/src/ai/prompt-input/PromptInput.svelte +19 -1
- package/src/ai/prompt-input/PromptInputSubmit.svelte +9 -3
- package/src/ai/reasoning/Reasoning.svelte +11 -8
- package/src/ambient.d.ts +4 -0
- package/src/animations/README.md +14 -14
- package/src/animations/index.css +3 -14
- package/src/animations/upstream-coverage.test.ts +1 -1
- package/src/components/copy/clipboard.test.ts +206 -0
- package/src/components/copy/clipboard.ts +49 -0
- package/src/components/copy/copied.svelte.ts +39 -0
- package/src/components/copy/copy-button.svelte +175 -0
- package/src/components/copy/copy-button.test.ts +110 -0
- package/src/components/copy/index.ts +3 -0
- package/src/components/data-table/data-table.test.ts +134 -0
- package/src/components/data-table/data-table.ts +77 -0
- package/src/components/data-table/index.ts +20 -0
- package/src/components/data-table/infinite-scroll.svelte +69 -0
- package/src/components/data-table/pagination.svelte +62 -0
- package/src/components/data-table/row-arrow.svelte +28 -0
- package/src/components/data-table/row-context-menu.svelte +106 -0
- package/src/components/data-table/row-context-menu.test.ts +18 -0
- package/src/components/data-table/sortable-head.svelte +56 -0
- package/src/components/data-table/table-skeleton.svelte +83 -0
- package/src/components/hold-to-confirm/hold-to-confirm.svelte +232 -0
- package/src/components/hold-to-confirm/index.ts +1 -0
- package/src/components/icon/icon.svelte +23 -0
- package/src/components/icon/index.ts +1 -0
- package/src/components/language-flag/index.ts +1 -0
- package/src/components/language-flag/language-flag.svelte +60 -0
- package/src/components/list-page/detail-empty.svelte +50 -0
- package/src/components/list-page/detail-header.svelte +107 -0
- package/src/components/list-page/detail-panel.svelte +90 -0
- package/src/components/list-page/detail-row.svelte +36 -0
- package/src/components/list-page/detail-stack.svelte +33 -0
- package/src/components/list-page/empty-state.svelte +91 -0
- package/src/components/list-page/filter-item.svelte +49 -0
- package/src/components/list-page/filter-popover.svelte +88 -0
- package/src/components/list-page/filter-section.ts +12 -0
- package/src/components/list-page/grouped-list.svelte +116 -0
- package/src/components/list-page/index.ts +28 -0
- package/src/components/list-page/list-page-shell.svelte +195 -0
- package/src/components/list-page/list-table.ts +19 -0
- package/src/components/list-page/master-detail.svelte +206 -0
- package/src/components/list-page/master-detail.test.ts +37 -0
- package/src/components/list-page/notice-bar.svelte +68 -0
- package/src/components/list-page/prev-next.svelte +104 -0
- package/src/components/list-page/queue-rail.ts +25 -0
- package/src/components/list-page/rail-body.svelte +29 -0
- package/src/components/list-page/rail-empty.svelte +51 -0
- package/src/components/list-page/rail-head.svelte +49 -0
- package/src/components/list-page/rail-row.svelte +163 -0
- package/src/components/list-page/section-host.ts +24 -0
- package/src/components/list-page/section-label.svelte +39 -0
- package/src/components/list-page/step.test.ts +21 -0
- package/src/components/list-page/step.ts +10 -0
- package/src/components/list-page/toolbar-search.svelte +63 -0
- package/src/components/list-page/toolbar-trigger.ts +23 -0
- package/src/components/list-page/types.ts +1 -0
- package/src/components/locale-switch/index.ts +1 -0
- package/src/components/locale-switch/locale-switch.svelte +59 -0
- package/src/components/mark/index.ts +1 -0
- package/src/components/mark/mark.svelte +70 -0
- package/src/components/open-in/index.ts +6 -0
- package/src/components/open-in/open-in.svelte +106 -0
- package/src/components/picker/index.ts +4 -0
- package/src/components/picker/mark-picker.svelte +84 -0
- package/src/components/picker/picker.svelte +143 -0
- package/src/components/picker/trigger.ts +42 -0
- package/src/components/picker/types.ts +26 -0
- package/src/components/segmented/animated-tabs.svelte +175 -0
- package/src/components/segmented/collapse.test.ts +49 -0
- package/src/components/segmented/collapse.ts +39 -0
- package/src/components/segmented/index.ts +41 -0
- package/src/components/segmented/section-runs.svelte +79 -0
- package/src/components/segmented/segment.svelte +106 -0
- package/src/components/segmented/segmented-filter.svelte +125 -0
- package/src/components/segmented/segmented-nav.svelte +82 -0
- package/src/components/segmented/segmented-overflow.svelte +85 -0
- package/src/components/segmented/segmented-track.svelte +181 -0
- package/src/components/segmented/segmented.test.ts +20 -0
- package/src/components/segmented/segmented.ts +80 -0
- package/src/components/star-rating-input/index.ts +9 -0
- package/src/components/star-rating-input/star-rating-input.svelte +256 -0
- package/src/components/svg/index.ts +1 -0
- package/src/components/svg/svg.svelte +9 -0
- package/src/effects/border-beam/BorderBeam.svelte +1 -1
- package/src/effects/image-comparison/ImageComparison.css +42 -8
- package/src/effects/image-comparison/ImageComparison.svelte +138 -21
- package/src/effects/image-comparison/compare.test.ts +17 -0
- package/src/effects/image-comparison/compare.ts +5 -0
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +338 -0
- package/src/effects/magic-card/MagicCard.css +47 -0
- package/src/effects/magic-card/MagicCard.svelte +57 -0
- package/src/effects/marquee/Marquee.css +27 -1
- package/src/effects/marquee/Marquee.svelte +67 -3
- package/src/effects/reveal/index.ts +2 -1
- package/src/effects/reveal/observe.test.ts +60 -0
- package/src/effects/reveal/observe.ts +33 -1
- package/src/effects/reveal/types.ts +7 -0
- package/src/effects/theme-toggle/ThemeToggle.css +199 -6
- package/src/effects/theme-toggle/ThemeToggle.svelte +51 -26
- package/src/gallery.test.ts +52 -0
- package/src/index.ts +5 -1
- package/src/lib/panel.ts +7 -1
- package/src/lib/theme-transition.ts +37 -11
- package/src/lib/tone.ts +48 -0
- package/src/shadcn/accordion/accordion-trigger.svelte +15 -6
- package/src/shadcn/alert/alert.svelte +3 -1
- package/src/shadcn/badge/badge.svelte +8 -7
- package/src/shadcn/breadcrumb/breadcrumb-ellipsis.svelte +2 -2
- package/src/shadcn/breadcrumb/breadcrumb-separator.svelte +2 -2
- package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
- package/src/shadcn/calendar/calendar-next-button.svelte +2 -2
- package/src/shadcn/calendar/calendar-prev-button.svelte +2 -2
- package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
- package/src/shadcn/calendar/calendar.svelte +6 -0
- package/src/shadcn/code-block/code-block-copy-button.svelte +6 -55
- package/src/shadcn/command/index.ts +3 -0
- package/src/shadcn/field/index.ts +3 -0
- package/src/shadcn/field/simple-field.svelte +71 -0
- package/src/shadcn/field/simple-field.test.ts +47 -0
- package/src/shadcn/message-scroller/message-scroller-button.svelte +2 -2
- package/src/shadcn/radio-group/radio-group-item.svelte +3 -2
- package/src/shadcn/sheet/sheet-content.svelte +1 -0
- package/src/shadcn/sheet/sheet.css +48 -0
- package/src/shadcn/sonner/sonner.svelte +7 -10
- package/src/shadcn/toggle-group/toggle-group.svelte +10 -3
- package/src/shadcn/trace/trace-collapsible-trigger.svelte +2 -2
- package/src/ai/composer/Composer.css +0 -108
- package/src/ai/composer/Composer.svelte +0 -111
- package/src/ai/composer/index.ts +0 -10
- package/src/animations/accordion.css +0 -58
- package/src/animations/icon-swap.css +0 -43
- package/src/animations/notification-badge.css +0 -81
- package/src/animations/panel.css +0 -86
- package/src/animations/reasoning-stream.css +0 -49
- package/src/animations/skeleton-reveal.css +0 -97
- package/src/animations/streaming-text.css +0 -31
- package/src/animations/tabs-sliding.css +0 -83
- package/src/animations/texts-reveal.css +0 -70
- package/src/animations/tooltip.css +0 -72
- package/src/animations/view-transition-reveal.ts +0 -74
- package/src/lib/utils.ts +0 -45
- package/src/showcase/AiSection.svelte +0 -150
- package/src/showcase/AnimationsSection.svelte +0 -1658
- package/src/showcase/EffectsSection.svelte +0 -152
- package/src/showcase/ShadcnSection.svelte +0 -27
- package/src/showcase/ShowcaseImport.svelte +0 -126
- package/src/showcase/ShowcaseList.svelte +0 -132
- package/src/showcase/ShowcaseNav.svelte +0 -247
- package/src/showcase/ShowcaseSection.svelte +0 -112
- package/src/showcase/ShowcaseShell.svelte +0 -55
- package/src/showcase/ShowcaseStage.svelte +0 -342
- package/src/showcase/ShowcaseVariant.svelte +0 -109
- package/src/showcase/assets/image-reveal/art-1.jpg +0 -0
- package/src/showcase/assets/image-reveal/art-2.jpg +0 -0
- package/src/showcase/assets/image-reveal/art-3.jpg +0 -0
- package/src/showcase/check.svg +0 -1
- package/src/showcase/chevron-arms.svg +0 -2
- package/src/showcase/chevron-down.svg +0 -1
- package/src/showcase/copy.svg +0 -2
- package/src/showcase/demos/ai/confirmation.svelte +0 -59
- package/src/showcase/demos/ai/loading-state.svelte +0 -19
- package/src/showcase/demos/ai/prompt-input.svelte +0 -57
- package/src/showcase/demos/ai/reasoning.svelte +0 -19
- package/src/showcase/demos/ai/thinking-dots.svelte +0 -15
- package/src/showcase/demos/ai/voice-waveform.svelte +0 -24
- package/src/showcase/demos/effects/animated-beam.svelte +0 -50
- package/src/showcase/demos/effects/animated-group.svelte +0 -47
- package/src/showcase/demos/effects/border-beam-default.svelte +0 -20
- package/src/showcase/demos/effects/border-beam-line.svelte +0 -20
- package/src/showcase/demos/effects/border-beam-mono.svelte +0 -20
- package/src/showcase/demos/effects/border-beam-sunset.svelte +0 -20
- package/src/showcase/demos/effects/image-comparison.svelte +0 -25
- package/src/showcase/demos/effects/image-reveal.svelte +0 -49
- package/src/showcase/demos/effects/in-view.svelte +0 -41
- package/src/showcase/demos/effects/integrations-hub.svelte +0 -82
- package/src/showcase/demos/effects/magnetic.svelte +0 -31
- package/src/showcase/demos/effects/marquee.svelte +0 -33
- package/src/showcase/demos/effects/progressive-blur.svelte +0 -47
- package/src/showcase/demos/effects/spotlight.svelte +0 -40
- package/src/showcase/demos/effects/text-effect.svelte +0 -55
- package/src/showcase/demos/shadcn/accordion.svelte +0 -45
- package/src/showcase/demos/shadcn/alert-dialog.svelte +0 -23
- package/src/showcase/demos/shadcn/alert.svelte +0 -30
- package/src/showcase/demos/shadcn/attachment.svelte +0 -71
- package/src/showcase/demos/shadcn/avatar.svelte +0 -30
- package/src/showcase/demos/shadcn/badge.svelte +0 -26
- package/src/showcase/demos/shadcn/breadcrumb.svelte +0 -34
- package/src/showcase/demos/shadcn/bubble.svelte +0 -36
- package/src/showcase/demos/shadcn/button.svelte +0 -11
- package/src/showcase/demos/shadcn/calendar.svelte +0 -8
- package/src/showcase/demos/shadcn/card.svelte +0 -39
- package/src/showcase/demos/shadcn/checkbox.svelte +0 -39
- package/src/showcase/demos/shadcn/code-block.svelte +0 -80
- package/src/showcase/demos/shadcn/collapsible.svelte +0 -20
- package/src/showcase/demos/shadcn/command.svelte +0 -48
- package/src/showcase/demos/shadcn/context-menu.svelte +0 -54
- package/src/showcase/demos/shadcn/demo-harness.svelte +0 -12
- package/src/showcase/demos/shadcn/demos.test.ts +0 -20
- package/src/showcase/demos/shadcn/dialog.svelte +0 -38
- package/src/showcase/demos/shadcn/dropdown-menu.svelte +0 -59
- package/src/showcase/demos/shadcn/empty.svelte +0 -29
- package/src/showcase/demos/shadcn/field.svelte +0 -115
- package/src/showcase/demos/shadcn/hover-card.svelte +0 -32
- package/src/showcase/demos/shadcn/index.ts +0 -382
- package/src/showcase/demos/shadcn/input-otp.svelte +0 -19
- package/src/showcase/demos/shadcn/input.svelte +0 -5
- package/src/showcase/demos/shadcn/label.svelte +0 -11
- package/src/showcase/demos/shadcn/marker.svelte +0 -30
- package/src/showcase/demos/shadcn/message-scroller.svelte +0 -44
- package/src/showcase/demos/shadcn/message.svelte +0 -75
- package/src/showcase/demos/shadcn/popover.svelte +0 -36
- package/src/showcase/demos/shadcn/progress.svelte +0 -13
- package/src/showcase/demos/shadcn/radio-group.svelte +0 -19
- package/src/showcase/demos/shadcn/scroll-area.svelte +0 -18
- package/src/showcase/demos/shadcn/select.svelte +0 -29
- package/src/showcase/demos/shadcn/separator.svelte +0 -12
- package/src/showcase/demos/shadcn/sheet.svelte +0 -48
- package/src/showcase/demos/shadcn/skeleton.svelte +0 -11
- package/src/showcase/demos/shadcn/slider.svelte +0 -7
- package/src/showcase/demos/shadcn/sonner.svelte +0 -18
- package/src/showcase/demos/shadcn/spinner.svelte +0 -11
- package/src/showcase/demos/shadcn/switch.svelte +0 -9
- package/src/showcase/demos/shadcn/table.svelte +0 -76
- package/src/showcase/demos/shadcn/tabs.svelte +0 -62
- package/src/showcase/demos/shadcn/textarea.svelte +0 -5
- package/src/showcase/demos/shadcn/toggle-group.svelte +0 -18
- package/src/showcase/demos/shadcn/toggle.svelte +0 -14
- package/src/showcase/demos/shadcn/tooltip.svelte +0 -13
- package/src/showcase/demos/shadcn/trace.svelte +0 -51
- package/src/showcase/heart.svg +0 -1
- package/src/showcase/index.ts +0 -38
- package/src/showcase/kit.test.ts +0 -72
- package/src/showcase/kit.ts +0 -16
- package/src/showcase/plus.svg +0 -1
- package/src/showcase/showcase-shell.css +0 -91
- package/src/showcase/stacked-section.css +0 -15
- package/src/showcase/success-check.svg +0 -1
- package/src/showcase/types.ts +0 -29
- package/src/svg-raw.d.ts +0 -7
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* Two sizes, picked by the job: `sm` reads a value back in a row or cell,
|
|
4
|
+
* `lg` is the ask.
|
|
5
|
+
*/
|
|
6
|
+
// A middle `md` step used to exist. Nobody could place it, so the same question
|
|
7
|
+
// ended up asked at three sizes.
|
|
8
|
+
export type StarRatingSize = "sm" | "lg";
|
|
9
|
+
|
|
10
|
+
const STAR_SIZE: Record<StarRatingSize, string> = {
|
|
11
|
+
sm: "size-4",
|
|
12
|
+
lg: "size-7",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const HIT_SIZE: Record<StarRatingSize, string> = {
|
|
16
|
+
sm: "p-0.5",
|
|
17
|
+
lg: "p-1",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Pop duration plus the longest stagger, after which the class can come off. */
|
|
21
|
+
const POP_WINDOW_MS = 350 + 4 * 40;
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script lang="ts">
|
|
25
|
+
import { Star } from "phosphor-svelte";
|
|
26
|
+
import { cn } from "@heyramzi/utils";
|
|
27
|
+
|
|
28
|
+
type Props = {
|
|
29
|
+
/** Current rating value (0 = unrated). */
|
|
30
|
+
value: number;
|
|
31
|
+
/** Maximum rating. Defaults to 5. */
|
|
32
|
+
max?: number;
|
|
33
|
+
/** Visual size of each star. `sm` reads back, `lg` asks. */
|
|
34
|
+
size?: StarRatingSize;
|
|
35
|
+
/** Render as static display, no hover or click. */
|
|
36
|
+
readonly?: boolean;
|
|
37
|
+
/** Disable interaction. */
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/** Form field name for native submission. When set, a hidden input is rendered. */
|
|
40
|
+
name?: string;
|
|
41
|
+
/** ARIA label for the radiogroup. */
|
|
42
|
+
ariaLabel?: string;
|
|
43
|
+
/** Per-step descriptive labels (length must equal max). */
|
|
44
|
+
labels?: readonly string[];
|
|
45
|
+
/** Show the label of the currently hovered/selected step beside the stars. */
|
|
46
|
+
showLabel?: boolean;
|
|
47
|
+
class?: string;
|
|
48
|
+
onchange?: (value: number) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let {
|
|
52
|
+
value = $bindable(0),
|
|
53
|
+
max = 5,
|
|
54
|
+
size = "sm",
|
|
55
|
+
readonly = false,
|
|
56
|
+
disabled = false,
|
|
57
|
+
name,
|
|
58
|
+
ariaLabel,
|
|
59
|
+
labels,
|
|
60
|
+
showLabel = false,
|
|
61
|
+
class: className,
|
|
62
|
+
onchange,
|
|
63
|
+
}: Props = $props();
|
|
64
|
+
|
|
65
|
+
let hover = $state(0);
|
|
66
|
+
/** Held for the length of the pop, then dropped so the next pick can replay it. */
|
|
67
|
+
let popping = $state(false);
|
|
68
|
+
let popTimer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
let displayed = $derived(hover || value);
|
|
70
|
+
let starSize = $derived(STAR_SIZE[size]);
|
|
71
|
+
let hitSize = $derived(HIT_SIZE[size]);
|
|
72
|
+
let groupAriaLabel = $derived(ariaLabel ?? `Rating, ${max} stars`);
|
|
73
|
+
let labelText = $derived(
|
|
74
|
+
displayed > 0 && labels && labels.length >= displayed ? labels[displayed - 1] : "",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
function buttonAriaLabel(step: number): string {
|
|
78
|
+
const stepLabel = labels?.[step - 1];
|
|
79
|
+
const base = `${step} of ${max}`;
|
|
80
|
+
return stepLabel ? `${base} (${stepLabel})` : base;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setRating(step: number) {
|
|
84
|
+
if (readonly || disabled) return;
|
|
85
|
+
value = step;
|
|
86
|
+
onchange?.(step);
|
|
87
|
+
|
|
88
|
+
// Drop the class before re-adding it, or a second pick inside the animation
|
|
89
|
+
// window finds it already set and nothing replays.
|
|
90
|
+
popping = false;
|
|
91
|
+
clearTimeout(popTimer);
|
|
92
|
+
requestAnimationFrame(() => {
|
|
93
|
+
popping = true;
|
|
94
|
+
popTimer = setTimeout(() => (popping = false), POP_WINDOW_MS);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function handleEnter(step: number) {
|
|
99
|
+
if (readonly || disabled) return;
|
|
100
|
+
hover = step;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function handleLeave() {
|
|
104
|
+
if (readonly || disabled) return;
|
|
105
|
+
hover = 0;
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<!-- One star, drawn once. It used to be written twice, once per branch, and the
|
|
110
|
+
two copies had already drifted apart on the glow. -->
|
|
111
|
+
{#snippet star(step: number)}
|
|
112
|
+
<Star
|
|
113
|
+
class={cn("star", starSize, step <= displayed && "is-filled")}
|
|
114
|
+
weight={step <= displayed ? "fill" : "regular"}
|
|
115
|
+
/>
|
|
116
|
+
{/snippet}
|
|
117
|
+
|
|
118
|
+
<div class={cn("rating", `rating-${size}`, className)}>
|
|
119
|
+
<div
|
|
120
|
+
class={cn("stars", popping && "is-popping")}
|
|
121
|
+
role={readonly ? "img" : "radiogroup"}
|
|
122
|
+
aria-label={groupAriaLabel}
|
|
123
|
+
onmouseleave={handleLeave}
|
|
124
|
+
>
|
|
125
|
+
{#each Array(max) as _, i (i)}
|
|
126
|
+
{@const step = i + 1}
|
|
127
|
+
{#if readonly}
|
|
128
|
+
<span class={cn("hit", hitSize)}>{@render star(step)}</span>
|
|
129
|
+
{:else}
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
role="radio"
|
|
133
|
+
aria-checked={value === step}
|
|
134
|
+
aria-label={buttonAriaLabel(step)}
|
|
135
|
+
{disabled}
|
|
136
|
+
data-on={step <= value}
|
|
137
|
+
onclick={() => setRating(step)}
|
|
138
|
+
onmouseenter={() => handleEnter(step)}
|
|
139
|
+
onfocus={() => handleEnter(step)}
|
|
140
|
+
onblur={handleLeave}
|
|
141
|
+
style:--pop-delay="{i * 40}ms"
|
|
142
|
+
class={cn("hit hit-button", hitSize, disabled && "is-disabled")}
|
|
143
|
+
>
|
|
144
|
+
{@render star(step)}
|
|
145
|
+
</button>
|
|
146
|
+
{/if}
|
|
147
|
+
{/each}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{#if showLabel && labelText}
|
|
151
|
+
<span class="rating-caption">{labelText}</span>
|
|
152
|
+
{/if}
|
|
153
|
+
|
|
154
|
+
{#if name}
|
|
155
|
+
<input type="hidden" {name} value={value} />
|
|
156
|
+
{/if}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<style>
|
|
160
|
+
.rating {
|
|
161
|
+
--like-pop: var(--duration-medium);
|
|
162
|
+
--like-pop-ease: var(--ease-smooth-out);
|
|
163
|
+
--like-fill: var(--duration-quick);
|
|
164
|
+
--like-ease: var(--ease-smooth-out);
|
|
165
|
+
|
|
166
|
+
display: flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
gap: 0.25rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.stars {
|
|
172
|
+
display: flex;
|
|
173
|
+
align-items: center;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.hit {
|
|
177
|
+
display: inline-flex;
|
|
178
|
+
border-radius: var(--radius-sm);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.hit-button {
|
|
182
|
+
transition: transform var(--duration-quick, 150ms) var(--ease-smooth-out, ease);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.hit-button:focus-visible {
|
|
186
|
+
outline: none;
|
|
187
|
+
box-shadow: 0 0 0 2px var(--color-ring);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.hit-button:not(.is-disabled):hover {
|
|
191
|
+
transform: scale(1.1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.hit-button:not(.is-disabled):active {
|
|
195
|
+
transform: scale(0.95);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.is-disabled {
|
|
199
|
+
cursor: not-allowed;
|
|
200
|
+
opacity: 0.6;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.rating :global(.star) {
|
|
204
|
+
color: var(--color-border);
|
|
205
|
+
transition: color var(--like-fill) var(--like-ease);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.rating :global(.star.is-filled) {
|
|
209
|
+
color: var(--color-warning-400);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* The glow is the size's business, not a prop: it belongs to the ask, where
|
|
213
|
+
the stars are large enough to carry it, and it turned to mud at 16px. */
|
|
214
|
+
.rating-lg :global(.star.is-filled) {
|
|
215
|
+
filter: drop-shadow(0 0 4px color-mix(in oklab, var(--color-warning-400) 45%, transparent));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* transitions.dev like-button pop. Every selected star pops, left to right,
|
|
219
|
+
as one gesture. A class on the group drives it, since rebuilding the row
|
|
220
|
+
would drop keyboard focus. */
|
|
221
|
+
.is-popping .hit-button[data-on="true"] {
|
|
222
|
+
animation: t-like-pop var(--like-pop) var(--like-pop-ease) var(--pop-delay, 0ms);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@keyframes t-like-pop {
|
|
226
|
+
0% {
|
|
227
|
+
transform: scale(1);
|
|
228
|
+
}
|
|
229
|
+
30% {
|
|
230
|
+
transform: scale(0.82);
|
|
231
|
+
}
|
|
232
|
+
100% {
|
|
233
|
+
transform: scale(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.rating-caption {
|
|
238
|
+
font-size: 0.75rem;
|
|
239
|
+
font-weight: 500;
|
|
240
|
+
font-variant-numeric: tabular-nums;
|
|
241
|
+
color: var(--color-muted-foreground);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@media (prefers-reduced-motion: reduce) {
|
|
245
|
+
.hit-button,
|
|
246
|
+
.is-popping .hit-button[data-on="true"] {
|
|
247
|
+
animation: none;
|
|
248
|
+
transition: none;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.hit-button:not(.is-disabled):hover,
|
|
252
|
+
.hit-button:not(.is-disabled):active {
|
|
253
|
+
transform: none;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Svg } from "./svg.svelte";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Renders a drawing kept in its own .svg file, imported with `?raw`.
|
|
3
|
+
// The markup is ours and static, so rendering it as HTML is safe.
|
|
4
|
+
let { src, class: className = "" }: { src: string; class?: string } = $props();
|
|
5
|
+
|
|
6
|
+
const markup = $derived(className ? src.replace("<svg", `<svg class="${className}"`) : src);
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
{@html markup}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
// STATE
|
|
39
39
|
const uid = `bb-${Math.random().toString(36).slice(2, 10)}`;
|
|
40
|
-
let container = null;
|
|
40
|
+
let container = $state<HTMLElement | null>(null);
|
|
41
41
|
let systemTheme = $state<ResolvedTheme>("dark");
|
|
42
42
|
let isActive = $state(false);
|
|
43
43
|
let isFading = $state(false);
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Brand hooks, all optional, set on the element's class:
|
|
3
|
+
* --hr-compare-handle-color, --hr-compare-handle-width, --hr-compare-handle-shadow,
|
|
4
|
+
* --hr-compare-grip-bg, --hr-compare-grip-color, --hr-compare-grip-size, --hr-compare-grip-shadow,
|
|
5
|
+
* --hr-compare-badge-inset, --hr-compare-hint-duration.
|
|
6
|
+
*/
|
|
1
7
|
.hr-compare {
|
|
2
8
|
position: relative;
|
|
3
9
|
overflow: hidden;
|
|
4
10
|
user-select: none;
|
|
5
11
|
isolation: isolate;
|
|
6
12
|
}
|
|
13
|
+
.hr-compare picture {
|
|
14
|
+
display: contents;
|
|
15
|
+
}
|
|
7
16
|
.hr-compare__img {
|
|
8
17
|
display: block;
|
|
9
18
|
width: 100%;
|
|
@@ -15,14 +24,32 @@
|
|
|
15
24
|
inset: 0;
|
|
16
25
|
clip-path: inset(0 0 0 calc(var(--hr-compare-pos) * 1%));
|
|
17
26
|
}
|
|
27
|
+
.hr-compare__badge {
|
|
28
|
+
position: absolute;
|
|
29
|
+
z-index: 1;
|
|
30
|
+
pointer-events: none;
|
|
31
|
+
}
|
|
32
|
+
.hr-compare__badge--before {
|
|
33
|
+
inset-inline-start: var(--hr-compare-badge-inset, 0.75rem);
|
|
34
|
+
}
|
|
35
|
+
.hr-compare__badge--after {
|
|
36
|
+
inset-inline-end: var(--hr-compare-badge-inset, 0.75rem);
|
|
37
|
+
}
|
|
38
|
+
.hr-compare[data-badges="top"] .hr-compare__badge {
|
|
39
|
+
inset-block-start: var(--hr-compare-badge-inset, 0.75rem);
|
|
40
|
+
}
|
|
41
|
+
.hr-compare[data-badges="bottom"] .hr-compare__badge {
|
|
42
|
+
inset-block-end: var(--hr-compare-badge-inset, 0.75rem);
|
|
43
|
+
}
|
|
18
44
|
.hr-compare__handle {
|
|
19
45
|
position: absolute;
|
|
46
|
+
z-index: 2;
|
|
20
47
|
inset-block: 0;
|
|
21
48
|
left: calc(var(--hr-compare-pos) * 1%);
|
|
22
|
-
width: 2px;
|
|
23
|
-
translate: -
|
|
49
|
+
width: var(--hr-compare-handle-width, 2px);
|
|
50
|
+
translate: calc(var(--hr-compare-handle-width, 2px) / -2) 0;
|
|
24
51
|
background: var(--hr-compare-handle-color, white);
|
|
25
|
-
box-shadow: var(--elev-2, 0 2px 8px rgb(0 0 0 / 0.25));
|
|
52
|
+
box-shadow: var(--hr-compare-handle-shadow, var(--elev-2, 0 2px 8px rgb(0 0 0 / 0.25)));
|
|
26
53
|
pointer-events: none;
|
|
27
54
|
}
|
|
28
55
|
.hr-compare__grip {
|
|
@@ -32,18 +59,18 @@
|
|
|
32
59
|
translate: -50% -50%;
|
|
33
60
|
display: grid;
|
|
34
61
|
place-items: center;
|
|
35
|
-
width: 2rem;
|
|
36
|
-
height: 2rem;
|
|
62
|
+
width: var(--hr-compare-grip-size, 2rem);
|
|
63
|
+
height: var(--hr-compare-grip-size, 2rem);
|
|
37
64
|
border-radius: 999px;
|
|
38
|
-
background:
|
|
65
|
+
background: var(--hr-compare-grip-bg, var(--hr-compare-handle-color, white));
|
|
39
66
|
color: var(--hr-compare-grip-color, black);
|
|
40
|
-
|
|
41
|
-
line-height: 1;
|
|
67
|
+
box-shadow: var(--hr-compare-grip-shadow, none);
|
|
42
68
|
}
|
|
43
69
|
/* The range covers the frame so pointer drag, touch and arrow keys all work. */
|
|
44
70
|
.hr-compare__range {
|
|
45
71
|
position: absolute;
|
|
46
72
|
inset: 0;
|
|
73
|
+
z-index: 3;
|
|
47
74
|
width: 100%;
|
|
48
75
|
height: 100%;
|
|
49
76
|
margin: 0;
|
|
@@ -51,6 +78,8 @@
|
|
|
51
78
|
cursor: ew-resize;
|
|
52
79
|
appearance: none;
|
|
53
80
|
background: transparent;
|
|
81
|
+
/* A vertical swipe over the frame still scrolls the page. */
|
|
82
|
+
touch-action: pan-y pinch-zoom;
|
|
54
83
|
}
|
|
55
84
|
.hr-compare__range:focus-visible ~ .hr-compare__handle {
|
|
56
85
|
outline: 2px solid var(--hr-compare-handle-color, white);
|
|
@@ -63,4 +92,9 @@
|
|
|
63
92
|
clip-path var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out),
|
|
64
93
|
left var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
65
94
|
}
|
|
95
|
+
/* A slower glide while the handle hints, so the sweep reads as a gesture. */
|
|
96
|
+
.hr-compare[data-hinting] .hr-compare__img--after,
|
|
97
|
+
.hr-compare[data-hinting] .hr-compare__handle {
|
|
98
|
+
transition-duration: var(--hr-compare-hint-duration, 800ms);
|
|
99
|
+
}
|
|
66
100
|
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
/** One side of the comparison. `sources` turns the image into a `<picture>`. */
|
|
5
|
+
export interface ComparisonImage {
|
|
6
|
+
src: string;
|
|
7
|
+
alt: string;
|
|
8
|
+
/** Modern formats tried before `src`, such as an AVIF with the PNG as the fallback. */
|
|
9
|
+
sources?: readonly { srcset: string; type: string }[];
|
|
10
|
+
/** Intrinsic size, so the browser reserves the frame before the image lands. */
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
1
16
|
<script lang="ts">
|
|
2
17
|
// =========================
|
|
3
18
|
// IMPORTS
|
|
@@ -5,21 +20,35 @@
|
|
|
5
20
|
import "./ImageComparison.css";
|
|
6
21
|
import { cn } from "@heyramzi/utils";
|
|
7
22
|
import { untrack } from "svelte";
|
|
23
|
+
import { positionAt } from "./compare.js";
|
|
8
24
|
|
|
9
25
|
// =========================
|
|
10
26
|
// TYPES
|
|
11
27
|
// =========================
|
|
12
28
|
interface ImageComparisonProps {
|
|
13
29
|
/** Image shown on the left of the handle. */
|
|
14
|
-
before:
|
|
30
|
+
before: ComparisonImage;
|
|
15
31
|
/** Image shown on the right of the handle. */
|
|
16
|
-
after:
|
|
17
|
-
/** Handle position
|
|
18
|
-
|
|
19
|
-
/** Track the pointer without a press. */
|
|
32
|
+
after: ComparisonImage;
|
|
33
|
+
/** Handle position, 0-100. Bind it to read it or to move the handle from outside. */
|
|
34
|
+
position?: number;
|
|
35
|
+
/** Track the pointer without a press. The handle returns to where it began. */
|
|
20
36
|
hover?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Positions the handle visits once on mount, then it returns to where it began.
|
|
39
|
+
* It stops for good at the first touch. Tells a visitor the frame moves.
|
|
40
|
+
*/
|
|
41
|
+
hint?: readonly number[];
|
|
42
|
+
/** Load both images now, ahead of the rest of the page. For a frame above the fold. */
|
|
43
|
+
eager?: boolean;
|
|
21
44
|
/** Label announced for the slider. */
|
|
22
45
|
label?: string;
|
|
46
|
+
/** A label drawn over the left side, such as "Before". */
|
|
47
|
+
beforeBadge?: Snippet;
|
|
48
|
+
/** A label drawn over the right side, such as "After". */
|
|
49
|
+
afterBadge?: Snippet;
|
|
50
|
+
/** Which edge the badges sit on. */
|
|
51
|
+
badgeEdge?: "top" | "bottom";
|
|
23
52
|
class?: string;
|
|
24
53
|
}
|
|
25
54
|
|
|
@@ -29,64 +58,152 @@
|
|
|
29
58
|
let {
|
|
30
59
|
before,
|
|
31
60
|
after,
|
|
32
|
-
|
|
61
|
+
position = $bindable(50),
|
|
33
62
|
hover = false,
|
|
63
|
+
hint,
|
|
64
|
+
eager = false,
|
|
34
65
|
label = "Compare before and after",
|
|
66
|
+
beforeBadge,
|
|
67
|
+
afterBadge,
|
|
68
|
+
badgeEdge = "bottom",
|
|
35
69
|
class: className,
|
|
36
70
|
}: ImageComparisonProps = $props();
|
|
37
71
|
|
|
38
72
|
// =========================
|
|
39
73
|
// STATE
|
|
40
74
|
// =========================
|
|
41
|
-
// WHY:
|
|
42
|
-
|
|
43
|
-
let position = $state(untrack(() => start));
|
|
75
|
+
// WHY: the handle's first position is where `hover` and `hint` bring it back to.
|
|
76
|
+
const rest = untrack(() => position);
|
|
44
77
|
let dragging = $state(false);
|
|
78
|
+
let hinting = $state(false);
|
|
79
|
+
let touched = $state(false);
|
|
80
|
+
|
|
81
|
+
// =========================
|
|
82
|
+
// EFFECTS
|
|
83
|
+
// =========================
|
|
84
|
+
const HINT_DELAY = 800;
|
|
85
|
+
const HINT_STEP = 900;
|
|
86
|
+
|
|
87
|
+
$effect(() => {
|
|
88
|
+
const steps = hint;
|
|
89
|
+
if (touched || !steps?.length) return;
|
|
90
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
91
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
92
|
+
hinting = true;
|
|
93
|
+
[...steps, rest].forEach((stop, index) => {
|
|
94
|
+
timers.push(
|
|
95
|
+
setTimeout(
|
|
96
|
+
() => {
|
|
97
|
+
position = stop;
|
|
98
|
+
},
|
|
99
|
+
HINT_DELAY + index * HINT_STEP,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
timers.push(
|
|
104
|
+
setTimeout(
|
|
105
|
+
() => {
|
|
106
|
+
hinting = false;
|
|
107
|
+
},
|
|
108
|
+
HINT_DELAY + steps.length * HINT_STEP + HINT_STEP / 2,
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
return () => {
|
|
112
|
+
timers.forEach(clearTimeout);
|
|
113
|
+
hinting = false;
|
|
114
|
+
};
|
|
115
|
+
});
|
|
45
116
|
|
|
46
117
|
// =========================
|
|
47
118
|
// FUNCTIONS
|
|
48
119
|
// =========================
|
|
120
|
+
// WHY: the effect reads `touched`, so setting it runs the effect's cleanup and cancels the timers.
|
|
121
|
+
function touch() {
|
|
122
|
+
touched = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
49
125
|
function trackPointer(event: PointerEvent) {
|
|
50
126
|
if (!hover && !dragging) return;
|
|
51
127
|
const rect =
|
|
52
128
|
event.currentTarget instanceof HTMLElement && event.currentTarget.getBoundingClientRect();
|
|
53
129
|
if (!rect) return;
|
|
54
|
-
position =
|
|
130
|
+
position = positionAt(event.clientX, rect.left, rect.width);
|
|
55
131
|
}
|
|
56
132
|
</script>
|
|
57
133
|
|
|
58
134
|
<!-- =========================== -->
|
|
59
135
|
<!-- MARKUP -->
|
|
60
136
|
<!-- =========================== -->
|
|
137
|
+
{#snippet picture(side: ComparisonImage, cls: string)}
|
|
138
|
+
{#if side.sources?.length}
|
|
139
|
+
<picture>
|
|
140
|
+
{#each side.sources as source (source.type)}
|
|
141
|
+
<source srcset={source.srcset} type={source.type} />
|
|
142
|
+
{/each}
|
|
143
|
+
{@render img(side, cls)}
|
|
144
|
+
</picture>
|
|
145
|
+
{:else}
|
|
146
|
+
{@render img(side, cls)}
|
|
147
|
+
{/if}
|
|
148
|
+
{/snippet}
|
|
149
|
+
|
|
150
|
+
{#snippet img(side: ComparisonImage, cls: string)}
|
|
151
|
+
<img
|
|
152
|
+
class={cls}
|
|
153
|
+
src={side.src}
|
|
154
|
+
alt={side.alt}
|
|
155
|
+
width={side.width}
|
|
156
|
+
height={side.height}
|
|
157
|
+
draggable="false"
|
|
158
|
+
loading={eager ? "eager" : undefined}
|
|
159
|
+
fetchpriority={eager ? "high" : undefined}
|
|
160
|
+
/>
|
|
161
|
+
{/snippet}
|
|
162
|
+
|
|
61
163
|
<div
|
|
62
164
|
role="group"
|
|
63
165
|
aria-label={label}
|
|
64
166
|
class={cn("hr-compare", className)}
|
|
65
167
|
style="--hr-compare-pos:{position}"
|
|
66
168
|
data-eased={dragging ? undefined : ""}
|
|
169
|
+
data-hinting={hinting ? "" : undefined}
|
|
170
|
+
data-badges={badgeEdge}
|
|
67
171
|
onpointermove={trackPointer}
|
|
68
|
-
onpointerdown={() =>
|
|
172
|
+
onpointerdown={() => {
|
|
173
|
+
dragging = true;
|
|
174
|
+
touch();
|
|
175
|
+
}}
|
|
69
176
|
onpointerup={() => (dragging = false)}
|
|
177
|
+
onpointercancel={() => (dragging = false)}
|
|
70
178
|
onpointerleave={() => {
|
|
71
179
|
dragging = false;
|
|
72
|
-
if (hover) position =
|
|
180
|
+
if (hover) position = rest;
|
|
73
181
|
}}
|
|
182
|
+
onkeydown={touch}
|
|
74
183
|
>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
184
|
+
{@render picture(before, "hr-compare__img")}
|
|
185
|
+
{@render picture(after, "hr-compare__img hr-compare__img--after")}
|
|
186
|
+
{#if beforeBadge}
|
|
187
|
+
<div class="hr-compare__badge hr-compare__badge--before">{@render beforeBadge()}</div>
|
|
188
|
+
{/if}
|
|
189
|
+
{#if afterBadge}
|
|
190
|
+
<div class="hr-compare__badge hr-compare__badge--after">{@render afterBadge()}</div>
|
|
191
|
+
{/if}
|
|
82
192
|
<input
|
|
83
193
|
class="hr-compare__range"
|
|
84
194
|
type="range"
|
|
85
195
|
min="0"
|
|
86
196
|
max="100"
|
|
87
|
-
step="
|
|
197
|
+
step="1"
|
|
88
198
|
aria-label={label}
|
|
89
199
|
bind:value={position}
|
|
90
200
|
/>
|
|
91
|
-
<div class="hr-compare__handle"
|
|
201
|
+
<div class="hr-compare__handle">
|
|
202
|
+
<span class="hr-compare__grip">
|
|
203
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
204
|
+
<polyline points="10 6 4 12 10 18"></polyline>
|
|
205
|
+
<polyline points="14 6 20 12 14 18"></polyline>
|
|
206
|
+
</svg>
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
92
209
|
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { positionAt } from "./compare.js";
|
|
3
|
+
|
|
4
|
+
describe("positionAt", () => {
|
|
5
|
+
it("maps the pointer to a percentage of the frame", () => {
|
|
6
|
+
expect(positionAt(150, 100, 200)).toBe(25);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("clamps a pointer outside the frame", () => {
|
|
10
|
+
expect(positionAt(50, 100, 200)).toBe(0);
|
|
11
|
+
expect(positionAt(400, 100, 200)).toBe(100);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rests in the middle of a frame with no width", () => {
|
|
15
|
+
expect(positionAt(10, 0, 0)).toBe(50);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Where the handle sits for a pointer at `clientX`, as a 0-100 percentage of the frame. */
|
|
2
|
+
export function positionAt(clientX: number, left: number, width: number): number {
|
|
3
|
+
if (width <= 0) return 50;
|
|
4
|
+
return Math.min(Math.max(((clientX - left) / width) * 100, 0), 100);
|
|
5
|
+
}
|