@kennycorrea/ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 — 2026-10-06
4
+
5
+ ### Added
6
+ - **`Slider` `fillFrom`**: fills from a fixed value to the thumb, in either direction, instead of
7
+ from `min`. Same units as `value`, clamped to [min, max]; nothing is filled when the thumb sits
8
+ on it. The fill's ends line up with the thumb's centre at every value, using Radix's own
9
+ in-bounds offset. Built on `@radix-ui/react-slider` and styled from Themes' public tokens
10
+ (`.ui-slider*` in `tokens.css`), because Themes' slider gives no way to place its range —
11
+ consumers overriding `.rt-SliderRange` can drop that CSS.
12
+ Single horizontal left-to-right thumb only: with two thumbs, `orientation="vertical"`,
13
+ `inverted` or `dir="rtl"` it is ignored with a development warning. Margin props are not
14
+ supported on this path; wrap it in a `Box`.
15
+ - **`Slider` `marks`**: `{ value, label }[]` drawn as an `aria-hidden` scale above the track,
16
+ each centred on its value, the first and last growing inwards so neither overflows.
17
+ - Unit tests (Vitest + Testing Library); `npm test` now runs them after lint and types.
18
+
19
+ ### Unchanged
20
+ - Without `fillFrom`, `Slider` is still the Radix Themes slider, rendered exactly as in 0.4.0.
21
+
22
+ ### Requires
23
+ - `tokens.css` imported, as it already must be for toasts — the `fillFrom` path is unstyled
24
+ without it.
25
+
26
+ ## 0.4.0
27
+ - `Slider` atom over Radix Themes' slider; `label` required.
package/README.md CHANGED
@@ -204,8 +204,8 @@ blaming the workflow:
204
204
  because there is no one there to type a one-time password. That failure reads like a bad
205
205
  credential and is not one.
206
206
 
207
- ⚠ **`npm test` has to keep existing.** It is lint plus a typecheck rather than unit tests — there
208
- are no unit tests here — and it is load-bearing for a reason that has nothing to do with quality:
207
+ ⚠ **`npm test` has to keep existing.** It is lint, a typecheck and (since 0.5.0) the Vitest suite — and
208
+ it is load-bearing for a reason that has nothing to do with quality:
209
209
  without a `test` script the `build` job fails on `Missing script: "test"`, `publish-npm` never
210
210
  runs because it needs `build`, and **the release publishes nothing while looking like it worked.**
211
211
  That was the state when the workflow was first added.
@@ -257,3 +257,33 @@ toast.dismiss(id); // or toast.dismiss() for all
257
257
  `duration` overrides the `Toaster` default per toast. An action closes the toast after its
258
258
  `onClick` unless `keepOpen` is set. Passing an existing `id` replaces that toast in place.
259
259
  The close button's name comes from `UiStrings.dismiss`, so feed it through `UiStringsProvider`.
