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,446 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { animate, useMotionValue, useReducedMotion } from 'motion/react';
|
||||
|
||||
const R = 80;
|
||||
const DEAD = 44;
|
||||
const K = 12;
|
||||
const V_FULL = 4;
|
||||
const V_FLICK = 2;
|
||||
const TAU_FOLLOW = 0.06;
|
||||
const TAU_V = 0.05;
|
||||
const DECEL = 0.99;
|
||||
const DRAG_PX = 10;
|
||||
const STALE_MS = 80;
|
||||
|
||||
export interface CometDialChangeDetail {
|
||||
velocity: number;
|
||||
bounce: number;
|
||||
}
|
||||
|
||||
export interface CometDialProps {
|
||||
value?: number;
|
||||
defaultValue?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
label?: string;
|
||||
accent?: string;
|
||||
ink?: string;
|
||||
size?: number;
|
||||
sweep?: number;
|
||||
thickness?: number;
|
||||
speed?: number;
|
||||
tapBounce?: number;
|
||||
flickBounce?: number;
|
||||
momentum?: number;
|
||||
cometReach?: number;
|
||||
cometWidth?: number;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: number) => void;
|
||||
onChangeEnd?: (value: number, detail: CometDialChangeDetail) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Sample = [number, number];
|
||||
|
||||
interface Grip {
|
||||
id: number;
|
||||
at: number | null;
|
||||
hist: Sample[];
|
||||
side: 'hi' | 'lo' | null;
|
||||
moved: boolean;
|
||||
x0: number;
|
||||
y0: number;
|
||||
}
|
||||
|
||||
interface Loop {
|
||||
raf: number;
|
||||
last: number;
|
||||
v: number;
|
||||
rPrev: number | null;
|
||||
text: string;
|
||||
valueText: string;
|
||||
}
|
||||
|
||||
interface Live {
|
||||
move: (e: PointerEvent) => void;
|
||||
up: (e: PointerEvent) => void;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
||||
const velocityOf = (hist: Sample[]) => {
|
||||
if (hist.length < 2) return 0;
|
||||
const a = hist[0];
|
||||
const b = hist[hist.length - 1];
|
||||
return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;
|
||||
};
|
||||
const decimalsOf = (step: number) => {
|
||||
const s = String(step);
|
||||
const i = s.indexOf('.');
|
||||
return i === -1 ? 0 : s.length - i - 1;
|
||||
};
|
||||
const pointAt = (deg: number): [number, number] => {
|
||||
const a = (deg * Math.PI) / 180;
|
||||
return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R];
|
||||
};
|
||||
const arcPath = (a0: number, a1: number) => {
|
||||
const [x0, y0] = pointAt(a0);
|
||||
const [x1, y1] = pointAt(a1);
|
||||
return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;
|
||||
};
|
||||
|
||||
const CometDial: React.FC<CometDialProps> = ({
|
||||
value,
|
||||
defaultValue = 62,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
unit = '%',
|
||||
label = 'Level',
|
||||
accent = '#f5f5f5',
|
||||
ink = '#fdfdfd',
|
||||
size = 250,
|
||||
sweep = 320,
|
||||
thickness = 5,
|
||||
speed = 25,
|
||||
tapBounce = 0.2,
|
||||
flickBounce = 0.1,
|
||||
momentum = 1,
|
||||
cometReach = 180,
|
||||
cometWidth = 12,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
className = ''
|
||||
}) => {
|
||||
const reduce = useReducedMotion();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const litRef = useRef<SVGPathElement>(null);
|
||||
const headRef = useRef<SVGCircleElement>(null);
|
||||
const comet = useRef<(SVGPathElement | null)[]>([]);
|
||||
const figure = useRef<HTMLSpanElement>(null);
|
||||
const target = useRef(clamp(value ?? defaultValue, min, max));
|
||||
const grip = useRef<Grip | null>(null);
|
||||
const unbind = useRef<(() => void) | null>(null);
|
||||
const loop = useRef<Loop>({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' });
|
||||
const live = useRef<Live>({} as Live);
|
||||
const reading = useMotionValue(target.current);
|
||||
|
||||
const range = Math.max(1e-9, max - min);
|
||||
const gap = 360 - sweep;
|
||||
const start = 90 + gap / 2;
|
||||
const end = start + sweep;
|
||||
const decimals = decimalsOf(step);
|
||||
const k = 200 + (clamp(speed, 0, 100) / 100) * 700;
|
||||
const crit = 2 * Math.sqrt(k);
|
||||
const snap = (v: number) =>
|
||||
step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max);
|
||||
|
||||
const commit = (v: number, finished: boolean, detail?: CometDialChangeDetail) => {
|
||||
const s = snap(v);
|
||||
if (s !== target.current) {
|
||||
target.current = s;
|
||||
onChange?.(s);
|
||||
}
|
||||
if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce });
|
||||
};
|
||||
|
||||
const paintRef = useRef<((now: number) => void) | null>(null);
|
||||
const tick = useCallback((now: number) => paintRef.current?.(now), []);
|
||||
const wake = () => {
|
||||
const L = loop.current;
|
||||
if (L.raf) return;
|
||||
L.last = performance.now();
|
||||
L.rPrev = null;
|
||||
L.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
const launch = (to: number, bounce: number, velocity = reading.getVelocity()) => {
|
||||
reading.stop();
|
||||
if (reduce) {
|
||||
reading.jump(to);
|
||||
wake();
|
||||
return;
|
||||
}
|
||||
animate(reading, to, {
|
||||
type: 'spring',
|
||||
stiffness: k,
|
||||
damping: crit * (1 - clamp(bounce, 0, 0.9)),
|
||||
mass: 1,
|
||||
velocity
|
||||
});
|
||||
wake();
|
||||
};
|
||||
|
||||
const paint = (now: number) => {
|
||||
const L = loop.current;
|
||||
const svg = svgRef.current;
|
||||
if (!svg) {
|
||||
L.raf = 0;
|
||||
return;
|
||||
}
|
||||
const dt = clamp((now - L.last) / 1000, 0, 0.05);
|
||||
L.last = now;
|
||||
const g = grip.current;
|
||||
if (g && g.at != null) {
|
||||
if (reduce) reading.jump(g.at);
|
||||
else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW)));
|
||||
}
|
||||
const r = reading.get();
|
||||
const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range;
|
||||
L.rPrev = r;
|
||||
L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V));
|
||||
const v = L.v;
|
||||
const s = clamp(Math.abs(v) / V_FULL, 0, 1);
|
||||
const dir = Math.sign(v) || 1;
|
||||
const f = clamp((r - min) / range, 0, 1);
|
||||
const ang = start + f * sweep;
|
||||
if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : '');
|
||||
if (headRef.current) {
|
||||
const [cx, cy] = pointAt(ang);
|
||||
headRef.current.setAttribute('cx', cx.toFixed(3));
|
||||
headRef.current.setAttribute('cy', cy.toFixed(3));
|
||||
}
|
||||
const seg = (cometReach * s) / K;
|
||||
for (let j = 0; j < K; j++) {
|
||||
const el = comet.current[j];
|
||||
if (!el) continue;
|
||||
let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg;
|
||||
let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg;
|
||||
a0 = clamp(a0, start, end);
|
||||
a1 = clamp(a1, start, end);
|
||||
if (seg < 0.01 || a1 - a0 < 0.01) {
|
||||
if (el.style.opacity !== '0') el.style.opacity = '0';
|
||||
continue;
|
||||
}
|
||||
const w = 1 - j / K;
|
||||
el.setAttribute('d', arcPath(a0, a1));
|
||||
el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2));
|
||||
el.style.opacity = (w * s).toFixed(3);
|
||||
}
|
||||
const shown = clamp(r, min, max).toFixed(decimals);
|
||||
if (shown !== L.text) {
|
||||
L.text = shown;
|
||||
if (figure.current) figure.current.textContent = shown;
|
||||
svg.setAttribute('aria-valuenow', shown);
|
||||
}
|
||||
const valueText = `${target.current}${unit}`;
|
||||
if (valueText !== L.valueText) {
|
||||
L.valueText = valueText;
|
||||
svg.setAttribute('aria-valuetext', valueText);
|
||||
}
|
||||
const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002;
|
||||
if (!moving) {
|
||||
for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0';
|
||||
}
|
||||
L.raf = moving ? requestAnimationFrame(tick) : 0;
|
||||
};
|
||||
paintRef.current = paint;
|
||||
|
||||
const localPoint = (cx: number, cy: number): [number, number] => {
|
||||
const b = (svgRef.current as SVGSVGElement).getBoundingClientRect();
|
||||
return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100];
|
||||
};
|
||||
const angleAt = (cx: number, cy: number) => {
|
||||
const [x, y] = localPoint(cx, cy);
|
||||
let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360;
|
||||
const g = grip.current;
|
||||
if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0;
|
||||
else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo';
|
||||
return min + (rel / sweep) * range;
|
||||
};
|
||||
|
||||
const move = (e: PointerEvent) => {
|
||||
const g = grip.current;
|
||||
if (!g || g.id !== e.pointerId) return;
|
||||
if (!g.moved) {
|
||||
if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return;
|
||||
g.moved = true;
|
||||
reading.stop();
|
||||
}
|
||||
g.at = angleAt(e.clientX, e.clientY);
|
||||
g.hist.push([performance.now(), g.at]);
|
||||
if (g.hist.length > 4) g.hist.shift();
|
||||
commit(g.at, false);
|
||||
wake();
|
||||
};
|
||||
const up = (e: PointerEvent) => {
|
||||
const g = grip.current;
|
||||
if (!g || g.id !== e.pointerId) return;
|
||||
grip.current = null;
|
||||
unbind.current?.();
|
||||
unbind.current = null;
|
||||
setDragging(false);
|
||||
try {
|
||||
svgRef.current?.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
if (!g.moved) {
|
||||
commit(target.current, true);
|
||||
return;
|
||||
}
|
||||
const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS;
|
||||
const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist);
|
||||
const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1);
|
||||
const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum);
|
||||
commit(to, true, { velocity: v, bounce });
|
||||
launch(to, bounce, v);
|
||||
};
|
||||
live.current = { move, up };
|
||||
|
||||
const down = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (disabled || grip.current || e.button > 0) return;
|
||||
const [x, y] = localPoint(e.clientX, e.clientY);
|
||||
if (Math.hypot(x, y) < DEAD) return;
|
||||
e.preventDefault();
|
||||
const svg = svgRef.current as SVGSVGElement;
|
||||
try {
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
svg.focus({ preventScroll: true });
|
||||
grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY };
|
||||
const at = angleAt(e.clientX, e.clientY);
|
||||
grip.current.hist.push([performance.now(), at]);
|
||||
setDragging(true);
|
||||
commit(at, false);
|
||||
launch(snap(at), tapBounce);
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (ev.isTrusted) live.current.move(ev);
|
||||
};
|
||||
const onUp = (ev: PointerEvent) => {
|
||||
if (ev.isTrusted) live.current.up(ev);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
unbind.current = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
};
|
||||
|
||||
const key = (e: React.KeyboardEvent<SVGSVGElement>) => {
|
||||
if (disabled) return;
|
||||
const t = target.current;
|
||||
const big = e.shiftKey ? 10 : 1;
|
||||
let to: number;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
to = t + step * big;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
to = t - step * big;
|
||||
break;
|
||||
case 'PageUp':
|
||||
to = t + step * 10;
|
||||
break;
|
||||
case 'PageDown':
|
||||
to = t - step * 10;
|
||||
break;
|
||||
case 'Home':
|
||||
to = min;
|
||||
break;
|
||||
case 'End':
|
||||
to = max;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
to = snap(to);
|
||||
reading.stop();
|
||||
reading.jump(to);
|
||||
commit(to, true);
|
||||
wake();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const L = loop.current;
|
||||
L.text = '';
|
||||
L.valueText = '';
|
||||
paintRef.current?.(performance.now());
|
||||
}, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]);
|
||||
useEffect(() => {
|
||||
if (value === undefined || grip.current) return;
|
||||
const s = snap(value);
|
||||
if (s === target.current) return;
|
||||
target.current = s;
|
||||
launch(s, tapBounce);
|
||||
}, [value]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelAnimationFrame(loop.current.raf);
|
||||
unbind.current?.();
|
||||
reading.stop();
|
||||
},
|
||||
[reading]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative select-none [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent] [width:var(--cd-size)] [height:var(--cd-size)] [color:var(--cd-ink)] data-[disabled]:opacity-55${className ? ` ${className}` : ''}`}
|
||||
data-dragging={dragging ? '' : undefined}
|
||||
data-disabled={disabled ? '' : undefined}
|
||||
style={
|
||||
{
|
||||
'--cd-accent': accent,
|
||||
'--cd-ink': ink,
|
||||
'--cd-size': `${size}px`,
|
||||
'--cd-figure': `${Math.round(size * 0.16)}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="absolute inset-0 block h-full w-full cursor-grab touch-none rounded-full outline-none group-data-[dragging]:cursor-grabbing group-data-[disabled]:pointer-events-none group-data-[disabled]:cursor-default"
|
||||
viewBox="0 0 200 200"
|
||||
role="slider"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={label}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={target.current}
|
||||
aria-valuetext={`${target.current}${unit}`}
|
||||
aria-disabled={disabled || undefined}
|
||||
onPointerDown={down}
|
||||
onKeyDown={key}
|
||||
>
|
||||
<path
|
||||
className="fill-none opacity-[0.24] [stroke:var(--cd-ink)] [stroke-linecap:round]"
|
||||
d={arcPath(start, end)}
|
||||
strokeWidth={thickness}
|
||||
/>
|
||||
<path
|
||||
ref={litRef}
|
||||
className="fill-none opacity-70 [stroke:var(--cd-accent)] [stroke-linecap:round]"
|
||||
strokeWidth={thickness}
|
||||
/>
|
||||
<g className="[&_path]:fill-none [&_path]:[stroke:var(--cd-accent)] [&_path]:[stroke-linecap:round]">
|
||||
{Array.from({ length: K }, (_, j) => (
|
||||
<path
|
||||
key={j}
|
||||
ref={el => {
|
||||
comet.current[j] = el;
|
||||
}}
|
||||
style={{ opacity: 0 }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
<circle ref={headRef} className="[fill:var(--cd-accent)]" r={thickness * 1.8} />
|
||||
</svg>
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center" aria-hidden="true">
|
||||
<span className="inline-flex items-baseline gap-[0.06em] leading-none font-medium tracking-[-0.04em] tabular-nums [font-size:var(--cd-figure)]">
|
||||
<span ref={figure} className="comet-dial__figure" />
|
||||
{unit ? <span className="text-[0.5em] opacity-50">{unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CometDial;
|
||||
Reference in new issue
Block a user