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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user