Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4919bc26e5 | |||
| a5d2742c7c | |||
| 22afa7e1a5 | |||
| 67993ae3ec | |||
| 2f937e94b1 | |||
| b6cf54c836 |
@@ -7,6 +7,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Storybook build output (the .storybook config + *.stories.tsx ARE committed)
|
||||||
|
storybook-static/
|
||||||
|
|
||||||
# Profile
|
# Profile
|
||||||
.rspack-profile-*/
|
.rspack-profile-*/
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { addons } from 'storybook/manager-api';
|
||||||
|
import theme from './theme';
|
||||||
|
|
||||||
|
addons.setConfig({ theme });
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
`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 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 .
|
||||||
|
```
|
||||||
|
|
||||||
|
No test suite exists. `npm run build` runs automatically on install via the `prepare` script — consumers build the package themselves.
|
||||||
|
|
||||||
|
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 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):** 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.
|
||||||
|
|
||||||
|
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 `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 (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 `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 `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
|
||||||
|
|
||||||
|
- React 19, React Compiler is enabled (`babel-plugin-react-compiler` in the Rsbuild dev pipeline).
|
||||||
|
- TypeScript is `noEmit` + `verbatimModuleSyntax`: use `import type` for type-only imports. `noUnusedLocals`/`noUnusedParameters` are on.
|
||||||
|
- ESM-only package (`"type": "module"`).
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
# @modernsk/ui
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="assets/logo.svg" alt="ModernSK" width="380" />
|
||||||
|
|
||||||
|
**Tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives.**
|
||||||
|
|
||||||
Tactile, dark-first React component library built on [Radix](https://www.radix-ui.com/) primitives.
|
|
||||||
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
|
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Git-hosted (pin to a tag):
|
Distributed via self-hosted git — install straight from the repo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install github:YOUR_ORG/modernsk-ui#v0.1.0
|
npm i git+ssh://git@git.ollyhearn.ru:49239/olly/modern-sk.git
|
||||||
```
|
```
|
||||||
|
|
||||||
`react` and `react-dom` (>=18) are peer dependencies — your app provides them.
|
`react` and `react-dom` (>=18) are peer dependencies — your app provides them.
|
||||||
@@ -19,8 +26,9 @@ The package builds itself on install via the `prepare` script.
|
|||||||
Import the stylesheet once at your app root, then use components anywhere:
|
Import the stylesheet once at your app root, then use components anywhere:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import '@modernsk/ui/styles.css';
|
import 'modern-sk/styles.css'; // required — tokens + components
|
||||||
import { ThemeProvider, TooltipProvider, Button, Card } from '@modernsk/ui';
|
import 'modern-sk/fonts.css'; // optional — branded faces (see Fonts)
|
||||||
|
import { ThemeProvider, TooltipProvider, Button, Card } from 'modern-sk';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -37,18 +45,56 @@ export function App() {
|
|||||||
|
|
||||||
- `ThemeProvider` manages dark/light via `data-theme` on `<html>` and persists to `localStorage`. Read it with `useTheme()`.
|
- `ThemeProvider` manages dark/light via `data-theme` on `<html>` and persists to `localStorage`. Read it with `useTheme()`.
|
||||||
- `TooltipProvider` must wrap any tree that uses `<Tooltip>`.
|
- `TooltipProvider` must wrap any tree that uses `<Tooltip>`.
|
||||||
- All visuals come from CSS custom properties in the shipped stylesheet; the self-hosted display font is inlined, so no extra asset hosting is needed.
|
- All visuals come from CSS custom properties in the shipped stylesheet.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
`modern-sk/styles.css` ships **no fonts**. The type tokens default to a chain
|
||||||
|
that degrades to `system-ui`, so the library works with zero font loading.
|
||||||
|
|
||||||
|
To get the branded ModernSK faces (Anta display + Onest + Geist Mono), import
|
||||||
|
the optional stylesheet — Anta is self-hosted and inlined, no asset hosting
|
||||||
|
needed:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import 'modern-sk/fonts.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
To use your **own** fonts, skip `fonts.css` and override the tokens anywhere
|
||||||
|
after `styles.css` — every component re-reads them:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--font-display: 'Your Display', sans-serif;
|
||||||
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
A live playground (every component on one page) runs via Rsbuild:
|
|
||||||
|
|
||||||
```bash
|
```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 build # build the publishable package into dist/
|
npm run storybook # Storybook component explorer + docs at http://localhost:6006
|
||||||
|
npm run build # build the publishable package into dist/
|
||||||
npm run lint
|
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
|
## What ships
|
||||||
|
|
||||||
`npm publish` / git-install exposes only `dist/` — built ESM + CJS, `.d.ts` types, and `styles.css`. The playground (`src/App.tsx`, Rsbuild config) is dev-only and never published.
|
A git install exposes only `dist/` — built ESM + CJS, `.d.ts` types,
|
||||||
|
`styles.css`, and `fonts.css`. Everything else (`src/App.tsx`, `.storybook/`,
|
||||||
|
stories, Rsbuild config) is dev-only and never published.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
brought to you by **ollyhearn** & **claude** with <3
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<svg width="440" height="140" viewBox="0 0 440 140" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ModernSK">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="140" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#1b1d15"/>
|
||||||
|
<stop offset="1" stop-color="#0d0e0a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sk" x1="300" y1="0" x2="420" y2="140" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#d4ff7a"/>
|
||||||
|
<stop offset="1" stop-color="#a8e04a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="1" y="1" width="438" height="138" rx="22" fill="url(#panel)" stroke="#2a2c22" stroke-width="1.5"/>
|
||||||
|
<rect x="6" y="6" width="428" height="2" rx="1" fill="#ffffff" opacity="0.05"/>
|
||||||
|
|
||||||
|
<text x="50%" y="50%" dominant-baseline="central" text-anchor="middle"
|
||||||
|
font-family="'Anta', 'Onest', system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||||
|
font-size="58" font-weight="700" letter-spacing="2">
|
||||||
|
<tspan fill="#e7e8e0">MODERN</tspan><tspan fill="url(#sk)">SK</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
Generated
+4863
-2
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@modernsk/ui",
|
"name": "modern-sk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "ModernSK — tactile, dark-first React component library built on Radix primitives.",
|
"description": "ModernSK — tactile, dark-first React component library built on Radix primitives.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -16,16 +16,19 @@
|
|||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.cjs"
|
||||||
},
|
},
|
||||||
"./styles.css": "./dist/styles.css"
|
"./styles.css": "./dist/styles.css",
|
||||||
|
"./fonts.css": "./dist/fonts.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup && npm run build:css",
|
"build": "tsup && npm run build:css",
|
||||||
"build:css": "esbuild src/styles/index.css --bundle --loader:.ttf=dataurl --outfile=dist/styles.css",
|
"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",
|
"dev": "rsbuild dev --open",
|
||||||
"preview": "rsbuild preview",
|
"preview": "rsbuild preview",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build",
|
||||||
"lint": "rslint",
|
"lint": "rslint",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"prepare": "npm run build"
|
"prepare": "npm run build"
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
"@rsbuild/plugin-babel": "^1.2.0",
|
"@rsbuild/plugin-babel": "^1.2.0",
|
||||||
"@rsbuild/plugin-react": "^2.0.0",
|
"@rsbuild/plugin-react": "^2.0.0",
|
||||||
"@rslint/core": "^0.5.1",
|
"@rslint/core": "^0.5.1",
|
||||||
|
"@storybook/addon-docs": "^10.4.1",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
@@ -50,6 +54,8 @@
|
|||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"storybook": "^10.4.1",
|
||||||
|
"storybook-react-rsbuild": "^3.3.4",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -76,7 +76,7 @@ const App = () => {
|
|||||||
const [chips, setChips] = useState(['design', '2026', 'invoices']);
|
const [chips, setChips] = useState(['design', '2026', 'invoices']);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="msk-felt">
|
<div className="modern-sk-felt">
|
||||||
<div className="wrap">
|
<div className="wrap">
|
||||||
<header>
|
<header>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
@@ -87,8 +87,8 @@ const App = () => {
|
|||||||
<p className="sub">
|
<p className="sub">
|
||||||
Every component, live and interactive, built on Radix Primitives
|
Every component, live and interactive, built on Radix Primitives
|
||||||
styled from the global tokens in{' '}
|
styled from the global tokens in{' '}
|
||||||
<span className="msk-mono">tokens.css</span> +{' '}
|
<span className="modern-sk-mono">tokens.css</span> +{' '}
|
||||||
<span className="msk-mono">components.css</span>. Click, toggle,
|
<span className="modern-sk-mono">components.css</span>. Click, toggle,
|
||||||
focus — it all responds.
|
focus — it all responds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,11 +238,12 @@ const App = () => {
|
|||||||
<div className="cap">Slider & stepper</div>
|
<div className="cap">Slider & stepper</div>
|
||||||
<div className="cluster">
|
<div className="cluster">
|
||||||
<Slider defaultValue={[62]} max={100} step={1} />
|
<Slider defaultValue={[62]} max={100} step={1} />
|
||||||
|
<Slider defaultValue={[40]} max={100} step={20} marks />
|
||||||
<Stepper
|
<Stepper
|
||||||
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
|
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
|
||||||
onIncrement={() => setCount((n) => n + 1)}
|
onIncrement={() => setCount((n) => n + 1)}
|
||||||
/>
|
/>
|
||||||
<span className="msk-mono" style={{ color: 'var(--fg-2)' }}>
|
<span className="modern-sk-mono" style={{ color: 'var(--fg-2)' }}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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} />
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
|
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={`modern-sk-slider__notches modern-sk-slider__notches--${side}`} aria-hidden>
|
||||||
|
{marks.map((mark) => (
|
||||||
|
<span
|
||||||
|
key={mark.value}
|
||||||
|
className="modern-sk-slider__notch"
|
||||||
|
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Slider = ({
|
||||||
|
marks,
|
||||||
|
notches = 'bottom',
|
||||||
|
knobStyle = 'square',
|
||||||
|
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 cls = [
|
||||||
|
'modern-sk-slider',
|
||||||
|
`modern-sk-slider--knob-${knobStyle}`,
|
||||||
|
hasMarks && 'modern-sk-slider--has-marks',
|
||||||
|
hasLabels && 'modern-sk-slider--has-labels',
|
||||||
|
showTop && 'modern-sk-slider--notch-top',
|
||||||
|
showBottom && 'modern-sk-slider--notch-bottom',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RSlider.Root className={cls} min={min} max={max} step={step} {...props}>
|
||||||
|
<RSlider.Track className="modern-sk-slider__track">
|
||||||
|
<RSlider.Range className="modern-sk-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="modern-sk-slider__labels" aria-hidden>
|
||||||
|
{resolved.map((mark) =>
|
||||||
|
mark.label != null ? (
|
||||||
|
<span
|
||||||
|
key={mark.value}
|
||||||
|
className="modern-sk-slider__label"
|
||||||
|
style={{ '--p': percent(mark.value, min, max) } as CSSProperties}
|
||||||
|
>
|
||||||
|
{mark.label}
|
||||||
|
</span>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</RSlider.Track>
|
||||||
|
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useId, type ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { cx } from '../utils';
|
||||||
|
|
||||||
|
export const Spinner = ({
|
||||||
|
size,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
|
||||||
|
const uid = useId();
|
||||||
|
const grooveId = `modern-sk-groove-${uid}`;
|
||||||
|
const glowId = `modern-sk-glow-${uid}`;
|
||||||
|
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>
|
||||||
|
{/* Carved channel: flat ring sunk by a top inner shadow — like the switch well. */}
|
||||||
|
<filter id={grooveId} x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feComponentTransfer in="SourceAlpha" result="inv">
|
||||||
|
<feFuncA type="table" tableValues="1 0" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feGaussianBlur in="inv" stdDeviation="1" result="blur" />
|
||||||
|
<feOffset in="blur" dy="1" result="off" />
|
||||||
|
<feFlood floodColor="#000" floodOpacity="0.7" />
|
||||||
|
<feComposite in2="off" operator="in" />
|
||||||
|
<feComposite in2="SourceAlpha" operator="in" result="shadow" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
<feMergeNode in="shadow" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
{/* Soft round glow — generous region so it never clips to a square. */}
|
||||||
|
<filter id={glowId} x="-100%" y="-100%" width="300%" height="300%">
|
||||||
|
<feGaussianBlur stdDeviation="1.6" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="14"
|
||||||
|
stroke="var(--spin-track)"
|
||||||
|
strokeWidth="5"
|
||||||
|
filter={`url(#${grooveId})`}
|
||||||
|
/>
|
||||||
|
<g className="modern-sk-spinner__arc">
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="14"
|
||||||
|
stroke="var(--lime)"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="22 88"
|
||||||
|
opacity="0.8"
|
||||||
|
filter={`url(#${glowId})`}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="14"
|
||||||
|
stroke="var(--lime)"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="22 88"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} />;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
type ThemeMode = 'dark' | 'light';
|
type ThemeMode = 'dark' | 'light';
|
||||||
const KEY = 'msk-theme';
|
const KEY = 'modern-sk-theme';
|
||||||
|
|
||||||
const ThemeContext = createContext<{
|
const ThemeContext = createContext<{
|
||||||
theme: ThemeMode;
|
theme: ThemeMode;
|
||||||
|
|||||||
@@ -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
@@ -1,679 +1,21 @@
|
|||||||
/* ============================================================
|
export * from './button';
|
||||||
ModernSK UI — Radix Primitives wrapped in the ModernSK look.
|
export * from './icon-button';
|
||||||
Logic/accessibility from Radix; every pixel from the tokens in
|
export * from './text-field';
|
||||||
styles/tokens.css + styles/components.css.
|
export * from './select';
|
||||||
============================================================ */
|
export * from './selection';
|
||||||
import {
|
export * from './segmented-control';
|
||||||
forwardRef,
|
export * from './slider';
|
||||||
useId,
|
export * from './tabs';
|
||||||
type ComponentPropsWithoutRef,
|
export * from './progress';
|
||||||
type CSSProperties,
|
export * from './badge';
|
||||||
type ReactNode,
|
export * from './card';
|
||||||
} from 'react';
|
export * from './list';
|
||||||
import {
|
export * from './menu';
|
||||||
Switch as RSwitch,
|
export * from './tooltip';
|
||||||
Checkbox as RCheckbox,
|
export * from './spinner';
|
||||||
RadioGroup as RRadioGroup,
|
export * from './callout';
|
||||||
Tabs as RTabs,
|
export * from './table';
|
||||||
Slider as RSlider,
|
export * from './scroll-area';
|
||||||
DropdownMenu as RMenu,
|
export * from './dialog';
|
||||||
Tooltip as RTooltip,
|
export * from './alert-dialog';
|
||||||
Progress as RProgress,
|
export * from './window';
|
||||||
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(
|
|
||||||
'msk-btn',
|
|
||||||
variant !== 'key' && `msk-btn--${variant}`,
|
|
||||||
size === 'sm' && 'msk-btn--sm',
|
|
||||||
iconOnly && 'msk-btn--icon',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Button.displayName = 'Button';
|
|
||||||
|
|
||||||
/* ---------- TEXT FIELD ---------- */
|
|
||||||
export const TextField = forwardRef<
|
|
||||||
HTMLInputElement,
|
|
||||||
ComponentPropsWithoutRef<'input'>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<input ref={ref} className={cx('msk-field', className)} {...props} />
|
|
||||||
));
|
|
||||||
TextField.displayName = 'TextField';
|
|
||||||
|
|
||||||
export const TextArea = forwardRef<
|
|
||||||
HTMLTextAreaElement,
|
|
||||||
ComponentPropsWithoutRef<'textarea'>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<textarea ref={ref} className={cx('msk-field', className)} {...props} />
|
|
||||||
));
|
|
||||||
TextArea.displayName = 'TextArea';
|
|
||||||
|
|
||||||
export const SearchField = ({
|
|
||||||
icon,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
|
|
||||||
<div className="msk-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="msk-select" aria-label={rest['aria-label']}>
|
|
||||||
<RSelect.Value placeholder={placeholder} />
|
|
||||||
<RSelect.Icon className="msk-select__icon">
|
|
||||||
<CaretDown size={12} weight="bold" />
|
|
||||||
</RSelect.Icon>
|
|
||||||
</RSelect.Trigger>
|
|
||||||
<RSelect.Portal>
|
|
||||||
<RSelect.Content
|
|
||||||
className="msk-select__content"
|
|
||||||
position="popper"
|
|
||||||
sideOffset={6}
|
|
||||||
>
|
|
||||||
<RSelect.Viewport>
|
|
||||||
{items.map((it) => (
|
|
||||||
<RSelect.Item
|
|
||||||
key={it.value}
|
|
||||||
value={it.value}
|
|
||||||
className="msk-select__item"
|
|
||||||
>
|
|
||||||
<RSelect.ItemText>{it.label}</RSelect.ItemText>
|
|
||||||
<RSelect.ItemIndicator className="msk-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="msk-switch" {...props}>
|
|
||||||
<RSwitch.Thumb className="msk-switch__thumb" />
|
|
||||||
</RSwitch.Root>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- CHECKBOX ---------- */
|
|
||||||
export const Checkbox = (
|
|
||||||
props: ComponentPropsWithoutRef<typeof RCheckbox.Root>,
|
|
||||||
) => (
|
|
||||||
<RCheckbox.Root className="msk-check" {...props}>
|
|
||||||
<RCheckbox.Indicator className="msk-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="msk-radio" value={value} {...props}>
|
|
||||||
<RRadioGroup.Indicator className="msk-radio__indicator" />
|
|
||||||
</RRadioGroup.Item>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* control + label row helper */
|
|
||||||
export const Control = ({
|
|
||||||
children,
|
|
||||||
control,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
control: ReactNode;
|
|
||||||
}) => (
|
|
||||||
<label className="msk-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="msk-seg"
|
|
||||||
value={value}
|
|
||||||
onValueChange={(v) => v && onValueChange(v)}
|
|
||||||
>
|
|
||||||
{items.map((it) => (
|
|
||||||
<RToggleGroup.Item
|
|
||||||
key={it.value}
|
|
||||||
value={it.value}
|
|
||||||
className="msk-seg__item"
|
|
||||||
>
|
|
||||||
{it.label}
|
|
||||||
</RToggleGroup.Item>
|
|
||||||
))}
|
|
||||||
</RToggleGroup.Root>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- SLIDER ---------- */
|
|
||||||
export const Slider = (
|
|
||||||
props: ComponentPropsWithoutRef<typeof RSlider.Root>,
|
|
||||||
) => (
|
|
||||||
<RSlider.Root className="msk-slider" {...props}>
|
|
||||||
<RSlider.Track className="msk-slider__track">
|
|
||||||
<RSlider.Range className="msk-slider__range" />
|
|
||||||
</RSlider.Track>
|
|
||||||
<RSlider.Thumb className="msk-slider__thumb" aria-label="Value" />
|
|
||||||
</RSlider.Root>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- STEPPER ---------- */
|
|
||||||
export const Stepper = ({
|
|
||||||
onDecrement,
|
|
||||||
onIncrement,
|
|
||||||
}: {
|
|
||||||
onDecrement: () => void;
|
|
||||||
onIncrement: () => void;
|
|
||||||
}) => (
|
|
||||||
<div className="msk-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="msk-tabs">
|
|
||||||
{items.map((it) => (
|
|
||||||
<RTabs.Trigger
|
|
||||||
key={it.value}
|
|
||||||
value={it.value}
|
|
||||||
className="msk-tabs__trigger"
|
|
||||||
>
|
|
||||||
{it.label}
|
|
||||||
</RTabs.Trigger>
|
|
||||||
))}
|
|
||||||
</RTabs.List>
|
|
||||||
);
|
|
||||||
export const TabsContent = RTabs.Content;
|
|
||||||
|
|
||||||
/* ---------- PROGRESS ---------- */
|
|
||||||
export const Progress = ({ value = 0 }: { value?: number }) => (
|
|
||||||
<RProgress.Root className="msk-progress" value={value}>
|
|
||||||
<RProgress.Indicator
|
|
||||||
className="msk-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(
|
|
||||||
'msk-badge',
|
|
||||||
`msk-badge--${variant}`,
|
|
||||||
dot && 'msk-badge--dot',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- CHIP ---------- */
|
|
||||||
export const Chip = ({
|
|
||||||
children,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
onRemove?: () => void;
|
|
||||||
}) => (
|
|
||||||
<span className="msk-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('msk-card', className)} {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- LIST ---------- */
|
|
||||||
export const List = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<'div'>) => (
|
|
||||||
<div className={cx('msk-list', className)} {...props} />
|
|
||||||
);
|
|
||||||
export const Row = ({
|
|
||||||
selected,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<'div'> & { selected?: boolean }) => (
|
|
||||||
<div
|
|
||||||
className={cx('msk-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="msk-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="msk-menu-item" {...props}>
|
|
||||||
{icon && <span className="ph">{icon}</span>}
|
|
||||||
{children}
|
|
||||||
{shortcut && <span className="sc">{shortcut}</span>}
|
|
||||||
</RMenu.Item>
|
|
||||||
);
|
|
||||||
export const MenuSeparator = () => (
|
|
||||||
<RMenu.Separator className="msk-menu-sep" />
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Static menu surface — for showcasing the menu without a trigger. */
|
|
||||||
export const MenuSurface = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div className="msk-menu">{children}</div>
|
|
||||||
);
|
|
||||||
export const MenuRow = ({
|
|
||||||
icon,
|
|
||||||
shortcut,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
icon?: ReactNode;
|
|
||||||
shortcut?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) => (
|
|
||||||
<div className="msk-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="msk-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(
|
|
||||||
'msk-btn',
|
|
||||||
'msk-iconbtn',
|
|
||||||
variant !== 'key' && `msk-btn--${variant}`,
|
|
||||||
size && `msk-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 = `msk-groove-${useId()}`;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading"
|
|
||||||
className={cx('msk-spinner', size && `msk-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="msk-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('msk-callout', variant !== 'info' && `msk-callout--${variant}`)}>
|
|
||||||
{icon && <span className="msk-callout__icon">{icon}</span>}
|
|
||||||
<div className="msk-callout__body">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---------- TABLE ---------- */
|
|
||||||
export const Table = ({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<'table'>) => (
|
|
||||||
<div className="msk-table-wrap">
|
|
||||||
<table className="msk-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('msk-scroll', className)} style={style}>
|
|
||||||
<RScrollArea.Viewport className="msk-scroll__viewport">
|
|
||||||
{children}
|
|
||||||
</RScrollArea.Viewport>
|
|
||||||
<RScrollArea.Scrollbar className="msk-scroll__bar" orientation="vertical">
|
|
||||||
<RScrollArea.Thumb className="msk-scroll__thumb" />
|
|
||||||
</RScrollArea.Scrollbar>
|
|
||||||
<RScrollArea.Scrollbar className="msk-scroll__bar" orientation="horizontal">
|
|
||||||
<RScrollArea.Thumb className="msk-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="msk-overlay" />
|
|
||||||
<RDialog.Content className="msk-dialog">
|
|
||||||
<RDialog.Title className="msk-dialog__title">{title}</RDialog.Title>
|
|
||||||
{description && (
|
|
||||||
<RDialog.Description className="msk-dialog__desc">
|
|
||||||
{description}
|
|
||||||
</RDialog.Description>
|
|
||||||
)}
|
|
||||||
{children && <div className="msk-dialog__body">{children}</div>}
|
|
||||||
{footer && <div className="msk-dialog__footer">{footer}</div>}
|
|
||||||
<RDialog.Close asChild>
|
|
||||||
<IconButton
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="msk-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="msk-overlay" />
|
|
||||||
<RAlertDialog.Content className="msk-dialog">
|
|
||||||
<RAlertDialog.Title className="msk-dialog__title">
|
|
||||||
{title}
|
|
||||||
</RAlertDialog.Title>
|
|
||||||
{description && (
|
|
||||||
<RAlertDialog.Description className="msk-dialog__desc">
|
|
||||||
{description}
|
|
||||||
</RAlertDialog.Description>
|
|
||||||
)}
|
|
||||||
<div className="msk-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="msk-window" {...props}>
|
|
||||||
<div className="msk-titlebar">
|
|
||||||
<span className="msk-traffic r" />
|
|
||||||
<span className="msk-traffic y" />
|
|
||||||
<span className="msk-traffic g" />
|
|
||||||
<span className="ttl">{title}</span>
|
|
||||||
{badge && (
|
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
|
||||||
{badge}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const cx = (...c: Array<string | false | undefined>) =>
|
||||||
|
c.filter(Boolean).join(' ');
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
ModernSK UI — public package entry.
|
ModernSK — public package entry.
|
||||||
Import components from here; import the stylesheet once at your
|
Import components from here; import the stylesheet once at your
|
||||||
app root: import '@modernsk/ui/styles.css';
|
app root: import 'modern-sk/styles.css';
|
||||||
|
Optionally add the branded fonts: import 'modern-sk/fonts.css';
|
||||||
============================================================ */
|
============================================================ */
|
||||||
import { Tooltip } from 'radix-ui';
|
import { Tooltip } from 'radix-ui';
|
||||||
|
|
||||||
|
|||||||
@@ -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="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 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,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.
|
||||||
@@ -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="modern-sk-h4" style={{ marginBottom: 8 }}>Scrollable content</h4>
|
||||||
|
{Array.from({ length: 20 }).map((_, i) => (
|
||||||
|
<p key={i} className="modern-sk-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 ModernSK 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="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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -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 `modern-sk-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 }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
title: { control: 'text' },
|
||||||
|
badge: { control: false },
|
||||||
|
children: { control: false },
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
};
|
||||||
+1232
-260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
/* ============================================================
|
||||||
|
ModernSK — optional branded webfonts.
|
||||||
|
Import this ONLY if you want the default ModernSK typefaces:
|
||||||
|
|
||||||
|
import 'modern-sk/styles.css'; // required — tokens + components
|
||||||
|
import 'modern-sk/fonts.css'; // optional — branded faces
|
||||||
|
|
||||||
|
Skip it and the --font-* tokens degrade to system-ui, or
|
||||||
|
override --font-display / --font-sans / --font-mono in your own
|
||||||
|
CSS to map components onto fonts your app already loads.
|
||||||
|
------------------------------------------------------------
|
||||||
|
Onest + Geist Mono come from Google Fonts; Anta (the squared
|
||||||
|
display face) is self-hosted and inlined at build time.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Anta';
|
||||||
|
src: url('../assets/Anta-Regular.ttf') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import './fonts.css';
|
||||||
@import './tokens.css';
|
@import './tokens.css';
|
||||||
@import './components.css';
|
@import './components.css';
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -1,12 +1,19 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
ModernSK UI — shippable stylesheet.
|
ModernSK — shippable stylesheet.
|
||||||
Tokens + component styles only. No demo/page layout, no global
|
Tokens + component styles only. No demo/page layout, no global
|
||||||
reset of consumer elements. Font is inlined at build time.
|
reset of consumer elements. No fonts: import 'modern-sk/fonts.css'
|
||||||
|
for the branded faces, or override the --font-* tokens.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@import './tokens.css';
|
@import './tokens.css';
|
||||||
@import './components.css';
|
@import './components.css';
|
||||||
|
|
||||||
/* Non-invasive box-sizing for our own components only (zero specificity). */
|
/* Non-invasive box-sizing + branded font for our own components only
|
||||||
:where([class^='msk-'], [class*=' msk-']) {
|
(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^='modern-sk-'], [class*=' modern-sk-']) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-29
@@ -4,19 +4,14 @@
|
|||||||
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
|
Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
Single source of truth. Every component reads from here.
|
Single source of truth. Every component reads from here.
|
||||||
|
------------------------------------------------------------
|
||||||
|
Fonts are NOT loaded here. The branded faces (Anta / Onest /
|
||||||
|
Geist Mono) live in the optional `modern-sk/fonts.css`. The
|
||||||
|
--font-* chains below degrade to system-ui when that file is
|
||||||
|
not imported, and an app can override the tokens to remap any
|
||||||
|
typeface without touching this file.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Onest:wght@300;400;500;600;700;800&family=Geist+Mono:wght@400;500;600&display=swap');
|
|
||||||
|
|
||||||
/* Anta — squared geometric display face (self-hosted from /public/fonts). */
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Anta';
|
|
||||||
src: url('../assets/Anta-Regular.ttf') format('truetype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* ---------- BRAND ---------- */
|
/* ---------- BRAND ---------- */
|
||||||
--lime: #bef264;
|
--lime: #bef264;
|
||||||
@@ -183,16 +178,15 @@
|
|||||||
--grain-opacity: 0.45;
|
--grain-opacity: 0.45;
|
||||||
|
|
||||||
/* ---------- SPINNER GROOVE ----------
|
/* ---------- SPINNER GROOVE ----------
|
||||||
Carved donut channel — dark at the top rim, catching light at the
|
Solid carved channel — flat base felt, sunk by an SVG inner shadow,
|
||||||
bottom, exactly like the sunk wells (switch / field). */
|
exactly like the switch well (no gradient). */
|
||||||
--spin-groove-1: #090a07; /* top — in shadow */
|
--spin-track: #23241c;
|
||||||
--spin-groove-2: #34352b; /* bottom — catches light */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
THE FELT — the one global background.
|
THE FELT — the one global background.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.msk-felt {
|
.modern-sk-felt {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--ink);
|
background-color: var(--ink);
|
||||||
background-image: var(--bg-glow);
|
background-image: var(--bg-glow);
|
||||||
@@ -201,7 +195,7 @@
|
|||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
color: var(--fg-1);
|
color: var(--fg-1);
|
||||||
}
|
}
|
||||||
.msk-felt::before {
|
.modern-sk-felt::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -269,48 +263,47 @@
|
|||||||
radial-gradient(90% 70% at 85% 110%, rgba(233,87,43,0.08), transparent 60%),
|
radial-gradient(90% 70% at 85% 110%, rgba(233,87,43,0.08), transparent 60%),
|
||||||
radial-gradient(100% 100% at 50% 50%, #f2f2ea 0%, #ecece3 60%, #e2e2d6 100%);
|
radial-gradient(100% 100% at 50% 50%, #f2f2ea 0%, #ecece3 60%, #e2e2d6 100%);
|
||||||
|
|
||||||
/* carved groove on warm paper — top grey shadow, bottom near-white */
|
/* carved groove on warm paper — flat base, sunk by inner shadow */
|
||||||
--spin-groove-1: #c2c3b6;
|
--spin-track: #dcdcd2;
|
||||||
--spin-groove-2: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
SEMANTIC TYPE CLASSES
|
SEMANTIC TYPE CLASSES
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.msk-display {
|
.modern-sk-display {
|
||||||
font-family: var(--font-display); font-weight: 400;
|
font-family: var(--font-display); font-weight: 400;
|
||||||
font-size: var(--text-5xl); line-height: var(--lh-tight);
|
font-size: var(--text-5xl); line-height: var(--lh-tight);
|
||||||
letter-spacing: -0.01em; color: var(--fg-1);
|
letter-spacing: -0.01em; color: var(--fg-1);
|
||||||
}
|
}
|
||||||
.msk-h1 {
|
.modern-sk-h1 {
|
||||||
font-family: var(--font-sans); font-weight: var(--w-bold);
|
font-family: var(--font-sans); font-weight: var(--w-bold);
|
||||||
font-size: var(--text-3xl); line-height: var(--lh-snug);
|
font-size: var(--text-3xl); line-height: var(--lh-snug);
|
||||||
letter-spacing: var(--track-snug); color: var(--fg-1);
|
letter-spacing: var(--track-snug); color: var(--fg-1);
|
||||||
}
|
}
|
||||||
.msk-h2 {
|
.modern-sk-h2 {
|
||||||
font-family: var(--font-sans); font-weight: var(--w-semibold);
|
font-family: var(--font-sans); font-weight: var(--w-semibold);
|
||||||
font-size: var(--text-2xl); line-height: var(--lh-snug);
|
font-size: var(--text-2xl); line-height: var(--lh-snug);
|
||||||
letter-spacing: var(--track-snug); color: var(--fg-1);
|
letter-spacing: var(--track-snug); color: var(--fg-1);
|
||||||
}
|
}
|
||||||
.msk-h3 {
|
.modern-sk-h3 {
|
||||||
font-family: var(--font-sans); font-weight: var(--w-semibold);
|
font-family: var(--font-sans); font-weight: var(--w-semibold);
|
||||||
font-size: var(--text-xl); line-height: var(--lh-snug); color: var(--fg-1);
|
font-size: var(--text-xl); line-height: var(--lh-snug); color: var(--fg-1);
|
||||||
}
|
}
|
||||||
.msk-body {
|
.modern-sk-body {
|
||||||
font-family: var(--font-sans); font-weight: var(--w-regular);
|
font-family: var(--font-sans); font-weight: var(--w-regular);
|
||||||
font-size: var(--text-base); line-height: var(--lh-normal); color: var(--fg-2);
|
font-size: var(--text-base); line-height: var(--lh-normal); color: var(--fg-2);
|
||||||
}
|
}
|
||||||
.msk-body-strong { font-weight: var(--w-medium); color: var(--fg-1); }
|
.modern-sk-body-strong { font-weight: var(--w-medium); color: var(--fg-1); }
|
||||||
.msk-caption {
|
.modern-sk-caption {
|
||||||
font-family: var(--font-sans); font-weight: var(--w-medium);
|
font-family: var(--font-sans); font-weight: var(--w-medium);
|
||||||
font-size: var(--text-sm); color: var(--fg-3);
|
font-size: var(--text-sm); color: var(--fg-3);
|
||||||
}
|
}
|
||||||
.msk-label {
|
.modern-sk-label {
|
||||||
font-family: var(--font-sans); font-weight: var(--w-semibold);
|
font-family: var(--font-sans); font-weight: var(--w-semibold);
|
||||||
font-size: 11px; text-transform: uppercase;
|
font-size: 11px; text-transform: uppercase;
|
||||||
letter-spacing: var(--track-caps); color: var(--fg-3);
|
letter-spacing: var(--track-caps); color: var(--fg-3);
|
||||||
}
|
}
|
||||||
.msk-mono {
|
.modern-sk-mono {
|
||||||
font-family: var(--font-mono); font-weight: var(--w-regular);
|
font-family: var(--font-mono); font-weight: var(--w-regular);
|
||||||
font-size: var(--text-sm); color: var(--fg-2);
|
font-size: var(--text-sm); color: var(--fg-2);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user