From 600053c63e69dfac83a76720115bae63d38915a0 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Fri, 12 Jun 2026 15:04:30 +0300 Subject: [PATCH] feat: fix lil stuff --- src/components/slider/index.tsx | 6 - src/components/tabs/index.tsx | 80 +++++++-- src/components/text-field/index.tsx | 267 +--------------------------- src/stories/Slider.stories.tsx | 5 - src/stories/Tabs.stories.tsx | 25 +++ src/styles/components.css | 201 +++++++-------------- 6 files changed, 163 insertions(+), 421 deletions(-) diff --git a/src/components/slider/index.tsx b/src/components/slider/index.tsx index d8b5a91..2532152 100644 --- a/src/components/slider/index.tsx +++ b/src/components/slider/index.tsx @@ -5,8 +5,6 @@ type Mark = { value: number; label?: string }; type MarksProp = boolean | Array; type NotchPlacement = 'top' | 'bottom' | 'both' | 'none'; -type KnobStyle = 'square' | 'round'; - type SliderProps = Omit, 'className'> & { /** * Step marks. @@ -20,8 +18,6 @@ type SliderProps = Omit, 'classNam * (labels still render when provided). No effect without `marks`. */ notches?: NotchPlacement; - /** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */ - knobStyle?: KnobStyle; /** * Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise. * Explicitly setting this always overrides the default. @@ -77,7 +73,6 @@ const NotchLayer = ({ export const Slider = ({ marks, notches = 'bottom', - knobStyle = 'square', animated, min = 0, max = 100, @@ -94,7 +89,6 @@ export const Slider = ({ const cls = [ 'mta-slider', - `mta-slider--knob-${knobStyle}`, isAnimated && 'mta-slider--animated', hasMarks && 'mta-slider--has-marks', hasLabels && 'mta-slider--has-labels', diff --git a/src/components/tabs/index.tsx b/src/components/tabs/index.tsx index c0bf826..1c8e6a0 100644 --- a/src/components/tabs/index.tsx +++ b/src/components/tabs/index.tsx @@ -1,4 +1,4 @@ -import { type ComponentPropsWithoutRef } from 'react'; +import { type ComponentPropsWithoutRef, useEffect, useRef } from 'react'; import { Tabs as RTabs } from 'radix-ui'; import { cx } from '../utils'; @@ -6,23 +6,69 @@ export const Tabs = RTabs.Root; export const TabsList = ({ items, + variant = 'primary', className, ...props -}: { items: Array<{ value: string; label: string }> } & Omit< - ComponentPropsWithoutRef, - 'children' ->) => ( - - {items.map((it) => ( - - {it.label} - - ))} - -); +}: { + items: Array<{ value: string; label: string }>; + /** Visual style. `'primary'` (default) is the segmented pill track; `'secondary'` is the underline style. */ + variant?: 'primary' | 'secondary'; +} & Omit, 'children'>) => { + const listRef = useRef(null); + const thumbRef = useRef(null); + const initialized = useRef(false); + + useEffect(() => { + const list = listRef.current; + const thumb = thumbRef.current; + if (!list || !thumb) return; + + const update = () => { + const active = list.querySelector('[role="tab"][data-state="active"]'); + if (!active) return; + + if (!initialized.current) { + thumb.style.transition = 'none'; + } + thumb.style.transform = `translateX(${active.offsetLeft}px)`; + thumb.style.width = `${active.offsetWidth}px`; + if (!initialized.current) { + thumb.getBoundingClientRect(); + thumb.style.transition = ''; + initialized.current = true; + } + }; + + update(); + + const attrObserver = new MutationObserver(update); + list.querySelectorAll('[role="tab"]').forEach((tab) => + attrObserver.observe(tab, { attributes: true, attributeFilter: ['data-state'] }), + ); + + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(list); + + return () => { + attrObserver.disconnect(); + resizeObserver.disconnect(); + }; + }, [variant, items]); + + return ( + + + {items.map((it) => ( + + {it.label} + + ))} + + ); +}; export const TabsContent = RTabs.Content; diff --git a/src/components/text-field/index.tsx b/src/components/text-field/index.tsx index 3f3bd77..7fe08cd 100644 --- a/src/components/text-field/index.tsx +++ b/src/components/text-field/index.tsx @@ -1,269 +1,20 @@ -import { - forwardRef, - useCallback, - useId, - useLayoutEffect, - useRef, - useState, - type ComponentPropsWithoutRef, - type ReactNode, - type Ref, -} from 'react'; +import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { cx } from '../utils'; -/* ------------------------------------------------------------------ * - * Typing animation (osu!-lazer style) - * - * Native inputs draw their own text, so individual letters can't be - * animated. Instead the real field renders transparent (caret stays - * visible) and a mirrored per-character overlay sits behind it: - * newly typed letters rise + fade in, erased letters fall + fade out. - * ------------------------------------------------------------------ */ - -type FieldElement = HTMLInputElement | HTMLTextAreaElement; - -interface CharEntry { - id: number; - char: string; - leaving: boolean; - x?: number; - y?: number; -} - -/** Longest-common-subsequence match so unchanged letters keep their id - * (and thus don't replay the appear animation on every keystroke). */ -function diffChars(prev: ReadonlyArray<{ id: number; char: string }>, next: string) { - const n = prev.length; - const m = next.length; - const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); - for (let i = n - 1; i >= 0; i--) { - for (let j = m - 1; j >= 0; j--) { - dp[i][j] = - prev[i].char === next[j] - ? dp[i + 1][j + 1] + 1 - : Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - const reusedId: Array = new Array(m).fill(null); - const keptPrev = new Array(n).fill(false); - let i = 0; - let j = 0; - while (i < n && j < m) { - if (prev[i].char === next[j]) { - reusedId[j] = prev[i].id; - keptPrev[i] = true; - i++; - j++; - } else if (dp[i + 1][j] >= dp[i][j + 1]) { - i++; - } else { - j++; - } - } - return { reusedId, keptPrev }; -} - -function useFieldAnimation( - multiline: boolean, - externalRef: Ref, - controlledValue: ComponentPropsWithoutRef<'input'>['value'], - initial: ComponentPropsWithoutRef<'input'>['defaultValue'], -) { - const innerRef = useRef(null); - const overlayRef = useRef(null); - const spanRefs = useRef>(new Map()); - const present = useRef>([]); - const nextId = useRef(0); - const idPrefix = useId(); - - const [text, setText] = useState(() => String(controlledValue ?? initial ?? '')); - const [entries, setEntries] = useState([]); - - const setRef = useCallback( - (node: FieldElement | null) => { - innerRef.current = node; - if (typeof externalRef === 'function') externalRef(node); - else if (externalRef) (externalRef as { current: FieldElement | null }).current = node; - }, - [externalRef], - ); - - const syncScroll = useCallback(() => { - const el = innerRef.current; - const ov = overlayRef.current; - if (el && ov) ov.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)`; - }, []); - - // Reconcile the overlay whenever the text changes. - useLayoutEffect(() => { - const prev = present.current; - const { reusedId, keptPrev } = diffChars(prev, text); - - const nextPresent: Array<{ id: number; char: string }> = []; - for (let k = 0; k < text.length; k++) { - const id = reusedId[k] ?? nextId.current++; - nextPresent.push({ id, char: text[k] }); - } - - // Letters that were removed fall away — pin them where they last sat. - const leaving: CharEntry[] = []; - for (let k = 0; k < prev.length; k++) { - if (keptPrev[k]) continue; - const el = spanRefs.current.get(prev[k].id); - if (el) { - leaving.push({ - id: prev[k].id, - char: prev[k].char, - leaving: true, - x: el.offsetLeft, - y: el.offsetTop, - }); - } - } - - present.current = nextPresent; - setEntries((current) => [ - ...nextPresent.map((e) => ({ ...e, leaving: false })), - ...current.filter((e) => e.leaving), - ...leaving, - ]); - syncScroll(); - }, [text, syncScroll]); - - // Stay in sync when used as a controlled component. - useLayoutEffect(() => { - if (controlledValue !== undefined) setText(String(controlledValue)); - }, [controlledValue]); - - const handleChange = useCallback((value: string) => setText(value), []); - - const onLeaveEnd = useCallback((id: number) => { - spanRefs.current.delete(id); - setEntries((current) => current.filter((e) => e.id !== id)); - }, []); - - const overlay = ( - - ); - - return { setRef, overlay, handleChange, syncScroll }; -} - -type TextFieldProps = ComponentPropsWithoutRef<'input'> & { animated?: boolean }; -type TextAreaProps = ComponentPropsWithoutRef<'textarea'> & { animated?: boolean }; +type TextFieldProps = ComponentPropsWithoutRef<'input'>; +type TextAreaProps = ComponentPropsWithoutRef<'textarea'>; export const TextField = forwardRef( - ({ className, style, onChange, onScroll, animated = true, ...props }, ref) => { - const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation( - false, - ref, - props.value, - props.defaultValue, - ); - if (!animated) { - return ( - - ); - } - return ( -
- { - handleChange(e.currentTarget.value); - onChange?.(e); - }} - onScroll={(e) => { - syncScroll(); - onScroll?.(e); - }} - {...props} - /> - {overlay} -
- ); - }, + ({ className, ...props }, ref) => ( + + ), ); TextField.displayName = 'TextField'; export const TextArea = forwardRef( - ({ className, style, onChange, onScroll, animated = true, ...props }, ref) => { - const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation( - true, - ref, - props.value, - props.defaultValue, - ); - if (!animated) { - return ( -