diff --git a/.storybook/main.ts b/.storybook/main.ts index 98a0d2a..734bdb6 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -2,6 +2,26 @@ import type { StorybookConfig } from 'storybook-react-rsbuild'; /* Dev-only playground. Never shipped — package `files` is ["dist"]. */ const config: StorybookConfig = { + rsbuildFinal: (config) => { + config.tools ??= {}; + // Append our rule without clobbering storybook-react-rsbuild's own + // tools.rspack hook (it injects the storybook-config-entry virtual module + // in build mode). Mutate in place and return nothing so its config stays. + const prev = config.tools.rspack; + config.tools.rspack = [ + ...(Array.isArray(prev) ? prev : prev ? [prev] : []), + (rspackConfig) => { + rspackConfig.module ??= {}; + rspackConfig.module.rules ??= []; + (rspackConfig.module.rules as unknown[]).push({ + test: /\.mjs$/, + type: 'javascript/auto', + resolve: { fullySpecified: false }, + }); + }, + ]; + return config; + }, stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'], addons: ['@storybook/addon-docs'], staticDirs: ['../src/assets'], diff --git a/CLAUDE.md b/CLAUDE.md index 014b7d4..ae787d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,9 +37,11 @@ Only `dist/` ships (`files: ["dist"]`), so the playground, stories, and `.storyb Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/mono` chains that degrade to `system-ui`; the actual faces (Anta `@font-face` + Onest/Geist Mono Google Fonts `@import`) live in `src/styles/fonts.css`, shipped as the optional `modern-sk/fonts.css` export. Consumers either import it, or override the `--font-*` tokens to remap typefaces. Storybook's `preview.tsx` and the dev `global.css` both import `fonts.css` so the playgrounds stay branded. -### Components (`src/components/ui.tsx`) +### Components (`src/components/`) -All components live in one file. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `modern-sk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `modern-sk-*` classes resolving against CSS custom properties.** +`src/components/ui.tsx` is a barrel that re-exports one folder per component (`button/`, `text-field/`, …); `cx`/shared helpers live in `src/components/utils.ts`. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `modern-sk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `modern-sk-*` classes resolving against CSS custom properties.** + +Exception — text inputs (`text-field/`): `TextField`/`TextArea`/`SearchField` animate letters in/out (osu!-lazer style). Native fields can't animate per-glyph, so the real element renders with transparent text (`modern-sk-field--animated`, caret stays visible) over a mirrored per-character `` overlay that plays the `modern-sk-char-in/out` keyframes; an LCS diff preserves letter identity so only inserted/removed glyphs animate. The overlay still derives all appearance from `modern-sk-*` classes/tokens — the only JS-set styles are the per-letter pin offset and scroll-sync transform. Pass `animated={false}` to opt out and render the plain native field. ### Styling system (`src/styles/`) @@ -47,7 +49,7 @@ All components live in one file. Pattern: Radix provides logic/accessibility, ev - `components.css` — the `modern-sk-*` class definitions. - Dark/light is driven by `data-theme` on ``, set by `ThemeProvider` (persisted to `localStorage` under key `modern-sk-theme`, default `dark`). -When adding or changing a component: add the wrapper in `ui.tsx`, define its `modern-sk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding. +When adding or changing a component: add the wrapper folder under `src/components/` and re-export it from `ui.tsx`, define its `modern-sk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding. ## Consumer contract diff --git a/src/components/text-field/index.tsx b/src/components/text-field/index.tsx index 17666fb..2b10ebe 100644 --- a/src/components/text-field/index.tsx +++ b/src/components/text-field/index.tsx @@ -1,24 +1,276 @@ -import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; +import { + forwardRef, + useCallback, + useId, + useLayoutEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type ReactNode, + type Ref, +} from 'react'; import { cx } from '../utils'; -export const TextField = forwardRef>( - ({ className, ...props }, ref) => ( - - ), +/* ------------------------------------------------------------------ * + * 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 }; + +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} +
+ ); + }, ); TextField.displayName = 'TextField'; -export const TextArea = forwardRef>( - ({ className, ...props }, ref) => ( -