Compare commits

..
Author SHA1 Message Date
Ken 56718b48c9 perf(Popover): Only calculate position when open 2026-10-30 13:06:35 +01:00
19 changed files with 214 additions and 316 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@navikt/ds-react": patch
---
Popover: Closed popovers no longer calculate their position on mount. This makes pages with many Dropdowns or HelpTexts render faster.
-6
View File
@@ -1,6 +0,0 @@
---
"@navikt/ds-react": patch
"@navikt/aksel-icons": patch
---
ESM: Mark ESM output as side-effect free, so bundlers like webpack, Turbopack and esbuild can tree-shake unused components and icons.
+1 -1
View File
@@ -64,7 +64,7 @@
"parse-svg": "node -e 'require(\"./config/parse-svg.js\").main()'",
"copy": "copyfiles util/* src",
"update-metadata": "node config/metadata.js",
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > dist/react/esm/package.json",
"write-packagejson": "echo '{\"type\": \"module\"}' > dist/react/esm/package.json",
"build": "yarn copy && yarn create-icons && concurrently \"tsc\" \"tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json && yarn write-packagejson\" && node config/cleanTypes.js && yarn update-metadata && yarn parse-svg",
"fetch-new:icons": "tsx --env-file=.env config/figma/index.ts",
"zip:icons": "node config/zip.js",
+30 -16
View File
@@ -142,7 +142,7 @@
flex-direction: column;
overflow: hidden;
.aksel-combobox2:not(:has(.aksel-combobox2__modal)) & {
.aksel-combobox2:has(:not(.aksel-combobox2__modal)) & {
outline: 1px solid var(--ax-border-neutral-subtleA);
outline-offset: -1px;
border-radius: var(--ax-radius-8);
@@ -158,21 +158,26 @@
margin: -1px;
}
.aksel-combobox2__filter .aksel-search__wrapper:has(.aksel-search__input:focus-visible) {
outline: none;
position: relative;
.aksel-combobox2__filter .aksel-search__wrapper {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 2px solid var(--ax-border-focus);
}
&:has(.aksel-search__input:focus-visible) {
outline: none;
position: relative;
.aksel-combobox2:not(:has(.aksel-combobox2__modal)) &::after {
border-top-left-radius: var(--ax-radius-8);
border-top-right-radius: var(--ax-radius-8);
&::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 2px solid var(--ax-border-focus);
}
.aksel-combobox2:has(:not(.aksel-combobox2__modal)) &::after {
border-top-left-radius: var(--ax-radius-8);
border-top-right-radius: var(--ax-radius-8);
}
}
}
@@ -184,7 +189,7 @@
border-color: var(--ax-border-neutral-subtleA);
}
.aksel-combobox2__popup[data-size="medium"] & {
.aksel-combobox2[data-size="medium"] & {
min-height: 0;
}
@@ -216,6 +221,15 @@
text-align: center;
}
/************************************************
* Group *
************************************************/
.aksel-combobox2__group-label {
padding: var(--ax-space-8) var(--ax-space-8);
font-weight: bold;
}
/************************************************
* Option *
************************************************/
@@ -239,7 +253,7 @@
margin-top: var(--ax-space-2);
float: left;
.aksel-combobox2__popup[data-size="small"] & {
.aksel-combobox2[data-size="small"] & {
--__axc-combobox2-option-checkmark-size: 1rem;
}
+1 -1
View File
@@ -775,7 +775,7 @@
"docgen:meta": "yarn metadata:generate-names && tsx ./scripts/extract-metadata.ts",
"metadata:generate-names": "tsx ./scripts/extract-component-names.ts",
"i18n-jsdoc": "tsx ./scripts/addJsdocToLocales.ts",
"write-packagejson": "echo '{\"type\": \"module\", \"sideEffects\": false}' > esm/package.json",
"write-packagejson": "echo '{\"type\": \"module\"}' > esm/package.json",
"clean": "rimraf cjs esm",
"build": "yarn i18n-jsdoc && yarn metadata:generate-names && concurrently \"tsc -p tsconfig.build.json --module commonjs --outDir cjs --tsBuildInfoFile cjs/.tsbuildinfo\" \"tsc -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo && tsc-alias -p tsconfig.build.json --dir esm -f && yarn write-packagejson\" && yarn i18n-jsdoc --cleanup && yarn copy-types",
"watch": "tsc --watch -p tsconfig.build.json --module esnext --outDir esm --tsBuildInfoFile esm/.tsbuildinfo",
+121 -149
View File
@@ -1,4 +1,4 @@
import React, { forwardRef } from "react";
import React from "react";
import { Dialog } from "../../dialog";
import { BodyShort, ErrorMessage } from "../../typography";
import { cl } from "../../utils/helpers";
@@ -36,114 +36,109 @@ interface ComboboxProps<
name?: string;
}
const Combobox = forwardRef(
<T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
function Combobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({
label,
hideLabel,
name,
size: sizeProp,
error,
errorId: errorIdProp,
disabled: disabledProp,
description,
id,
readOnly: readOnlyProp,
multiple,
...rest
}: ComboboxProps<T>) {
const {
inputProps,
errorId,
showErrorMsg,
hasError,
size,
inputDescriptionId,
readOnly,
} = useFormField(
{
label,
hideLabel,
name,
size: sizeProp,
description,
disabled: disabledProp,
error,
errorId: errorIdProp,
disabled: disabledProp,
description,
id,
readOnly: readOnlyProp,
multiple,
...rest
}: ComboboxProps<T>,
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
const {
inputProps,
errorId,
showErrorMsg,
hasError,
size,
inputDescriptionId,
readOnly,
} = useFormField(
{
description,
disabled: disabledProp,
error,
errorId: errorIdProp,
id,
readOnly: readOnlyProp,
size: sizeProp,
},
"combobox",
);
size: sizeProp,
},
"combobox",
);
return (
<ComboboxRoot
size={size}
disabled={inputProps.disabled}
multiple={multiple}
{...rest}
return (
<ComboboxRoot
size={size}
disabled={inputProps.disabled}
multiple={multiple}
{...rest}
>
<ComboboxLabel
htmlFor={inputProps.id}
hide={hideLabel}
readOnly={readOnly}
>
<ComboboxLabel
htmlFor={inputProps.id}
hide={hideLabel}
readOnly={readOnly}
>
{label}
</ComboboxLabel>
{!!description && (
<BodyShort
className={cl(
"aksel-form-field__description aksel-combobox2__description",
{ "aksel-sr-only": hideLabel },
)}
id={inputDescriptionId}
size={size}
as="div"
>
{description}
</BodyShort>
)}
<ComboboxTrigger readOnly={readOnly} {...inputProps}>
<ComboboxField ref={ref} hasError={hasError} />
</ComboboxTrigger>
<div
className="aksel-form-field__error aksel-combobox2__error"
id={errorId}
aria-relevant="additions removals"
aria-live="polite"
>
{showErrorMsg && (
<ErrorMessage size={size} showIcon>
{error}
</ErrorMessage>
{label}
</ComboboxLabel>
{!!description && (
<BodyShort
className={cl(
"aksel-form-field__description aksel-combobox2__description",
{ "aksel-sr-only": hideLabel },
)}
</div>
{name &&
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
))}
<ComboboxOverlay
mobileHeader={
<Dialog.Header>
<Dialog.Title>{label}</Dialog.Title>
{!!description && (
<Dialog.Description>{description}</Dialog.Description>
)}
</Dialog.Header>
}
id={inputDescriptionId}
size={size}
as="div"
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
);
},
) as <T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
props: ComboboxProps<T> & React.RefAttributes<HTMLButtonElement>,
) => React.ReactElement;
{description}
</BodyShort>
)}
<ComboboxTrigger readOnly={readOnly} {...inputProps}>
<ComboboxField hasError={hasError} />
</ComboboxTrigger>
<div
className="aksel-form-field__error aksel-combobox2__error"
id={errorId}
aria-relevant="additions removals"
aria-live="polite"
>
{showErrorMsg && (
<ErrorMessage size={size} showIcon>
{error}
</ErrorMessage>
)}
</div>
{name &&
rest.value.map((val) => (
<input key={val} type="hidden" name={name} value={val} />
))}
<ComboboxOverlay
mobileHeader={
<Dialog.Header>
<Dialog.Title>{label}</Dialog.Title>
{!!description && (
<Dialog.Description>{description}</Dialog.Description>
)}
</Dialog.Header>
}
>
<ComboboxPopup>
<ComboboxFilter />
<ComboboxList />
</ComboboxPopup>
</ComboboxOverlay>
</ComboboxRoot>
);
}
// ----------- ComboboxSingle ----------- //
@@ -154,67 +149,44 @@ interface ComboboxSingleProps<
* Called when selected option changes.
*/
onChange?: (
newValue: NonNullable<ComboboxProps<T>["value"]>[number] | null,
newValue: NonNullable<ComboboxProps<T>["value"]>[number] | undefined,
) => void;
/**
* Value of the selected option.
*/
value?: NonNullable<ComboboxProps<T>["value"]>[number] | null;
value?: NonNullable<ComboboxProps<T>["value"]>[number];
/**
* Initial value when uncontrolled.
*/
defaultValue?: NonNullable<ComboboxProps<T>["value"]>[number];
}
/**
* Component for selecting a value from a list of options that can be filtered.
*
* **WARNING: This component is in active development and may receive breaking changes outside major releases!**
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/TODO)
* @see 🏷️ {@link ComboboxSingleProps}
*
* @example
* ```jsx
* <ComboboxSingle
* label="Select country"
* name="country"
* options={countries}
* />
* ```
*/
const ComboboxSingle = forwardRef(
<T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
{ onChange, value, defaultValue, ...rest }: ComboboxSingleProps<T>,
ref: React.ForwardedRef<HTMLButtonElement>,
function ComboboxSingle<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>({ onChange, value, defaultValue, ...rest }: ComboboxSingleProps<T>) {
const [localValue, setLocalValue] = useControllableState({
defaultValue,
value,
onChange,
});
const onToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
const [localValue, setLocalValue] = useControllableState({
defaultValue,
value,
onChange,
});
setLocalValue(isSelected ? option.value : undefined);
}; // TODO: Dumt at ikke får hele option-objektet... Legge til i onChange? Hvis vi tilbyr onToggle så blir det så likt onChange...
const onToggleOption: ComboboxProps<T>["onToggleOption"] = (
option,
isSelected,
) => {
setLocalValue(isSelected ? option.value : null);
}; // TODO: Dumt at ikke får hele option-objektet... Legge til i onChange? Hvis vi tilbyr onToggle så blir det så likt onChange...
return (
<Combobox
{...rest}
ref={ref}
value={localValue ? [localValue] : []}
defaultValue={defaultValue ? [defaultValue] : []}
multiple={false}
onToggleOption={onToggleOption}
/>
);
},
) as <T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>>(
props: ComboboxSingleProps<T> & React.RefAttributes<HTMLButtonElement>,
) => React.ReactElement;
return (
<Combobox
{...rest}
value={localValue ? [localValue] : []}
defaultValue={defaultValue ? [defaultValue] : []}
multiple={false}
onToggleOption={onToggleOption}
/>
);
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ComboboxSingle {
@@ -157,7 +157,7 @@ export const Name = () => (
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.target);
const land = formData.get("land");
const land = formData.getAll("land");
console.log(land);
}}
>
@@ -168,7 +168,7 @@ export const Name = () => (
);
export const Controlled = () => {
const [value, setValue] = useState<MyOption["value"] | null>("opt-1");
const [value, setValue] = useState<MyOption["value"] | undefined>("opt-1");
return (
<VStack gap="space-8" minWidth="250px">
@@ -180,7 +180,7 @@ export const Controlled = () => {
/>
Selected: {value}
<div>
<button type="button" onClick={() => setValue(null)}>
<button type="button" onClick={() => setValue(undefined)}>
Clear
</button>
</div>
@@ -1,5 +1,5 @@
import type { Meta } from "@storybook/react-vite";
import React, { useRef, useState } from "react";
import React, { useState } from "react";
import { expect, userEvent, within } from "storybook/test";
import { ComboboxSingle } from "./Combobox";
import { ComboboxFilter } from "./filter/ComboboxFilter";
@@ -60,7 +60,7 @@ const groupedOptions = [
function BaseCombobox<
T extends ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>,
>(props: Partial<ComboboxSingle.Props<T>>) {
const [value, setValue] = useState<string | null>("opt-1");
const [value, setValue] = useState<string | undefined>("option-1");
return (
<ComboboxSingle
@@ -81,27 +81,6 @@ AccessibleNameAndDesc.play = async ({ canvasElement }) => {
await expect(trigger).toHaveAccessibleDescription("Landet hvor du er født.");
};
export const FocusWithRef = () => {
const ref = useRef<HTMLButtonElement>(null);
return (
<div>
<button type="button" onClick={() => ref.current?.focus()}>
Fokuser combobox
</button>
<ComboboxSingle ref={ref} options={countries} label="Velg land" />
</div>
);
};
FocusWithRef.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const combobox = await canvas.findByRole("combobox", { name: "Velg land" });
await expect(combobox).not.toHaveFocus();
await userEvent.click(
canvas.getByRole("button", { name: "Fokuser combobox" }),
);
await expect(combobox).toHaveFocus();
};
let optionMemoTestRenderCnt = 0;
const optionMemoTestRenderFn = (
optOrGroup: (typeof groupedOptions)[number],
@@ -114,7 +93,7 @@ const optionMemoTestRenderFn = (
return optOrGroup.label;
};
export const OptionMemoization = () => {
const [value, setValue] = useState(["opt-1"]);
const [value, setValue] = useState(["option-1"]);
return (
<div style={{ minHeight: "450px" }}>
@@ -1,7 +1,6 @@
import React, { forwardRef } from "react";
import { ChevronDownIcon } from "@navikt/aksel-icons";
import { cl } from "../../../utils/helpers";
import { useI18n } from "../../../utils/i18n/i18n.hooks";
import {
type ComboboxRootProps,
useComboboxRootContext,
@@ -15,14 +14,10 @@ interface ComboboxFieldProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
const ComboboxField = forwardRef<HTMLButtonElement, ComboboxFieldProps>(
({ children, hasError, ...rest }, ref) => {
const { options, value, size } = useComboboxRootContext();
const translate = useI18n("Combobox2");
const labels = getLabels(value, options);
const label =
children ??
(labels.length > 1
? translate("selectedCount", { count: labels.length })
: labels[0]);
children ?? (labels.length > 1 ? `${labels.length} valgt` : labels[0]);
return (
<div className="aksel-combobox2__field-container">
@@ -42,7 +42,6 @@ export const ComboboxFilter = (props: ComboboxFilterProps) => {
}
}}
aria-autocomplete="list"
aria-expanded="true"
inputMode="text"
/>
</Listbox.InputSlot>
@@ -55,7 +55,7 @@ function ComboboxListContent<
useComboboxRootContext();
const memoizedOnToggleOption = useEventCallback(onToggleOption);
const deferredFilterString = useDeferredValue(filterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString);
const maybeAnnouceHits = useHitsAnnouncer(deferredFilterString, filterString);
const translate = useI18n("Combobox2");
const localOnToggleOption = useCallback(
@@ -80,7 +80,6 @@ function ComboboxListContent<
maybeAnnouceHits(filteredOptions);
if (filteredOptions.length === 0) {
// TODO: Consider moving outside of role=listbox
return (
<div className="aksel-combobox2__no-results">{translate("noHits")}</div>
);
@@ -18,9 +18,9 @@ function getNumberOfOptions<T extends ComboboxOptionData>(
}, 0);
}
function useHitsAnnouncer(deferredFilterString: string) {
function useHitsAnnouncer(deferredFilterString: string, filterString: string) {
const translate = useI18n("Combobox2");
const oldFilterStringRef = useRef(deferredFilterString);
const shouldAnnounceRef = useRef(false);
const announce = useAnnounce();
const announceHits = useMemo(() => {
@@ -50,20 +50,20 @@ function useHitsAnnouncer(deferredFilterString: string) {
ComboboxOptionData | ComboboxGroupData<ComboboxOptionData>
)[],
) => {
// Announce number of hits when filter string changes.
// We can't do this in a useEffect b.c. of how ComboboxList is structured. Consider refactoring.
if (deferredFilterString === oldFilterStringRef.current) {
return;
// The options are filtered based on deferredFilterString, so we need to wait for it to settle.
if (deferredFilterString !== filterString) {
shouldAnnounceRef.current = true;
} else if (shouldAnnounceRef.current) {
if (filteredOptions.length === 0) {
announceHits.clear();
announceNoHits();
} else {
const hits = getNumberOfOptions(filteredOptions);
announceNoHits.clear();
announceHits(hits);
}
shouldAnnounceRef.current = false;
}
if (filteredOptions.length === 0) {
announceHits.clear();
announceNoHits();
} else {
const hits = getNumberOfOptions(filteredOptions);
announceNoHits.clear();
announceHits(hits);
}
oldFilterStringRef.current = deferredFilterString;
};
}
@@ -3,7 +3,6 @@ import { CheckmarkHeavyIcon } from "@navikt/aksel-icons";
import { Listbox } from "../../../utils/components/Listbox/root/ListboxRoot";
import type { ComboboxListProps } from "../list/ComboboxList";
import type { ComboboxOptionData } from "../root/ComboboxRoot";
import { highlightSubstring } from "./highlightSubstring";
export interface ComboboxOptionProps<T extends ComboboxOptionData> {
option: T;
@@ -50,6 +49,31 @@ function ComboboxOptionComponent<T extends ComboboxOptionData>({
);
}
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 ComboboxOption = React.memo(
ComboboxOptionComponent,
) as typeof ComboboxOptionComponent;
@@ -1,27 +0,0 @@
import { describe, expect, test } from "vitest";
import { getHighlightRange } from "./highlightSubstring";
const highlighted = (text: string, substring: string) => {
const range = getHighlightRange(text, substring);
return range && text.substring(...range);
};
describe("getHighlightRange", () => {
test("matches case-insensitively", () => {
expect(getHighlightRange("Norge", "OR")).toEqual([1, 3]);
});
test("returns null when there is no match", () => {
expect(getHighlightRange("Norge", "x")).toBeUndefined();
});
test("handles characters that get longer when lowercased", () => {
expect(highlighted("İstanbul", "an")).toBe("an");
expect(highlighted("İzmir og Antalya", "an")).toBe("An");
expect(highlighted("İzmir", "İz")).toBe("İz");
});
test("includes the whole original character when only part of its lowercase form matches", () => {
expect(highlighted("İstanbul", "i")).toBe("İ");
});
});
@@ -1,50 +0,0 @@
import React from "react";
function highlightSubstring(text: string, substring: string) {
const range = getHighlightRange(text, substring);
if (!range) {
// This can happen if the consumer has implemented their own filtering logic
return text;
}
const [rangeStart, rangeEnd] = range;
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 it doesn't work in normal browsers
<span aria-label={text}>
{text.substring(0, rangeStart)}
<mark>{text.substring(rangeStart, rangeEnd)}</mark>
{text.substring(rangeEnd)}
</span>
);
}
/**
* Finds the first case-insensitive occurrence of `substring` in `text`.
* Handles characters whose lowercase form has a different length than the original (e.g. "İ").
* @returns The `[start, end]` range of the match in `text`, or `undefined` if there is no match.
*/
function getHighlightRange(text: string, substring: string) {
const lowerSubstring = substring.toLocaleLowerCase();
const matchIndex = text.toLocaleLowerCase().indexOf(lowerSubstring);
if (matchIndex === -1) {
return;
}
const matchEnd = matchIndex + lowerSubstring.length;
let start = 0;
let origPos = 0;
let lowerPos = 0;
for (const char of text) {
if (lowerPos <= matchIndex) {
start = origPos;
}
origPos += char.length;
lowerPos += char.toLocaleLowerCase().length;
if (lowerPos >= matchEnd) {
return [start, origPos] as const;
}
}
}
export { highlightSubstring, getHighlightRange };
@@ -1,4 +1,4 @@
import React, { useLayoutEffect, useRef, useState } from "react";
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";
@@ -29,7 +29,7 @@ export const ComboboxPopup = ({ children }: ComboboxPopupProps) => {
const rootContext = useComboboxRootContext();
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(
useEffect(
function onOpen() {
const canHover = window.matchMedia("(hover: hover)").matches;
@@ -123,9 +123,13 @@ const PopoverRoot = forwardRef<HTMLDivElement, PopoverProps>(
],
});
/**
* Only give Floating UI a reference while open. Otherwise every closed instance
* computes (and commits) a position
*/
useClientLayoutEffect(() => {
refs.setReference(anchorEl);
}, [anchorEl, refs]);
refs.setReference(open ? anchorEl : null);
}, [anchorEl, open, refs]);
const floatingRef = useMergeRefs(refs.setFloating, ref);
@@ -39,12 +39,4 @@ function ListboxOptions({ children, ...rest }: ListboxOptionsProps) {
);
}
/**
* TODO: Things that should be fixed before we allow focus directly
* on the listbox (i.e. when there's no filter input/combobox elm.)
* - Set aria-activedescendant
* - Consider aria-multiselectable
* - Probably aria-label(ledby)?
*/
export { ListboxOptions };
@@ -5,8 +5,6 @@ const internalTranslations = {
selected: "{count} rows selected",
},
Combobox2: {
/** The "value" that is shown when multiple options are selected. */
selectedCount: "{count} valgt",
/** Label for filter input. Only visible to screen readers. */
filter: "Filtrer",
noHits: "Ingen treff",