detailpanel

This commit is contained in:
2025-11-01 04:16:31 +01:00
parent fd13edae6d
commit 303ece0529
10 changed files with 1334 additions and 625 deletions
+205
View File
@@ -0,0 +1,205 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PlusIcon } from './icons';
import useFloatingMenu from './useFloatingMenu';
const normalizeOption = (option, index) => {
if (option == null) {
return null;
}
if (typeof option === 'string') {
return {
id: option,
label: option,
original: option,
index,
};
}
const label = option.label ?? option.name;
if (!label) {
return null;
}
return {
id: option.id ?? label,
label,
original: option,
index,
};
};
const QuickAddMenu = ({
onSelectOption,
onCreate,
options = [],
placeholder = 'Search or create…',
createLabel = 'Add',
emptyMessage = 'No matches',
className,
triggerAriaLabel = 'Add item',
triggerTitle = 'Add',
renderOption,
menuMinWidth = 220,
triggerClassName = 'icon-button quick-add__trigger',
triggerContent = null,
}) => {
const anchorRef = useRef(null);
const inputRef = useRef(null);
const [query, setQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
const {
isOpen,
toggle,
close,
menuRef,
menuStyle,
updatePosition,
} = useFloatingMenu({
anchorRef,
minWidth: menuMinWidth,
matchAnchorWidth: false,
});
useEffect(() => {
if (!isOpen) {
return undefined;
}
setQuery('');
setSubmitting(false);
const frame = requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select?.();
updatePosition();
});
return () => cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
const normalizedOptions = useMemo(
() =>
options
.map((option, index) => normalizeOption(option, index))
.filter((option) => option && typeof option.label === 'string'),
[options],
);
const filteredOptions = useMemo(() => {
if (!query.trim()) {
return normalizedOptions;
}
const search = query.trim().toLowerCase();
return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search));
}, [normalizedOptions, query]);
const handleSelect = useCallback(
async (option) => {
if (!option || !onSelectOption) {
return;
}
setSubmitting(true);
try {
await onSelectOption(option.original ?? option.label, option);
setSubmitting(false);
close();
} catch (error) {
setSubmitting(false);
console.error('[quick-add] option selection failed', error);
}
},
[close, onSelectOption],
);
const handleCreate = useCallback(
async (event) => {
event.preventDefault();
if (!onCreate) {
return;
}
const value = query.trim();
if (!value) {
return;
}
setSubmitting(true);
try {
await onCreate(value);
setSubmitting(false);
close();
} catch (error) {
setSubmitting(false);
console.error('[quick-add] creation failed', error);
}
},
[close, onCreate, query],
);
const canCreate = Boolean(onCreate);
return (
<div className={className ? `quick-add ${className}` : 'quick-add'}>
<button
type="button"
ref={anchorRef}
className={triggerClassName}
aria-haspopup="menu"
aria-expanded={isOpen}
onClick={toggle}
aria-label={triggerAriaLabel}
title={triggerTitle}
>
{triggerContent ?? <PlusIcon />}
</button>
{isOpen ? (
<div
className="menu menu--floating quick-add__menu"
ref={menuRef}
style={menuStyle || undefined}
role="menu"
>
{canCreate ? (
<form className="quick-add__form" onSubmit={handleCreate}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
disabled={submitting}
aria-label={placeholder}
/>
<button type="submit" disabled={submitting || !query.trim()}>
{createLabel}
</button>
</form>
) : null}
<div className="menu__list quick-add__list" role="presentation">
{filteredOptions.length ? (
filteredOptions.map((option) => {
const key = option.id ?? option.index;
return (
<button
key={key}
type="button"
className="menu__item"
role="menuitem"
onClick={() => handleSelect(option)}
disabled={submitting}
>
{renderOption ? (
renderOption(option.original ?? option.label, option)
) : (
<span className="menu__label">{option.label}</span>
)}
</button>
);
})
) : (
<div className="menu__empty">{emptyMessage}</div>
)}
</div>
</div>
) : null}
</div>
);
};
export default QuickAddMenu;
+11 -3
View File
@@ -1,4 +1,3 @@
import { useId } from 'react';
import {
IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload,
@@ -22,7 +21,7 @@ import {
IconMinusVertical,
IconLogout,
IconChevronDown,
IconX,
IconX as TablerIconX,
IconSettings,
IconCheck,
IconPlus,
@@ -239,9 +238,18 @@ export const IconFileStack = ({ className, size = 24, stroke = 160, ...rest }) =
);
};
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<TablerIconX
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const CloseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconX
className={composeClassName('icon', className)}
className={className}
size={size}
stroke={stroke}
{...rest}
+13 -7
View File
@@ -64,7 +64,10 @@ const useFloatingMenu = ({
const rect = anchor.getBoundingClientRect();
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
const viewportWidth = resolveViewportWidth();
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
const menu = menuRef.current;
const menuHeight = menu?.offsetHeight ?? 0;
let left;
if (align === 'end') {
@@ -75,14 +78,17 @@ const useFloatingMenu = ({
left = rect.left;
}
const maxLeft = viewportWidth > 0
? viewportWidth - desiredWidth - safeMargin
: left;
const clampedLeft = viewportWidth > 0
? clamp(left, safeMargin, Math.max(maxLeft, safeMargin))
: left;
const maxLeft = viewportWidth > 0 ? viewportWidth - desiredWidth - safeMargin : left;
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
const top = rect.bottom + offset;
let top = rect.bottom + offset;
if (viewportHeight > 0 && menuHeight > 0) {
const projectedBottom = top + menuHeight + safeMargin;
if (projectedBottom > viewportHeight) {
const upwardTop = rect.top - offset - menuHeight;
top = Math.max(upwardTop, safeMargin);
}
}
setMenuMetrics({
top,