@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 +34 -8
- package/dist/index.js +56 -25
- package/package.json +4 -4
- package/src/components/color-picker.tsx +43 -13
- package/src/components/json-viewer.tsx +10 -3
- package/src/components/markdown-editor.tsx +33 -12
- package/src/components/tag.tsx +8 -2
- package/src/components/tree-view.tsx +28 -3
- package/src/components-a11y.test.tsx +4 -0
- package/src/components-behavior.test.tsx +28 -0
- package/src/components-keyboard.test.tsx +13 -3
- package/src/components-lifecycle.test.tsx +173 -0
- package/src/stories/ColorPicker.stories.tsx +2 -2
- package/src/stories/DirectionProvider.stories.tsx +47 -0
- package/src/stories/MarkdownEditor.stories.tsx +2 -2
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
|
-
|
|
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
|
|
1727
|
-
|
|
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
|
-
|
|
1803
|
-
|
|
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":
|
|
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) =>
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
5940
|
+
const [r, g, b] = hexToRgba(current);
|
|
5931
5941
|
return rgbToHsv(r, g, b);
|
|
5932
5942
|
});
|
|
5933
|
-
const [a, setA] = React86.useState(() => hexToRgba(
|
|
5934
|
-
const lastEmitted = React86.useRef(
|
|
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
|
-
|
|
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
|
-
[
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
}, [
|
|
6472
|
+
}, [text]);
|
|
6442
6473
|
const applyInline = (before, after, placeholder2) => {
|
|
6443
6474
|
const el = textareaRef.current;
|
|
6444
6475
|
if (!el) return;
|
|
6445
|
-
const result = wrapSelection(
|
|
6476
|
+
const result = wrapSelection(text, el.selectionStart, el.selectionEnd, before, after, placeholder2);
|
|
6446
6477
|
pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
|
|
6447
|
-
|
|
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(
|
|
6483
|
+
const result = prefixLines(text, el.selectionStart, el.selectionEnd, prefix);
|
|
6453
6484
|
pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
|
|
6454
|
-
|
|
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) =>
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
85
|
-
|
|
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,
|
|
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(
|
|
125
|
+
const [r, g, b] = hexToRgba(current);
|
|
116
126
|
return rgbToHsv(r, g, b);
|
|
117
127
|
});
|
|
118
|
-
const [a, setA] = React.useState(() => hexToRgba(
|
|
119
|
-
const lastEmitted = React.useRef(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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:
|
|
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={
|
|
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
|
-
|
|
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,
|
|
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
|
-
{
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
(
|
|
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
|
-
}, [
|
|
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(
|
|
204
|
+
const result = wrapSelection(text, el.selectionStart, el.selectionEnd, before, after, placeholder);
|
|
184
205
|
pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
|
|
185
|
-
|
|
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(
|
|
212
|
+
const result = prefixLines(text, el.selectionStart, el.selectionEnd, prefix);
|
|
192
213
|
pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
|
|
193
|
-
|
|
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={
|
|
236
|
-
onChange={(e) =>
|
|
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(
|
|
270
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
|
|
250
271
|
/>
|
|
251
272
|
)}
|
|
252
273
|
</div>
|
package/src/components/tag.tsx
CHANGED
|
@@ -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=
|
|
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) =>
|
|
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">
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
|