4 Commits

Author SHA1 Message Date
olly a5d2742c7c fixes 2026-05-31 18:09:55 +03:00
olly 22afa7e1a5 feat: structure 2026-05-31 17:49:29 +03:00
olly 67993ae3ec feat: storybook 2026-05-31 17:11:42 +03:00
olly 2f937e94b1 feat: storybook 2026-05-31 17:11:33 +03:00
49 changed files with 6613 additions and 703 deletions
+3
View File
@@ -7,6 +7,9 @@
node_modules
dist/
# Storybook build output (the .storybook config + *.stories.tsx ARE committed)
storybook-static/
# Profile
.rspack-profile-*/
+27
View File
@@ -0,0 +1,27 @@
import type { StorybookConfig } from 'storybook-react-rsbuild';
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-docs'],
staticDirs: ['../src/assets'],
framework: {
name: 'storybook-react-rsbuild',
options: {},
},
typescript: {
// Prop tables in autodocs come from the components' TS types.
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
// Keep our own props + Radix primitives; drop other node_modules noise.
propFilter: (prop) =>
prop.parent
? !/node_modules/.test(prop.parent.fileName) ||
/node_modules\/radix-ui/.test(prop.parent.fileName)
: true,
},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
import { addons } from 'storybook/manager-api';
import theme from './theme';
addons.setConfig({ theme });
+12
View File
@@ -0,0 +1,12 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
@font-face {
font-family: 'Anta';
src: url('/Anta-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
/* Calm dark canvas. Uses the felt's subtle radial glow (no heavy fixed
grain) so the story sits on-brand without flooding the frame. The
--bg-glow / --ink tokens come from the imported stylesheet and react
to the data-theme toggle automatically. */
.sb-show-main {
background-color: var(--ink);
background-image: var(--bg-glow);
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
}
/* Autodocs preview blocks: same dark surface as the canvas. */
.docs-story,
.sbdocs-preview {
background-color: var(--ink) !important;
background-image: var(--bg-glow);
background-size: cover;
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, type ReactNode } from 'react';
import type { Preview, Decorator } from 'storybook-react-rsbuild';
import { Tooltip } from 'radix-ui';
/* The shipped library surface, exactly as a consumer would load it.
Fonts are loaded via preview-head.html (Google Fonts link + Anta @font-face)
to avoid bundler inlining the @import url() mid-stylesheet. */
import '../src/styles/index.css';
/* Storybook-only canvas styling (background, docs blocks). */
import './preview.css';
import theme from './theme';
/* Toolbar theme switch → drives `data-theme` on <html>, same lever as the
library's ThemeProvider. The frame stays content-sized; the dark canvas
comes from preview.css, so stories never balloon to full-viewport. */
function ThemeFrame({ theme, children }: { theme: string; children: ReactNode }) {
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return <Tooltip.Provider delayDuration={200}>{children}</Tooltip.Provider>;
}
const withModernSk: Decorator = (Story, context) => (
<ThemeFrame theme={(context.globals.theme as string) ?? 'dark'}>
<Story />
</ThemeFrame>
);
const preview: Preview = {
tags: ['autodocs'],
decorators: [withModernSk],
parameters: {
layout: 'centered',
backgrounds: { disable: true },
docs: { theme },
controls: {
matchers: { color: /(background|color)$/i, date: /Date$/i },
},
options: {
storySort: { order: ['Getting Started', 'Inputs', 'Selection', 'Feedback', 'Overlays', 'Data Display', 'Layout'] },
},
},
globalTypes: {
theme: {
description: 'ModernSK theme',
defaultValue: 'dark',
toolbar: {
title: 'Theme',
icon: 'contrast',
items: [
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'light', title: 'Light', icon: 'sun' },
],
dynamicTitle: true,
},
},
},
};
export default preview;
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'storybook/theming';
/* Dark, lime-accented chrome so the Storybook UI + autodocs match the
components instead of framing them in stock white. */
export default create({
base: 'dark',
brandTitle: 'ModernSK',
colorPrimary: '#bef264',
colorSecondary: '#bef264',
// App
appBg: '#0f100d',
appContentBg: '#0f100d',
appPreviewBg: '#0f100d',
appBorderColor: '#2a2c22',
appBorderRadius: 10,
// Typography
fontBase: "'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif",
fontCode: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace",
// Text
textColor: '#f3f4ee',
textInverseColor: '#0f100d',
textMutedColor: '#9a9c8c',
// Toolbar / sidebar bars
barBg: '#16170f',
barTextColor: '#9a9c8c',
barSelectedColor: '#bef264',
barHoverColor: '#bef264',
// Inputs
inputBg: '#1c1d16',
inputBorder: '#2a2c22',
inputTextColor: '#f3f4ee',
inputBorderRadius: 8,
});
+19 -12
View File
@@ -4,14 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## What this is
`@modernsk/ui` — a tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives. Distributed as a git-installable / publishable package; only `dist/` ships. Consumers get built ESM + CJS, `.d.ts` types, and a single `styles.css`.
`modern-sk` — a tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives. Distributed as a git-installable / publishable package; only `dist/` ships. Consumers get built ESM + CJS, `.d.ts` types, and a single `styles.css`.
## Commands
```bash
npm run dev # Rsbuild playground at http://localhost:3000 (renders src/App.tsx — every component on one page)
npm run build # build publishable package: tsup (JS/types) + build:css (bundled stylesheet) into dist/
npm run lint # rslint (rslint.config.ts)
npm run storybook # Storybook component explorer + autodocs at http://localhost:6006
npm run build # build publishable package: tsup (JS/types) + build:css into dist/
npm run lint # rslint (rslint.config.ts) — covers src/ including stories
npm run format # prettier --write .
```
@@ -19,32 +20,38 @@ No test suite exists. `npm run build` runs automatically on install via the `pre
The build is two steps that must both run (the `build` script chains them):
- `tsup` bundles `src/index.ts` → ESM/CJS + types. `react`/`react-dom` are externalized (peer deps); `radix-ui` + `@phosphor-icons/react` are bundled.
- `build:css` runs esbuild on `src/styles/index.css` with `--loader:.ttf=dataurl`, inlining the self-hosted Anta font so no asset hosting is needed.
- `build:css` runs esbuild twice: `src/styles/index.css``dist/styles.css` (fontless core), and `src/styles/fonts.css``dist/fonts.css` with `--loader:.ttf=dataurl` (inlines the self-hosted Anta font). Both are package exports.
## Architecture
Two parallel surfaces share the same source but never mix at publish time:
- **Library (shipped):** `src/index.ts` is the public entry. It re-exports everything from `src/components/ui.tsx`, plus `ThemeProvider`/`useTheme` from `src/components/theme.tsx`, and exposes `TooltipProvider` (Radix's `Tooltip.Provider`). The shipped stylesheet entry is `src/styles/index.css`.
- **Playground (dev-only, never published):** `src/index.tsx` mounts `src/App.tsx`, and uses `src/styles/global.css`. These are the Rsbuild dev target only.
- **Playground (dev-only, never published):** two of them — `src/index.tsx` mounts the `src/App.tsx` kitchen sink (Rsbuild dev target, `src/styles/global.css`), and Storybook (`.storybook/` config + `src/stories/*.stories.tsx`) is the component catalogue with autodocs.
`src/styles/index.css` (shipped) vs `src/styles/global.css` (dev) is a deliberate split: `index.css` imports only `tokens.css` + `components.css` and applies box-sizing at **zero specificity** via `:where([class^='msk-'])` so it never touches consumer elements. `global.css` adds a global reset and kitchen-sink layout helpers — those must stay out of the shipped bundle.
Only `dist/` ships (`files: ["dist"]`), so the playground, stories, and `.storybook/` are excluded from the package automatically — but they ARE committed to git so anyone can run them.
`src/styles/index.css` (shipped) vs `src/styles/global.css` (dev) is a deliberate split: `index.css` imports only `tokens.css` + `components.css` and applies box-sizing at **zero specificity** via `:where([class^='modern-sk-'])` so it never touches consumer elements. `global.css` adds a global reset, the optional `fonts.css`, and kitchen-sink layout helpers — those must stay out of the shipped bundle.
### Fonts (`src/styles/fonts.css`)
Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/mono` chains that degrade to `system-ui`; the actual faces (Anta `@font-face` + Onest/Geist Mono Google Fonts `@import`) live in `src/styles/fonts.css`, shipped as the optional `modern-sk/fonts.css` export. Consumers either import it, or override the `--font-*` tokens to remap typefaces. Storybook's `preview.tsx` and the dev `global.css` both import `fonts.css` so the playgrounds stay branded.
### Components (`src/components/ui.tsx`)
All components live in one file. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `msk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `msk-*` classes resolving against CSS custom properties.**
All components live in one file. Pattern: Radix provides logic/accessibility, every visual comes from CSS. Components are thin wrappers that attach `modern-sk-*` classes and spread props. The `cx()` helper joins class names (no classnames dependency). Components forward refs where they wrap a DOM element. There is no inline styling and no CSS-in-JS — **all appearance is driven by `modern-sk-*` classes resolving against CSS custom properties.**
### Styling system (`src/styles/`)
- `tokens.css` — single source of truth: color/type CSS custom properties, `@font-face`, Google Fonts import. Every component reads from here.
- `components.css` — the `msk-*` class definitions.
- Dark/light is driven by `data-theme` on `<html>`, set by `ThemeProvider` (persisted to `localStorage` under key `msk-theme`, default `dark`).
- `tokens.css` — single source of truth: color/type CSS custom properties (no font loading — see Fonts above). Every component reads from here.
- `components.css` — the `modern-sk-*` class definitions.
- Dark/light is driven by `data-theme` on `<html>`, set by `ThemeProvider` (persisted to `localStorage` under key `modern-sk-theme`, default `dark`).
When adding or changing a component: add the wrapper in `ui.tsx`, define its `msk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding.
When adding or changing a component: add the wrapper in `ui.tsx`, define its `modern-sk-*` styles in `components.css`, and pull any new color/spacing value from a token in `tokens.css` rather than hardcoding.
## Consumer contract
Consumers import `@modernsk/ui/styles.css` once at app root, wrap their tree in `ThemeProvider`, and wrap any tooltip-using subtree in `TooltipProvider`. Keep these provider requirements intact when refactoring exports.
Consumers import `modern-sk/styles.css` once at app root, wrap their tree in `ThemeProvider`, and wrap any tooltip-using subtree in `TooltipProvider`. Keep these provider requirements intact when refactoring exports.
## Conventions
+9 -5
View File
@@ -73,19 +73,23 @@ after `styles.css` — every component re-reads them:
## Develop
A live playground (every component on one page) runs via Rsbuild:
```bash
npm run dev # playground at http://localhost:3000
npm run dev # Rsbuild playground (every component on one page) at http://localhost:3000
npm run storybook # Storybook component explorer + docs at http://localhost:6006
npm run build # build the publishable package into dist/
npm run lint
```
**Storybook** is the component catalogue: each component has live controls and an
auto-generated prop table, plus a theme toggle in the toolbar. Stories live in
`src/stories/*.stories.tsx`; config is in `.storybook/`. It is committed to git so
anyone can `npm run storybook` and browse — but it never ships in the package.
## What ships
A git install exposes only `dist/` — built ESM + CJS, `.d.ts` types,
`styles.css`, and `fonts.css`. The playground (`src/App.tsx`, Rsbuild config)
is dev-only and never published.
`styles.css`, and `fonts.css`. Everything else (`src/App.tsx`, `.storybook/`,
stories, Rsbuild config) is dev-only and never published.
---
+4861
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -27,6 +27,8 @@
"build:css": "esbuild src/styles/index.css --bundle --outfile=dist/styles.css && esbuild src/styles/fonts.css --bundle --loader:.ttf=dataurl --outfile=dist/fonts.css",
"dev": "rsbuild dev --open",
"preview": "rsbuild preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"lint": "rslint",
"format": "prettier --write .",
"prepare": "npm run build"
@@ -44,6 +46,7 @@
"@rsbuild/plugin-babel": "^1.2.0",
"@rsbuild/plugin-react": "^2.0.0",
"@rslint/core": "^0.5.1",
"@storybook/addon-docs": "^10.4.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "^1.0.0",
@@ -51,6 +54,8 @@
"prettier": "^3.8.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"storybook": "^10.4.1",
"storybook-react-rsbuild": "^3.3.4",
"tsup": "^8.5.0",
"typescript": "^6.0.3"
}
+57
View File
@@ -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="modern-sk-overlay" />
<RAlertDialog.Content className="modern-sk-dialog">
<RAlertDialog.Title className="modern-sk-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="modern-sk-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="modern-sk-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>
);
+44
View File
@@ -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(
'modern-sk-badge',
`modern-sk-badge--${variant}`,
dot && 'modern-sk-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="modern-sk-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
+27
View File
@@ -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(
'modern-sk-btn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size === 'sm' && 'modern-sk-btn--sm',
iconOnly && 'modern-sk-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
+19
View File
@@ -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('modern-sk-callout', variant !== 'info' && `modern-sk-callout--${variant}`)}>
{icon && <span className="modern-sk-callout__icon">{icon}</span>}
<div className="modern-sk-callout__body">{children}</div>
</div>
);
+6
View File
@@ -0,0 +1,6 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Card = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-card', className)} {...props} />
);
+55
View File
@@ -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="modern-sk-overlay" />
<RDialog.Content className="modern-sk-dialog">
<RDialog.Title className="modern-sk-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="modern-sk-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="modern-sk-dialog__body">{children}</div>}
{footer && <div className="modern-sk-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="modern-sk-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
export const DialogClose = RDialog.Close;
+25
View File
@@ -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(
'modern-sk-btn',
'modern-sk-iconbtn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size && `modern-sk-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
+17
View File
@@ -0,0 +1,17 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const List = ({ className, ...props }: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('modern-sk-row', selected && 'is-selected', className)}
{...props}
/>
);
+56
View File
@@ -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="modern-sk-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="modern-sk-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="modern-sk-menu-sep" />
);
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="modern-sk-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="modern-sk-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
+16
View File
@@ -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('modern-sk-progress', className)} value={value} {...props}>
<RProgress.Indicator
className="modern-sk-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
+22
View File
@@ -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('modern-sk-scroll', className)} {...props}>
<RScrollArea.Viewport className="modern-sk-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="modern-sk-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('modern-sk-seg', className)}
value={value}
onValueChange={(v) => v && onValueChange(v)}
{...props}
>
<span ref={thumbRef} className="modern-sk-seg__thumb" aria-hidden />
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="modern-sk-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
};
+42
View File
@@ -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="modern-sk-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="modern-sk-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="modern-sk-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="modern-sk-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="modern-sk-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
+53
View File
@@ -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="modern-sk-switch" {...props}>
<RSwitch.Thumb className="modern-sk-switch__thumb" />
</RSwitch.Root>
);
export const Checkbox = (props: ComponentPropsWithoutRef<typeof RCheckbox.Root>) => (
<RCheckbox.Root className="modern-sk-check" {...props}>
<RCheckbox.Indicator className="modern-sk-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="modern-sk-radio" value={value} {...props}>
<RRadioGroup.Indicator className="modern-sk-radio__indicator" />
</RRadioGroup.Item>
);
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="modern-sk-control">
{control}
{children}
</label>
);
+76
View File
@@ -0,0 +1,76 @@
import { type ComponentPropsWithoutRef } from 'react';
import { Slider as RSlider } from 'radix-ui';
type Step = { value: number; label?: string };
type SliderProps = ComponentPropsWithoutRef<typeof RSlider.Root> & {
steps?: number | Step[];
};
function resolveSteps(steps: number | Step[], min: number, max: number): Step[] {
if (Array.isArray(steps)) return steps;
if (steps < 2) return [];
return Array.from({ length: steps }, (_, i) => ({
value: min + (i / (steps - 1)) * (max - min),
}));
}
export const Slider = ({ steps, min = 0, max = 100, ...props }: SliderProps) => {
const resolved = steps != null ? resolveSteps(steps, min, max) : [];
const hasSteps = resolved.length > 0;
return (
<RSlider.Root
className={`modern-sk-slider${hasSteps ? ' modern-sk-slider--has-steps' : ''}`}
min={min}
max={max}
{...props}
>
<RSlider.Track className="modern-sk-slider__track">
<RSlider.Range className="modern-sk-slider__range" />
{hasSteps && resolved.map((step) => (
<div
key={step.value}
className="modern-sk-slider__step-dot"
aria-hidden
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
/>
))}
</RSlider.Track>
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
{hasSteps && (
<div className="modern-sk-slider__steps" aria-hidden>
{resolved.map((step) => (
<div
key={step.value}
className="modern-sk-slider__step"
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
>
<div className="modern-sk-slider__step-tick" />
{step.label != null && (
<span className="modern-sk-slider__step-label">{step.label}</span>
)}
</div>
))}
</div>
)}
</RSlider.Root>
);
};
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="modern-sk-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
+45
View File
@@ -0,0 +1,45 @@
import { useId, type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
const gid = `modern-sk-groove-${useId()}`;
return (
<span
role="status"
aria-label="Loading"
className={cx('modern-sk-spinner', size && `modern-sk-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<defs>
<linearGradient
id={gid}
x1="18"
y1="4"
x2="18"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--spin-groove-1)" />
<stop offset="1" stopColor="var(--spin-groove-2)" />
</linearGradient>
</defs>
<circle cx="18" cy="18" r="14" stroke={`url(#${gid})`} strokeWidth="5" />
<circle
className="modern-sk-spinner__arc"
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</svg>
</span>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { type ComponentPropsWithoutRef } from 'react';
import { cx } from '../utils';
export const Table = ({ children, ...props }: ComponentPropsWithoutRef<'table'>) => (
<div className="modern-sk-table-wrap">
<table className="modern-sk-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} />;
+28
View File
@@ -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('modern-sk-tabs', className)} {...props}>
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="modern-sk-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
+26
View File
@@ -0,0 +1,26 @@
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cx } from '../utils';
export const TextField = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>(
({ className, ...props }, ref) => (
<input ref={ref} className={cx('modern-sk-field', className)} {...props} />
),
);
TextField.displayName = 'TextField';
export const TextArea = forwardRef<HTMLTextAreaElement, ComponentPropsWithoutRef<'textarea'>>(
({ className, ...props }, ref) => (
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} />
),
);
TextArea.displayName = 'TextArea';
export const SearchField = ({
icon,
...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
<div className="modern-sk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
+42
View File
@@ -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('modern-sk-tooltip', className)}
sideOffset={sideOffset}
{...contentProps}
>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
+21 -679
View File
@@ -1,679 +1,21 @@
/* ============================================================
ModernSK UI — Radix Primitives wrapped in the ModernSK look.
Logic/accessibility from Radix; every pixel from the tokens in
styles/tokens.css + styles/components.css.
============================================================ */
import {
forwardRef,
useId,
type ComponentPropsWithoutRef,
type CSSProperties,
type ReactNode,
} from 'react';
import {
Switch as RSwitch,
Checkbox as RCheckbox,
RadioGroup as RRadioGroup,
Tabs as RTabs,
Slider as RSlider,
DropdownMenu as RMenu,
Tooltip as RTooltip,
Progress as RProgress,
Select as RSelect,
ToggleGroup as RToggleGroup,
Dialog as RDialog,
AlertDialog as RAlertDialog,
ScrollArea as RScrollArea,
} from 'radix-ui';
import { Check, CaretDown, X } from '@phosphor-icons/react';
const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
/* ---------- BUTTON ---------- */
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(
'modern-sk-btn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size === 'sm' && 'modern-sk-btn--sm',
iconOnly && 'modern-sk-btn--icon',
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';
/* ---------- TEXT FIELD ---------- */
export const TextField = forwardRef<
HTMLInputElement,
ComponentPropsWithoutRef<'input'>
>(({ className, ...props }, ref) => (
<input ref={ref} className={cx('modern-sk-field', className)} {...props} />
));
TextField.displayName = 'TextField';
export const TextArea = forwardRef<
HTMLTextAreaElement,
ComponentPropsWithoutRef<'textarea'>
>(({ className, ...props }, ref) => (
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} />
));
TextArea.displayName = 'TextArea';
export const SearchField = ({
icon,
...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
<div className="modern-sk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
</div>
);
/* ---------- SELECT (Radix) ---------- */
type SelectProps = {
value?: string;
defaultValue?: string;
onValueChange?: (v: string) => void;
placeholder?: string;
items: Array<{ value: string; label: string }>;
'aria-label'?: string;
};
export const Select = ({
value,
defaultValue,
onValueChange,
placeholder,
items,
...rest
}: SelectProps) => (
<RSelect.Root
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
>
<RSelect.Trigger className="modern-sk-select" aria-label={rest['aria-label']}>
<RSelect.Value placeholder={placeholder} />
<RSelect.Icon className="modern-sk-select__icon">
<CaretDown size={12} weight="bold" />
</RSelect.Icon>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
className="modern-sk-select__content"
position="popper"
sideOffset={6}
>
<RSelect.Viewport>
{items.map((it) => (
<RSelect.Item
key={it.value}
value={it.value}
className="modern-sk-select__item"
>
<RSelect.ItemText>{it.label}</RSelect.ItemText>
<RSelect.ItemIndicator className="modern-sk-select__item-indicator">
<Check size={14} weight="bold" />
</RSelect.ItemIndicator>
</RSelect.Item>
))}
</RSelect.Viewport>
</RSelect.Content>
</RSelect.Portal>
</RSelect.Root>
);
/* ---------- SWITCH ---------- */
export const Switch = (
props: ComponentPropsWithoutRef<typeof RSwitch.Root>,
) => (
<RSwitch.Root className="modern-sk-switch" {...props}>
<RSwitch.Thumb className="modern-sk-switch__thumb" />
</RSwitch.Root>
);
/* ---------- CHECKBOX ---------- */
export const Checkbox = (
props: ComponentPropsWithoutRef<typeof RCheckbox.Root>,
) => (
<RCheckbox.Root className="modern-sk-check" {...props}>
<RCheckbox.Indicator className="modern-sk-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>
);
/* ---------- RADIO GROUP ---------- */
export const RadioGroup = RRadioGroup.Root;
export const RadioItem = ({
value,
...props
}: ComponentPropsWithoutRef<typeof RRadioGroup.Item>) => (
<RRadioGroup.Item className="modern-sk-radio" value={value} {...props}>
<RRadioGroup.Indicator className="modern-sk-radio__indicator" />
</RRadioGroup.Item>
);
/* control + label row helper */
export const Control = ({
children,
control,
}: {
children: ReactNode;
control: ReactNode;
}) => (
<label className="modern-sk-control">
{control}
{children}
</label>
);
/* ---------- SEGMENTED CONTROL (ToggleGroup single) ---------- */
type SegProps = {
value: string;
onValueChange: (v: string) => void;
items: Array<{ value: string; label: string }>;
};
export const SegmentedControl = ({
value,
onValueChange,
items,
}: SegProps) => (
<RToggleGroup.Root
type="single"
className="modern-sk-seg"
value={value}
onValueChange={(v) => v && onValueChange(v)}
>
{items.map((it) => (
<RToggleGroup.Item
key={it.value}
value={it.value}
className="modern-sk-seg__item"
>
{it.label}
</RToggleGroup.Item>
))}
</RToggleGroup.Root>
);
/* ---------- SLIDER ---------- */
export const Slider = (
props: ComponentPropsWithoutRef<typeof RSlider.Root>,
) => (
<RSlider.Root className="modern-sk-slider" {...props}>
<RSlider.Track className="modern-sk-slider__track">
<RSlider.Range className="modern-sk-slider__range" />
</RSlider.Track>
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
</RSlider.Root>
);
/* ---------- STEPPER ---------- */
export const Stepper = ({
onDecrement,
onIncrement,
}: {
onDecrement: () => void;
onIncrement: () => void;
}) => (
<div className="modern-sk-stepper">
<button type="button" onClick={onDecrement} aria-label="Decrease">
</button>
<button type="button" onClick={onIncrement} aria-label="Increase">
+
</button>
</div>
);
/* ---------- TABS ---------- */
export const Tabs = RTabs.Root;
export const TabsList = ({
items,
}: {
items: Array<{ value: string; label: string }>;
}) => (
<RTabs.List className="modern-sk-tabs">
{items.map((it) => (
<RTabs.Trigger
key={it.value}
value={it.value}
className="modern-sk-tabs__trigger"
>
{it.label}
</RTabs.Trigger>
))}
</RTabs.List>
);
export const TabsContent = RTabs.Content;
/* ---------- PROGRESS ---------- */
export const Progress = ({ value = 0 }: { value?: number }) => (
<RProgress.Root className="modern-sk-progress" value={value}>
<RProgress.Indicator
className="modern-sk-progress__indicator"
style={{ width: `${value}%` }}
/>
</RProgress.Root>
);
/* ---------- BADGE ---------- */
type BadgeVariant = 'lime' | 'ember' | 'neutral' | 'outline';
export const Badge = ({
variant = 'neutral',
dot,
className,
children,
...props
}: ComponentPropsWithoutRef<'span'> & {
variant?: BadgeVariant;
dot?: boolean;
}) => (
<span
className={cx(
'modern-sk-badge',
`modern-sk-badge--${variant}`,
dot && 'modern-sk-badge--dot',
className,
)}
{...props}
>
{children}
</span>
);
/* ---------- CHIP ---------- */
export const Chip = ({
children,
onRemove,
}: {
children: ReactNode;
onRemove?: () => void;
}) => (
<span className="modern-sk-chip">
{children}
{onRemove && (
<button type="button" className="x" onClick={onRemove} aria-label="Remove">
×
</button>
)}
</span>
);
/* ---------- CARD ---------- */
export const Card = ({
className,
...props
}: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-card', className)} {...props} />
);
/* ---------- LIST ---------- */
export const List = ({
className,
...props
}: ComponentPropsWithoutRef<'div'>) => (
<div className={cx('modern-sk-list', className)} {...props} />
);
export const Row = ({
selected,
className,
...props
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
<div
className={cx('modern-sk-row', selected && 'is-selected', className)}
{...props}
/>
);
/* ---------- DROPDOWN MENU (Radix) ---------- */
export const Menu = RMenu.Root;
export const MenuTrigger = RMenu.Trigger;
export const MenuContent = ({
children,
...props
}: ComponentPropsWithoutRef<typeof RMenu.Content>) => (
<RMenu.Portal>
<RMenu.Content className="modern-sk-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="modern-sk-menu-item" {...props}>
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</RMenu.Item>
);
export const MenuSeparator = () => (
<RMenu.Separator className="modern-sk-menu-sep" />
);
/* Static menu surface — for showcasing the menu without a trigger. */
export const MenuSurface = ({ children }: { children: ReactNode }) => (
<div className="modern-sk-menu">{children}</div>
);
export const MenuRow = ({
icon,
shortcut,
children,
}: {
icon?: ReactNode;
shortcut?: string;
children: ReactNode;
}) => (
<div className="modern-sk-menu-item">
{icon && <span className="ph">{icon}</span>}
{children}
{shortcut && <span className="sc">{shortcut}</span>}
</div>
);
/* ---------- TOOLTIP (Radix) ---------- */
export const Tooltip = ({
content,
children,
}: {
content: ReactNode;
children: ReactNode;
}) => (
<RTooltip.Root>
<RTooltip.Trigger asChild>{children}</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content className="modern-sk-tooltip" sideOffset={6}>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
);
/* ---------- ICON 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(
'modern-sk-btn',
'modern-sk-iconbtn',
variant !== 'key' && `modern-sk-btn--${variant}`,
size && `modern-sk-iconbtn--${size}`,
className,
)}
{...props}
/>
),
);
IconButton.displayName = 'IconButton';
/* ---------- SPINNER ----------
Carved donut groove (sunk like the switch well, dark rim at top →
light catch at the bottom) with a glossy lime arc spinning inside it. */
export const Spinner = ({
size,
className,
...props
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
const gid = `modern-sk-groove-${useId()}`;
return (
<span
role="status"
aria-label="Loading"
className={cx('modern-sk-spinner', size && `modern-sk-spinner--${size}`, className)}
{...props}
>
<svg viewBox="0 0 36 36" fill="none">
<defs>
<linearGradient
id={gid}
x1="18"
y1="4"
x2="18"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="var(--spin-groove-1)" />
<stop offset="1" stopColor="var(--spin-groove-2)" />
</linearGradient>
</defs>
{/* carved channel */}
<circle cx="18" cy="18" r="14" stroke={`url(#${gid})`} strokeWidth="5" />
{/* glossy lime arc, nested inside the groove with rounded ends */}
<circle
className="modern-sk-spinner__arc"
cx="18"
cy="18"
r="14"
stroke="var(--lime)"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray="22 88"
/>
</svg>
</span>
);
};
/* ---------- CALLOUT ---------- */
type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
export const Callout = ({
variant = 'info',
icon,
children,
}: {
variant?: CalloutVariant;
icon?: ReactNode;
children: ReactNode;
}) => (
<div className={cx('modern-sk-callout', variant !== 'info' && `modern-sk-callout--${variant}`)}>
{icon && <span className="modern-sk-callout__icon">{icon}</span>}
<div className="modern-sk-callout__body">{children}</div>
</div>
);
/* ---------- TABLE ---------- */
export const Table = ({
children,
...props
}: ComponentPropsWithoutRef<'table'>) => (
<div className="modern-sk-table-wrap">
<table className="modern-sk-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} />;
/* ---------- SCROLL AREA (Radix) ---------- */
export const ScrollArea = ({
children,
className,
style,
}: {
children: ReactNode;
className?: string;
style?: CSSProperties;
}) => (
<RScrollArea.Root className={cx('modern-sk-scroll', className)} style={style}>
<RScrollArea.Viewport className="modern-sk-scroll__viewport">
{children}
</RScrollArea.Viewport>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="vertical">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Scrollbar className="modern-sk-scroll__bar" orientation="horizontal">
<RScrollArea.Thumb className="modern-sk-scroll__thumb" />
</RScrollArea.Scrollbar>
<RScrollArea.Corner />
</RScrollArea.Root>
);
/* ---------- DIALOG / MODAL (Radix Dialog) ---------- */
export const Dialog = ({
trigger,
title,
description,
children,
footer,
open,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RDialog.Trigger asChild>{trigger}</RDialog.Trigger>}
<RDialog.Portal>
<RDialog.Overlay className="modern-sk-overlay" />
<RDialog.Content className="modern-sk-dialog">
<RDialog.Title className="modern-sk-dialog__title">{title}</RDialog.Title>
{description && (
<RDialog.Description className="modern-sk-dialog__desc">
{description}
</RDialog.Description>
)}
{children && <div className="modern-sk-dialog__body">{children}</div>}
{footer && <div className="modern-sk-dialog__footer">{footer}</div>}
<RDialog.Close asChild>
<IconButton
variant="ghost"
size="sm"
className="modern-sk-dialog__close"
aria-label="Close"
>
<X size={14} weight="bold" />
</IconButton>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
);
/* Low-level Dialog parts (for custom compositions). */
export const DialogClose = RDialog.Close;
/* ---------- ALERT DIALOG (Radix AlertDialog) ---------- */
export const AlertDialog = ({
trigger,
title,
description,
cancelLabel = 'Cancel',
actionLabel = 'Confirm',
destructive,
onAction,
open,
onOpenChange,
}: {
trigger?: ReactNode;
title: string;
description?: ReactNode;
cancelLabel?: string;
actionLabel?: string;
destructive?: boolean;
onAction?: () => void;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) => (
<RAlertDialog.Root open={open} onOpenChange={onOpenChange}>
{trigger && <RAlertDialog.Trigger asChild>{trigger}</RAlertDialog.Trigger>}
<RAlertDialog.Portal>
<RAlertDialog.Overlay className="modern-sk-overlay" />
<RAlertDialog.Content className="modern-sk-dialog">
<RAlertDialog.Title className="modern-sk-dialog__title">
{title}
</RAlertDialog.Title>
{description && (
<RAlertDialog.Description className="modern-sk-dialog__desc">
{description}
</RAlertDialog.Description>
)}
<div className="modern-sk-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>
);
/* ---------- WINDOW CHROME ---------- */
export const Window = ({
title,
badge,
children,
...props
}: ComponentPropsWithoutRef<'div'> & {
title: string;
badge?: ReactNode;
}) => (
<div className="modern-sk-window" {...props}>
<div className="modern-sk-titlebar">
<span className="modern-sk-traffic r" />
<span className="modern-sk-traffic y" />
<span className="modern-sk-traffic g" />
<span className="ttl">{title}</span>
{badge && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{badge}
</div>
)}
</div>
{children}
</div>
);
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';
export * from './window';
+2
View File
@@ -0,0 +1,2 @@
export const cx = (...c: Array<string | false | undefined>) =>
c.filter(Boolean).join(' ');
+26
View File
@@ -0,0 +1,26 @@
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
export const Window = ({
title,
badge,
children,
...props
}: ComponentPropsWithoutRef<'div'> & {
title: string;
badge?: ReactNode;
}) => (
<div className="modern-sk-window" {...props}>
<div className="modern-sk-titlebar">
<span className="modern-sk-traffic r" />
<span className="modern-sk-traffic y" />
<span className="modern-sk-traffic g" />
<span className="ttl">{title}</span>
{badge && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{badge}
</div>
)}
</div>
{children}
</div>
);
+64
View File
@@ -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={() => {}}
/>
),
};
+62
View File
@@ -0,0 +1,62 @@
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' },
},
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' },
};
+74
View File
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import {
Card,
List,
Row,
Table,
THead,
TBody,
Tr,
Th,
Td,
Badge,
} from '../components/ui';
const meta = {
title: 'Data Display/Surfaces',
component: Card,
parameters: {
docs: {
description: {
component: 'Cards, selectable lists/rows, and the bordered table.',
},
},
},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CardSurface: Story = {
render: () => (
<Card style={{ maxWidth: 320, padding: 20 }}>
<h3 className="modern-sk-h3">Storage</h3>
<p className="modern-sk-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 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>
),
};
+54
View File
@@ -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',
},
};
+77
View File
@@ -0,0 +1,77 @@
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.',
},
},
},
} satisfies Meta<typeof Progress>;
export default meta;
type Story = StoryObj<typeof meta>;
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>
),
};
+48
View File
@@ -0,0 +1,48 @@
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'] },
size: { control: 'inline-radio', options: ['sm', undefined, 'lg'] },
},
} 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>
),
};
+42
View File
@@ -0,0 +1,42 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Getting Started/Introduction" />
# ModernSK
Tactile, dark-first React components built on [Radix](https://www.radix-ui.com/) primitives.
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
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 'modern-sk/styles.css'; // required — tokens + components
import 'modern-sk/fonts.css'; // optional — branded faces
import { ThemeProvider, TooltipProvider, Button } from 'modern-sk';
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 dark and
light. In an app the same lever is `data-theme` on `<html>`, managed by
`ThemeProvider` / `useTheme()`.
## Fonts
Components read the `--font-display`, `--font-sans` and `--font-mono` tokens. Import
`modern-sk/fonts.css` for the branded faces, or override those tokens to map the
components onto fonts your app already loads.
+87
View File
@@ -0,0 +1,87 @@
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: false },
},
args: {
content: '',
children: null,
},
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof meta>;
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={() => {}}
/>
),
};
+30
View File
@@ -0,0 +1,30 @@
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 ModernSK skin. Pass `items` plus an optional `placeholder`; control it with `value` / `onValueChange` or leave it uncontrolled with `defaultValue`.',
},
},
},
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' } };
+74
View File
@@ -0,0 +1,74 @@
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.',
},
},
},
} satisfies Meta<typeof Switch>;
export default meta;
type Story = StoryObj<typeof meta>;
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 /> };
+23
View File
@@ -0,0 +1,23 @@
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`).',
},
},
},
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = { args: { defaultValue: [60], max: 100, step: 1 } };
export const Stepped: Story = { args: { defaultValue: [40], max: 100, step: 10 } };
+41
View File
@@ -0,0 +1,41 @@
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`.',
},
},
},
} 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="modern-sk-body">
Project at a glance.
</TabsContent>
<TabsContent value="activity" style={{ paddingTop: 16 }} className="modern-sk-body">
Recent activity feed.
</TabsContent>
<TabsContent value="settings" style={{ paddingTop: 16 }} className="modern-sk-body">
Preferences and configuration.
</TabsContent>
</Tabs>
),
};
+38
View File
@@ -0,0 +1,38 @@
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 `modern-sk-field` look and forward all native props.',
},
},
},
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 }}
/>
),
};
+31
View File
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import { Window, Badge, List, Row } from '../components/ui';
const meta = {
title: 'Layout/Window',
component: Window,
parameters: {
docs: {
description: {
component:
'macOS-style window chrome: traffic lights, a title, an optional `badge` slot, and arbitrary children for the body.',
},
},
},
args: { title: 'Finder' },
} satisfies Meta<typeof Window>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<Window {...args} style={{ width: 380 }} badge={<Badge variant="lime" dot>Synced</Badge>}>
<List style={{ padding: 12 }}>
<Row selected>Documents</Row>
<Row>Downloads</Row>
<Row>Pictures</Row>
</List>
</Window>
),
};
+11 -4
View File
@@ -165,10 +165,11 @@ textarea.modern-sk-field{ resize:vertical; min-height:64px; line-height:1.5; }
.modern-sk-control{ display:inline-flex; align-items:center; gap:10px; font-size:14px; color:var(--fg-2); cursor:pointer; }
/* ---------- SEGMENTED CONTROL (Radix ToggleGroup) ---------- */
.modern-sk-seg{ display:inline-flex; background:var(--steel-900); border:1px solid var(--edge-inset); border-radius:var(--r-md); padding:3px; box-shadow:var(--shadow-inset-well); gap:2px; }
.modern-sk-seg__item{ font-family:var(--font-sans); font-size:13px; font-weight:600; color:var(--fg-2); background:transparent; border:none; padding:var(--seg-pad-y) 14px; border-radius:var(--r-sm); cursor:pointer; transition:color var(--dur-quick), background var(--dur-quick); }
.modern-sk-seg{ position:relative; display:inline-flex; background:var(--steel-900); border:1px solid var(--edge-inset); border-radius:var(--r-md); padding:3px; box-shadow:var(--shadow-inset-well); gap:2px; }
.modern-sk-seg__thumb{ position:absolute; top:3px; left:0; height:calc(100% - 6px); background:var(--grad-key); border-radius:var(--r-sm); box-shadow:var(--shadow-raised); pointer-events:none; transition:transform 180ms var(--ease-snap), width 180ms var(--ease-snap); will-change:transform,width; }
.modern-sk-seg__item{ position:relative; z-index:1; font-family:var(--font-sans); font-size:13px; font-weight:600; color:var(--fg-2); background:transparent; border:none; padding:var(--seg-pad-y) 14px; border-radius:var(--r-sm); cursor:pointer; transition:color var(--dur-quick); }
.modern-sk-seg__item:hover{ color:var(--fg-1); }
.modern-sk-seg__item[data-state="on"]{ color:var(--fg-1); background:var(--grad-key); box-shadow:var(--shadow-raised); }
.modern-sk-seg__item[data-state="on"]{ color:var(--fg-1); }
/* ---------- SLIDER ---------- */
.modern-sk-slider{ position:relative; display:flex; align-items:center; width:200px; height:20px; user-select:none; touch-action:none; }
@@ -176,6 +177,12 @@ textarea.modern-sk-field{ resize:vertical; min-height:64px; line-height:1.5; }
.modern-sk-slider__range{ position:absolute; height:100%; border-radius:3px; background:linear-gradient(90deg,var(--lime-deep),var(--lime)); box-shadow:0 0 10px rgba(190,242,100,.4); }
.modern-sk-slider__thumb{ display:block; width:20px; height:20px; border-radius:50%; background:linear-gradient(180deg,#fff,#e6e8dd); box-shadow:0 2px 5px rgba(0,0,0,.5), 0 1px 0 rgba(255,255,255,.9) inset; cursor:pointer; outline:none; }
.modern-sk-slider__thumb:focus-visible{ box-shadow:0 2px 5px rgba(0,0,0,.5), 0 1px 0 rgba(255,255,255,.9) inset, var(--focus-ring); }
.modern-sk-slider--has-steps{ padding-bottom:22px; }
.modern-sk-slider__step-dot{ position:absolute; top:50%; width:4px; height:4px; border-radius:50%; background:var(--steel-500); transform:translate(-50%,-50%); z-index:1; pointer-events:none; }
.modern-sk-slider__steps{ position:absolute; left:10px; right:10px; top:calc(50% + 6px); pointer-events:none; }
.modern-sk-slider__step{ position:absolute; transform:translateX(-50%); display:flex; flex-direction:column; align-items:center; gap:3px; }
.modern-sk-slider__step-tick{ width:1px; height:5px; background:var(--steel-600); }
.modern-sk-slider__step-label{ font-family:var(--font-mono); font-size:10px; line-height:1; color:var(--fg-3); white-space:nowrap; }
/* ---------- STEPPER ---------- */
.modern-sk-stepper{ display:inline-flex; border-radius:var(--r-md); overflow:hidden; box-shadow:var(--shadow-raised); border:1px solid var(--hair-strong); }
@@ -310,7 +317,7 @@ textarea.modern-sk-field{ resize:vertical; min-height:64px; line-height:1.5; }
.modern-sk-overlay{ position:fixed; inset:0; z-index:80; background:rgba(8,9,6,.55); backdrop-filter:blur(3px); -webkit-backdrop-filter:blur(3px); }
.modern-sk-overlay[data-state="open"]{ animation:modern-sk-fade-in var(--dur-base) var(--ease-out); }
.modern-sk-overlay[data-state="closed"]{ animation:modern-sk-fade-out var(--dur-quick) var(--ease-out); }
.modern-sk-dialog{ position:fixed; z-index:81; top:50%; left:50%; transform:translate(-50%,-50%); width:min(92vw,460px); max-height:85vh; overflow:auto; border-radius:var(--r-xl); border:1px solid var(--hair-strong); background:var(--steel-800); box-shadow:var(--shadow-window); padding:22px 22px 20px; transform-origin:center; }
.modern-sk-dialog{ position:fixed; z-index:81; top:50%; left:50%; translate:-50% -50%; width:min(92vw,460px); max-height:85vh; overflow:auto; border-radius:var(--r-xl); border:1px solid var(--hair-strong); background:var(--steel-800); box-shadow:var(--shadow-window); padding:22px 22px 20px; transform-origin:center; font-family:var(--font-sans); }
.modern-sk-dialog[data-state="open"]{ animation:modern-sk-scale-in var(--dur-base) var(--ease-out); }
.modern-sk-dialog[data-state="closed"]{ animation:modern-sk-scale-out var(--dur-quick) var(--ease-out); }
.modern-sk-dialog__title{ font-family:var(--font-sans); font-size:var(--text-lg); font-weight:600; color:var(--fg-1); letter-spacing:var(--track-snug); }