@kennycorrea/ui 0.5.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
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
+
3
19
  ## 0.5.0 — 2026-10-06
4
20
 
5
21
  ### 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';
@@ -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.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",
@@ -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
+ }