feat(js): implement react aria combobox

This commit is contained in:
Paul Chavard 2024-05-06 18:08:26 +02:00
parent 1e11ad4ce6
commit df34784d5c
No known key found for this signature in database
7 changed files with 914 additions and 27 deletions

View file

@ -32,29 +32,78 @@ trix-editor.fr-input {
}
.fr-ds-combobox {
.fr-menu {
width: 100%;
.fr-menu__list {
width: 100%;
max-height: 300px;
}
}
.fr-autocomplete {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
}
}
.fr-ds-combobox__menu {
&[data-placement=top] {
--origin: translateY(8px);
}
&[data-placement=bottom] {
--origin: translateY(-8px);
}
&[data-placement=right] {
--origin: translateX(-8px);
}
&[data-placement=left] {
--origin: translateX(8px);
}
&[data-entering] {
animation: popover-slide 200ms;
}
&.fr-menu {
width: var(--trigger-width);
top: unset;
.fr-menu__list {
display: block;
width: unset;
max-height: 300px;
overflow: auto;
}
.fr-menu__item {
&[data-selected] {
font-weight: bold;
}
&[data-focused] {
font-weight: bold;
}
}
}
}
@keyframes popover-slide {
from {
transform: var(--origin);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 62em) {
.fr-ds-combobox .fr-menu .fr-menu__list {
z-index: calc(var(--ground) + 1000);
background-color: var(--background-default-grey);
--idle: transparent;
--hover: var(--background-overlap-grey-hover);
--active: var(--background-overlap-grey-active);
filter: drop-shadow(var(--overlap-shadow));
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
.fr-ds-combobox__menu {
&.fr-menu .fr-menu__list {
z-index: calc(var(--ground) + 1000);
background-color: var(--background-default-grey);
--idle: transparent;
--hover: var(--background-overlap-grey-hover);
--active: var(--background-overlap-grey-active);
filter: drop-shadow(var(--overlap-shadow));
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
}
}
}

View file

@ -634,10 +634,6 @@ textarea::placeholder {
.fr-menu__item {
list-style-type: none;
margin-bottom: $default-spacer;
&[aria-selected] {
font-weight: bold;
}
}
}

View file

@ -73,22 +73,26 @@ module Dsfr
}
end
def input_opts(other_opts = {})
def react_input_opts(other_opts = {})
input_opts(other_opts, true)
end
def input_opts(other_opts = {}, react = false)
@opts = @opts.deep_merge!(other_opts)
@opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class])
@opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class])
.merge({
'fr-password__input': password?,
'fr-input': true,
'fr-input': !react,
'fr-mb-0': true
}.merge(input_error_class_names)))
if errors_on_attribute?
@opts.deep_merge!(aria: { describedby: describedby_id })
@opts.deep_merge!('aria-describedby': describedby_id)
elsif hintable?
@opts.deep_merge!(aria: { describedby: hint_id })
@opts.deep_merge!('aria-describedby': hint_id)
end
if @required
@opts[:required] = true
@opts[react ? :is_required : :required] = true
end
if email?

View file

