@kennycorrea/ui 0.4.0 → 0.6.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,43 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0 — 2026-10-07
4
+
5
+ ### Added
6
+ - **`RichText`**: a Tiptap editor (StarterKit) under a Radix `Toolbar`. Marks (bold, italic,
7
+ strikethrough) are a multiple `ToggleGroup`, blocks (heading, bulleted, numbered list) a single
8
+ one, so the toolbar is one tab stop with arrow keys. `value`/`onChange` speak HTML; also
9
+ `placeholder`, `disabled`, `invalid`, `rows`, `aria-label`/`aria-labelledby`, and a ref
10
+ exposing `{ editor, focus }`.
11
+ Styled as a Themes `TextArea` (surface, size 2) from public tokens — border, focus ring,
12
+ background, invalid and disabled states — in `.ui-richtext*` in `tokens.css`.
13
+ - `UiStrings` gains `formatting`, `bold`, `italic`, `strike`, `heading`, `bulletList`,
14
+ `orderedList` (English defaults; hosts should translate them).
15
+
16
+ ### Requires
17
+ - `tokens.css` imported; the editor is unstyled without it.
18
+
19
+ ## 0.5.0 — 2026-10-06
20
+
21
+ ### Added
22
+ - **`Slider` `fillFrom`**: fills from a fixed value to the thumb, in either direction, instead of
23
+ from `min`. Same units as `value`, clamped to [min, max]; nothing is filled when the thumb sits
24
+ on it. The fill's ends line up with the thumb's centre at every value, using Radix's own
25
+ in-bounds offset. Built on `@radix-ui/react-slider` and styled from Themes' public tokens
26
+ (`.ui-slider*` in `tokens.css`), because Themes' slider gives no way to place its range —
27
+ consumers overriding `.rt-SliderRange` can drop that CSS.
28
+ Single horizontal left-to-right thumb only: with two thumbs, `orientation="vertical"`,
29
+ `inverted` or `dir="rtl"` it is ignored with a development warning. Margin props are not
30
+ supported on this path; wrap it in a `Box`.
31
+ - **`Slider` `marks`**: `{ value, label }[]` drawn as an `aria-hidden` scale above the track,
32
+ each centred on its value, the first and last growing inwards so neither overflows.
33
+ - Unit tests (Vitest + Testing Library); `npm test` now runs them after lint and types.
34
+
35
+ ### Unchanged
36
+ - Without `fillFrom`, `Slider` is still the Radix Themes slider, rendered exactly as in 0.4.0.
37
+
38
+ ### Requires
39
+ - `tokens.css` imported, as it already must be for toasts — the `fillFrom` path is unstyled
40
+ without it.
41
+
42
+ ## 0.4.0
43
+ - `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.
@@ -270,3 +270,20 @@ becomes the `aria-label`, because a slider has no visible text of its own.
270
270
  ```
271
271
 