260
+
261
+ ## Slider — 0.4.0
262
+
263
+ A thin wrap of Radix Themes' `Slider` at size `2`, like the other atoms. `label` is required and
264
+ becomes the `aria-label`, because a slider has no visible text of its own.
265
+
266
+ ```tsx
267
+ <Slider label="Reminder lead time (hours)" min={1} max={48} step={1}
268
+ value={[hours]} onValueChange={([h]) => setHours(h)} />
269
+ <Slider label="Price range" defaultValue={[10, 60]} min={0} max={200} /> // two thumbs = range
270
+ ```
271
+
272
+ Values are arrays, as in Radix: one entry per thumb.
273
+
274
+ ## Slider fill origin and marks — 0.5.0
275
+
276
+ ```tsx
277
+ const marks = [0, 6, 11, 16, 22, 29].map((value) => ({ value, label: String(value) }));
278
+ <Slider label="Budget cycle" min={0} max={29} value={day} onValueChange={setDay}
279
+ fillFrom={16} marks={marks} />
280
+ ```
281
+
282
+ `fillFrom` fills from that value to the thumb, either way. Themes' slider cannot place its
283
+ range, so with `fillFrom` the atom is built on `@radix-ui/react-slider` and styled by
284
+ `.ui-slider*` in `tokens.css` from Themes' public tokens — no `.rt-*` class is touched.
285
+ Without it, nothing changed. Limits and details: [CHANGELOG.md](CHANGELOG.md); a full example
286
+ is in `examples/BudgetCycleSlider.tsx`.
287
+
288
+ ⚠ `--ui-slider-thumb-size` mirrors Themes' size-2 thumb (`--space-2 + --space-1`). The fill
289
+ and the marks are placed from it, so if Themes ever resizes its thumb, change it there.
@@ -0,0 +1,24 @@
1
+ import { Slider as ThemedSlider } from '@radix-ui/themes';
2
+ import { type ComponentPropsWithoutRef } from 'react';
3
+ type ThemedProps = ComponentPropsWithoutRef<typeof ThemedSlider>;
4
+ export interface SliderMark {
5
+ value: number;
6
+ label: string;
7
+ }
8
+ export interface SliderProps extends Omit<ThemedProps, 'size' | 'aria-label'> {
9
+ /**
10
+ * Required: a slider has no visible text of its own, so without this a screen reader
11
+ * announces a bare number with nothing to say what it measures.
12
+ */
13
+ label: string;
14
+ /**
15
+ * Fill from this value to the thumb instead of from `min`, in either direction — for a scale
16
+ * with a meaningful middle. Same units as `value`, clamped to [min, max]. Only for one
17
+ * horizontal left-to-right thumb; anything else ignores it and warns in development.
18
+ */
19
+ fillFrom?: number;
20
+ /** A scale drawn above the track. Decorative: the slider already announces its value. */
21
+ marks?: SliderMark[];
22
+ }
23
+ export declare const Slider: import("react").ForwardRefExoticComponent<SliderProps & import("react").RefAttributes<HTMLSpanElement>>;
24
+ export {};
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as SliderPrimitive from '@radix-ui/react-slider';
3
+ import { Slider as ThemedSlider } from '@radix-ui/themes';
4
+ import { forwardRef, useState } from 'react';
5
+ import { cn } from '../../utils/cn.js';
6
+ import { fillEdges, fillStyle, percentOf, thumbCentre } from './geometry.js';
7
+ const MARGIN_KEYS = ['m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'];
8
+ const isDev = globalThis.process?.env?.NODE_ENV !==
9
+ 'production';
10
+ /** Why `fillFrom` cannot be honoured, or null when it can. */
11
+ function fillFromUnsupported(props, thumbs) {
12
+ if (thumbs !== 1)
13
+ return `it needs exactly one thumb, and this slider has ${thumbs}`;
14
+ if (props.orientation === 'vertical')
15
+ return 'vertical sliders are not supported';
16
+ if (props.inverted)
17
+ return 'inverted sliders are not supported';
18
+ if (props.dir === 'rtl')
19
+ return 'right-to-left sliders are not supported';
20
+ return null;
21
+ }
22
+ export const Slider = forwardRef(function Slider(props, ref) {
23
+ const { label, fillFrom, marks, className, onValueChange, ...rest } = props;
24
+ const min = rest.min ?? 0;
25
+ const max = rest.max ?? 100;
26
+ // Mirrored so the fill can follow an uncontrolled slider too.
27
+ const [inner, setInner] = useState(rest.defaultValue ?? [min]);
28
+ const values = rest.value ?? inner;
29
+ const handleChange = (next) => {
30
+ setInner(next);
31
+ onValueChange?.(next);
32
+ };
33
+ const unsupported = fillFrom === undefined ? null : fillFromUnsupported(props, values.length);
34
+ if (unsupported && isDev)
35
+ console.warn(`Slider: fillFrom is ignored — ${unsupported}.`);
36
+ const slider = fillFrom !== undefined && !unsupported ? (_jsx(FillFromSlider, { ref: ref, ...rest, label: label, className: className, values: values, fillFrom: fillFrom, min: min, max: max, onValueChange: handleChange })) : (_jsx(ThemedSlider, { ref: ref, size: "2", "aria-label": label, className: cn(className), ...rest, onValueChange: handleChange }));
37
+ if (!marks?.length)
38
+ return slider;
39
+ return (_jsxs("div", { className: "ui-slider-field", children: [_jsx(SliderMarks, { marks: marks, min: min, max: max }), slider] }));
40
+ });
41
+ /**
42
+ * Radix Themes draws its range itself and offers no way to place it, so this path is built on the
43
+ * primitives and styled in `tokens.css` (`.ui-slider*`) from Themes' public tokens — never by
44
+ * reaching into Themes' own class names.
45
+ */
46
+ const FillFromSlider = forwardRef(function FillFromSlider({ label, values, fillFrom, min, max, className, color, radius, variant, highContrast, defaultValue: _uncontrolledHandledAbove, tabIndex, ...rest }, ref) {
47
+ const primitiveProps = {};
48
+ let droppedMargin = false;
49
+ for (const [key, val] of Object.entries(rest)) {
50
+ if (MARGIN_KEYS.includes(key))
51
+ droppedMargin = true;
52
+ else
53
+ primitiveProps[key] = val;
54
+ }
55
+ if (droppedMargin && isDev) {
56
+ console.warn('Slider: margin props are not supported with fillFrom — wrap it in a Box.');
57
+ }
58
+ const edges = fillEdges(values[0] ?? min, fillFrom, min, max);
59
+ return (_jsxs(SliderPrimitive.Root, { ref: ref, ...primitiveProps, value: values, min: min, max: max, "aria-label": label, "data-accent-color": color, "data-radius": radius, "data-variant": variant ?? 'surface', className: cn('ui-slider', highContrast && 'ui-slider-high-contrast', className), children: [_jsx(SliderPrimitive.Track, { className: "ui-slider-track", children: _jsx("span", { className: "ui-slider-fill", "data-fill": "", "data-empty": edges.from === edges.to ? '' : undefined, "data-disabled": rest.disabled ? '' : undefined, style: fillStyle(edges) }) }), _jsx(SliderPrimitive.Thumb, { className: "ui-slider-thumb", "aria-label": label, ...(tabIndex !== undefined ? { tabIndex } : undefined) })] }));
60
+ });
61
+ function SliderMarks({ marks, min, max }) {
62
+ const sorted = [...marks].sort((a, b) => a.value - b.value);
63
+ const last = sorted.length - 1;
64
+ return (_jsx("div", { className: "ui-slider-marks", "aria-hidden": true, children: sorted.map((mark, i) => {
65
+ const centre = thumbCentre(percentOf(mark.value, min, max));
66
+ // Centred over its value, except the two ends, which grow inwards so neither overflows.
67
+ let style = { left: centre, transform: 'translateX(-50%)' };
68
+ if (last > 0 && i === 0)
69
+ style = { left: centre };
70
+ if (last > 0 && i === last)
71
+ style = { right: `calc(100% - ${centre})` };
72
+ return (_jsx("span", { "data-mark": mark.value, style: style, children: mark.label }, `${mark.value}-${mark.label}`));
73
+ }) }));
74
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The same maths Radix's slider uses to place a thumb, so anything we draw on the track lines up
3
+ * with the thumb's centre. Radix keeps the thumb inside the track by nudging it inwards by up to
4
+ * half its width at the ends (`getThumbInBoundsOffset`): `+half` at 0%, nothing at 50%, `-half`
5
+ * at 100%. Written as CSS against `--ui-slider-thumb-size` so no element has to be measured.
6
+ */
7
+ /** Radix's `convertValueToPercentage`: 0–100, clamped. */
8
+ export declare function percentOf(value: number, min: number, max: number): number;
9
+ /** Distance from the track's left edge to the centre of a thumb sitting at `percent`. */
10
+ export declare function thumbCentre(percent: number): string;
11
+ export interface FillEdges {
12
+ /** Percent of the lower end, clamped to the track. */
13
+ from: number;
14
+ /** Percent of the higher end. Equal to `from` when nothing is filled. */
15
+ to: number;
16
+ }
17
+ /** The stretch between `fillFrom` and the thumb, whichever is lower to whichever is higher. */
18
+ export declare function fillEdges(value: number, fillFrom: number, min: number, max: number): FillEdges;
19
+ /** Inline position of the fill: left edge at the lower centre, right edge at the higher one. */
20
+ export declare function fillStyle({ from, to }: FillEdges): {
21
+ left: string;
22
+ right: string;
23
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The same maths Radix's slider uses to place a thumb, so anything we draw on the track lines up
3
+ * with the thumb's centre. Radix keeps the thumb inside the track by nudging it inwards by up to
4
+ * half its width at the ends (`getThumbInBoundsOffset`): `+half` at 0%, nothing at 50%, `-half`
5
+ * at 100%. Written as CSS against `--ui-slider-thumb-size` so no element has to be measured.
6
+ */
7
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
8
+ /** Radix's `convertValueToPercentage`: 0–100, clamped. */
9
+ export function percentOf(value, min, max) {
10
+ if (max === min)
11
+ return 0;
12
+ return clamp(((value - min) / (max - min)) * 100, 0, 100);
13
+ }
14
+ /** Distance from the track's left edge to the centre of a thumb sitting at `percent`. */
15
+ export function thumbCentre(percent) {
16
+ return `calc(${percent}% + var(--ui-slider-thumb-size) * ${0.5 - percent / 100})`;
17
+ }
18
+ /** The stretch between `fillFrom` and the thumb, whichever is lower to whichever is higher. */
19
+ export function fillEdges(value, fillFrom, min, max) {
20
+ const a = percentOf(value, min, max);
21
+ const b = percentOf(fillFrom, min, max);
22
+ return { from: Math.min(a, b), to: Math.max(a, b) };
23
+ }
24
+ /** Inline position of the fill: left edge at the lower centre, right edge at the higher one. */
25
+ export function fillStyle({ from, to }) {
26
+ return { left: thumbCentre(from), right: `calc(100% - ${thumbCentre(to)})` };
27
+ }
@@ -0,0 +1 @@
1
+ export * from './Slider.js';
@@ -0,0 +1 @@
1
+ export * from './Slider.js';
@@ -12,6 +12,7 @@ export * from './Popover/index.js';
12
12
  export * from './Progress/index.js';
13
13
  export * from './Select/index.js';
14
14
  export * from './Separator/index.js';
15
+ export * from './Slider/index.js';
15
16
  export * from './Spinner/index.js';
16
17
  export * from './Switch/index.js';
17
18
  export * from './Textarea/index.js';
@@ -12,6 +12,7 @@ export * from './Popover/index.js';
12
12
  export * from './Progress/index.js';
13
13
  export * from './Select/index.js';
14
14
  export * from './Separator/index.js';
15
+ export * from './Slider/index.js';
15
16
  export * from './Spinner/index.js';
16
17
  export * from './Switch/index.js';
17
18
  export * from './Textarea/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Upfront's house component layer: atoms over Radix Themes, the token bridge, and the accent logic.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "tokens.css",
11
- "README.md"
11
+ "README.md",
12
+ "CHANGELOG.md"
12
13
  ],
13
14
  "main": "./dist/index.js",
14
15
  "types": "./dist/index.d.ts",
@@ -25,7 +26,8 @@
25
26
  "lint": "biome check .",
26
27
  "format": "biome check --write .",
27
28
  "prepublishOnly": "npm run build",
28
- "test": "biome check . && tsc -p tsconfig.json --noEmit"
29
+ "test": "biome check . && tsc -p tsconfig.test.json && vitest run",
30
+ "test:watch": "vitest"
29
31
  },
