diff options
| author | Aaron Iker <[email protected]> | 2026-02-02 01:17:14 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-02 01:17:14 +0100 |
| commit | 377bf7ff21a4f05807c38675ac70cd08fe67b516 (patch) | |
| tree | 018639c6a87c248d2df325f6a32c511d7e9caf15 /packages/ui/src/components/cycle-label.tsx | |
| parent | b39c1f158f5e78b41edd944dce792a8b602819c1 (diff) | |
| download | opencode-377bf7ff21a4f05807c38675ac70cd08fe67b516.tar.gz opencode-377bf7ff21a4f05807c38675ac70cd08fe67b516.zip | |
feat(ui): Select, dropdown, popover styles & transitions (#11675)
Diffstat (limited to 'packages/ui/src/components/cycle-label.tsx')
| -rw-r--r-- | packages/ui/src/components/cycle-label.tsx | 132 |
1 files changed, 132 insertions, 0 deletions
diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx new file mode 100644 index 000000000..f1eaa88f3 --- /dev/null +++ b/packages/ui/src/components/cycle-label.tsx @@ -0,0 +1,132 @@ +import "./cycle-label.css" +import { createEffect, createSignal, JSX, on } from "solid-js" + +export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> { + value: string + onValueChange?: (value: string) => void + duration?: number | ((value: string) => number) + stagger?: number + opacity?: [number, number] + blur?: [number, number] + skewX?: number + onAnimationStart?: () => void + onAnimationEnd?: () => void +} + +const segmenter = + typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null + +const getChars = (text: string): string[] => + segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function CycleLabel(props: CycleLabelProps) { + const getDuration = (text: string) => { + const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200 + return typeof d === "function" ? d(text) : d + } + const stagger = () => props?.stagger ?? 30 + const opacity = () => props?.opacity ?? [0, 1] + const blur = () => props?.blur ?? [0, 0] + const skewX = () => props?.skewX ?? 10 + + let containerRef: HTMLSpanElement | undefined + let isAnimating = false + const [currentText, setCurrentText] = createSignal(props.value) + + const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { + el.innerHTML = "" + const chars = getChars(text) + chars.forEach((char, i) => { + const span = document.createElement("span") + span.textContent = char === " " ? "\u00A0" : char + span.className = `cycle-char ${state}` + span.style.setProperty("--i", String(i)) + el.appendChild(span) + }) + } + + const animateToText = async (newText: string) => { + if (!containerRef || isAnimating) return + if (newText === currentText()) return + + isAnimating = true + props.onAnimationStart?.() + + const dur = getDuration(newText) + const stag = stagger() + + containerRef.style.width = containerRef.offsetWidth + "px" + + const oldChars = containerRef.querySelectorAll(".cycle-char") + oldChars.forEach((c) => c.classList.replace("enter", "exit")) + + const clone = containerRef.cloneNode(false) as HTMLElement + Object.assign(clone.style, { + position: "absolute", + visibility: "hidden", + width: "auto", + transition: "none", + }) + setChars(clone, newText) + document.body.appendChild(clone) + const nextWidth = clone.offsetWidth + clone.remove() + + const exitTime = oldChars.length * stag + dur + await wait(exitTime * 0.3) + + containerRef.style.width = nextWidth + "px" + + const widthDur = 200 + await wait(widthDur * 0.3) + + setChars(containerRef, newText, "pre") + containerRef.offsetWidth + + Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) + setCurrentText(newText) + props.onValueChange?.(newText) + + const enterTime = getChars(newText).length * stag + dur + await wait(enterTime) + + containerRef.style.width = "" + isAnimating = false + props.onAnimationEnd?.() + } + + createEffect( + on( + () => props.value, + (newValue) => { + if (newValue !== currentText()) { + animateToText(newValue) + } + }, + ), + ) + + const initRef = (el: HTMLSpanElement) => { + containerRef = el + setChars(el, props.value) + } + + return ( + <span + ref={initRef} + class={`cycle-label ${props.class ?? ""}`} + style={{ + "--c-duration": `${getDuration(currentText())}ms`, + "--c-stagger": `${stagger()}ms`, + "--c-opacity-start": opacity()[0], + "--c-opacity-end": opacity()[1], + "--c-blur-start": `${blur()[0]}px`, + "--c-blur-end": `${blur()[1]}px`, + "--c-skew": `${skewX()}deg`, + ...(typeof props.style === "object" ? props.style : {}), + }} + /> + ) +}
\ No newline at end of file |
