13 Commits

Author SHA1 Message Date
olly a7e2a1887d feat(window): add default inner padding to Window body
Publish npm package / publish (push) Successful in 17s
Window children rendered flush against the frame, so content (settings
rows, placeholder text) touched the left edge. Wrap children in
.modern-sk-window-body with 16px padding for consistent breathing room.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:44:17 +03:00
Senko-san 70c55dffad Update README.md 2026-06-06 15:23:16 +03:00
Senko-san db44cab4ea chore: package ver.
Publish npm package / publish (push) Successful in 57s
2026-06-06 13:44:08 +03:00
Senko-san 3d2ce9e0a7 fix: no registry url
Publish npm package / publish (push) Successful in 55s
2026-06-06 13:36:42 +03:00
Senko-san ee0dce9d75 feat: build
Publish npm package / publish (push) Failing after 14s
2026-06-06 13:27:33 +03:00
olly 5cd33a2b9d fix: knob & text 2026-06-05 15:11:02 +03:00
olly ef69f7bb65 feat: knobs 2026-06-03 10:42:33 +03:00
olly b6017d60c8 chore: knob upd & memories 2026-06-02 18:10:19 +03:00
olly 3da99a7214 Merge branch 'master' of ssh://git.ollyhearn.ru:49239/olly/modern-sk 2026-06-02 15:27:01 +03:00
olly 3b9d3f908d feat: animated slider 2026-06-02 15:26:32 +03:00
olly 49d6ac8a4e feat: text & slider animations 2026-06-02 12:18:26 +03:00
olly 37f0592464 chore: update readme 2026-06-01 18:17:14 +03:00
olly 4919bc26e5 visuals, fixes & storybook upd 2026-06-01 01:09:55 +03:00
29 changed files with 2753 additions and 366 deletions
+34
View File
@@ -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
+20
View File
@@ -2,6 +2,26 @@ import type { StorybookConfig } from 'storybook-react-rsbuild';
/* Dev-only playground. Never shipped — package `files` is ["dist"]. */
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)'],
addons: ['@storybook/addon-docs'],
staticDirs: ['../src/assets'],
+7 -3
View File
@@ -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.
### 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/`)
@@ -47,7 +51,7 @@ All components live in one file. Pattern: Radix provides logic/accessibility, ev
- `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.
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
+13 -8
View File
@@ -12,23 +12,28 @@ Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
## 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
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.
The package builds itself on install via the `prepare` script.
## Usage
Import the stylesheet once at your app root, then use components anywhere:
```tsx
import 'modern-sk/styles.css'; // required — tokens + components
import 'modern-sk/fonts.css'; // optional — branded faces (see Fonts)
import { ThemeProvider, TooltipProvider, Button, Card } from 'modern-sk';
import '@olly/modern-sk/styles.css'; // required — tokens + components
import '@olly/modern-sk/fonts.css'; // optional — branded faces (see Fonts)
import { ThemeProvider, TooltipProvider, Button, Card } from '@olly/modern-sk';
export function App() {
return (
@@ -49,7 +54,7 @@ export function App() {
## 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.
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:
```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
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "modern-sk",
"version": "0.1.0",
"name": "@olly/modern-sk",
"version": "0.1.2",
"description": "ModernSK — tactile, dark-first React component library built on Radix primitives.",
"license": "MIT",
"type": "module",
@@ -22,6 +22,9 @@
"files": [
"dist"
],
"publishConfig": {
"registry": "https://git.ollyhearn.ru/api/packages/olly/npm/"
},
"scripts": {
"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",
+10
View File
@@ -30,6 +30,7 @@ import {
Dialog,
DialogClose,
IconButton,
Knob,
List,
MenuRow,
MenuSeparator,
@@ -238,6 +239,7 @@ const App = () => {
<div className="cap">Slider &amp; stepper</div>
<div className="cluster">
<Slider defaultValue={[62]} max={100} step={1} />
<Slider defaultValue={[40]} max={100} step={20} marks />
<Stepper
onDecrement={() => setCount((n) => Math.max(0, n - 1))}
onIncrement={() => setCount((n) => n + 1)}
@@ -247,6 +249,14 @@ const App = () => {
</span>
</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 className="stack">
<div>
+261
View File
@@ -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
View File
@@ -1,59 +1,133 @@
import { type ComponentPropsWithoutRef } from 'react';
import { type ComponentPropsWithoutRef, type CSSProperties } from 'react';
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> & {
steps?: number | Step[];
type KnobStyle = 'square' | 'round';
type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'className'> & {
/**
* Step marks.
* - `true` — auto-generate one mark per `step` between `min` and `max`.
* - array — explicit marks; numbers or `{ value, label }` for tick labels.
*/
marks?: MarksProp;
/**
* Where to draw the notch ticks relative to the track.
* `'bottom'` (default), `'top'`, `'both'`, or `'none'` to hide ticks
* (labels still render when provided). No effect without `marks`.
*/
notches?: NotchPlacement;
/** Thumb shape. `'square'` (default) has a small border-radius; `'round'` is a full circle. */
knobStyle?: KnobStyle;
/**
* Enable step-glide animation. Defaults to `true` when `marks` is set, `false` otherwise.
* Explicitly setting this always overrides the default.
*/
animated?: boolean;
className?: string;
};
function resolveSteps(steps: number | Step[], min: number, max: number): Step[] {
if (Array.isArray(steps)) return steps;
if (steps < 2) return [];
return Array.from({ length: steps }, (_, i) => ({
value: min + (i / (steps - 1)) * (max - min),
}));
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 01000 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 resolved = steps != null ? resolveSteps(steps, min, max) : [];
const hasSteps = resolved.length > 0;
const percent = (value: number, min: number, max: number) =>
max === min ? 0 : (value - min) / (max - min);
return (
<RSlider.Root
className={`modern-sk-slider${hasSteps ? ' modern-sk-slider--has-steps' : ''}`}
min={min}
max={max}
{...props}
>
<RSlider.Track className="modern-sk-slider__track">
<RSlider.Range className="modern-sk-slider__range" />
{hasSteps && resolved.map((step) => (
<div
key={step.value}
className="modern-sk-slider__step-dot"
aria-hidden
style={{ left: `${((step.value - min) / (max - min)) * 100}%` }}
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',
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.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>
);
};
+43 -14
View File
@@ -6,7 +6,9 @@ export const Spinner = ({
className,
...props
}: 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 (
<span
role="status"
@@ -16,21 +18,47 @@ export const Spinner = ({
>
<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>
{/* 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={`url(#${gid})`} strokeWidth="5" />
<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"
cy="18"
r="14"
@@ -39,6 +67,7 @@ export const Spinner = ({
strokeLinecap="round"
strokeDasharray="22 88"
/>
</g>
</svg>
</span>
);
+261 -9
View File
@@ -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';
export const TextField = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'>>(
({ className, ...props }, ref) => (
<input ref={ref} className={cx('modern-sk-field', className)} {...props} />
/* ------------------------------------------------------------------ *
* Typing animation (osu!-lazer style)
*
* Native inputs draw their own text, so individual letters can't be
* animated. Instead the real field renders transparent (caret stays
* visible) and a mirrored per-character <span> overlay sits behind it:
* newly typed letters rise + fade in, erased letters fall + fade out.
* ------------------------------------------------------------------ */
type FieldElement = HTMLInputElement | HTMLTextAreaElement;
interface CharEntry {
id: number;
char: string;
leaving: boolean;
x?: number;
y?: number;
}
/** Longest-common-subsequence match so unchanged letters keep their id
* (and thus don't replay the appear animation on every keystroke). */
function diffChars(prev: ReadonlyArray<{ id: number; char: string }>, next: string) {
const n = prev.length;
const m = next.length;
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] =
prev[i].char === next[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const reusedId: Array<number | null> = new Array(m).fill(null);
const keptPrev = new Array(n).fill(false);
let i = 0;
let j = 0;
while (i < n && j < m) {
if (prev[i].char === next[j]) {
reusedId[j] = prev[i].id;
keptPrev[i] = true;
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
i++;
} else {
j++;
}
}
return { reusedId, keptPrev };
}
function useFieldAnimation(
multiline: boolean,
externalRef: Ref<FieldElement>,
controlledValue: ComponentPropsWithoutRef<'input'>['value'],
initial: ComponentPropsWithoutRef<'input'>['defaultValue'],
) {
const innerRef = useRef<FieldElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const spanRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
const present = useRef<Array<{ id: number; char: string }>>([]);
const nextId = useRef(0);
const idPrefix = useId();
const [text, setText] = useState(() => String(controlledValue ?? initial ?? ''));
const [entries, setEntries] = useState<CharEntry[]>([]);
const setRef = useCallback(
(node: FieldElement | null) => {
innerRef.current = node;
if (typeof externalRef === 'function') externalRef(node);
else if (externalRef) (externalRef as { current: FieldElement | null }).current = node;
},
[externalRef],
);
const syncScroll = useCallback(() => {
const el = innerRef.current;
const ov = overlayRef.current;
if (el && ov) ov.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)`;
}, []);
// Reconcile the overlay whenever the text changes.
useLayoutEffect(() => {
const prev = present.current;
const { reusedId, keptPrev } = diffChars(prev, text);
const nextPresent: Array<{ id: number; char: string }> = [];
for (let k = 0; k < text.length; k++) {
const id = reusedId[k] ?? nextId.current++;
nextPresent.push({ id, char: text[k] });
}
// Letters that were removed fall away — pin them where they last sat.
const leaving: CharEntry[] = [];
for (let k = 0; k < prev.length; k++) {
if (keptPrev[k]) continue;
const el = spanRefs.current.get(prev[k].id);
if (el) {
leaving.push({
id: prev[k].id,
char: prev[k].char,
leaving: true,
x: el.offsetLeft,
y: el.offsetTop,
});
}
}
present.current = nextPresent;
setEntries((current) => [
...nextPresent.map((e) => ({ ...e, leaving: false })),
...current.filter((e) => e.leaving),
...leaving,
]);
syncScroll();
}, [text, syncScroll]);
// Stay in sync when used as a controlled component.
useLayoutEffect(() => {
if (controlledValue !== undefined) setText(String(controlledValue));
}, [controlledValue]);
const handleChange = useCallback((value: string) => setText(value), []);
const onLeaveEnd = useCallback((id: number) => {
spanRefs.current.delete(id);
setEntries((current) => current.filter((e) => e.id !== id));
}, []);
const overlay = (
<div
ref={overlayRef}
aria-hidden="true"
className={cx('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';
export const TextArea = forwardRef<HTMLTextAreaElement, ComponentPropsWithoutRef<'textarea'>>(
({ className, ...props }, ref) => (
<textarea ref={ref} className={cx('modern-sk-field', className)} {...props} />
),
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
true,
ref,
props.value,
props.defaultValue,
);
if (!animated) {
return (
<textarea
ref={ref}
className={cx('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';
export const SearchField = ({
icon,
...props
}: ComponentPropsWithoutRef<'input'> & { icon: ReactNode }) => (
}: TextFieldProps & { icon: ReactNode }) => (
<div className="modern-sk-search">
<span className="ph">{icon}</span>
<TextField {...props} />
+1
View File
@@ -5,6 +5,7 @@ export * from './select';
export * from './selection';
export * from './segmented-control';
export * from './slider';
export * from './knob';
export * from './tabs';
export * from './progress';
export * from './badge';
+1 -1
View File
@@ -21,6 +21,6 @@ export const Window = ({
</div>
)}
</div>
{children}
<div className="modern-sk-window-body">{children}</div>
</div>
);
+1
View File
@@ -26,6 +26,7 @@ const meta = {
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>;
+31 -4
View File
@@ -10,6 +10,7 @@ import {
Th,
Td,
Badge,
Chip,
} from '../components/ui';
const meta = {
@@ -18,18 +19,23 @@ const meta = {
parameters: {
docs: {
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>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CardSurface: Story = {
render: () => (
<Card style={{ maxWidth: 320, padding: 20 }}>
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>
@@ -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 = {
render: () => (
<Table>
+13
View File
@@ -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>;
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 (
+11 -2
View File
@@ -14,8 +14,17 @@ const meta = {
},
},
argTypes: {
variant: { control: 'inline-radio', options: ['key', 'primary', 'ember', 'ghost'] },
size: { control: 'inline-radio', options: ['sm', undefined, 'lg'] },
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>;
+97
View File
@@ -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 },
};
+17 -2
View File
@@ -25,17 +25,32 @@ const meta = {
},
argTypes: {
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: {
content: '',
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: () => (
+73
View File
@@ -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>
),
};
+68
View File
@@ -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 />,
};
+9
View File
@@ -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' },
} satisfies Meta<typeof Select>;
+10
View File
@@ -22,11 +22,21 @@ const meta = {
},
},
},
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' }}>
+54 -3
View File
@@ -8,16 +8,67 @@ const meta = {
docs: {
description: {
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>],
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = { args: { defaultValue: [60], max: 100, step: 1 } };
export const 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 },
};
+6
View File
@@ -12,6 +12,12 @@ const meta = {
},
},
},
argTypes: {
defaultValue: { control: 'text' },
value: { control: 'text' },
onValueChange: { action: 'value changed' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Tabs>;
export default meta;
+10
View File
@@ -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…' },
} satisfies Meta<typeof TextField>;
+5
View File
@@ -12,6 +12,11 @@ const meta = {
},
},
},
argTypes: {
title: { control: 'text' },
badge: { control: false },
children: { control: false },
},
args: { title: 'Finder' },
} satisfies Meta<typeof Window>;
+1529 -233
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -7,7 +7,13 @@
@import './tokens.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-']) {
box-sizing: border-box;
font-family: var(--font-sans);
}
+5 -7
View File
@@ -178,10 +178,9 @@
--grain-opacity: 0.45;
/* ---------- SPINNER GROOVE ----------
Carved donut channel — dark at the top rim, catching light at the
bottom, exactly like the sunk wells (switch / field). */
--spin-groove-1: #090a07; /* top — in shadow */
--spin-groove-2: #34352b; /* bottom — catches light */
Solid carved channel — flat base felt, sunk by an SVG inner shadow,
exactly like the switch well (no gradient). */
--spin-track: #23241c;
}
/* ============================================================
@@ -264,9 +263,8 @@
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%);
/* carved groove on warm paper — top grey shadow, bottom near-white */
--spin-groove-1: #c2c3b6;
--spin-groove-2: #ffffff;
/* carved groove on warm paper — flat base, sunk by inner shadow */
--spin-track: #dcdcd2;
}
/* ============================================================