mirror of
https://github.com/navikt/aksel.git
synced 2026-10-12 16:52:03 +00:00
feat(core): Combobox 2.0 WIP
This commit is contained in:
15 files changed
+1541
-1
No files matched your search
@@ -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 = ({
|
||||
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user