272
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,31 @@
1
+ import { type Editor } from '@tiptap/react';
2
+ export interface RichTextProps {
3
+ /** HTML. Later changes from outside replace the document. */
4
+ value?: string;
5
+ /** Called with the document as HTML on every edit. */
6
+ onChange?: (html: string) => void;
7
+ placeholder?: string;
8
+ disabled?: boolean;
9
+ invalid?: boolean;
10
+ /** Minimum editable height in rows of text, like `Textarea`'s `rows`. */
11
+ rows?: number;
12
+ /** Accessible name of the editing surface. */
13
+ 'aria-label'?: string;
14
+ 'aria-labelledby'?: string;
15
+ id?: string;
16
+ className?: string;
17
+ }
18
+ /** Imperative access for hosts that need to focus or drive the editor directly. */
19
+ export interface RichTextHandle {
20
+ editor: Editor | null;
21
+ focus: () => void;
22
+ }
23
+ /**
24
+ * Tiptap's editing surface under a Radix Toolbar, dressed as a Themes `TextArea`.
25
+ *
26
+ * Radix has no rich-text canvas, so the container borrows the TextArea's look from Themes'
27
+ * public tokens (`.ui-richtext*` in `tokens.css`) rather than its classes — a Themes release
28
+ * can rename `.rt-TextAreaRoot` without breaking this. Marks are a multiple ToggleGroup, block
29
+ * types a single one, so the whole toolbar is one tab stop with arrow-key navigation.
30
+ */
31
+ export declare const RichText: import("react").ForwardRefExoticComponent<RichTextProps & import("react").RefAttributes<RichTextHandle>>;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as Toolbar from '@radix-ui/react-toolbar';
3
+ import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
6
+ import { useUiStrings } from '../../strings.js';
7
+ import { cn } from '../../utils/cn.js';
8
+ const MARKS = ['bold', 'italic', 'strike'];
9
+ /**
10
+ * Tiptap's editing surface under a Radix Toolbar, dressed as a Themes `TextArea`.
11
+ *
12
+ * Radix has no rich-text canvas, so the container borrows the TextArea's look from Themes'
13
+ * public tokens (`.ui-richtext*` in `tokens.css`) rather than its classes — a Themes release
14
+ * can rename `.rt-TextAreaRoot` without breaking this. Marks are a multiple ToggleGroup, block
15
+ * types a single one, so the whole toolbar is one tab stop with arrow-key navigation.
16
+ */
17
+ export const RichText = forwardRef(function RichText({ value, onChange, placeholder, disabled = false, invalid = false, rows = 3, id, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, }, ref) {
18
+ const strings = useUiStrings();
19
+ const attributes = {
20
+ class: 'ui-richtext-content',
21
+ role: 'textbox',
22
+ 'aria-multiline': 'true',
23
+ };
24
+ if (ariaLabel)
25
+ attributes['aria-label'] = ariaLabel;
26
+ if (ariaLabelledby)
27
+ attributes['aria-labelledby'] = ariaLabelledby;
28
+ if (invalid)
29
+ attributes['aria-invalid'] = 'true';
30
+ if (placeholder)
31
+ attributes['data-placeholder'] = placeholder;
32
+ if (id)
33
+ attributes.id = id;
34
+ // ProseMirror calls this on every view update, so props that change later still apply.
35
+ const attributesRef = useRef(attributes);
36
+ attributesRef.current = attributes;
37
+ const editor = useEditor({
38
+ extensions: [StarterKit.configure({ link: false })],
39
+ content: value ?? '',
40
+ editable: !disabled,
41
+ immediatelyRender: false,
42
+ editorProps: { attributes: () => attributesRef.current },
43
+ onUpdate: ({ editor: e }) => onChange?.(e.getHTML()),
44
+ });
45
+ useImperativeHandle(ref, () => ({ editor, focus: () => editor?.commands.focus() }), [editor]);
46
+ useEffect(() => {
47
+ editor?.setEditable(!disabled);
48
+ }, [editor, disabled]);
49
+ // Only push an outside value in when it differs, or every keystroke would reset the caret.
50
+ useEffect(() => {
51
+ if (editor && value !== undefined && value !== editor.getHTML()) {
52
+ editor.commands.setContent(value, { emitUpdate: false });
53
+ }
54
+ }, [editor, value]);
55
+ const state = useEditorState({
56
+ editor,
57
+ selector: ({ editor: e }) => {
58
+ if (!e)
59
+ return { marks: [], block: '', empty: true };
60
+ return {
61
+ marks: MARKS.filter((m) => e.isActive(m)),
62
+ block: e.isActive('heading', { level: 2 })
63
+ ? 'h2'
64
+ : e.isActive('bulletList')
65
+ ? 'bullet'
66
+ : e.isActive('orderedList')
67
+ ? 'ordered'
68
+ : '',
69
+ empty: e.isEmpty,
70
+ };
71
+ },
72
+ }) ?? { marks: [], block: '', empty: true };
73
+ const setMarks = (next) => {
74
+ if (!editor)
75
+ return;
76
+ const chain = editor.chain().focus();
77
+ for (const mark of MARKS) {
78
+ if (next.includes(mark) !== state.marks.includes(mark))
79
+ chain.toggleMark(mark);
80
+ }
81
+ chain.run();
82
+ };
83
+ const setBlock = (next) => {
84
+ if (!editor)
85
+ return;
86
+ // Radix reports '' when the active item is pressed again: toggle that one off.
87
+ const target = (next || state.block);
88
+ const chain = editor.chain().focus();
89
+ if (target === 'h2')
90
+ chain.toggleHeading({ level: 2 });
91
+ else if (target === 'bullet')
92
+ chain.toggleBulletList();
93
+ else if (target === 'ordered')
94
+ chain.toggleOrderedList();
95
+ chain.run();
96
+ };
97
+ return (_jsxs("div", { className: cn('ui-richtext', className), "data-disabled": disabled || undefined, "data-invalid": invalid || undefined, "data-empty": state.empty || undefined, style: { ['--ui-richtext-rows']: rows }, children: [_jsxs(Toolbar.Root, { className: "ui-richtext-toolbar", "aria-label": strings.formatting, children: [_jsxs(Toolbar.ToggleGroup, { type: "multiple", className: "ui-richtext-group", value: state.marks, onValueChange: setMarks, disabled: disabled, children: [_jsx(Toolbar.ToggleItem, { className: "ui-richtext-item", value: "bold", "aria-label": strings.bold, children: _jsx("b", { "aria-hidden": true, children: "B" }) }), _jsx(Toolbar.ToggleItem, { className: "ui-richtext-item", value: "italic", "aria-label": strings.italic, children: _jsx("i", { "aria-hidden": true, children: "I" }) }), _jsx(Toolbar.ToggleItem, { className: "ui-richtext-item", value: "strike", "aria-label": strings.strike, children: _jsx("s", { "aria-hidden": true, children: "S" }) })] }), _jsx(Toolbar.Separator, { className: "ui-richtext-separator" }), _jsxs(Toolbar.ToggleGroup, { type: "single", className: "ui-richtext-group", value: state.block, onValueChange: setBlock, disabled: disabled, children: [_jsx(Toolbar.ToggleItem, { className: "ui-richtext-item", value: "h2", "aria-label": strings.heading, children: _jsx("span", { "aria-hidden": true, children: "H" }) }), _jsx(Toolbar.ToggleItem, { className: "ui-richtext-item", value: "bullet", "aria-label": strings.bulletList, children: _jsx("span", { "aria-hidden": true, children: "\u2022" }) }), _jsx(Toolbar.ToggleItem, { className: "ui-richtext-item", value: "ordered", "aria-label": strings.orderedList, children: _jsx("span", { "aria-hidden": true, children: "1." }) })] })] }), _jsx(EditorContent, { editor: editor, className: "ui-richtext-body" })] }));
98
+ });
@@ -0,0 +1 @@
1
+ export * from './RichText.js';
@@ -0,0 +1 @@
1
+ export * from './RichText.js';
@@ -1,10 +1,24 @@
1
1
  import { Slider as ThemedSlider } from '@radix-ui/themes';
