Files
sui/src/lib/Button.svelte

176 lines
3.8 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue, HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements';
import Spinner from './Spinner.svelte';
import { defaultIconProps, type IconDef } from './util';
interface Props extends Omit<HTMLButtonAttributes, 'class'> {
icon?: IconDef;
animate?: boolean;
loading?: boolean;
class?: ClassValue | null | undefined;
ref?: HTMLButtonElement | null;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
}
let {
type = 'button',
icon,
animate = true,
loading,
disabled,
class: classValue,
ref = $bindable<HTMLButtonElement | null>(null),
children,
onclick,
...others
}: Props = $props();
type SVGInHTML = HTMLElement & SVGElement;
let iconElement = $state<SVGInHTML | null>(null);
$effect(() => {
if (icon && ref) {
iconElement = ref.querySelector('svg') as SVGInHTML | null;
}
});
const handleButtonClick: MouseEventHandler<HTMLButtonElement> = (event) => {
if (animate && !loading && !disabled) {
animateLoop();
animateRipple(event);
}
if (loading || disabled) return;
onclick?.(event);
};
const triggerAnimation = (className: string) => {
if (icon && iconElement) {
iconElement.classList.remove(className);
// make sure we have DOM reflow
requestAnimationFrame(() => {
iconElement?.classList.add(className);
});
}
};
export const animateLoop = () => triggerAnimation('animate');
export const animateBounce = () => triggerAnimation('bounce');
export const animateRipple: MouseEventHandler<HTMLButtonElement> = (event) => {
const button = event.currentTarget;
const circle = document.createElement('span');
const diameter = Math.max(button.clientWidth, button.clientHeight);
const radius = diameter / 2;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left - radius;
const y = event.clientY - rect.top - radius;
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${x}px`;
circle.style.top = `${y}px`;
circle.classList.add('ripple');
const ripples = button.getElementsByClassName('ripple');
for (let i = 0; i < ripples.length; i++) {
ripples[i].remove();
}
button.appendChild(circle);
};
</script>
<button
{type}
bind:this={ref}
class={[
'button group relative flex items-center gap-3 overflow-hidden rounded-sm px-5',
'text-sui-background py-3 font-medium transition-colors',
!loading && !disabled
? ' bg-sui-primary hover:bg-sui-secondary cursor-pointer'
: 'cursor-not-allowed opacity-55',
classValue
]}
onclick={handleButtonClick}
{...others}
>
{@render children()}
{#if icon && !loading}
<icon.component {...icon.props || defaultIconProps} />
{/if}
{#if loading}
<Spinner class="-mr-1 ml-0.5" size="1.3rem" />
{/if}
</button>
<style>
@keyframes loop {
0% {
transform: translateX(0);
opacity: 100%;
}
40% {
opacity: 100%;
}
50% {
transform: translateX(3rem);
opacity: 0%;
}
51% {
transform: translateX(-2rem);
opacity: 0%;
}
100% {
transform: translateX(0);
}
}
@keyframes bounce {
0% {
transform: translateX(0);
}
25% {
transform: translateX(-0.25rem);
}
75% {
transform: translateX(0.25rem);
}
100% {
transform: translateX(0);
}
}
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
:global(button svg.animate) {
animation-name: loop;
animation-duration: 0.5s;
}
:global(button svg.bounce) {
animation-name: bounce;
animation-duration: 180ms;
animation-timing-function: ease-in-out;
animation-iteration-count: 3;
}
:global(span.ripple) {
position: absolute;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
animation: ripple 0.5s linear;
transform: scale(0);
pointer-events: none;
}
</style>