@kennycorrea/ui 0.5.0 → 0.6.1

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 — 2026-10-08
4
+
5
+ ### Fixed
6
+ - **`Slider` thumbs always have an accessible name.** Without `fillFrom`, `label` landed on the
7
+ Themes root, which has no role, and the `role="slider"` thumb had none (axe:
8
+ aria-input-field-name). The thumb is now named `label`; a range's thumbs are
9
+ `"<label>, minimum"` / `"<label>, maximum"` (`"<label>, n of m"` beyond two) instead of
10
+ Radix's bare "Minimum"/"Maximum". Consumers using `fillFrom={min}` only to get a name can
11
+ drop it.
12
+
13
+ ## 0.6.0 — 2026-10-07
14
+
15
+ ### Added
16
+ - **`RichText`**: a Tiptap editor (StarterKit) under a Radix `Toolbar`. Marks (bold, italic,
17
+ strikethrough) are a multiple `ToggleGroup`, blocks (heading, bulleted, numbered list) a single
18
+ one, so the toolbar is one tab stop with arrow keys. `value`/`onChange` speak HTML; also
19
+ `placeholder`, `disabled`, `invalid`, `rows`, `aria-label`/`aria-labelledby`, and a ref
20
+ exposing `{ editor, focus }`.
21
+ Styled as a Themes `TextArea` (surface, size 2) from public tokens — border, focus ring,
22
+ background, invalid and disabled states — in `.ui-richtext*` in `tokens.css`.
23
+ - `UiStrings` gains `formatting`, `bold`, `italic`, `strike`, `heading`, `bulletList`,
24
+ `orderedList` (English defaults; hosts should translate them).
25
+
26
+ ### Requires
27
+ - `tokens.css` imported; the editor is unstyled without it.
28
+
3
29
  ## 0.5.0 — 2026-10-06
4
30
 
5
31
  ### Added
@@ -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,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as SliderPrimitive from '@radix-ui/react-slider';
3
3
  import { Slider as ThemedSlider } from '@radix-ui/themes';
4
- import { forwardRef, useState } from 'react';
4
+ import { forwardRef, useCallback, useLayoutEffect, useRef, useState, } from 'react';
5
5
  import { cn } from '../../utils/cn.js';
6
6
  import { fillEdges, fillStyle, percentOf, thumbCentre } from './geometry.js';
7
7
  const MARGIN_KEYS = ['m', 'mx', 'my', 'mt', 'mr', 'mb', 'ml'];
@@ -30,14 +30,54 @@ export const Slider = forwardRef(function Slider(props, ref) {
30
30
  setInner(next);
31
31
  onValueChange?.(next);
32
32
  };
33
+ // Themes spreads every prop onto the Root, which has no role, and gives its Thumbs no way in —
34
+ // so the role="slider" elements are named here, after Radix has rendered them.
35
+ const themedRoot = useRef(null);
36
+ const setThemedRoot = useCallback((node) => {
37
+ themedRoot.current = node;
38
+ assignRef(ref, node);
39
+ }, [ref]);
40
+ // Radix labels a range's thumbs "Minimum"/"Maximum" in a later render of its own, so a
41
+ // one-off write is overwritten; the observer puts ours back whenever anything touches them.
42
+ useLayoutEffect(() => {
43
+ const root = themedRoot.current;
44
+ if (!root)
45
+ return;
46
+ const name = () => {
47
+ const thumbs = root.querySelectorAll('[role="slider"]');
48
+ thumbs.forEach((thumb, i) => {
49
+ const wanted = thumbLabel(label, i, thumbs.length);
50
+ if (thumb.getAttribute('aria-label') !== wanted)
51
+ thumb.setAttribute('aria-label', wanted);
52
+ });
53
+ };
54
+ name();
55
+ const observer = new MutationObserver(name);
56
+ observer.observe(root, { subtree: true, childList: true, attributeFilter: ['aria-label'] });
57
+ return () => observer.disconnect();
58
+ }, [label]);
33
59
  const unsupported = fillFrom === undefined ? null : fillFromUnsupported(props, values.length);
34
60
  if (unsupported && isDev)
35
61
  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 }));
62
+ 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: setThemedRoot, size: "2", "aria-label": label, className: cn(className), ...rest, onValueChange: handleChange }));
37
63
  if (!marks?.length)
38
64
  return slider;
39
65
  return (_jsxs("div", { className: "ui-slider-field", children: [_jsx(SliderMarks, { marks: marks, min: min, max: max }), slider] }));
40
66
  });
67
+ /** One thumb takes the label; a range says which end each thumb is. */
68
+ function thumbLabel(label, index, count) {
69
+ if (count === 1)
70
+ return label;
71
+ if (count === 2)
72
+ return `${label}, ${index === 0 ? 'minimum' : 'maximum'}`;
73
+ return `${label}, ${index + 1} of ${count}`;
74
+ }
75
+ function assignRef(ref, value) {
76
+ if (typeof ref === 'function')
77
+ ref(value);
78
+ else if (ref)
79
+ ref.current = value;
80
+ }
41
81
  /**
42
82
  * Radix Themes draws its range itself and offers no way to place it, so this path is built on the
43
83
  * primitives and styled in `tokens.css` (`.ui-slider*`) from Themes' public tokens — never by
@@ -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.5.0",
3
+ "version": "0.6.1",
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",
@@ -37,6 +37,10 @@
37
37
  "dependencies": {
38
38
  "@radix-ui/react-slider": "^1.5.0",
39
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",
40
44
  "clsx": "^2.1.1",
41
45
  "tailwind-merge": "^3.7.0"
42
46
  },
package/tokens.css CHANGED
@@ -374,3 +374,84 @@
374
374
  .ui-slider-thumb[data-disabled],
375
375
  .ui-slider-thumb[data-disabled]::after { cursor: var(--cursor-disabled); }
376
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
+ }