import type { Handle } from '@remix-run/ui' import { css, on, ref } from '@remix-run/ui' import { animateEntrance, animateExit } from '@remix-run/ui/animation' import { spring } from '@remix-run/ui/animation' const STATES = { idle: 'Start', processing: 'Processing', success: 'Done', error: 'Something went wrong', } as const type State = keyof typeof STATES function getNextState(state: State): State { let states = Object.keys(STATES) as State[] let nextIndex = (states.indexOf(state) + 1) % states.length return states[nextIndex] } const ICON_SIZE = 20 const STROKE_WIDTH = 1.5 const VIEW_BOX_SIZE = 24 const iconEnterAnimation = { transform: 'translateY(-40px) scale(0.5)', filter: 'blur(6px)', duration: 150, easing: 'ease-out', } const iconExitAnimation = { transform: 'translateY(40px) scale(0.5)', filter: 'blur(6px)', duration: 150, easing: 'ease-in', } export function MultiStateBadge(handle: Handle) { let state: State = 'idle' return () => (
) } function Badge(handle: Handle<{ state: State }>) { let badgeEl: HTMLDivElement let prevState: State | null = null return () => { let { state } = handle.props // Trigger shake/scale animations on state change if (prevState !== null && prevState !== state) { handle.queueTask(() => { if (state === 'error') { badgeEl.animate( { transform: [ 'translateX(0)', 'translateX(-6px)', 'translateX(6px)', 'translateX(-6px)', 'translateX(0)', ], }, { duration: 300, easing: 'ease-in-out', delay: 100 }, ) } else if (state === 'success') { badgeEl.animate( { transform: ['scale(1)', 'scale(1.2)', 'scale(1)'] }, { duration: 300, easing: 'ease-in-out' }, ) } }) } prevState = state return (
(badgeEl = node)), css({ backgroundColor: '#e2e8f0', color: '#0f1115', display: 'flex', overflow: 'hidden', alignItems: 'center', justifyContent: 'center', padding: '12px 20px', fontSize: 16, borderRadius: 999, willChange: 'transform, filter', transition: `gap ${spring('snappy')}`, }), ]} style={{ gap: state === 'idle' ? '0px' : '8px' }} >
) } } function Icon(handle: Handle<{ state: State }>) { return () => ( {handle.props.state === 'processing' && ( )} {handle.props.state === 'success' && ( )} {handle.props.state === 'error' && ( )} ) } function Loader() { return () => (
{ node.animate( { transform: ['rotate(0deg)', 'rotate(360deg)'] }, { duration: 1000, iterations: Infinity }, ) }), css({ display: 'flex', alignItems: 'center', justifyContent: 'center', width: ICON_SIZE, height: ICON_SIZE, }), ]} >
) } function Check() { return () => ( { let length = node.getTotalLength() node.style.strokeDasharray = `${length}` node.style.strokeDashoffset = `${length}` node.animate( { strokeDashoffset: [length, 0] }, { ...spring({ duration: 300, bounce: 0.1 }), fill: 'forwards' }, ) }), ]} /> ) } function X() { return () => ( { let length = node.getTotalLength() node.style.strokeDasharray = `${length}` node.style.strokeDashoffset = `${length}` node.animate( { strokeDashoffset: [length, 0] }, { ...spring({ duration: 300, bounce: 0.1 }), fill: 'forwards' }, ) }), ]} /> { let length = node.getTotalLength() node.style.strokeDasharray = `${length}` node.style.strokeDashoffset = `${length}` node.animate( { strokeDashoffset: [length, 0] }, { ...spring({ duration: 300, bounce: 0.1 }), delay: 100, fill: 'forwards' }, ) }), ]} /> ) } function Label(handle: Handle<{ state: State }>) { let measureEl: HTMLSpanElement let labelWidth = 0 let labelHeight = 0 // Don't animate the label on initial render let isFirstRender = true handle.queueTask(() => { isFirstRender = false }) return () => { let { state } = handle.props // Measure label dimensions after render handle.queueTask(() => { if (measureEl) { let rect = measureEl.getBoundingClientRect() if (rect.width !== labelWidth || rect.height !== labelHeight) { labelWidth = rect.width labelHeight = rect.height handle.update() } } }) let labelMix = [ animateExit({ transform: 'translateY(20px)', opacity: 0, filter: 'blur(10px)', duration: 200, easing: 'ease-in-out', }), ] if (!isFirstRender) { labelMix.unshift( animateEntrance({ transform: 'translateY(-20px)', opacity: 0, filter: 'blur(10px)', duration: 200, easing: 'ease-in-out', }), ) } return ( {/* Hidden measurement element */} (measureEl = node)), css({ position: 'absolute', visibility: 'hidden', whiteSpace: 'nowrap' }), ]} > {STATES[state]} {state === 'idle' && ( {STATES.idle} )} {state === 'processing' && ( {STATES.processing} )} {state === 'success' && ( {STATES.success} )} {state === 'error' && ( {STATES.error} )} ) } }