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 () => (
)
}
function X() {
return () => (
)
}
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}
)}
)
}
}