Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 600053c63e | |||
| 14884f416e | |||
| e4e249937e |
@@ -1,5 +1,13 @@
|
||||
# MtAir
|
||||
|
||||

|
||||
|
||||
[](https://git.ollyhearn.ru/olly/mt-air/releases)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://react.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.radix-ui.com/)
|
||||
|
||||
Calm, light-first React components built on [Radix](https://www.radix-ui.com/) primitives.
|
||||
Simplicity, air, lightness — every interface should feel as open and uncluttered as a
|
||||
deep breath.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "mt-air",
|
||||
"name": "@olly/mt-air",
|
||||
"version": "0.1.0",
|
||||
"description": "Simplicity, air, lightness. Every interface should feel as open and uncluttered as a deep breath.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -5,8 +5,6 @@ type Mark = { value: number; label?: string };
|
||||
type MarksProp = boolean | Array<number | Mark>;
|
||||
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
|
||||
|
||||
type KnobStyle = 'square' | 'round';
|
||||
|
||||
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
|
||||
/**
|
||||
* Step marks.
|
||||
@@ -20,8 +18,6 @@ type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, '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',
|
||||
|
||||
@@ -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,13 +6,58 @@ export const Tabs = RTabs.Root;
|
||||
|
||||
export const TabsList = ({
|
||||
items,
|
||||
variant = 'primary',
|
||||
className,
|
||||
...props
|
||||
}: { items: Array<{ value: string; label: string }> } & Omit<
|
||||
ComponentPropsWithoutRef<typeof RTabs.List>,
|
||||
'children'
|
||||
>) => (
|
||||
<RTabs.List className={cx('mta-tabs', className)} {...props}>
|
||||
}: {
|
||||
items: Array<{ value: string; label: string }>;
|
||||
/** Visual style. `'primary'` (default) is the segmented pill track; `'secondary'` is the underline style. */
|
||||
variant?: 'primary' | 'secondary';
|
||||
} & 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) => (
|
||||
<RTabs.Trigger
|
||||
key={it.value}
|
||||
@@ -24,5 +69,6 @@ export const TabsList = ({
|
||||
))}
|
||||
</RTabs.List>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabsContent = RTabs.Content;
|
||||
|
||||
@@ -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 <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 };
|
||||
type TextFieldProps = ComponentPropsWithoutRef<'input'>;
|
||||
type TextAreaProps = ComponentPropsWithoutRef<'textarea'>;
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
|
||||
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
|
||||
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>
|
||||
);
|
||||
},
|
||||
({ className, ...props }, ref) => (
|
||||
<input ref={ref} className={cx('mta-field', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TextField.displayName = 'TextField';
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
|
||||
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
|
||||
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>
|
||||
);
|
||||
},
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea ref={ref} className={cx('mta-field', className)} {...props} />
|
||||
),
|
||||
);
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
|
||||
@@ -28,11 +28,6 @@ const meta = {
|
||||
options: ['top', 'bottom', 'both', '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' },
|
||||
},
|
||||
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
|
||||
|
||||
@@ -45,3 +45,28 @@ export const Playground: Story = {
|
||||
</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
@@ -195,117 +195,6 @@ textarea.mta-field {
|
||||
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) ---------- */
|
||||
.mta-select {
|
||||
font-family: var(--mta-font-sans);
|
||||
@@ -595,7 +484,6 @@ textarea.mta-field {
|
||||
/* ---------- SLIDER ---------- */
|
||||
.mta-slider {
|
||||
--ms-thumb: 20px;
|
||||
--ms-thumb-w: var(--ms-thumb);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -626,9 +514,9 @@ textarea.mta-field {
|
||||
}
|
||||
.mta-slider__thumb {
|
||||
display: block;
|
||||
width: var(--ms-thumb-w);
|
||||
width: var(--ms-thumb);
|
||||
height: var(--ms-thumb);
|
||||
border-radius: 4px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 1.5px solid var(--color-border-strong);
|
||||
box-shadow: var(--mta-shadow-sm);
|
||||
@@ -647,13 +535,6 @@ textarea.mta-field {
|
||||
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 {
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--mta-shadow-sm), var(--focus-ring);
|
||||
@@ -924,37 +805,87 @@ textarea.mta-field {
|
||||
|
||||
/* ---------- TABS (Radix Tabs) ---------- */
|
||||
.mta-tabs {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.mta-tabs__trigger {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-size: var(--mta-font-size-sm);
|
||||
font-weight: var(--mta-font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
.mta-tabs__trigger[data-state='active'] {
|
||||
.mta-tabs--primary .mta-tabs__trigger:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-tabs__trigger[data-state='active']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
.mta-tabs--primary .mta-tabs__trigger[data-state='active'] {
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
height: 2px;
|
||||
width: 0;
|
||||
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) ---------- */
|
||||
|
||||
Reference in New Issue
Block a user