Match tag input to other menus

This commit is contained in:
Sam Becker 2025-06-13 12:40:43 -05:00
parent 7373a1b1f9
commit 7b9195ea35
2 changed files with 71 additions and 61 deletions

View File

@ -1,7 +1,8 @@
import { HTMLAttributes, useRef } from 'react';
import { HTMLAttributes, RefObject, useRef } from 'react';
import useMaskedScroll from './useMaskedScroll';
export default function MaskedScroll({
ref: refProp,
direction,
fadeSize,
animationDuration,
@ -13,8 +14,10 @@ export default function MaskedScroll({
children,
...props
}: HTMLAttributes<HTMLDivElement> &
Omit<Parameters<typeof useMaskedScroll>[0], 'ref'>) {
const ref = useRef<HTMLDivElement>(null);
Omit<Parameters<typeof useMaskedScroll>[0], 'ref'> &
{ ref?: RefObject<HTMLDivElement | null> }) {
const refInternal = useRef<HTMLDivElement>(null);
const ref = refProp ?? refInternal;
const { styleMask } = useMaskedScroll({
ref,

View File

@ -9,6 +9,8 @@ import {
useRef,
useState,
} from 'react';
import MaskedScroll from './MaskedScroll';
const KEY_KEYDOWN = 'keydown';
const CREATE_LABEL = 'Create';
@ -46,7 +48,7 @@ export default function TagInput({
const containerRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLInputElement>(null);
const optionsRef = useRef<HTMLDivElement>(null);
const [shouldShowMenu, setShouldShowMenu] = useState(false);
const [inputText, setInputText] = useState('');
@ -357,68 +359,73 @@ export default function TagInput({
/>
<input type="hidden" name={name} value={value} />
</div>
{shouldShowMenu && optionsFiltered.length > 0 &&
<div className="relative">
<div className="relative">
{shouldShowMenu && optionsFiltered.length > 0 &&
<div
id={ARIA_ID_TAG_OPTIONS}
role="listbox"
ref={optionsRef}
className={clsx(
'control absolute top-0 mt-3 w-full z-10 px-1.5! py-1.5!',
'max-h-[8rem] overflow-y-auto',
'flex flex-col gap-y-1',
'text-xl shadow-lg dark:shadow-xl',
'component-surface',
'absolute top-3 w-full px-1.5 py-1.5',
'max-h-[8rem] overflow-y-auto flex flex-col',
'shadow-lg dark:shadow-xl',
)}
>
{/* Menu Options */}
{optionsFiltered.map(({
value,
annotation,
annotationAria,
}, index) =>
<div
key={value}
role="option"
aria-selected={
index === selectedOptionIndex ||
(index === 0 && selectedOptionIndex === undefined)
}
tabIndex={0}
className={clsx(
'group flex items-center gap-2',
'px-1.5 py-1 rounded-xs',
'text-base select-none',
hasReachedLimit ? 'cursor-not-allowed' : 'cursor-pointer',
'hover:bg-gray-100 dark:hover:bg-gray-800',
!hasReachedLimit &&
'active:bg-gray-50 dark:active:bg-gray-900',
'focus:bg-gray-100 dark:focus:bg-gray-800',
index === 0 && selectedOptionIndex === undefined &&
'bg-gray-100 dark:bg-gray-800',
'outline-hidden',
)}
onClick={() => {
if (!hasReachedLimit) {
addOptions([value]);
<MaskedScroll
id={ARIA_ID_TAG_OPTIONS}
role="listbox"
className="flex flex-col gap-y-1 text-xl"
ref={optionsRef}
fadeSize={16}
>
{/* Menu Options */}
{optionsFiltered.map(({
value,
annotation,
annotationAria,
}, index) =>
<div
key={value}
role="option"
aria-selected={
index === selectedOptionIndex ||
(index === 0 && selectedOptionIndex === undefined)
}
}}
onFocus={() => setSelectedOptionIndex(index)}
>
<span className="grow inline-flex items-center gap-2 min-w-0">
{renderTag(value)}
</span>
{annotation &&
<span
className="whitespace-nowrap text-dim text-sm"
aria-label={annotationAria}
>
<span aria-hidden={Boolean(annotationAria)}>
{annotation}
</span>
</span>}
</div>)}
</div>
</div>}
tabIndex={0}
className={clsx(
'group flex items-center gap-2',
'px-1.5 py-1 rounded-sm',
'text-base select-none',
hasReachedLimit ? 'cursor-not-allowed' : 'cursor-pointer',
'hover:bg-gray-100 dark:hover:bg-gray-800',
!hasReachedLimit &&
'active:bg-gray-50 dark:active:bg-gray-900',
'focus:bg-gray-100 dark:focus:bg-gray-800',
index === 0 && selectedOptionIndex === undefined &&
'bg-gray-100 dark:bg-gray-800',
'outline-hidden',
)}
onClick={() => {
if (!hasReachedLimit) {
addOptions([value]);
}
}}
onFocus={() => setSelectedOptionIndex(index)}
>
<span className="grow inline-flex items-center gap-2 min-w-0">
{renderTag(value)}
</span>
{annotation &&
<span
className="whitespace-nowrap text-dim text-sm"
aria-label={annotationAria}
>
<span aria-hidden={Boolean(annotationAria)}>
{annotation}
</span>
</span>}
</div>)}
</MaskedScroll>
</div>}
</div>
</div>
);
}