@ -0,0 +1,326 @@
import type { ListBoxItemProps } from 'react-aria-components';
import {
ComboBox as AriaComboBox,
ListBox,
ListBoxItem,
Popover,
Input,
Label,
Text,
Button,
TagGroup,
TagList,
Tag
} from 'react-aria-components';
import { useMemo, useRef, createContext, useContext } from 'react';
import type { RefObject } from 'react';
import { findOrCreateContainerElement } from '@coldwired/react';
import {
useLabelledBy,
useDispatchChangeEvent,
useMultiList,
useSingleList,
useRemoteList,
createLoader,
type ComboBoxProps
} from './react-aria/hooks';
import {
type Item,
SingleComboBoxProps,
MultiComboBoxProps,
RemoteComboBoxProps
} from './react-aria/props';
const getPortal = () => findOrCreateContainerElement('rac-portal');
export function ComboBox({
children,
label,
description,
className,
inputRef,
...props
}: ComboBoxProps & { inputRef?: RefObject<HTMLInputElement> }) {
return (
<AriaComboBox
{...props}
className={`fr-ds-combobox ${className ?? ''}`}
shouldFocusWrap={true}
>
{label ? <Label className="fr-label">{label}</Label> : null}
{description ? (
<Text slot="description" className="fr-hint-text">
{description}
</Text>
) : null}
<div className="fr-ds-combobox__input" style={{ position: 'relative' }}>
<Input className="fr-select fr-autocomplete" ref={inputRef} />
<Button
style={{
width: '40px',
height: '100%',
position: 'absolute',
opacity: 0,
right: 0,
top: 0
}}
>
{' '}
</Button>
</div>
<Popover
className="fr-ds-combobox__menu fr-menu"
UNSTABLE_portalContainer={getPortal()!}
>
<ListBox className="fr-menu__list">{children}</ListBox>
</Popover>
</AriaComboBox>
);
}
export function ComboBoxItem(props: ListBoxItemProps<Item>) {
return <ListBoxItem {...props} className="fr-menu__item" />;
}
export function SingleComboBox({
children,
...maybeProps
}: SingleComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
emptyFilterKey,
name,
formValue,
form,
data,
...props
} = useMemo(() => SingleComboBoxProps.parse(maybeProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const { selectedItem, ...comboBoxProps } = useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange: dispatch
});
return (
<>
<ComboBox aria-labelledby={labelledby} {...comboBoxProps} {...props}>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={formValue == 'text' ? 'label' : 'value'}
name={name}
form={form}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function MultiComboBox(maybeProps: MultiComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKeys: defaultSelectedKeys,
name,
form,
formValue,
allowsCustomValue,
valueSeparator,
...props
} = useMemo(() => MultiComboBoxProps.parse(maybeProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const inputRef = useRef<HTMLInputElement>(null);
const { selectedItems, hiddenInputValues, onRemove, ...comboBoxProps } =
useMultiList({
defaultItems,
defaultSelectedKeys,
onChange: dispatch,
formValue,
allowsCustomValue,
valueSeparator,
focusInput: () => {
inputRef.current?.focus();
}
});
return (
<div className="fr-ds-combobox__multiple">
{selectedItems.length > 0 ? (
<TagGroup onRemove={onRemove} aria-label={props['aria-label']}>
<TagList items={selectedItems} className="fr-tag-list">
{selectedItems.map((item) => (
<Tag
key={item.value}
id={item.value}
textValue={`Retirer ${item.label}`}
className="fr-tag fr-tag--sm"
>
{item.label}
<Button slot="remove" className="fr-tag--dismiss"></Button>
</Tag>
))}
</TagList>
</TagGroup>
) : null}
<ComboBox
aria-labelledby={labelledby}
allowsCustomValue={allowsCustomValue}
inputRef={inputRef}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{name ? (
<span ref={ref}>
{hiddenInputValues.map((value) => (
<input
type="hidden"
value={value}
name={name}
form={form}
key={value}
/>
))}
</span>
) : null}
</div>
);
}
export function RemoteComboBox({
loader,
onChange,
children,
...maybeProps
}: RemoteComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
allowsCustomValue,
minimumInputLength,
limit,
formValue,
name,
form,
data,
...props
} = useMemo(() => RemoteComboBoxProps.parse(maybeProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const load = useMemo(
() =>
typeof loader == 'string'
? createLoader(loader, { minimumInputLength, limit })
: loader,
[loader, minimumInputLength, limit]
);
const { selectedItem, ...comboBoxProps } = useRemoteList({
allowsCustomValue,
defaultItems,
defaultSelectedKey,
load,
onChange: (item) => {
onChange?.(item);
dispatch();
}
});
return (
<>
<ComboBox
allowsEmptyCollection={comboBoxProps.inputValue.length > 0}
allowsCustomValue={allowsCustomValue}
aria-labelledby={labelledby}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={
formValue == 'text' || allowsCustomValue ? 'label' : 'value'
}
name={name}
form={form}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function ComboBoxValueSlot({
field,
name,
form,
data
}: {
field: 'label' | 'value' | 'data';
name: string;
form?: string;
data?: Record<string, string>;
}) {
const selectedItem = useContext(SelectedItemContext);
const value = getSelectedValue(selectedItem, field);
const dataProps = Object.fromEntries(
Object.entries(data ?? {}).map(([key, value]) => [
`data-${key.replace(/_/g, '-')}`,
value
])
);
return (
<input type="hidden" name={name} value={value} form={form} {...dataProps} />
);
}
const SelectedItemContext = createContext<Item | null>(null);
const SelectedItemProvider = SelectedItemContext.Provider;
function getSelectedValue(
selectedItem: Item | null,
field: 'label' | 'value' | 'data'
): string {
if (selectedItem == null) {
return '';
} else if (field == 'data') {
if (typeof selectedItem.data == 'string') {
return selectedItem.data;
} else if (!selectedItem.data) {
return '';
}
return JSON.stringify(selectedItem.data);
}
return selectedItem[field];
}

View file

@ -0,0 +1,438 @@
import type {
ComboBoxProps as AriaComboBoxProps,
TagGroupProps
} from 'react-aria-components';
import { useAsyncList, type AsyncListOptions } from 'react-stately';
import { useMemo, useRef, useState, useEffect } from 'react';
import type { Key } from 'react';
import { matchSorter } from 'match-sorter';
import { useDebounceCallback } from 'usehooks-ts';
import { useEvent } from 'react-use-event-hook';
import isEqual from 'react-fast-compare';
import { Item } from './props';
export type Loader = AsyncListOptions<Item, string>['load'];
export interface ComboBoxProps
extends Omit<AriaComboBoxProps<Item>, 'children'> {
children: React.ReactNode | ((item: Item) => React.ReactNode);
label?: string;
description?: string;
}
const inputMap = new WeakMap<HTMLInputElement, string>();
export function useDispatchChangeEvent() {
const ref = useRef<HTMLSpanElement>(null);
return {
ref,
dispatch: () => {
requestAnimationFrame(() => {
const input = ref.current?.querySelector('input');
if (input) {
const value = input.value;
const prevValue = inputMap.get(input) || '';
if (value != prevValue) {
inputMap.set(input, value);
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
}
};
}
export function useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange
}: {
defaultItems?: Item[];
defaultSelectedKey?: string | null;
emptyFilterKey?: string;
onChange?: (item: Item | null) => void;
}) {
const [selectedKey, setSelectedKey] = useState(defaultSelectedKey);
const items = useMemo(() => defaultItems || [], [defaultItems]);
const selectedItem = useMemo(
() => items.find((item) => item.value == selectedKey) ?? null,
[items, selectedKey]
);
const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? '');
// show fallback item when input value is not matching any items
const fallbackItem = useMemo(
() => items.find((item) => item.value == emptyFilterKey),
[items, emptyFilterKey]
);
const filteredItems = useMemo(() => {
if (inputValue == '') {
return items;
}
const filteredItems = matchSorter(items, inputValue, { keys: ['label'] });
if (filteredItems.length == 0 && fallbackItem) {
return [fallbackItem];
} else {
return filteredItems;
}
}, [items, inputValue, fallbackItem]);
const initialSelectedKeyRef = useRef(defaultSelectedKey);
const setSelection = useEvent((key?: string | null) => {
const inputValue = defaultSelectedKey
? items.find((item) => item.value == defaultSelectedKey)?.label
: '';
setSelectedKey(key);
setInputValue(inputValue ?? '');
});
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
setSelection(key ? String(key) : null);
const item =
typeof key != 'string'
? null
: selectedItem?.value == key
? selectedItem
: items.find((item) => item.value == key) ?? null;
onChange?.(item);
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
setInputValue(value);
if (value == '') {
onSelectionChange(null);
}
}
);
// reset default selected key when props change
useEffect(() => {
if (initialSelectedKeyRef.current != defaultSelectedKey) {
initialSelectedKeyRef.current = defaultSelectedKey;
setSelection(defaultSelectedKey);
}
}, [defaultSelectedKey, setSelection]);
return {
selectedItem,
selectedKey,
onSelectionChange,
inputValue,
onInputChange,
items: filteredItems
};
}
export function useMultiList({
defaultItems,
defaultSelectedKeys,
allowsCustomValue,
valueSeparator,
onChange,
focusInput,
formValue
}: {
defaultItems?: Item[];
defaultSelectedKeys?: string[];
allowsCustomValue?: boolean;
valueSeparator?: string;
onChange?: () => void;
focusInput?: () => void;
formValue?: 'text' | 'key';
}) {
const valueSeparatorRegExp = useMemo(
() => (valueSeparator ? new RegExp(valueSeparator) : /\s|,|;/),
[valueSeparator]
);
const [selectedKeys, setSelectedKeys] = useState(
() => new Set(defaultSelectedKeys ?? [])
);
const [inputValue, setInputValue] = useState('');
const items = useMemo(() => defaultItems || [], [defaultItems]);
const itemsIndex = useMemo(() => {
const index = new Map<string, Item>();
for (const item of items) {
index.set(item.value, item);
}
return index;
}, [items]);
const filteredItems = useMemo(
() =>
inputValue.length == 0
? items
: matchSorter(items, inputValue, { keys: ['label'] }),
[items, inputValue]
);
const selectedItems = useMemo(() => {
const selectedItems: Item[] = [];
for (const key of selectedKeys) {
const item = itemsIndex.get(key);
if (item) {
selectedItems.push(item);
} else if (allowsCustomValue) {
selectedItems.push({ label: key, value: key });
}
}
return selectedItems;
}, [itemsIndex, selectedKeys, allowsCustomValue]);
const hiddenInputValues = useMemo(() => {
const values = selectedItems.map((item) =>
formValue == 'text' || allowsCustomValue ? item.label : item.value
);
if (!allowsCustomValue || inputValue == '') {
return values;
}
return [
...new Set([
...values,
...inputValue.split(valueSeparatorRegExp).filter(Boolean)
])
];
}, [
selectedItems,
inputValue,
valueSeparatorRegExp,
allowsCustomValue,
formValue
]);
const isSelectionSetRef = useRef(false);
const initialSelectedKeysRef = useRef(defaultSelectedKeys);
// reset default selected keys when props change
useEffect(() => {
if (!isEqual(initialSelectedKeysRef.current, defaultSelectedKeys)) {
initialSelectedKeysRef.current = defaultSelectedKeys;
setSelectedKeys(new Set(defaultSelectedKeys));
}
}, [defaultSelectedKeys]);
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
if (key) {
isSelectionSetRef.current = true;
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
selectedKeys.add(String(key));
return selectedKeys;
});
setInputValue('');
onChange?.();
}
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
const isSelectionSet = isSelectionSetRef.current;
isSelectionSetRef.current = false;
if (isSelectionSet) {
setInputValue('');
return;
}
if (allowsCustomValue) {
const values = value.split(valueSeparatorRegExp);
// if input contains a separator, add all values
if (values.length > 1) {
const addedKeys = values.filter(Boolean);
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
for (const key of addedKeys) {
selectedKeys.add(key);
}
return selectedKeys;
});
setInputValue('');
} else {
setInputValue(value);
}
onChange?.();
} else {
setInputValue(value);
}
}
);
const onRemove = useEvent<NonNullable<TagGroupProps['onRemove']>>(
(removedKeys) => {
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
for (const key of removedKeys) {
selectedKeys.delete(String(key));
}
// focus input when all items are removed
if (selectedKeys.size == 0) {
focusInput?.();
}
return selectedKeys;
});
onChange?.();
}
);
return {
onRemove,
onSelectionChange,
onInputChange,
selectedItems,
items: filteredItems,
hiddenInputValues,
inputValue
};
}
export function useRemoteList({
load,
defaultItems,
defaultSelectedKey,
onChange,
debounce,
allowsCustomValue
}: {
load: Loader;
defaultItems?: Item[];
defaultSelectedKey?: Key | null;
onChange?: (item: Item | null) => void;
debounce?: number;
allowsCustomValue?: boolean;
}) {
const [defaultSelectedItem, setSelectedItem] = useState<Item | null>(() => {
if (defaultItems) {
return (
defaultItems.find((item) => item.value == defaultSelectedKey) ?? null
);
}
return null;
});
const [inputValue, setInputValue] = useState(
defaultSelectedItem?.label ?? ''
);
const selectedItem = useMemo<Item | null>(() => {
if (defaultSelectedItem) {
return defaultSelectedItem;
}
if (allowsCustomValue && inputValue != '') {
return { label: inputValue, value: inputValue };
}
return null;
}, [defaultSelectedItem, inputValue, allowsCustomValue]);
const list = useAsyncList<Item>({ getKey, load });
const setFilterText = useEvent((filterText: string) => {
list.setFilterText(filterText);
});
const debouncedSetFilterText = useDebounceCallback(
setFilterText,
debounce ?? 300
);
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
const item =
typeof key != 'string'
? null
: selectedItem?.value == key
? selectedItem
: list.getItem(key);
setSelectedItem(item);
if (item) {
setInputValue(item.label);
} else if (!allowsCustomValue) {
setInputValue('');
}
onChange?.(item);
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
debouncedSetFilterText(value);
setInputValue(value);
if (value == '') {
onSelectionChange(null);
} else if (allowsCustomValue && selectedItem?.label != value) {
onChange?.(selectedItem);
}
}
);
// add to items list current selected item if it's not in the list
const items =
selectedItem && !list.getItem(selectedItem.value)
? [selectedItem, ...list.items]
: list.items;
return {
selectedItem,
selectedKey: selectedItem?.value ?? null,
onSelectionChange,
inputValue,
onInputChange,
items
};
}
function getKey(item: Item) {
return item.value;
}
export const createLoader: (
source: string,
options?: {
minimumInputLength?: number;
limit?: number;
param?: string;
}
) => Loader =
(source, options) =>
async ({ signal, filterText }) => {
const url = new URL(source, location.href);
const minimumInputLength = options?.minimumInputLength ?? 2;
const param = options?.param ?? 'q';
const limit = options?.limit ?? 10;
if (!filterText || filterText.length < minimumInputLength) {
return { items: [] };
}
url.searchParams.set(param, filterText);
try {
const response = await fetch(url.toString(), {
headers: { accept: 'application/json' },
signal
});
if (response.ok) {
const json = await response.json();
const result = Item.array().safeParse(json);
if (result.success) {
const items = matchSorter(result.data, filterText, {
keys: ['label']
});
return {
items: limit ? items.slice(0, limit) : items
};
}
}
return { items: [] };
} catch {
return { items: [] };
}
};
export function useLabelledBy(id?: string, ariaLabelledby?: string) {
return useMemo(
() => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)),
[id, ariaLabelledby]
);
}
function findLabelledbyId(id?: string) {
if (!id) {
return;
}
const label = document.querySelector(`[for="${id}"]`);
if (!label?.id) {
return;
}
return label.id;
}

