diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 13dadafbae..210b912bd6 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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 = ({ diff --git a/@navikt/core/css/src/form/combobox2.css b/@navikt/core/css/src/form/combobox2.css new file mode 100644 index 0000000000..27af08e5c9 --- /dev/null +++ b/@navikt/core/css/src/form/combobox2.css @@ -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); + } +} diff --git a/@navikt/core/react/src/form/combobox2/Combobox.tsx b/@navikt/core/react/src/form/combobox2/Combobox.tsx new file mode 100644 index 0000000000..dc4a762545 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/Combobox.tsx @@ -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, +> extends Omit, "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, +>({ label, hideLabel, description, ...rest }: ComboboxProps) { + 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 ( + + {label} + {!!description && ( + + {description} + + )} + + + + + + + + + + + ); +} + +export { Combobox }; diff --git a/@navikt/core/react/src/form/combobox2/Combobox2.stories.tsx b/@navikt/core/react/src/form/combobox2/Combobox2.stories.tsx new file mode 100644 index 0000000000..863b924725 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/Combobox2.stories.tsx @@ -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 = { + 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([ + "item-1", + ]); + + return ( + { + setSelectedItems((prev) => + newSelected + ? [...prev, item.value] + : prev.filter((v) => v !== item.value), + ); + }} + > + + + + + + ); +}; + +export const SingleSelect = () => { + const [selectedItem, setSelectedItem] = React.useState(items[0]); + + return ( + + + + + + + ); +}; + +export const Groups = () => { + const [selectedItem, setSelectedItem] = React.useState(items[0]); + + return ( + + + + + + + + + + + + + + + + ); +}; + +export const CustomItemRendering = () => { + const [selectedItems, setSelectedItems] = useState([ + "item-1", + ]); + + return ( + { + setSelectedItems( + isSelected + ? [...selectedItems, item.value] + : selectedItems.filter((v) => v !== item.value), + ); + }} + > + + + > + {(item) => ( + + {item.label} + + {"metadata" in item ? item.metadata : null}{" "} + {item.label.substring(0, 2).toUpperCase()} + + + )} + + + + ); +}; + +export const CustomItemRenderingGroups = () => { + const [selectedItem, setSelectedItem] = React.useState(items[0]); + + return ( + + + + > + {(itemOrGroup) => + "items" in itemOrGroup ? ( + {itemOrGroup.label} + ) : ( + + {itemOrGroup.label} + + {itemOrGroup.metadata}{" "} + {itemOrGroup.label.substring(0, 2).toUpperCase()} + + + ) + } + + + + ); +}; +// 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([ + "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 ( + { + setSelectedItems((prev) => + prev.includes(item.value) + ? prev.filter((v) => v !== item.value) + : [...prev, item.value], + ); + }} + > + + + + + + ); +}; + +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 ( +
+ { + setSelectedItems((prev) => + prev.includes(item.value) + ? prev.filter((v) => v !== item.value) + : [...prev, item.value], + ); + }} + label="Test" + /> +
+ ); +}; +ManyItems.parameters = { + a11y: { disable: true }, + controls: { disable: true }, + docs: { disable: true }, +}; + +export const Trigger = () => { + const [selectedItems, setSelectedItems] = useState([ + "item-1", + ]); + + const [selectedItem, setSelectedItem] = React.useState(items[0]); + + const rootProps = { + items, + selectedItems, + onToggleItem: (item) => { + setSelectedItems((prev) => + prev.includes(item.value) + ? prev.filter((v) => v !== item.value) + : [...prev, item.value], + ); + }, + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Velg land + + + + + + + + + + + + + + +
+ + Velg land + + + + + + + + + + +
+ + + Velg land + + + + + + + + + + +
+ ); +}; +Trigger.parameters = { layout: "padded" }; + +export const TriggerSingleSelect = () => { + const [selectedItem, setSelectedItem] = React.useState(); + + return ( + + + + + Velg land (Combobox root) + + + + + + + + + + + + + + + + ); +}; + +/* 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 +*/ diff --git a/@navikt/core/react/src/form/combobox2/Combobox2.tests.stories.tsx b/@navikt/core/react/src/form/combobox2/Combobox2.tests.stories.tsx new file mode 100644 index 0000000000..5f983ae21f --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/Combobox2.tests.stories.tsx @@ -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 = { + 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 ( +
+
+ Item render count:{" "} + + 0 + +
+ { + setSelectedItems((prev) => + prev.includes(item.value) + ? prev.filter((v) => v !== item.value) + : [...prev, item.value], + ); + }} + > + + + {itemMemoTestRenderFn} + + +
+ ); +}; +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);*/ +}; diff --git a/@navikt/core/react/src/form/combobox2/field/ComboboxField.tsx b/@navikt/core/react/src/form/combobox2/field/ComboboxField.tsx new file mode 100644 index 0000000000..eb0b2724c4 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/field/ComboboxField.tsx @@ -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 { + children?: React.ReactNode; +} + +const ComboboxField = forwardRef( + ({ children, ...rest }, ref) => { + const { items, selectedItems } = useComboboxRootContext(); + + const labels = getLabels(selectedItems, items); + + return ( +
+ + +
+ ); + }, +); + +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 }; diff --git a/@navikt/core/react/src/form/combobox2/filter/ComboboxFilter.tsx b/@navikt/core/react/src/form/combobox2/filter/ComboboxFilter.tsx new file mode 100644 index 0000000000..e0e394e28a --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/filter/ComboboxFilter.tsx @@ -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, + "value" | "onChange" +>; + +export const ComboboxFilter = (props: ComboboxFilterProps) => { + const { size } = useComboboxRootContext(); + const { filterString, setFilterString } = useComboboxPopupContext(); + + return ( +
+ + { + if (["ArrowUp", "ArrowDown"].includes(event.key)) { + event.preventDefault(); // Prevents navigation with screen reader + } + }} + aria-autocomplete="list" + /> + +
+ ); +}; diff --git a/@navikt/core/react/src/form/combobox2/group/ComboboxGroup.tsx b/@navikt/core/react/src/form/combobox2/group/ComboboxGroup.tsx new file mode 100644 index 0000000000..5c95b682d5 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/group/ComboboxGroup.tsx @@ -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, +> { + group: ComboboxGroupData; + childrenProp: ComboboxListProps["children"]; + children: React.ReactNode; +} + +function ComboboxGroup< + T extends ComboboxItemData | ComboboxGroupData, +>({ group, childrenProp, children }: ComboboxGroupProps) { + return ( + + {children} + + ); +} + +export { ComboboxGroup }; diff --git a/@navikt/core/react/src/form/combobox2/label/ComboboxLabel.tsx b/@navikt/core/react/src/form/combobox2/label/ComboboxLabel.tsx new file mode 100644 index 0000000000..97e51e2a30 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/label/ComboboxLabel.tsx @@ -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 { + children: string; +} + +const ComboboxLabel = ({ + children, + className, + ...rest +}: ComboboxLabelProps) => { + const { triggerId } = useComboboxRootContext(); + + return ( + + ); +}; + +export { ComboboxLabel }; diff --git a/@navikt/core/react/src/form/combobox2/list/ComboboxList.tsx b/@navikt/core/react/src/form/combobox2/list/ComboboxList.tsx new file mode 100644 index 0000000000..68cd647ed4 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/list/ComboboxList.tsx @@ -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, +> { + children?: + React.ReactNode | ((itemOrGroup: ResolveItem | 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, +>({ children }: ComboboxListProps) { + /*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 ( + + {/*isPending && ( +
+ Laster... +
+ )*/} + >{children} +
+ ); +} + +function ComboboxListContent< + T extends ComboboxItemData | ComboboxGroupData, +>({ children }: ComboboxListProps) { + 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 ( +
+ Ingen treff +
+ ); + } + + return filteredItems.map((itemOrGroup) => + "items" in itemOrGroup ? ( + + {itemOrGroup.items.map((item) => ( + + {children as ComboboxListProps["children"]} + + ))} + + ) : ( + + {children as ComboboxListProps["children"]} + + ), + ); +} + +function filterItems( + items: (T | ComboboxGroupData)[], + filterString: string, +): (T | ComboboxGroupData)[] { + 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 => item !== null); +} + +export type { ComboboxListProps }; +export { ComboboxList }; diff --git a/@navikt/core/react/src/form/combobox2/option/ComboboxOption.tsx b/@navikt/core/react/src/form/combobox2/option/ComboboxOption.tsx new file mode 100644 index 0000000000..426234b01d --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/option/ComboboxOption.tsx @@ -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 { + item: T; + onToggleItem: (item: T, isSelected: boolean) => void; + isSelected: boolean; + hasVirtualFocus: boolean; + filterString: string; + children?: React.ReactNode | ComboboxListProps["children"]; +} + +function ComboboxItemComponent({ + item, + onToggleItem, + isSelected, + hasVirtualFocus, + filterString, + children, +}: ComboboxItemProps) { + //console.log("Rendering item", item.value); + + return ( + onToggleItem(item, !isSelected)} + aria-selected={isSelected} + hasVirtualFocus={hasVirtualFocus} + > +
+ {isSelected && } +
+
+ {typeof children === "function" + ? children(item) + : (children ?? + (filterString + ? highlightSubstring(item.label, filterString) + : item.label))} +
+
+ ); +} + +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 + + {start} + {highlight && {highlight}} + {end} + + ); +}; + +export const ComboboxItem = React.memo( + ComboboxItemComponent, +) as typeof ComboboxItemComponent; diff --git a/@navikt/core/react/src/form/combobox2/overlay/ComboboxOverlay.tsx b/@navikt/core/react/src/form/combobox2/overlay/ComboboxOverlay.tsx new file mode 100644 index 0000000000..f6d0c224b1 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/overlay/ComboboxOverlay.tsx @@ -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 ( + { + // 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} + > + + {children} + + + ); +}; + +export { ComboboxOverlay }; diff --git a/@navikt/core/react/src/form/combobox2/popup/ComboboxPopup.tsx b/@navikt/core/react/src/form/combobox2/popup/ComboboxPopup.tsx new file mode 100644 index 0000000000..05c3ca8117 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/popup/ComboboxPopup.tsx @@ -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({ + name: "ComboboxPopupContext", + errorMessage: "The component must be used within ", +}); + +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(null); + + useEffect( + function onOpen() { + // Virtually focus and scroll to first (selected) item + const selectedItemElm = ref.current?.querySelector( + '[aria-selected="true"]', + ); + if (selectedItemElm) { + setVirtuallyFocusedItemValue(selectedItemElm.dataset.id || ""); + setTimeout( + () => selectedItemElm.scrollIntoView({ block: "nearest" }), + 0, + ); + } else { + const firstItemElm = + ref.current?.querySelector('[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("input"); + if (inputElm) { + inputElm.focus(); + return; + } + const listElm = + ref.current?.querySelector('[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 ( + + + {children} + + + ); +}; diff --git a/@navikt/core/react/src/form/combobox2/root/ComboboxRoot.tsx b/@navikt/core/react/src/form/combobox2/root/ComboboxRoot.tsx new file mode 100644 index 0000000000..fe8ba76bb3 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/root/ComboboxRoot.tsx @@ -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, +> = T extends ComboboxItemData + ? T + : T extends ComboboxGroupData + ? U + : never; + +export type ComboboxItemData = { + label: string; + value: string; +}; + +export type ComboboxGroupData = { + label: string; + id: string; + items: T[]; +}; + +export interface ComboboxRootProps< + T extends ComboboxItemData | ComboboxGroupData = + ComboboxItemData | ComboboxGroupData, +> { + 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["value"][]; + onToggleItem: ComboboxItemProps>["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)[]; + selectedItems: T["value"][]; + onToggleItem: ComboboxItemProps["onToggleItem"]; +*/ + +export interface ComboboxRootContextProps { + open: boolean; + setOpen: (newOpen: boolean) => void; + triggerRef: React.RefObject; + items: (ComboboxItemData | ComboboxGroupData)[]; // Can't use generics in contexts + selectedItems: ComboboxItemData["value"][]; + onToggleItem: ComboboxItemProps["onToggleItem"]; // Can't use generics in contexts + multiselect: boolean; + triggerId: string; + size: "small" | "medium"; +} + +export const { + Provider: ComboboxRootContextProvider, + useContext: useComboboxRootContext, +} = createStrictContext({ + name: "ComboboxRootContext", + errorMessage: "The component must be used within ", +}); + +export function ComboboxRoot< + T extends ComboboxItemData | ComboboxGroupData, +>({ + children, + items, + selectedItems, + onToggleItem, + defaultOpen = false, + multiselect = true, + triggerId: triggerIdProp, + size = "medium", +}: ComboboxRootProps) { + const triggerRef = useRef(null); + const [open, setOpen] = useState(defaultOpen); + const triggerId = useId(triggerIdProp); + + return ( +
+ + {children} + +
+ ); +} diff --git a/@navikt/core/react/src/form/combobox2/trigger/ComboboxTrigger.tsx b/@navikt/core/react/src/form/combobox2/trigger/ComboboxTrigger.tsx new file mode 100644 index 0000000000..43a8bae755 --- /dev/null +++ b/@navikt/core/react/src/form/combobox2/trigger/ComboboxTrigger.tsx @@ -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 ( + + 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} + + + ); +}; + +export { ComboboxTrigger };