Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e2a1887d | |||
| 70c55dffad | |||
| db44cab4ea | |||
| 3d2ce9e0a7 | |||
| ee0dce9d75 | |||
| 5cd33a2b9d | |||
| ef69f7bb65 | |||
| b6017d60c8 | |||
| 3da99a7214 | |||
| 3b9d3f908d | |||
| 49d6ac8a4e | |||
| 37f0592464 | |||
| 4919bc26e5 |
@@ -0,0 +1,34 @@
|
|||||||
|
name: Publish npm package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Set version from tag
|
||||||
|
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Configure Gitea npm auth
|
||||||
|
run: |
|
||||||
|
echo "//git.ollyhearn.ru/api/packages/olly/npm/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: npm publish
|
||||||
@@ -2,6 +2,26 @@ import type { StorybookConfig } from 'storybook-react-rsbuild';
|
|||||||
|
|
||||||
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */
|
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
|
rsbuildFinal: (config) => {
|
||||||
|
config.tools ??= {};
|
||||||
|
// Append our rule without clobbering storybook-react-rsbuild's own
|
||||||
|
// tools.rspack hook (it injects the storybook-config-entry virtual module
|
||||||
|
// in build mode). Mutate in place and return nothing so its config stays.
|
||||||
|
const prev = config.tools.rspack;
|
||||||
|
config.tools.rspack = [
|
||||||
|
...(Array.isArray(prev) ? prev : prev ? [prev] : []),
|
||||||
|
(rspackConfig) => {
|
||||||
|
rspackConfig.module ??= {};
|
||||||
|
rspackConfig.module.rules ??= [];
|
||||||
|
(rspackConfig.module.rules as unknown[]).push({
|
||||||
|
test: /\.mjs$/,
|
||||||
|
type: 'javascript/auto',
|
||||||
|
resolve: { fullySpecified: false },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return config;
|
||||||
|
},
|
||||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
|
||||||
addons: ['@storybook/addon-docs'],
|
addons: ['@storybook/addon-docs'],
|
||||||
staticDirs: ['../src/assets'],
|
staticDirs: ['../src/assets'],
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ Only `dist/` ships (`files: ["dist"]`), so the playground, stories, and `.storyb
|
|||||||
|
|
||||||
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.
|
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`)
|
### Components (`src/components/`)
|
||||||
|
|
||||||
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.**
|
`src/components/ui.tsx` is a barrel that re-exports one folder per component (`button/`, `text-field/`, …); `cx`/shared helpers live in `src/components/utils.ts`. 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.**
|
||||||
|
|
||||||
|
Exception — text inputs (`text-field/`): `TextField`/`TextArea`/`SearchField` animate letters in/out (osu!-lazer style). Native fields can't animate per-glyph, so the real element renders with transparent text (`modern-sk-field--animated`, caret stays visible) over a mirrored per-character `<span>` overlay that plays the `modern-sk-char-in/out` keyframes; an LCS diff preserves letter identity so only inserted/removed glyphs animate. The overlay still derives all appearance from `modern-sk-*` classes/tokens — the only JS-set styles are the per-letter pin offset and scroll-sync transform. Pass `animated={false}` to opt out and render the plain native field.
|
||||||
|
|
||||||
|
Exception — knob (`knob/`): a rotary circular slider (`Knob`), ported from the design handoff. Two visuals move at different speeds — the **dial** is bound 1:1 to the pointer while dragging (instant, continuous angle via inline `transform: rotate`), while the **gauge fill + value** snap to detents and *glide* between them (like the stepped Slider). It tracks two values: a continuous `visual` (drives the dial mid-drag) and the snapped `committed` (drives fill/ticks/aria); `dialValue = dragging ? visual : committed`, so on drag-release the dial settles to the detent. Three non-obvious constraints, all already tried-and-rejected — don't regress them: (1) the gauge **fill must be the full arc revealed via `strokeDashoffset`** set as a *plain number* — `pathLength=1` + CSS `var()`/`calc` silently collapsed to a full ring, and `transition: d` on a swept arc interpolates the endpoint as a straight chord and distorts the arc mid-glide; dashoffset eases along the true circle. (2) the dial transition is disabled via `.is-dragging` so the mouse-bound dial never lags. (3) wheel-to-change uses a **native non-passive `wheel` listener** (via `useEffect` + a ref-held handler) because React's synthetic `onWheel` is passive and `preventDefault` can't block page scroll. `animated` defaults `true` when `step > 0`.
|
||||||
|
|
||||||
### Styling system (`src/styles/`)
|
### Styling system (`src/styles/`)
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ All components live in one file. Pattern: Radix provides logic/accessibility, ev
|
|||||||
- `components.css` — the `modern-sk-*` class definitions.
|
- `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`).
|
- 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.
|
When adding or changing a component: add the wrapper folder under `src/components/` and re-export it from `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
|
## Consumer contract
|
||||||
|
|
||||||
|
|||||||
@@ -12,23 +12,28 @@ Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Distributed via self-hosted git — install straight from the repo:
|
Add the registry to your project `.npmrc`:
|
||||||
|
|
||||||
|
```
|
||||||
|
@olly:registry=https://git.ollyhearn.ru/api/packages/olly/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i git+ssh://git@git.ollyhearn.ru:49239/olly/modern-sk.git
|
npm install @olly/@olly/modern-sk
|
||||||
```
|
```
|
||||||
|
|
||||||
`react` and `react-dom` (>=18) are peer dependencies — your app provides them.
|
`react` and `react-dom` (>=18) are peer dependencies — your app provides them.
|
||||||
The package builds itself on install via the `prepare` script.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
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 'modern-sk/styles.css'; // required — tokens + components
|
import '@olly/modern-sk/styles.css'; // required — tokens + components
|
||||||
import 'modern-sk/fonts.css'; // optional — branded faces (see Fonts)
|
import '@olly/modern-sk/fonts.css'; // optional — branded faces (see Fonts)
|
||||||
import { ThemeProvider, TooltipProvider, Button, Card } from 'modern-sk';
|
import { ThemeProvider, TooltipProvider, Button, Card } from '@olly/modern-sk';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +54,7 @@ export function App() {
|
|||||||
|
|
||||||
## Fonts
|
## Fonts
|
||||||
|
|
||||||
`modern-sk/styles.css` ships **no fonts**. The type tokens default to a chain
|
`@olly/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.
|
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
|
To get the branded ModernSK faces (Anta display + Onest + Geist Mono), import
|
||||||
@@ -57,7 +62,7 @@ the optional stylesheet — Anta is self-hosted and inlined, no asset hosting
|
|||||||
needed:
|
needed:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import 'modern-sk/fonts.css';
|
import '@olly/modern-sk/fonts.css';
|
||||||
```
|
```
|
||||||
|
|
||||||
To use your **own** fonts, skip `fonts.css` and override the tokens anywhere
|
To use your **own** fonts, skip `fonts.css` and override the tokens anywhere
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modern-sk",
|
"name": "@olly/modern-sk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"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",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.ollyhearn.ru/api/packages/olly/npm/"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup && npm run build:css",
|
"build": "tsup && npm run build: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",
|
"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",
|
||||||
|
|||||||
+10
@@ -30,6 +30,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Knob,
|
||||||
List,
|
List,
|
||||||
MenuRow,
|
MenuRow,
|
||||||
MenuSeparator,
|
MenuSeparator,
|
||||||
@@ -238,6 +239,7 @@ 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)}
|
||||||
@@ -247,6 +249,14 @@ const App = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="cap">Knobs — circular sliders</div>
|
||||||
|
<div className="cluster" style={{ gap: 40, alignItems: 'flex-start' }}>
|
||||||
|
<Knob defaultValue={62} aria-label="Volume" />
|
||||||
|
<Knob defaultValue={3} min={1} max={5} step={1} aria-label="Quality" />
|
||||||
|
<Knob defaultValue={40} accent="ember" aria-label="Warmth" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type PointerEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { cx } from '../utils';
|
||||||
|
|
||||||
|
type KnobAccent = 'lime' | 'ember';
|
||||||
|
|
||||||
|
type KnobProps = {
|
||||||
|
/** Controlled value. */
|
||||||
|
value?: number;
|
||||||
|
/** Uncontrolled starting value. Defaults to `min`. */
|
||||||
|
defaultValue?: number;
|
||||||
|
/** Fires with the snapped value on every change. */
|
||||||
|
onValueChange?: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
/** Snap increment. `0`/omitted = continuous (no detents, no ticks). */
|
||||||
|
step?: number;
|
||||||
|
/** Accent colour for the gauge fill + pointer. `'lime'` (default) or `'ember'`. */
|
||||||
|
accent?: KnobAccent;
|
||||||
|
/** Diameter in px. Drives every internal measurement via `--knob-size`. */
|
||||||
|
size?: number;
|
||||||
|
/**
|
||||||
|
* Glide the gauge fill + pointer between detents. The dial always tracks the
|
||||||
|
* pointer 1:1 while dragging; this only eases the *value* settle (keyboard,
|
||||||
|
* wheel, drag-release) — like the stepped Slider. Defaults to `true` when
|
||||||
|
* `step > 0`, `false` otherwise.
|
||||||
|
*/
|
||||||
|
animated?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
'aria-labelledby'?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gauge geometry: 270° sweep starting at the 7-o'clock position. R is the arc
|
||||||
|
// radius in the 100×100 SVG viewBox.
|
||||||
|
const START = -135;
|
||||||
|
const SWEEP = 270;
|
||||||
|
const R = 43;
|
||||||
|
|
||||||
|
const polar = (cx: number, cy: number, r: number, aDeg: number): [number, number] => {
|
||||||
|
const t = (aDeg * Math.PI) / 180;
|
||||||
|
return [cx + r * Math.sin(t), cy - r * Math.cos(t)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const arcPath = (a0: number, a1: number) => {
|
||||||
|
if (a1 - a0 < 0.01) a1 = a0 + 0.01;
|
||||||
|
const [x0, y0] = polar(50, 50, R, a0);
|
||||||
|
const [x1, y1] = polar(50, 50, R, a1);
|
||||||
|
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRACK_PATH = arcPath(START, START + SWEEP);
|
||||||
|
// Arc length of the gauge. The fill is the full arc revealed via dashoffset, so
|
||||||
|
// the glide eases ALONG the circle (transitioning `d` interpolates endpoints in
|
||||||
|
// a straight chord and distorts the arc mid-animation).
|
||||||
|
const ARC_LEN = (R * SWEEP * Math.PI) / 180;
|
||||||
|
|
||||||
|
export const Knob = ({
|
||||||
|
value: valueProp,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 0,
|
||||||
|
accent = 'lime',
|
||||||
|
size,
|
||||||
|
animated,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
'aria-labelledby': ariaLabelledby,
|
||||||
|
}: KnobProps) => {
|
||||||
|
const span = max - min || 1;
|
||||||
|
const stepped = step > 0;
|
||||||
|
const isAnimated = animated !== undefined ? animated : stepped;
|
||||||
|
|
||||||
|
const clamp = (v: number) => Math.min(max, Math.max(min, v));
|
||||||
|
const snap = (v: number) =>
|
||||||
|
stepped ? clamp(Math.round((v - min) / step) * step + min) : clamp(v);
|
||||||
|
|
||||||
|
const isControlled = valueProp !== undefined;
|
||||||
|
const [internal, setInternal] = useState(() => snap(defaultValue ?? min));
|
||||||
|
const committed = isControlled ? snap(valueProp) : internal;
|
||||||
|
|
||||||
|
// Continuous, pointer-bound position for the dial; only meaningful mid-drag.
|
||||||
|
const [visual, setVisual] = useState(committed);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const elRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Drag tracking: previous pointer angle + a continuous value accumulator.
|
||||||
|
// Accumulating per-move deltas (each < 180°) instead of measuring from the
|
||||||
|
// grab angle lets the dial wrap past ±180° without the value snapping.
|
||||||
|
const lastAngle = useRef(0);
|
||||||
|
const acc = useRef(0);
|
||||||
|
|
||||||
|
const reactId = useId();
|
||||||
|
const knobId = id ?? reactId;
|
||||||
|
|
||||||
|
// `continuous` keeps the dial on the raw pointer angle; otherwise the dial
|
||||||
|
// settles (and glides) to the snapped detent.
|
||||||
|
const commit = (raw: number, continuous: boolean) => {
|
||||||
|
const c = clamp(raw);
|
||||||
|
const snapped = snap(c);
|
||||||
|
setVisual(continuous ? c : snapped);
|
||||||
|
if (!isControlled) setInternal(snapped);
|
||||||
|
if (snapped !== committed) onValueChange?.(snapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const angleAt = (clientX: number, clientY: number) => {
|
||||||
|
const el = elRef.current;
|
||||||
|
if (!el) return 0;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
(Math.atan2(clientX - (r.left + r.width / 2), -(clientY - (r.top + r.height / 2))) * 180) /
|
||||||
|
Math.PI
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
elRef.current?.focus();
|
||||||
|
elRef.current?.setPointerCapture(e.pointerId);
|
||||||
|
setDragging(true);
|
||||||
|
lastAngle.current = angleAt(e.clientX, e.clientY);
|
||||||
|
acc.current = clamp(committed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const a = angleAt(e.clientX, e.clientY);
|
||||||
|
let d = a - lastAngle.current;
|
||||||
|
if (d > 180) d -= 360;
|
||||||
|
if (d < -180) d += 360;
|
||||||
|
lastAngle.current = a;
|
||||||
|
// Clamp the accumulator (not just the committed value) so overshoot past
|
||||||
|
// an end doesn't build a dead zone you must unwind before reversing.
|
||||||
|
acc.current = clamp(acc.current + (d / SWEEP) * span);
|
||||||
|
commit(acc.current, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
elRef.current?.releasePointerCapture(e.pointerId);
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// React's synthetic onWheel is passive, so preventDefault can't block page
|
||||||
|
// scroll — attach a native non-passive listener. A ref keeps it current
|
||||||
|
// without re-binding every render.
|
||||||
|
const wheelRef = useRef<(e: WheelEvent) => void>(() => {});
|
||||||
|
wheelRef.current = (e) => {
|
||||||
|
if (disabled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
commit(committed - (step || span / 100) * Math.sign(e.deltaY), false);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const el = elRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const handler = (e: WheelEvent) => wheelRef.current(e);
|
||||||
|
el.addEventListener('wheel', handler, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
const big = step || span / 10;
|
||||||
|
const sm = step || span / 100;
|
||||||
|
const k = e.key;
|
||||||
|
if (k === 'ArrowUp' || k === 'ArrowRight') commit(committed + sm, false);
|
||||||
|
else if (k === 'ArrowDown' || k === 'ArrowLeft') commit(committed - sm, false);
|
||||||
|
else if (k === 'PageUp') commit(committed + big, false);
|
||||||
|
else if (k === 'PageDown') commit(committed - big, false);
|
||||||
|
else if (k === 'Home') commit(min, false);
|
||||||
|
else if (k === 'End') commit(max, false);
|
||||||
|
else return;
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialValue = dragging ? visual : committed;
|
||||||
|
const dialAngle = START + ((clamp(dialValue) - min) / span) * SWEEP;
|
||||||
|
const fillOffset = ARC_LEN * (1 - (committed - min) / span);
|
||||||
|
const valueNow = stepped ? committed : Math.round(committed);
|
||||||
|
|
||||||
|
const ticks = stepped
|
||||||
|
? Array.from({ length: Math.round(span / step) + 1 }, (_, i) => {
|
||||||
|
const a = START + (i / Math.round(span / step)) * SWEEP;
|
||||||
|
const [x1, y1] = polar(50, 50, R + 6, a);
|
||||||
|
const [x2, y2] = polar(50, 50, R + 10.5, a);
|
||||||
|
const val = min + (i / Math.round(span / step)) * span;
|
||||||
|
return { x1, y1, x2, y2, on: val <= committed + 1e-6, key: i };
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={elRef}
|
||||||
|
id={knobId}
|
||||||
|
role="slider"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={ariaLabelledby}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={valueNow}
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
className={cx(
|
||||||
|
'modern-sk-knob',
|
||||||
|
`modern-sk-knob--${accent}`,
|
||||||
|
isAnimated && 'modern-sk-knob--animated',
|
||||||
|
dragging && 'is-dragging',
|
||||||
|
disabled && 'modern-sk-knob--disabled',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={size != null ? ({ '--knob-size': `${size}px` } as CSSProperties) : undefined}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<svg className="modern-sk-knob__gauge" viewBox="0 0 100 100" aria-hidden>
|
||||||
|
<path className="modern-sk-knob__track" d={TRACK_PATH} />
|
||||||
|
<path
|
||||||
|
className="modern-sk-knob__fill"
|
||||||
|
d={TRACK_PATH}
|
||||||
|
strokeDasharray={ARC_LEN}
|
||||||
|
strokeDashoffset={fillOffset}
|
||||||
|
/>
|
||||||
|
<g className="modern-sk-knob__ticks">
|
||||||
|
{ticks.map((t) => (
|
||||||
|
<line
|
||||||
|
key={t.key}
|
||||||
|
className={cx('modern-sk-knob__tick', t.on && 'is-on')}
|
||||||
|
x1={t.x1.toFixed(2)}
|
||||||
|
y1={t.y1.toFixed(2)}
|
||||||
|
x2={t.x2.toFixed(2)}
|
||||||
|
y2={t.y2.toFixed(2)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="modern-sk-knob__cap">
|
||||||
|
<div className="modern-sk-knob__dial" style={{ transform: `rotate(${dialAngle}deg)` }}>
|
||||||
|
<span className="modern-sk-knob__pointer" />
|
||||||
|
</div>
|
||||||
|
<div className="modern-sk-knob__hub" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+118
-44
@@ -1,59 +1,133 @@
|
|||||||
import { type ComponentPropsWithoutRef } from 'react';
|
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
|
||||||
import { Slider as RSlider } from 'radix-ui';
|
import { Slider as RSlider } from 'radix-ui';
|
||||||
|
|
||||||
type Step = { value: number; label?: string };
|
type Mark = { value: number; label?: string };
|
||||||
|
type MarksProp = boolean | Array<number | Mark>;
|
||||||
|
type NotchPlacement = 'top' | 'bottom' | 'both' | 'none';
|
||||||
|
|
||||||
type SliderProps = ComponentPropsWithoutRef<typeof RSlider.Root> & {
|
type KnobStyle = 'square' | 'round';
|
||||||
steps?: number | Step[];
|
|
||||||
|
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
|
||||||
|
/**
|
||||||
|
* Step marks.
|
||||||
|
* - `true` — auto-generate one mark per `step` between `min` and `max`.
|
||||||
|
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
|
||||||
|
*/
|
||||||
|
marks?: MarksProp;
|
||||||
|
/**
|
||||||
|
* Where to draw the notch ticks relative to the track.
|
||||||
|
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
|
||||||
|
* (labels still render when provided). No effect without `marks`.
|
||||||
|
*/
|
||||||
|
notches?: NotchPlacement;
|
||||||
|
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
|
||||||
|
knobStyle?: KnobStyle;
|
||||||
|
/**
|
||||||
|
* Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise.
|
||||||
|
* Explicitly setting this always overrides the default.
|
||||||
|
*/
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveSteps(steps: number | Step[], min: number, max: number): Step[] {
|
function resolveMarks(
|
||||||
if (Array.isArray(steps)) return steps;
|
marks: MarksProp,
|
||||||
if (steps < 2) return [];
|
min: number,
|
||||||
return Array.from({ length: steps }, (_, i) => ({
|
max: number,
|
||||||
value: min + (i / (steps - 1)) * (max - min),
|
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 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Slider = ({ steps, min = 0, max = 100, ...props }: SliderProps) => {
|
const percent = (value: number, min: number, max: number) =>
|
||||||
const resolved = steps != null ? resolveSteps(steps, min, max) : [];
|
max === min ? 0 : (value - min) / (max - min);
|
||||||
const hasSteps = resolved.length > 0;
|
|
||||||
|
|
||||||
return (
|
const NotchLayer = ({
|
||||||
<RSlider.Root
|
marks,
|
||||||
className={`modern-sk-slider${hasSteps ? ' modern-sk-slider--has-steps' : ''}`}
|
min,
|
||||||
min={min}
|
max,
|
||||||
max={max}
|
side,
|
||||||
{...props}
|
}: {
|
||||||
>
|
marks: Mark[];
|
||||||
<RSlider.Track className="modern-sk-slider__track">
|
min: number;
|
||||||
<RSlider.Range className="modern-sk-slider__range" />
|
max: number;
|
||||||
{hasSteps && resolved.map((step) => (
|
side: 'top' | 'bottom';
|
||||||
<div
|
}) => (
|
||||||
key={step.value}
|
<div className={`modern-sk-slider__notches modern-sk-slider__notches--${side}`} aria-hidden>
|
||||||
className="modern-sk-slider__step-dot"
|
{marks.map((mark) => (
|
||||||
aria-hidden
|
<span
|
||||||
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
|
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',
|
||||||
|
animated,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SliderProps) => {
|
||||||
|
const resolved = marks != null ? resolveMarks(marks, min, max, step) : [];
|
||||||
|
const hasMarks = resolved.length > 0;
|
||||||
|
const hasLabels = resolved.some((m) => m.label != null);
|
||||||
|
const showTop = hasMarks && (notches === 'top' || notches === 'both');
|
||||||
|
const showBottom = hasMarks && (notches === 'bottom' || notches === 'both');
|
||||||
|
const isAnimated = animated !== undefined ? animated : hasMarks;
|
||||||
|
|
||||||
|
const cls = [
|
||||||
|
'modern-sk-slider',
|
||||||
|
`modern-sk-slider--knob-${knobStyle}`,
|
||||||
|
isAnimated && 'modern-sk-slider--animated',
|
||||||
|
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.Track>
|
||||||
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
|
<RSlider.Thumb className="modern-sk-slider__thumb" aria-label="Value" />
|
||||||
{hasSteps && (
|
|
||||||
<div className="modern-sk-slider__steps" aria-hidden>
|
|
||||||
{resolved.map((step) => (
|
|
||||||
<div
|
|
||||||
key={step.value}
|
|
||||||
className="modern-sk-slider__step"
|
|
||||||
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
|
|
||||||
>
|
|
||||||
<div className="modern-sk-slider__step-tick" />
|
|
||||||
{step.label != null && (
|
|
||||||
<span className="modern-sk-slider__step-label">{step.label}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</RSlider.Root>
|
</RSlider.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export const Spinner = ({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
|
}: ComponentPropsWithoutRef<'span'> & { size?: 'sm' | 'lg' }) => {
|
||||||
const gid = `modern-sk-groove-${useId()}`;
|
const uid = useId();
|
||||||
|
const grooveId = `modern-sk-groove-${uid}`;
|
||||||
|
const glowId = `modern-sk-glow-${uid}`;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
@@ -16,21 +18,47 @@ export const Spinner = ({
|
|||||||
>
|
>
|
||||||
<svg viewBox="0 0 36 36" fill="none">
|
<svg viewBox="0 0 36 36" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
{/* Carved channel: flat ring sunk by a top inner shadow — like the switch well. */}
|
||||||
id={gid}
|
<filter id={grooveId} x="-30%" y="-30%" width="160%" height="160%">
|
||||||
x1="18"
|
<feComponentTransfer in="SourceAlpha" result="inv">
|
||||||
y1="4"
|
<feFuncA type="table" tableValues="1 0" />
|
||||||
x2="18"
|
</feComponentTransfer>
|
||||||
y2="32"
|
<feGaussianBlur in="inv" stdDeviation="1" result="blur" />
|
||||||
gradientUnits="userSpaceOnUse"
|
<feOffset in="blur" dy="1" result="off" />
|
||||||
>
|
<feFlood floodColor="#000" floodOpacity="0.7" />
|
||||||
<stop offset="0" stopColor="var(--spin-groove-1)" />
|
<feComposite in2="off" operator="in" />
|
||||||
<stop offset="1" stopColor="var(--spin-groove-2)" />
|
<feComposite in2="SourceAlpha" operator="in" result="shadow" />
|
||||||
</linearGradient>
|
<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>
|
</defs>
|
||||||
<circle cx="18" cy="18" r="14" stroke={`url(#${gid})`} strokeWidth="5" />
|
|
||||||
<circle
|
<circle
|
||||||
className="modern-sk-spinner__arc"
|
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"
|
cx="18"
|
||||||
cy="18"
|
cy="18"
|
||||||
r="14"
|
r="14"
|
||||||
@@ -39,6 +67,7 @@ export const Spinner = ({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray="22 88"
|
strokeDasharray="22 88"
|
||||||
/>
|
/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,276 @@
|
|||||||
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useId,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
|
type ReactNode,
|
||||||
|
type Ref,
|
||||||
|
} from 'react';
|
||||||
import { cx } from '../utils';
|
import { cx } from '../utils';
|
||||||
|
|
||||||
export const TextField = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>(
|
/* ------------------------------------------------------------------ *
|
||||||
({ className, ...props }, ref) => (
|
* Typing animation (osu!-lazer style)
|
||||||
<input ref={ref} className={cx('modern-sk-field', className)} {...props} />
|
*
|
||||||
|
* Native inputs draw their own text, so individual letters can't be
|
||||||
|
* animated. Instead the real field renders transparent (caret stays
|
||||||
|
* visible) and a mirrored per-character <span> overlay sits behind it:
|
||||||
|
* newly typed letters rise + fade in, erased letters fall + fade out.
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type FieldElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
interface CharEntry {
|
||||||
|
id: number;
|
||||||
|
char: string;
|
||||||
|
leaving: boolean;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest-common-subsequence match so unchanged letters keep their id
|
||||||
|
* (and thus don't replay the appear animation on every keystroke). */
|
||||||
|
function diffChars(prev: ReadonlyArray<{ id: number; char: string }>, next: string) {
|
||||||
|
const n = prev.length;
|
||||||
|
const m = next.length;
|
||||||
|
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
|
dp[i][j] =
|
||||||
|
prev[i].char === next[j]
|
||||||
|
? dp[i + 1][j + 1] + 1
|
||||||
|
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const reusedId: Array<number | null> = new Array(m).fill(null);
|
||||||
|
const keptPrev = new Array(n).fill(false);
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (prev[i].char === next[j]) {
|
||||||
|
reusedId[j] = prev[i].id;
|
||||||
|
keptPrev[i] = true;
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { reusedId, keptPrev };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFieldAnimation(
|
||||||
|
multiline: boolean,
|
||||||
|
externalRef: Ref<FieldElement>,
|
||||||
|
controlledValue: ComponentPropsWithoutRef<'input'>['value'],
|
||||||
|
initial: ComponentPropsWithoutRef<'input'>['defaultValue'],
|
||||||
|
) {
|
||||||
|
const innerRef = useRef<FieldElement | null>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const spanRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
|
||||||
|
const present = useRef<Array<{ id: number; char: string }>>([]);
|
||||||
|
const nextId = useRef(0);
|
||||||
|
const idPrefix = useId();
|
||||||
|
|
||||||
|
const [text, setText] = useState(() => String(controlledValue ?? initial ?? ''));
|
||||||
|
const [entries, setEntries] = useState<CharEntry[]>([]);
|
||||||
|
|
||||||
|
const setRef = useCallback(
|
||||||
|
(node: FieldElement | null) => {
|
||||||
|
innerRef.current = node;
|
||||||
|
if (typeof externalRef === 'function') externalRef(node);
|
||||||
|
else if (externalRef) (externalRef as { current: FieldElement | null }).current = node;
|
||||||
|
},
|
||||||
|
[externalRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncScroll = useCallback(() => {
|
||||||
|
const el = innerRef.current;
|
||||||
|
const ov = overlayRef.current;
|
||||||
|
if (el && ov) ov.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reconcile the overlay whenever the text changes.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const prev = present.current;
|
||||||
|
const { reusedId, keptPrev } = diffChars(prev, text);
|
||||||
|
|
||||||
|
const nextPresent: Array<{ id: number; char: string }> = [];
|
||||||
|
for (let k = 0; k < text.length; k++) {
|
||||||
|
const id = reusedId[k] ?? nextId.current++;
|
||||||
|
nextPresent.push({ id, char: text[k] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters that were removed fall away — pin them where they last sat.
|
||||||
|
const leaving: CharEntry[] = [];
|
||||||
|
for (let k = 0; k < prev.length; k++) {
|
||||||
|
if (keptPrev[k]) continue;
|
||||||
|
const el = spanRefs.current.get(prev[k].id);
|
||||||
|
if (el) {
|
||||||
|
leaving.push({
|
||||||
|
id: prev[k].id,
|
||||||
|
char: prev[k].char,
|
||||||
|
leaving: true,
|
||||||
|
x: el.offsetLeft,
|
||||||
|
y: el.offsetTop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
present.current = nextPresent;
|
||||||
|
setEntries((current) => [
|
||||||
|
...nextPresent.map((e) => ({ ...e, leaving: false })),
|
||||||
|
...current.filter((e) => e.leaving),
|
||||||
|
...leaving,
|
||||||
|
]);
|
||||||
|
syncScroll();
|
||||||
|
}, [text, syncScroll]);
|
||||||
|
|
||||||
|
// Stay in sync when used as a controlled component.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (controlledValue !== undefined) setText(String(controlledValue));
|
||||||
|
}, [controlledValue]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((value: string) => setText(value), []);
|
||||||
|
|
||||||
|
const onLeaveEnd = useCallback((id: number) => {
|
||||||
|
spanRefs.current.delete(id);
|
||||||
|
setEntries((current) => current.filter((e) => e.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const overlay = (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cx('modern-sk-field-overlay', multiline && 'modern-sk-field-overlay--multiline')}
|
||||||
|
>
|
||||||
|
{entries.map((e) =>
|
||||||
|
e.leaving ? (
|
||||||
|
<span
|
||||||
|
key={`${idPrefix}-${e.id}`}
|
||||||
|
className="modern-sk-field-char modern-sk-field-char--leaving"
|
||||||
|
style={{ left: e.x, top: e.y }}
|
||||||
|
onAnimationEnd={() => onLeaveEnd(e.id)}
|
||||||
|
>
|
||||||
|
{e.char}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
key={`${idPrefix}-${e.id}`}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) spanRefs.current.set(e.id, node);
|
||||||
|
else spanRefs.current.delete(e.id);
|
||||||
|
}}
|
||||||
|
className={cx('modern-sk-field-char', !multiline && 'modern-sk-field-char--composited')}
|
||||||
|
>
|
||||||
|
{e.char}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { setRef, overlay, handleChange, syncScroll };
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextFieldProps = ComponentPropsWithoutRef<'input'> & { animated?: boolean };
|
||||||
|
type TextAreaProps = ComponentPropsWithoutRef<'textarea'> & { animated?: boolean };
|
||||||
|
|
||||||
|
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||||
|
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
|
||||||
|
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
|
||||||
|
false,
|
||||||
|
ref,
|
||||||
|
props.value,
|
||||||
|
props.defaultValue,
|
||||||
|
);
|
||||||
|
if (!animated) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cx('modern-sk-field', className)}
|
||||||
|
style={style}
|
||||||
|
onChange={onChange}
|
||||||
|
onScroll={onScroll}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cx('modern-sk-field-wrap', className)} style={style}>
|
||||||
|
<input
|
||||||
|
ref={setRef}
|
||||||
|
className="modern-sk-field modern-sk-field--animated"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleChange(e.currentTarget.value);
|
||||||
|
onChange?.(e);
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
syncScroll();
|
||||||
|
onScroll?.(e);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
TextField.displayName = 'TextField';
|
TextField.displayName = 'TextField';
|
||||||
|
|
||||||
export const TextArea = forwardRef<HTMLTextAreaElement, ComponentPropsWithoutRef<'textarea'>>(
|
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
|
||||||
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} />
|
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
|
||||||
),
|
true,
|
||||||
|
ref,
|
||||||
|
props.value,
|
||||||
|
props.defaultValue,
|
||||||
|
);
|
||||||
|
if (!animated) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cx('modern-sk-field', className)}
|
||||||
|
style={style}
|
||||||
|
onChange={onChange}
|
||||||
|
onScroll={onScroll}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('modern-sk-field-wrap', 'modern-sk-field-wrap--multiline', className)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={setRef}
|
||||||
|
className="modern-sk-field modern-sk-field--animated"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleChange(e.currentTarget.value);
|
||||||
|
onChange?.(e);
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
syncScroll();
|
||||||
|
onScroll?.(e);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
TextArea.displayName = 'TextArea';
|
TextArea.displayName = 'TextArea';
|
||||||
|
|
||||||
export const SearchField = ({
|
export const SearchField = ({
|
||||||
icon,
|
icon,
|
||||||
...props
|
...props
|
||||||
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
|
}: TextFieldProps & { icon: ReactNode }) => (
|
||||||
<div className="modern-sk-search">
|
<div className="modern-sk-search">
|
||||||
<span className="ph">{icon}</span>
|
<span className="ph">{icon}</span>
|
||||||
<TextField {...props} />
|
<TextField {...props} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './select';
|
|||||||
export * from './selection';
|
export * from './selection';
|
||||||
export * from './segmented-control';
|
export * from './segmented-control';
|
||||||
export * from './slider';
|
export * from './slider';
|
||||||
|
export * from './knob';
|
||||||
export * from './tabs';
|
export * from './tabs';
|
||||||
export * from './progress';
|
export * from './progress';
|
||||||
export * from './badge';
|
export * from './badge';
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export const Window = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
<div className="modern-sk-window-body">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const meta = {
|
|||||||
iconOnly: { control: 'boolean', description: 'Square padding for a single glyph.' },
|
iconOnly: { control: 'boolean', description: 'Square padding for a single glyph.' },
|
||||||
disabled: { control: 'boolean' },
|
disabled: { control: 'boolean' },
|
||||||
children: { control: 'text' },
|
children: { control: 'text' },
|
||||||
|
className: { control: 'text' },
|
||||||
},
|
},
|
||||||
args: { children: 'Button', variant: 'key' },
|
args: { children: 'Button', variant: 'key' },
|
||||||
} satisfies Meta<typeof Button>;
|
} satisfies Meta<typeof Button>;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Th,
|
Th,
|
||||||
Td,
|
Td,
|
||||||
Badge,
|
Badge,
|
||||||
|
Chip,
|
||||||
} from '../components/ui';
|
} from '../components/ui';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -18,18 +19,23 @@ const meta = {
|
|||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component: 'Cards, selectable lists/rows, and the bordered table.',
|
component: 'Cards, selectable lists/rows, badges, chips, and the bordered table.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
argTypes: {
|
||||||
|
className: { control: 'text' },
|
||||||
|
},
|
||||||
} satisfies Meta<typeof Card>;
|
} satisfies Meta<typeof Card>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const CardSurface: Story = {
|
export const CardPlayground: Story = {
|
||||||
render: () => (
|
name: 'Card',
|
||||||
<Card style={{ maxWidth: 320, padding: 20 }}>
|
args: { children: 'Card content' },
|
||||||
|
render: (args) => (
|
||||||
|
<Card {...args} style={{ maxWidth: 320, padding: 20 }}>
|
||||||
<h3 className="modern-sk-h3">Storage</h3>
|
<h3 className="modern-sk-h3">Storage</h3>
|
||||||
<p className="modern-sk-body">128 GB of 256 GB used.</p>
|
<p className="modern-sk-body">128 GB of 256 GB used.</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -47,6 +53,27 @@ export const ListRows: Story = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
export const DataTable: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -14,11 +14,24 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
argTypes: {
|
||||||
|
value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
|
||||||
|
className: { control: 'text' },
|
||||||
|
},
|
||||||
|
args: { value: 40 },
|
||||||
} satisfies Meta<typeof Progress>;
|
} satisfies Meta<typeof Progress>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Playground: Story = {
|
||||||
|
render: (args) => (
|
||||||
|
<div style={{ width: 320 }}>
|
||||||
|
<Progress {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
function ProgressDemo() {
|
function ProgressDemo() {
|
||||||
const [v, setV] = useState(40);
|
const [v, setV] = useState(40);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,8 +14,17 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
variant: { control: 'inline-radio', options: ['key', 'primary', 'ember', 'ghost'] },
|
variant: {
|
||||||
size: { control: 'inline-radio', options: ['sm', undefined, 'lg'] },
|
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>;
|
} satisfies Meta<typeof IconButton>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Knob } from '../components/ui';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Inputs/Knob',
|
||||||
|
component: Knob,
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Skeuomorphic rotary control — a circular slider. Drag anywhere on the cap (relative angular drag, no jump-to-pointer), scroll to nudge, or focus and use the arrow keys. The dial tracks the pointer 1:1 while dragging; the gauge fill + value snap to detents and *glide* between them when `animated` (on by default for stepped knobs), exactly like the stepped Slider.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args: { defaultValue: 62, min: 0, max: 100, step: 0, accent: 'lime' },
|
||||||
|
argTypes: {
|
||||||
|
defaultValue: {
|
||||||
|
control: 'number',
|
||||||
|
description: 'Uncontrolled starting value.',
|
||||||
|
},
|
||||||
|
min: { control: 'number' },
|
||||||
|
max: { control: 'number' },
|
||||||
|
step: {
|
||||||
|
control: 'number',
|
||||||
|
description: '`0` = continuous; `> 0` adds detents + ticks.',
|
||||||
|
},
|
||||||
|
accent: { control: 'inline-radio', options: ['lime', 'ember'] },
|
||||||
|
size: { control: 'number', description: 'Diameter in px (default 108).' },
|
||||||
|
animated: {
|
||||||
|
control: 'boolean',
|
||||||
|
description:
|
||||||
|
'Glide the fill/value between detents. Defaults to `true` when `step > 0`.',
|
||||||
|
},
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
className: { control: 'text' },
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Knob>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Playground: Story = {};
|
||||||
|
|
||||||
|
/** Continuous — the dial and gauge follow the pointer with no snapping. */
|
||||||
|
export const Continuous: Story = {
|
||||||
|
args: { defaultValue: 62, step: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stepped — the dial stays bound to the mouse while the fill/value snap and glide into detents. */
|
||||||
|
export const Stepped: Story = {
|
||||||
|
args: { defaultValue: 3, min: 1, max: 5, step: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ember accent variant. */
|
||||||
|
export const Ember: Story = {
|
||||||
|
args: { defaultValue: 40, accent: 'ember' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: { defaultValue: 50, disabled: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const Readout = (args: React.ComponentProps<typeof Knob>) => {
|
||||||
|
const [v, setV] = useState(62);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Knob {...args} value={v} onValueChange={setV} aria-label="Volume" />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 22,
|
||||||
|
color: 'var(--fg-1)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v.toFixed(2)}
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--fg-3)', marginLeft: 2 }}>
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Controlled, with a live readout below the knob. */
|
||||||
|
export const WithReadout: Story = {
|
||||||
|
render: (args) => <Readout {...args} />,
|
||||||
|
args: { defaultValue: 62 },
|
||||||
|
};
|
||||||
@@ -25,17 +25,32 @@ const meta = {
|
|||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
children: { control: false },
|
children: { control: false },
|
||||||
content: { control: false },
|
content: { control: 'text' },
|
||||||
|
delayDuration: { control: 'number' },
|
||||||
|
open: { control: 'boolean' },
|
||||||
|
defaultOpen: { control: 'boolean' },
|
||||||
|
onOpenChange: { action: 'open changed' },
|
||||||
|
sideOffset: { control: 'number' },
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
content: '',
|
content: 'Tooltip text',
|
||||||
children: null,
|
children: null,
|
||||||
|
delayDuration: 0,
|
||||||
},
|
},
|
||||||
} satisfies Meta<typeof Tooltip>;
|
} satisfies Meta<typeof Tooltip>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof 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 = {
|
export const TooltipStory: Story = {
|
||||||
name: 'Tooltip',
|
name: 'Tooltip',
|
||||||
render: () => (
|
render: () => (
|
||||||
|
|||||||
@@ -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 />,
|
||||||
|
};
|
||||||
@@ -19,6 +19,15 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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' },
|
args: { items, placeholder: 'Pick a release…', 'aria-label': 'macOS release' },
|
||||||
} satisfies Meta<typeof Select>;
|
} satisfies Meta<typeof Select>;
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,21 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
argTypes: {
|
||||||
|
defaultChecked: { control: 'boolean' },
|
||||||
|
checked: { control: 'boolean' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
onCheckedChange: { action: 'checked changed' },
|
||||||
|
},
|
||||||
} satisfies Meta<typeof Switch>;
|
} satisfies Meta<typeof Switch>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Playground: Story = {
|
||||||
|
args: { defaultChecked: false },
|
||||||
|
};
|
||||||
|
|
||||||
export const Switches: Story = {
|
export const Switches: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -8,16 +8,67 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Radix Slider in the carved-track skin. All Radix Slider props pass through (`defaultValue`, `min`, `max`, `step`, `onValueChange`).',
|
'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>],
|
decorators: [(Story) => <div style={{ width: 280 }}><Story /></div>],
|
||||||
} satisfies Meta<typeof Slider>;
|
} satisfies Meta<typeof Slider>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const Playground: Story = { args: { defaultValue: [60], max: 100, step: 1 } };
|
export const Playground: Story = {};
|
||||||
|
|
||||||
export const Stepped: Story = { args: { defaultValue: [40], max: 100, step: 10 } };
|
/** `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 },
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
argTypes: {
|
||||||
|
defaultValue: { control: 'text' },
|
||||||
|
value: { control: 'text' },
|
||||||
|
onValueChange: { action: 'value changed' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
},
|
||||||
} satisfies Meta<typeof Tabs>;
|
} satisfies Meta<typeof Tabs>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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…' },
|
args: { placeholder: 'Type here…' },
|
||||||
} satisfies Meta<typeof TextField>;
|
} satisfies Meta<typeof TextField>;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
argTypes: {
|
||||||
|
title: { control: 'text' },
|
||||||
|
badge: { control: false },
|
||||||
|
children: { control: false },
|
||||||
|
},
|
||||||
args: { title: 'Finder' },
|
args: { title: 'Finder' },
|
||||||
} satisfies Meta<typeof Window>;
|
} satisfies Meta<typeof Window>;
|
||||||
|
|
||||||
|
|||||||
+1555
-259
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,13 @@
|
|||||||
@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
|
||||||
|
(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-']) {
|
:where([class^='modern-sk-'], [class*=' modern-sk-']) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,10 +178,9 @@
|
|||||||
--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 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -264,9 +263,8 @@
|
|||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user