Compare commits

..
1 Commits
Author SHA1 Message Date
Halvor Haugan 2c1a3378cb Combobox2: Misc. improvements 2026-11-06 19:06:57 +01:00
9 changed files with 61 additions and 73 deletions

No files matched your search

@@ -272,6 +272,7 @@ export const Composition = () => {
: [...prev, option.value],
);
},
multiple: true,
};
return (
@@ -126,6 +126,7 @@ export const OptionMemoization = () => {
</div>
<ComboboxRoot
defaultOpen
multiple
options={groupedOptions}
value={value}
onToggleOption={(option) => {
@@ -38,7 +38,7 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
}}
onKeyDown={(event) => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
setVirtuallyFocusedOptionValue("");
setVirtuallyFocusedOptionValue(""); // Assume you want to edit the input, so remove virtual focus
}
}}
aria-autocomplete="list"
@@ -1,4 +1,4 @@
import React, { useCallback } from "react";
import React from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { useDeferredValue, useEventCallback } from "../../../utils/hooks";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
@@ -51,24 +51,16 @@ function ComboboxListContent<
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedOptionValue, filterString } =
useComboboxPopupContext();
const { setOpen, options, value, onToggleOption, multiple, triggerRef } =
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const {
options,
value, // Selected options
onToggleOption: originalOnToggleOption,
} = useComboboxRootContext();
const onToggleOption = useEventCallback(originalOnToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString);
const translate = useI18n("Combobox2");
const localOnToggleOption = useCallback(
(option: ComboboxOptionData, isSelected: boolean) => {
memoizedOnToggleOption(option, isSelected);
if (!multiple) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleOption, multiple, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
return children;
}
@@ -97,7 +89,7 @@ function ComboboxListContent<
<ComboboxOption
key={option.value}
option={option}
onToggleOption={localOnToggleOption}
onToggleOption={onToggleOption}
isSelected={value.includes(option.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === option.value}
highlightText={deferredFilterString}
@@ -110,7 +102,7 @@ function ComboboxListContent<
<ComboboxOption
key={optOrGroup.value}
option={optOrGroup}
onToggleOption={localOnToggleOption}
onToggleOption={onToggleOption}
isSelected={value.includes(optOrGroup.value)}
hasVirtualFocus={virtuallyFocusedOptionValue === optOrGroup.value}
highlightText={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
@@ -126,26 +118,27 @@ function filterOptions<T extends ComboboxOptionData>(
filterString: string,
): (T | ComboboxGroupData<T>)[] {
const filterStringLowerCase = filterString.toLocaleLowerCase();
const results: (T | ComboboxGroupData<T>)[] = [];
return options
.map((optOrGroup) => {
if ("options" in optOrGroup) {
const matchingOptions = optOrGroup.options.filter((option) =>
option.label.toLocaleLowerCase().includes(filterStringLowerCase),
);
if (matchingOptions.length > 0) {
return { ...optOrGroup, options: matchingOptions };
for (const optOrGroup of options) {
if ("options" in optOrGroup) {
const matchingOptions: T[] = [];
for (const option of optOrGroup.options) {
if (option.label.toLocaleLowerCase().includes(filterStringLowerCase)) {
matchingOptions.push(option);
}
return null;
}
if (
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
return optOrGroup;
if (matchingOptions.length > 0) {
results.push({ ...optOrGroup, options: matchingOptions });
}
return null;
})
.filter((option): option is T | ComboboxGroupData<T> => option !== null);
} else if (
optOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
results.push(optOrGroup);
}
}
return results;
}
export type { ComboboxListProps };
@@ -91,7 +91,7 @@ function announce(message: string, politeness: Politeness = "polite") {
}
/**
* Speaks a message through a persistently-mounted, visually-hidden live region.
* Announces a message through a persistently-mounted, visually-hidden live region.
*
* The polite and assertive regions are created once and kept mounted.
*
@@ -19,16 +19,7 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
if (isMobile) {
return (
<ComboboxModal
open={rootContext.open}
onOpenChange={(open) => {
rootContext.setOpen(open);
!open && rootContext.triggerRef.current?.focus(); // This does not work when closing with button
}}
onOpenChangeComplete={(open) =>
!open && rootContext.triggerRef.current?.focus()
}
>
<ComboboxModal open={rootContext.open} onOpenChange={rootContext.setOpen}>
{mobileHeader}
{children}
</ComboboxModal>
@@ -42,7 +33,6 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
return (
<DismissableLayer
asChild
//safeZone={{ anchor: rootContext.triggerRef.current }}
onDismiss={(event) => {
// We don't have trigger as `safeZone` because we want to close when trigger is focused.
// To avoid immediately re-opening the popup when trigger is clicked (blur closes, click opens),
@@ -55,7 +45,6 @@ function ComboboxOverlay({ children, mobileHeader }: ComboboxOverlayProps) {
return;
}
rootContext.setOpen(false);
rootContext.triggerRef.current?.focus();
}}
enabled={rootContext.open}
>
@@ -77,7 +66,6 @@ type ComboboxModalProps = {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
onOpenChangeComplete: (open: boolean) => void;
};
function subscribeToResize(callback: () => void) {
@@ -89,12 +77,7 @@ function getVisualViewportHeight() {
return visualViewport?.height; // Takes into account virtual keyboard etc.
}
function ComboboxModal({
children,
open,
onOpenChange,
onOpenChangeComplete,
}: ComboboxModalProps) {
function ComboboxModal({ children, open, onOpenChange }: ComboboxModalProps) {
const visualViewportHeight = useSyncExternalStore(
subscribeToResize,
getVisualViewportHeight,
@@ -112,12 +95,7 @@ function ComboboxModal({
}, [open]);
return (
<Dialog
size="small"
open={open}
onOpenChange={onOpenChange}
onOpenChangeComplete={onOpenChangeComplete}
>
<Dialog size="small" open={open} onOpenChange={onOpenChange}>
<Dialog.Popup className="aksel-combobox2__modal" position="fullscreen">
<div
className="aksel-combobox2__modal-inner"
@@ -62,6 +62,7 @@ export const Default = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={options}
value={value}
onToggleOption={(option, newSelected) => {
@@ -173,6 +174,7 @@ export const CustomOptionRendering = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={options}
value={value}
onToggleOption={(option, isSelected) => {
@@ -259,6 +261,7 @@ export const ControlledInput = () => {
return (
<ComboboxRoot
defaultOpen
multiple
options={filteredOptions}
value={value}
onToggleOption={(option) => {
@@ -1,4 +1,5 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { useClientLayoutEffect } from "../../../utils-external";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { createStrictContext } from "../../../utils/helpers";
import { useComboboxRootContext } from "../root/ComboboxRoot";
@@ -29,7 +30,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(
useClientLayoutEffect(
function onOpen() {
const canHover = window.matchMedia("(hover: hover)").matches;
@@ -85,7 +86,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
filterString={filterString}
setFilterString={(newString) => {
setFilterString(newString);
setVirtuallyFocusedOptionValue("");
setVirtuallyFocusedOptionValue(""); // Remove virtual focus when filter string changes
}}
>
{children}
@@ -1,4 +1,4 @@
import React, { useRef, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import { Floating } from "../../../utils/components/floating/Floating";
import { cl, createStrictContext } from "../../../utils/helpers";
import type { FormFieldProps } from "../../useFormField";
@@ -74,9 +74,8 @@ export interface ComboboxRootProps<
defaultOpen?: boolean;
/**
* Whether multiple options can be selected.
* @default true
*/
multiple?: boolean; // TODO: Hva skal være default?
multiple?: boolean;
}
export interface ComboboxRootContextProps {
@@ -84,9 +83,10 @@ export interface ComboboxRootContextProps {
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
options: ComboboxRootProps["options"];
/** Values of selected options */
value: ComboboxRootProps["value"];
/** Consumer callback */
onToggleOption: ComboboxRootProps["onToggleOption"];
multiple: boolean;
size: Exclude<ComboboxRootProps["size"], undefined>;
disabled: ComboboxRootProps["disabled"];
}
@@ -107,7 +107,7 @@ export function ComboboxRoot<
value,
onToggleOption,
defaultOpen = false,
multiple = true,
multiple = false,
size = "medium",
disabled,
className,
@@ -125,14 +125,25 @@ export function ComboboxRoot<
>
<ComboboxRootContextProvider
open={open}
setOpen={setOpen}
setOpen={useCallback((newOpen) => {
setOpen(newOpen);
if (!newOpen) {
// queueMicrotask is needed when closing with close button in Dialog
queueMicrotask(() => triggerRef.current?.focus());
}
}, [])}
triggerRef={triggerRef}
options={options}
value={value}
onToggleOption={
onToggleOption as ComboboxRootContextProps["onToggleOption"]
}
multiple={multiple}
onToggleOption={useCallback(
(...args) => {
(onToggleOption as ComboboxRootProps["onToggleOption"])(...args);
if (!multiple) {
setOpen(false);
}
},
[onToggleOption, multiple],
)}
size={size}
disabled={disabled}
>