detailpanel
This commit is contained in:
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user