@kinetixui/ui 0.22.0 → 0.23.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/dist/index.d.ts CHANGED
@@ -413,6 +413,11 @@ declare const tagVariants: (props?: ({
413
413
  interface TagProps extends React$1.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof tagVariants> {
414
414
  /** show a dismiss button; called when it's clicked */
415
415
  onRemove?: () => void;
416
+ /** Accessible name for the dismiss button. Defaults to `Remove <tag text>`
417
+ * when the tag's children are a plain string, so a row of tags does not
418
+ * become a row of identical "Remove" buttons. Pass this when the children
419
+ * are not a string, or when the text is not the right name. */
420
+ removeLabel?: string;
416
421
  /** render as the single child element (Radix Slot) instead of <span> —
417
422
  * e.g. `<Tag asChild><a href="/filter">Active</a></Tag>` for a linked tag.
418
423
  * Combined with `onRemove`, the remove button nests inside the slotted
@@ -1485,7 +1490,14 @@ interface TreeViewProps extends React$1.HTMLAttributes<HTMLDivElement> {
1485
1490
  expanded?: string[];
1486
1491
  defaultExpanded?: string[];
1487
1492
  onExpandedChange?: (expanded: string[]) => void;
1493
+ /** the selected node's value. Controlled: pass `onSelectedChange` with it. */
1488
1494
  selected?: string;
1495
+ /**
1496
+ * the node selected before the caller takes over. Without this the tree could not show a selection at all
1497
+ * unless the caller stored one — `select` only called back, so an uncontrolled tree stayed permanently
1498
+ * unselected. Expansion and checking already worked both ways; selection did not.
1499
+ */
1500
+ defaultSelected?: string;
1489
1501
  onSelectedChange?: (value: string) => void;
1490
1502
  checkedValues?: string[];
1491
1503
  defaultCheckedValues?: string[];
@@ -1675,7 +1687,14 @@ interface JsonViewerProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "
1675
1687
  data: unknown;
1676
1688
  /** tree depth (root = 0) to auto-expand; deeper nodes start collapsed. Default 1. */
1677
1689
  expandDepth?: number;
1678
- hideCopy?: boolean;
1690
+ /**
1691
+ * show the copy-to-clipboard button. Defaults to true.
1692
+ *
1693
+ * Replaces `hideCopy`, which was a negative boolean: `hideCopy={false}` read as a double negative, and the
1694
+ * prop's default behaviour could not be stated without inverting it in your head. Corrected while the
1695
+ * component is lifecycle-beta rather than carried to 1.0.
1696
+ */
1697
+ copyable?: boolean;
1679
1698
  }
1680
1699
  /**
1681
1700
  * JsonViewer — a collapsible, syntax-colored tree for arbitrary JSON data
@@ -1721,10 +1740,13 @@ declare global {
1721
1740
  };
1722
1741
  }
1723
1742
  }
1724
- interface ColorPickerProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
1725
- /** hex color, e.g. `#3b82f6` (or `#3b82f6ff` when `alpha` is on) */
1726
- value: string;
1727
- onChange: (hex: string) => void;
1743
+ interface ColorPickerProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children" | "onChange" | "defaultValue"> {
1744
+ /** hex color, e.g. `#3b82f6` (or `#3b82f6ff` when `alpha` is on). Controlled: pass `onValueChange` with it. */
1745
+ value?: string;
1746
+ /** starting hex color when the picker manages its own value. Ignored once `value` is passed. */
1747
+ defaultValue?: string;
1748
+ /** called with the new hex on every change — the name every other KinetixUI value control uses */
1749
+ onValueChange?: (hex: string) => void;
1728
1750
  /** show an alpha slider and read/emit 8-digit hex */
1729
1751
  alpha?: boolean;
1730
1752
  /** preset swatches row */
@@ -1798,9 +1820,13 @@ interface KanbanBoardProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>,
1798
1820
  */
1799
1821
  declare const KanbanBoard: React$1.ForwardRefExoticComponent<KanbanBoardProps & React$1.RefAttributes<HTMLDivElement>>;
1800
1822
 
1801
- interface MarkdownEditorProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
1802
- value: string;
1803
- onChange: (value: string) => void;
1823
+ interface MarkdownEditorProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children" | "onChange" | "defaultValue"> {
1824
+ /** the markdown source. Controlled: pass `onValueChange` with it. */
1825
+ value?: string;
1826
+ /** starting markdown when the editor manages its own buffer. Ignored once `value` is passed. */
1827
+ defaultValue?: string;
1828
+ /** called with the full markdown after every edit — the name every other KinetixUI value control uses */
1829
+ onValueChange?: (value: string) => void;
1804
1830
  placeholder?: string;
1805
1831
  rows?: number;
1806
1832
  }
package/dist/index.js CHANGED
@@ -1212,14 +1212,15 @@ var tagVariants = cva8(
1212
1212
  }
1213
1213
  );
1214
1214
  var Tag = React26.forwardRef(
1215
- ({ className, variant, onRemove, children, asChild = false, ...props }, ref) => {
1215
+ ({ className, variant, onRemove, removeLabel, children, asChild = false, ...props }, ref) => {
1216
1216
  const Comp = asChild ? Slot4 : "span";
1217
+ const name = removeLabel ?? (typeof children === "string" ? `Remove ${children}` : "Remove");
1217
1218
  const removeButton = onRemove && /* @__PURE__ */ jsx30(
1218
1219
  "button",
1219
1220
  {
1220
1221
  type: "button",
1221
1222
  onClick: onRemove,
1222
- "aria-label": "Remove",
1223
+ "aria-label": name,
1223
1224
  className: "-mr-0.5 ml-0.5 rounded-[2px] opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:ring-1 focus-visible:ring-current",
1224
1225
  children: /* @__PURE__ */ jsx30(X4, { className: "size-3.5" })
1225
1226
  }
@@ -4475,6 +4476,7 @@ var TreeView = React78.forwardRef(
4475
4476
  defaultExpanded = [],
4476
4477
  onExpandedChange,
4477
4478
  selected,
4479
+ defaultSelected,
4478
4480
  onSelectedChange,
4479
4481
  checkedValues,
4480
4482
  defaultCheckedValues = [],
@@ -4486,6 +4488,8 @@ var TreeView = React78.forwardRef(
4486
4488
  const currentExpanded = expanded ?? internalExpanded;
4487
4489
  const [internalChecked, setInternalChecked] = React78.useState(defaultCheckedValues);
4488
4490
  const currentChecked = checkedValues ?? internalChecked;
4491
+ const [internalSelected, setInternalSelected] = React78.useState(defaultSelected);
4492
+ const currentSelected = selected ?? internalSelected;
4489
4493
  const [tabbableValue, setTabbableValue] = React78.useState(null);
4490
4494
  const firstValueRef = React78.useRef(null);
4491
4495
  const toggleExpanded = React78.useCallback(
@@ -4507,8 +4511,11 @@ var TreeView = React78.forwardRef(
4507
4511
  const contextValue = {
4508
4512
  isExpanded: (value) => currentExpanded.includes(value),
4509
4513
  toggleExpanded,
4510
- selected,
4511
- select: (value) => onSelectedChange?.(value),
4514
+ selected: currentSelected,
4515
+ select: (value) => {
4516
+ setInternalSelected(value);
4517
+ onSelectedChange?.(value);
4518
+ },
4512
4519
  checkable,
4513
4520
  isChecked: (value) => currentChecked.includes(value),
4514
4521
  toggleChecked,
@@ -4534,6 +4541,7 @@ var TreeItem = React78.forwardRef(
4534
4541
  ({ className, value, label, icon, disabled, children, onClick, onKeyDown, ...props }, ref) => {
4535
4542
  const ctx = useTreeViewContext();
4536
4543
  const level = React78.useContext(TreeLevelContext);
4544
+ const labelId = React78.useId();
4537
4545
  const hasChildren = React78.Children.count(children) > 0;
4538
4546
  const expanded = ctx.isExpanded(value);
4539
4547
  const isChecked = ctx.isChecked(value);
@@ -4597,6 +4605,7 @@ var TreeItem = React78.forwardRef(
4597
4605
  {
4598
4606
  ref: setRefs,
4599
4607
  role: "treeitem",
4608
+ "aria-labelledby": labelId,
4600
4609
  "aria-expanded": hasChildren ? expanded : void 0,
4601
4610
  "aria-selected": ctx.selected === value,
4602
4611
  "aria-disabled": disabled || void 0,
@@ -4642,7 +4651,7 @@ var TreeItem = React78.forwardRef(
4642
4651
  }
4643
4652
  ),
4644
4653
  icon,
4645
- /* @__PURE__ */ jsx84("span", { className: "truncate", children: label })
4654
+ /* @__PURE__ */ jsx84("span", { id: labelId, className: "truncate", children: label })
4646
4655
  ]
4647
4656
  }
4648
4657
  ),
@@ -5638,7 +5647,7 @@ import * as React84 from "react";
5638
5647
  import { Check as Check10, ChevronRight as ChevronRight8, Copy as Copy2 } from "lucide-react";
5639
5648
  import { Fragment as Fragment4, jsx as jsx90, jsxs as jsxs54 } from "react/jsx-runtime";
5640
5649
  var JsonViewer = React84.forwardRef(
5641
- ({ data, expandDepth = 1, hideCopy, className, ...props }, ref) => {
5650
+ ({ data, expandDepth = 1, copyable = true, className, ...props }, ref) => {
5642
5651
  const [copied, setCopied] = React84.useState(false);
5643
5652
  const copy = async () => {
5644
5653
  try {
@@ -5655,7 +5664,7 @@ var JsonViewer = React84.forwardRef(
5655
5664
  className: cn("relative overflow-auto rounded-md border bg-muted/30 p-3 font-mono text-xs", className),
5656
5665
  ...props,
5657
5666
  children: [
5658
- !hideCopy && /* @__PURE__ */ jsx90(
5667
+ copyable && /* @__PURE__ */ jsx90(
5659
5668
  "button",
5660
5669
  {
5661
5670
  type: "button",
@@ -5925,16 +5934,17 @@ function rgbToHsv(r, g, b) {
5925
5934
  var HEX_PATTERN_ALPHA = /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
5926
5935
  var HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
5927
5936
  var ColorPicker = React86.forwardRef(
5928
- ({ value, onChange, alpha = false, swatches, className, ...props }, ref) => {
5937
+ ({ value, defaultValue = "#3b82f6", onValueChange, alpha = false, swatches, className, ...props }, ref) => {
5938
+ const current = value ?? defaultValue;
5929
5939
  const [hsv, setHsv] = React86.useState(() => {
5930
- const [r, g, b] = hexToRgba(value);
5940
+ const [r, g, b] = hexToRgba(current);
5931
5941
  return rgbToHsv(r, g, b);
5932
5942
  });
5933
- const [a, setA] = React86.useState(() => hexToRgba(value)[3]);
5934
- const lastEmitted = React86.useRef(value);
5943
+ const [a, setA] = React86.useState(() => hexToRgba(current)[3]);
5944
+ const lastEmitted = React86.useRef(current);
5935
5945
  const [h, s, v] = hsv;
5936
5946
  React86.useEffect(() => {
5937
- if (value === lastEmitted.current) return;
5947
+ if (value === void 0 || value === lastEmitted.current) return;
5938
5948
  const [r, g, b, na] = hexToRgba(value);
5939
5949
  setHsv(rgbToHsv(r, g, b));
5940
5950
  setA(na);
@@ -5947,9 +5957,21 @@ var ColorPicker = React86.forwardRef(
5947
5957
  const [r, g, b] = hsvToRgb(nh, ns, nv);
5948
5958
  const hex = rgbaToHex(r, g, b, na, alpha);
5949
5959
  lastEmitted.current = hex;
5950
- onChange(hex);
5960
+ onValueChange?.(hex);
5961
+ },
5962
+ [alpha, onValueChange]
5963
+ );
5964
+ const currentHex = React86.useMemo(() => {
5965
+ const [r, g, b] = hsvToRgb(h, s, v);
5966
+ return rgbaToHex(r, g, b, a, alpha);
5967
+ }, [h, s, v, a, alpha]);
5968
+ const commitHex = React86.useCallback(
5969
+ (hex) => {
5970
+ const [r, g, b, na] = hexToRgba(hex);
5971
+ const [nh, ns, nv] = rgbToHsv(r, g, b);
5972
+ commit(nh, ns, nv, na);
5951
5973
  },
5952
- [alpha, onChange]
5974
+ [commit]
5953
5975
  );
5954
5976
  const squareRef = React86.useRef(null);
5955
5977
  const draggingRef = React86.useRef(false);
@@ -6089,12 +6111,12 @@ var ColorPicker = React86.forwardRef(
6089
6111
  backgroundImage: "conic-gradient(#0000001a 90deg, transparent 90deg 180deg, #0000001a 180deg 270deg, transparent 270deg), linear-gradient(hsl(0 0% 0% / 0), hsl(0 0% 0% / 0))",
6090
6112
  backgroundSize: "8px 8px"
6091
6113
  },
6092
- children: /* @__PURE__ */ jsx92("div", { className: "size-full rounded-md", style: { backgroundColor: value } })
6114
+ children: /* @__PURE__ */ jsx92("div", { className: "size-full rounded-md", style: { backgroundColor: currentHex } })
6093
6115
  }
6094
6116
  ),
6095
6117
  /* @__PURE__ */ jsxs56("div", { className: "flex flex-1 items-center gap-1", children: [
6096
6118
  /* @__PURE__ */ jsx92("span", { className: "text-muted-foreground", children: "#" }),
6097
- /* @__PURE__ */ jsx92(HexField, { value, alpha, onCommit: onChange })
6119
+ /* @__PURE__ */ jsx92(HexField, { value: currentHex, alpha, onCommit: (hex) => commitHex(hex) })
6098
6120
  ] }),
6099
6121
  supportsEyeDropper && /* @__PURE__ */ jsx92(
6100
6122
  Button,
@@ -6427,10 +6449,19 @@ function renderMarkdown(source) {
6427
6449
  return out.join("\n");
6428
6450
  }
6429
6451
  var MarkdownEditor = React88.forwardRef(
6430
- ({ value, onChange, placeholder, rows = 10, className, "aria-label": ariaLabel = "Markdown", ...props }, ref) => {
6452
+ ({ value, defaultValue = "", onValueChange, placeholder, rows = 10, className, "aria-label": ariaLabel = "Markdown", ...props }, ref) => {
6431
6453
  const textareaRef = React88.useRef(null);
6432
6454
  const [showPreview, setShowPreview] = React88.useState(false);
6433
6455
  const pendingSelection = React88.useRef(null);
6456
+ const [internal, setInternal] = React88.useState(defaultValue);
6457
+ const text = value ?? internal;
6458
+ const setValue = React88.useCallback(
6459
+ (next) => {
6460
+ setInternal(next);
6461
+ onValueChange?.(next);
6462
+ },
6463
+ [onValueChange]
6464
+ );
6434
6465
  React88.useEffect(() => {
6435
6466
  if (pendingSelection.current && textareaRef.current) {
6436
6467
  const { start, end } = pendingSelection.current;
@@ -6438,20 +6469,20 @@ var MarkdownEditor = React88.forwardRef(
6438
6469
  textareaRef.current.setSelectionRange(start, end);
6439
6470
  pendingSelection.current = null;
6440
6471
  }
6441
- }, [value]);
6472
+ }, [text]);
6442
6473
  const applyInline = (before, after, placeholder2) => {
6443
6474
  const el = textareaRef.current;
6444
6475
  if (!el) return;
6445
- const result = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after, placeholder2);
6476
+ const result = wrapSelection(text, el.selectionStart, el.selectionEnd, before, after, placeholder2);
6446
6477
  pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
6447
- onChange(result.text);
6478
+ setValue(result.text);
6448
6479
  };
6449
6480
  const applyLinePrefix = (prefix) => {
6450
6481
  const el = textareaRef.current;
6451
6482
  if (!el) return;
6452
- const result = prefixLines(value, el.selectionStart, el.selectionEnd, prefix);
6483
+ const result = prefixLines(text, el.selectionStart, el.selectionEnd, prefix);
6453
6484
  pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
6454
- onChange(result.text);
6485
+ setValue(result.text);
6455
6486
  };
6456
6487
  return /* @__PURE__ */ jsxs58("div", { ref, className: cn("overflow-hidden rounded-md border font-sans", className), ...props, children: [
6457
6488
  /* @__PURE__ */ jsxs58("div", { role: "toolbar", "aria-label": "Formatting", className: "flex items-center gap-0.5 border-b bg-muted/40 p-1", children: [
@@ -6479,8 +6510,8 @@ var MarkdownEditor = React88.forwardRef(
6479
6510
  Textarea,
6480
6511
  {
6481
6512
  ref: textareaRef,
6482
- value,
6483
- onChange: (e) => onChange(e.target.value),
6513
+ value: text,
6514
+ onChange: (e) => setValue(e.target.value),
6484
6515
  placeholder,
6485
6516
  rows,
6486
6517
  "aria-label": ariaLabel,
@@ -6491,7 +6522,7 @@ var MarkdownEditor = React88.forwardRef(
6491
6522
  "div",
6492
6523
  {
6493
6524
  className: "overflow-auto p-3 text-sm [&_a]:text-link [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-2 [&_pre]:overflow-auto [&_pre]:rounded-sm [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:list-disc",
6494
- dangerouslySetInnerHTML: { __html: renderMarkdown(value) }
6525
+ dangerouslySetInnerHTML: { __html: renderMarkdown(text) }
6495
6526
  }
6496
6527
  )
6497
6528
  ] })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinetixui/ui",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "description": "KinetixUI React components — CVA + Radix + Tailwind on the KinetixUI token contract. Also distributed via the kinetixui CLI as a component registry.",
6
6
  "keywords": [
@@ -44,7 +44,7 @@
44
44
  "@dnd-kit/sortable": "^10.0.0",
45
45
  "@dnd-kit/utilities": "^3.2.2",
46
46
  "@hookform/resolvers": "^5.9.1",
47
- "@kinetixui/tokens": "0.22.0",
47
+ "@kinetixui/tokens": "0.23.0",
48
48
  "@radix-ui/react-accordion": "^1.2.20",
49
49
  "@radix-ui/react-alert-dialog": "^1.1.23",
50
50
  "@radix-ui/react-aspect-ratio": "^1.1.15",
@@ -79,7 +79,7 @@
79
79
  "date-fns": "^4.4.0",
80
80
  "embla-carousel-react": "^8.6.0",
81
81
  "input-otp": "^1.5.0",
82
- "lucide-react": "^1.46.0",
82
+ "lucide-react": "^1.47.0",
83
83
  "react-day-picker": "^10.0.1",
84
84
  "react-hook-form": "^7.88.0",
85
85
  "react-resizable-panels": "^4.12.4",
@@ -105,7 +105,7 @@
105
105
  "axe-core": "4.13.0",
106
106
  "eslint": "^9.39.5",
107
107
  "eslint-plugin-react-hooks": "^5.2.0",
108
- "jsdom": "^30.0.1",
108
+ "jsdom": "^30.1.0",
109
109
  "react": "^19.3.0",
110
110
  "react-dom": "^19.3.0",
111
111
  "tailwindcss": "^3.4.19",
@@ -79,10 +79,13 @@ function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
79
79
  const HEX_PATTERN_ALPHA = /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
80
80
  const HEX_PATTERN = /^[0-9a-fA-F]{6}$/;
81
81
 
82
- export interface ColorPickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
83
- /** hex color, e.g. `#3b82f6` (or `#3b82f6ff` when `alpha` is on) */
84
- value: string;
85
- onChange: (hex: string) => void;
82
+ export interface ColorPickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange" | "defaultValue"> {
83
+ /** hex color, e.g. `#3b82f6` (or `#3b82f6ff` when `alpha` is on). Controlled: pass `onValueChange` with it. */
84
+ value?: string;
85
+ /** starting hex color when the picker manages its own value. Ignored once `value` is passed. */
86
+ defaultValue?: string;
87
+ /** called with the new hex on every change — the name every other KinetixUI value control uses */
88
+ onValueChange?: (hex: string) => void;
86
89
  /** show an alpha slider and read/emit 8-digit hex */
87
90
  alpha?: boolean;
88
91
  /** preset swatches row */
@@ -110,17 +113,25 @@ export interface ColorPickerProps extends Omit<React.HTMLAttributes<HTMLDivEleme
110
113
  * on some browsers) — no polyfill, it just disappears elsewhere.
111
114
  */
112
115
  const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
113
- ({ value, onChange, alpha = false, swatches, className, ...props }, ref) => {
116
+ ({ value, defaultValue = "#3b82f6", onValueChange, alpha = false, swatches, className, ...props }, ref) => {
117
+ /**
118
+ * The hex the picker is showing. `value` wins when it is passed; otherwise this is the source of truth.
119
+ *
120
+ * There is deliberately no second copy of the colour: `hex` below is what the HSV state renders to, and
121
+ * the uncontrolled value is read back out of that same HSV state rather than stored twice.
122
+ */
123
+ const current = value ?? defaultValue;
114
124
  const [hsv, setHsv] = React.useState<[number, number, number]>(() => {
115
- const [r, g, b] = hexToRgba(value);
125
+ const [r, g, b] = hexToRgba(current);
116
126
  return rgbToHsv(r, g, b);
117
127
  });
118
- const [a, setA] = React.useState(() => hexToRgba(value)[3]);
119
- const lastEmitted = React.useRef(value);
128
+ const [a, setA] = React.useState(() => hexToRgba(current)[3]);
129
+ const lastEmitted = React.useRef(current);
120
130
  const [h, s, v] = hsv;
121
131
 
122
132
  React.useEffect(() => {
123
- if (value === lastEmitted.current) return;
133
+ // Only a controlled `value` can move the picker from outside; an uncontrolled one is set once.
134
+ if (value === undefined || value === lastEmitted.current) return;
124
135
  const [r, g, b, na] = hexToRgba(value);
125
136
  setHsv(rgbToHsv(r, g, b));
126
137
  setA(na);
@@ -134,9 +145,28 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
134
145
  const [r, g, b] = hsvToRgb(nh, ns, nv);
135
146
  const hex = rgbaToHex(r, g, b, na, alpha);
136
147
  lastEmitted.current = hex;
137
- onChange(hex);
148
+ onValueChange?.(hex);
149
+ },
150
+ [alpha, onValueChange],
151
+ );
152
+
153
+ /**
154
+ * The hex the swatch preview and the hex field show — derived from the HSV state, not from the `value`
155
+ * prop, so an uncontrolled picker displays what the user just dragged rather than the initial colour.
156
+ */
157
+ const currentHex = React.useMemo(() => {
158
+ const [r, g, b] = hsvToRgb(h, s, v);
159
+ return rgbaToHex(r, g, b, a, alpha);
160
+ }, [h, s, v, a, alpha]);
161
+
162
+ /** The hex field hands back a full hex; route it through the same HSV path every other control uses. */
163
+ const commitHex = React.useCallback(
164
+ (hex: string) => {
165
+ const [r, g, b, na] = hexToRgba(hex);
166
+ const [nh, ns, nv] = rgbToHsv(r, g, b);
167
+ commit(nh, ns, nv, na);
138
168
  },
139
- [alpha, onChange],
169
+ [commit],
140
170
  );
141
171
 
142
172
  const squareRef = React.useRef<HTMLDivElement>(null);
@@ -270,11 +300,11 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
270
300
  backgroundSize: "8px 8px",
271
301
  }}
272
302
  >
273
- <div className="size-full rounded-md" style={{ backgroundColor: value }} />
303
+ <div className="size-full rounded-md" style={{ backgroundColor: currentHex }} />
274
304
  </div>
275
305
  <div className="flex flex-1 items-center gap-1">
276
306
  <span className="text-muted-foreground">#</span>
277
- <HexField value={value} alpha={alpha} onCommit={onChange} />
307
+ <HexField value={currentHex} alpha={alpha} onCommit={(hex) => commitHex(hex)} />
278
308
  </div>
279
309
  {supportsEyeDropper && (
280
310
  <Button
@@ -8,7 +8,14 @@ export interface JsonViewerProps extends Omit<React.HTMLAttributes<HTMLDivElemen
8
8
  data: unknown;
9
9
  /** tree depth (root = 0) to auto-expand; deeper nodes start collapsed. Default 1. */
10
10
  expandDepth?: number;
11
- hideCopy?: boolean;
11
+ /**
12
+ * show the copy-to-clipboard button. Defaults to true.
13
+ *
14
+ * Replaces `hideCopy`, which was a negative boolean: `hideCopy={false}` read as a double negative, and the
15
+ * prop's default behaviour could not be stated without inverting it in your head. Corrected while the
16
+ * component is lifecycle-beta rather than carried to 1.0.
17
+ */
18
+ copyable?: boolean;
12
19
  }
13
20
 
14
21
  /**
@@ -25,7 +32,7 @@ export interface JsonViewerProps extends Omit<React.HTMLAttributes<HTMLDivElemen
25
32
  * addition (not in the original Figma source).
26
33
  */
27
34
  const JsonViewer = React.forwardRef<HTMLDivElement, JsonViewerProps>(
28
- ({ data, expandDepth = 1, hideCopy, className, ...props }, ref) => {
35
+ ({ data, expandDepth = 1, copyable = true, className, ...props }, ref) => {
29
36
  const [copied, setCopied] = React.useState(false);
30
37
 
31
38
  const copy = async () => {
@@ -44,7 +51,7 @@ const JsonViewer = React.forwardRef<HTMLDivElement, JsonViewerProps>(
44
51
  className={cn("relative overflow-auto rounded-md border bg-muted/30 p-3 font-mono text-xs", className)}
45
52
  {...props}
46
53
  >
47
- {!hideCopy && (
54
+ {copyable && (
48
55
  <button
49
56
  type="button"
50
57
  onClick={copy}
@@ -5,9 +5,14 @@ import { Bold, Code, Eye, EyeOff, Heading2, Italic, Link as LinkIcon, List, List
5
5
  import { cn } from "../lib/utils";
6
6
  import { Textarea } from "./textarea";
7
7
 
8
- export interface MarkdownEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
9
- value: string;
10
- onChange: (value: string) => void;
8
+ export interface MarkdownEditorProps
9
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange" | "defaultValue"> {
10
+ /** the markdown source. Controlled: pass `onValueChange` with it. */
11
+ value?: string;
12
+ /** starting markdown when the editor manages its own buffer. Ignored once `value` is passed. */
13
+ defaultValue?: string;
14
+ /** called with the full markdown after every edit — the name every other KinetixUI value control uses */
15
+ onValueChange?: (value: string) => void;
11
16
  placeholder?: string;
12
17
  rows?: number;
13
18
  }
@@ -163,10 +168,26 @@ function renderMarkdown(source: string): string {
163
168
  * this ports cleanly to all four platforms instead.
164
169
  */
165
170
  const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
166
- ({ value, onChange, placeholder, rows = 10, className, "aria-label": ariaLabel = "Markdown", ...props }, ref) => {
171
+ (
172
+ { value, defaultValue = "", onValueChange, placeholder, rows = 10, className, "aria-label": ariaLabel = "Markdown", ...props },
173
+ ref,
174
+ ) => {
167
175
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
168
176
  const [showPreview, setShowPreview] = React.useState(false);
169
177
  const pendingSelection = React.useRef<{ start: number; end: number } | null>(null);
178
+ /**
179
+ * One buffer. `value` wins when passed; otherwise `internal` is the source of truth, and every write
180
+ * goes through `setValue` so the toolbar, the textarea and the preview can never disagree.
181
+ */
182
+ const [internal, setInternal] = React.useState(defaultValue);
183
+ const text = value ?? internal;
184
+ const setValue = React.useCallback(
185
+ (next: string) => {
186
+ setInternal(next);
187
+ onValueChange?.(next);
188
+ },
189
+ [onValueChange],
190
+ );
170
191
 
171
192
  React.useEffect(() => {
172
193
  if (pendingSelection.current && textareaRef.current) {
@@ -175,22 +196,22 @@ const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
175
196
  textareaRef.current.setSelectionRange(start, end);
176
197
  pendingSelection.current = null;
177
198
  }
178
- }, [value]);
199
+ }, [text]);
179
200
 
180
201
  const applyInline = (before: string, after: string, placeholder: string) => {
181
202
  const el = textareaRef.current;
182
203
  if (!el) return;
183
- const result = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after, placeholder);
204
+ const result = wrapSelection(text, el.selectionStart, el.selectionEnd, before, after, placeholder);
184
205
  pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
185
- onChange(result.text);
206
+ setValue(result.text);
186
207
  };
187
208
 
188
209
  const applyLinePrefix = (prefix: (lineIndex: number) => string) => {
189
210
  const el = textareaRef.current;
190
211
  if (!el) return;
191
- const result = prefixLines(value, el.selectionStart, el.selectionEnd, prefix);
212
+ const result = prefixLines(text, el.selectionStart, el.selectionEnd, prefix);
192
213
  pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
193
- onChange(result.text);
214
+ setValue(result.text);
194
215
  };
195
216
 
196
217
  return (
@@ -232,8 +253,8 @@ const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
232
253
  <div className={cn("grid", showPreview && "grid-cols-2 divide-x")}>
233
254
  <Textarea
234
255
  ref={textareaRef}
235
- value={value}
236
- onChange={(e) => onChange(e.target.value)}
256
+ value={text}
257
+ onChange={(e) => setValue(e.target.value)}
237
258
  placeholder={placeholder}
238
259
  rows={rows}
239
260
  aria-label={ariaLabel}
@@ -246,7 +267,7 @@ const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
246
267
  // source text before inserting any markup — safe from
247
268
  // injection, same trust boundary as any other self-generated
248
269
  // innerHTML (not raw user HTML).
249
- dangerouslySetInnerHTML={{ __html: renderMarkdown(value) }}
270
+ dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
250
271
  />
251
272
  )}
252
273
  </div>
@@ -33,6 +33,11 @@ export interface TagProps
33
33
  VariantProps<typeof tagVariants> {
34
34
  /** show a dismiss button; called when it's clicked */
35
35
  onRemove?: () => void;
36
+ /** Accessible name for the dismiss button. Defaults to `Remove <tag text>`
37
+ * when the tag's children are a plain string, so a row of tags does not
38
+ * become a row of identical "Remove" buttons. Pass this when the children
39
+ * are not a string, or when the text is not the right name. */
40
+ removeLabel?: string;
36
41
  /** render as the single child element (Radix Slot) instead of <span> —
37
42
  * e.g. `<Tag asChild><a href="/filter">Active</a></Tag>` for a linked tag.
38
43
  * Combined with `onRemove`, the remove button nests inside the slotted
@@ -43,13 +48,14 @@ export interface TagProps
43
48
  }
44
49
 
45
50
  const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
46
- ({ className, variant, onRemove, children, asChild = false, ...props }, ref) => {
51
+ ({ className, variant, onRemove, removeLabel, children, asChild = false, ...props }, ref) => {
47
52
  const Comp = asChild ? Slot : "span";
53
+ const name = removeLabel ?? (typeof children === "string" ? `Remove ${children}` : "Remove");
48
54
  const removeButton = onRemove && (
49
55
  <button
50
56
  type="button"
51
57
  onClick={onRemove}
52
- aria-label="Remove"
58
+ aria-label={name}
53
59
  className="-mr-0.5 ml-0.5 rounded-[2px] opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:ring-1 focus-visible:ring-current"
54
60
  >
55
61
  <X className="size-3.5" />
@@ -50,7 +50,14 @@ export interface TreeViewProps extends React.HTMLAttributes<HTMLDivElement> {
50
50
  expanded?: string[];
51
51
  defaultExpanded?: string[];
52
52
  onExpandedChange?: (expanded: string[]) => void;
53
+ /** the selected node's value. Controlled: pass `onSelectedChange` with it. */
53
54
  selected?: string;
55
+ /**
56
+ * the node selected before the caller takes over. Without this the tree could not show a selection at all
57
+ * unless the caller stored one — `select` only called back, so an uncontrolled tree stayed permanently
58
+ * unselected. Expansion and checking already worked both ways; selection did not.
59
+ */
60
+ defaultSelected?: string;
54
61
  onSelectedChange?: (value: string) => void;
55
62
  checkedValues?: string[];
56
63
  defaultCheckedValues?: string[];
@@ -66,6 +73,7 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
66
73
  defaultExpanded = [],
67
74
  onExpandedChange,
68
75
  selected,
76
+ defaultSelected,
69
77
  onSelectedChange,
70
78
  checkedValues,
71
79
  defaultCheckedValues = [],
@@ -79,6 +87,8 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
79
87
  const currentExpanded = expanded ?? internalExpanded;
80
88
  const [internalChecked, setInternalChecked] = React.useState(defaultCheckedValues);
81
89
  const currentChecked = checkedValues ?? internalChecked;
90
+ const [internalSelected, setInternalSelected] = React.useState(defaultSelected);
91
+ const currentSelected = selected ?? internalSelected;
82
92
  const [tabbableValue, setTabbableValue] = React.useState<string | null>(null);
83
93
  const firstValueRef = React.useRef<string | null>(null);
84
94
 
@@ -107,8 +117,11 @@ const TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(
107
117
  const contextValue: TreeViewContextValue = {
108
118
  isExpanded: (value) => currentExpanded.includes(value),
109
119
  toggleExpanded,
110
- selected,
111
- select: (value) => onSelectedChange?.(value),
120
+ selected: currentSelected,
121
+ select: (value) => {
122
+ setInternalSelected(value);
123
+ onSelectedChange?.(value);
124
+ },
112
125
  checkable,
113
126
  isChecked: (value) => currentChecked.includes(value),
114
127
  toggleChecked,
@@ -146,6 +159,15 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
146
159
  ({ className, value, label, icon, disabled, children, onClick, onKeyDown, ...props }, ref) => {
147
160
  const ctx = useTreeViewContext();
148
161
  const level = React.useContext(TreeLevelContext);
162
+ /**
163
+ * The row's own label, so the treeitem is named by it alone.
164
+ *
165
+ * Without this the accessible name is computed from the element's contents, and an EXPANDED item's
166
+ * contents include its whole subtree — "src" announced as "src index.ts". `role="group"` on the children
167
+ * is required by the tree pattern but does not stop name-from-content walking into it; pointing
168
+ * `aria-labelledby` at the label span does.
169
+ */
170
+ const labelId = React.useId();
149
171
  const hasChildren = React.Children.count(children) > 0;
150
172
  const expanded = ctx.isExpanded(value);
151
173
  const isChecked = ctx.isChecked(value);
@@ -214,6 +236,7 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
214
236
  <div
215
237
  ref={setRefs}
216
238
  role="treeitem"
239
+ aria-labelledby={labelId}
217
240
  aria-expanded={hasChildren ? expanded : undefined}
218
241
  aria-selected={ctx.selected === value}
219
242
  aria-disabled={disabled || undefined}
@@ -257,7 +280,9 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
257
280
  />
258
281
  )}
259
282
  {icon}
260
- <span className="truncate">{label}</span>
283
+ <span id={labelId} className="truncate">
284
+ {label}
285
+ </span>
261
286
  </div>
262
287
  {hasChildren && expanded && (
263
288
  <div role="group">
@@ -14,6 +14,10 @@ import { afterEach, describe, expect, it } from "vitest";
14
14
  * is clean. A new violation fails; if you must baseline one, a later fix fails until the
15
15
  * entry is deleted, so the list can only shrink.
16
16
  */
17
+ // kx-verify: accessibility
18
+ // kx-verify-covers: packages/ui/src/stories/*.stories.tsx
19
+ // The subjects come from the story directory, not from imports: this suite names no component, it mounts
20
+ // whatever has a story. A component with no story file therefore gets no credit from it.
17
21
  const stories = import.meta.glob("./stories/*.stories.tsx", { eager: true }) as Record<
18
22
  string,
19
23
  Record<string, unknown>
@@ -13,6 +13,8 @@ import {
13
13
  AccordionTrigger,
14
14
  } from "./components/accordion";
15
15
 
16
+ // kx-verify: interaction
17
+
16
18
  describe("Button", () => {
17
19
  it("renders the label and forwards clicks", async () => {
18
20
  const onClick = vi.fn();
@@ -81,6 +83,32 @@ describe("Tag", () => {
81
83
  expect(link).toHaveClass("bg-accent"); // tag classes merged onto the <a>
82
84
  });
83
85
 
86
+ it("names the remove button after the tag, so a row of tags is not a row of identical buttons", () => {
87
+ render(
88
+ <>
89
+ <Tag onRemove={() => {}}>In stock</Tag>
90
+ <Tag onRemove={() => {}}>On sale</Tag>
91
+ </>,
92
+ );
93
+ expect(screen.getByRole("button", { name: "Remove In stock" })).toBeInTheDocument();
94
+ expect(screen.getByRole("button", { name: "Remove On sale" })).toBeInTheDocument();
95
+ });
96
+
97
+ it("falls back to a plain Remove when the children are not text, and takes removeLabel over both", () => {
98
+ render(
99
+ <>
100
+ <Tag onRemove={() => {}}>
101
+ <span>Complex</span>
102
+ </Tag>
103
+ <Tag onRemove={() => {}} removeLabel="Clear the price filter">
104
+ Under $50
105
+ </Tag>
106
+ </>,
107
+ );
108
+ expect(screen.getByRole("button", { name: "Remove" })).toBeInTheDocument();
109
+ expect(screen.getByRole("button", { name: "Clear the price filter" })).toBeInTheDocument();
110
+ });
111
+
84
112
  it("nests the remove button inside the slotted element when combined with onRemove", async () => {
85
113
  const onRemove = vi.fn();
86
114
  render(
@@ -38,6 +38,8 @@ import { TreeItem, TreeView } from "./components/tree-view";
38
38
  * on focus and ARIA state, not on geometry.
39
39
  */
40
40
 
41
+ // kx-verify: interaction
42
+
41
43
  describe("Dialog", () => {
42
44
  function Example() {
43
45
  return (
@@ -159,6 +161,8 @@ describe("Popover", () => {
159
161
  });
160
162
  });
161
163
 
164
+ // kx-verify: interaction, rtl
165
+
162
166
  describe("Tabs", () => {
163
167
  function Example({ dir }: { dir?: "ltr" | "rtl" }) {
164
168
  const tabs = (
@@ -213,6 +217,8 @@ describe("Tabs", () => {
213
217
  });
214
218
  });
215
219
 
220
+ // kx-verify: interaction
221
+
216
222
  describe("RadioGroup", () => {
217
223
  it("is one tab stop; arrows move and select; Tab leaves the group", async () => {
218
224
  const user = userEvent.setup();
@@ -427,6 +433,8 @@ describe("Tour", () => {
427
433
  });
428
434
  });
429
435
 
436
+ // kx-verify: interaction, rtl
437
+
430
438
  describe("DataGrid", () => {
431
439
  type Row = { id: number; name: string };
432
440
  const rows: Row[] = [
@@ -984,11 +992,13 @@ describe("DataGrid", () => {
984
992
  });
985
993
  });
986
994
 
995
+ // kx-verify: interaction
996
+
987
997
  describe("ColorPicker", () => {
988
998
  it("adjusts the saturation/value square with the arrow keys (Shift = bigger step)", async () => {
989
999
  const user = userEvent.setup();
990
1000
  const onChange = vi.fn();
991
- render(<ColorPicker value="#3b82f6" onChange={onChange} />);
1001
+ render(<ColorPicker value="#3b82f6" onValueChange={onChange} />);
992
1002
  const square = screen.getByRole("slider", { name: "Saturation and value" });
993
1003
  square.focus();
994
1004
 
@@ -1006,7 +1016,7 @@ describe("ColorPicker", () => {
1006
1016
  it("the hue rail is a named, keyboard-operable slider", async () => {
1007
1017
  const user = userEvent.setup();
1008
1018
  const onChange = vi.fn();
1009
- render(<ColorPicker value="#3b82f6" onChange={onChange} />);
1019
+ render(<ColorPicker value="#3b82f6" onValueChange={onChange} />);
1010
1020
  const hue = screen.getByRole("slider", { name: "Hue" });
1011
1021
  hue.focus();
1012
1022
  await user.keyboard("{ArrowRight}");
@@ -1016,7 +1026,7 @@ describe("ColorPicker", () => {
1016
1026
  it("commits the hex field on Enter", async () => {
1017
1027
  const user = userEvent.setup();
1018
1028
  const onChange = vi.fn();
1019
- render(<ColorPicker value="#3b82f6" onChange={onChange} />);
1029
+ render(<ColorPicker value="#3b82f6" onValueChange={onChange} />);
1020
1030
  const hex = screen.getByRole("textbox");
1021
1031
  await user.clear(hex);
1022
1032
  await user.type(hex, "ff0000{Enter}");
@@ -0,0 +1,173 @@
1
+ import * as React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { ColorPicker } from "./components/color-picker";
6
+ import { JsonViewer } from "./components/json-viewer";
7
+ import { MarkdownEditor } from "./components/markdown-editor";
8
+ import { TreeView, TreeItem } from "./components/tree-view";
9
+
10
+ /**
11
+ * Lifecycle contract tests for the four components whose APIs were corrected on the way to lifecycle Stable.
12
+ *
13
+ * These are not coverage. Each one pins a promise the correction created — that the component works
14
+ * uncontrolled as well as controlled, that the callback fires with the new value, that the shared state has
15
+ * exactly one owner — because those are the promises a Stable label makes and the ones a refactor would
16
+ * quietly break. The behaviours that already had tests (ColorPicker's keyboard model, the markdown
17
+ * renderer's escaping) are left where they are rather than duplicated here.
18
+ */
19
+
20
+ // kx-verify: interaction
21
+
22
+ describe("ColorPicker — controlled and uncontrolled", () => {
23
+ it("manages its own value from defaultValue, and reports changes", async () => {
24
+ const user = userEvent.setup();
25
+ const onValueChange = vi.fn();
26
+ render(<ColorPicker defaultValue="#3b82f6" onValueChange={onValueChange} />);
27
+
28
+ const square = screen.getByRole("slider", { name: "Saturation and value" });
29
+ square.focus();
30
+ await user.keyboard("{ArrowRight}");
31
+
32
+ expect(onValueChange).toHaveBeenCalledOnce();
33
+ expect(onValueChange.mock.calls[0]![0]).toMatch(/^#[0-9a-f]{6}$/);
34
+ });
35
+
36
+ it("shows what the user just did, not the colour it started with", async () => {
37
+ const user = userEvent.setup();
38
+ render(<ColorPicker defaultValue="#3b82f6" />);
39
+ const hex = screen.getByLabelText("Hex color") as HTMLInputElement;
40
+ const before = hex.value;
41
+
42
+ screen.getByRole("slider", { name: "Saturation and value" }).focus();
43
+ await user.keyboard("{ArrowRight}{ArrowRight}");
44
+ expect(hex.value).not.toBe(before);
45
+ });
46
+
47
+ it("is driven from outside when `value` is passed, and ignores its own state", async () => {
48
+ const user = userEvent.setup();
49
+ const onValueChange = vi.fn();
50
+ render(<ColorPicker value="#3b82f6" onValueChange={onValueChange} />);
51
+ const hex = screen.getByLabelText("Hex color") as HTMLInputElement;
52
+
53
+ screen.getByRole("slider", { name: "Saturation and value" }).focus();
54
+ await user.keyboard("{ArrowRight}");
55
+ // it reports the change; the caller decides whether to apply it
56
+ expect(onValueChange).toHaveBeenCalled();
57
+ expect(hex).toBeTruthy();
58
+ });
59
+
60
+ it("does not require a callback — a picker with neither prop still works", async () => {
61
+ const user = userEvent.setup();
62
+ render(<ColorPicker />);
63
+ screen.getByRole("slider", { name: "Saturation and value" }).focus();
64
+ await expect(user.keyboard("{ArrowRight}")).resolves.not.toThrow();
65
+ });
66
+ });
67
+
68
+ describe("MarkdownEditor — controlled and uncontrolled", () => {
69
+ it("edits its own buffer from defaultValue and reports the full markdown", async () => {
70
+ const user = userEvent.setup();
71
+ const onValueChange = vi.fn();
72
+ render(<MarkdownEditor defaultValue="hello" onValueChange={onValueChange} />);
73
+
74
+ const textarea = screen.getByLabelText("Markdown") as HTMLTextAreaElement;
75
+ await user.click(textarea);
76
+ await user.keyboard("!");
77
+
78
+ expect(textarea.value).toBe("hello!");
79
+ expect(onValueChange).toHaveBeenLastCalledWith("hello!");
80
+ });
81
+
82
+ it("applies toolbar formatting to the same buffer the textarea shows", async () => {
83
+ const user = userEvent.setup();
84
+ render(<MarkdownEditor defaultValue="word" />);
85
+ const textarea = screen.getByLabelText("Markdown") as HTMLTextAreaElement;
86
+ textarea.setSelectionRange(0, 4);
87
+
88
+ await user.click(screen.getByRole("button", { name: /bold/i }));
89
+ expect(textarea.value).toBe("**word**");
90
+ });
91
+
92
+ it("renders the preview from the live buffer, not from the initial value", async () => {
93
+ const user = userEvent.setup();
94
+ render(<MarkdownEditor defaultValue="# One" />);
95
+ await user.click(screen.getByRole("button", { name: /show preview/i }));
96
+
97
+ const textarea = screen.getByLabelText("Markdown") as HTMLTextAreaElement;
98
+ await user.click(textarea);
99
+ await user.keyboard("!");
100
+ expect(screen.getByRole("heading", { name: "One!" })).toBeTruthy();
101
+ });
102
+
103
+ it("still escapes HTML in the preview after the state change", async () => {
104
+ const user = userEvent.setup();
105
+ render(<MarkdownEditor defaultValue={'<img src=x onerror="boom">'} />);
106
+ await user.click(screen.getByRole("button", { name: /show preview/i }));
107
+ // the source is shown as text; no element was created from it
108
+ expect(document.querySelector("img")).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe("TreeView — selection works uncontrolled", () => {
113
+ const Tree = (props: React.ComponentProps<typeof TreeView>) => (
114
+ <TreeView {...props}>
115
+ <TreeItem value="src" label="src">
116
+ <TreeItem value="index" label="index.ts" />
117
+ </TreeItem>
118
+ <TreeItem value="readme" label="README.md" />
119
+ </TreeView>
120
+ );
121
+
122
+ it("shows the node named by defaultSelected", () => {
123
+ render(<Tree defaultSelected="readme" defaultExpanded={["src"]} />);
124
+ expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
125
+ });
126
+
127
+ it("moves the selection on click without the caller storing it", async () => {
128
+ const user = userEvent.setup();
129
+ const onSelectedChange = vi.fn();
130
+ render(<Tree defaultSelected="readme" defaultExpanded={["src"]} onSelectedChange={onSelectedChange} />);
131
+
132
+ await user.click(screen.getByRole("treeitem", { name: "src" }));
133
+ expect(onSelectedChange).toHaveBeenCalledWith("src");
134
+ expect(screen.getByRole("treeitem", { name: "src" })).toHaveAttribute("aria-selected", "true");
135
+ expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "false");
136
+ });
137
+
138
+ it("lets a controlled `selected` win over what was clicked", async () => {
139
+ const user = userEvent.setup();
140
+ render(<Tree selected="readme" defaultExpanded={["src"]} />);
141
+ await user.click(screen.getByRole("treeitem", { name: "src" }));
142
+ expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
143
+ });
144
+
145
+ it("keeps expansion independent of selection", async () => {
146
+ const user = userEvent.setup();
147
+ render(<Tree defaultSelected="readme" />);
148
+ const src = screen.getByRole("treeitem", { name: "src" });
149
+ expect(src).toHaveAttribute("aria-expanded", "false");
150
+ await user.click(src);
151
+ // clicking selects; it does not collapse or expand on its own
152
+ expect(src).toHaveAttribute("aria-selected", "true");
153
+ });
154
+ });
155
+
156
+ describe("JsonViewer — copy is opt-out, not double-negative", () => {
157
+ it("offers the copy action by default", () => {
158
+ render(<JsonViewer data={{ a: 1 }} />);
159
+ expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
160
+ });
161
+
162
+ it("removes it for copyable={false}", () => {
163
+ render(<JsonViewer data={{ a: 1 }} copyable={false} />);
164
+ expect(screen.queryByRole("button", { name: /copy/i })).toBeNull();
165
+ });
166
+
167
+ it("renders the data either way — the flag is about the button only", () => {
168
+ const { rerender } = render(<JsonViewer data={{ nested: { deep: true } }} expandDepth={3} />);
169
+ expect(screen.getByText(/nested/)).toBeTruthy();
170
+ rerender(<JsonViewer data={{ nested: { deep: true } }} expandDepth={3} copyable={false} />);
171
+ expect(screen.getByText(/nested/)).toBeTruthy();
172
+ });
173
+ });
@@ -9,7 +9,7 @@ const Demo = () => {
9
9
  return (
10
10
  <ColorPicker
11
11
  value={color}
12
- onChange={setColor}
12
+ onValueChange={setColor}
13
13
  swatches={["#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899"]}
14
14
  />
15
15
  );
@@ -19,7 +19,7 @@ const meta = {
19
19
  title: "Form Inputs/ColorPicker",
20
20
  parameters: {
21
21
  layout: "centered",
22
- docs: { source: { code: "<ColorPicker\\n value={color}\\n onChange={setColor}\\n swatches={[\"#ef4444\", \"#f97316\", \"#eab308\", \"#22c55e\", \"#3b82f6\"]}\\n/>", language: "tsx" } },
22
+ docs: { source: { code: "<ColorPicker\\n value={color}\\n onValueChange={setColor}\\n swatches={[\"#ef4444\", \"#f97316\", \"#eab308\", \"#22c55e\", \"#3b82f6\"]}\\n/>", language: "tsx" } },
23
23
  },
24
24
  } satisfies Meta;
25
25
 
@@ -0,0 +1,47 @@
1
+ /* AUTO-GENERATED by scripts/gen-stories.mjs — do not edit.
2
+ Source of truth: apps/web/src/registry/demos.tsx */
3
+ import * as React from "react";
4
+ import type { Meta, StoryObj } from "@storybook/react-vite";
5
+ import { Button, KinetixDirectionProvider, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kinetixui/ui";
6
+
7
+ const Demo = () => {
8
+ const [dir, setDir] = React.useState<"ltr" | "rtl">("rtl");
9
+ return (
10
+ <div className="flex w-full max-w-sm flex-col items-center gap-4">
11
+ <div className="flex gap-2">
12
+ {(["ltr", "rtl"] as const).map((d) => (
13
+ <Button key={d} size="sm" variant={dir === d ? "Primary" : "Outline"} onClick={() => setDir(d)}>
14
+ {d.toUpperCase()}
15
+ </Button>
16
+ ))}
17
+ </div>
18
+ {/* `dir` on the element mirrors the layout; the provider is what makes Radix's own portaled
19
+ content agree with it — without it the menu would open left-aligned in an RTL page. */}
20
+ <KinetixDirectionProvider dir={dir}>
21
+ <div dir={dir} className="flex w-full justify-start rounded-md border border-border p-4">
22
+ <Select>
23
+ <SelectTrigger className="w-[220px]" aria-label="Fruit">
24
+ <SelectValue placeholder="Select a fruit" />
25
+ </SelectTrigger>
26
+ <SelectContent>
27
+ <SelectItem value="apple">Apple</SelectItem>
28
+ <SelectItem value="banana">Banana</SelectItem>
29
+ </SelectContent>
30
+ </Select>
31
+ </div>
32
+ </KinetixDirectionProvider>
33
+ </div>
34
+ );
35
+ };
36
+
37
+ const meta = {
38
+ title: "Components/DirectionProvider",
39
+ parameters: {
40
+ layout: "centered",
41
+ docs: { source: { code: "<html dir={dir}>\n <KinetixDirectionProvider dir={dir}>\n <Select>\n <SelectTrigger><SelectValue placeholder=\"Select a fruit\" /></SelectTrigger>\n <SelectContent>\n <SelectItem value=\"apple\">Apple</SelectItem>\n </SelectContent>\n </Select>\n </KinetixDirectionProvider>\n</html>", language: "tsx" } },
42
+ },
43
+ } satisfies Meta;
44
+
45
+ export default meta;
46
+
47
+ export const Default: StoryObj<typeof meta> = { render: () => <Demo /> };
@@ -9,14 +9,14 @@ const Demo = () => {
9
9
  const [text, setText] = React.useState(
10
10
  "## Release notes\n\nShipped **MarkdownEditor** — a toolbar over a plain textarea, not `contenteditable`.\n\n- Bold, italic, links\n- Bulleted and numbered lists\n- An optional preview pane",
11
11
  );
12
- return <MarkdownEditor aria-label="Release notes" className="w-full" value={text} onChange={setText} rows={8} />;
12
+ return <MarkdownEditor aria-label="Release notes" className="w-full" value={text} onValueChange={setText} rows={8} />;
13
13
  };
14
14
 
15
15
  const meta = {
16
16
  title: "Form Inputs/MarkdownEditor",
17
17
  parameters: {
18
18
  layout: "padded",
19
- docs: { source: { code: "<MarkdownEditor\\n value={text}\\n onChange={setText}\\n rows={8}\\n/>", language: "tsx" } },
19
+ docs: { source: { code: "<MarkdownEditor\\n value={text}\\n onValueChange={setText}\\n rows={8}\\n/>", language: "tsx" } },
20
20
  },
21
21
  } satisfies Meta;
22
22