Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0dce9d75 | |||
| 5cd33a2b9d | |||
| ef69f7bb65 | |||
| b6017d60c8 | |||
| 3da99a7214 | |||
| 3b9d3f908d | |||
| 37f0592464 |
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
registry-url: "https://git.ollyhearn.ru/api/packages/olly/npm/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Publish
|
||||
run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -43,6 +43,8 @@ Fonts are NOT in the core stylesheet. `tokens.css` defines `--font-display/sans/
|
||||
|
||||
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/`)
|
||||
|
||||
- `tokens.css` — single source of truth: color/type CSS custom properties (no font loading — see Fonts above). Every component reads from here.
|
||||
|
||||
@@ -15,7 +15,7 @@ Old-iOS skeuomorphism × macOS Sequoia neatness × Ubuntu warmth.
|
||||
Distributed via self-hosted git — install straight from the repo:
|
||||
|
||||
```bash
|
||||
npm i git+ssh://git@git.ollyhearn.ru:49239/olly/modern-sk.git
|
||||
npm i git+https://git.ollyhearn.ru/olly/modern-sk.git
|
||||
```
|
||||
|
||||
`react` and `react-dom` (>=18) are peer dependencies — your app provides them.
|
||||
|
||||
+5
-2
@@ -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",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
IconButton,
|
||||
Knob,
|
||||
List,
|
||||
MenuRow,
|
||||
MenuSeparator,
|
||||
@@ -248,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,11 @@ type SliderProps = Omit<ComponentPropsWithoutRef<typeof RSlider.Root>, 'classNam
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -73,6 +78,7 @@ export const Slider = ({
|
||||
marks,
|
||||
notches = 'bottom',
|
||||
knobStyle = 'square',
|
||||
animated,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
@@ -84,10 +90,12 @@ export const Slider = ({
|
||||
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',
|
||||
|
||||
@@ -165,7 +165,7 @@ function useFieldAnimation(
|
||||
if (node) spanRefs.current.set(e.id, node);
|
||||
else spanRefs.current.delete(e.id);
|
||||
}}
|
||||
className="modern-sk-field-char"
|
||||
className={cx('modern-sk-field-char', !multiline && 'modern-sk-field-char--composited')}
|
||||
>
|
||||
{e.char}
|
||||
</span>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
+230
-7
@@ -269,11 +269,23 @@ textarea.modern-sk-field {
|
||||
.modern-sk-field-char {
|
||||
/* inline (not inline-block) so the overlay wraps exactly like the field;
|
||||
inherit white-space so multiline (pre-wrap) wraps while input (pre) won't.
|
||||
'pre' on the span itself would suppress wrapping at its boundaries. */
|
||||
'pre' on the span itself would suppress wrapping at its boundaries.
|
||||
Multiline keeps this inline path: inline boxes ignore transform, so the
|
||||
rise eases `top` (a layout property — acceptable here, textarea wraps
|
||||
instead of scrolling so there's no per-keystroke scroll-sync thrash). */
|
||||
position: relative;
|
||||
white-space: inherit;
|
||||
animation: modern-sk-char-in var(--dur-base) var(--ease-snap) both;
|
||||
}
|
||||
/* Single-line never wraps, so chars can be inline-block and animate
|
||||
`transform` (compositor-only) instead of `top` (relayout every frame).
|
||||
The single-line field scrolls horizontally and syncs the overlay each
|
||||
keystroke, so the layout-triggering `top` path janked there — this doesn't. */
|
||||
.modern-sk-field-char--composited {
|
||||
display: inline-block;
|
||||
will-change: transform, opacity;
|
||||
animation-name: modern-sk-char-in-rise;
|
||||
}
|
||||
.modern-sk-field-char--leaving {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
@@ -292,6 +304,17 @@ textarea.modern-sk-field {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
/* inline-block (single-line) variant — composited transform, no relayout */
|
||||
@keyframes modern-sk-char-in-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.32em);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes modern-sk-char-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
@@ -607,7 +630,7 @@ textarea.modern-sk-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 200px;
|
||||
width: 100%;
|
||||
height: var(--ms-thumb);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
@@ -626,8 +649,10 @@ textarea.modern-sk-field {
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--lime-deep), var(--lime));
|
||||
box-shadow: 0 0 10px rgba(190, 242, 100, 0.4);
|
||||
/* Radix positions the range via left/right offsets (not width); ease those
|
||||
so the fill glides between discrete steps while dragging. */
|
||||
}
|
||||
/* Radix positions the range via left/right offsets (not width); ease those
|
||||
so the fill glides between discrete steps while dragging. */
|
||||
.modern-sk-slider--animated .modern-sk-slider__range {
|
||||
transition:
|
||||
left 0.12s ease-out,
|
||||
right 0.12s ease-out;
|
||||
@@ -647,12 +672,12 @@ textarea.modern-sk-field {
|
||||
/* Radix sets the step position (left) on a WRAPPER span around the thumb, not on
|
||||
the thumb element itself — so the transition must live on that wrapper. The
|
||||
wrapper is the slider's direct child span that isn't the track. */
|
||||
.modern-sk-slider > span:not(.modern-sk-slider__track) {
|
||||
.modern-sk-slider--animated > span:not(.modern-sk-slider__track) {
|
||||
transition: left 0.12s ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modern-sk-slider > span:not(.modern-sk-slider__track),
|
||||
.modern-sk-slider__range {
|
||||
.modern-sk-slider--animated > span:not(.modern-sk-slider__track),
|
||||
.modern-sk-slider--animated .modern-sk-slider__range {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -736,6 +761,204 @@ textarea.modern-sk-field {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- KNOB (circular slider) ----------
|
||||
Skeuomorphic rotary control. The dial follows the pointer 1:1 while dragging
|
||||
(bound to the mouse); the gauge fill + value snap to detents and *glide*
|
||||
between them (like the stepped Slider) when `--animated`. Anatomy:
|
||||
recessed SVG gauge ring → glossy cap → knurled dial (rotates, carries the
|
||||
accent pointer) → turned-metal hub. Sizing flows from --knob-size. */
|
||||
.modern-sk-knob {
|
||||
--knob-size: 108px;
|
||||
--knob-accent: var(--lime);
|
||||
--knob-accent-deep: var(--lime-deep);
|
||||
--knob-glow: rgba(190, 242, 100, 0.5);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: var(--knob-size);
|
||||
height: var(--knob-size);
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
touch-action: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.modern-sk-knob.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.modern-sk-knob--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* recessed gauge ring */
|
||||
.modern-sk-knob__gauge {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
.modern-sk-knob__track {
|
||||
fill: none;
|
||||
stroke: var(--steel-800);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
/* The fill is the full arc, revealed up to the value via stroke-dashoffset (set
|
||||
inline as a plain number). When `--animated`, transition the offset so the
|
||||
accent eases along the circle between detents. */
|
||||
.modern-sk-knob__fill {
|
||||
fill: none;
|
||||
stroke: var(--knob-accent);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 5px var(--knob-glow));
|
||||
transition: stroke var(--dur-quick) var(--ease-out);
|
||||
}
|
||||
.modern-sk-knob--animated .modern-sk-knob__fill {
|
||||
transition:
|
||||
stroke-dashoffset 0.12s ease-out,
|
||||
stroke var(--dur-quick) var(--ease-out);
|
||||
}
|
||||
.modern-sk-knob__tick {
|
||||
stroke: var(--fg-3);
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.45;
|
||||
transition:
|
||||
stroke var(--dur-quick),
|
||||
opacity var(--dur-quick);
|
||||
}
|
||||
.modern-sk-knob__tick.is-on {
|
||||
stroke: var(--knob-accent);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* glossy cap — static top-down gloss (light source stays put) */
|
||||
.modern-sk-knob__cap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(var(--knob-size) - 34px);
|
||||
height: calc(var(--knob-size) - 34px);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--hair-strong);
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.16), transparent 58%),
|
||||
var(--grad-key);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.08) inset,
|
||||
0 4px 9px rgba(0, 0, 0, 0.5),
|
||||
0 11px 22px rgba(0, 0, 0, 0.42);
|
||||
overflow: hidden;
|
||||
transition: box-shadow var(--dur-quick) var(--ease-out);
|
||||
}
|
||||
|
||||
/* knurled dial — rotates with the pointer, carries the glowing pointer dot.
|
||||
The ribbed grip lives on ::before (masked to a ring) so the pointer dot can
|
||||
sit unmasked on top and keep its full halo. While dragging it tracks the
|
||||
mouse with no transition; otherwise it glides to the settled value. */
|
||||
.modern-sk-knob__dial {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.modern-sk-knob--animated .modern-sk-knob__dial {
|
||||
transition: transform 0.12s ease-out;
|
||||
}
|
||||
.modern-sk-knob.is-dragging .modern-sk-knob__dial {
|
||||
transition: none;
|
||||
}
|
||||
.modern-sk-knob__dial::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(255, 255, 255, 0.09) 0deg 3deg,
|
||||
rgba(0, 0, 0, 0.28) 3deg 6deg
|
||||
);
|
||||
-webkit-mask: radial-gradient(circle, transparent 0 58%, #000 61% 100%);
|
||||
mask: radial-gradient(circle, transparent 0 58%, #000 61% 100%);
|
||||
}
|
||||
.modern-sk-knob__pointer {
|
||||
position: absolute;
|
||||
top: 5%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--knob-accent);
|
||||
box-shadow: 0 0 6px var(--knob-glow);
|
||||
}
|
||||
/* turned-metal hub covers the inner ends; pointer reads as an outer notch */
|
||||
.modern-sk-knob__hub {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48%;
|
||||
height: 48%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 50% 34%, #36372c, #1d1e17);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.07) inset,
|
||||
0 1px 3px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.modern-sk-knob:focus-visible .modern-sk-knob__cap {
|
||||
box-shadow:
|
||||
var(--focus-ring),
|
||||
0 1px 0 rgba(255, 255, 255, 0.08) inset,
|
||||
0 4px 9px rgba(0, 0, 0, 0.5),
|
||||
0 11px 22px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
/* ember accent variant */
|
||||
.modern-sk-knob--ember {
|
||||
--knob-accent: var(--ember);
|
||||
--knob-accent-deep: var(--ember-deep);
|
||||
--knob-glow: rgba(233, 87, 43, 0.5);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modern-sk-knob--animated .modern-sk-knob__fill,
|
||||
.modern-sk-knob--animated .modern-sk-knob__dial {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* light theme: brighter cap gloss, darker knurl ridges so the grip reads, a
|
||||
pale turned hub, and a lighter recessed gauge channel. */
|
||||
[data-theme='light'] .modern-sk-knob__cap {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.9) inset,
|
||||
0 2px 5px rgba(0, 0, 0, 0.16),
|
||||
0 7px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
[data-theme='light'] .modern-sk-knob__dial::before {
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
rgba(0, 0, 0, 0.09) 0deg 3deg,
|
||||
rgba(255, 255, 255, 0.6) 3deg 6deg
|
||||
);
|
||||
}
|
||||
[data-theme='light'] .modern-sk-knob__hub {
|
||||
background: radial-gradient(circle at 50% 34%, #ffffff, #e2e2d6);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.9) inset,
|
||||
0 1px 3px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
[data-theme='light'] .modern-sk-knob__track {
|
||||
stroke: var(--steel-500);
|
||||
}
|
||||
[data-theme='light'] .modern-sk-knob__tick {
|
||||
stroke: var(--steel-400);
|
||||
}
|
||||
|
||||
/* ---------- STEPPER ---------- */
|
||||
.modern-sk-stepper {
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user