feat(core): Combobox 2.0 WIP

This commit is contained in:
Halvor Haugan committed 2026-08-25 13:37:21 +02:00
1 parent c7848d39fb
commit db3d267fc1
15 files changed
+1541 -1

No files matched your search

+4 -1
View File
@@ -10,6 +10,8 @@ import "../@navikt/core/css/src/data-token-filter.css";
import dataTokenFilterStyles from "../@navikt/core/css/src/data-token-filter.css?inline";
import "../@navikt/core/css/src/data-toolbar.css";
import dataToolbarStyles from "../@navikt/core/css/src/data-toolbar.css?inline";
import "../@navikt/core/css/src/form/combobox2.css";
import combobox2Styles from "../@navikt/core/css/src/form/combobox2.css?inline";
import "../@navikt/core/css/src/index.css";
import dsStyles from "../@navikt/core/css/src/index.css?inline";
import "../@navikt/core/css/src/listbox.css";
@@ -54,8 +56,9 @@ const StoryProviderDecorator = ({
const webComponentStyles = [
dataTokenFilterStyles,
dataToolbarStyles,
dsStyles,
listboxStyles,
combobox2Styles,
dsStyles,
].join("\n");
const WebComponentDecorator = ({
+173
View File
@@ -0,0 +1,173 @@
/************************************************
* Label & description *
************************************************/
.aksel-combobox2__label {
display: flex;
margin-bottom: var(--ax-space-8);
}
/************************************************
* Field *
************************************************/
.aksel-combobox2__field-container {
position: relative;
}
.aksel-combobox2__field-btn {
background-color: var(--ax-bg-input);
border-radius: var(--ax-radius-8);
border: 1px solid var(--ax-border-neutral);
color: var(--ax-text-neutral);
width: 100%;
min-height: 3rem;
display: inline-block;
position: relative;
padding: var(--ax-space-8);
padding-right: var(--ax-space-40);
scroll-margin-block-start: 4rem;
text-align: left;
&:hover {
border-color: var(--ax-border-strong);
cursor: pointer;
}
&:focus {
outline: 3px solid var(--ax-border-focus);
outline-offset: 3px;
border-color: var(--ax-border-strong);
}
@media (forced-colors: active) {
background-color: ButtonFace;
border-color: ButtonText;
color: ButtonText;
}
}
.aksel-combobox2__field-chevron {
position: absolute;
font-size: 1.5rem;
right: var(--ax-space-8);
pointer-events: none;
margin-top: 12px; /* We don't use dynamic centering because we don't want it to move if height changes */
color: var(--ax-text-neutral);
@media (forced-colors: active) {
color: ButtonText;
}
}
/************************************************
* Popup *
************************************************/
.aksel-combobox2__popup {
/* stylelint-disable-next-line csstools/value-no-unknown-custom-properties */
width: max(280px, var(--__axc-floating-anchor-width));
/* stylelint-disable-next-line csstools/value-no-unknown-custom-properties */
max-height: calc(var(--__axc-floating-available-height) - 4px);
background-color: var(--ax-bg-raised);
outline: 1px solid var(--ax-border-neutral-subtleA);
outline-offset: -1px;
padding: 1px;
border-radius: var(--ax-radius-8);
box-shadow: var(--ax-shadow-dialog);
/* This is needed to get overflow:auto to work on child */
display: flex;
flex-direction: column;
overflow: hidden;
}
/************************************************
* Filter input *
************************************************/
.aksel-combobox2__filter {
margin: -1px;
margin-bottom: var(--ax-space-12);
& .aksel-search__wrapper {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&:has(.aksel-search__input:focus-visible) {
outline: none;
position: relative;
&::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 2px solid var(--ax-border-focus);
border-top-left-radius: var(--ax-radius-8);
border-top-right-radius: var(--ax-radius-8);
}
}
}
& .aksel-search__input {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
.aksel-listbox[data-size="medium"] & {
min-height: 0;
}
&:not(:hover) {
border-color: var(--ax-border-neutral-subtleA);
}
}
}
/************************************************
* List *
************************************************/
.aksel-combobox2__list {
overflow: auto;
}
.aksel-combobox2__no-results {
padding: var(--ax-space-12) var(--ax-space-8);
text-align: center;
}
/************************************************
* Item *
************************************************/
.aksel-combobox2__item {
& > div {
display: flex;
gap: var(--ax-space-2);
& > div:last-child {
flex-grow: 1;
}
}
&:is([aria-selected="true"], [aria-selected="true"][data-virtual-focus="true"]) > div {
background-color: var(--ax-bg-moderate-pressedA);
}
}
.aksel-combobox2__item-checkmark {
--__axc-combobox2-item-checkmark-size: 1.25rem;
width: var(--__axc-combobox2-item-checkmark-size);
height: var(--__axc-combobox2-item-checkmark-size);
margin-top: 0.1875rem;
.aksel-combobox2[data-size="small"] & {
--__axc-combobox2-item-checkmark-size: 1rem;
}
& svg {
font-size: var(--__axc-combobox2-item-checkmark-size);
}
}
@@ -0,0 +1,83 @@
import React from "react";
import { BodyShort } from "../../typography";
import { cl } from "../../utils/helpers";
import { useFormField } from "../useFormField";
import { ComboboxField } from "./field/ComboboxField";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxLabel } from "./label/ComboboxLabel";
import { ComboboxList } from "./list/ComboboxList";
import { ComboboxOverlay } from "./overlay/ComboboxOverlay";
import { ComboboxPopup } from "./popup/ComboboxPopup";
import type {
ComboboxGroupData,
ComboboxItemData,
ComboboxRootProps,
} from "./root/ComboboxRoot";
import { ComboboxRoot } from "./root/ComboboxRoot";
import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
interface ComboboxProps<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
> extends Omit<ComboboxRootProps<T>, "children"> {
label: string;
hideLabel?: boolean;
description?: string;
//name?: string; // TODO: rendre hidden input med valgt(e) verdi(er) hvis satt.
}
function Combobox<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
>({ label, hideLabel, description, ...rest }: ComboboxProps<T>) {
const {
//inputProps,
//errorId,
//showErrorMsg,
//hasError,
//size,
inputDescriptionId,
//readOnly,
} = useFormField(
{
description,
//disabled,
//error,
//errorId,
//id: rest.triggerId,
//readOnly,
//size,
},
"combobox",
);
// TODO: hideLabel
// TODO: Kunne være ukontrollert?
return (
<ComboboxRoot {...rest}>
<ComboboxLabel>{label}</ComboboxLabel>
{!!description && (
<BodyShort
className={cl("aksel-form-field__description", {
"aksel-sr-only": hideLabel,
})}
id={inputDescriptionId}
//size={size}
as="div"
>
{description}
</BodyShort>
)}
<ComboboxTrigger>
<ComboboxField aria-describedby={inputDescriptionId} />
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
);
}
export { Combobox };
@@ -0,0 +1,493 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { Button } from "../../button";
import { HStack, VStack } from "../../primitives/stack";
import { BodyShort } from "../../typography";
import { Select } from "../select";
import { Combobox } from "./Combobox";
import { ComboboxField } from "./field/ComboboxField";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxLabel } from "./label/ComboboxLabel";
import { ComboboxList } from "./list/ComboboxList";
import { ComboboxOverlay } from "./overlay/ComboboxOverlay";
import { ComboboxPopup } from "./popup/ComboboxPopup";
import { ComboboxRoot } from "./root/ComboboxRoot";
import { ComboboxTrigger } from "./trigger/ComboboxTrigger";
const meta: Meta<typeof ComboboxRoot> = {
title: "ds-react/Combobox2",
component: ComboboxRoot,
parameters: {
chromatic: { disable: true },
},
};
export default meta;
type MyItem = {
label: string;
value: `item-${number}`;
metadata?: string;
};
const items: MyItem[] = [
{ label: "Norway", value: "item-1", metadata: "foo1" },
{ label: "Finland", value: "item-2" },
{ label: "Sweden", value: "item-3", metadata: "foo3" },
{ label: "Denmark", value: "item-4" },
{ label: "Iceland", value: "item-5" },
{ label: "Faroe Islands", value: "item-6" },
{ label: "Åland Islands", value: "item-7" },
{ label: "Estonia", value: "item-8" },
{ label: "Latvia", value: "item-9" },
{ label: "Lithuania", value: "item-10" },
];
type MyGroup = {
label: string;
id: `group-${number}`;
items: MyItem[];
};
const groupedItems: (MyGroup | MyItem)[] = [
{
label: "Nordic countries",
id: "group-1",
items: items.slice(0, 6),
},
{
label: "Baltic countries",
id: "group-2",
items: items.slice(6),
},
{ label: "Singel item", value: "item-01" } satisfies MyItem,
];
export const Default = () => {
const [selectedItems, setSelectedItems] = React.useState<MyItem["value"][]>([
"item-1",
]);
return (
<ComboboxRoot
defaultOpen
items={items}
selectedItems={selectedItems}
onToggleItem={(item, newSelected) => {
setSelectedItems((prev) =>
newSelected
? [...prev, item.value]
: prev.filter((v) => v !== item.value),
);
}}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxRoot>
);
};
export const SingleSelect = () => {
const [selectedItem, setSelectedItem] = React.useState<MyItem>(items[0]);
return (
<ComboboxRoot
defaultOpen
items={items}
selectedItems={[selectedItem.value]}
onToggleItem={setSelectedItem}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxRoot>
);
};
export const Groups = () => {
const [selectedItem, setSelectedItem] = React.useState<MyItem>(items[0]);
return (
<HStack gap="space-12">
<ComboboxRoot
defaultOpen
items={groupedItems}
selectedItems={[selectedItem.value]}
onToggleItem={setSelectedItem}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxRoot>
<ComboboxRoot
size="small"
defaultOpen
items={groupedItems}
selectedItems={[selectedItem.value]}
onToggleItem={setSelectedItem}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxRoot>
</HStack>
);
};
export const CustomItemRendering = () => {
const [selectedItems, setSelectedItems] = useState<MyItem["value"][]>([
"item-1",
]);
return (
<ComboboxRoot
defaultOpen
items={items}
selectedItems={selectedItems}
onToggleItem={(item, isSelected) => {
setSelectedItems(
isSelected
? [...selectedItems, item.value]
: selectedItems.filter((v) => v !== item.value),
);
}}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList<MyItem>>
{(item) => (
<HStack justify="space-between" align="center">
<span>{item.label}</span>
<BodyShort textColor="subtle" size="small">
{"metadata" in item ? item.metadata : null}{" "}
{item.label.substring(0, 2).toUpperCase()}
</BodyShort>
</HStack>
)}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
);
};
export const CustomItemRenderingGroups = () => {
const [selectedItem, setSelectedItem] = React.useState<MyItem>(items[0]);
return (
<ComboboxRoot
defaultOpen
items={groupedItems}
selectedItems={[selectedItem.value]}
onToggleItem={setSelectedItem}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList<MyItem | MyGroup>>
{(itemOrGroup) =>
"items" in itemOrGroup ? (
<em>{itemOrGroup.label}</em>
) : (
<HStack justify="space-between" align="center">
<span>{itemOrGroup.label}</span>
<BodyShort textColor="subtle" size="small">
{itemOrGroup.metadata}{" "}
{itemOrGroup.label.substring(0, 2).toUpperCase()}
</BodyShort>
</HStack>
)
}
</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
);
};
// Alternativer: Kun støtte item, måtte iterere selv, renderItem/group-props på root, flytte props ned (hvordan få tak i valgte i Input?) 🤔
export const ControlledInput = () => {
const [selectedItems, setSelectedItems] = useState<MyItem["value"][]>([
"item-1",
]);
const [filterString, setFilterString] = useState("");
const filterStringLowerCase = filterString.toLocaleLowerCase();
const filteredItems = filterString
? items
.filter((item) =>
item.label.toLocaleLowerCase().includes(filterStringLowerCase),
)
.sort((a, b) => {
const labelA = a.label.toLocaleLowerCase();
const labelB = b.label.toLocaleLowerCase();
return labelA.startsWith(filterStringLowerCase) ===
labelB.startsWith(filterStringLowerCase)
? 0
: labelA.startsWith(filterStringLowerCase)
? -1
: 1;
})
: items;
return (
<ComboboxRoot
defaultOpen
items={filteredItems}
selectedItems={selectedItems}
onToggleItem={(item) => {
setSelectedItems((prev) =>
prev.includes(item.value)
? prev.filter((v) => v !== item.value)
: [...prev, item.value],
);
}}
>
<ComboboxPopup>
<ComboboxFilter value={filterString} onChange={setFilterString} />
<ComboboxList />
</ComboboxPopup>
</ComboboxRoot>
);
};
const manyItems = Array.from({ length: 10_000 }, (_, i) => ({
label: `Item ${String(i + 1).padStart(4, "0")}`,
value: `item-${i + 1}`,
}));
export const ManyItems = () => {
const [selectedItems, setSelectedItems] = React.useState(["item-1"]);
return (
<div style={{ minHeight: "400px", width: "250px" }}>
<Combobox
items={manyItems}
selectedItems={selectedItems}
onToggleItem={(item) => {
setSelectedItems((prev) =>
prev.includes(item.value)
? prev.filter((v) => v !== item.value)
: [...prev, item.value],
);
}}
label="Test"
/>
</div>
);
};
ManyItems.parameters = {
a11y: { disable: true },
controls: { disable: true },
docs: { disable: true },
};
export const Trigger = () => {
const [selectedItems, setSelectedItems] = useState<MyItem["value"][]>([
"item-1",
]);
const [selectedItem, setSelectedItem] = React.useState<MyItem>(items[0]);
const rootProps = {
items,
selectedItems,
onToggleItem: (item) => {
setSelectedItems((prev) =>
prev.includes(item.value)
? prev.filter((v) => v !== item.value)
: [...prev, item.value],
);
},
};
return (
<HStack gap="space-16" align="start">
<ComboboxRoot {...rootProps}>
<ComboboxTrigger>
<Button>Med input</Button>
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
<ComboboxRoot {...rootProps}>
<ComboboxTrigger>
<Button>Uten input</Button>
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
<ComboboxRoot
items={items}
selectedItems={[selectedItem.value]}
onToggleItem={setSelectedItem}
multiselect={false}
>
<ComboboxTrigger>
<Button>Single select</Button>
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
<VStack>
<ComboboxRoot {...rootProps}>
<ComboboxLabel>Velg land</ComboboxLabel>
<ComboboxTrigger>
<Button>Med input og label</Button>
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
</VStack>
<Select label="Vanlig select">
<option value="">Velg land</option>
<option value="norge">Norge</option>
<option value="sverige">Sverige</option>
<option value="danmark">Danmark</option>
</Select>
<div style={{ width: "300px" }}>
<ComboboxRoot
items={items}
selectedItems={[selectedItem.value]}
onToggleItem={setSelectedItem}
multiselect={false}
>
<ComboboxLabel>Velg land</ComboboxLabel>
<ComboboxTrigger>
<ComboboxField />
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
</div>
<ComboboxRoot {...rootProps}>
<ComboboxLabel>Velg land</ComboboxLabel>
<ComboboxTrigger>
<ComboboxField />
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
</HStack>
);
};
Trigger.parameters = { layout: "padded" };
export const TriggerSingleSelect = () => {
const [selectedItem, setSelectedItem] = React.useState<MyItem>();
return (
<VStack gap="space-32" width="300px">
<button type="button" onClick={() => console.log("Knapp før")}>
Knapp før
</button>
<ComboboxRoot
items={items}
selectedItems={selectedItem ? [selectedItem.value] : []}
onToggleItem={setSelectedItem}
multiselect={false}
>
<ComboboxLabel>Velg land (Combobox root)</ComboboxLabel>
<ComboboxTrigger>
<ComboboxField />
</ComboboxTrigger>
<ComboboxOverlay>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
<Combobox
items={items}
selectedItems={selectedItem ? [selectedItem.value] : []}
onToggleItem={setSelectedItem}
multiselect={false}
label="Velg land (Combobox)"
description="Beskrivelse"
/>
<Select
label="Velg land (vanlig select)"
description="Dette er en vanlig select."
//error="Test"
>
<option value=""></option>
<option value="norway">Norway</option>
<option value="finland">Finland</option>
<option value="sweden">Sweden</option>
<option value="denmark">Denmark</option>
<option value="iceland">Iceland</option>
<option value="faroe-islands">Faroe Islands</option>
<option value="aland-islands">Åland Islands</option>
<option value="estonia">Estonia</option>
<option value="latvia">Latvia</option>
<option value="lithuania">Lithuania</option>
</Select>
</VStack>
);
};
/* TODO:
- Fullskjerm på mobil
- Følge Combobox-pattern (mer)? Kan ikke følge det slavisk uansett.
Pil opp og ned velger
Ikke loop
- Skal den hete noe annet enn Combobox? Er jo på en måte ikke det...
- Vurder om fokus skal låses til søkefelt (mest aktuelt ved multiselect).
- Åpne på pil ned?
- PageUp/Down
- Error, disabled (?), readonly osv.
- Vurder hvilke funksjoner i gamle CB vi skal ta med (maks valg, legg til osv.)
- Tester
- A11y-sjekk
- Beslutningsloggen?
Utfordringer med komposisjon:
- Vanskelig å bruke
- Utfordrende for oss å endre (ref FormSummary)
- Context er litt magisk/uoversiktlig
- Description: Hvordan skal root/trigger vite at aria-describedby skal settes?
Forslag: Tilby enkeltkomponent for de vanligste tilfellene, men også subkomponentene for fleksibilitet.
Kan ev. ha slot/render-props for enkelte ting.
Kan ev. bruke children for å kunne bytte ut/skreddersy innholdet i popup.
Ressurser:
- https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/combobox_role
- https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/listbox_role
*/
@@ -0,0 +1,107 @@
import type { Meta } from "@storybook/react-vite";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { ComboboxFilter } from "./filter/ComboboxFilter";
import { ComboboxList } from "./list/ComboboxList";
import { ComboboxPopup } from "./popup/ComboboxPopup";
import { ComboboxRoot } from "./root/ComboboxRoot";
const meta: Meta<typeof ComboboxRoot> = {
title: "ds-react/Combobox2/Tests",
component: ComboboxRoot,
parameters: {
chromatic: { disable: true },
},
};
export default meta;
const items = [
{ label: "Norway", value: "item-1" },
{ label: "Finland", value: "item-2" },
{ label: "Sweden", value: "item-3" },
{ label: "Denmark", value: "item-4" },
{ label: "Iceland", value: "item-5" },
{ label: "Faroe Islands", value: "item-6" },
{ label: "Åland Islands", value: "item-7" },
{ label: "Estonia", value: "item-8" },
{ label: "Latvia", value: "item-9" },
{ label: "Lithuania", value: "item-10" },
];
const groupedItems = [
{
label: "Nordic countries",
id: "group-1",
items: items.slice(0, 6),
},
{
label: "Baltic countries",
id: "group-2",
items: items.slice(6),
},
{ label: "Singel item", value: "item-01" },
];
let itemMemoTestRenderCnt = 0;
const itemMemoTestRenderFn = (itemOrGroup: (typeof groupedItems)[number]) => {
if ("items" in itemOrGroup) return itemOrGroup.label;
itemMemoTestRenderCnt++;
const renderCountEl = document.getElementById("render-count");
if (renderCountEl) renderCountEl.textContent = String(itemMemoTestRenderCnt);
return itemOrGroup.label;
};
export const ItemMemoization = () => {
const [selectedItems, setSelectedItems] = useState(["item-1"]);
return (
<div style={{ minHeight: "450px" }}>
<div>
Item render count:{" "}
<span id="render-count" data-testid="count">
0
</span>
</div>
<ComboboxRoot
defaultOpen
items={groupedItems}
selectedItems={selectedItems}
onToggleItem={(item) => {
setSelectedItems((prev) =>
prev.includes(item.value)
? prev.filter((v) => v !== item.value)
: [...prev, item.value],
);
}}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList>{itemMemoTestRenderFn}</ComboboxList>
</ComboboxPopup>
</ComboboxRoot>
</div>
);
};
ItemMemoization.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const combobox = canvas.getByRole("combobox");
const countElm = canvas.getByTestId("count"); // Number of times an item has been rendered
const startCount = Number(countElm.textContent);
expect(startCount).toBeGreaterThan(3); // Minimum items needed for the test to be meaningful
// Moving virtual focus should re-render old and new option
await userEvent.click(combobox);
await userEvent.keyboard("{ArrowDown}");
let expectedCount = startCount + 2;
expect(Number(countElm.textContent)).toBe(expectedCount);
// Seleting an option should re-render only that option
await userEvent.keyboard("{Enter}");
expectedCount++;
expect(Number(countElm.textContent)).toBe(expectedCount);
// Filtering should not re-render any items
// TODO: For this to work we must omit the filterString prop on ComboboxItem
/*await userEvent.type(combobox, "nor", { delay: 200 });
expect(Number(countElm.textContent)).toBe(expectedCount);
expect(canvas.getAllByRole("option").length).toBe(1);*/
};
@@ -0,0 +1,63 @@
import React, { forwardRef } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import {
type ComboboxRootProps,
useComboboxRootContext,
} from "../root/ComboboxRoot";
interface ComboboxFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: React.ReactNode;
}
const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
({ children, ...rest }, ref) => {
const { items, selectedItems } = useComboboxRootContext();
const labels = getLabels(selectedItems, items);
return (
<div className="aksel-combobox2__field-container">
<button
type="button"
ref={ref}
className="aksel-combobox2__field-btn"
{...rest}
>
{children ||
(labels.length > 1 ? `${labels.length} valgt` : labels[0])}
</button>
<ChevronDownIcon
className="aksel-combobox2__field-chevron"
aria-hidden
/>
</div>
);
},
);
const getLabels = (
selectedItems: ComboboxRootProps["selectedItems"],
items: ComboboxRootProps["items"],
) => {
const labels: string[] = [];
for (const itemOrGroup of items) {
if ("items" in itemOrGroup) {
// It's a group
for (const item of itemOrGroup.items) {
if (selectedItems.includes(item.value)) {
labels.push(item.label);
}
}
} else {
// It's a single item
if (selectedItems.includes(itemOrGroup.value)) {
labels.push(itemOrGroup.label);
}
}
}
return labels;
};
export { ComboboxField };
@@ -0,0 +1,37 @@
import React from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { Search } from "../../search";
import { useComboboxPopupContext } from "../popup/ComboboxPopup";
import { useComboboxRootContext } from "../root/ComboboxRoot";
type ComboboxFilterProps = Pick<
React.ComponentProps<typeof Search>,
"value" | "onChange"
>;
export const ComboboxFilter = (props: ComboboxFilterProps) => {
const { size } = useComboboxRootContext();
const { filterString, setFilterString } = useComboboxPopupContext();
return (
<div className="aksel-combobox2__filter">
<Listbox.InputSlot>
<Search
value={filterString}
onChange={setFilterString}
size={size}
{...props}
label="Filtrer"
hideLabel
variant="simple"
onKeyUp={(event) => {
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
event.preventDefault(); // Prevents navigation with screen reader
}
}}
aria-autocomplete="list"
/>
</Listbox.InputSlot>
</div>
);
};
@@ -0,0 +1,30 @@
import React from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import type { ComboboxListProps } from "../list/ComboboxList";
import type { ComboboxGroupData, ComboboxItemData } from "../root/ComboboxRoot";
interface ComboboxGroupProps<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
> {
group: ComboboxGroupData<ComboboxItemData>;
childrenProp: ComboboxListProps<T>["children"];
children: React.ReactNode;
}
function ComboboxGroup<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
>({ group, childrenProp, children }: ComboboxGroupProps<T>) {
return (
<Listbox.Group
label={
typeof childrenProp === "function"
? childrenProp(group as T)
: group.label
}
>
{children}
</Listbox.Group>
);
}
export { ComboboxGroup };
@@ -0,0 +1,31 @@
import React from "react";
import { Label } from "../../../typography";
import { cl } from "../../../utils/helpers";
import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
children: string;
}
const ComboboxLabel = ({
children,
className,
...rest
}: ComboboxLabelProps) => {
const { triggerId } = useComboboxRootContext();
return (
<Label
htmlFor={triggerId}
className={cl(
"aksel-form-field__label aksel-combobox2__label",
className,
)}
{...rest}
>
{children}
</Label>
);
};
export { ComboboxLabel };
@@ -0,0 +1,152 @@
import React, { useCallback } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { useDeferredValue, useEventCallback } from "../../../utils/hooks";
import { ComboboxGroup } from "../group/ComboboxGroup";
import { ComboboxItem } from "../option/ComboboxOption";
import { useComboboxPopupContext } from "../popup/ComboboxPopup";
import {
type ComboboxGroupData,
type ComboboxItemData,
type ResolveItem,
useComboboxRootContext,
} from "../root/ComboboxRoot";
interface ComboboxListProps<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
> {
children?:
React.ReactNode | ((itemOrGroup: ResolveItem<T> | T) => React.ReactNode); // TODO: Blir litt rart at children-funksjonen må håndtere både items og grupper. (Usikker på om typen for itemOrGroup er riktig også.)
}
function ComboboxList<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
>({ children }: ComboboxListProps<T>) {
/*const [isPending, startTransition] = useTransition();
const [renderItems, setRenderItems] = useState(true); // TODO: Prop for å aktivere "async rendering", ev. basert på antall options.
useEffect(() => {
if (renderItems) {
return;
}
startTransition(() => {
setRenderItems(true);
});
}, []);*/
return (
<Listbox.Options className="aksel-combobox2__list">
{/*isPending && (
<div className="aksel-combobox2__loading" role="status">
Laster...
</div>
)*/}
<ComboboxListContent<T>>{children}</ComboboxListContent>
</Listbox.Options>
);
}
function ComboboxListContent<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
>({ children }: ComboboxListProps<T>) {
const { virtuallyFocusedItemValue, filterString } = useComboboxPopupContext();
const {
setOpen,
items,
selectedItems,
onToggleItem,
multiselect,
triggerRef,
} = useComboboxRootContext();
const memoizedOnToggleItem = useEventCallback(onToggleItem);
const deferredFilterString = useDeferredValue(filterString);
const localOnToggleItem = useCallback(
(item: ComboboxItemData, isSelected: boolean) => {
memoizedOnToggleItem(item, isSelected);
if (!multiselect) {
setOpen(false);
triggerRef.current?.focus();
}
},
[memoizedOnToggleItem, multiselect, triggerRef, setOpen],
);
if (children && typeof children !== "function") {
return children;
}
const filteredItems = deferredFilterString
? filterItems(items, deferredFilterString)
: items;
if (filteredItems.length === 0) {
return (
<div className="aksel-combobox2__no-results" role="status">
Ingen treff
</div>
);
}
return filteredItems.map((itemOrGroup) =>
"items" in itemOrGroup ? (
<ComboboxGroup
key={itemOrGroup.id}
group={itemOrGroup}
childrenProp={children}
>
{itemOrGroup.items.map((item) => (
<ComboboxItem
key={item.value}
item={item}
onToggleItem={localOnToggleItem}
isSelected={selectedItems.includes(item.value)}
hasVirtualFocus={virtuallyFocusedItemValue === item.value}
filterString={deferredFilterString}
>
{children as ComboboxListProps<typeof item>["children"]}
</ComboboxItem>
))}
</ComboboxGroup>
) : (
<ComboboxItem
key={itemOrGroup.value}
item={itemOrGroup}
onToggleItem={localOnToggleItem}
isSelected={selectedItems.includes(itemOrGroup.value)}
hasVirtualFocus={virtuallyFocusedItemValue === itemOrGroup.value}
filterString={deferredFilterString} // TODO: Vurder å kunne slå av dette for bedre ytelse
>
{children as ComboboxListProps<typeof itemOrGroup>["children"]}
</ComboboxItem>
),
);
}
function filterItems<T extends ComboboxItemData>(
items: (T | ComboboxGroupData<T>)[],
filterString: string,
): (T | ComboboxGroupData<T>)[] {
const filterStringLowerCase = filterString.toLocaleLowerCase();
return items
.map((itemOrGroup) => {
if ("items" in itemOrGroup) {
const matchingItems = itemOrGroup.items.filter((item) =>
item.label.toLocaleLowerCase().includes(filterStringLowerCase),
);
if (matchingItems.length > 0) {
return { ...itemOrGroup, items: matchingItems };
}
return null;
}
if (
itemOrGroup.label.toLocaleLowerCase().includes(filterStringLowerCase)
) {
return itemOrGroup;
}
return null;
})
.filter((item): item is T | ComboboxGroupData<T> => item !== null);
}
export type { ComboboxListProps };
export { ComboboxList };
@@ -0,0 +1,76 @@
import React from "react";
import { CheckmarkHeavyIcon } from "@navikt/aksel-icons";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import type { ComboboxListProps } from "../list/ComboboxList";
import type { ComboboxItemData } from "../root/ComboboxRoot";
export interface ComboboxItemProps<T extends ComboboxItemData> {
item: T;
onToggleItem: (item: T, isSelected: boolean) => void;
isSelected: boolean;
hasVirtualFocus: boolean;
filterString: string;
children?: React.ReactNode | ComboboxListProps<T>["children"];
}
function ComboboxItemComponent<T extends ComboboxItemData>({
item,
onToggleItem,
isSelected,
hasVirtualFocus,
filterString,
children,
}: ComboboxItemProps<T>) {
//console.log("Rendering item", item.value);
return (
<Listbox.Option
className="aksel-combobox2__item"
id={item.value}
onClick={() => onToggleItem(item, !isSelected)}
aria-selected={isSelected}
hasVirtualFocus={hasVirtualFocus}
>
<div className="aksel-combobox2__item-checkmark" aria-hidden>
{isSelected && <CheckmarkHeavyIcon aria-hidden />}
</div>
<div>
{typeof children === "function"
? children(item)
: (children ??
(filterString
? highlightSubstring(item.label, filterString)
: item.label))}
</div>
</Listbox.Option>
);
}
const highlightSubstring = (text: string, substring: string) => {
const indexOfHighlightedText = text
.toLocaleLowerCase()
.indexOf(substring.toLocaleLowerCase());
if (indexOfHighlightedText === -1) {
// This can happen if the consumer has implemented their own filtering logic
return text;
}
const start = text.substring(0, indexOfHighlightedText);
const highlight = text.substring(
indexOfHighlightedText,
indexOfHighlightedText + substring.length,
);
const end = text.substring(indexOfHighlightedText + substring.length);
return (
// Aria-label is used to fix testing-library wrongly evaluating the accessible name of the option when highlighting text
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: Doesn't matter if aria-label doesn't work
<span aria-label={text}>
{start}
{highlight && <mark>{highlight}</mark>}
{end}
</span>
);
};
export const ComboboxItem = React.memo(
ComboboxItemComponent,
) as typeof ComboboxItemComponent;
@@ -0,0 +1,54 @@
import React from "react";
import { DismissableLayer } from "../../../utils/components/dismissablelayer/DismissableLayer";
import {
Floating,
MENU_COLLISION_AVOIDANCE,
} from "../../../utils/components/floating/Floating";
import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxOverlayProps {
children: React.ReactNode;
}
const ComboboxOverlay = ({ children }: ComboboxOverlayProps) => {
const rootContext = useComboboxRootContext();
if (!rootContext.open) {
return null;
}
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),
// we avoid closing here if the click is on the trigger. The trigger will handle closing in that case.
if (
event.type === "AKSEL_POINTER_DOWN_OUTSIDE" &&
event.target instanceof HTMLElement &&
rootContext.triggerRef.current?.contains(event.target)
) {
return;
}
rootContext.setOpen(false);
rootContext.triggerRef.current?.focus();
}}
enabled={rootContext.open}
>
<Floating.Content
sideOffset={4}
side="bottom"
fallbackPlacements={["top"]}
fallbackAxisSideDirection={MENU_COLLISION_AVOIDANCE.fallbackAxisSide}
align="start"
enabled={rootContext.open}
>
{children}
</Floating.Content>
</DismissableLayer>
);
};
export { ComboboxOverlay };
@@ -0,0 +1,88 @@
import React, { useEffect, useRef, useState } from "react";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import { createStrictContext } from "../../../utils/helpers";
import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxPopupContextProps {
virtuallyFocusedItemValue: string;
filterString: string;
setFilterString: (value: string) => void;
}
export const {
Provider: ComboboxPopupContextProvider,
useContext: useComboboxPopupContext,
} = createStrictContext<ComboboxPopupContextProps>({
name: "ComboboxPopupContext",
errorMessage: "The component must be used within <Combobox.Popup>",
});
export interface ComboboxPopupProps {
children: React.ReactNode;
}
export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const [virtuallyFocusedItemValue, setVirtuallyFocusedItemValue] =
useState("");
const [filterString, setFilterString] = useState("");
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
useEffect(
function onOpen() {
// Virtually focus and scroll to first (selected) item
const selectedItemElm = ref.current?.querySelector<HTMLElement>(
'[aria-selected="true"]',
);
if (selectedItemElm) {
setVirtuallyFocusedItemValue(selectedItemElm.dataset.id || "");
setTimeout(
() => selectedItemElm.scrollIntoView({ block: "nearest" }),
0,
);
} else {
const firstItemElm =
ref.current?.querySelector<HTMLElement>('[role="option"]');
if (firstItemElm) {
setVirtuallyFocusedItemValue(firstItemElm.dataset.id || "");
}
}
// Autofocus
if (!rootContext.triggerRef.current) {
return; // Assume we don't need to autofocus if there's no trigger
}
const inputElm = ref.current?.querySelector<HTMLInputElement>("input");
if (inputElm) {
inputElm.focus();
return;
}
const listElm =
ref.current?.querySelector<HTMLElement>('[role="listbox"]');
if (listElm) {
listElm.focus();
// TODO: For at ting skal funke som det skal med skjermleser, må vi kanskje beholde fokus på knappen.
}
},
[rootContext.triggerRef],
);
return (
<Listbox
ref={ref}
className="aksel-combobox2__popup"
setVirtuallyFocusedOptionId={setVirtuallyFocusedItemValue}
size={rootContext.size}
>
<ComboboxPopupContextProvider
virtuallyFocusedItemValue={virtuallyFocusedItemValue}
filterString={filterString}
setFilterString={setFilterString}
>
{children}
</ComboboxPopupContextProvider>
</Listbox>
);
};
@@ -0,0 +1,110 @@
import React, { useRef, useState } from "react";
import { useId } from "../../../utils-external";
import { Floating } from "../../../utils/components/floating/Floating";
import { createStrictContext } from "../../../utils/helpers";
import type { ComboboxItemProps } from "../option/ComboboxOption";
/** Resolves the item type based on whether the parameter is a group or a single item */
export type ResolveItem<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
> = T extends ComboboxItemData
? T
: T extends ComboboxGroupData<infer U>
? U
: never;
export type ComboboxItemData = {
label: string;
value: string;
};
export type ComboboxGroupData<T extends ComboboxItemData> = {
label: string;
id: string;
items: T[];
};
export interface ComboboxRootProps<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData> =
ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
> {
children: React.ReactNode;
/**
* Can be either an array of items ({@link ComboboxItemData})
* or an array of groups ({@link ComboboxGroupData}) containing items.
*
* NB: It is not recommended to mix single items and groups, but if you do,
* put the single items first.
*/
items: T[];
selectedItems: ResolveItem<T>["value"][];
onToggleItem: ComboboxItemProps<ResolveItem<T>>["onToggleItem"]; // TODO: Vurder å tilby onChange som returnerer valgte verdier
defaultOpen?: boolean;
multiselect?: boolean; // TODO: Vurder annet navn
triggerId?: string;
/**
* @default "medium"
*/
size?: "small" | "medium";
}
/* Alternativt:
items: (T | ComboboxGroupData<T>)[];
selectedItems: T["value"][];
onToggleItem: ComboboxItemProps<T>["onToggleItem"];
*/
export interface ComboboxRootContextProps {
open: boolean;
setOpen: (newOpen: boolean) => void;
triggerRef: React.RefObject<HTMLDivElement | null>;
items: (ComboboxItemData | ComboboxGroupData<ComboboxItemData>)[]; // Can't use generics in contexts
selectedItems: ComboboxItemData["value"][];
onToggleItem: ComboboxItemProps<ComboboxItemData>["onToggleItem"]; // Can't use generics in contexts
multiselect: boolean;
triggerId: string;
size: "small" | "medium";
}
export const {
Provider: ComboboxRootContextProvider,
useContext: useComboboxRootContext,
} = createStrictContext<ComboboxRootContextProps>({
name: "ComboboxRootContext",
errorMessage: "The component must be used within <Combobox.Root>",
});
export function ComboboxRoot<
T extends ComboboxItemData | ComboboxGroupData<ComboboxItemData>,
>({
children,
items,
selectedItems,
onToggleItem,
defaultOpen = false,
multiselect = true,
triggerId: triggerIdProp,
size = "medium",
}: ComboboxRootProps<T>) {
const triggerRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(defaultOpen);
const triggerId = useId(triggerIdProp);
return (
<div className="aksel-combobox2" data-size={size}>
<ComboboxRootContextProvider
open={open}
setOpen={setOpen}
triggerRef={triggerRef}
items={items}
selectedItems={selectedItems}
onToggleItem={onToggleItem as ComboboxRootContextProps["onToggleItem"]}
multiselect={multiselect}
triggerId={triggerId}
size={size}
>
<Floating>{children}</Floating>
</ComboboxRootContextProvider>
</div>
);
}
@@ -0,0 +1,40 @@
import React from "react";
import { Floating } from "../../../utils/components/floating/Floating";
import { Slot } from "../../../utils/components/slot/Slot";
import { useComboboxRootContext } from "../root/ComboboxRoot";
interface ComboboxTriggerProps {
children: React.ReactNode;
}
const ComboboxTrigger = ({ children }: ComboboxTriggerProps) => {
const rootContext = useComboboxRootContext();
return (
<Floating.Anchor asChild ref={rootContext.triggerRef}>
<Slot
role="combobox"
//aria-haspopup="listbox" // Dette er implicit med role combobox. TODO: Vurder om hele popupen bør ha role listbox, ev. dialog.
aria-expanded={rootContext.open}
//aria-controls={context.open ? context.contentId : undefined} // Del av Combobox Pattern, men vet ikke om det er hensiktsmessig.
onClick={() => rootContext.setOpen(!rootContext.open)}
onMouseDown={(event) => {
// Prevents "flash of focus" on trigger before focus is moved to input
if (!rootContext.open) event.preventDefault();
}}
/*onKeyDown={composeEventHandlers(onKeyDown, (event) => {
if (event.key === "ArrowDown") {
context.onOpenChange(true);
// Stop keydown from scrolling window
event.preventDefault();
}
})}*/
id={rootContext.triggerId}
>
{children}
</Slot>
</Floating.Anchor>
);
};
export { ComboboxTrigger };