Repository: digdir/designsystemet
Pull request #3782 (2025-06-27 15:59:57)

fix(Suggestion): controlled state for single-suggestion didn't render correctly
## Summary

- Adds tests for rendering of controlled state in Suggestion component (stories ControlledSingle and ControlledMultiple).
- Fixes that the test fails for ControlledSingle, because of a bug / ommision upstream in u-combobox: https://github.com/u-elements/u-elements/issues/25

## Checks

- [x] I have read the [contribution guidelines](https://github.com/digdir/designsystemet/blob/main/CONTRIBUTING.md)
- [x] I have added a changeset (run `pnpm changeset` if relevant)


github-actions[bot]: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🔵</td> <td align="left">Lines</td> <td align="right">50.82%</td> <td align="right">3307 / 6507</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Statements</td> <td align="right">50.82%</td> <td align="right">3307 / 6507</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Functions</td> <td align="right">84.29%</td> <td align="right">220 / 261</td> </tr> <tr> <td align="center">🔵</td> <td align="left">Branches</td> <td align="right">77.43%</td> <td align="right">580 / 749</td> </tr> </tbody> </table><details><summary>File Coverage</summary>No changed files found.</details>
<em>Generated in workflow <a href=https://github.com/digdir/designsystemet/actions/runs/15930612845>#3498</a> for commit <a href="https://github.com/digdir/designsystemet/commit/4b9237cd974ce43314ae0e9dc15e1cf02527ec73">4b9237c</a> by the <a href="https://github.com/davelosert/vitest-coverage-report-action">Vitest Coverage Report Action</a></em>

<!-- vitest-coverage-report-marker-root -->

Patch:
diff --git a/.changeset/eleven-nails-whisper.md b/.changeset/eleven-nails-whisper.md
new file mode 100644
index 00000000..1d6ba3ee
--- /dev/null
+++ b/.changeset/eleven-nails-whisper.md
@@ -0,0 +1,5 @@
+---
+"@digdir/designsystemet-react": patch
+---
+
+**Field**: Make sure there will be no duplicate IDs when having multiple of the same `data-field` type
diff --git a/.changeset/sweet-shoes-juggle.md b/.changeset/sweet-shoes-juggle.md
new file mode 100644
index 00000000..ba415439
--- /dev/null
+++ b/.changeset/sweet-shoes-juggle.md
@@ -0,0 +1,5 @@
+---
+"@digdir/designsystemet-react": patch
+---
+
+**Suggestion**: Visual value didn't correctly update in single mode when controlled value was updated externally
diff --git a/packages/react/src/components/Field/fieldObserver.ts b/packages/react/src/components/Field/fieldObserver.ts
index 0b29373c..936eb432 100644
--- a/packages/react/src/components/Field/fieldObserver.ts
+++ b/packages/react/src/components/Field/fieldObserver.ts
@@ -2,6 +2,7 @@ export function fieldObserver(fieldElement: HTMLElement | null) {
   if (!fieldElement) return;
 
   const elements = new Map<Element, string | null>();
+  const typeCounter = new Map<string, number>(); // Track count for each data-field type
   const uuid = `:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
   let input: Element | null = null;
   let describedby = '';
@@ -44,9 +45,21 @@ export function fieldObserver(fieldElement: HTMLElement | null) {
     const describedbyIds = [describedby]; // Keep original aria-describedby
     const inputId = input?.id || uuid;
 
+    // Reset type counters since we reprocess all elements
+    typeCounter.clear();
+
     for (const [el, value] of elements) {
       const descriptionType = el.getAttribute('data-field');
-      const id = descriptionType ? `${inputId}:${descriptionType}` : inputId;
+      let id: string;
+
+      if (descriptionType) {
+        // Increment type counter for this type
+        const count = (typeCounter.get(descriptionType) || 0) + 1;
+        typeCounter.set(descriptionType, count);
+        id = `${inputId}:${descriptionType}:${count}`;
+      } else {
+        id = inputId;
+      }
 
       if (!value) setAttr(el, isLabel(el) ? 'for' : 'id', id); // Ensure we have a value
       if (descriptionType === 'validation')
diff --git a/packages/react/src/components/Suggestion/Suggestion.stories.tsx b/packages/react/src/components/Suggestion/Suggestion.stories.tsx
index 78cdc61d..2413b0d6 100644
--- a/packages/react/src/components/Suggestion/Suggestion.stories.tsx
+++ b/packages/react/src/components/Suggestion/Suggestion.stories.tsx
@@ -1,6 +1,6 @@
 import type { Meta, StoryFn } from '@storybook/react-vite';
 import { type ChangeEvent, useState } from 'react';
-import { userEvent, waitFor, within } from 'storybook/test';
+import { expect, userEvent, waitFor, within } from 'storybook/test';
 import { useDebounceCallback } from '../../utilities';
 import { Button, Divider, Field, Label, Paragraph, Spinner } from '..';
 import {
@@ -81,7 +81,73 @@ export const Preview: StoryFn<typeof Suggestion> = (args) => {
   );
 };
 
-export const Controlled: StoryFn<typeof Suggestion> = (args) => {
+export const ControlledSingle: StoryFn<typeof Suggestion> = (args) => {
+  const [value, setValue] = useState<string[]>(['Oslo']);
+
+  return (
+    <>
+      <Field>
+        <Label>Velg destinasjon</Label>
+        <Suggestion
+          {...args}
+          value={value}
+          onValueChange={(items) => setValue(items.map((item) => item.value))}
+        >
+          <Suggestion.Chips />
+          <Suggestion.Input />
+          <Suggestion.Clear />
+          <Suggestion.List>
+            <Suggestion.Empty>Tomt</Suggestion.Empty>
+            {DATA_PLACES.map((place) => (
+              <Suggestion.Option key={place} label={place} value={place}>
+                {place}
+                <div>Kommune</div>
+              </Suggestion.Option>
+            ))}
+          </Suggestion.List>
+        </Suggestion>
+      </Field>
+      <Divider style={{ marginTop: 'var(--ds-size-4)' }} />
+
+      <Paragraph style={{ margin: 'var(--ds-size-2) 0' }}>
+        Valgte reisemål: {value.join(', ')}
+      </Paragraph>
+
+      <Button
+        onClick={() => {
+          setValue(['Sogndal']);
+        }}
+      >
+        Sett reisemål til Sogndal
+      </Button>
+    </>
+  );
+};
+ControlledSingle.play = async ({ canvasElement, step }) => {
+  const input = await waitFor(() =>
+    within(canvasElement).getByRole('combobox'),
+  );
+  const resultText = within(canvasElement).getByText('Valgte reisemål:', {
+    exact: false,
+  });
+  const button = within(canvasElement).getByText('Sett reisemål', {
+    exact: false,
+    selector: 'button',
+  });
+
+  await step('Initial state is rendered correctly', async () => {
+    await expect(resultText).toHaveTextContent('Oslo');
+    await waitFor(() => expect(input).toHaveValue('Oslo'));
+  });
+
+  await step('Controlled state change renders correctly', async () => {
+    await userEvent.click(button);
+    await expect(resultText).toHaveTextContent('Sogndal');
+    await waitFor(() => expect(input).toHaveValue('Sogndal'));
+  });
+};
+
+export const ControlledMultiple: StoryFn<typeof Suggestion> = (args) => {
   const [value, setValue] = useState<string[]>(['Oslo']);
 
   return (
@@ -124,6 +190,38 @@ export const Controlled: StoryFn<typeof Suggestion> = (args) => {
     </>
   );
 };
+
+ControlledMultiple.play = async ({ canvasElement, step }) => {
+  const getChipValues = async () =>
+    waitFor(() =>
+      within(canvasElement)
+        .getAllByLabelText('Press to remove', { exact: false })
+        .filter((el) => el instanceof HTMLDataElement)
+        .map((x) => x.value),
+    );
+  const resultText = within(canvasElement).getByText('Valgte reisemål:', {
+    exact: false,
+  });
+  const button = within(canvasElement).getByText('Sett reisemål', {
+    exact: false,
+    selector: 'button',
+  });
+
+  await step('Initial state is rendered correctly', async () => {
+    await expect(resultText).toHaveTextContent('Oslo');
+    await expect(await getChipValues()).toContain('Oslo');
+  });
+
+  await step('Controlled state change renders correctly', async () => {
+    await userEvent.click(button);
+    await expect(resultText).toHaveTextContent('Sogndal');
+    await expect(resultText).toHaveTextContent('Stavanger');
+    const chipValues = await getChipValues();
+    await expect(chipValues).toContain('Sogndal');
+    await expect(chipValues).toContain('Stavanger');
+  });
+};
+
 export const CustomFilterAlt1: StoryFn<typeof Suggestion> = (args) => {
   return (
     <Field>
diff --git a/packages/react/src/components/Suggestion/Suggestion.tsx b/packages/react/src/components/Suggestion/Suggestion.tsx
index 6d135b5e..ef98c338 100644
--- a/packages/react/src/components/Suggestion/Suggestion.tsx
+++ b/packages/react/src/components/Suggestion/Suggestion.tsx
@@ -126,6 +126,18 @@ const nextItems = (
 const defaultFilter: Filter = ({ label, input }) =>
   label.toLowerCase().includes(input.value.trim().toLowerCase());
 
+// https://github.com/u-elements/u-elements/blob/fe076b724272c2fef00d41838b091b1563661829/packages/utils.ts#L222
+// TODO: DELETE THIS when bug in u-combobox is fixed. See https://github.com/u-elements/u-elements/issues/25
+const setValue = (input: HTMLInputElement, data: string, type = '') => {
+  const event = { bubbles: true, composed: true, data, inputType: type };
+  const proto = HTMLInputElement.prototype;
+
+  input.dispatchEvent(new InputEvent('beforeinput', event));
+  Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(input, data);
+  input.dispatchEvent(new InputEvent('input', event));
+  input.dispatchEvent(new Event('change', { bubbles: true }));
+};
+
 export const Suggestion = forwardRef<UHTMLComboboxElement, SuggestionProps>(
   function Suggestion(
     {
@@ -159,6 +171,15 @@ export const Suggestion = forwardRef<UHTMLComboboxElement, SuggestionProps>(
       setSelectedItems(sanitizeItems(prevControlled.current));
     }
 
+    useEffect(() => {
+      // Workaround for missing input value update in u-combobox in single mode
+      const sanitizedValue = sanitizeItems(prevControlled.current);
+      const inputEl = uComboboxRef.current?.querySelector('input');
+      if (inputEl && !multiple && sanitizedValue[0]) {
+        setValue(inputEl, sanitizedValue[0].value, 'insertText');
+      }
+    }, [prevControlled.current, uComboboxRef.current]);
+
     /**
      * Listerners and handling of adding/removing
      */


Test patch:
diff --git a/packages/react/src/components/Field/fieldObserver.test.tsx b/packages/react/src/components/Field/fieldObserver.test.tsx
index 5a080c4c..b61edb6a 100644
--- a/packages/react/src/components/Field/fieldObserver.test.tsx
+++ b/packages/react/src/components/Field/fieldObserver.test.tsx
@@ -91,10 +91,47 @@ describe('fieldObserver', () => {
     const description = screen.getByText('Description');
     const input = screen.getByLabelText('Navn');
 
-    expect(description).toHaveAttribute('id', `${input.id}:description`);
+    expect(description).toHaveAttribute('id', `${input.id}:description:1`);
     expect(input).toHaveAttribute(
       'aria-describedby',
-      `${input.id}:description`,
+      `${input.id}:description:1`,
     );
   });
+  it('assigns unique IDs to multiple elements with the same data-field type', () => {
+    const { container } = render(
+      <div>
+        <Label>Name</Label>
+        <Input />
+        <div data-field='validation'>First validation message</div>
+        <div data-field='validation'>Second validation message</div>
+        <div data-field='description'>Description text</div>
+        <div data-field='description'>Additional description</div>
+      </div>,
+    );
+    fieldObserver(container);
+
+    const input = screen.getByLabelText('Name');
+    const firstValidation = screen.getByText('First validation message');
+    const secondValidation = screen.getByText('Second validation message');
+    const firstDescription = screen.getByText('Description text');
+    const secondDescription = screen.getByText('Additional description');
+
+    // First of each type gets the simple format
+    expect(firstValidation).toHaveAttribute('id', `${input.id}:validation:1`);
+    expect(firstDescription).toHaveAttribute('id', `${input.id}:description:1`);
+
+    // Subsequent ones get numbered
+    expect(secondValidation).toHaveAttribute('id', `${input.id}:validation:2`);
+    expect(secondDescription).toHaveAttribute(
+      'id',
+      `${input.id}:description:2`,
+    );
+
+    // All should be in aria-describedby
+    const ariaDescribedby = input.getAttribute('aria-describedby');
+    expect(ariaDescribedby).toContain(firstValidation.id);
+    expect(ariaDescribedby).toContain(secondValidation.id);
+    expect(ariaDescribedby).toContain(firstDescription.id);
+    expect(ariaDescribedby).toContain(secondDescription.id);
+  });
 });