Fix component bugs across hooks, elements, form, layout, editors and utils

- Hooks: per-instance timers in useReady; cleanup for useIntersectionObserver;
  useWebSocket no longer reconnects after unmount/refresh, drains the queue
  and dispatches non-JSON messages
- Modal/Popover/Dropdown: onClose only on close, Escape to close, no stray
  DOM props, hooks before early return, scrollbar-aware positioning,
  top-left/top-right dropdown positions
- Elements: Toast/Search/StarRating per-instance timers, Pagination labels
  and empty state, Breadcrumbs titles, LinkList dividers/props, segment-wise
  active-link matching (new utils/link-matches-path), Table object cells
- Form: Select with a default option is changeable, Input clear notifies
  controlled parents and copy works on textareas, Checkbox random label,
  NumberInputButtons runaway interval, upload prop/spread fixes
- Layout: Button disabled while loading, Stack gap and Span truncate via
  inline styles, Img fallbackImageSrc, className/JSDoc fixes
- Editors/MDX/AI: TinyMCE id/cleanup/stale handler/duplicate scripts,
  AceEditor debounce, markdown heights and frontmatter regex, last
  assistant message, AIPromptBlock prompt ref
- Utils: fetchApi headers/FormData/fetchOptions/no mutation, base64 file
  names and read errors, slugify underscore trim, serializeQuery zero values
