= () => {
+ const [value, setValue] = useState('value1');
-Eksempel.args = {
- items: [
- {
- name: 'Ild',
- content: (
-
- Nulla nec rutrum libero. Curabitur lorem est, tempor nec iaculis in,
- egestas eu lacus. Ut malesuada risus ut ipsum consequat mattis. Donec
- quis nunc ut lorem suscipit pharetra. Nulla ornare sed nisl nec
- facilisis. Sed in lacinia elit. Sed et eleifend nisi. Sed egestas
- nulla lobortis sapien scelerisque, at venenatis risus elementum.
- Aliquam eleifend, metus non molestie viverra, erat sem ornare enim,
- nec suscipit nulla nisi vel dolor. Etiam volutpat sapien arcu. Orci
- varius natoque penatibus et magnis dis parturient montes, nascetur
- ridiculus mus. Nulla sollicitudin molestie leo sit amet faucibus. Sed
- interdum condimentum interdum. Praesent volutpat turpis mattis purus
- venenatis egestas. In iaculis condimentum fringilla. Duis dignissim
- turpis mattis tristique vulputate.
-
- ),
- },
- {
- name: 'Jord',
- content: (
-
- Vestibulum nisl diam, tempus sit amet justo eu, semper facilisis
- dolor. Proin scelerisque tellus sit amet consectetur condimentum.
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et
- dui vehicula, semper arcu vitae, posuere odio. Pellentesque eu ante in
- elit semper pellentesque. Donec cursus eros non diam condimentum
- viverra. Pellentesque at odio lorem. Aenean ac enim et risus bibendum
- scelerisque et a purus. Donec ultricies, ex et ornare fringilla,
- turpis ex consectetur ante, ut porta libero metus quis magna. Nulla eu
- hendrerit ex, non dapibus quam. Nulla dictum ligula tellus, et
- elementum orci convallis sit amet. Class aptent taciti sociosqu ad
- litora torquent per conubia nostra, per inceptos himenaeos. Fusce
- dolor orci, sagittis vel elit eget, viverra ultrices nulla.
-
- ),
- },
- {
- name: 'Luft',
- content: (
-
- Integer dictum lacus vitae urna lobortis, scelerisque varius metus
- maximus. Integer ornare pharetra metus, vel mattis urna. Interdum et
- malesuada fames ac ante ipsum primis in faucibus. Nulla consectetur
- ipsum ac magna sollicitudin, ac fermentum sem tempus. Proin rutrum
- aliquam eros eu accumsan. Duis rhoncus urna a tellus sagittis, eu
- aliquam dui pharetra. Praesent eu libero consectetur, varius urna
- quis, volutpat magna. Vivamus ornare magna at vehicula pulvinar.
- Curabitur risus lorem, placerat sit amet mollis venenatis, placerat
- sed ligula. Donec pellentesque quis est nec viverra. Sed ultricies
- aliquam nunc, sit amet faucibus augue tempor quis. Pellentesque
- porttitor sapien quis risus placerat, in facilisis augue molestie.
-
- ),
- },
- {
- name: 'Vann',
- content: (
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean vel
- leo nibh. Fusce neque nulla, semper quis rutrum eu, volutpat nec
- mauris. In lacinia iaculis venenatis. Aliquam pulvinar lectus lorem, a
- congue nulla dictum vel. Donec augue eros, cursus ut porta eu, mollis
- sodales odio. Vestibulum rutrum sollicitudin nisi, sed facilisis nibh
- dictum at. Nulla arcu mi, iaculis quis luctus at, vulputate hendrerit
- quam. Suspendisse condimentum pellentesque varius. Nullam molestie
- dictum pellentesque. Nunc felis sem, elementum a sapien a, consectetur
- ullamcorper tellus. Nullam porta tempus nisl, in vehicula quam congue
- eget.
-
- ),
- },
- ],
+ return (
+ <>
+
+
+
+
+
+ Tab 1
+
+ }
+ >
+ Tab 2
+
+ }
+ >
+ Tab 3
+
+
+ content 1
+ content 2
+ content 3
+
+ >
+ );
};
diff --git a/packages/react/src/components/Tabs/Tabs.test.tsx b/packages/react/src/components/Tabs/Tabs.test.tsx
index 2c5578c11f..c1e9453fb8 100644
--- a/packages/react/src/components/Tabs/Tabs.test.tsx
+++ b/packages/react/src/components/Tabs/Tabs.test.tsx
@@ -1,331 +1,68 @@
-import assert from 'assert';
-
import React from 'react';
-import { act, screen, render as renderRtl } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { lastItem } from '../../utils/arrayUtils';
-
-import type { TabItem, TabsProps } from './Tabs';
-import { Tabs } from './Tabs';
+import { Tabs } from '.';
const user = userEvent.setup();
-// Test data:
-const itemInfo = [
- {
- name: 'Item 1',
- textContent: 'Lorem ipsum dolor sit amet.',
- value: 'item1',
- },
- {
- name: 'Item 2',
- textContent: 'Etiam ac magna pretium, laoreet.',
- value: 'item2',
- },
- {
- name: 'Item 3',
- textContent: 'Phasellus convallis porta commodo. Vivamus.',
- value: 'item3',
- },
-];
-const items: TabItem[] = itemInfo.map((item) => ({
- name: item.name,
- content: {item.textContent}
,
-}));
-const itemsWithValues: TabItem[] = itemInfo.map((item) => ({
- name: item.name,
- content: {item.textContent}
,
- value: item.value,
-}));
-const defaultProps: TabsProps = {
- items,
-};
-
describe('Tabs', () => {
- it('Renders expected elements', () => {
- render();
- expect(getTablist()).toBeInTheDocument();
- expect(getTabs()).toHaveLength(items.length);
- expect(getTabpanel()).toBeInTheDocument();
- });
-
- it('Selects first tab by default', () => {
- render();
- expectSelected(getTab(0));
- expectNotSelected(getTab(1));
- expectNotSelected(getTab(2));
- expect(getTabpanel()).toHaveTextContent(itemInfo[0].textContent);
- });
-
- it('Initially selects tab with name given in the activeTab property if values are not set', () => {
- render({ activeTab: items[1].name });
- expectSelected(getTab(1));
- expectNotSelected(getTab(0));
- expectNotSelected(getTab(2));
- expect(getTabpanel()).toHaveTextContent(itemInfo[1].textContent);
- });
-
- it('Initially selects tab with value given in the activeTab property if values are set', () => {
- render({ items: itemsWithValues, activeTab: itemsWithValues[1].value });
- expectSelected(getTab(1));
- expectNotSelected(getTab(0));
- expectNotSelected(getTab(2));
- expect(getTabpanel()).toHaveTextContent(itemInfo[1].textContent);
- });
-
- it('Sets given tab id on tab', () => {
- const tabId = 'some-unique-tab-id';
- render({ items: [...items, { tabId, name: 'Item 4', content: }] });
- expect(getLastTab()).toHaveAttribute('id', tabId);
- });
-
- it('Sets given panel id on tab panel', () => {
- const panelId = 'some-unique-panel-id';
- const textContent = 'In dignissim risus enim, sed.';
- const content = {textContent}
;
- const tabs = [...items, { panelId, name: 'Item 4', content }];
- render({ items: tabs });
- expect(document.getElementById(panelId)).toHaveTextContent(textContent);
- });
-
- it('Links corresponding tabs and panels to each other with accessibility attributes', () => {
- render();
- items.forEach((_, i) => {
- const panelId = getTab(i).getAttribute('aria-controls');
- const panel = screen.getByLabelText(items[i].name);
- expect(panel).toHaveAttribute('id', panelId);
- });
- });
-
- it('Selects and focuses on tab when user clicks on it', async () => {
- render();
- for (let i = 0; i < items.length; i++) {
- await act(() => user.click(getTab(i)));
- expectSelectedIndex(i);
- expect(getTab(i)).toHaveFocus();
- }
- });
-
- it('Does not focus on any tab by default', () => {
- render();
- getTabs().forEach((tab) => expect(tab).not.toHaveFocus());
- });
-
- it('Focuses on the first tab when user presses the tab key', async () => {
- const { container } = render();
- await act(() => user.click(container));
- await act(() => user.tab());
- expect(getTab(0)).toHaveFocus();
- });
-
- it('Moves focus out when one tab has focus and user presses the tab key', async () => {
- render();
- await act(() => user.click(getTab(1)));
- expect(getTab(1)).toHaveFocus();
- await act(() => user.tab());
- getTabs().forEach((tab) => expect(tab).not.toHaveFocus());
- });
-
- it('Moves focus to the right when user presses the right arrow key', async () => {
- render();
- await act(() => user.click(getTab(0)));
- expect(getTab(0)).toHaveFocus();
- await act(() => user.keyboard('{ArrowRight}'));
- expect(getTab(1)).toHaveFocus();
- await act(() => user.keyboard('{ArrowRight}'));
- expect(getTab(2)).toHaveFocus();
- });
-
- it('Moves focus to the left when user presses the left arrow key', async () => {
- render();
- await act(() => user.click(getTab(2)));
- expect(getTab(2)).toHaveFocus();
- await act(() => user.keyboard('{ArrowLeft}'));
- expect(getTab(1)).toHaveFocus();
- await act(() => user.keyboard('{ArrowLeft}'));
- expect(getTab(0)).toHaveFocus();
- });
-
- it('Moves focus to the first tab when the last tab is focused and user presses the right arrow key', async () => {
- render();
- await act(() => user.click(getLastTab()));
- expect(getLastTab()).toHaveFocus();
- await act(() => user.keyboard('{ArrowRight}'));
- expect(getTab(0)).toHaveFocus();
- });
-
- it('Moves focus to the last tab when the first tab is focused and user presses the left arrow key', async () => {
- render();
- await act(() => user.click(getTab(0)));
- expect(getTab(0)).toHaveFocus();
- await act(() => user.keyboard('{ArrowLeft}'));
- expect(getLastTab()).toHaveFocus();
- });
-
- it('Selects tab when user navigates with arrow keys and presses the enter key', async () => {
- render();
- await act(() => user.click(getTab(0)));
- await act(() => user.keyboard('{ArrowRight}'));
- await act(() => user.keyboard('{Enter}'));
- expectSelected(getTab(1));
- });
-
- it('Selects tab when user navigates with arrow keys and presses the space key', async () => {
- render();
- await act(() => user.click(getTab(0)));
- await act(() => user.keyboard('{ArrowRight}'));
- await act(() => user.keyboard('{Space}'));
- expectSelected(getTab(1));
- });
-
- it('Throws error if item values are not set and names are not unique', () => {
- const renderFn = () => {
- render({ items: [...items, { name: items[0].name, content: }] });
- };
- jest.spyOn(console, 'error').mockImplementation(jest.fn()); // Keeps the console output clean
- expect(renderFn).toThrow('Each tab value must be unique.');
- });
-
- it('Throws error if item values are set, but not unique', () => {
- const renderFn = () => {
- render({
- items: [
- ...itemsWithValues,
- { name: 'Item 4', content: , value: itemsWithValues[0].value },
- ],
- });
- };
- jest.spyOn(console, 'error').mockImplementation(jest.fn()); // Keeps the console output clean
- expect(renderFn).toThrow('Each tab value must be unique.');
- });
-
- it('Throws error if the value given in activeTab is not present among the item names and item values are not given', () => {
- const renderFn = () => {
- render({ activeTab: 'Some name that is not in the list' });
- };
- jest.spyOn(console, 'error').mockImplementation(jest.fn()); // Keeps the console output clean
- const error = 'The given active tab value must exist in the list of items.';
- expect(renderFn).toThrow(error);
- });
-
- it('Throws error if the value given in activeTab is not present among the item values if they are given', () => {
- const renderFn = () => {
- render({
- items: itemsWithValues,
- activeTab: 'Some value that is not in the list',
- });
- };
- jest.spyOn(console, 'error').mockImplementation(jest.fn()); // Keeps the console output clean
- const error = 'The given active tab value must exist in the list of items.';
- expect(renderFn).toThrow(error);
- });
-
- it('Switches selected tab when rerendered with a new name in the activeTab property and values are not set', () => {
- const { rerender } = render();
- rerender(
- ,
+ test('has passed size to TabItems', () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ content 1
+ content 2
+ ,
);
- expectSelected(getTab(1));
- });
- it('Switches selected tab when rerendered with a new value in the activeTab property and values are set', () => {
- const { rerender } = render();
- rerender(
- ,
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
+ expect(tab1).toHaveClass('medium');
+ expect(tab2).toHaveClass('medium');
+ });
+
+ test('can navigate tabs with keyboard', async () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ content 1
+ content 2
+ ,
);
- expectSelected(getTab(1));
- });
-
- it("Calls the onChange function with the selected tab's name as a parameter when user selects a tab using the mouse and values are not set", async () => {
- const onChange = jest.fn();
- render({ onChange });
- await act(() => user.click(getTab(1)));
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(items[1].name);
- });
-
- it("Calls the onChange function with the selected tab's value as a parameter when user selects a tab using the mouse and values are set", async () => {
- const onChange = jest.fn();
- render({ items: itemsWithValues, onChange });
- await act(() => user.click(getTab(1)));
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(itemsWithValues[1].value);
- });
-
- it("Calls the onChange function with the selected tab's name as a parameter when user selects a tab using the enter key and values are not set", async () => {
- const onChange = jest.fn();
- render({ onChange });
- await act(() => user.click(getTab(0)));
- await act(() => user.keyboard('{ArrowRight}'));
- await act(() => user.keyboard('{Enter}'));
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(items[1].name);
- });
- it("Calls the onChange function with the selected tab's value as a parameter when user selects a tab using the enter key and values are set", async () => {
- const onChange = jest.fn();
- render({ items: itemsWithValues, onChange });
- await act(() => user.click(getTab(0)));
- await act(() => user.keyboard('{ArrowRight}'));
- await act(() => user.keyboard('{Enter}'));
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(itemsWithValues[1].value);
- });
-
- it("Calls the onChange function with the selected tab's name as a parameter when user selects a tab using the space key and values are not set", async () => {
- const onChange = jest.fn();
- render({ onChange });
- await act(() => user.click(getTab(0)));
- await act(() => user.keyboard('{ArrowRight}'));
- await act(() => user.keyboard('{Space}'));
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(items[1].name);
- });
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
+ await user.tab();
+ expect(tab1).toHaveFocus();
+ await user.type(tab1, '{arrowright}');
+ expect(tab2).toHaveFocus();
+ await user.type(tab2, '{arrowleft}');
+ expect(tab1).toHaveFocus();
+ });
+
+ test('renders content based on value', async () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ content 1
+ content 2
+ ,
+ );
- it("Calls the onChange function with the selected tab's value as a parameter when user selects a tab using the space key and values are set", async () => {
- const onChange = jest.fn();
- render({ items: itemsWithValues, onChange });
- await act(() => user.click(getTab(0)));
- await act(() => user.keyboard('{ArrowRight}'));
- await act(() => user.keyboard('{Space}'));
- expect(onChange).toHaveBeenCalledTimes(1);
- expect(onChange).toHaveBeenCalledWith(itemsWithValues[1].value);
+ expect(screen.queryByText('content 1')).toBeVisible();
+ expect(screen.queryByText('content 2')).not.toBeInTheDocument();
+ await user.click(screen.getByRole('tab', { name: 'Tab 2' }));
+ expect(screen.queryByText('content 2')).toBeVisible();
+ expect(screen.queryByText('content 1')).not.toBeInTheDocument();
});
});
-
-const render = (props?: Partial) => {
- return renderRtl(
- ,
- );
-};
-
-const getTablist = () => screen.getByRole('tablist');
-const getTabs = () => screen.getAllByRole('tab');
-const getTab = (index: number) => getTabs()[index];
-const getLastTab = () => {
- const last = lastItem(getTabs());
- assert(last !== undefined);
- return last;
-};
-const getTabpanel = () => screen.getByRole('tabpanel');
-
-const expectSelected = (tab: HTMLElement) =>
- expect(tab).toHaveAttribute('aria-selected', 'true');
-const expectNotSelected = (tab: HTMLElement) =>
- expect(tab).toHaveAttribute('aria-selected', 'false');
-const expectSelectedIndex = (index: number) =>
- getTabs().forEach((tab, i) => {
- if (index === i) expectSelected(tab);
- else expectNotSelected(tab);
- });
diff --git a/packages/react/src/components/Tabs/Tabs.tsx b/packages/react/src/components/Tabs/Tabs.tsx
index 21d2bb15ed..d59e9d1bd1 100644
--- a/packages/react/src/components/Tabs/Tabs.tsx
+++ b/packages/react/src/components/Tabs/Tabs.tsx
@@ -1,141 +1,75 @@
-import type { KeyboardEventHandler } from 'react';
-import React, { useEffect, useId, useRef, useState } from 'react';
-import cn from 'classnames';
+import type { HTMLAttributes } from 'react';
+import React, { createContext, forwardRef, useState } from 'react';
-import { useUpdate } from '../../hooks';
-import { areItemsUnique } from '../../utils/arrayUtils';
-
-import classes from './Tabs.module.css';
-
-export interface TabItem {
- name: string;
- content: React.ReactNode;
- tabId?: string;
- panelId?: string;
+export type TabsProps = {
+ /** Controlled state for `Tabs` component. */
value?: string;
-}
-
-export interface TabsProps {
- activeTab?: string;
- items: TabItem[];
- onChange?: (name: string) => void;
-}
-
-const validId = (str: string) => str.replace(/\s/, '_');
-
-const Tabs = ({ activeTab, items, onChange }: TabsProps) => {
- const idBase = useId();
-
- // Generate values for undefined properties
- const tabs: Required[] = items.map(
- ({
- name,
- content,
- value: optionalValue,
- tabId: optionalTabId,
- panelId: optionalPanelId,
- }) => {
- const value = optionalValue ?? name;
- const tabId = optionalTabId ?? idBase + validId(value) + '-tab';
- const panelId = optionalPanelId ?? idBase + validId(value) + '-panel';
- return { name, content, value, tabId, panelId };
- },
- );
-
- if (!areItemsUnique(tabs.map(({ value }) => value))) {
- throw Error('Each tab value must be unique.');
- }
- if (activeTab !== undefined && !tabs.some((tab) => tab.value === activeTab)) {
- throw Error('The given active tab value must exist in the list of items.');
- }
-
- const findTabIndexByValue = (value: string) =>
- tabs.findIndex((tab) => tab.value === value);
- const initialTab = activeTab ?? tabs[0].value;
- const [visiblePanel, setVisiblePanel] = useState(initialTab);
- const [focusIndex, setFocusIndex] = useState(
- findTabIndexByValue(initialTab),
- );
- useEffect(() => setVisiblePanel(initialTab), [initialTab]);
- const tablistRef = useRef(null);
- const lastIndex = tabs.length - 1;
-
- useUpdate(() => {
- tablistRef.current
- ?.querySelectorAll('[role="tab"]')
- [focusIndex].focus();
- }, [focusIndex]);
-
- const selectTab = (value: string) => {
- visiblePanel !== value && onChange && onChange(value);
- setVisiblePanel(value);
- setFocusIndex(findTabIndexByValue(value));
- };
-
- const moveFocusRight = () =>
- focusIndex !== undefined &&
- setFocusIndex(focusIndex === lastIndex ? 0 : focusIndex + 1);
- const moveFocusLeft = () =>
- focusIndex !== undefined &&
- setFocusIndex(focusIndex === 0 ? lastIndex : focusIndex - 1);
-
- const onKeyDown =
- (name: string) => (event: Parameters[0]) => {
- switch (event.key) {
- case 'ArrowRight':
- moveFocusRight();
- break;
- case 'ArrowLeft':
- moveFocusLeft();
- break;
- case 'Space':
- selectTab(name);
- }
- };
+ /** Default value. */
+ defaultValue?: string;
+ /** Callback with selected `TabItem` `value` */
+ onChange?: (value: string) => void;
+ /** Changes items size and paddings */
+ size?: 'small' | 'medium' | 'large';
+} & Omit, 'onChange' | 'value'>;
+
+/** `Tabs` component.
+ * @example
+ * ```tsx
+ * console.log(value)}>
+ *
+ * Tab 1
+ * Tab 2
+ * Tab 3
+ *
+ * content 1
+ * content 2
+ * content 3
+ *
+ * ```
+ */
+export type TabsContextProps = {
+ value?: string;
+ defaultValue?: string;
+ onChange?: (value: string) => void;
+ size?: 'small' | 'medium' | 'large';
+};
- return (
-
-
({});
+
+export const Tabs = forwardRef(
+ (
+ { children, value, defaultValue, onChange, size = 'medium', ...rest },
+ ref,
+ ) => {
+ const isControlled = value !== undefined;
+ const [uncontrolledValue, setUncontrolledValue] = useState<
+ string | undefined
+ >(defaultValue);
+
+ let onValueChange = onChange;
+ if (!isControlled) {
+ onValueChange = (newValue: string) => {
+ setUncontrolledValue(newValue);
+ onChange?.(newValue);
+ };
+ value = uncontrolledValue;
+ }
+ return (
+
- {tabs.map((tab, i) => {
- const isSelected = tab.value === visiblePanel;
- return (
-
- );
- })}
-
-
- {tabs.map((tab) => (
- {tab.content}
+ {children}
- ))}
-
- );
-};
-
-Tabs.displayName = 'Tabs';
-
-export { Tabs };
+
+ );
+ },
+);
diff --git a/packages/react/src/components/Tabs/index.ts b/packages/react/src/components/Tabs/index.ts
index 10fb61972b..0623eae158 100644
--- a/packages/react/src/components/Tabs/index.ts
+++ b/packages/react/src/components/Tabs/index.ts
@@ -1,2 +1,26 @@
-export { Tabs } from './Tabs';
-export type { TabsProps, TabItem } from './Tabs';
+import { Tabs as TabsRoot } from './Tabs';
+import { Tab } from './Tab';
+import { TabList } from './TabList';
+import { TabContent } from './TabContent';
+
+export type { TabsProps } from './Tabs';
+export type { TabProps } from './Tab';
+export type { TabContentProps } from './TabContent';
+
+type TabsComponent = typeof TabsRoot & {
+ Tab: typeof Tab;
+ List: typeof TabList;
+ Content: typeof TabContent;
+};
+
+const Tabs = TabsRoot as TabsComponent;
+
+Tabs.Tab = Tab;
+Tabs.List = TabList;
+Tabs.Content = TabContent;
+
+Tabs.Tab.displayName = 'Tabs.Tab';
+Tabs.List.displayName = 'Tabs.List';
+Tabs.Content.displayName = 'Tabs.Content';
+
+export { Tabs, Tab, TabList, TabContent };
diff --git a/packages/react/src/components/Tag/Tag.module.css b/packages/react/src/components/Tag/Tag.module.css
index 8537fd2f15..323ab48f5c 100644
--- a/packages/react/src/components/Tag/Tag.module.css
+++ b/packages/react/src/components/Tag/Tag.module.css
@@ -64,21 +64,21 @@
--fdsc-tag-text: var(--fds-semantic-text-danger-on_danger_subtle);
}
-.outlined.primary {
- --fdsc-tag-border: var(--fds-semantic-surface-primary-dark);
- --fdsc-tag-background: var(--fds-semantic-surface-primary-light);
+.outlined.first {
+ --fdsc-tag-border: var(--fds-semantic-surface-first-dark);
+ --fdsc-tag-background: var(--fds-semantic-surface-first-light);
--fdsc-tag-text: var(--fds-semantic-text-neutral-default);
}
-.outlined.secondary {
- --fdsc-tag-border: var(--fds-semantic-surface-secondary-dark);
- --fdsc-tag-background: var(--fds-semantic-surface-secondary-light);
+.outlined.second {
+ --fdsc-tag-border: var(--fds-semantic-surface-second-dark);
+ --fdsc-tag-background: var(--fds-semantic-surface-second-light);
--fdsc-tag-text: var(--fds-semantic-text-neutral-default);
}
-.outlined.tertiary {
- --fdsc-tag-border: var(--fds-semantic-surface-tertiary-dark);
- --fdsc-tag-background: var(--fds-semantic-surface-tertiary-light);
+.outlined.third {
+ --fdsc-tag-border: var(--fds-semantic-surface-third-dark);
+ --fdsc-tag-background: var(--fds-semantic-surface-third-light);
--fdsc-tag-text: var(--fds-semantic-text-neutral-default);
}
@@ -112,20 +112,20 @@
--fdsc-tag-text: var(--fds-semantic-text-danger-on_danger);
}
-.filled.primary {
- --fdsc-tag-border: var(--fds-semantic-surface-primary-dark);
- --fdsc-tag-background: var(--fds-semantic-surface-primary-dark);
+.filled.first {
+ --fdsc-tag-border: var(--fds-semantic-surface-first-dark);
+ --fdsc-tag-background: var(--fds-semantic-surface-first-dark);
--fdsc-tag-text: var(--fds-semantic-text-neutral-on_inverted);
}
-.filled.secondary {
- --fdsc-tag-border: var(--fds-semantic-surface-secondary-dark);
- --fdsc-tag-background: var(--fds-semantic-surface-secondary-dark);
+.filled.second {
+ --fdsc-tag-border: var(--fds-semantic-surface-second-dark);
+ --fdsc-tag-background: var(--fds-semantic-surface-second-dark);
--fdsc-tag-text: var(--fds-semantic-text-neutral-on_inverted);
}
-.filled.tertiary {
- --fdsc-tag-border: var(--fds-semantic-surface-tertiary-dark);
- --fdsc-tag-background: var(--fds-semantic-surface-tertiary-dark);
+.filled.third {
+ --fdsc-tag-border: var(--fds-semantic-surface-third-dark);
+ --fdsc-tag-background: var(--fds-semantic-surface-third-dark);
--fdsc-tag-text: var(--fds-semantic-text-neutral-on_inverted);
}
diff --git a/packages/react/src/components/Tag/Tag.stories.tsx b/packages/react/src/components/Tag/Tag.stories.tsx
index 45b509019d..ceefc449de 100644
--- a/packages/react/src/components/Tag/Tag.stories.tsx
+++ b/packages/react/src/components/Tag/Tag.stories.tsx
@@ -69,9 +69,9 @@ const colors: TagProps['color'][] = [
'warning',
'danger',
'info',
- 'primary',
- 'secondary',
- 'tertiary',
+ 'first',
+ 'second',
+ 'third',
];
export const Colors: StoryFn = ({ ...rest }): JSX.Element => {
diff --git a/packages/react/src/components/Tag/Tag.test.tsx b/packages/react/src/components/Tag/Tag.test.tsx
index 1006a90ce8..202c0d1002 100644
--- a/packages/react/src/components/Tag/Tag.test.tsx
+++ b/packages/react/src/components/Tag/Tag.test.tsx
@@ -37,9 +37,9 @@ describe('Tag', () => {
expect(screen.getByText('Beta')).toHaveClass('outlined');
});
- test('should render color primary', (): void => {
- render(Beta);
- expect(screen.getByText('Beta')).toHaveClass('primary');
+ test('should render color first', (): void => {
+ render(Beta);
+ expect(screen.getByText('Beta')).toHaveClass('first');
});
test('should have custom className', () => {
diff --git a/packages/react/src/components/Tag/Tag.tsx b/packages/react/src/components/Tag/Tag.tsx
index 28aef87160..c9ece3ac01 100644
--- a/packages/react/src/components/Tag/Tag.tsx
+++ b/packages/react/src/components/Tag/Tag.tsx
@@ -7,7 +7,7 @@ import { Paragraph } from '../Typography';
import classes from './Tag.module.css';
-type BrandColor = 'primary' | 'secondary' | 'tertiary';
+type BrandColor = 'first' | 'second' | 'third';
type VariantColor = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
type Size = Exclude;
diff --git a/packages/react/src/components/TextArea/TextArea.mdx b/packages/react/src/components/TextArea/TextArea.mdx
deleted file mode 100644
index fcc0c2247f..0000000000
--- a/packages/react/src/components/TextArea/TextArea.mdx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { Meta, Canvas, Story, Primary, Controls } from '@storybook/blocks';
-import { TextArea } from './';
-import { Information, TokensTable } from '../../../../../docs-components';
-import { ArgsTable } from '@storybook/blocks';
-import * as TextAreaStories from './TextArea.stories';
-
-
-
-# TextArea
-
-
-
-`TextArea` brukes til å ta imot lengre tekster, gjerne over flere linjer.
-
-## Bruk
-
-```tsx
-import { TextArea } from '@digdir/design-system-react';
-
-;
-```
-
-## Props
-
-
-
-
-## Eksempler
-
-### Standard
-
-
-
-### Med feil
-
-
-
-### Skrivebeskyttet
-
-
-
-### Bekreftelsesvisning
-
-
-
-### Deaktivert
-
-
-
-### Viser antall tegn igjen å skrive
-
-
-
-## Tokens
-
-
diff --git a/packages/react/src/components/TextArea/index.ts b/packages/react/src/components/TextArea/index.ts
deleted file mode 100644
index 1c303c305c..0000000000
--- a/packages/react/src/components/TextArea/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { TextArea } from './TextArea';
-export type { TextAreaProps } from './TextArea';
diff --git a/packages/react/src/components/TextField/index.ts b/packages/react/src/components/TextField/index.ts
deleted file mode 100644
index 03fca4ab6a..0000000000
--- a/packages/react/src/components/TextField/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { TextField } from './TextField';
-export type { TextFieldProps, TextFieldFormatting } from './TextField';
diff --git a/packages/react/src/components/ToggleButtonGroup/index.ts b/packages/react/src/components/ToggleButtonGroup/index.ts
deleted file mode 100644
index 4de1e7ce09..0000000000
--- a/packages/react/src/components/ToggleButtonGroup/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export { ToggleButtonGroup } from './ToggleButtonGroup';
-export type {
- ToggleButtonGroupProps,
- ToggleButtonProps,
-} from './ToggleButtonGroup';
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.mdx b/packages/react/src/components/ToggleGroup/ToggleGroup.mdx
new file mode 100644
index 0000000000..c703aa60aa
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.mdx
@@ -0,0 +1,35 @@
+import { Meta, Canvas, Story, Controls, Primary } from '@storybook/blocks';
+import { Information } from '../../../../../docs-components';
+import * as ToggleGroupStories from './ToggleGroup.stories';
+
+
+
+# ToggleGroup
+
+Description of the ToggleGroup component.
+
+
+
+
+## Bruk
+
+
+
+```tsx
+import '@digdir/design-system-tokens/brand/altinn/tokens.css'; // Importeres kun en gang i appen din.
+import { ToggleGroup } from '@digdir/design-system-react';
+
+
+ Option 1
+ Option 2
+ Option 3
+;
+```
+
+## Only Icons
+
+
+
+## Controlled
+
+
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.module.css b/packages/react/src/components/ToggleGroup/ToggleGroup.module.css
new file mode 100644
index 0000000000..4b87541de4
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.module.css
@@ -0,0 +1,13 @@
+.toggleGroupContainer {
+ background-color: var(--fds-semantic-background-default);
+ border: var(--fds-semantic-border-neutral-default) solid var(--fds-border_width-default);
+ border-radius: var(--fds-border_radius-medium);
+}
+
+.groupContent {
+ display: inline-grid;
+ gap: var(--fds-spacing-1);
+ grid-auto-columns: 1fr;
+ grid-auto-flow: column;
+ padding: var(--fds-spacing-1);
+}
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx b/packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx
new file mode 100644
index 0000000000..57ab1d4801
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.stories.tsx
@@ -0,0 +1,117 @@
+import React, { useState } from 'react';
+import type { Meta, StoryFn } from '@storybook/react';
+import * as icons from '@navikt/aksel-icons';
+
+import { Button } from '../Button';
+
+import { ToggleGroup } from '.';
+
+const icon = (
+
+);
+
+const AkselIcon = icons.AirplaneFillIcon;
+const AkselIcon2 = icons.NewspaperFillIcon;
+const AkselIcon3 = icons.BrailleIcon;
+const AkselIcon4 = icons.BackpackFillIcon;
+
+export default {
+ title: 'Felles/ToggleGroup',
+ component: ToggleGroup,
+} as Meta;
+
+export const Preview: StoryFn = (args) => {
+ return (
+
+ Peanut
+ Walnut
+ Pistachio 🤤
+
+ );
+};
+
+Preview.args = {
+ defaultValue: 'Peanut',
+ size: 'medium',
+ name: 'toggle-group-nuts',
+};
+
+export const OnlyIcons: StoryFn = () => {
+ const handleChange = (value: string) => {
+ console.log(value);
+ };
+
+ return (
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+ );
+};
+
+export const Controlled: StoryFn = () => {
+ const [value, setValue] = useState('peanut');
+ return (
+ <>
+
+
+
+
+
+ }
+ >
+ Pistachio
+
+
+ Peanut
+
+ }
+ >
+ Walnut
+
+
+
+ You have chosen: {value}
+ >
+ );
+};
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx b/packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx
new file mode 100644
index 0000000000..d06936c25c
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.test.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ToggleGroup } from '.';
+
+const user = userEvent.setup();
+
+describe('ToggleGroup', () => {
+ test('has generated name for ToggleGroupItem children', () => {
+ render(
+
+ test
+ ,
+ );
+
+ const item = screen.getByRole('radio');
+ expect(item).toHaveAttribute('name');
+ });
+
+ test('has passed name to ToggleGroupItem children', (): void => {
+ render(
+
+ test
+ ,
+ );
+
+ const item = screen.getByRole('radio');
+ expect(item.name).toEqual('my name');
+ });
+ test('has passed size to ToggleGroupItem children', (): void => {
+ render(
+
+ test
+ ,
+ );
+
+ const item = screen.getByRole('radio');
+ expect(item).toHaveClass('medium');
+ });
+ test('can navigate with tab and arrow keys', async () => {
+ render(
+
+ test
+ test2
+ test3
+ ,
+ );
+
+ const item1 = screen.getByRole('radio', {
+ name: 'test',
+ });
+ const item2 = screen.getByRole('radio', {
+ name: 'test2',
+ });
+ const item3 = screen.getByRole('radio', {
+ name: 'test3',
+ });
+ await user.tab();
+ expect(item1).toHaveFocus();
+ await user.type(item1, '{arrowright}');
+ expect(item2).toHaveFocus();
+ await user.type(item2, '{arrowright}');
+ expect(item3).toHaveFocus();
+ await user.type(item3, '{arrowleft}');
+ expect(item2).toHaveFocus();
+ });
+ test('has correct ToggleGroupItem defaultChecked & checked when defaultValue is used', () => {
+ render(
+
+ test1
+ test2
+ test3
+ ,
+ );
+
+ const item = screen.getByRole('radio', {
+ name: 'test2',
+ });
+ expect(item).toHaveAttribute('aria-checked', 'true');
+ });
+ test('has passed clicked ToggleGroupItem element to onChange', async () => {
+ let onChangeValue = '';
+
+ render(
+ (onChangeValue = value)}>
+ test1
+ test2
+ ,
+ );
+
+ const item = screen.getByRole('radio', {
+ name: 'test2',
+ });
+
+ expect(item).toHaveAttribute('aria-checked', 'false');
+
+ await user.click(item);
+
+ expect(onChangeValue).toEqual('test2');
+ expect(item).toHaveAttribute('aria-checked', 'true');
+ });
+ test('has passed clicked ToggleGroupItem element to onChange when defaultValue is used', async () => {
+ let onChangeValue = '';
+
+ render(
+ (onChangeValue = value)}
+ >
+ test1
+ test2
+ ,
+ );
+
+ const item1 = screen.getByRole('radio', {
+ name: 'test1',
+ });
+ const item2 = screen.getByRole('radio', {
+ name: 'test2',
+ });
+
+ expect(item1).toHaveAttribute('aria-checked', 'true');
+ expect(item2).toHaveAttribute('aria-checked', 'false');
+
+ await user.click(item2);
+
+ expect(onChangeValue).toEqual('test2');
+ expect(item2).toHaveAttribute('aria-checked', 'true');
+ });
+});
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroup.tsx b/packages/react/src/components/ToggleGroup/ToggleGroup.tsx
new file mode 100644
index 0000000000..9f01c90805
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroup.tsx
@@ -0,0 +1,89 @@
+import type { HTMLAttributes } from 'react';
+import React, { createContext, forwardRef, useId, useState } from 'react';
+import cn from 'classnames';
+
+import { RovingTabindexRoot } from '../../utility-components/RovingTabIndex';
+
+import classes from './ToggleGroup.module.css';
+
+export type ToggleGroupContextProps = {
+ value?: string;
+ defaultValue?: string;
+ onChange?: (value: string) => void;
+ name?: string;
+ size?: 'small' | 'medium' | 'large';
+};
+
+export const ToggleGroupContext = createContext({});
+
+export type ToggleGroupProps = {
+ /** Controlled state for `ToggleGroup` component. */
+ value?: string;
+ /** Default value. */
+ defaultValue?: string;
+ /** Callback with selected `ToggleGroupItem` `value` */
+ onChange?: (value: string) => void;
+ /** Form element name */
+ name?: string;
+ /** Changes items size and paddings */
+ size?: 'small' | 'medium' | 'large';
+} & Omit, 'value' | 'onChange'>;
+
+/** `ToggleGroup` component.
+ * @example
+ * ```tsx
+ * console.log(value)}>
+ * Toggle 1
+ * Toggle 2
+ * Toggle 3
+ *
+ * ```
+ */
+export const ToggleGroup = forwardRef(
+ (
+ { children, value, defaultValue, onChange, size = 'medium', name, ...rest },
+ ref,
+ ) => {
+ const nameId = useId();
+ const isControlled = value !== undefined;
+ const [uncontrolledValue, setUncontrolledValue] = useState<
+ string | undefined
+ >(defaultValue);
+
+ let onValueChange = onChange;
+ if (!isControlled) {
+ onValueChange = (newValue: string) => {
+ setUncontrolledValue(newValue);
+ onChange?.(newValue);
+ };
+ value = uncontrolledValue;
+ }
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ },
+);
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css
new file mode 100644
index 0000000000..ff805e0589
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.module.css
@@ -0,0 +1,15 @@
+.toggleGroupItem.small {
+ padding: var(--fds-spacing-1) var(--fds-spacing-2);
+}
+
+.toggleGroupItem.medium {
+ padding: var(--fds-spacing-2) var(--fds-spacing-3);
+}
+
+.toggleGroupItem.large {
+ padding: var(--fds-spacing-2) var(--fds-spacing-3);
+}
+
+.toggleGroupItem:focus-visible {
+ z-index: 1;
+}
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx
new file mode 100644
index 0000000000..6476b587a7
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroupItem/ToggleGroupItem.tsx
@@ -0,0 +1,47 @@
+import type { ButtonHTMLAttributes } from 'react';
+import React, { forwardRef } from 'react';
+import cn from 'classnames';
+
+import { Button } from '../../Button';
+import { RovingTabindexItem } from '../../../utility-components/RovingTabIndex';
+
+import classes from './ToggleGroupItem.module.css';
+import { useToggleGroupItem } from './useToggleGroupitem';
+
+export type ToggleGroupItemProps = {
+ /** The value of the ToggleGroupItem. If not set, the string value of the items children will be used. */
+ value?: string;
+ /** Icon to be displayed on the ToggleGroupItem */
+ icon?: React.ReactNode;
+ /** The text to be displayed on the ToggleGroupItem */
+ children?: string;
+} & Omit, 'value' | 'children'>;
+
+export const ToggleGroupItem = forwardRef<
+ HTMLButtonElement,
+ ToggleGroupItemProps
+>((props, ref) => {
+ const { children, icon, ...rest } = props;
+ const { active, size = 'medium', buttonProps } = useToggleGroupItem(props);
+ return (
+
+ {children}
+
+ );
+});
diff --git a/packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts b/packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts
new file mode 100644
index 0000000000..cc247c279b
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/ToggleGroupItem/useToggleGroupitem.ts
@@ -0,0 +1,43 @@
+import { useContext, useId } from 'react';
+
+import { ToggleGroupContext } from '../ToggleGroup';
+import type { ButtonProps } from '../../Button';
+
+import type { ToggleGroupItemProps } from './ToggleGroupItem';
+
+type UseToggleGroupItem = (props: ToggleGroupItemProps) => {
+ active: boolean;
+ size?: 'small' | 'medium' | 'large';
+ buttonProps?: Pick<
+ ButtonProps,
+ 'id' | 'onClick' | 'role' | 'aria-checked' | 'aria-current' | 'name'
+ >;
+};
+
+/** Handles props for `ToggleGroup.Item` in context with `ToggleGroup` and `RovingTabIndex` */
+export const useToggleGroupItem: UseToggleGroupItem = (
+ props: ToggleGroupItemProps,
+) => {
+ const { ...rest } = props;
+ const toggleGroup = useContext(ToggleGroupContext);
+ const itemValue =
+ props.value ?? (typeof props.children === 'string' ? props.children : '');
+ const active = toggleGroup.value == itemValue;
+ const buttonId = `togglegroup-item-${useId()}`;
+
+ return {
+ ...rest,
+ active: active,
+ size: toggleGroup?.size,
+ buttonProps: {
+ id: buttonId,
+ 'aria-checked': active,
+ 'aria-current': active,
+ role: 'radio',
+ name: toggleGroup.name,
+ onClick: () => {
+ toggleGroup.onChange?.(itemValue);
+ },
+ },
+ };
+};
diff --git a/packages/react/src/components/ToggleGroup/index.ts b/packages/react/src/components/ToggleGroup/index.ts
new file mode 100644
index 0000000000..5aedd6a78f
--- /dev/null
+++ b/packages/react/src/components/ToggleGroup/index.ts
@@ -0,0 +1,17 @@
+import { ToggleGroup as ToggleGroupRoot } from './ToggleGroup';
+import { ToggleGroupItem } from './ToggleGroupItem/ToggleGroupItem';
+
+export type { ToggleGroupProps } from './ToggleGroup';
+export type { ToggleGroupItemProps } from './ToggleGroupItem/ToggleGroupItem';
+
+type ToggleGroupComponent = typeof ToggleGroupRoot & {
+ Item: typeof ToggleGroupItem;
+};
+
+const ToggleGroup = ToggleGroupRoot as ToggleGroupComponent;
+
+ToggleGroup.Item = ToggleGroupItem;
+
+ToggleGroup.Item.displayName = 'ToggleGroup.Item';
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.module.css b/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.module.css
index 831d41eb4e..1830300e98 100644
--- a/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.module.css
+++ b/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.module.css
@@ -13,6 +13,13 @@
margin-bottom: var(--fdsc-bottom-spacing);
}
+.errorMessage.large {
+ --fdsc-bottom-spacing: var(--fds-spacing-5);
+
+ font: var(--fds-typography-error_message-large);
+ font-family: var(--fdsc-typography-font-family);
+}
+
.errorMessage.medium {
--fdsc-bottom-spacing: var(--fds-spacing-5);
diff --git a/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.tsx b/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.tsx
index ed0d9fbefb..bf62c32f91 100644
--- a/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.tsx
+++ b/packages/react/src/components/Typography/ErrorMessage/ErrorMessage.tsx
@@ -8,7 +8,7 @@ import classes from './ErrorMessage.module.css';
export type ErrorMessageProps = {
/** Changes text sizing */
- size?: 'xsmall' | 'small' | 'medium';
+ size?: 'xsmall' | 'small' | 'medium' | 'large';
/** Adds margin-bottom */
spacing?: boolean;
/** Toggle error color */
diff --git a/packages/react/src/components/form/CharacterCounter.tsx b/packages/react/src/components/form/CharacterCounter.tsx
new file mode 100644
index 0000000000..c123211dfc
--- /dev/null
+++ b/packages/react/src/components/form/CharacterCounter.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+
+import utilityClasses from '../../utils/utility.module.css';
+import { ErrorMessage } from '../Typography';
+
+export type CharacterLimitProps = Omit<
+ CharacterCounterProps,
+ 'id' | 'value' | 'size'
+>;
+
+type CharacterCounterProps = {
+ /** The message indicating the remaining character limit. */
+ label?: (count: number) => string;
+ /** The description of the maximum character limit for screen readers. */
+ srLabel?: string;
+ /** The maximum allowed character count. */
+ maxCount: number;
+ /** The current value. */
+ value: string;
+ /** The ID of the element that describes the maximum character limit for accessibility purposes. */
+ id: string;
+ /** Text size */
+ size?: 'xsmall' | 'small' | 'medium' | 'large';
+};
+
+const defaultLabel: CharacterCounterProps['label'] = (count) =>
+ count > -1 ? `${count} tegn igjen` : `${Math.abs(count)} tegn for mye`;
+
+const defaultSrLabel = (maxCount: number) =>
+ `Tekstfelt med plass til ${maxCount} tegn`;
+
+export const CharacterCounter = ({
+ label = defaultLabel,
+ srLabel: propsSrLabel,
+ maxCount,
+ value,
+ id,
+ size,
+}: CharacterCounterProps): JSX.Element => {
+ const currentCount = maxCount - value.length;
+ const hasExceededLimit = value.length > maxCount;
+ const srLabel = propsSrLabel ? propsSrLabel : defaultSrLabel(maxCount);
+
+ return (
+ <>
+
+ {srLabel}
+
+
+ {label(currentCount)}
+
+ >
+ );
+};
diff --git a/packages/react/src/components/form/Checkbox/index.ts b/packages/react/src/components/form/Checkbox/index.ts
index 985e7afe60..8069d15009 100644
--- a/packages/react/src/components/form/Checkbox/index.ts
+++ b/packages/react/src/components/form/Checkbox/index.ts
@@ -26,4 +26,4 @@ Checkbox.Group.displayName = 'Checkbox.Group';
export type { CheckboxProps, CheckboxGroupProps };
-export { Checkbox };
+export { Checkbox, CheckboxGroup };
diff --git a/packages/react/src/components/form/Fieldset/Fieldset.tsx b/packages/react/src/components/form/Fieldset/Fieldset.tsx
index f761062569..6716367d70 100644
--- a/packages/react/src/components/form/Fieldset/Fieldset.tsx
+++ b/packages/react/src/components/form/Fieldset/Fieldset.tsx
@@ -5,17 +5,15 @@ import { PadlockLockedFillIcon } from '@navikt/aksel-icons';
import { Label, Paragraph, ErrorMessage } from '../../Typography';
import utilityclasses from '../../../utils/utility.module.css';
+import type { FormFieldProps } from '../useFormField';
import { useFieldset } from './useFieldset';
import classes from './Fieldset.module.css';
-export type FieldsetContextType = {
- error?: ReactNode;
- errorId?: string;
- disabled?: boolean;
- readOnly?: boolean;
- size?: 'xsmall' | 'small' | 'medium';
-};
+export type FieldsetContextType = Pick<
+ FormFieldProps,
+ 'error' | 'errorId' | 'disabled' | 'readOnly' | 'size'
+>;
export const FieldsetContext = createContext(null);
@@ -28,14 +26,13 @@ export type FieldsetProps = {
error?: ReactNode;
/** The legend of the fieldset. */
legend?: ReactNode;
- /** The size of the fieldset. */
- size?: 'xsmall' | 'small' | 'medium';
/** Toggle `readOnly` on fieldset context.
* @note This does not prevent fieldset values from being submited */
readOnly?: boolean;
/** Visually hide `legend` and `description` (still available for screen readers) */
hideLegend?: boolean;
-} & FieldsetHTMLAttributes;
+} & Pick &
+ FieldsetHTMLAttributes;
export const Fieldset = forwardRef(
(props, ref) => {
diff --git a/packages/react/src/components/form/Radio/index.ts b/packages/react/src/components/form/Radio/index.ts
index f0de6d81f7..33a118a8dc 100644
--- a/packages/react/src/components/form/Radio/index.ts
+++ b/packages/react/src/components/form/Radio/index.ts
@@ -24,4 +24,4 @@ Radio.Group.displayName = 'Radio.Group';
export type { RadioProps, RadioGroupProps };
-export { Radio };
+export { Radio, RadioGroup };
diff --git a/packages/react/src/components/form/Textarea/Textarea.mdx b/packages/react/src/components/form/Textarea/Textarea.mdx
new file mode 100644
index 0000000000..f1c800787c
--- /dev/null
+++ b/packages/react/src/components/form/Textarea/Textarea.mdx
@@ -0,0 +1,22 @@
+import { Meta, Canvas, Story, Controls, Primary } from '@storybook/blocks';
+import { Information } from '../../../../../../docs-components';
+import * as TextareaStories from './Textarea.stories';
+
+
+
+# Textarea
+
+
+
+
+## Antall tegn
+
+
+
+## Kontrollert
+
+
+
+## Full bredde
+
+
diff --git a/packages/react/src/components/form/Textarea/Textarea.module.css b/packages/react/src/components/form/Textarea/Textarea.module.css
new file mode 100644
index 0000000000..c2f3915f63
--- /dev/null
+++ b/packages/react/src/components/form/Textarea/Textarea.module.css
@@ -0,0 +1,78 @@
+.formField {
+ display: grid;
+ gap: var(--fds-spacing-2);
+}
+
+.padlock {
+ height: 1.2rem;
+ width: 1.2rem;
+}
+
+.errorMessage:empty {
+ display: none;
+}
+
+.label {
+ min-width: min-content;
+ display: inline-flex;
+ flex-direction: row;
+ gap: var(--fds-spacing-1);
+ align-items: center;
+}
+
+.description {
+ color: var(--fds-semantic-text-neutral-subtle);
+ margin-top: calc(var(--fds-spacing-2) * -1);
+}
+
+.textarea {
+ font: inherit;
+ position: relative;
+ box-sizing: border-box;
+ flex: 0 1 auto;
+ min-height: 2.5em;
+ width: 100%;
+ appearance: none;
+ padding: var(--fds-spacing-3);
+ border: solid 1px var(--fds-semantic-border-action-dark);
+ border-radius: var(--fds-border_radius-medium);
+ resize: vertical;
+}
+
+.textarea.xsmall,
+.textarea.small {
+ padding: var(--fds-spacing-2);
+}
+
+.textarea.medium {
+ padding: var(--fds-spacing-3);
+}
+
+.textarea.large {
+ padding: var(--fds-spacing-4);
+}
+
+.disabled {
+ opacity: 0.3;
+}
+
+.disabled .textarea {
+ cursor: not-allowed;
+}
+
+.readonly .textarea {
+ background: var(--fds-semantic-surface-neutral-subtle);
+ border-color: var(--fds-semantic-border-neutral-default);
+}
+
+.error > .textarea:not(:focus-visible) {
+ border-color: var(--fds-semantic-border-danger-default);
+ box-shadow: inset 0 0 0 1px var(--fds-semantic-border-danger-default);
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .textarea:not(:focus-visible, :disabled):hover {
+ border-color: var(--fds-semantic-border-action-hover);
+ box-shadow: inset 0 0 0 1px var(--fds-semantic-border-action-hover);
+ }
+}
diff --git a/packages/react/src/components/form/Textarea/Textarea.stories.tsx b/packages/react/src/components/form/Textarea/Textarea.stories.tsx
new file mode 100644
index 0000000000..ff738ba3e3
--- /dev/null
+++ b/packages/react/src/components/form/Textarea/Textarea.stories.tsx
@@ -0,0 +1,71 @@
+import type { Meta, StoryObj, StoryFn } from '@storybook/react';
+import React, { useState } from 'react';
+
+import { Button, Paragraph } from '../..';
+
+import { Textarea } from '.';
+
+type Story = StoryObj;
+
+export default {
+ title: 'Felles/Textarea',
+ component: Textarea,
+} as Meta;
+
+export const Preview: Story = {
+ args: {
+ label: 'Label',
+ disabled: false,
+ readOnly: false,
+ size: 'medium',
+ description: '',
+ error: '',
+ cols: 40,
+ },
+};
+
+export const WithCharacterCounter: Story = {
+ args: {
+ label: 'Label',
+ cols: 40,
+ characterLimit: {
+ maxCount: 5,
+ },
+ },
+};
+
+export const FullWidth: Story = {
+ args: {
+ label: 'Label',
+ rows: 10,
+ cols: 40,
+ },
+ parameters: {
+ layout: 'padded',
+ },
+};
+
+export const Controlled: StoryFn = () => {
+ const [value, setValue] = useState();
+ return (
+ <>
+ Du har skrevet inn: {value}
+
+
+ >
+ );
+};
diff --git a/packages/react/src/components/form/Textarea/Textarea.test.tsx b/packages/react/src/components/form/Textarea/Textarea.test.tsx
new file mode 100644
index 0000000000..41934fe19c
--- /dev/null
+++ b/packages/react/src/components/form/Textarea/Textarea.test.tsx
@@ -0,0 +1,136 @@
+import { render as renderRtl, screen } from '@testing-library/react';
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+
+import type { TextareaProps } from './Textarea';
+import { Textarea } from './Textarea';
+
+const user = userEvent.setup();
+
+describe('Textarea', () => {
+ test('has correct value and label', () => {
+ render({ value: 'test', label: 'label' });
+ expect(screen.getByLabelText('label')).toBeDefined();
+ expect(screen.getByDisplayValue('test')).toBeDefined();
+ });
+
+ test('has correct description', () => {
+ render({ description: 'description' });
+ expect(
+ screen.getByRole('textbox', { description: 'description' }),
+ ).toBeDefined();
+ });
+
+ test('has correct description and label when label is hidden', () => {
+ render({ description: 'description', label: 'label', hideLabel: true });
+
+ expect(screen.getByLabelText('label')).toBeDefined();
+ expect(
+ screen.getByRole('textbox', { description: 'description' }),
+ ).toBeDefined();
+ });
+
+ test('is invalid with correct error message', () => {
+ render({ error: 'error-message' });
+
+ const textarea = screen.getByRole('textbox', {
+ description: 'error-message',
+ });
+ expect(textarea).toBeDefined();
+ expect(textarea).toBeInvalid();
+ });
+ test('is invalid with correct error message from errorId', () => {
+ renderRtl(
+ <>
+ my error message
+
+ >,
+ );
+
+ const textarea = screen.getByRole('textbox', {
+ description: 'my error message',
+ });
+ expect(textarea).toBeDefined();
+ expect(textarea).toBeInvalid();
+ });
+ it('should have max allowed characters label for screen readers', () => {
+ render({
+ characterLimit: {
+ maxCount: 10,
+ srLabel: 'Max 10 characters is allowed',
+ label: (count: number) => `${count} characters remaining`,
+ },
+ });
+ const screenReaderText = screen.getByText('Max 10 characters is allowed');
+ expect(screenReaderText).toBeInTheDocument();
+ });
+
+ it('should countdown remaining characters', async () => {
+ const user = userEvent.setup();
+ render({
+ label: 'First name',
+ characterLimit: {
+ maxCount: 10,
+ label: (count: number) => `${count} characters remaining`,
+ srLabel: 'characters remaining',
+ },
+ });
+ const textareaField = screen.getByLabelText('First name');
+ await user.type(textareaField, 'Peter');
+ expect(screen.getByText('5 characters remaining')).toBeInTheDocument();
+ });
+
+ it('Triggers onBlur event when field loses focus', async () => {
+ const onBlur = jest.fn();
+ render({ onBlur });
+ const element = screen.getByRole('textbox');
+ await user.click(element);
+ expect(element).toHaveFocus();
+ await user.tab();
+ expect(onBlur).toHaveBeenCalledTimes(1);
+ });
+
+ it('Triggers onChange event for each keystroke', async () => {
+ const onChange = jest.fn();
+ const data = 'test';
+ render({ onChange });
+ const element = screen.getByRole('textbox');
+ await user.click(element);
+ expect(element).toHaveFocus();
+ await user.keyboard(data);
+ expect(onChange).toHaveBeenCalledTimes(data.length);
+ });
+
+ it('Sets given id on textarea field', () => {
+ const id = 'some-unique-id';
+ render({ id });
+ expect(screen.getByRole('textbox')).toHaveAttribute('id', id);
+ });
+
+ it('Focuses on textarea field when label is clicked and id is not given', async () => {
+ const label = 'Lorem ipsum';
+ render({ label });
+ await user.click(screen.getByText(label));
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+
+ it('Focuses on textarea field when label is clicked and id is given', async () => {
+ const label = 'Lorem ipsum';
+ render({ id: 'some-unique-id', label });
+ await user.click(screen.getByText(label));
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+});
+
+const render = (props: Partial = {}) =>
+ renderRtl(
+ ,
+ );
diff --git a/packages/react/src/components/form/Textarea/Textarea.tsx b/packages/react/src/components/form/Textarea/Textarea.tsx
new file mode 100644
index 0000000000..95cf378a66
--- /dev/null
+++ b/packages/react/src/components/form/Textarea/Textarea.tsx
@@ -0,0 +1,145 @@
+import type { ReactNode, TextareaHTMLAttributes } from 'react';
+import React, { useState, forwardRef } from 'react';
+import cn from 'classnames';
+import { PadlockLockedFillIcon } from '@navikt/aksel-icons';
+
+import { omit } from '../../../utils';
+import { Label, Paragraph, ErrorMessage } from '../../Typography';
+import type { FormFieldProps } from '../useFormField';
+import type { CharacterLimitProps } from '../CharacterCounter';
+import { CharacterCounter } from '../CharacterCounter';
+
+import { useTextarea } from './useTextarea';
+import classes from './Textarea.module.css';
+import utilityClasses from './../../../utils/utility.module.css';
+
+export type TextareaProps = {
+ /** Label */
+ label?: ReactNode;
+ /** Visually hides `label` and `description` (still available for screen readers) */
+ hideLabel?: boolean;
+ /** Changes field size and paddings */
+ size?: 'xsmall' | 'small' | 'medium' | 'large';
+
+ /**
+ * The characterLimit function calculates remaining characters based on `maxCount`
+ *
+ * Provide a `label` function that takes count as parameter and returns a message.
+ *
+ * Use `srLabel` to describe `maxCount` for screen readers.
+ *
+ * Defaults to Norwegian if no labels are provided.
+ */
+ characterLimit?: CharacterLimitProps;
+} & Omit &
+ Omit, 'size'>;
+
+/** Textarea field
+ *
+ * @example
+ * ```tsx
+ *