View file

@ -0,0 +1,73 @@
import type { ReactNode } from 'react';
import { z } from 'zod';
import type { Loader } from './hooks';
export const Item = z.object({
label: z.string(),
value: z.string(),
data: z.any().optional()
});
export type Item = z.infer<typeof Item>;
const ComboBoxPropsSchema = z
.object({
id: z.string(),
className: z.string(),
name: z.string(),
label: z.string(),
description: z.string(),
isRequired: z.boolean(),
'aria-label': z.string(),
'aria-labelledby': z.string(),
'aria-describedby': z.string(),
items: z
.array(Item)
.or(
z
.string()
.array()
.transform((items) =>
items.map<Item>((label) => ({ label, value: label }))
)
)
.or(
z
.tuple([z.string(), z.string().or(z.number())])
.array()
.transform((items) =>
items.map<Item>(([label, value]) => ({
label,
value: String(value)
}))
)
),
formValue: z.enum(['text', 'key']),
form: z.string(),
data: z.record(z.string())
})
.partial();
export const SingleComboBoxProps = ComboBoxPropsSchema.extend({
selectedKey: z.string().nullable(),
emptyFilterKey: z.string()
}).partial();
export const MultiComboBoxProps = ComboBoxPropsSchema.extend({
selectedKeys: z.string().array(),
allowsCustomValue: z.boolean(),
valueSeparator: z.string()
}).partial();
export const RemoteComboBoxProps = ComboBoxPropsSchema.extend({
selectedKey: z.string().nullable(),
minimumInputLength: z.number(),
limit: z.number(),
allowsCustomValue: z.boolean()
}).partial();
export type SingleComboBoxProps = z.infer<typeof SingleComboBoxProps> & {
children?: ReactNode;
};
export type MultiComboBoxProps = z.infer<typeof MultiComboBoxProps>;
export type RemoteComboBoxProps = z.infer<typeof RemoteComboBoxProps> & {
children?: ReactNode;
loader: Loader | string;
onChange?: (item: Item | null) => void;
};

View file

@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController {
target.isConnected &&
!this.element.contains(target) &&
!target.closest('reach-portal') &&
!target.closest('#rac-portal') &&
this.isOpen
);
}