diff --git a/.changeset/chilly-pigs-heal.md b/.changeset/chilly-pigs-heal.md new file mode 100644 index 0000000000..45f94ab6cc --- /dev/null +++ b/.changeset/chilly-pigs-heal.md @@ -0,0 +1,6 @@ +--- +"@digdir/designsystemet-react": patch +"@digdir/designsystemet-web": patch +--- + +**Field**: `` should now respect existing `aria-describedby` and `aria-invalid` diff --git a/.changeset/eight-taxes-cut.md b/.changeset/eight-taxes-cut.md new file mode 100644 index 0000000000..c9a7f04848 --- /dev/null +++ b/.changeset/eight-taxes-cut.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-react": patch +--- + +**Pagination:** fix `PaginationButton` missing some `Button` props diff --git a/.changeset/free-bobcats-dream.md b/.changeset/free-bobcats-dream.md new file mode 100644 index 0000000000..3af698fa1f --- /dev/null +++ b/.changeset/free-bobcats-dream.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-react": patch +--- + +**Tabs**: Now supports programmatically triggering click on controlled `Tabs` diff --git a/.changeset/lovely-kids-reflect.md b/.changeset/lovely-kids-reflect.md new file mode 100644 index 0000000000..fdf64c7fc8 --- /dev/null +++ b/.changeset/lovely-kids-reflect.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-web": patch +--- + +**Field:** No longer uses `CSS.supports` to play nice with Jest + JSDOM diff --git a/.changeset/nasty-shrimps-watch.md b/.changeset/nasty-shrimps-watch.md new file mode 100644 index 0000000000..8793f85506 --- /dev/null +++ b/.changeset/nasty-shrimps-watch.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-web": patch +--- + +fixed some native keystrokes being ignored if readonly fields were focused diff --git a/.changeset/old-boats-work.md b/.changeset/old-boats-work.md new file mode 100644 index 0000000000..4a71957b35 --- /dev/null +++ b/.changeset/old-boats-work.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-css": patch +--- + +**Breadcrumbs:** Renders correcly as `display: block` diff --git a/.changeset/pretty-bugs-enter.md b/.changeset/pretty-bugs-enter.md new file mode 100644 index 0000000000..f3559934f0 --- /dev/null +++ b/.changeset/pretty-bugs-enter.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-web": patch +--- + +`invokers-polyfill` is now bundled inline as part of source files for better compatibility with Jest module resolving. diff --git a/.changeset/sharp-bulldogs-run.md b/.changeset/sharp-bulldogs-run.md new file mode 100644 index 0000000000..eb7aa31724 --- /dev/null +++ b/.changeset/sharp-bulldogs-run.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-web": patch +--- + +**All components:** Renders instantly for easier test setup diff --git a/.changeset/swift-shrimps-return.md b/.changeset/swift-shrimps-return.md new file mode 100644 index 0000000000..a78d268828 --- /dev/null +++ b/.changeset/swift-shrimps-return.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-react": patch +--- + +**Dialog:** Use `ref` in `Dialog.TriggerContext` for better performance diff --git a/packages/css/src/breadcrumbs.css b/packages/css/src/breadcrumbs.css index 71444e6310..a499249bc7 100644 --- a/packages/css/src/breadcrumbs.css +++ b/packages/css/src/breadcrumbs.css @@ -5,6 +5,10 @@ --dsc-breadcrumbs-color: var(--ds-color-text-subtle); --_ds-aria-label: var(--dsc-breadcrumbs-label); /* "proxy" so attrOrCSS works even if changing --ds- prefix */ + &:not([hidden]) { + display: block; /* Needed for element */ + } + &:is([lang='nb'], [lang='nn'], [lang='no']), :is([lang='nb'], [lang='nn'], [lang='no']) & { --dsc-breadcrumbs-label: 'Du er her:'; /* Only set default label if Norwegian */ diff --git a/packages/react/src/components/breadcrumbs/breadcrumbs.test.tsx b/packages/react/src/components/breadcrumbs/breadcrumbs.test.tsx index e26ff5e457..9c6235856c 100644 --- a/packages/react/src/components/breadcrumbs/breadcrumbs.test.tsx +++ b/packages/react/src/components/breadcrumbs/breadcrumbs.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import type { BreadcrumbsProps } from '../'; import { Breadcrumbs } from '../'; @@ -26,34 +26,29 @@ const renderWithRoot = (props?: BreadcrumbsProps) => ); describe('Breadcrumbs', () => { - it('should render correctly with default props', async () => { + it('should render correctly with default props', () => { renderWithRoot(); - expect(await screen.findByRole('navigation')).toBeInTheDocument(); + expect(screen.getByRole('navigation')).toBeInTheDocument(); }); }); describe('Breadcrumbs.List', () => { - it('should render with aria-current on last item', async () => { + it('should render with aria-current on last item', () => { renderWithRoot(); - await waitFor(() => { - const links = screen.getAllByRole('link'); - expect(links.at(-1)).toHaveAttribute('aria-current', 'page'); - }); const links = screen.getAllByRole('link'); + expect(links.at(-1)).toHaveAttribute('aria-current', 'page'); expect(links.at(0)).not.toHaveAttribute('aria-current', 'page'); expect(links.at(1)).not.toHaveAttribute('aria-current', 'page'); expect(links.at(2)).not.toHaveAttribute('aria-current', 'page'); }); - it('should move aria-current to item when re-rendering', async () => { + it('should move aria-current to item when re-rendering', () => { renderWithRoot(); - await waitFor(() => { - const links = screen.getAllByRole('link'); - expect(links.at(-1)).toHaveAttribute('aria-current', 'page'); - }); + const links = screen.getAllByRole('link'); + expect(links.at(-1)).toHaveAttribute('aria-current', 'page'); // Re-render with additional level render( @@ -81,11 +76,7 @@ describe('Breadcrumbs.List', () => { , ); - await waitFor(() => { - const links = screen.getAllByRole('link'); - expect(links.at(-1)).toHaveAttribute('aria-current', 'page'); - }); - const links = screen.getAllByRole('link'); + expect(links.at(-1)).toHaveAttribute('aria-current', 'page'); expect(links.at(-2)).not.toHaveAttribute('aria-current', 'page'); }); }); diff --git a/packages/react/src/components/button/button.test.tsx b/packages/react/src/components/button/button.test.tsx index edeeb5449e..7c66cfa560 100644 --- a/packages/react/src/components/button/button.test.tsx +++ b/packages/react/src/components/button/button.test.tsx @@ -1,12 +1,6 @@ -import { render as renderRtl, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { act } from 'react'; - -import type { ButtonProps } from './button'; +import { act, render, screen } from '@testing-library/react'; import { Button } from './button'; -const user = userEvent.setup(); - describe('Button', () => { beforeAll(() => { // Spinner for loading state uses animations, which we need to mock @@ -16,9 +10,7 @@ describe('Button', () => { }); it('should render as aria-disabled when aria-disabled is true regardless of variant', () => { - render({ - 'aria-disabled': true, - }); + render(); expect( screen.getByRole('button', { name: 'different button text' }), ).toBeInTheDocument(); @@ -56,22 +42,28 @@ describe('Button', () => { it('should handle onClick event', async () => { const fn = vi.fn(); - render({ onClick: fn }); - await act(async () => await user.click(screen.getByRole('button'))); + render(, + ); expect(screen.getByRole('link')).not.toHaveAttribute('type'); - expect(screen.queryByRole('button')).toBeNull(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); it('should not render children when icon-only button is loading', () => { - render({ loading: true, icon: true, children: 'Button text' }); - expect(screen.queryByText('Button text')).toBeNull(); + render( + , + ); + expect(screen.queryByText('Button text')).not.toBeInTheDocument(); expect(screen.getByRole('button')).toHaveAttribute('aria-busy'); }); }); - -const render = (props?: ButtonProps) => renderRtl( - ), - onClose, - closeButton: false, - }); + , + ); - await user.click(screen.getByRole('button', { name: OPEN_Dialog })); - await user.click(screen.getByTestId('closebutton')); + screen.getByRole('button', { name: OPEN_Dialog }).click(); + await act(async () => screen.getByTestId('closebutton').click()); expect(onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react/src/components/dialog/dialog.tsx b/packages/react/src/components/dialog/dialog.tsx index 0bdc1ab3dc..986baea96d 100644 --- a/packages/react/src/components/dialog/dialog.tsx +++ b/packages/react/src/components/dialog/dialog.tsx @@ -97,10 +97,10 @@ export const Dialog = forwardRef( }, ref, ) { - const { setContext } = useContext(Context); + const contextRef = useContext(Context); const dialogRef = useRef(null); // This local ref is used to make sure the dialog works without a DialogTriggerContext const Component = asChild ? Slot : 'dialog'; - const mergedRefs = useMergeRefs([ref, dialogRef]); + const mergedRefs = useMergeRefs([contextRef, ref, dialogRef]); const showProp = modal ? 'showModal' : 'show'; const autoId = useId(); const usedId = id ?? autoId; @@ -108,13 +108,11 @@ export const Dialog = forwardRef( // Toggle open based on prop useEffect(() => dialogRef.current?.[open ? showProp : 'close'](), [open]); - // Store context for DialogTrigger to consume, so it can open the dialog when the trigger is clicked - useEffect(() => setContext?.({ id: usedId, modal }), [usedId, modal]); - return ( onClose?.(event.nativeEvent)} // Backward compatibility: expose native event onClick={(event) => { @@ -144,7 +142,7 @@ export const Dialog = forwardRef( icon variant='tertiary' command='close' - commandfor={id ?? autoId} + commandfor={usedId} /> )} {children} diff --git a/packages/react/src/components/dropdown/dropdown.test.tsx b/packages/react/src/components/dropdown/dropdown.test.tsx index 418020d442..59dd38773b 100644 --- a/packages/react/src/components/dropdown/dropdown.test.tsx +++ b/packages/react/src/components/dropdown/dropdown.test.tsx @@ -1,6 +1,4 @@ -import { render as renderRtl, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { act } from 'react'; +import { act, render, screen } from '@testing-library/react'; import { Dropdown } from './'; import type { DropdownTriggerContextProps } from './dropdown-trigger-context'; @@ -21,48 +19,33 @@ const Comp = (args: Partial) => { ); }; -const render = async (props: Partial = {}) => { - /* Flush microtasks */ - await act(async () => {}); - const user = userEvent.setup(); - - return { - user, - ...renderRtl(), - }; -}; - -describe('Dropdown', () => { +describe('Dropdown', async () => { /* We are testing closing and opening in Popover.tests.tsx */ it('should render children', async () => { - const { user } = await render({ - children: ( + render( + Item 2 - ), - }); - const dropdownTrigger = screen.getByRole('button'); + , + ); - await act(async () => await user.click(dropdownTrigger)); - - expect(screen.queryByText('Item 2')).toBeInTheDocument(); + await act(async () => screen.getByRole('button').click()); + expect(screen.getByText('Item 2')).toBeInTheDocument(); }); it('should be able to render `Dropdown.Button` as a anchor element using asChild', async () => { - const { user } = await render({ - children: ( + render( + Anchor - ), - }); - const dropdownTrigger = screen.getByRole('button'); - - await act(async () => await user.click(dropdownTrigger)); + , + ); + await act(async () => screen.getByRole('button').click()); expect(screen.getByText('Anchor')).toHaveAttribute('href', '/'); expect(screen.getByText('Anchor').tagName).toBe('A'); }); diff --git a/packages/react/src/components/input/input.test.tsx b/packages/react/src/components/input/input.test.tsx index b9757f2818..6984416479 100644 --- a/packages/react/src/components/input/input.test.tsx +++ b/packages/react/src/components/input/input.test.tsx @@ -1,32 +1,21 @@ -import { render as renderRtl, screen } from '@testing-library/react'; - -import type { InputProps } from './input'; +import { render, screen } from '@testing-library/react'; import { Input } from './input'; describe('Input', () => { test('has correct value and label', () => { - render({ value: 'test' }); - expect(screen.getByDisplayValue('test')).toBeDefined(); + const value = 'test'; + render(); + expect(screen.getByDisplayValue(value)).toBeDefined(); }); it('Has type attribute set to "text" by default', () => { - render(); + render(); expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); }); it('Has given type attribute if set', () => { const type = 'tel'; - render({ type }); + render(); expect(screen.getByRole('textbox')).toHaveAttribute('type', type); }); }); - -const render = (props: Partial = {}) => - renderRtl( - , - ); diff --git a/packages/react/src/components/link/link.test.tsx b/packages/react/src/components/link/link.test.tsx index 6197dfb37a..57ad451469 100644 --- a/packages/react/src/components/link/link.test.tsx +++ b/packages/react/src/components/link/link.test.tsx @@ -1,17 +1,14 @@ -import { render as renderRtl, screen } from '@testing-library/react'; -import type { ComponentProps, RefObject } from 'react'; +import { render, screen } from '@testing-library/react'; import { createRef } from 'react'; -import type { LinkProps } from './link'; import { Link } from './link'; // Test data: const href = 'https://designsystemet.no/'; const children = 'Gå til designsystemet'; -const defaultProps: LinkProps = { href, children }; describe('Link', () => { it('Renders an anchor element with the given text and href', () => { - render(); + render({children}); const link = screen.getByRole('link'); expect(link).toBeInTheDocument(); expect(link).toHaveTextContent(children); @@ -20,7 +17,11 @@ describe('Link', () => { it('Appends given className to the anchor element', () => { const className = 'foo'; - render({ className }); + render( + + {children} + , + ); const link = screen.getByRole('link'); expect(link).toHaveClass('ds-link'); expect(link).toHaveClass(className); @@ -28,19 +29,11 @@ describe('Link', () => { it('Sets the ref on the anchor element if given', () => { const ref = createRef(); - render({}, ref); + render( + + {children} + , + ); expect(ref.current).toBe(screen.getByRole('link')); }); }); - -const render = ( - props: Partial> = {}, - ref?: RefObject, -) => { - const allProps = { ...defaultProps, ...props }; - return renderRtl( - - {allProps.children} - , - ); -}; diff --git a/packages/react/src/components/list/list.test.tsx b/packages/react/src/components/list/list.test.tsx index 17eb95464a..7618b146fd 100644 --- a/packages/react/src/components/list/list.test.tsx +++ b/packages/react/src/components/list/list.test.tsx @@ -1,30 +1,29 @@ -import { render as renderRtl, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { List } from './'; -import type { ListUnorderedProps } from './lists'; - -const render = (props: Partial = {}) => { - const allProps: ListUnorderedProps = { - children: Test, - ...props, - }; - return renderRtl(); -}; describe('List', () => { it('Renders the list', () => { - render(); + render( + + Test + , + ); expect(screen.getByRole('list')).toBeInTheDocument(); }); it('Renders an unordered list', () => { - render(); + render( + + Test + , + ); const list = document.querySelector('ul'); expect(list).toBeInTheDocument(); }); it('Renders an ordered list', () => { - renderRtl( + render( Test , @@ -34,12 +33,20 @@ describe('List', () => { }); it('Renders the children', () => { - render(); + render( + + Test + , + ); expect(screen.getByText('Test')).toBeInTheDocument(); }); it('should have the passed size', () => { - render({ 'data-size': 'lg' }); + render( + + Test + , + ); expect(screen.getByRole('list')).toHaveAttribute('data-size', 'lg'); }); }); diff --git a/packages/react/src/components/pagination/pagination-button.tsx b/packages/react/src/components/pagination/pagination-button.tsx index fdd127f332..4fbbf85aa7 100644 --- a/packages/react/src/components/pagination/pagination-button.tsx +++ b/packages/react/src/components/pagination/pagination-button.tsx @@ -1,5 +1,5 @@ -import { Slot } from '@radix-ui/react-slot'; -import { type AriaAttributes, forwardRef, type HTMLAttributes } from 'react'; +import { type AriaAttributes, forwardRef } from 'react'; +import { Button, type ButtonProps } from '../button/button'; export type PaginationButtonProps = { /** @@ -7,12 +7,7 @@ export type PaginationButtonProps = { * @default false */ 'aria-current'?: AriaAttributes['aria-current']; - /** - * Change the default rendered element for the one passed as a child, merging their props and behavior. - * @default false - */ - asChild?: boolean; -} & HTMLAttributes; +} & Omit; /** * PaginationButton component, use within a Pagination.Item. @@ -25,16 +20,6 @@ export type PaginationButtonProps = { export const PaginationButton = forwardRef< HTMLButtonElement, PaginationButtonProps ->(function PaginationButton({ asChild, ...rest }, ref) { - const Component = asChild ? Slot : 'button'; - - return ( - adds attributes - ref={ref} - {...rest} - /> - ); +>(function PaginationButton(rest, ref) { + return '; const button = document.querySelector('button') as HTMLButtonElement; - await user.click(button); + button.focus(); }; - it('should mount live region on first user interaction', async () => { + it('should mount live region on first user interaction', () => { // Reset by removing any existing live region document.querySelector('[aria-live="assertive"]')?.remove(); - await ensureLiveRegionMounted(); + ensureLiveRegionMounted(); const live = document.querySelector('[aria-live="assertive"]'); expect(live).toBeInTheDocument(); }); - it('should reuse the same live region element', async () => { - await ensureLiveRegionMounted(); + it('should reuse the same live region element', () => { + ensureLiveRegionMounted(); announce('First'); announce('Second'); @@ -195,8 +192,8 @@ describe('utils', () => { expect(regions[0]).toHaveTextContent('Second'); }); - it('should alternate non-breaking space to force re-announcement', async () => { - await ensureLiveRegionMounted(); + it('should alternate non-breaking space to force re-announcement', () => { + ensureLiveRegionMounted(); announce('Same'); const live = document.querySelector('[aria-live="assertive"]'); @@ -210,8 +207,8 @@ describe('utils', () => { expect([first, second]).toContain('Same\u00A0'); }); - it('should not set text content when called without text', async () => { - await ensureLiveRegionMounted(); + it('should not set text content when called without text', () => { + ensureLiveRegionMounted(); announce('Existing'); const live = document.querySelector('[aria-live="assertive"]'); diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index e8db0513ba..c40c62f0bd 100644 --- a/packages/web/src/utils/utils.ts +++ b/packages/web/src/utils/utils.ts @@ -29,7 +29,7 @@ export function debounce( /** * warn - * @description Utility to console.warn, but can be silenced in production with window.dsWarnings = false; + * @description Utility to console.log, but can be silenced in production with window.dsWarnings = false; */ declare global { interface Window { @@ -38,11 +38,11 @@ declare global { } export const warn = ( message: string, - ...args: Parameters + ...args: Parameters // Using console.log, not console.warn, to prevent stopping test runners such as Jest ) => !isBrowser() || window.dsWarnings === false || - console.warn(`Designsystemet: ${message}`, ...args); + console.log(`\x1B[1mDesignsystemet:\x1B[m ${message}`, ...args); /** * attr @@ -62,7 +62,17 @@ export const attr = ( return null; }; -const STRIP_SURROUNDING_QUOTES = /^["']|["']$/g; // Matches surrounding single or double quotes +/** + * getCSSProp + * @description Retrieves and CSS property value and trims it + * @param el The Element to read attributes/CSS from + * @param name Attribute or CSS property to get + * @return string CSS property value + */ +export const getCSSProp = (el: Element, prop: string) => + getComputedStyle(el).getPropertyValue(prop).trim(); + +const STRIP_QUOTES = /^["']|["']$/g; // Matches surrounding single or double quotes /** * attrOrCSS * @description Retrieves and updates attribute based on attribute or CSS property value @@ -72,12 +82,10 @@ const STRIP_SURROUNDING_QUOTES = /^["']|["']$/g; // Matches surrounding single o */ export const attrOrCSS = (el: Element, name: string) => { let value = attr(el, name); - if (!value) { - const prop = getComputedStyle(el).getPropertyValue(`--_ds-${name}`); - value = prop.replace(STRIP_SURROUNDING_QUOTES, '').trim() || null; - } + if (!value) + value = getCSSProp(el, `--_ds-${name}`).replace(STRIP_QUOTES, '').trim(); if (!value) warn(`Missing ${name} on:`, el); - return value; + return value || null; }; /** @@ -131,29 +139,23 @@ export const onHotReload = (key: string, setup: () => Array<() => void>) => { }; /** - * Speed up MutationObserver by debouncing and only running when page is visible + * MutationObserver wrapper with automatic cleanup and option to skip mutations while updating textContent * @return new MutaionObserver */ let SKIP_MUTATIONS = false; -export const onMutation = ( - el: Node, - callback: (observer: MutationObserver) => void, +export const onMutation = ( + el: T, + callback: (el: T, records?: MutationRecord[]) => void, options: MutationObserverInit, ) => { - let queue = 0; - const onFrame = () => { - if (!el.isConnected) return cleanup(); // Stop observing if element is removed from DOM - callback(observer); - observer.takeRecords(); // Clear records in case mutations happened during callback - queue = 0; - }; - const cleanup = () => observer?.disconnect?.(); - const observer = new MutationObserver(() => { - if (!SKIP_MUTATIONS && !queue) queue = requestAnimationFrame(onFrame); // requestAnimationFrame only runs when page is visible + const cleanup = () => observer.disconnect(); + const observer = new MutationObserver((records) => { + if (!isBrowser() || !el.isConnected) return cleanup(); // Stop observing if element is removed from DOM or document is removed by jdsom tests + if (!SKIP_MUTATIONS) callback(el, records); }); observer.observe(el, options); - requestAnimationFrame(onFrame); // Initial run when page is visible and children has mounted + callback(el); // Initial is run instantly to make test markup predictable return cleanup; }; diff --git a/packages/web/vitest.browser.config.ts b/packages/web/vitest.config.ts similarity index 91% rename from packages/web/vitest.browser.config.ts rename to packages/web/vitest.config.ts index a35bde641f..08b7460007 100644 --- a/packages/web/vitest.browser.config.ts +++ b/packages/web/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // environment: 'jsdom', + // css: true, setupFiles: ['./vitest.setup.ts'], fakeTimers: { shouldAdvanceTime: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d9046ca87..1a8867ec42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -693,9 +693,6 @@ importers: '@u-elements/u-tabs': specifier: ^0.1.2 version: 0.1.2 - invokers-polyfill: - specifier: ^1.0.2 - version: 1.0.2(patch_hash=d5677be15320f04cdc552d82cb4a79242bd1cf8b26bcbb0a13e1153e7bed3b5a) devDependencies: '@custom-elements-manifest/analyzer': specifier: ^0.11.0 @@ -709,6 +706,9 @@ importers: custom-element-vs-code-integration: specifier: ^1.5.0 version: 1.5.0(prettier@3.8.1) + invokers-polyfill: + specifier: ^1.0.2 + version: 1.0.2(patch_hash=d5677be15320f04cdc552d82cb4a79242bd1cf8b26bcbb0a13e1153e7bed3b5a) rimraf: specifier: ^6.1.3 version: 6.1.3