2
2
  import { type ComponentPropsWithoutRef } from 'react';
3
- export interface SliderProps extends Omit<ComponentPropsWithoutRef<typeof ThemedSlider>, 'size' | 'aria-label'> {
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'> {
4
9
  /**
5
10
  * Required: a slider has no visible text of its own, so without this a screen reader
6
11
  * announces a bare number with nothing to say what it measures.
7
12
  */
8
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[];
9
22
  }
10
23
  export declare const Slider: import("react").ForwardRefExoticComponent<SliderProps & import("react").RefAttributes<HTMLSpanElement>>;
24
+ export {};
@@ -1,7 +1,74 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as SliderPrimitive from '@radix-ui/react-slider';
2
3
  import { Slider as ThemedSlider } from '@radix-ui/themes';
3
- import { forwardRef } from 'react';
4
+ import { forwardRef, useState } from 'react';
4
5
  import { cn } from '../../utils/cn.js';
5
- export const Slider = forwardRef(function Slider({ label, className, ...props }, ref) {
6
- return (_jsx(ThemedSlider, { ref: ref, size: "2", "aria-label": label, className: cn(className), ...props }));
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] }));
7
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
+ }
@@ -10,6 +10,7 @@ export * from './Input/index.js';
10
10
  export * from './Label/index.js';