30
32
  "peerDependencies": {
31
33
  "@radix-ui/themes": "^3.3.0",
@@ -33,6 +35,7 @@
33
35
  "react-dom": "^19.0.0"
34
36
  },
35
37
  "dependencies": {
38
+ "@radix-ui/react-slider": "^1.5.0",
36
39
  "@radix-ui/react-toast": "^1.2.23",
37
40
  "clsx": "^2.1.1",
38
41
  "tailwind-merge": "^3.7.0"
@@ -40,11 +43,15 @@
40
43
  "devDependencies": {
41
44
  "@biomejs/biome": "^2.5.13",
42
45
  "@radix-ui/themes": "^3.3.0",
46
+ "@testing-library/react": "^16.3.3",
47
+ "@testing-library/user-event": "^14.6.7",
43
48
  "@types/react": "^19.2.18",
44
49
  "@types/react-dom": "^19.2.7",
50
+ "jsdom": "^30.1.2",
45
51
  "react": "^19.2.8",
46
52
  "react-dom": "^19.2.8",
47
- "typescript": "~6.0.2"
53
+ "typescript": "~6.0.2",
54
+ "vitest": "^5.0.3"
48
55
  },
49
56
  "publishConfig": {
50
57
  "access": "public"
package/tokens.css CHANGED
@@ -261,3 +261,116 @@
261
261
  @media (prefers-reduced-motion: reduce) {
262
262
  .ui-toast[data-state] { animation-duration: 1ms; }
263
263
  }
264
+
265
+ /**
266
+ * Slider with `fillFrom` — built on @radix-ui/react-slider because Radix Themes gives no way to
267
+ * place its range. Mirrors Themes' size-2 slider using only its public tokens, so a Themes
268
+ * release cannot break it by renaming a class. Keep `--ui-slider-thumb-size` in step with
269
+ * Themes' `--slider-thumb-size`: the fill and the marks are positioned from it.
270
+ */
271
+ .ui-slider-field,
272
+ .ui-slider {
273
+ --ui-slider-track-size: var(--space-2);
274
+ --ui-slider-thumb-size: calc(var(--ui-slider-track-size) + var(--space-1));
275
+ }
276
+ .ui-slider-field { display: flex; flex-direction: column; gap: var(--space-2); flex-grow: 1; width: 100%; }
277
+ .ui-slider-marks {
278
+ position: relative;
279
+ height: var(--line-height-1);
280
+ font-size: var(--font-size-1);
281
+ line-height: var(--line-height-1);
282
+ color: var(--gray-11);
283
+ }
284
+ .ui-slider-marks > span { position: absolute; top: 0; white-space: nowrap; }
285
+
286
+ .ui-slider {
287
+ --ui-slider-thumb-shadow: 0 0 0 1px var(--black-a4);
288
+ position: relative;
289
+ display: flex;
290
+ align-items: center;
291
+ flex-grow: 1;
292
+ width: 100%;
293
+ height: var(--ui-slider-track-size);
294
+ border-radius: max(
295
+ calc(var(--radius-factor) * var(--ui-slider-track-size) / 3),
296
+ calc(var(--radius-factor) * var(--radius-thumb))
297
+ );
298
+ user-select: none;
299
+ touch-action: none;
300
+ }
301
+ .ui-slider-track {
302
+ position: relative;
303
+ overflow: hidden;
304
+ flex-grow: 1;
305
+ height: var(--ui-slider-track-size);
306
+ border-radius: inherit;
307
+ background-color: var(--gray-a3);
308
+ box-shadow: inset 0 0 0 1px var(--gray-a5);
309
+ }
310
+ .ui-slider-fill {
311
+ position: absolute;
312
+ height: 100%;
313
+ border-radius: inherit;
314
+ background-color: var(--accent-track);
315
+ box-shadow: inset 0 0 0 1px var(--gray-a5);
316
+ }
317
+ .ui-slider-fill[data-empty] { visibility: hidden; }
318
+ .ui-slider-high-contrast .ui-slider-fill { background-color: var(--accent-12); }
319
+
320
+ .ui-slider[data-variant='soft'] .ui-slider-track {
321
+ background-color: var(--gray-a4);
322
+ background-image: linear-gradient(var(--white-a1), var(--white-a1));
323
+ box-shadow: none;
324
+ }
325
+ .ui-slider[data-variant='soft'] .ui-slider-fill {
326
+ background-color: var(--accent-6);
327
+ background-image: linear-gradient(var(--accent-a5), var(--accent-a5));
328
+ box-shadow: none;
329
+ }
330
+ .ui-slider[data-variant='classic'] .ui-slider-track { box-shadow: var(--shadow-1); }
331
+ .ui-slider[data-variant='classic'] .ui-slider-fill {
332
+ box-shadow: inset 0 0 0 1px var(--accent-a4), inset 0 1.5px 2px 0 var(--black-a2);
333
+ }
334
+ .ui-slider[data-variant='classic'] {
335
+ --ui-slider-thumb-shadow: 0 0 0 1px var(--black-a3), 0 1px 3px var(--black-a1),
336
+ 0 2px 4px -1px var(--black-a1);
337
+ }
338
+
339
+ .ui-slider-thumb {
340
+ display: block;
341
+ width: var(--ui-slider-thumb-size);
342
+ height: var(--ui-slider-thumb-size);
343
+ outline: 0;
344
+ }
345
+ /* A larger hit area, and a visible thumb that slightly overshoots the track, as in Themes. */
346
+ .ui-slider-thumb::before {
347
+ content: '';
348
+ position: absolute;
349
+ z-index: -1;
350
+ width: calc(var(--ui-slider-thumb-size) * 3);
351
+ height: calc(var(--ui-slider-thumb-size) * 3);
352
+ top: 50%;
353
+ left: 50%;
354
+ transform: translate(-50%, -50%);
355
+ }
356
+ .ui-slider-thumb::after {
357
+ content: '';
358
+ position: absolute;
359
+ inset: calc(-0.25 * var(--ui-slider-track-size));
360
+ background-color: white;
361
+ border-radius: max(var(--radius-1), var(--radius-thumb));
362
+ box-shadow: var(--ui-slider-thumb-shadow);
363
+ cursor: var(--cursor-slider-thumb);
364
+ }
365
+ .ui-slider-thumb:focus-visible::after {
366
+ box-shadow: var(--ui-slider-thumb-shadow), 0 0 0 3px var(--accent-3), 0 0 0 5px var(--focus-8);
367
+ }
368
+ .ui-slider-thumb:active { cursor: var(--cursor-slider-thumb-active); }
369
+
370
+ .ui-slider[data-disabled] { cursor: var(--cursor-disabled); mix-blend-mode: multiply; }
371
+ :is(.dark, .dark-theme) .ui-slider[data-disabled] { mix-blend-mode: screen; }
372
+ .ui-slider-track[data-disabled] { box-shadow: inset 0 0 0 1px var(--gray-a4); }
373
+ .ui-slider-fill[data-disabled] { background: none; box-shadow: none; }
374
+ .ui-slider-thumb[data-disabled],
375
+ .ui-slider-thumb[data-disabled]::after { cursor: var(--cursor-disabled); }
376
+ .ui-slider-thumb[data-disabled]::after { background-color: var(--gray-1); box-shadow: 0 0 0 1px var(--gray-6); }