feat: ReactBits micro components integration
Installed 6 ReactBits micro components (src/components/): - SwipeRow: swipe-to-action cards (lead approve/reject/snooze) - CometDial: circular dial selector (future: dashboard KPI selector) - CodeSlots: code input slots (future: API token confirmation) - FuseButton: fuse-animated CTA with undo window - SlideCommit: slide-to-confirm (timesheet week lock) - HoldButton: hold-to-confirm (contract sign, dangerous actions) Integrated into views: - Timesheet: SlideCommit replaces 'Woche sperren' button - Contracts: HoldButton replaces 'E-Signatur anfordern' (hold 1.5s to sign) - Recurring: FuseButton replaces 'Jetzt ausführen' (with undo window) - Leads: SwipeableLeadCard created (swipe to approve/reject/snooze) Created wrapper components: - swipeable-lead-card.tsx: SwipeRow wrapper for lead cards - hold-to-confirm.tsx: HoldButton wrapper with loading state - fuse-cta.tsx: FuseButton wrapper with toast feedback - slide-to-lock.tsx: SlideCommit wrapper for GoBD week lock 0 TS errors, ESLint clean, no console errors.
This commit is contained in:
1 parent
21e112e5cb
commit
37e976a732
17 files changed
+2884
-25
No files matched your search
@@ -0,0 +1,358 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
export type HoldButtonSize = 'sm' | 'md' | 'lg';
|
||||
export type HoldButtonDirection = 'right' | 'up';
|
||||
|
||||
export interface HoldButtonProps {
|
||||
children?: ReactNode;
|
||||
doneLabel?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
doneIcon?: ReactNode;
|
||||
backgroundColor?: string;
|
||||
fillColor?: string;
|
||||
textColor?: string;
|
||||
fillTextColor?: string;
|
||||
size?: HoldButtonSize;
|
||||
radius?: number;
|
||||
fillDirection?: HoldButtonDirection;
|
||||
holdTime?: number;
|
||||
releaseTime?: number;
|
||||
pressScale?: number;
|
||||
wave?: boolean;
|
||||
waveAmplitude?: number;
|
||||
glow?: boolean;
|
||||
resetAfter?: number;
|
||||
disabled?: boolean;
|
||||
onHold?: () => void;
|
||||
onTap?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Phase = 'idle' | 'holding' | 'done';
|
||||
type Input = 'pointer' | 'key' | null;
|
||||
|
||||
interface Motion {
|
||||
raf: number;
|
||||
p: number;
|
||||
from: number;
|
||||
to: number;
|
||||
start: number;
|
||||
}
|
||||
|
||||
interface Gesture {
|
||||
pointerId: number | null;
|
||||
start: number;
|
||||
rect: DOMRect | null;
|
||||
}
|
||||
|
||||
interface ReleaseOptions {
|
||||
drifted?: boolean;
|
||||
}
|
||||
|
||||
const TAP_MS = 250;
|
||||
const HIT_PAD = 10;
|
||||
const LINEAR = (t: number) => t;
|
||||
const EASE_OUT = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
const SIZES: Record<HoldButtonSize, string> = {
|
||||
sm: 'h-9 px-4 text-[13px]',
|
||||
md: 'h-11 px-[22px] text-[15px]',
|
||||
lg: 'h-[52px] px-7 text-[17px]'
|
||||
};
|
||||
|
||||
const GLOW = 'inset_0_1px_0_rgba(255,255,255,0.06),0_10px_32px_-6px_color-mix(in_srgb,var(--hb-fill)_70%,transparent)';
|
||||
|
||||
const LABEL_SPAN =
|
||||
'[grid-area:1/1] inline-flex items-center gap-2 whitespace-nowrap [transition:opacity_200ms_ease,filter_200ms_ease]';
|
||||
|
||||
const STYLE = `
|
||||
.hb-root{--hb-w:0px;--hb-h:0px;--hb-cycles:2;--hb-p:0}
|
||||
.hb-fill{clip-path:inset(0 calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0)}
|
||||
.hb-root[data-direction=up] .hb-fill{clip-path:inset(calc((1 - var(--hb-p)) * (100% + 0.75 * var(--hb-wave)) - var(--hb-p) * 0.25 * var(--hb-wave)) 0 0 0)}
|
||||
.hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='200' viewBox='0 0 20 200' preserveAspectRatio='none'%3E%3Cpath d='M0 0H10C18 8 18 25.3 10 33.3S2 58.7 10 66.7S18 92 10 100S2 125.3 10 133.3S18 158.7 10 166.7S2 192 10 200H0Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-y;mask-repeat:repeat-y;-webkit-mask-size:var(--hb-wave) calc(var(--hb-h) * 2);mask-size:var(--hb-wave) calc(var(--hb-h) * 2);-webkit-mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));mask-position-x:calc(-1 * var(--hb-wave) + var(--hb-p) * (var(--hb-w) + var(--hb-wave)));-webkit-mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h));mask-position-y:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-h))}
|
||||
.hb-root[data-direction=up] .hb-crest{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='20' viewBox='0 0 200 20' preserveAspectRatio='none'%3E%3Cpath d='M0 20V10C12 2 38 2 50 10S88 18 100 10S138 2 150 10S188 18 200 10V20Z'/%3E%3C/svg%3E");-webkit-mask-repeat:repeat-x;mask-repeat:repeat-x;-webkit-mask-size:calc(var(--hb-w) * 2) var(--hb-wave);mask-size:calc(var(--hb-w) * 2) var(--hb-wave);-webkit-mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));mask-position-x:calc(-1 * var(--hb-p) * var(--hb-cycles) * var(--hb-w));-webkit-mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)));mask-position-y:calc(var(--hb-h) - var(--hb-p) * (var(--hb-h) + var(--hb-wave)))}
|
||||
@keyframes hb-pulse{from{opacity:1;box-shadow:0 0 0 0 color-mix(in srgb,var(--hb-fill) 55%,transparent)}to{opacity:0;box-shadow:0 0 0 14px color-mix(in srgb,var(--hb-fill) 0%,transparent)}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.hb-root{transform:none!important;transition:background-color 160ms ease,box-shadow var(--hb-release) ease!important}
|
||||
.hb-fill{clip-path:inset(0)!important;opacity:0;transition:opacity var(--hb-release) ease!important}
|
||||
.hb-crest{display:none}
|
||||
.hb-root[data-phase=holding] .hb-fill,.hb-root[data-phase=done] .hb-fill{opacity:1;transition:opacity var(--hb-hold) linear!important}
|
||||
.hb-pulse{animation:none!important}
|
||||
.hb-label>span{filter:none!important;transition:opacity 200ms ease!important}
|
||||
}`;
|
||||
|
||||
const HoldButton: React.FC<HoldButtonProps> = ({
|
||||
children = 'Hold to delete',
|
||||
doneLabel = 'Deleted',
|
||||
icon = null,
|
||||
doneIcon = null,
|
||||
backgroundColor = '#27272a',
|
||||
fillColor = '#5227FF',
|
||||
textColor = '#f5f5f5',
|
||||
fillTextColor = '#ffffff',
|
||||
size = 'md',
|
||||
radius = 14,
|
||||
fillDirection = 'right',
|
||||
holdTime = 2000,
|
||||
releaseTime = 200,
|
||||
pressScale = 0.97,
|
||||
wave = true,
|
||||
waveAmplitude = 6,
|
||||
glow = true,
|
||||
resetAfter = 1200,
|
||||
disabled = false,
|
||||
onHold,
|
||||
onTap,
|
||||
className = ''
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
const [input, setInput] = useState<Input>(null);
|
||||
const phaseRef = useRef<Phase>('idle');
|
||||
const inputRef = useRef<Input>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const gesture = useRef<Gesture>({ pointerId: null, start: 0, rect: null });
|
||||
const timers = useRef({ complete: 0, reset: 0 });
|
||||
const hintId = useId();
|
||||
|
||||
const go = (next: Phase, kind: Input = null) => {
|
||||
phaseRef.current = next;
|
||||
inputRef.current = kind;
|
||||
setPhase(next);
|
||||
setInput(kind);
|
||||
};
|
||||
|
||||
const clearTimers = () => {
|
||||
clearTimeout(timers.current.complete);
|
||||
clearTimeout(timers.current.reset);
|
||||
};
|
||||
|
||||
const motion = useRef<Motion>({ raf: 0, p: 0, from: 0, to: 0, start: 0 });
|
||||
const drive = (to: number, duration: number, ease: (t: number) => number) => {
|
||||
const m = motion.current;
|
||||
cancelAnimationFrame(m.raf);
|
||||
m.from = m.p;
|
||||
m.to = to;
|
||||
m.start = performance.now();
|
||||
const step = (now: number) => {
|
||||
const t = duration > 0 ? Math.min(1, (now - m.start) / duration) : 1;
|
||||
m.p = m.from + (m.to - m.from) * ease(t);
|
||||
buttonRef.current?.style.setProperty('--hb-p', m.p.toFixed(4));
|
||||
if (t < 1) {
|
||||
m.raf = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
m.raf = 0;
|
||||
if (m.to === 1) complete();
|
||||
};
|
||||
m.raf = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const complete = () => {
|
||||
if (phaseRef.current !== 'holding') return;
|
||||
if (performance.now() - gesture.current.start < holdTime - 50) return;
|
||||
clearTimers();
|
||||
go('done', inputRef.current);
|
||||
onHold?.();
|
||||
if (resetAfter > 0) {
|
||||
timers.current.reset = window.setTimeout(() => {
|
||||
go('idle');
|
||||
drive(0, releaseTime, EASE_OUT);
|
||||
}, resetAfter);
|
||||
}
|
||||
};
|
||||
|
||||
const begin = (kind: Input) => {
|
||||
if (disabled || phaseRef.current !== 'idle') return false;
|
||||
const button = buttonRef.current;
|
||||
if (!button) return false;
|
||||
gesture.current.start = performance.now();
|
||||
gesture.current.rect = button.getBoundingClientRect();
|
||||
go('holding', kind);
|
||||
drive(1, holdTime, LINEAR);
|
||||
timers.current.complete = window.setTimeout(complete, holdTime + 100);
|
||||
return true;
|
||||
};
|
||||
|
||||
const release = ({ drifted = false }: ReleaseOptions = {}) => {
|
||||
if (phaseRef.current !== 'holding') return;
|
||||
clearTimers();
|
||||
const held = performance.now() - gesture.current.start;
|
||||
go('idle');
|
||||
drive(0, releaseTime, EASE_OUT);
|
||||
if (!drifted && held < TAP_MS) onTap?.();
|
||||
};
|
||||
const releaseRef = useRef(release);
|
||||
releaseRef.current = release;
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0 || !e.isPrimary || gesture.current.pointerId !== null) return;
|
||||
if (!begin('pointer')) return;
|
||||
gesture.current.pointerId = e.pointerId;
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const endPointer = (e: React.PointerEvent<HTMLButtonElement>, options?: ReleaseOptions) => {
|
||||
if (e.pointerId !== gesture.current.pointerId) return;
|
||||
gesture.current.pointerId = null;
|
||||
try {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
release(options);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.pointerId !== gesture.current.pointerId) return;
|
||||
const r = gesture.current.rect;
|
||||
if (!r) return;
|
||||
const out =
|
||||
e.clientX < r.left - HIT_PAD ||
|
||||
e.clientX > r.right + HIT_PAD ||
|
||||
e.clientY < r.top - HIT_PAD ||
|
||||
e.clientY > r.bottom + HIT_PAD;
|
||||
if (out) endPointer(e, { drifted: true });
|
||||
};
|
||||
|
||||
const handlePointerLeave = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.pointerType !== 'touch') endPointer(e, { drifted: true });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (inputRef.current === 'key') release({ drifted: true });
|
||||
return;
|
||||
}
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) begin('key');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (inputRef.current === 'key') release();
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return undefined;
|
||||
const measure = () => {
|
||||
button.style.setProperty('--hb-w', `${button.offsetWidth}px`);
|
||||
button.style.setProperty('--hb-h', `${button.offsetHeight}px`);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(button);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'holding') return undefined;
|
||||
const cancel = () => releaseRef.current({ drifted: true });
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) cancel();
|
||||
};
|
||||
window.addEventListener('blur', cancel);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('blur', cancel);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = timers.current;
|
||||
const m = motion.current;
|
||||
return () => {
|
||||
clearTimeout(t.complete);
|
||||
clearTimeout(t.reset);
|
||||
cancelAnimationFrame(m.raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const direction: HoldButtonDirection = fillDirection === 'up' ? 'up' : 'right';
|
||||
const labels = (
|
||||
<>
|
||||
<span
|
||||
className={`${LABEL_SPAN} group-data-[phase=done]:opacity-0 group-data-[phase=done]:blur-[2px]`}
|
||||
aria-hidden={phase === 'done'}
|
||||
>
|
||||
{icon ? <span className="inline-flex flex-none [&>svg]:block">{icon}</span> : null}
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
className={`${LABEL_SPAN} opacity-0 blur-[2px] group-data-[phase=done]:opacity-100 group-data-[phase=done]:blur-0`}
|
||||
aria-hidden={phase !== 'done'}
|
||||
>
|
||||
{doneIcon ? <span className="inline-flex flex-none [&>svg]:block">{doneIcon}</span> : null}
|
||||
{doneLabel}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const cssVars = {
|
||||
'--hb-radius': `${radius}px`,
|
||||
'--hb-bg': backgroundColor,
|
||||
'--hb-fill': fillColor,
|
||||
'--hb-text': textColor,
|
||||
'--hb-fill-text': fillTextColor,
|
||||
'--hb-hold': `${holdTime}ms`,
|
||||
'--hb-cycles': holdTime / 1100,
|
||||
'--hb-release': `${releaseTime}ms`,
|
||||
'--hb-press': pressScale,
|
||||
'--hb-wave': `${wave ? waveAmplitude : 0}px`,
|
||||
'--hb-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={`hb-root group relative isolate m-0 inline-grid cursor-pointer touch-manipulation select-none place-items-center border-0 font-medium leading-none tracking-[0.01em] outline-none [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [background:var(--hb-bg)] [border-radius:var(--hb-radius)] [color:var(--hb-text)] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] [transition:transform_160ms_var(--hb-ease-out),background-color_160ms_ease,box-shadow_var(--hb-release)_var(--hb-ease-out)] [@media(hover:hover)_and_(pointer:fine)]:enabled:hover:[background:color-mix(in_srgb,var(--hb-bg)_92%,#fff)] data-[phase=holding]:data-[input=pointer]:[transform:scale(var(--hb-press))] data-[glow=true]:data-[phase=holding]:shadow-[${GLOW}] data-[glow=true]:data-[phase=done]:shadow-[${GLOW}] data-[glow=true]:data-[phase=holding]:[transition:transform_160ms_var(--hb-ease-out),background-color_160ms_ease,box-shadow_var(--hb-hold)_linear] focus-visible:[outline:2px_solid_var(--hb-fill)] focus-visible:outline-offset-[3px] disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 contrast-more:[outline:1px_solid_var(--hb-text)] ${SIZES[size] || SIZES.md}${className ? ` ${className}` : ''}`}
|
||||
data-phase={phase}
|
||||
data-input={input ?? undefined}
|
||||
data-direction={direction}
|
||||
data-glow={glow ? 'true' : undefined}
|
||||
aria-describedby={hintId}
|
||||
style={cssVars}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={e => endPointer(e)}
|
||||
onPointerCancel={e => endPointer(e, { drifted: true })}
|
||||
onLostPointerCapture={e => endPointer(e, { drifted: true })}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
<style>{STYLE}</style>
|
||||
<span
|
||||
className="hb-pulse pointer-events-none absolute inset-0 z-0 opacity-0 [border-radius:var(--hb-radius)] group-data-[glow=true]:group-data-[phase=done]:[animation:hb-pulse_600ms_var(--hb-ease-out)_forwards]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hb-label relative z-[2] grid place-items-center">{labels}</span>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 z-[3] [clip-path:inset(0_round_var(--hb-radius))]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="hb-fill absolute inset-0 grid place-items-center [background:var(--hb-fill)] [color:var(--hb-fill-text)]">
|
||||
<span className="hb-label grid place-items-center">{labels}</span>
|
||||
</span>
|
||||
<span className="hb-crest absolute inset-0 grid place-items-center [background:var(--hb-fill)] [color:var(--hb-fill-text)]">
|
||||
<span className="hb-label grid place-items-center">{labels}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span id={hintId} className="absolute h-px w-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">
|
||||
Press and hold for {Math.round(holdTime / 100) / 10} seconds to confirm
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoldButton;
|
||||
Reference in new issue
Block a user