feat: fix lil stuff
Publish npm package / publish (push) Successful in 20s

This commit is contained in:
Senko-san
2026-06-12 15:04:30 +03:00
parent 14884f416e
commit 600053c63e
6 changed files with 163 additions and 421 deletions
-6
View File
@@ -5,8 +5,6 @@ type Mark = { value: number; label?: string };
type MarksProp = boolean | Array<number | Mark>; type MarksProp = boolean | Array<number | Mark>;
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none'; type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
type KnobStyle = 'square' | 'round';
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & { type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
/** /**
* Step marks. * Step marks.
@@ -20,8 +18,6 @@ type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'classNam
* (labels still render when provided). No effect without `marks`. * (labels still render when provided). No effect without `marks`.
*/ */
notches?: NotchPlacement; 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. * Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise.
* Explicitly setting this always overrides the default. * Explicitly setting this always overrides the default.
@@ -77,7 +73,6 @@ const NotchLayer = ({
export const Slider = ({ export const Slider = ({
marks, marks,
notches = 'bottom', notches = 'bottom',
knobStyle = 'square',
animated, animated,
min = 0, min = 0,
max = 100, max = 100,
@@ -94,7 +89,6 @@ export const Slider = ({
const cls = [ const cls = [
'mta-slider', 'mta-slider',
`mta-slider--knob-${knobStyle}`,
isAnimated && 'mta-slider--animated', isAnimated && 'mta-slider--animated',
hasMarks && 'mta-slider--has-marks', hasMarks && 'mta-slider--has-marks',
hasLabels && 'mta-slider--has-labels', hasLabels && 'mta-slider--has-labels',
+52 -6
View File
@@ -1,4 +1,4 @@
import { type ComponentPropsWithoutRef } from 'react'; import { type ComponentPropsWithoutRef, useEffect, useRef } from 'react';
import { Tabs as RTabs } from 'radix-ui'; import { Tabs as RTabs } from 'radix-ui';
import { cx } from '../utils'; import { cx } from '../utils';
@@ -6,13 +6,58 @@ export const Tabs = RTabs.Root;
export const TabsList = ({ export const TabsList = ({
items, items,
variant = 'primary',
className, className,
...props ...props
}: { items: Array<{ value: string; label: string }> } & Omit< }: {
ComponentPropsWithoutRef<typeof RTabs.List>, items: Array<{ value: string; label: string }>;
'children' /** Visual style. `'primary'` (default) is the segmented pill track; `'secondary'` is the underline style. */
>) => ( variant?: 'primary' | 'secondary';
<RTabs.List className={cx('mta-tabs', className)} {...props}> } & Omit<ComponentPropsWithoutRef<typeof RTabs.List>, 'children'>) => {
const listRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLSpanElement>(null);
const initialized = useRef(false);
useEffect(() => {
const list = listRef.current;
const thumb = thumbRef.current;
if (!list || !thumb) return;
const update = () => {
const active = list.querySelector<HTMLElement>('[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 (
<RTabs.List ref={listRef} className={cx('mta-tabs', `mta-tabs--${variant}`, className)} {...props}>
<span ref={thumbRef} className="mta-tabs__thumb" aria-hidden />
{items.map((it) => ( {items.map((it) => (
<RTabs.Trigger <RTabs.Trigger
key={it.value} key={it.value}
@@ -24,5 +69,6 @@ export const TabsList = ({
))} ))}
</RTabs.List> </RTabs.List>
); );
};
export const TabsContent = RTabs.Content; export const TabsContent = RTabs.Content;
+9 -258
View File
@@ -1,269 +1,20 @@
import { import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
forwardRef,
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
type Ref,
} from 'react';
import { cx } from '../utils'; import { cx } from '../utils';
/* ------------------------------------------------------------------ * type TextFieldProps = ComponentPropsWithoutRef<'input'>;
* Typing animation (osu!-lazer style) type TextAreaProps = ComponentPropsWithoutRef<'textarea'>;
*
* 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 <span> 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<number | null> = 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<FieldElement>,
controlledValue: ComponentPropsWithoutRef<'input'>['value'],
initial: ComponentPropsWithoutRef<'input'>['defaultValue'],
) {
const innerRef = useRef<FieldElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const spanRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
const present = useRef<Array<{ id: number; char: string }>>([]);
const nextId = useRef(0);
const idPrefix = useId();
const [text, setText] = useState(() => String(controlledValue ?? initial ?? ''));
const [entries, setEntries] = useState<CharEntry[]>([]);
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 = (
<div
ref={overlayRef}
aria-hidden="true"
className={cx('mta-field-overlay', multiline && 'mta-field-overlay--multiline')}
>
{entries.map((e) =>
e.leaving ? (
<span
key={`${idPrefix}-${e.id}`}
className="mta-field-char mta-field-char--leaving"
style={{ left: e.x, top: e.y }}
onAnimationEnd={() => onLeaveEnd(e.id)}
>
{e.char}
</span>
) : (
<span
key={`${idPrefix}-${e.id}`}
ref={(node) => {
if (node) spanRefs.current.set(e.id, node);
else spanRefs.current.delete(e.id);
}}
className={cx('mta-field-char', !multiline && 'mta-field-char--composited')}
>
{e.char}
</span>
),
)}
</div>
);
return { setRef, overlay, handleChange, syncScroll };
}
type TextFieldProps = ComponentPropsWithoutRef<'input'> & { animated?: boolean };
type TextAreaProps = ComponentPropsWithoutRef<'textarea'> & { animated?: boolean };
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => { ({ className, ...props }, ref) => (
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation( <input ref={ref} className={cx('mta-field', className)} {...props} />
false, ),
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<input
ref={ref}
className={cx('mta-field', className)}
style={style}
onChange={onChange}
onScroll={onScroll}
{...props}
/>
);
}
return (
<div className={cx('mta-field-wrap', className)} style={style}>
<input
ref={setRef}
className="mta-field mta-field--animated"
onChange={(e) => {
handleChange(e.currentTarget.value);
onChange?.(e);
}}
onScroll={(e) => {
syncScroll();
onScroll?.(e);
}}
{...props}
/>
{overlay}
</div>
);
},
); );
TextField.displayName = 'TextField'; TextField.displayName = 'TextField';
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>( export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => { ({ className, ...props }, ref) => (
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation( <textarea ref={ref} className={cx('mta-field', className)} {...props} />
true, ),
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<textarea
ref={ref}
className={cx('mta-field', className)}
style={style}
onChange={onChange}
onScroll={onScroll}
{...props}
/>
);
}
return (
<div
className={cx('mta-field-wrap', 'mta-field-wrap--multiline', className)}
style={style}
>
<textarea
ref={setRef}
className="mta-field mta-field--animated"
onChange={(e) => {
handleChange(e.currentTarget.value);
onChange?.(e);
}}
onScroll={(e) => {
syncScroll();
onScroll?.(e);
}}
{...props}
/>
{overlay}
</div>
);
},
); );
TextArea.displayName = 'TextArea'; TextArea.displayName = 'TextArea';
-5
View File
@@ -28,11 +28,6 @@ const meta = {
options: ['top', 'bottom', 'both', 'none'], options: ['top', 'bottom', 'both', 'none'],
description: 'Notch tick placement relative to the track (labels still render when `none`).', description: 'Notch tick placement relative to the track (labels still render when `none`).',
}, },
knobStyle: {
control: 'inline-radio',
options: ['square', 'round'],
description: 'Knob shape: `square` (default) or `round`.',
},
className: { control: 'text' }, className: { control: 'text' },
}, },
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>], decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
+25
View File
@@ -45,3 +45,28 @@ export const Playground: Story = {
</Tabs> </Tabs>
), ),
}; };
/** `variant="secondary"` swaps the segmented pill track for an underline. */
export const Secondary: Story = {
render: () => (
<Tabs defaultValue="overview" style={{ width: 420 }}>
<TabsList
variant="secondary"
items={[
{ value: 'overview', label: 'Overview' },
{ value: 'activity', label: 'Activity' },
{ value: 'settings', label: 'Settings' },
]}
/>
<TabsContent value="overview" style={{ paddingTop: 16 }} className="mta-body">
Project at a glance.
</TabsContent>
<TabsContent value="activity" style={{ paddingTop: 16 }} className="mta-body">
Recent activity feed.
</TabsContent>
<TabsContent value="settings" style={{ paddingTop: 16 }} className="mta-body">
Preferences and configuration.
</TabsContent>
</Tabs>
),
};
+66 -135
View File
@@ -195,117 +195,6 @@ textarea.mta-field {
padding-left: 34px; padding-left: 34px;
} }
/* ---------- TYPING ANIMATION (osu!-lazer style char in/out) ---------- */
/* The real field renders transparent over a mirrored per-letter overlay. */
.mta-field-wrap {
position: relative;
width: 100%;
}
.mta-search .mta-field-wrap {
width: 100%;
}
.mta-field--animated {
color: transparent;
caret-color: var(--color-text-primary);
}
.mta-field--animated::placeholder {
color: var(--color-text-muted);
}
.mta-field-overlay {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
/* transparent border mirrors the field's 1px border so content aligns */
border: 1px solid transparent;
padding: var(--field-pad-y) var(--field-pad-x);
font-family: var(--mta-font-sans);
font-size: var(--ctl-font);
line-height: normal;
color: var(--color-text-primary);
white-space: pre;
text-align: left;
will-change: transform;
}
.mta-field-overlay--multiline {
white-space: pre-wrap;
overflow-wrap: break-word;
line-height: 1.5;
}
.mta-search .mta-field-overlay {
padding-left: 34px;
}
.mta-field-char {
/* inline (not inline-block) so the overlay wraps exactly like the field;
inherit white-space so multiline (pre-wrap) wraps while input (pre) won't.
'pre' on the span itself would suppress wrapping at its boundaries.
Multiline keeps this inline path: inline boxes ignore transform, so the
rise eases `top` (a layout property — acceptable here, textarea wraps
instead of scrolling so there's no per-keystroke scroll-sync thrash). */
position: relative;
white-space: inherit;
animation: mta-char-in var(--dur-base) var(--ease-snap) both;
}
/* Single-line never wraps, so chars can be inline-block and animate
`transform` (compositor-only) instead of `top` (relayout every frame).
The single-line field scrolls horizontally and syncs the overlay each
keystroke, so the layout-triggering `top` path janked there — this doesn't. */
.mta-field-char--composited {
display: inline-block;
will-change: transform, opacity;
animation-name: mta-char-in-rise;
}
.mta-field-char--leaving {
display: inline-block;
position: absolute;
white-space: pre;
animation: mta-char-out var(--dur-base) var(--ease-out) forwards;
}
/* inline elements ignore transform, so the rise uses top instead */
@keyframes mta-char-in {
from {
opacity: 0;
top: -0.32em;
}
to {
opacity: 1;
top: 0;
}
}
/* inline-block (single-line) variant — composited transform, no relayout */
@keyframes mta-char-in-rise {
from {
opacity: 0;
transform: translateY(-0.32em);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes mta-char-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(0.7em) scale(0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.mta-field-char {
animation: none;
}
.mta-field-char--leaving {
animation: mta-char-out 1ms linear forwards;
}
}
/* ---------- SELECT (Radix Select) ---------- */ /* ---------- SELECT (Radix Select) ---------- */
.mta-select { .mta-select {
font-family: var(--mta-font-sans); font-family: var(--mta-font-sans);
@@ -595,7 +484,6 @@ textarea.mta-field {
/* ---------- SLIDER ---------- */ /* ---------- SLIDER ---------- */
.mta-slider { .mta-slider {
--ms-thumb: 20px; --ms-thumb: 20px;
--ms-thumb-w: var(--ms-thumb);
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -626,9 +514,9 @@ textarea.mta-field {
} }
.mta-slider__thumb { .mta-slider__thumb {
display: block; display: block;
width: var(--ms-thumb-w); width: var(--ms-thumb);
height: var(--ms-thumb); height: var(--ms-thumb);
border-radius: 4px; border-radius: 50%;
background: #fff; background: #fff;
border: 1.5px solid var(--color-border-strong); border: 1.5px solid var(--color-border-strong);
box-shadow: var(--mta-shadow-sm); box-shadow: var(--mta-shadow-sm);
@@ -647,13 +535,6 @@ textarea.mta-field {
transition: none; transition: none;
} }
} }
.mta-slider--knob-square .mta-slider__thumb {
--ms-thumb-w: 14px;
}
.mta-slider--knob-round .mta-slider__thumb {
--ms-thumb-w: var(--ms-thumb);
border-radius: 50%;
}
.mta-slider__thumb:focus-visible { .mta-slider__thumb:focus-visible {
border-color: var(--color-border-focus); border-color: var(--color-border-focus);
box-shadow: var(--mta-shadow-sm), var(--focus-ring); box-shadow: var(--mta-shadow-sm), var(--focus-ring);
@@ -924,37 +805,87 @@ textarea.mta-field {
/* ---------- TABS (Radix Tabs) ---------- */ /* ---------- TABS (Radix Tabs) ---------- */
.mta-tabs { .mta-tabs {
display: flex; display: inline-flex;
align-items: center;
gap: 2px; gap: 2px;
border-bottom: 1px solid var(--color-border);
} }
.mta-tabs__trigger { .mta-tabs__trigger {
font-family: var(--mta-font-sans); font-family: var(--mta-font-sans);
font-size: var(--mta-font-size-sm); font-size: var(--mta-font-size-sm);
font-weight: var(--mta-font-weight-semibold);
color: var(--color-text-muted);
background: transparent; background: transparent;
border: none; border: none;
padding: 9px 14px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
transition: color var(--dur-quick); transition:
color var(--dur-quick),
background var(--dur-quick),
box-shadow var(--dur-quick);
} }
.mta-tabs__trigger:hover {
/* Primary (default) — pill track with sliding thumb */
.mta-tabs--primary {
position: relative;
padding: 4px;
background: var(--color-bg-card);
border-radius: var(--r-pill);
box-shadow: var(--mta-shadow-sm);
}
.mta-tabs__thumb {
position: absolute;
top: 4px;
left: 0;
height: calc(100% - 8px);
background: var(--color-accent);
border-radius: var(--r-pill);
pointer-events: none;
transition:
transform var(--dur-quick) var(--ease-out),
width var(--dur-quick) var(--ease-out);
will-change: transform, width;
}
.mta-tabs--primary .mta-tabs__trigger {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 20px;
border-radius: var(--r-pill);
font-weight: var(--mta-font-weight-medium);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.mta-tabs__trigger[data-state='active'] { .mta-tabs--primary .mta-tabs__trigger:hover {
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.mta-tabs__trigger[data-state='active']::after { .mta-tabs--primary .mta-tabs__trigger[data-state='active'] {
content: ''; color: var(--color-text-on-primary);
position: absolute; }
left: 8px;
right: 8px; /* Secondary — underline with sliding indicator */
.mta-tabs--secondary {
position: relative;
border-bottom: 1px solid var(--color-border);
border-radius: 0;
}
.mta-tabs--secondary .mta-tabs__thumb {
top: auto;
left: 0;
bottom: -1px; bottom: -1px;
height: 2px; height: 2px;
width: 0;
border-radius: 2px; border-radius: 2px;
background: var(--color-accent); }
.mta-tabs--secondary .mta-tabs__trigger {
font-weight: var(--mta-font-weight-semibold);
color: var(--color-text-muted);
padding: 9px 14px;
}
.mta-tabs--secondary .mta-tabs__trigger:hover {
color: var(--color-text-secondary);
}
.mta-tabs--secondary .mta-tabs__trigger[data-state='active'] {
color: var(--color-text-primary);
} }
/* ---------- PROGRESS (Radix Progress) ---------- */ /* ---------- PROGRESS (Radix Progress) ---------- */