+521
@@ -0,0 +1,521 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Trash,
|
||||
Plus,
|
||||
DotsThree,
|
||||
MagnifyingGlass,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
PencilSimple,
|
||||
Copy,
|
||||
Tag,
|
||||
Info,
|
||||
FilePdf,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
WarningOctagon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useTheme } from './components/theme';
|
||||
import {
|
||||
AlertDialog,
|
||||
Badge,
|
||||
Button,
|
||||
Callout,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chip,
|
||||
Control,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
IconButton,
|
||||
List,
|
||||
MenuRow,
|
||||
MenuSeparator,
|
||||
MenuSurface,
|
||||
Progress,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Row,
|
||||
ScrollArea,
|
||||
SearchField,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Slider,
|
||||
Spinner,
|
||||
Stepper,
|
||||
Switch,
|
||||
TBody,
|
||||
THead,
|
||||
Table,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
Td,
|
||||
TextArea,
|
||||
TextField,
|
||||
Th,
|
||||
Tooltip,
|
||||
Tr,
|
||||
} from './components/ui';
|
||||
|
||||
const Section = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<section>
|
||||
<div className="sec-label">{label}</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
const App = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [seg, setSeg] = useState('list');
|
||||
const [tab, setTab] = useState('general');
|
||||
const [count, setCount] = useState(4);
|
||||
const [chips, setChips] = useState(['design', '2026', 'invoices']);
|
||||
|
||||
return (
|
||||
<div className="mta-felt">
|
||||
<div className="wrap">
|
||||
<header>
|
||||
<div className="topbar">
|
||||
<div>
|
||||
<div className="word">
|
||||
MT<b>AIR</b> · KITCHEN SINK
|
||||
</div>
|
||||
<p className="sub">
|
||||
Every component, live and interactive, built on Radix Primitives
|
||||
styled from the global tokens in{' '}
|
||||
<span className="mta-mono">tokens.css</span> +{' '}
|
||||
<span className="mta-mono">components.css</span>. Click, toggle,
|
||||
focus — it all responds.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<SegmentedControl
|
||||
value={theme}
|
||||
onValueChange={(v) => setTheme(v as 'dark' | 'light')}
|
||||
items={[
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* BUTTONS */}
|
||||
<Section label="Buttons">
|
||||
<div className="stack">
|
||||
<div>
|
||||
<div className="cap">Variants</div>
|
||||
<div className="cluster">
|
||||
<Button variant="primary">
|
||||
<ArrowRight size={16} />
|
||||
Primary
|
||||
</Button>
|
||||
<Button>Push button</Button>
|
||||
<Button variant="ember">
|
||||
<Trash size={16} />
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="cap">Sizes & icon</div>
|
||||
<div className="cluster">
|
||||
<Button variant="primary" size="sm">
|
||||
Small
|
||||
</Button>
|
||||
<Button size="sm">Small</Button>
|
||||
<Button iconOnly aria-label="Add">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
<Button iconOnly aria-label="More">
|
||||
<DotsThree size={16} weight="bold" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* FIELDS */}
|
||||
<Section label="Text fields & selects">
|
||||
<div className="two">
|
||||
<div className="stack">
|
||||
<div>
|
||||
<div className="lab">Name</div>
|
||||
<TextField defaultValue="Quarterly Report" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="lab">Location</div>
|
||||
<TextField placeholder="Choose a folder…" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="lab">Search</div>
|
||||
<SearchField
|
||||
icon={<MagnifyingGlass size={16} />}
|
||||
placeholder="Search files…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack">
|
||||
<div>
|
||||
<div className="lab">View</div>
|
||||
<Select
|
||||
defaultValue="name"
|
||||
aria-label="Sort order"
|
||||
items={[
|
||||
{ value: 'name', label: 'Sort by name' },
|
||||
{ value: 'date', label: 'Sort by date' },
|
||||
{ value: 'size', label: 'Sort by size' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="lab">Notes</div>
|
||||
<TextArea
|
||||
placeholder="Add a note…"
|
||||
defaultValue="Everything right at your hands."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* TOGGLES */}
|
||||
<Section label="Switches · checkboxes · radios">
|
||||
<div className="three">
|
||||
<div>
|
||||
<div className="cap">Switch</div>
|
||||
<div className="stack">
|
||||
<Control control={<Switch defaultChecked />}>
|
||||
Sync across devices
|
||||
</Control>
|
||||
<Control control={<Switch />}>Show hidden files</Control>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="cap">Checkbox</div>
|
||||
<div className="stack">
|
||||
<Control control={<Checkbox defaultChecked />}>
|
||||
Include subfolders
|
||||
</Control>
|
||||
<Control control={<Checkbox />}>Follow symlinks</Control>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="cap">Radio</div>
|
||||
<RadioGroup defaultValue="list" className="stack">
|
||||
<Control control={<RadioItem value="list" />}>List view</Control>
|
||||
<Control control={<RadioItem value="grid" />}>Grid view</Control>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* CONTROLS */}
|
||||
<Section label="Segmented · slider · stepper · tabs · progress">
|
||||
<div className="two">
|
||||
<div className="stack">
|
||||
<div>
|
||||
<div className="cap">Segmented</div>
|
||||
<SegmentedControl
|
||||
value={seg}
|
||||
onValueChange={setSeg}
|
||||
items={[
|
||||
{ value: 'icons', label: 'Icons' },
|
||||
{ value: 'list', label: 'List' },
|
||||
{ value: 'columns', label: 'Columns' },
|
||||
{ value: 'gallery', label: 'Gallery' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="cap">Slider & stepper</div>
|
||||
<div className="cluster">
|
||||
<Slider defaultValue={[62]} max={100} step={1} />
|
||||
<Slider defaultValue={[40]} max={100} step={20} marks />
|
||||
<Stepper
|
||||
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
|
||||
onIncrement={() => setCount((n) => n + 1)}
|
||||
/>
|
||||
<span className="mta-mono" style={{ color: 'var(--fg-2)' }}>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack">
|
||||
<div>
|
||||
<div className="cap">Tabs</div>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList
|
||||
items={[
|
||||
{ value: 'general', label: 'General' },
|
||||
{ value: 'sharing', label: 'Sharing' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
]}
|
||||
/>
|
||||
<TabsContent value={tab} />
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<div className="cap">Progress</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Progress value={64} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* BADGES */}
|
||||
<Section label="Badges · chips · tags">
|
||||
<div className="stack">
|
||||
<div className="cluster">
|
||||
<Badge variant="lime">Synced</Badge>
|
||||
<Badge variant="ember">3 conflicts</Badge>
|
||||
<Badge variant="neutral">Draft</Badge>
|
||||
<Badge variant="outline">v2.4</Badge>
|
||||
<Badge variant="neutral" dot style={{ color: 'var(--lime)' }}>
|
||||
Online
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="cluster">
|
||||
{chips.map((c) => (
|
||||
<Chip
|
||||
key={c}
|
||||
onRemove={() => setChips((cs) => cs.filter((x) => x !== c))}
|
||||
>
|
||||
{c}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* SURFACES */}
|
||||
<Section label="Cards · list rows · menu">
|
||||
<div className="three">
|
||||
<Card>
|
||||
<div style={{ fontSize: 24, color: 'var(--lime)' }}>
|
||||
<Folder weight="fill" />
|
||||
</div>
|
||||
<div style={{ fontWeight: 600, marginTop: 10 }}>Projects</div>
|
||||
<div
|
||||
style={{ color: 'var(--fg-3)', fontSize: 13, marginTop: 2 }}
|
||||
>
|
||||
24 items · 1.2 GB
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress value={48} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<List>
|
||||
<Row selected>
|
||||
<Folder weight="fill" size={22} color="var(--lime)" />
|
||||
<span className="nm">Projects</span>
|
||||
<span className="meta">24</span>
|
||||
</Row>
|
||||
<Row>
|
||||
<Folder weight="fill" size={22} color="var(--ember)" />
|
||||
<span className="nm">Invoices</span>
|
||||
<span className="meta">8</span>
|
||||
</Row>
|
||||
<Row>
|
||||
<FilePdf weight="fill" size={22} color="var(--fg-2)" />
|
||||
<span className="nm">Contract.pdf</span>
|
||||
<span className="meta">2.4 MB</span>
|
||||
</Row>
|
||||
<Row>
|
||||
<FileText weight="fill" size={22} color="var(--fg-2)" />
|
||||
<span className="nm">notes.md</span>
|
||||
<span className="meta">12 KB</span>
|
||||
</Row>
|
||||
</List>
|
||||
|
||||
<MenuSurface>
|
||||
<MenuRow icon={<FolderOpen size={16} />} shortcut="⌘O">
|
||||
Open
|
||||
</MenuRow>
|
||||
<MenuRow icon={<PencilSimple size={16} />} shortcut="⏎">
|
||||
Rename
|
||||
</MenuRow>
|
||||
<MenuRow icon={<Copy size={16} />} shortcut="⌘D">
|
||||
Duplicate
|
||||
</MenuRow>
|
||||
<MenuSeparator />
|
||||
<MenuRow icon={<Tag size={16} />}>Add Tag…</MenuRow>
|
||||
<MenuRow icon={<Info size={16} />} shortcut="⌘I">
|
||||
Get Info
|
||||
</MenuRow>
|
||||
</MenuSurface>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* TOOLTIP */}
|
||||
<Section label="Tooltip">
|
||||
<Tooltip
|
||||
content={
|
||||
<>
|
||||
<Info size={14} style={{ marginRight: 5 }} />
|
||||
Tooltip — appears over floating layers
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
Hover for tooltip
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
|
||||
{/* ICON BUTTONS · SPINNER */}
|
||||
<Section label="Icon buttons · spinner">
|
||||
<div className="cluster">
|
||||
<IconButton variant="primary" aria-label="Add">
|
||||
<Plus size={16} weight="bold" />
|
||||
</IconButton>
|
||||
<IconButton aria-label="Edit">
|
||||
<PencilSimple size={15} />
|
||||
</IconButton>
|
||||
<IconButton variant="ember" aria-label="Delete">
|
||||
<Trash size={15} />
|
||||
</IconButton>
|
||||
<IconButton variant="ghost" aria-label="More">
|
||||
<DotsThree size={18} weight="bold" />
|
||||
</IconButton>
|
||||
<IconButton size="lg" aria-label="Open">
|
||||
<FolderOpen size={18} />
|
||||
</IconButton>
|
||||
<span style={{ width: 16 }} />
|
||||
<Spinner size="sm" />
|
||||
<Spinner />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* CALLOUTS */}
|
||||
<Section label="Callouts">
|
||||
<div className="stack">
|
||||
<Callout variant="info" icon={<Info size={17} weight="fill" />}>
|
||||
<strong>Heads up.</strong> Files sync automatically when you’re
|
||||
online — no manual save needed.
|
||||
</Callout>
|
||||
<Callout variant="success" icon={<CheckCircle size={17} weight="fill" />}>
|
||||
<strong>All set.</strong> Your 24 projects are backed up and
|
||||
encrypted.
|
||||
</Callout>
|
||||
<Callout variant="warning" icon={<Warning size={17} weight="fill" />}>
|
||||
<strong>Low space.</strong> 1.2 GB left on this device.
|
||||
</Callout>
|
||||
<Callout variant="danger" icon={<WarningOctagon size={17} weight="fill" />}>
|
||||
<strong>3 conflicts.</strong> Some files changed in two places at
|
||||
once.
|
||||
</Callout>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* TABLE */}
|
||||
<Section label="Table">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Owner</Th>
|
||||
<Th>Status</Th>
|
||||
<Th style={{ textAlign: 'right' }}>Size</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
<Tr selected>
|
||||
<Td>Quarterly Report.pdf</Td>
|
||||
<Td className="muted">You</Td>
|
||||
<Td>
|
||||
<Badge variant="lime">Synced</Badge>
|
||||
</Td>
|
||||
<Td className="num">2.4 MB</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>Invoices</Td>
|
||||
<Td className="muted">Mara K.</Td>
|
||||
<Td>
|
||||
<Badge variant="ember">3 conflicts</Badge>
|
||||
</Td>
|
||||
<Td className="num">812 KB</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>notes.md</Td>
|
||||
<Td className="muted">You</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral">Draft</Badge>
|
||||
</Td>
|
||||
<Td className="num">12 KB</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
</Table>
|
||||
</Section>
|
||||
|
||||
{/* SCROLL AREA */}
|
||||
<Section label="Scroll area">
|
||||
<Card style={{ padding: 0, maxWidth: 320 }}>
|
||||
<ScrollArea style={{ height: 160 }}>
|
||||
<List style={{ border: 'none', boxShadow: 'none', borderRadius: 0 }}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Row key={i}>
|
||||
<FileText weight="fill" size={20} color="var(--fg-2)" />
|
||||
<span className="nm">document-{i + 1}.txt</span>
|
||||
<span className="meta">{(i + 1) * 7} KB</span>
|
||||
</Row>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{/* DIALOGS */}
|
||||
<Section label="Dialog · alert dialog">
|
||||
<div className="cluster">
|
||||
<Dialog
|
||||
title="Rename file"
|
||||
description="Choose a new name for this item."
|
||||
trigger={<Button variant="primary">Open dialog</Button>}
|
||||
footer={
|
||||
<>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">Cancel</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button variant="primary">Save</Button>
|
||||
</DialogClose>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lab">Name</div>
|
||||
<TextField defaultValue="Quarterly Report.pdf" autoFocus />
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
title="Delete 3 files?"
|
||||
description="This permanently removes the selected files. This action cannot be undone."
|
||||
cancelLabel="Keep files"
|
||||
actionLabel="Delete"
|
||||
destructive
|
||||
trigger={
|
||||
<Button variant="ember">
|
||||
<Trash size={16} />
|
||||
Delete…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { AlertDialog as RAlertDialog } from 'radix-ui';
|
||||
import { Button } from '../button';
|
||||
|
||||
export const AlertDialog = ({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
cancelLabel = 'Cancel',
|
||||
actionLabel = 'Confirm',
|
||||
destructive,
|
||||
onAction,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
trigger?: ReactNode;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
cancelLabel?: string;
|
||||
actionLabel?: string;
|
||||
destructive?: boolean;
|
||||
onAction?: () => void;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (o: boolean) => void;
|
||||
}) => (
|
||||
<RAlertDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
||||
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
|
||||
<RAlertDialog.Portal>
|
||||
<RAlertDialog.Overlay className="mta-overlay" />
|
||||
<RAlertDialog.Content className="mta-dialog">
|
||||
<RAlertDialog.Title className="mta-dialog__title">
|
||||
{title}
|
||||
</RAlertDialog.Title>
|
||||
{description && (
|
||||
<RAlertDialog.Description className="mta-dialog__desc">
|
||||
{description}
|
||||
</RAlertDialog.Description>
|
||||
)}
|
||||
<div className="mta-dialog__footer">
|
||||
<RAlertDialog.Cancel asChild>
|
||||
<Button variant="ghost">{cancelLabel}</Button>
|
||||
</RAlertDialog.Cancel>
|
||||
<RAlertDialog.Action asChild>
|
||||
<Button
|
||||
variant={destructive ? 'ember' : 'primary'}
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</RAlertDialog.Action>
|
||||
</div>
|
||||
</RAlertDialog.Content>
|
||||
</RAlertDialog.Portal>
|
||||
</RAlertDialog.Root>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
|
||||
|
||||
export const Badge = ({
|
||||
variant = 'neutral',
|
||||
dot,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<'span'> & {
|
||||
variant?: BadgeVariant;
|
||||
dot?: boolean;
|
||||
}) => (
|
||||
<span
|
||||
className={cx(
|
||||
'mta-badge',
|
||||
`mta-badge--${variant}`,
|
||||
dot && 'mta-badge--dot',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const Chip = ({
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onRemove?: () => void;
|
||||
}) => (
|
||||
<span className="mta-chip">
|
||||
{children}
|
||||
{onRemove && (
|
||||
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export type BtnVariant = 'key' | 'primary' | 'ember' | 'ghost';
|
||||
|
||||
type ButtonProps = ComponentPropsWithoutRef<'button'> & {
|
||||
variant?: BtnVariant;
|
||||
size?: 'sm';
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ variant = 'key', size, iconOnly, className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'mta-btn',
|
||||
variant !== 'key' && `mta-btn--${variant}`,
|
||||
size === 'sm' && 'mta-btn--sm',
|
||||
iconOnly && 'mta-btn--icon',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
|
||||
|
||||
export const Callout = ({
|
||||
variant = 'info',
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
variant?: CalloutVariant;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div className={cx('mta-callout', variant !== 'info' && `mta-callout--${variant}`)}>
|
||||
{icon && <span className="mta-callout__icon">{icon}</span>}
|
||||
<div className="mta-callout__body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const Card = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
|
||||
<div className={cx('mta-card', className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Dialog as RDialog } from 'radix-ui';
|
||||
import { X } from '@phosphor-icons/react';
|
||||
import { IconButton } from '../icon-button';
|
||||
|
||||
export const Dialog = ({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
modal,
|
||||
}: {
|
||||
trigger?: ReactNode;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (o: boolean) => void;
|
||||
modal?: boolean;
|
||||
}) => (
|
||||
<RDialog.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} modal={modal}>
|
||||
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
|
||||
<RDialog.Portal>
|
||||
<RDialog.Overlay className="mta-overlay" />
|
||||
<RDialog.Content className="mta-dialog">
|
||||
<RDialog.Title className="mta-dialog__title">{title}</RDialog.Title>
|
||||
{description && (
|
||||
<RDialog.Description className="mta-dialog__desc">
|
||||
{description}
|
||||
</RDialog.Description>
|
||||
)}
|
||||
{children && <div className="mta-dialog__body">{children}</div>}
|
||||
{footer && <div className="mta-dialog__footer">{footer}</div>}
|
||||
<RDialog.Close asChild>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mta-dialog__close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
</IconButton>
|
||||
</RDialog.Close>
|
||||
</RDialog.Content>
|
||||
</RDialog.Portal>
|
||||
</RDialog.Root>
|
||||
);
|
||||
|
||||
export const DialogClose = RDialog.Close;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import { cx } from '../utils';
|
||||
import type { BtnVariant } from '../button';
|
||||
|
||||
type IconButtonProps = ComponentPropsWithoutRef<'button'> & {
|
||||
variant?: BtnVariant;
|
||||
size?: 'sm' | 'lg';
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ variant = 'key', size, className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cx(
|
||||
'mta-btn',
|
||||
'mta-iconbtn',
|
||||
variant !== 'key' && `mta-btn--${variant}`,
|
||||
size && `mta-iconbtn--${size}`,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
IconButton.displayName = 'IconButton';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const List = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
|
||||
<div className={cx('mta-list', className)} {...props} />
|
||||
);
|
||||
|
||||
export const Row = ({
|
||||
selected,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
|
||||
<div
|
||||
className={cx('mta-row', selected && 'is-selected', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { DropdownMenu as RMenu } from 'radix-ui';
|
||||
|
||||
export const Menu = RMenu.Root;
|
||||
export const MenuTrigger = RMenu.Trigger;
|
||||
|
||||
export const MenuContent = ({
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
|
||||
<RMenu.Portal>
|
||||
<RMenu.Content className="mta-menu" sideOffset={6} {...props}>
|
||||
{children}
|
||||
</RMenu.Content>
|
||||
</RMenu.Portal>
|
||||
);
|
||||
|
||||
export const MenuItem = ({
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof RMenu.Item> & {
|
||||
icon?: ReactNode;
|
||||
shortcut?: string;
|
||||
}) => (
|
||||
<RMenu.Item className="mta-menu-item" {...props}>
|
||||
{icon && <span className="ph">{icon}</span>}
|
||||
{children}
|
||||
{shortcut && <span className="sc">{shortcut}</span>}
|
||||
</RMenu.Item>
|
||||
);
|
||||
|
||||
export const MenuSeparator = () => (
|
||||
<RMenu.Separator className="mta-menu-sep" />
|
||||
);
|
||||
|
||||
export const MenuSurface = ({ children }: { children: ReactNode }) => (
|
||||
<div className="mta-menu">{children}</div>
|
||||
);
|
||||
|
||||
export const MenuRow = ({
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
shortcut?: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div className="mta-menu-item">
|
||||
{icon && <span className="ph">{icon}</span>}
|
||||
{children}
|
||||
{shortcut && <span className="sc">{shortcut}</span>}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Progress as RProgress } from 'radix-ui';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const Progress = ({
|
||||
value = 0,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof RProgress.Root>) => (
|
||||
<RProgress.Root className={cx('mta-progress', className)} value={value} {...props}>
|
||||
<RProgress.Indicator
|
||||
className="mta-progress__indicator"
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</RProgress.Root>
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { ScrollArea as RScrollArea } from 'radix-ui';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const ScrollArea = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof RScrollArea.Root>) => (
|
||||
<RScrollArea.Root className={cx('mta-scroll', className)} {...props}>
|
||||
<RScrollArea.Viewport className="mta-scroll__viewport">
|
||||
{children}
|
||||
</RScrollArea.Viewport>
|
||||
<RScrollArea.Scrollbar className="mta-scroll__bar" orientation="vertical">
|
||||
<RScrollArea.Thumb className="mta-scroll__thumb" />
|
||||
</RScrollArea.Scrollbar>
|
||||
<RScrollArea.Scrollbar className="mta-scroll__bar" orientation="horizontal">
|
||||
<RScrollArea.Thumb className="mta-scroll__thumb" />
|
||||
</RScrollArea.Scrollbar>
|
||||
<RScrollArea.Corner />
|
||||
</RScrollArea.Root>
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ComponentPropsWithoutRef, useEffect, useRef } from 'react';
|
||||
import { ToggleGroup as RToggleGroup } from 'radix-ui';
|
||||
import { cx } from '../utils';
|
||||
|
||||
type SegProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof RToggleGroup.Root>,
|
||||
'type' | 'onValueChange' | 'defaultValue' | 'value'
|
||||
> & {
|
||||
value: string;
|
||||
defaultValue?: string;
|
||||
onValueChange: (v: string) => void;
|
||||
items: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export const SegmentedControl = ({
|
||||
value,
|
||||
onValueChange,
|
||||
items,
|
||||
className,
|
||||
...props
|
||||
}: SegProps) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const thumbRef = useRef<HTMLSpanElement>(null);
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
const thumb = thumbRef.current;
|
||||
if (!root || !thumb) return;
|
||||
const selected = root.querySelector<HTMLElement>('[data-state="on"]');
|
||||
if (!selected) return;
|
||||
|
||||
if (!initialized.current) {
|
||||
thumb.style.transition = 'none';
|
||||
}
|
||||
thumb.style.transform = `translateX(${selected.offsetLeft}px)`;
|
||||
thumb.style.width = `${selected.offsetWidth}px`;
|
||||
if (!initialized.current) {
|
||||
thumb.getBoundingClientRect();
|
||||
thumb.style.transition = '';
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<RToggleGroup.Root
|
||||
ref={rootRef}
|
||||
type="single"
|
||||
className={cx('mta-seg', className)}
|
||||
value={value}
|
||||
onValueChange={(v) => v && onValueChange(v)}
|
||||
{...props}
|
||||
>
|
||||
<span ref={thumbRef} className="mta-seg__thumb" aria-hidden />
|
||||
{items.map((it) => (
|
||||
<RToggleGroup.Item
|
||||
key={it.value}
|
||||
value={it.value}
|
||||
className="mta-seg__item"
|
||||
>
|
||||
{it.label}
|
||||
</RToggleGroup.Item>
|
||||
))}
|
||||
</RToggleGroup.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Select as RSelect } from 'radix-ui';
|
||||
import { Check, CaretDown } from '@phosphor-icons/react';
|
||||
|
||||
type SelectProps = ComponentPropsWithoutRef<typeof RSelect.Root> & {
|
||||
placeholder?: string;
|
||||
items: Array<{ value: string; label: string }>;
|
||||
'aria-label'?: string;
|
||||
};
|
||||
|
||||
export const Select = ({ placeholder, items, ...rest }: SelectProps) => (
|
||||
<RSelect.Root {...rest}>
|
||||
<RSelect.Trigger className="mta-select" aria-label={rest['aria-label']}>
|
||||
<RSelect.Value placeholder={placeholder} />
|
||||
<RSelect.Icon className="mta-select__icon">
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</RSelect.Icon>
|
||||
</RSelect.Trigger>
|
||||
<RSelect.Portal>
|
||||
<RSelect.Content
|
||||
className="mta-select__content"
|
||||
position="popper"
|
||||
sideOffset={6}
|
||||
>
|
||||
<RSelect.Viewport>
|
||||
{items.map((it) => (
|
||||
<RSelect.Item
|
||||
key={it.value}
|
||||
value={it.value}
|
||||
className="mta-select__item"
|
||||
>
|
||||
<RSelect.ItemText>{it.label}</RSelect.ItemText>
|
||||
<RSelect.ItemIndicator className="mta-select__item-indicator">
|
||||
<Check size={14} weight="bold" />
|
||||
</RSelect.ItemIndicator>
|
||||
</RSelect.Item>
|
||||
))}
|
||||
</RSelect.Viewport>
|
||||
</RSelect.Content>
|
||||
</RSelect.Portal>
|
||||
</RSelect.Root>
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import {
|
||||
Switch as RSwitch,
|
||||
Checkbox as RCheckbox,
|
||||
RadioGroup as RRadioGroup,
|
||||
} from 'radix-ui';
|
||||
|
||||
export const Switch = (props: ComponentPropsWithoutRef<typeof RSwitch.Root>) => (
|
||||
<RSwitch.Root className="mta-switch" {...props}>
|
||||
<RSwitch.Thumb className="mta-switch__thumb" />
|
||||
</RSwitch.Root>
|
||||
);
|
||||
|
||||
export const Checkbox = (props: ComponentPropsWithoutRef<typeof RCheckbox.Root>) => (
|
||||
<RCheckbox.Root className="mta-check" {...props}>
|
||||
<RCheckbox.Indicator className="mta-check__indicator">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--lime-ink)"
|
||||
strokeWidth={3.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 12l5 5L20 6" />
|
||||
</svg>
|
||||
</RCheckbox.Indicator>
|
||||
</RCheckbox.Root>
|
||||
);
|
||||
|
||||
export const RadioGroup = RRadioGroup.Root;
|
||||
|
||||
export const RadioItem = ({
|
||||
value,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
|
||||
<RRadioGroup.Item className="mta-radio" value={value} {...props}>
|
||||
<RRadioGroup.Indicator className="mta-radio__indicator" />
|
||||
</RRadioGroup.Item>
|
||||
);
|
||||
|
||||
export const Control = ({
|
||||
children,
|
||||
control,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
control: ReactNode;
|
||||
}) => (
|
||||
<label className="mta-control">
|
||||
{control}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
|
||||
import { Slider as RSlider } from 'radix-ui';
|
||||
|
||||
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.
|
||||
* - `true` — auto-generate one mark per `step` between `min` and `max`.
|
||||
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
|
||||
*/
|
||||
marks?: MarksProp;
|
||||
/**
|
||||
* Where to draw the notch ticks relative to the track.
|
||||
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
|
||||
* (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.
|
||||
*/
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function resolveMarks(
|
||||
marks: MarksProp,
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
): Mark[] {
|
||||
if (Array.isArray(marks)) {
|
||||
return marks
|
||||
.map((m) => (typeof m === 'number' ? { value: m } : m))
|
||||
.filter((m) => m.value >= min && m.value <= max);
|
||||
}
|
||||
if (marks !== true) return [];
|
||||
if (!(step > 0) || max <= min) return [];
|
||||
const count = Math.floor((max - min) / step);
|
||||
// Guard against absurd notch counts (e.g. step=1 over a 0–1000 range).
|
||||
if (count < 1 || count > 100) return [];
|
||||
return Array.from({ length: count + 1 }, (_, i) => ({ value: min + i * step }));
|
||||
}
|
||||
|
||||
const percent = (value: number, min: number, max: number) =>
|
||||
max === min ? 0 : (value - min) / (max - min);
|
||||
|
||||
const NotchLayer = ({
|
||||
marks,
|
||||
min,
|
||||
max,
|
||||
side,
|
||||
}: {
|
||||
marks: Mark[];
|
||||
min: number;
|
||||
max: number;
|
||||
side: 'top' | 'bottom';
|
||||
}) => (
|
||||
<div className={`mta-slider__notches mta-slider__notches--${side}`} aria-hidden>
|
||||
{marks.map((mark) => (
|
||||
<span
|
||||
key={mark.value}
|
||||
className="mta-slider__notch"
|
||||
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Slider = ({
|
||||
marks,
|
||||
notches = 'bottom',
|
||||
knobStyle = 'square',
|
||||
animated,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
className,
|
||||
...props
|
||||
}: SliderProps) => {
|
||||
const resolved = marks != null ? resolveMarks(marks, min, max, step) : [];
|
||||
const hasMarks = resolved.length > 0;
|
||||
const hasLabels = resolved.some((m) => m.label != null);
|
||||
const showTop = hasMarks && (notches === 'top' || notches === 'both');
|
||||
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
|
||||
const isAnimated = animated !== undefined ? animated : hasMarks;
|
||||
|
||||
const cls = [
|
||||
'mta-slider',
|
||||
`mta-slider--knob-${knobStyle}`,
|
||||
isAnimated && 'mta-slider--animated',
|
||||
hasMarks && 'mta-slider--has-marks',
|
||||
hasLabels && 'mta-slider--has-labels',
|
||||
showTop && 'mta-slider--notch-top',
|
||||
showBottom && 'mta-slider--notch-bottom',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<RSlider.Root className={cls} min={min} max={max} step={step} {...props}>
|
||||
<RSlider.Track className="mta-slider__track">
|
||||
<RSlider.Range className="mta-slider__range" />
|
||||
{showTop && <NotchLayer marks={resolved} min={min} max={max} side="top" />}
|
||||
{showBottom && <NotchLayer marks={resolved} min={min} max={max} side="bottom" />}
|
||||
{hasLabels && (
|
||||
<div className="mta-slider__labels" aria-hidden>
|
||||
{resolved.map((mark) =>
|
||||
mark.label != null ? (
|
||||
<span
|
||||
key={mark.value}
|
||||
className="mta-slider__label"
|
||||
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</RSlider.Track>
|
||||
<RSlider.Thumb className="mta-slider__thumb" aria-label="Value" />
|
||||
</RSlider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const Stepper = ({
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}: {
|
||||
onDecrement: () => void;
|
||||
onIncrement: () => void;
|
||||
}) => (
|
||||
<div className="mta-stepper">
|
||||
<button type="button" onClick={onDecrement} aria-label="Decrease">
|
||||
−
|
||||
</button>
|
||||
<button type="button" onClick={onIncrement} aria-label="Increase">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const Spinner = ({
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cx('mta-spinner', size && `mta-spinner--${size}`, className)}
|
||||
{...props}
|
||||
>
|
||||
<svg viewBox="0 0 36 36" fill="none">
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
stroke="var(--color-bg-muted)"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
className="mta-spinner__arc"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="14"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="22 88"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const Table = ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => (
|
||||
<div className="mta-table-wrap">
|
||||
<table className="mta-table" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const THead = (p: ComponentPropsWithoutRef<'thead'>) => <thead {...p} />;
|
||||
export const TBody = (p: ComponentPropsWithoutRef<'tbody'>) => <tbody {...p} />;
|
||||
|
||||
export const Tr = ({
|
||||
selected,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<'tr'> & { selected?: boolean }) => (
|
||||
<tr className={cx(selected && 'is-selected', className)} {...props} />
|
||||
);
|
||||
|
||||
export const Th = (p: ComponentPropsWithoutRef<'th'>) => <th {...p} />;
|
||||
export const Td = (p: ComponentPropsWithoutRef<'td'>) => <td {...p} />;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import { Tabs as RTabs } from 'radix-ui';
|
||||
import { cx } from '../utils';
|
||||
|
||||
export const Tabs = RTabs.Root;
|
||||
|
||||
export const TabsList = ({
|
||||
items,
|
||||
className,
|
||||
...props
|
||||
}: { items: Array<{ value: string; label: string }> } & Omit<
|
||||
ComponentPropsWithoutRef<typeof RTabs.List>,
|
||||
'children'
|
||||
>) => (
|
||||
<RTabs.List className={cx('mta-tabs', className)} {...props}>
|
||||
{items.map((it) => (
|
||||
<RTabs.Trigger
|
||||
key={it.value}
|
||||
value={it.value}
|
||||
className="mta-tabs__trigger"
|
||||
>
|
||||
{it.label}
|
||||
</RTabs.Trigger>
|
||||
))}
|
||||
</RTabs.List>
|
||||
);
|
||||
|
||||
export const TabsContent = RTabs.Content;
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} 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 };
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
TextArea.displayName = 'TextArea';
|
||||
|
||||
export const SearchField = ({
|
||||
icon,
|
||||
...props
|
||||
}: TextFieldProps & { icon: ReactNode }) => (
|
||||
<div className="mta-search">
|
||||
<span className="ph">{icon}</span>
|
||||
<TextField {...props} />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
const KEY = 'mta-theme';
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: ThemeMode;
|
||||
setTheme: (t: ThemeMode) => void;
|
||||
}>({ theme: 'light', setTheme: () => {} });
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => {
|
||||
if (typeof localStorage === 'undefined') return 'light';
|
||||
return (localStorage.getItem(KEY) as ThemeMode) || 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||
import { Tooltip as RTooltip } from 'radix-ui';
|
||||
import { cx } from '../utils';
|
||||
|
||||
type TooltipProps = {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
delayDuration?: number;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (o: boolean) => void;
|
||||
} & Omit<ComponentPropsWithoutRef<typeof RTooltip.Content>, 'children' | 'content'>;
|
||||
|
||||
export const Tooltip = ({
|
||||
content,
|
||||
children,
|
||||
delayDuration,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
sideOffset = 6,
|
||||
className,
|
||||
...contentProps
|
||||
}: TooltipProps) => (
|
||||
<RTooltip.Root
|
||||
delayDuration={delayDuration}
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
|
||||
<RTooltip.Portal>
|
||||
<RTooltip.Content
|
||||
className={cx('mta-tooltip', className)}
|
||||
sideOffset={sideOffset}
|
||||
{...contentProps}
|
||||
>
|
||||
{content}
|
||||
</RTooltip.Content>
|
||||
</RTooltip.Portal>
|
||||
</RTooltip.Root>
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from './button';
|
||||
export * from './icon-button';
|
||||
export * from './text-field';
|
||||
export * from './select';
|
||||
export * from './selection';
|
||||
export * from './segmented-control';
|
||||
export * from './slider';
|
||||
export * from './tabs';
|
||||
export * from './progress';
|
||||
export * from './badge';
|
||||
export * from './card';
|
||||
export * from './list';
|
||||
export * from './menu';
|
||||
export * from './tooltip';
|
||||
export * from './spinner';
|
||||
export * from './callout';
|
||||
export * from './table';
|
||||
export * from './scroll-area';
|
||||
export * from './dialog';
|
||||
export * from './alert-dialog';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const cx = (...c: Array<string | false | undefined>) =>
|
||||
c.filter(Boolean).join(' ');
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
|
||||
/**
|
||||
* Imports the SVG file as a React component.
|
||||
* @requires [@rsbuild/plugin-svgr](https://npmjs.com/package/@rsbuild/plugin-svgr)
|
||||
*/
|
||||
declare module '*.svg?react' {
|
||||
import type React from 'react';
|
||||
const ReactComponent: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export default ReactComponent;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* ============================================================
|
||||
MtAir — public package entry.
|
||||
Import components from here; import the stylesheet once at your
|
||||
app root: import '@olly/mt-air/styles.css';
|
||||
Optionally add the branded fonts: import '@olly/mt-air/fonts.css';
|
||||
============================================================ */
|
||||
import { Tooltip } from 'radix-ui';
|
||||
|
||||
export * from './components/ui';
|
||||
export { ThemeProvider, useTheme } from './components/theme';
|
||||
|
||||
/* Tooltips need a single provider near your app root. */
|
||||
export const TooltipProvider = Tooltip.Provider;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Tooltip } from 'radix-ui';
|
||||
import App from './App';
|
||||
import { ThemeProvider } from './components/theme';
|
||||
import './styles/global.css';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (rootEl) {
|
||||
const root = ReactDOM.createRoot(rootEl);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<App />
|
||||
</Tooltip.Provider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { AlertDialog, Button } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Overlays/AlertDialog',
|
||||
component: AlertDialog,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Confirmation dialog built on Radix AlertDialog. Use for destructive or irreversible actions — focus is trapped and the cancel button is always reachable. Set `destructive` to switch the action button to the ember (red) variant.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
title: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
cancelLabel: { control: 'text' },
|
||||
actionLabel: { control: 'text' },
|
||||
destructive: { control: 'boolean' },
|
||||
trigger: { control: false },
|
||||
},
|
||||
args: {
|
||||
title: 'Delete this project?',
|
||||
description: 'This action cannot be undone.',
|
||||
actionLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
destructive: true,
|
||||
trigger: <Button variant="ember">Delete…</Button>,
|
||||
onAction: () => {},
|
||||
},
|
||||
} satisfies Meta<typeof AlertDialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Destructive: Story = {
|
||||
render: () => (
|
||||
<AlertDialog
|
||||
trigger={<Button variant="ember">Delete account</Button>}
|
||||
title="Delete your account?"
|
||||
description="All data will be permanently removed. This cannot be undone."
|
||||
actionLabel="Delete account"
|
||||
cancelLabel="Keep account"
|
||||
destructive
|
||||
onAction={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Confirm: Story = {
|
||||
render: () => (
|
||||
<AlertDialog
|
||||
trigger={<Button variant="primary">Publish</Button>}
|
||||
title="Publish changes?"
|
||||
description="This will make your changes visible to all users."
|
||||
actionLabel="Publish"
|
||||
cancelLabel="Cancel"
|
||||
onAction={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Button } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Inputs/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Tactile push button. Four variants and an optional small size; everything else is a native `<button>`, so all standard button props pass through.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'inline-radio',
|
||||
options: ['key', 'primary', 'ember', 'ghost'],
|
||||
description: 'Visual emphasis. `key` is the default neutral button.',
|
||||
},
|
||||
size: {
|
||||
control: 'inline-radio',
|
||||
options: [undefined, 'sm'],
|
||||
description: 'Omit for default; `sm` for the compact size.',
|
||||
},
|
||||
iconOnly: { control: 'boolean', description: 'Square padding for a single glyph.' },
|
||||
disabled: { control: 'boolean' },
|
||||
children: { control: 'text' },
|
||||
className: { control: 'text' },
|
||||
},
|
||||
args: { children: 'Button', variant: 'key' },
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Button variant="key">Key</Button>
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="ember">Ember</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button variant="primary">Default</Button>
|
||||
<Button variant="primary" size="sm">
|
||||
Small
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true, variant: 'primary', children: 'Disabled' },
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import {
|
||||
Card,
|
||||
List,
|
||||
Row,
|
||||
Table,
|
||||
THead,
|
||||
TBody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Badge,
|
||||
Chip,
|
||||
} from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Data Display/Surfaces',
|
||||
component: Card,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Cards, selectable lists/rows, badges, chips, and the bordered table.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
className: { control: 'text' },
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const CardPlayground: Story = {
|
||||
name: 'Card',
|
||||
args: { children: 'Card content' },
|
||||
render: (args) => (
|
||||
<Card {...args} style={{ maxWidth: 320, padding: 20 }}>
|
||||
<h3 className="mta-h3">Storage</h3>
|
||||
<p className="mta-body">128 GB of 256 GB used.</p>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const ListRows: Story = {
|
||||
render: () => (
|
||||
<List style={{ width: 320 }}>
|
||||
<Row selected>General</Row>
|
||||
<Row>Appearance</Row>
|
||||
<Row>Notifications</Row>
|
||||
<Row>Privacy</Row>
|
||||
</List>
|
||||
),
|
||||
};
|
||||
|
||||
export const BadgeShowcase: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Badge variant="lime">Lime</Badge>
|
||||
<Badge variant="ember">Ember</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
<Badge variant="lime" dot>Online</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chips: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<Chip>Design</Chip>
|
||||
<Chip onRemove={() => {}}>Removable</Chip>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DataTable: Story = {
|
||||
render: () => (
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Device</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Battery</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
<Tr selected>
|
||||
<Td>MacBook Pro</Td>
|
||||
<Td><Badge variant="lime" dot>Online</Badge></Td>
|
||||
<Td>82%</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td>iPhone 16</Td>
|
||||
<Td><Badge variant="neutral">Idle</Badge></Td>
|
||||
<Td>54%</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Dialog, Button, TextField } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Overlays/Dialog',
|
||||
component: Dialog,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Modal dialog built on Radix Dialog. Pass a `trigger` to wire open/close automatically, or control it with `open` / `onOpenChange`. Title is required; description, body, and footer are optional slots.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
title: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
trigger: { control: false },
|
||||
children: { control: false },
|
||||
footer: { control: false },
|
||||
},
|
||||
args: {
|
||||
title: 'Rename project',
|
||||
description: 'Choose a new name for this project.',
|
||||
trigger: <Button variant="primary">Open dialog</Button>,
|
||||
},
|
||||
} satisfies Meta<typeof Dialog>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithBody: Story = {
|
||||
name: 'With body',
|
||||
render: () => (
|
||||
<Dialog
|
||||
trigger={<Button variant="primary">Open dialog</Button>}
|
||||
title="Rename project"
|
||||
description="Choose a new name for this project."
|
||||
footer={<Button variant="primary">Save</Button>}
|
||||
>
|
||||
<TextField placeholder="Project name" defaultValue="My project" />
|
||||
</Dialog>
|
||||
),
|
||||
};
|
||||
|
||||
export const NoDescription: Story = {
|
||||
name: 'No description',
|
||||
args: {
|
||||
description: undefined,
|
||||
title: 'Confirm action',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Info, CheckCircle, Warning, XCircle } from '@phosphor-icons/react';
|
||||
import { Progress, Spinner, Callout, Badge, Chip, Button } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Feedback/Status',
|
||||
component: Progress,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Progress bar, spinner, callouts, badges and chips — the status + signalling family.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
|
||||
className: { control: 'text' },
|
||||
},
|
||||
args: { value: 40 },
|
||||
} satisfies Meta<typeof Progress>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
render: (args) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<Progress {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
function ProgressDemo() {
|
||||
const [v, setV] = useState(40);
|
||||
return (
|
||||
<div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Progress value={v} />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="sm" onClick={() => setV((x) => Math.max(0, x - 10))}>−10</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => setV((x) => Math.min(100, x + 10))}>+10</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProgressBar: Story = { render: () => <ProgressDemo /> };
|
||||
|
||||
export const Spinners: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Spinner size="sm" />
|
||||
<Spinner />
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Callouts: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 460 }}>
|
||||
<Callout variant="info" icon={<Info size={18} />}>Sync runs in the background.</Callout>
|
||||
<Callout variant="success" icon={<CheckCircle size={18} />}>All changes saved.</Callout>
|
||||
<Callout variant="warning" icon={<Warning size={18} />}>Storage is almost full.</Callout>
|
||||
<Callout variant="danger" icon={<XCircle size={18} />}>Failed to reach the server.</Callout>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Badges: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Badge variant="lime">Lime</Badge>
|
||||
<Badge variant="ember">Ember</Badge>
|
||||
<Badge variant="neutral">Neutral</Badge>
|
||||
<Badge variant="outline">Outline</Badge>
|
||||
<Badge variant="lime" dot>Online</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Chips: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<Chip>Design</Chip>
|
||||
<Chip onRemove={() => {}}>Removable</Chip>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Gear, Plus, Trash } from '@phosphor-icons/react';
|
||||
import { IconButton } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Inputs/IconButton',
|
||||
component: IconButton,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Square button for a single icon. Shares the Button variants; sizes are `sm` / default / `lg`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'inline-radio',
|
||||
options: ['key', 'primary', 'ember', 'ghost'],
|
||||
description: 'Visual emphasis. `key` is the default neutral button.',
|
||||
},
|
||||
size: {
|
||||
control: 'inline-radio',
|
||||
options: ['sm', undefined, 'lg'],
|
||||
description: 'Button size: `sm` compact, default regular, `lg` large.',
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof IconButton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
args: { variant: 'key', children: <Gear size={18} /> },
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<IconButton variant="key" aria-label="settings"><Gear size={18} /></IconButton>
|
||||
<IconButton variant="primary" aria-label="add"><Plus size={18} weight="bold" /></IconButton>
|
||||
<IconButton variant="ember" aria-label="delete"><Trash size={18} /></IconButton>
|
||||
<IconButton variant="ghost" aria-label="settings"><Gear size={18} /></IconButton>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<IconButton size="sm" variant="primary" aria-label="add"><Plus size={14} weight="bold" /></IconButton>
|
||||
<IconButton variant="primary" aria-label="add"><Plus size={18} weight="bold" /></IconButton>
|
||||
<IconButton size="lg" variant="primary" aria-label="add"><Plus size={22} weight="bold" /></IconButton>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Getting Started/Introduction" />
|
||||
|
||||
# MtAir
|
||||
|
||||
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.
|
||||
|
||||
This Storybook is the **development playground** — it is never published with the
|
||||
package. Use it to browse every component, read its prop table (the **Docs** tab on
|
||||
each story), and try props live in the **Controls** panel.
|
||||
|
||||
## Using the library in an app
|
||||
|
||||
```tsx
|
||||
import '@olly/mt-air/styles.css'; // required — tokens + components
|
||||
import '@olly/mt-air/fonts.css'; // optional — branded faces
|
||||
import { ThemeProvider, TooltipProvider, Button } from '@olly/mt-air';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Button variant="primary">Click</Button>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Theme
|
||||
|
||||
Use the **Theme** toggle in the toolbar above to flip every story between light and
|
||||
dark. In an app the same lever is `data-theme` on `<html>`, managed by
|
||||
`ThemeProvider` / `useTheme()`.
|
||||
|
||||
## Fonts
|
||||
|
||||
Components read the `--mta-font-sans` and `--mta-font-mono` tokens. Import
|
||||
`@olly/mt-air/fonts.css` for the branded faces (Plus Jakarta Sans + Fira Code), or
|
||||
override those tokens to map the components onto fonts your app already loads.
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Copy, Scissors, Trash, ArrowCounterClockwise } from '@phosphor-icons/react';
|
||||
import {
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
Dialog,
|
||||
AlertDialog,
|
||||
Button,
|
||||
} from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Overlays/Floating',
|
||||
component: Tooltip,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Floating surfaces — Tooltip, dropdown Menu, Dialog and AlertDialog. All are Radix-backed and portal-rendered.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
children: { control: false },
|
||||
content: { control: 'text' },
|
||||
delayDuration: { control: 'number' },
|
||||
open: { control: 'boolean' },
|
||||
defaultOpen: { control: 'boolean' },
|
||||
onOpenChange: { action: 'open changed' },
|
||||
sideOffset: { control: 'number' },
|
||||
},
|
||||
args: {
|
||||
content: 'Tooltip text',
|
||||
children: null,
|
||||
delayDuration: 0,
|
||||
},
|
||||
} satisfies Meta<typeof Tooltip>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
name: 'Tooltip Playground',
|
||||
render: (args) => (
|
||||
<Tooltip {...args}>
|
||||
<Button variant="ghost">Hover me</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
|
||||
export const TooltipStory: Story = {
|
||||
name: 'Tooltip',
|
||||
render: () => (
|
||||
<Tooltip content="Saved to iCloud">
|
||||
<Button variant="ghost">Hover me</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
|
||||
export const DropdownMenu: Story = {
|
||||
render: () => (
|
||||
<Menu>
|
||||
<MenuTrigger asChild>
|
||||
<Button>Open menu</Button>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem icon={<Copy size={16} />} shortcut="⌘C">Copy</MenuItem>
|
||||
<MenuItem icon={<Scissors size={16} />} shortcut="⌘X">Cut</MenuItem>
|
||||
<MenuItem icon={<ArrowCounterClockwise size={16} />} shortcut="⌘Z">Undo</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem icon={<Trash size={16} />}>Delete</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
),
|
||||
};
|
||||
|
||||
export const ModalDialog: Story = {
|
||||
render: () => (
|
||||
<Dialog
|
||||
trigger={<Button variant="primary">Open dialog</Button>}
|
||||
title="Rename project"
|
||||
description="Choose a new name for this project."
|
||||
footer={<Button variant="primary">Save</Button>}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Confirm: Story = {
|
||||
render: () => (
|
||||
<AlertDialog
|
||||
trigger={<Button variant="ember">Delete…</Button>}
|
||||
title="Delete this project?"
|
||||
description="This action cannot be undone."
|
||||
actionLabel="Delete"
|
||||
destructive
|
||||
onAction={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { ScrollArea } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Layout/ScrollArea',
|
||||
component: ScrollArea,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Radix ScrollArea with custom styled scrollbars. Wraps content in a viewport with vertical and horizontal scrollbars.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
children: { control: false },
|
||||
className: { control: 'text' },
|
||||
},
|
||||
} satisfies Meta<typeof ScrollArea>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => (
|
||||
<ScrollArea style={{ width: 300, height: 200, border: '1px solid var(--neutral-border)' }}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<h4 className="mta-h4" style={{ marginBottom: 8 }}>Scrollable content</h4>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<p key={i} className="mta-body" style={{ marginBottom: 12 }}>
|
||||
Item {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
),
|
||||
};
|
||||
|
||||
export const Vertical: Story = {
|
||||
render: () => (
|
||||
<ScrollArea style={{ width: 280, height: 150, border: '1px solid var(--neutral-border)' }}>
|
||||
<div style={{ padding: 12 }}>
|
||||
{Array.from({ length: 30 }).map((_, i) => (
|
||||
<div key={i} style={{ padding: 8, borderBottom: '1px solid var(--neutral-border)' }}>
|
||||
Row {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
),
|
||||
};
|
||||
|
||||
export const Horizontal: Story = {
|
||||
render: () => (
|
||||
<ScrollArea style={{ width: 320, height: 80, border: '1px solid var(--neutral-border)' }}>
|
||||
<div style={{ display: 'flex', gap: 8, padding: 12, width: 'fit-content' }}>
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: 8,
|
||||
minWidth: 100,
|
||||
background: 'var(--neutral-surface)',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Item {i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { SegmentedControl } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Selection/SegmentedControl',
|
||||
component: SegmentedControl,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Single-select button group with animated thumb. Pass `items` array of `{ value, label }` objects, plus `value` and `onValueChange` for control.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
value: { control: 'text' },
|
||||
onValueChange: { action: 'value changed' },
|
||||
items: { control: false },
|
||||
className: { control: 'text' },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof SegmentedControl>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function SegmentedDemo() {
|
||||
const [v, setV] = useState('day');
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={v}
|
||||
onValueChange={setV}
|
||||
items={[
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <SegmentedDemo />,
|
||||
};
|
||||
|
||||
export const TimeRange: Story = {
|
||||
render: () => <SegmentedDemo />,
|
||||
};
|
||||
|
||||
function OptionsDemo() {
|
||||
const [v, setV] = useState('draft');
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={v}
|
||||
onValueChange={setV}
|
||||
items={[
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Options: Story = {
|
||||
render: () => <OptionsDemo />,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Select } from '../components/ui';
|
||||
|
||||
const items = [
|
||||
{ value: 'sequoia', label: 'Sequoia' },
|
||||
{ value: 'sonoma', label: 'Sonoma' },
|
||||
{ value: 'ventura', label: 'Ventura' },
|
||||
{ value: 'monterey', label: 'Monterey' },
|
||||
];
|
||||
|
||||
const meta = {
|
||||
title: 'Inputs/Select',
|
||||
component: Select,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Radix Select in the MtAir skin. Pass `items` plus an optional `placeholder`; control it with `value` / `onValueChange` or leave it uncontrolled with `defaultValue`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
items: { control: false },
|
||||
placeholder: { control: 'text' },
|
||||
disabled: { control: 'boolean' },
|
||||
defaultValue: { control: 'text' },
|
||||
value: { control: 'text' },
|
||||
onValueChange: { action: 'value changed' },
|
||||
'aria-label': { control: 'text' },
|
||||
},
|
||||
args: { items, placeholder: 'Pick a release…', 'aria-label': 'macOS release' },
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const WithDefault: Story = { args: { defaultValue: 'sonoma' } };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import {
|
||||
Switch,
|
||||
Checkbox,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
SegmentedControl,
|
||||
Control,
|
||||
} from '../components/ui';
|
||||
|
||||
/* Grouped selection controls. Anchored on Switch for the docgen table;
|
||||
the stories below showcase each control. */
|
||||
const meta = {
|
||||
title: 'Selection/Controls',
|
||||
component: Switch,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Toggle, checkbox, radio group and segmented control. The `Control` helper pairs any of them with a clickable label.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
defaultChecked: { control: 'boolean' },
|
||||
checked: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
onCheckedChange: { action: 'checked changed' },
|
||||
},
|
||||
} satisfies Meta<typeof Switch>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
args: { defaultChecked: false },
|
||||
};
|
||||
|
||||
export const Switches: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
|
||||
<Switch defaultChecked />
|
||||
<Switch />
|
||||
<Control control={<Switch defaultChecked />}>Wi-Fi</Control>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Checkboxes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Control control={<Checkbox defaultChecked />}>Sync to iCloud</Control>
|
||||
<Control control={<Checkbox />}>Share analytics</Control>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Radios: Story = {
|
||||
render: () => (
|
||||
<RadioGroup defaultValue="comfortable" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Control control={<RadioItem value="compact" />}>Compact</Control>
|
||||
<Control control={<RadioItem value="comfortable" />}>Comfortable</Control>
|
||||
<Control control={<RadioItem value="spacious" />}>Spacious</Control>
|
||||
</RadioGroup>
|
||||
),
|
||||
};
|
||||
|
||||
function SegmentedDemo() {
|
||||
const [v, setV] = useState('day');
|
||||
return (
|
||||
<SegmentedControl
|
||||
value={v}
|
||||
onValueChange={setV}
|
||||
items={[
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Segmented: Story = { render: () => <SegmentedDemo /> };
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Slider } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Inputs/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Radix Slider in the carved-track skin. All Radix Slider props pass through (`defaultValue`, `min`, `max`, `step`, `onValueChange`). Set `marks` to carve step notches into the track — `marks` snaps to the Radix `step`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: { defaultValue: [60], min: 0, max: 100, step: 1 },
|
||||
argTypes: {
|
||||
defaultValue: { control: 'object', description: 'Uncontrolled starting value(s).' },
|
||||
min: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
step: { control: 'number', description: 'Snap increment (also drives auto `marks`).' },
|
||||
disabled: { control: 'boolean' },
|
||||
marks: {
|
||||
control: 'boolean',
|
||||
description: 'Step marks. `true` derives one per `step`; or pass an array for custom/labelled marks.',
|
||||
},
|
||||
notches: {
|
||||
control: 'inline-radio',
|
||||
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>],
|
||||
} satisfies Meta<typeof Slider>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
/** `marks` auto-derives one notch per `step`. */
|
||||
export const Stepped: Story = {
|
||||
args: { defaultValue: [40], step: 10, marks: true },
|
||||
};
|
||||
|
||||
/** `notches='both'` carves ticks above and below the bar. */
|
||||
export const NotchesBoth: Story = {
|
||||
args: { defaultValue: [60], step: 20, marks: true, notches: 'both' },
|
||||
};
|
||||
|
||||
/** Pass an array of `{ value, label }` for labelled ticks. */
|
||||
export const LabelledMarks: Story = {
|
||||
args: {
|
||||
defaultValue: [50],
|
||||
step: 25,
|
||||
notches: 'bottom',
|
||||
marks: [
|
||||
{ value: 0, label: 'Off' },
|
||||
{ value: 25, label: 'Low' },
|
||||
{ value: 50, label: 'Mid' },
|
||||
{ value: 75, label: 'High' },
|
||||
{ value: 100, label: 'Max' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { defaultValue: [30], step: 10, marks: true, disabled: true },
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { Tabs, TabsList, TabsContent } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Navigation/Tabs',
|
||||
component: Tabs,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Radix Tabs. `Tabs` is the root, `TabsList` takes `items`, and `TabsContent` matches each `value`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
defaultValue: { control: 'text' },
|
||||
value: { control: 'text' },
|
||||
onValueChange: { action: 'value changed' },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
} satisfies Meta<typeof Tabs>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="overview" style={{ width: 420 }}>
|
||||
<TabsList
|
||||
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>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react';
|
||||
import { TextField, TextArea, SearchField } from '../components/ui';
|
||||
|
||||
const meta = {
|
||||
title: 'Inputs/TextField',
|
||||
component: TextField,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Sunken text input. `TextField`, `TextArea`, and `SearchField` (icon + input) share the `mta-field` look and forward all native props.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
placeholder: { control: 'text' },
|
||||
value: { control: 'text' },
|
||||
defaultValue: { control: 'text' },
|
||||
disabled: { control: 'boolean' },
|
||||
readOnly: { control: 'boolean' },
|
||||
required: { control: 'boolean' },
|
||||
type: { control: 'text' },
|
||||
onChange: { action: 'changed' },
|
||||
},
|
||||
args: { placeholder: 'Type here…' },
|
||||
} satisfies Meta<typeof TextField>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Disabled: Story = { args: { disabled: true, value: 'Read only' } };
|
||||
|
||||
export const Multiline: Story = {
|
||||
render: () => <TextArea rows={4} placeholder="Multiple lines…" style={{ width: 320 }} />,
|
||||
};
|
||||
|
||||
export const Search: Story = {
|
||||
render: () => (
|
||||
<SearchField
|
||||
icon={<MagnifyingGlass size={16} />}
|
||||
placeholder="Search…"
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
/* ============================================================
|
||||
MtAir — optional branded webfonts.
|
||||
Import this ONLY if you want the default MtAir typefaces:
|
||||
|
||||
import '@olly/mt-air/styles.css'; // required — tokens + components
|
||||
import '@olly/mt-air/fonts.css'; // optional — branded faces
|
||||
|
||||
Skip it and the --mta-font-* tokens degrade to system-ui, or
|
||||
override --mta-font-sans / --mta-font-mono in your own CSS to
|
||||
map components onto fonts your app already loads.
|
||||
------------------------------------------------------------
|
||||
Plus Jakarta Sans (variable, 200–800) and Fira Code (mono) are
|
||||
self-hosted and inlined at build time.
|
||||
============================================================ */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('../assets/fonts/PlusJakartaSans-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 200 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Plus Jakarta Sans';
|
||||
src: url('../assets/fonts/PlusJakartaSans-Italic-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 200 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Fira Code — monospace (code, tokens, metadata) ────────────── */
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('../assets/fonts/FiraCode-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('../assets/fonts/FiraCode-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('../assets/fonts/FiraCode-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('../assets/fonts/FiraCode-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('../assets/fonts/FiraCode-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@import './fonts.css';
|
||||
@import './tokens.css';
|
||||
@import './components.css';
|
||||
|
||||
*{ box-sizing:border-box; margin:0; padding:0; }
|
||||
html{ height:100%; }
|
||||
body{
|
||||
min-height:100vh;
|
||||
font-family:var(--font-sans);
|
||||
color:var(--fg-1);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
|
||||
/* Kitchen Sink layout helpers (ported from the prototype's inline <style>) */
|
||||
.wrap{ max-width:1100px; margin:0 auto; padding:64px 40px 120px; position:relative; z-index:1; }
|
||||
|
||||
.word{ font-family:var(--font-display); font-size:30px; letter-spacing:.01em; text-transform:uppercase; }
|
||||
.word b{ color:var(--lime); font-weight:400; }
|
||||
.sub{ color:var(--fg-2); font-size:15px; margin-top:8px; max-width:560px; line-height:1.5; }
|
||||
.topbar{ display:flex; align-items:flex-start; justify-content:space-between; gap:24px; }
|
||||
|
||||
section{ margin-top:52px; }
|
||||
.sec-label{ font-size:11px; font-weight:600; letter-spacing:.22em; text-transform:uppercase; color:var(--fg-3); margin-bottom:20px; display:flex; align-items:center; gap:12px; }
|
||||
.sec-label::after{ content:""; flex:1; height:1px; background:var(--hair); }
|
||||
|
||||
.grid{ display:grid; gap:18px; }
|
||||
.cluster{ display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
|
||||
.stack{ display:flex; flex-direction:column; gap:14px; }
|
||||
.cap{ font-size:11px; font-weight:600; color:var(--fg-3); text-transform:uppercase; letter-spacing:.14em; margin-bottom:10px; }
|
||||
.lab{ font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.18em; color:var(--fg-3); margin-bottom:5px; }
|
||||
.two{ display:grid; grid-template-columns:1fr 1fr; gap:32px; }
|
||||
.three{ display:grid; grid-template-columns:repeat(3,1fr); gap:24px; align-items:start; }
|
||||
.panel{ padding:20px; }
|
||||
@@ -0,0 +1,19 @@
|
||||
/* ============================================================
|
||||
MtAir — shippable stylesheet.
|
||||
Tokens + component styles only. No demo/page layout, no global
|
||||
reset of consumer elements. No fonts: import '@olly/mt-air/fonts.css'
|
||||
for the branded faces, or override the --mta-font-* tokens.
|
||||
============================================================ */
|
||||
@import './tokens.css';
|
||||
@import './components.css';
|
||||
|
||||
/* Non-invasive box-sizing + branded font for our own components only
|
||||
(zero specificity, so consumer elements are never touched and the
|
||||
--font-mono / --font-display classes still win by class specificity).
|
||||
This is what carries the typeface onto portalled content (tooltips,
|
||||
menus, dialogs) and bare text nodes (control labels) that never set
|
||||
their own font-family. */
|
||||
:where([class^='mta-'], [class*=' mta-']) {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/* ============================================================
|
||||
MtAir — Design Tokens (ported from the MtAir Design System)
|
||||
Warm, airy, light-first design system for MedipalTech Air.
|
||||
------------------------------------------------------------
|
||||
Single source of truth. Every component reads from here.
|
||||
Fonts are NOT loaded here — see `fonts.css` for the branded
|
||||
Plus Jakarta Sans / Fira Code faces. The --mta-font-* chains
|
||||
below degrade to system-ui when that file is not imported.
|
||||
============================================================ */
|
||||
|
||||
/* ---------- COLOR ----------
|
||||
Warm, natural palette: health clarity, air, simplicity. */
|
||||
:root {
|
||||
/* ── Green (Primary brand — forest green) ──────────────────── */
|
||||
--mta-green-50: #EFF7F2;
|
||||
--mta-green-100: #D4EADE;
|
||||
--mta-green-200: #A8D5BE;
|
||||
--mta-green-300: #75BE9C;
|
||||
--mta-green-400: #46A579;
|
||||
--mta-green-500: #298D60;
|
||||
--mta-green-600: #1E6E48; /* ← brand primary */
|
||||
--mta-green-700: #155337;
|
||||
--mta-green-800: #0D3924;
|
||||
--mta-green-900: #062113;
|
||||
|
||||
/* ── Amber (Warm accent — copper/honey) ────────────────────── */
|
||||
--mta-amber-50: #FFF8EE;
|
||||
--mta-amber-100: #FEECD3;
|
||||
--mta-amber-200: #FDD5A6;
|
||||
--mta-amber-300: #FBB869;
|
||||
--mta-amber-400: #F89935;
|
||||
--mta-amber-500: #E87D1A;
|
||||
--mta-amber-600: #CA681E; /* ← warm CTA / text highlights */
|
||||
--mta-amber-700: #A55118;
|
||||
--mta-amber-800: #823C11;
|
||||
--mta-amber-900: #5C270B;
|
||||
|
||||
/* ── Neutral (Warm gray — cream undertone) ─────────────────── */
|
||||
--mta-neutral-0: #FFFFFF;
|
||||
--mta-neutral-50: #FAF9F7;
|
||||
--mta-neutral-100: #F3F0EB;
|
||||
--mta-neutral-200: #E9E5DD;
|
||||
--mta-neutral-300: #D4CFC9;
|
||||
--mta-neutral-400: #B3AFA7;
|
||||
--mta-neutral-500: #908C83;
|
||||
--mta-neutral-600: #6B6760;
|
||||
--mta-neutral-700: #47433D;
|
||||
--mta-neutral-800: #2C2A24;
|
||||
--mta-neutral-900: #181610;
|
||||
|
||||
/* ── Dark (Hero / panel backgrounds) ──────────────────────── */
|
||||
--mta-dark-50: #2C2920;
|
||||
--mta-dark-100: #201E17;
|
||||
--mta-dark-200: #171510;
|
||||
--mta-dark-300: #0F0D09;
|
||||
|
||||
/* ── Dark-theme surface scale (warm charcoal — never pitch black) ─ */
|
||||
--mta-surface-dark-0: #1C1B15; /* page — deep warm charcoal, "airy" */
|
||||
--mta-surface-dark-1: #24221A; /* card */
|
||||
--mta-surface-dark-2: #2C2A20; /* raised / popover */
|
||||
--mta-surface-dark-3: #353227; /* muted fill */
|
||||
--mta-surface-dark-hover: #322F25; /* row / item hover */
|
||||
--mta-surface-dark-border: #38352B;
|
||||
--mta-surface-dark-border-strong: #4A4639;
|
||||
|
||||
/* ── Red (Error / danger) ──────────────────────────────────── */
|
||||
--mta-red-50: #FFF2F1;
|
||||
--mta-red-100: #FFE2DF;
|
||||
--mta-red-200: #FFBFBB;
|
||||
--mta-red-500: #DC3B2F;
|
||||
--mta-red-600: #B82920;
|
||||
--mta-red-700: #951E16;
|
||||
--mta-red-900: #4A0A06;
|
||||
}
|
||||
|
||||
/* ── Semantic tokens ───────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--color-bg: var(--mta-neutral-100);
|
||||
--color-bg-page: var(--mta-neutral-100);
|
||||
--color-bg-card: var(--mta-neutral-0);
|
||||
--color-bg-subtle: var(--mta-neutral-50);
|
||||
--color-bg-muted: var(--mta-neutral-200);
|
||||
--color-bg-dark: var(--mta-dark-100);
|
||||
--color-bg-overlay: rgba(24, 22, 16, 0.40);
|
||||
|
||||
/* Surfaces */
|
||||
--color-surface: var(--mta-neutral-0);
|
||||
--color-surface-raised: var(--mta-neutral-50);
|
||||
|
||||
/* Filled controls (secondary buttons, chips, default tags/badges) */
|
||||
--color-control: var(--mta-neutral-100);
|
||||
--color-control-hover: var(--mta-neutral-200);
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: var(--mta-neutral-900);
|
||||
--color-text-secondary: var(--mta-neutral-600);
|
||||
--color-text-muted: var(--mta-neutral-500);
|
||||
--color-text-disabled: var(--mta-neutral-400);
|
||||
--color-text-inverse: var(--mta-neutral-0);
|
||||
--color-text-on-primary: var(--mta-neutral-0);
|
||||
|
||||
/* Borders */
|
||||
--color-border: var(--mta-neutral-200);
|
||||
--color-border-strong: var(--mta-neutral-300);
|
||||
--color-border-subtle: var(--mta-neutral-100);
|
||||
--color-border-focus: var(--mta-green-600);
|
||||
|
||||
/* Accent — green */
|
||||
--color-accent: var(--mta-green-600);
|
||||
--color-accent-hover: var(--mta-green-700);
|
||||
--color-accent-active: var(--mta-green-800);
|
||||
--color-accent-subtle: var(--mta-green-50);
|
||||
--color-accent-muted: var(--mta-green-100);
|
||||
--color-accent-text: var(--mta-green-700);
|
||||
|
||||
/* Warm — amber */
|
||||
--color-warm: var(--mta-amber-600);
|
||||
--color-warm-hover: var(--mta-amber-700);
|
||||
--color-warm-subtle: var(--mta-amber-50);
|
||||
--color-warm-text: var(--mta-amber-700);
|
||||
|
||||
/* Status */
|
||||
--color-success: var(--mta-green-600);
|
||||
--color-success-bg: var(--mta-green-50);
|
||||
--color-success-border: var(--mta-green-200);
|
||||
--color-success-text: var(--mta-green-700);
|
||||
|
||||
--color-warning: var(--mta-amber-500);
|
||||
--color-warning-bg: var(--mta-amber-50);
|
||||
--color-warning-border: var(--mta-amber-200);
|
||||
--color-warning-text: var(--mta-amber-700);
|
||||
|
||||
--color-error: var(--mta-red-500);
|
||||
--color-error-bg: var(--mta-red-50);
|
||||
--color-error-border: var(--mta-red-200);
|
||||
--color-error-text: var(--mta-red-600);
|
||||
}
|
||||
|
||||
/* ── Dark theme ────────────────────────────────────────────────────
|
||||
* Background is a deep WARM charcoal — never pitch black — to keep
|
||||
* the brand's airy, natural feel. Activated by [data-theme="dark"]
|
||||
* (set by ThemeProvider) — .dark / .dark-theme included for drop-in
|
||||
* compatibility with @radix-ui/themes.
|
||||
* ─────────────────────────────────────────────────────────────────── */
|
||||
.dark,
|
||||
.dark-theme,
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Backgrounds */
|
||||
--color-bg: var(--mta-surface-dark-0);
|
||||
--color-bg-page: var(--mta-surface-dark-0);
|
||||
--color-bg-card: var(--mta-surface-dark-1);
|
||||
--color-bg-subtle: var(--mta-surface-dark-2);
|
||||
--color-bg-muted: var(--mta-surface-dark-3);
|
||||
--color-bg-dark: #14130E;
|
||||
--color-bg-overlay: rgba(8, 7, 4, 0.62);
|
||||
|
||||
/* Surfaces */
|
||||
--color-surface: var(--mta-surface-dark-1);
|
||||
--color-surface-raised: var(--mta-surface-dark-2);
|
||||
|
||||
/* Filled controls */
|
||||
--color-control: var(--mta-surface-dark-2);
|
||||
--color-control-hover: var(--mta-surface-dark-3);
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: var(--mta-neutral-100);
|
||||
--color-text-secondary: var(--mta-neutral-400);
|
||||
--color-text-muted: var(--mta-neutral-500);
|
||||
--color-text-disabled: var(--mta-neutral-600);
|
||||
--color-text-inverse: var(--mta-neutral-900);
|
||||
--color-text-on-primary: #ffffff;
|
||||
|
||||
/* Borders */
|
||||
--color-border: var(--mta-surface-dark-border);
|
||||
--color-border-strong: var(--mta-surface-dark-border-strong);
|
||||
--color-border-subtle: #2A2820;
|
||||
--color-border-focus: var(--mta-green-400);
|
||||
|
||||
/* Accent — green (lifted for legibility on dark) */
|
||||
--color-accent: var(--mta-green-500);
|
||||
--color-accent-hover: var(--mta-green-400);
|
||||
--color-accent-active: var(--mta-green-300);
|
||||
--color-accent-subtle: rgba(70, 165, 121, 0.14);
|
||||
--color-accent-muted: rgba(70, 165, 121, 0.24);
|
||||
--color-accent-text: var(--mta-green-300);
|
||||
|
||||
/* Warm — amber */
|
||||
--color-warm: var(--mta-amber-500);
|
||||
--color-warm-hover: var(--mta-amber-400);
|
||||
--color-warm-subtle: rgba(248, 153, 53, 0.14);
|
||||
--color-warm-text: var(--mta-amber-300);
|
||||
|
||||
/* Status */
|
||||
--color-success: var(--mta-green-400);
|
||||
--color-success-bg: rgba(70, 165, 121, 0.14);
|
||||
--color-success-border: rgba(70, 165, 121, 0.34);
|
||||
--color-success-text: var(--mta-green-300);
|
||||
|
||||
--color-warning: var(--mta-amber-400);
|
||||
--color-warning-bg: rgba(248, 153, 53, 0.14);
|
||||
--color-warning-border: rgba(248, 153, 53, 0.32);
|
||||
--color-warning-text: var(--mta-amber-300);
|
||||
|
||||
--color-error: var(--mta-red-500);
|
||||
--color-error-bg: rgba(220, 59, 47, 0.15);
|
||||
--color-error-border: rgba(220, 59, 47, 0.36);
|
||||
--color-error-text: #FF9A90;
|
||||
|
||||
/* Shadows — deeper & cooler against dark surfaces */
|
||||
--mta-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.40);
|
||||
--mta-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.46),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.40);
|
||||
--mta-shadow-md: 0 4px 8px -1px rgba(0, 0, 0, 0.50),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.42);
|
||||
--mta-shadow-lg: 0 10px 20px -3px rgba(0, 0, 0, 0.55),
|
||||
0 4px 8px -4px rgba(0, 0, 0, 0.46);
|
||||
--mta-shadow-xl: 0 20px 32px -5px rgba(0, 0, 0, 0.60),
|
||||
0 8px 14px -6px rgba(0, 0, 0, 0.50);
|
||||
--mta-shadow-modal: 0 32px 64px -12px rgba(0, 0, 0, 0.70),
|
||||
0 12px 24px -8px rgba(0, 0, 0, 0.55);
|
||||
|
||||
/* Focus rings — brighter green on dark */
|
||||
--mta-focus-ring: 0 0 0 3px rgba(70, 165, 121, 0.40);
|
||||
}
|
||||
|
||||
/* ---------- TYPOGRAPHY ----------
|
||||
Typeface: Plus Jakarta Sans (variable, 200–800 weight). */
|
||||
:root {
|
||||
/* Font families */
|
||||
--mta-font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
/* Fira Code — self-hosted (weights 300–700), see fonts.css */
|
||||
--mta-font-mono: 'Fira Code', 'SF Mono', 'Cascadia Code', ui-monospace, monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--mta-font-size-xs: 11px;
|
||||
--mta-font-size-sm: 13px;
|
||||
--mta-font-size-base: 14px;
|
||||
--mta-font-size-md: 16px;
|
||||
--mta-font-size-lg: 18px;
|
||||
--mta-font-size-xl: 20px;
|
||||
--mta-font-size-2xl: 24px;
|
||||
--mta-font-size-3xl: 30px;
|
||||
--mta-font-size-4xl: 38px;
|
||||
--mta-font-size-5xl: 48px;
|
||||
--mta-font-size-6xl: 60px;
|
||||
|
||||
/* Font weights */
|
||||
--mta-font-weight-light: 300;
|
||||
--mta-font-weight-regular: 400;
|
||||
--mta-font-weight-medium: 500;
|
||||
--mta-font-weight-semibold: 600;
|
||||
--mta-font-weight-bold: 700;
|
||||
--mta-font-weight-extrabold: 800;
|
||||
|
||||
/* Line heights */
|
||||
--mta-line-height-none: 1;
|
||||
--mta-line-height-tight: 1.2;
|
||||
--mta-line-height-snug: 1.35;
|
||||
--mta-line-height-normal: 1.5;
|
||||
--mta-line-height-relaxed: 1.65;
|
||||
--mta-line-height-loose: 1.8;
|
||||
|
||||
/* Letter spacing */
|
||||
--mta-tracking-tighter: -0.03em;
|
||||
--mta-tracking-tight: -0.02em;
|
||||
--mta-tracking-snug: -0.01em;
|
||||
--mta-tracking-normal: 0em;
|
||||
--mta-tracking-wide: 0.02em;
|
||||
--mta-tracking-wider: 0.06em;
|
||||
--mta-tracking-widest: 0.12em;
|
||||
|
||||
/* ── Semantic type styles ──────────────────────────────────── */
|
||||
--mta-text-display-size: var(--mta-font-size-5xl);
|
||||
--mta-text-display-weight: var(--mta-font-weight-bold);
|
||||
--mta-text-display-leading: var(--mta-line-height-tight);
|
||||
--mta-text-display-tracking: var(--mta-tracking-tighter);
|
||||
|
||||
--mta-text-h1-size: var(--mta-font-size-4xl);
|
||||
--mta-text-h1-weight: var(--mta-font-weight-bold);
|
||||
--mta-text-h1-leading: var(--mta-line-height-tight);
|
||||
--mta-text-h1-tracking: var(--mta-tracking-tight);
|
||||
|
||||
--mta-text-h2-size: var(--mta-font-size-3xl);
|
||||
--mta-text-h2-weight: var(--mta-font-weight-semibold);
|
||||
--mta-text-h2-leading: var(--mta-line-height-snug);
|
||||
--mta-text-h2-tracking: var(--mta-tracking-tight);
|
||||
|
||||
--mta-text-h3-size: var(--mta-font-size-2xl);
|
||||
--mta-text-h3-weight: var(--mta-font-weight-semibold);
|
||||
--mta-text-h3-leading: var(--mta-line-height-snug);
|
||||
--mta-text-h3-tracking: var(--mta-tracking-snug);
|
||||
|
||||
--mta-text-h4-size: var(--mta-font-size-xl);
|
||||
--mta-text-h4-weight: var(--mta-font-weight-semibold);
|
||||
--mta-text-h4-leading: var(--mta-line-height-snug);
|
||||
--mta-text-h4-tracking: var(--mta-tracking-snug);
|
||||
|
||||
--mta-text-body-lg-size: var(--mta-font-size-md);
|
||||
--mta-text-body-lg-weight: var(--mta-font-weight-regular);
|
||||
--mta-text-body-lg-leading: var(--mta-line-height-relaxed);
|
||||
|
||||
--mta-text-body-size: var(--mta-font-size-base);
|
||||
--mta-text-body-weight: var(--mta-font-weight-regular);
|
||||
--mta-text-body-leading: var(--mta-line-height-normal);
|
||||
|
||||
--mta-text-body-sm-size: var(--mta-font-size-sm);
|
||||
--mta-text-body-sm-weight: var(--mta-font-weight-regular);
|
||||
--mta-text-body-sm-leading: var(--mta-line-height-normal);
|
||||
|
||||
--mta-text-label-size: var(--mta-font-size-sm);
|
||||
--mta-text-label-weight: var(--mta-font-weight-medium);
|
||||
--mta-text-label-leading: var(--mta-line-height-none);
|
||||
|
||||
--mta-text-label-sm-size: var(--mta-font-size-xs);
|
||||
--mta-text-label-sm-weight: var(--mta-font-weight-medium);
|
||||
--mta-text-label-sm-tracking: var(--mta-tracking-wide);
|
||||
|
||||
--mta-text-caption-size: var(--mta-font-size-xs);
|
||||
--mta-text-caption-weight: var(--mta-font-weight-regular);
|
||||
--mta-text-caption-leading: var(--mta-line-height-normal);
|
||||
}
|
||||
|
||||
/* ---------- SPACING + SIZING ----------
|
||||
Base unit: 4px. */
|
||||
:root {
|
||||
--mta-space-px: 1px;
|
||||
--mta-space-0: 0px;
|
||||
--mta-space-0-5: 2px;
|
||||
--mta-space-1: 4px;
|
||||
--mta-space-1-5: 6px;
|
||||
--mta-space-2: 8px;
|
||||
--mta-space-2-5: 10px;
|
||||
--mta-space-3: 12px;
|
||||
--mta-space-3-5: 14px;
|
||||
--mta-space-4: 16px;
|
||||
--mta-space-5: 20px;
|
||||
--mta-space-6: 24px;
|
||||
--mta-space-7: 28px;
|
||||
--mta-space-8: 32px;
|
||||
--mta-space-9: 36px;
|
||||
--mta-space-10: 40px;
|
||||
--mta-space-11: 44px;
|
||||
--mta-space-12: 48px;
|
||||
--mta-space-14: 56px;
|
||||
--mta-space-16: 64px;
|
||||
--mta-space-20: 80px;
|
||||
--mta-space-24: 96px;
|
||||
--mta-space-28: 112px;
|
||||
--mta-space-32: 128px;
|
||||
|
||||
/* ── Border radius ─────────────────────────────────────────── */
|
||||
--mta-radius-none: 0px;
|
||||
--mta-radius-xs: 3px; /* tags, badges */
|
||||
--mta-radius-sm: 5px; /* small inputs, chips */
|
||||
--mta-radius-md: 8px; /* inputs, buttons, code */
|
||||
--mta-radius-lg: 12px; /* cards, dropdowns */
|
||||
--mta-radius-xl: 16px; /* modals, panels */
|
||||
--mta-radius-2xl: 20px; /* large cards */
|
||||
--mta-radius-3xl: 28px; /* hero cards */
|
||||
--mta-radius-full: 9999px; /* pills, avatars, toggles */
|
||||
|
||||
/* ── Layout constants ──────────────────────────────────────── */
|
||||
--mta-sidebar-width: 240px;
|
||||
--mta-topbar-height: 56px;
|
||||
--mta-max-content-width: 760px;
|
||||
--mta-max-page-width: 1280px;
|
||||
|
||||
/* ── Control sizing (compact, global) ─────────────────────── */
|
||||
--ctl-font: var(--mta-font-size-base);
|
||||
--ctl-pad-y: 9px;
|
||||
--ctl-pad-x: var(--mta-space-4);
|
||||
--field-pad-y: var(--mta-space-2);
|
||||
--field-pad-x: var(--mta-space-3);
|
||||
--switch-w: 36px;
|
||||
--switch-h: 20px;
|
||||
--switch-knob: 16px;
|
||||
--switch-gap: 2px;
|
||||
--seg-pad-y: var(--mta-space-1);
|
||||
--stepper-h: 30px;
|
||||
}
|
||||
|
||||
/* ---------- EFFECTS ----------
|
||||
Shadows, transitions, blur — warm-tinted, minimal, airy. */
|
||||
:root {
|
||||
/* ── Shadows (warm-tinted: hsl(42 30% 6%)) ────────────────── */
|
||||
--mta-shadow-none: none;
|
||||
--mta-shadow-xs: 0 1px 2px 0 rgba(24, 20, 12, 0.05);
|
||||
--mta-shadow-sm: 0 1px 3px 0 rgba(24, 20, 12, 0.07),
|
||||
0 1px 2px -1px rgba(24, 20, 12, 0.05);
|
||||
--mta-shadow-md: 0 4px 8px -1px rgba(24, 20, 12, 0.08),
|
||||
0 2px 4px -2px rgba(24, 20, 12, 0.05);
|
||||
--mta-shadow-lg: 0 10px 20px -3px rgba(24, 20, 12, 0.09),
|
||||
0 4px 8px -4px rgba(24, 20, 12, 0.06);
|
||||
--mta-shadow-xl: 0 20px 32px -5px rgba(24, 20, 12, 0.10),
|
||||
0 8px 14px -6px rgba(24, 20, 12, 0.07);
|
||||
--mta-shadow-modal: 0 32px 64px -12px rgba(24, 20, 12, 0.22),
|
||||
0 12px 24px -8px rgba(24, 20, 12, 0.12);
|
||||
|
||||
/* ── Focus rings ───────────────────────────────────────────── */
|
||||
--mta-focus-ring: 0 0 0 3px rgba(30, 110, 72, 0.28);
|
||||
--mta-focus-ring-amber: 0 0 0 3px rgba(202, 104, 30, 0.28);
|
||||
--mta-focus-ring-error: 0 0 0 3px rgba(220, 59, 47, 0.28);
|
||||
|
||||
/* ── Transitions ───────────────────────────────────────────── */
|
||||
--mta-duration-instant: 50ms;
|
||||
--mta-duration-fast: 100ms;
|
||||
--mta-duration-normal: 150ms;
|
||||
--mta-duration-slow: 250ms;
|
||||
--mta-duration-slower: 400ms;
|
||||
--mta-duration-slowest: 600ms;
|
||||
|
||||
--mta-ease-default: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--mta-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--mta-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--mta-ease-inout: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--mta-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* Shorthand */
|
||||
--mta-transition-color: color var(--mta-duration-normal) var(--mta-ease-default),
|
||||
background-color var(--mta-duration-normal) var(--mta-ease-default),
|
||||
border-color var(--mta-duration-normal) var(--mta-ease-default);
|
||||
--mta-transition-transform: transform var(--mta-duration-fast) var(--mta-ease-default);
|
||||
--mta-transition-shadow: box-shadow var(--mta-duration-normal) var(--mta-ease-default);
|
||||
--mta-transition-opacity: opacity var(--mta-duration-normal) var(--mta-ease-default);
|
||||
--mta-transition-all: all var(--mta-duration-normal) var(--mta-ease-default);
|
||||
|
||||
/* ── Backdrop blur ─────────────────────────────────────────── */
|
||||
--mta-blur-sm: 4px;
|
||||
--mta-blur-md: 12px;
|
||||
--mta-blur-lg: 24px;
|
||||
--mta-blur-xl: 40px;
|
||||
|
||||
/* ── Z-index scale ─────────────────────────────────────────── */
|
||||
--mta-z-base: 0;
|
||||
--mta-z-raised: 10;
|
||||
--mta-z-sticky: 100;
|
||||
--mta-z-overlay: 200;
|
||||
--mta-z-modal: 300;
|
||||
--mta-z-toast: 400;
|
||||
--mta-z-tooltip: 500;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COMPATIBILITY BRIDGE
|
||||
Legacy variable names used by components.css recipes that
|
||||
haven't been individually re-styled to the MtAir tokens above
|
||||
yet. As each component's CSS is rewritten, its references to
|
||||
these legacy names should be dropped in favor of the --mta-*
|
||||
and --color-* tokens directly, and this bridge trimmed.
|
||||
============================================================ */
|
||||
:root {
|
||||
/* Type */
|
||||
--font-sans: var(--mta-font-sans);
|
||||
--font-display: var(--mta-font-sans);
|
||||
--font-mono: var(--mta-font-mono);
|
||||
|
||||
--text-xs: var(--mta-font-size-xs);
|
||||
--text-sm: var(--mta-font-size-sm);
|
||||
--text-base: var(--mta-font-size-base);
|
||||
--text-md: var(--mta-font-size-md);
|
||||
--text-lg: var(--mta-font-size-lg);
|
||||
--text-xl: var(--mta-font-size-xl);
|
||||
--text-2xl: var(--mta-font-size-2xl);
|
||||
--text-3xl: var(--mta-font-size-3xl);
|
||||
--text-4xl: var(--mta-font-size-4xl);
|
||||
--text-5xl: var(--mta-font-size-5xl);
|
||||
|
||||
--w-light: var(--mta-font-weight-light);
|
||||
--w-regular: var(--mta-font-weight-regular);
|
||||
--w-medium: var(--mta-font-weight-medium);
|
||||
--w-semibold: var(--mta-font-weight-semibold);
|
||||
--w-bold: var(--mta-font-weight-bold);
|
||||
--w-black: var(--mta-font-weight-extrabold);
|
||||
|
||||
--lh-tight: var(--mta-line-height-tight);
|
||||
--lh-snug: var(--mta-line-height-snug);
|
||||
--lh-normal: var(--mta-line-height-normal);
|
||||
--lh-relaxed: var(--mta-line-height-relaxed);
|
||||
--track-tight: var(--mta-tracking-tight);
|
||||
--track-snug: var(--mta-tracking-snug);
|
||||
--track-wide: var(--mta-tracking-wide);
|
||||
--track-caps: var(--mta-tracking-widest);
|
||||
|
||||
/* Radii */
|
||||
--r-xs: var(--mta-radius-xs);
|
||||
--r-sm: var(--mta-radius-sm);
|
||||
--r-md: var(--mta-radius-md);
|
||||
--r-lg: var(--mta-radius-lg);
|
||||
--r-xl: var(--mta-radius-xl);
|
||||
--r-pill: var(--mta-radius-full);
|
||||
|
||||
/* Spacing 1:1 — both scales are 4px-based with matching steps */
|
||||
--space-1: var(--mta-space-1);
|
||||
--space-2: var(--mta-space-2);
|
||||
--space-3: var(--mta-space-3);
|
||||
--space-4: var(--mta-space-4);
|
||||
--space-5: var(--mta-space-5);
|
||||
--space-6: var(--mta-space-6);
|
||||
--space-8: var(--mta-space-8);
|
||||
--space-10: var(--mta-space-10);
|
||||
--space-12: var(--mta-space-12);
|
||||
--space-16: var(--mta-space-16);
|
||||
|
||||
/* Surfaces, text, borders */
|
||||
--ink: var(--color-bg-page);
|
||||
--ink-deep: var(--color-bg);
|
||||
--ink-raised: var(--color-bg-card);
|
||||
--steel-900: var(--color-bg-card);
|
||||
--steel-800: var(--color-control);
|
||||
--steel-700: var(--color-control-hover);
|
||||
--steel-600: var(--color-border-strong);
|
||||
--steel-500: var(--color-border);
|
||||
--steel-400: var(--color-text-disabled);
|
||||
|
||||
--fg-1: var(--color-text-primary);
|
||||
--fg-2: var(--color-text-secondary);
|
||||
--fg-3: var(--color-text-muted);
|
||||
--fg-on-lime: var(--color-text-on-primary);
|
||||
--fg-on-ember: #ffffff;
|
||||
|
||||
--hair: var(--color-border);
|
||||
--hair-strong: var(--color-border-strong);
|
||||
--edge-top: rgba(255, 255, 255, 0.6);
|
||||
--edge-inset: var(--color-border);
|
||||
|
||||
/* Brand accents — green (lime) / red (ember) */
|
||||
--lime: var(--color-accent);
|
||||
--lime-bright: var(--mta-green-400);
|
||||
--lime-deep: var(--color-accent-hover);
|
||||
--lime-ink: var(--color-text-on-primary);
|
||||
|
||||
--ember: var(--color-error);
|
||||
--ember-bright: var(--mta-red-500);
|
||||
--ember-deep: var(--mta-red-700);
|
||||
|
||||
/* Status */
|
||||
--success: var(--color-success);
|
||||
--warning: var(--color-warning);
|
||||
--danger: var(--color-error);
|
||||
--info: var(--color-accent);
|
||||
--success-bg: var(--color-success-bg);
|
||||
--warning-bg: var(--color-warning-bg);
|
||||
--danger-bg: var(--color-error-bg);
|
||||
--info-bg: var(--color-accent-subtle);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-raised: var(--mta-shadow-sm);
|
||||
--shadow-raised-hover: var(--mta-shadow-md);
|
||||
--shadow-pressed: inset 0 1px 2px rgba(24, 20, 12, 0.10);
|
||||
--shadow-inset-well: inset 0 1px 2px rgba(24, 20, 12, 0.08);
|
||||
--shadow-card: var(--mta-shadow-sm);
|
||||
--shadow-window: var(--mta-shadow-modal);
|
||||
--focus-ring: var(--mta-focus-ring);
|
||||
--focus-ring-ember: var(--mta-focus-ring-error);
|
||||
--glow-lime: 0 0 0 0 transparent;
|
||||
|
||||
/* Flat "gradients" — MtAir uses flat fills, no gloss */
|
||||
--grad-key: var(--color-control);
|
||||
--grad-key-hover: var(--color-control-hover);
|
||||
--grad-key-down: var(--color-control-hover);
|
||||
--grad-primary: var(--color-accent);
|
||||
--grad-primary-down: var(--color-accent-active);
|
||||
--grad-ember: var(--color-error);
|
||||
--grad-ember-down: var(--mta-red-700);
|
||||
--grad-titlebar: var(--color-bg-subtle);
|
||||
|
||||
/* Motion */
|
||||
--dur-press: var(--mta-duration-instant);
|
||||
--dur-quick: var(--mta-duration-fast);
|
||||
--dur-base: var(--mta-duration-normal);
|
||||
--ease-out: var(--mta-ease-out);
|
||||
--ease-snap: var(--mta-ease-spring);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SEMANTIC TYPE CLASSES
|
||||
============================================================ */
|
||||
.mta-felt {
|
||||
background-color: var(--color-bg-page);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-display {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-display-weight);
|
||||
font-size: var(--mta-text-display-size);
|
||||
line-height: var(--mta-text-display-leading);
|
||||
letter-spacing: var(--mta-text-display-tracking);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-h1 {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-h1-weight);
|
||||
font-size: var(--mta-text-h1-size);
|
||||
line-height: var(--mta-text-h1-leading);
|
||||
letter-spacing: var(--mta-text-h1-tracking);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-h2 {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-h2-weight);
|
||||
font-size: var(--mta-text-h2-size);
|
||||
line-height: var(--mta-text-h2-leading);
|
||||
letter-spacing: var(--mta-text-h2-tracking);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-h3 {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-h3-weight);
|
||||
font-size: var(--mta-text-h3-size);
|
||||
line-height: var(--mta-text-h3-leading);
|
||||
letter-spacing: var(--mta-text-h3-tracking);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-body {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-body-weight);
|
||||
font-size: var(--mta-text-body-size);
|
||||
line-height: var(--mta-text-body-leading);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.mta-body-strong {
|
||||
font-weight: var(--mta-font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.mta-caption {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-caption-weight);
|
||||
font-size: var(--mta-text-caption-size);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.mta-label {
|
||||
font-family: var(--mta-font-sans);
|
||||
font-weight: var(--mta-text-label-sm-weight);
|
||||
font-size: var(--mta-font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--mta-tracking-widest);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.mta-mono {
|
||||
font-family: var(--mta-font-mono);
|
||||
font-weight: var(--mta-font-weight-regular);
|
||||
font-size: var(--mta-text-body-sm-size);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
Reference in New Issue
Block a user