- Tests for fetchApi, link matching, slugify and serializeQuery
- AGENTS.md updated for the package layout

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 10:45:46 +01:00
co-authored by Claude Opus 5.5
parent 11da9b89e7
commit cdeeb8201e
56 changed files with 1128 additions and 722 deletions
+1 -3
View File
@@ -71,9 +71,7 @@ export default function Checkbox({
defaultChecked || externalChecked || false,
);
const finalTitle = title
? title
: `Checkbox-${Math.round(Math.random() * 100000)}`;
const finalTitle = title;
React.useEffect(() => {
if (typeof externalChecked == "undefined") return;
+17 -6
View File
@@ -139,8 +139,8 @@ export default function FileUpload({
<input
type="file"
multiple={multiple}
className={twMerge("hidden", fileInputProps?.className)}
{...fileInputProps}
className={twMerge("hidden", fileInputProps?.className)}
onChange={(e) => {
if (multiple) {
(async () => {
@@ -154,11 +154,13 @@ export default function FileUpload({
const file = files[i];
const fileObj = await fileInputToBase64({
inputFile: file!,
allowedRegex,
});
filesArr.push(fileObj);
if (fileObj.fileBase64Full) filesArr.push(fileObj);
}
externalSetFiles?.(filesArr);
fileInputProps?.onChange?.(e);
})();
} else {
const inputFile = e.target.files?.[0];
@@ -249,8 +251,11 @@ export default function FileUpload({
</Card>
) : fileUrl ? (
<Card
className="w-full relative h-full items-center justify-center overflow-hidden"
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center overflow-hidden",
previewImageWrapperProps?.className,
)}
>
<Stack className="w-full">
{disablePreview ? (
@@ -295,6 +300,8 @@ export default function FileUpload({
onChangeHandler?.(undefined);
changeHandler?.(undefined);
setFileUrl(undefined);
externalSetFileURL?.(undefined);
onClear?.();
}}
title="Cancel File Button"
>
@@ -303,6 +310,7 @@ export default function FileUpload({
</Card>
) : (
<Card
{...placeHolderWrapper}
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100/50 dark:hover:bg-white/5",
"border-dashed border-2",
@@ -343,12 +351,15 @@ export default function FileUpload({
(res) => {
setFile(res);
externalSetFile?.(res);
onChangeHandler?.(res);
changeHandler?.(res);
onChangeHandler?.(
res,
inputRef,
fileInputUtils,
);
changeHandler?.(res, inputRef, fileInputUtils);
},
);
}}
{...placeHolderWrapper}
>
<Center
className={twMerge(
+12 -6
View File
@@ -104,17 +104,20 @@ export default function ImageUpload({
>
<input
type="file"
className={twMerge("hidden", fileInputProps?.className)}
multiple={multiple}
accept="image/*"
{...fileInputProps}
className={twMerge("hidden", fileInputProps?.className)}
onChange={(e) => {
setLoading?.(true);
if (multiple) {
(async () => {
const files = e.target.files;
if (!files?.[0]) return;
if (!files?.[0]) {
setLoading?.(false);
return;
}
let imgArr: ImageInputToBase64FunctionReturn[] = [];
@@ -147,8 +150,11 @@ export default function ImageUpload({
{src || imageObject?.imageBase64Full ? (
<Card
className="w-full relative h-full items-center justify-center"
{...previewImageWrapperProps}
className={twMerge(
"w-full relative h-full items-center justify-center",
previewImageWrapperProps?.className,
)}
>
{label && (
<label
@@ -183,7 +189,7 @@ export default function ImageUpload({
setImageObject?.(undefined);
externalSetImage?.(undefined);
if (inputRef.current) {
inputRef.current.value == "";
inputRef.current.value = "";
}
}}
title="Cancel Image Upload Button"
@@ -193,6 +199,7 @@ export default function ImageUpload({
</Card>
) : (
<Card
{...placeHolderWrapper}
className={twMerge(
"w-full h-full cursor-pointer hover:bg-slate-100 dark:hover:bg-white/20",
placeHolderWrapper?.className,
@@ -206,7 +213,6 @@ export default function ImageUpload({
inputRef.current?.click();
placeHolderWrapper?.onClick?.(e);
}}
{...placeHolderWrapper}
>
<Center>
<Stack className="items-center gap-2">
@@ -229,7 +235,7 @@ export default function ImageUpload({
}}
/>
<Button
title="Restore Image Button"
title="Set Image URL Button"
size="smaller"
variant="outlined"
color="gray"
+7
View File
@@ -59,6 +59,7 @@ export default function NumberInputButtons({
function incrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true);
pressTimeout = setTimeout(() => {
@@ -75,6 +76,8 @@ export default function NumberInputButtons({
}
function decrementDownPress() {
window.clearTimeout(pressTimeout);
window.clearInterval(pressInterval);
setButtonDown(true);
pressTimeout = setTimeout(() => {
@@ -143,7 +146,9 @@ export default function NumberInputButtons({
onMouseDown={decrementDownPress}
onTouchStart={decrementDownPress}
onMouseUp={decrementDownCancel}
onMouseLeave={decrementDownCancel}
onTouchEnd={decrementDownCancel}
onTouchCancel={decrementDownCancel}
>
<Minus size={20} />
</Row>
@@ -156,7 +161,9 @@ export default function NumberInputButtons({
onMouseDown={incrementDownPress}
onTouchStart={incrementDownPress}
onMouseUp={incrementDownCancel}
onMouseLeave={incrementDownCancel}
onTouchEnd={incrementDownCancel}
onTouchCancel={incrementDownCancel}
>
<Plus size={20} />
</Row>
+37 -28
View File
@@ -153,8 +153,10 @@ export default function Input<KeyType extends string>(
},
);
const inputRef = componentRef || React.useRef<HTMLInputElement>(null);
const textAreaRef = componentRef || React.useRef<HTMLTextAreaElement>(null);
const internalInputRef = React.useRef<HTMLInputElement>(null);
const internalTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const inputRef = componentRef || internalInputRef;
const textAreaRef = componentRef || internalTextAreaRef;
const buttonDownRef = React.useRef(false);
const [value, setValue] = React.useState(
@@ -344,7 +346,11 @@ export default function Input<KeyType extends string>(
return (
<Stack
title={`${finalLabel}${props.required ? " (Required)" : ""}`}
title={
finalLabel
? `${finalLabel}${props.required ? " (Required)" : ""}`
: undefined
}
{...wrapperWrapperProps}
className={twMerge(
"w-full gap-1.5 relative z-0 hover:z-100",
@@ -417,9 +423,9 @@ export default function Input<KeyType extends string>(
title="Copy value"
className="cursor-pointer opacity-50 hover:opacity-100"
onClick={() => {
const text =
inputRef.current.value ||
textAreaRef.current.value;
const text = (
istextarea ? textAreaRef : inputRef
).current?.value;
if (text) {
navigator.clipboard
@@ -446,14 +452,18 @@ export default function Input<KeyType extends string>(
onClick={(e) => {
e.preventDefault();
if (inputRef.current) {
inputRef.current.value = "";
}
if (textAreaRef.current) {
textAreaRef.current.value = "";
const element = istextarea
? textAreaRef.current
: inputRef.current;
// Goes through React's onChange so controlled
// inputs and `onChange` listeners see the clear
if (element) {
setNativeInputValue(element, "");
} else {
setValue("");
}
setValue("");
clearInputProps?.onClick?.(e);
}}
>
@@ -465,7 +475,7 @@ export default function Input<KeyType extends string>(
<div
title={
inputType == "password"
? "View Psasword"
? "View Password"
: "Hide Password"
}
className={twMerge("p-1 -my-2 -mx-1")}
@@ -545,25 +555,24 @@ export default function Input<KeyType extends string>(
const PICKER_INPUT_TYPES = ["date", "datetime-local", "time", "month", "week"];
const updateInput = ({
newValue,
inputRef,
}: {
newValue: string;
inputRef: RefObject<HTMLInputElement & HTMLTextAreaElement>;
}) => {
const input = inputRef.current;
if (!input) return;
function setNativeInputValue(
element: HTMLInputElement | HTMLTextAreaElement,
newValue: string,
) {
const prototype =
element instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
// Get the native setter (React overrides the default one)
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
const nativeValueSetter = Object.getOwnPropertyDescriptor(
prototype,
"value",
)?.set;
// Call it directly on the input
nativeInputValueSetter?.call(input, newValue);
// Call it directly on the element
nativeValueSetter?.call(element, newValue);
// Dispatch the event - React listens for 'input', not 'change'
input.dispatchEvent(new Event("input", { bubbles: true }));
};
element.dispatchEvent(new Event("input", { bubbles: true }));
}
+16 -15
View File
@@ -153,14 +153,12 @@ export default function SearchSelect<
const commit = React.useCallback(
(option: TWUISelectOptionObject<KeyType, T>) => {
if (multiple) {
setSelected((prev) => {
const exists = prev.some((s) => s.value === option.value);
const next = exists
? prev.filter((s) => s.value !== option.value)
: [...prev, option];
notify(next);
return next;
});
const exists = selected.some((s) => s.value === option.value);
const next = exists
? selected.filter((s) => s.value !== option.value)
: [...selected, option];
setSelected(next);
notify(next);
setQuery("");
setOpen(true);
setActiveIndex(0);
@@ -174,20 +172,18 @@ export default function SearchSelect<
notify(next);
}
},
[multiple, notify],
[multiple, notify, selected],
);
const removeOne = React.useCallback(
(value: KeyType) => {
setSelected((prev) => {
const next = prev.filter((s) => s.value !== value);
notify(next);
return next;
});
const next = selected.filter((s) => s.value !== value);
setSelected(next);
notify(next);
if (!multiple) setQuery("");
inputRef.current?.focus();
},
[multiple, notify],
[multiple, notify, selected],
);
const clear = React.useCallback(() => {
@@ -204,6 +200,11 @@ export default function SearchSelect<
React.useEffect(() => {
if (initRef.current || !initialSelected.length) return;
initRef.current = true;
// Options may arrive after mount, so sync the visible state too
setSelected(initialSelected);
if (!multiple && initialSelected[0]) {
setQuery(getOptionLabel(initialSelected[0]));
}
notify(initialSelected);
}, [initialSelected, notify]);
+31 -10
View File
@@ -102,15 +102,35 @@ export default function Select<
isValid: true,
});
const selectRef = componentRef || React.useRef<HTMLSelectElement>(null);
const internalSelectRef = React.useRef<HTMLSelectElement>(null);
const selectRef = componentRef || internalSelectRef;
const generatedId = React.useId();
const flatOptions = options.flat();
const defaultOption = flatOptions.find((opt) => opt.default);
const initialOption = defaultOption || flatOptions[0];
const [value, setValue] = React.useState<TWUISelectValueObject<KeyType, T>>(
{
value: options[0]?.value!,
data: options[0]?.data,
value: initialOption?.value!,
data: initialOption?.data,
},
);
/**
* Selected value when an option is marked `default`. Kept in state so
* the user can still change the selection, and re-synced whenever the
* `default` option changes (e.g. options derived from parent state).
*/
const [selectedValue, setSelectedValue] = React.useState<
string | undefined
>(defaultOption?.value);
React.useEffect(() => {
if (defaultOption?.value === undefined) return;
setSelectedValue(defaultOption.value);
}, [defaultOption?.value]);
React.useEffect(() => {
setTimeout(() => {
requestAnimationFrame(() => {
@@ -135,7 +155,7 @@ export default function Select<
? twuiSlugify(props.name)
: props.title
? twuiSlugify(props.title)
: `select-${Math.round(Math.random() * 1000000)}`;
: `select-${generatedId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<Stack
@@ -181,15 +201,16 @@ export default function Select<
props.className,
)}
ref={selectRef}
value={
options.flat().find((opt) => opt.default)?.value ||
undefined
}
value={props.value ?? selectedValue}
onChange={(e) => {
const targetValue = options.find(
const targetValue = flatOptions.find(
(opt) => opt.value == e.target.value,
);
if (selectedValue !== undefined) {
setSelectedValue(e.target.value);
}
if (targetValue) {
setValue(targetValue);
}
@@ -206,7 +227,7 @@ export default function Select<
});
}}
>
{options.flat().map((option, index) => {
{flatOptions.map((option, index) => {
const optionTitle =
option.title || twuiSlugToNormalText(option.value);