11
11
  export * from './Popover/index.js';
12
12
  export * from './Progress/index.js';
13
+ export * from './RichText/index.js';
13
14
  export * from './Select/index.js';
14
15
  export * from './Separator/index.js';
15
16
  export * from './Slider/index.js';
@@ -10,6 +10,7 @@ export * from './Input/index.js';
10
10
  export * from './Label/index.js';
11
11
  export * from './Popover/index.js';
12
12
  export * from './Progress/index.js';
13
+ export * from './RichText/index.js';
13
14
  export * from './Select/index.js';
14
15
  export * from './Separator/index.js';
15
16
  export * from './Slider/index.js';
package/dist/strings.d.ts CHANGED
@@ -20,6 +20,14 @@ export interface UiStrings {
20
20
  select: string;
21
21
  /** The accessible name of a toast's close button. */
22
22
  dismiss: string;
23
+ /** RichText's toolbar and the names of its buttons. */
24
+ formatting: string;
25
+ bold: string;
26
+ italic: string;
27
+ strike: string;
28
+ heading: string;
29
+ bulletList: string;
30
+ orderedList: string;
23
31
  }
24
32
  /**
25
33
  * Feeds the atoms their strings, once, near the root.
package/dist/strings.js CHANGED
@@ -4,6 +4,13 @@ const DEFAULTS = {
4
4
  loading: 'Loading',
5
5
  select: 'Choose…',
6
6
  dismiss: 'Dismiss',
7
+ formatting: 'Formatting',
8
+ bold: 'Bold',
9
+ italic: 'Italic',
10
+ strike: 'Strikethrough',
11
+ heading: 'Heading',
12
+ bulletList: 'Bulleted list',
13
+ orderedList: 'Numbered list',
7
14
  };
8
15
  const StringsContext = createContext(DEFAULTS);
9
16
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,18 +35,27 @@
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",
40
+ "@radix-ui/react-toolbar": "^1.1.20",
41
+ "@tiptap/pm": "^3.31.4",
42
+ "@tiptap/react": "^3.31.4",
43
+ "@tiptap/starter-kit": "^3.31.4",
37
44
  "clsx": "^2.1.1",
38
45
  "tailwind-merge": "^3.7.0"
39
46
  },
40
47
  "devDependencies": {
41
48
  "@biomejs/biome": "^2.5.13",
42
49
  "@radix-ui/themes": "^3.3.0",
50
+ "@testing-library/react": "^16.3.3",
51
+ "@testing-library/user-event": "^14.6.7",
43
52
  "@types/react": "^19.2.18",
44
53
  "@types/react-dom": "^19.2.7",
54
+ "jsdom": "^30.1.2",
45
55
  "react": "^19.2.8",
46
56
  "react-dom": "^19.2.8",
47
- "typescript": "~6.0.2"
57
+ "typescript": "~6.0.2",
58
+ "vitest": "^5.0.3"
48
59
  },
49
60
  "publishConfig": {
50
61
  "access": "public"
package/tokens.css CHANGED
@@ -261,3 +261,197 @@
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); }
377
+
378
+ /*
379
+ * RichText: Tiptap under a Radix Toolbar, dressed as a Themes TextArea (variant surface,
380
+ * size 2) from public tokens only, for the same reason as the slider above.
381
+ */
382
+ .ui-richtext {
383
+ display: flex;
384
+ flex-direction: column;
385
+ width: 100%;
386
+ box-sizing: border-box;
387
+ border-radius: var(--radius-2);
388
+ background-color: var(--color-surface);
389
+ box-shadow: inset 0 0 0 1px var(--gray-a7);
390
+ color: var(--gray-12);
391
+ font-family: var(--default-font-family);
392
+ font-size: var(--font-size-2);
393
+ line-height: var(--line-height-2);
394
+ letter-spacing: var(--letter-spacing-2);
395
+ overflow: hidden;
396
+ }
397
+ .ui-richtext:focus-within { outline: 2px solid var(--focus-8); outline-offset: -1px; }
398
+ .ui-richtext[data-invalid] { box-shadow: inset 0 0 0 1px var(--red-a8); }
399
+ .ui-richtext[data-invalid]:focus-within { outline-color: var(--red-8); }
400
+ .ui-richtext[data-disabled] {
401
+ background-color: var(--gray-a2);
402
+ box-shadow: inset 0 0 0 1px var(--gray-a6);
403
+ color: var(--gray-a11);
404
+ cursor: var(--cursor-disabled);
405
+ }
406
+
407
+ .ui-richtext-toolbar {
408
+ display: flex;
409
+ align-items: center;
410
+ gap: var(--space-1);
411
+ padding: var(--space-1);
412
+ border-bottom: 1px solid var(--gray-a5);
413
+ }
414
+ .ui-richtext-group { display: flex; gap: 2px; }
415
+ .ui-richtext-separator { width: 1px; align-self: stretch; margin: 2px var(--space-1); background-color: var(--gray-a5); }
416
+ .ui-richtext-item {
417
+ all: unset;
418
+ box-sizing: border-box;
419
+ display: inline-flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ min-width: var(--space-6);
423
+ height: var(--space-6);
424
+ padding: 0 var(--space-1);
425
+ border-radius: var(--radius-1);
426
+ color: var(--gray-11);
427
+ font-size: var(--font-size-2);
428
+ }
429
+ .ui-richtext-item:hover:not([data-disabled]) { background-color: var(--gray-a3); }
430
+ .ui-richtext-item[data-state="on"] { background-color: var(--accent-a4); color: var(--accent-a11); }
431
+ .ui-richtext-item:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -1px; }
432
+ .ui-richtext-item[data-disabled] { color: var(--gray-a8); cursor: var(--cursor-disabled); }
433
+
434
+ .ui-richtext-body { position: relative; }
435
+ .ui-richtext-content {
436
+ min-height: calc(var(--ui-richtext-rows, 3) * var(--line-height-2));
437
+ padding: calc(var(--space-2) - 1px) calc(var(--space-3) - 1px);
438
+ outline: none;
439
+ overflow-wrap: break-word;
440
+ }
441
+ .ui-richtext-content > :first-child { margin-top: 0; }
442
+ .ui-richtext-content > :last-child { margin-bottom: 0; }
443
+ .ui-richtext-content p { margin: 0 0 var(--space-2); }
444
+ .ui-richtext-content h2 {
445
+ margin: var(--space-3) 0 var(--space-2);
446
+ font-size: var(--font-size-4);
447
+ line-height: var(--line-height-4);
448
+ font-weight: var(--font-weight-bold);
449
+ }
450
+ .ui-richtext-content ul,
451
+ .ui-richtext-content ol { margin: 0 0 var(--space-2); padding-left: var(--space-5); }
452
+ .ui-richtext[data-empty] .ui-richtext-content::before {
453
+ content: attr(data-placeholder);
454
+ position: absolute;
455
+ pointer-events: none;
456
+ color: var(--gray-a10);
457
+ }