From 3300b6478cf282f8cbc01eddd96e7c115b7a8bce Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Thu, 2 Apr 2026 08:07:07 +0200 Subject: [PATCH 1/5] feat: add router-provider and ui-kit-provider packages Introduces two new packages to decouple ui-kit from react-router-dom: - @commercetools-uikit/router-provider: Provides RouterProvider context, useNavigate hook, TLocationDescriptor types, and locationDescriptorToString utility. Components consume this to perform client-side navigation. - @commercetools-uikit/ui-kit-provider: Consumer-facing UIKitProvider that composes RouterProvider (and future providers). Accepts a router config with a navigate function. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/router-provider/index.ts | 1 + packages/router-provider/package.json | 32 ++++++++++++++++++ packages/router-provider/src/export-types.ts | 6 ++++ packages/router-provider/src/index.ts | 4 +++ .../router-provider/src/router-provider.tsx | 31 +++++++++++++++++ packages/router-provider/src/types.ts | 29 ++++++++++++++++ packages/router-provider/src/utils.ts | 19 +++++++++++ packages/router-provider/src/version.ts | 1 + packages/ui-kit-provider/index.ts | 1 + packages/ui-kit-provider/package.json | 33 +++++++++++++++++++ packages/ui-kit-provider/src/export-types.ts | 1 + packages/ui-kit-provider/src/index.ts | 3 ++ .../ui-kit-provider/src/ui-kit-provider.tsx | 24 ++++++++++++++ packages/ui-kit-provider/src/version.ts | 1 + 14 files changed, 186 insertions(+) create mode 100644 packages/router-provider/index.ts create mode 100644 packages/router-provider/package.json create mode 100644 packages/router-provider/src/export-types.ts create mode 100644 packages/router-provider/src/index.ts create mode 100644 packages/router-provider/src/router-provider.tsx create mode 100644 packages/router-provider/src/types.ts create mode 100644 packages/router-provider/src/utils.ts create mode 100644 packages/router-provider/src/version.ts create mode 100644 packages/ui-kit-provider/index.ts create mode 100644 packages/ui-kit-provider/package.json create mode 100644 packages/ui-kit-provider/src/export-types.ts create mode 100644 packages/ui-kit-provider/src/index.ts create mode 100644 packages/ui-kit-provider/src/ui-kit-provider.tsx create mode 100644 packages/ui-kit-provider/src/version.ts diff --git a/packages/router-provider/index.ts b/packages/router-provider/index.ts new file mode 100644 index 0000000000..8420b1093f --- /dev/null +++ b/packages/router-provider/index.ts @@ -0,0 +1 @@ +export * from './src'; diff --git a/packages/router-provider/package.json b/packages/router-provider/package.json new file mode 100644 index 0000000000..ebfae570ba --- /dev/null +++ b/packages/router-provider/package.json @@ -0,0 +1,32 @@ +{ + "name": "@commercetools-uikit/router-provider", + "description": "Router-agnostic navigation context for ui-kit components.", + "version": "20.5.0", + "bugs": "https://github.com/commercetools/ui-kit/issues", + "repository": { + "type": "git", + "url": "https://github.com/commercetools/ui-kit.git", + "directory": "packages/router-provider" + }, + "homepage": "https://uikit.commercetools.com", + "keywords": ["javascript", "typescript", "design-system", "react", "uikit"], + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "main": "dist/commercetools-uikit-router-provider.cjs.js", + "module": "dist/commercetools-uikit-router-provider.esm.js", + "files": ["dist"], + "dependencies": { + "@babel/runtime": "^7.20.13", + "@babel/runtime-corejs3": "^7.20.13", + "@emotion/react": "^11.10.5" + }, + "devDependencies": { + "react": "19.2.0" + }, + "peerDependencies": { + "react": "19.x" + } +} diff --git a/packages/router-provider/src/export-types.ts b/packages/router-provider/src/export-types.ts new file mode 100644 index 0000000000..cc9f4b75f1 --- /dev/null +++ b/packages/router-provider/src/export-types.ts @@ -0,0 +1,6 @@ +export type { TRouterProviderProps } from './router-provider'; +export type { + TLocationDescriptor, + TLocationDescriptorObject, + TRouterConfig, +} from './types'; diff --git a/packages/router-provider/src/index.ts b/packages/router-provider/src/index.ts new file mode 100644 index 0000000000..a244a2d312 --- /dev/null +++ b/packages/router-provider/src/index.ts @@ -0,0 +1,4 @@ +export { RouterProvider, useNavigate } from './router-provider'; +export { locationDescriptorToString } from './utils'; +export { default as version } from './version'; +export * from './export-types'; diff --git a/packages/router-provider/src/router-provider.tsx b/packages/router-provider/src/router-provider.tsx new file mode 100644 index 0000000000..052e1a2c72 --- /dev/null +++ b/packages/router-provider/src/router-provider.tsx @@ -0,0 +1,31 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import type { TRouterConfig, TLocationDescriptor } from './types'; + +const RouterContext = createContext(null); + +export type TRouterProviderProps = { + router: TRouterConfig; + children: ReactNode; +}; + +/** + * Provides router configuration (navigate function) to all ui-kit components. + */ +export const RouterProvider = (props: TRouterProviderProps) => { + const value = useMemo(() => props.router, [props.router]); + return ( + + {props.children} + + ); +}; +RouterProvider.displayName = 'RouterProvider'; + +/** + * Returns the navigate function from the nearest RouterProvider. + * Returns `null` if no provider is found (links fall back to default browser navigation). + */ +export const useNavigate = (): ((to: TLocationDescriptor) => void) | null => { + const context = useContext(RouterContext); + return context?.navigate ?? null; +}; diff --git a/packages/router-provider/src/types.ts b/packages/router-provider/src/types.ts new file mode 100644 index 0000000000..b0e579fb09 --- /dev/null +++ b/packages/router-provider/src/types.ts @@ -0,0 +1,29 @@ +/** + * An object describing a location, compatible with the `history` package's + * LocationDescriptorObject. This allows ui-kit to accept the same location + * shapes without depending on the `history` package. + */ +export type TLocationDescriptorObject = { + pathname?: string; + search?: string; + hash?: string; + state?: unknown; + key?: string; +}; + +/** + * A location can be either a URL string or a location descriptor object. + * This replaces `LocationDescriptor` from the `history` package. + */ +export type TLocationDescriptor = string | TLocationDescriptorObject; + +/** + * Configuration for the router integration. + */ +export type TRouterConfig = { + /** + * Function to perform client-side navigation. + * Typically `history.push` (react-router v5) or `navigate` (react-router v6+). + */ + navigate: (to: TLocationDescriptor) => void; +}; diff --git a/packages/router-provider/src/utils.ts b/packages/router-provider/src/utils.ts new file mode 100644 index 0000000000..b024daf2f1 --- /dev/null +++ b/packages/router-provider/src/utils.ts @@ -0,0 +1,19 @@ +import type { TLocationDescriptor, TLocationDescriptorObject } from './types'; + +/** + * Converts a TLocationDescriptor (string or object) to a URL string. + * Used to set the `href` attribute on `` tags. + */ +export function locationDescriptorToString(to: TLocationDescriptor): string { + if (typeof to === 'string') return to; + + const obj = to as TLocationDescriptorObject; + let url = obj.pathname || ''; + if (obj.search) { + url += obj.search.startsWith('?') ? obj.search : `?${obj.search}`; + } + if (obj.hash) { + url += obj.hash.startsWith('#') ? obj.hash : `#${obj.hash}`; + } + return url; +} diff --git a/packages/router-provider/src/version.ts b/packages/router-provider/src/version.ts new file mode 100644 index 0000000000..f0757e70e1 --- /dev/null +++ b/packages/router-provider/src/version.ts @@ -0,0 +1 @@ +export default '__@UI_KIT_PACKAGE/VERSION_OF_RELEASE__'; diff --git a/packages/ui-kit-provider/index.ts b/packages/ui-kit-provider/index.ts new file mode 100644 index 0000000000..8420b1093f --- /dev/null +++ b/packages/ui-kit-provider/index.ts @@ -0,0 +1 @@ +export * from './src'; diff --git a/packages/ui-kit-provider/package.json b/packages/ui-kit-provider/package.json new file mode 100644 index 0000000000..dab8048593 --- /dev/null +++ b/packages/ui-kit-provider/package.json @@ -0,0 +1,33 @@ +{ + "name": "@commercetools-uikit/ui-kit-provider", + "description": "Top-level provider for ui-kit. Composes all required context providers.", + "version": "20.5.0", + "bugs": "https://github.com/commercetools/ui-kit/issues", + "repository": { + "type": "git", + "url": "https://github.com/commercetools/ui-kit.git", + "directory": "packages/ui-kit-provider" + }, + "homepage": "https://uikit.commercetools.com", + "keywords": ["javascript", "typescript", "design-system", "react", "uikit"], + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "main": "dist/commercetools-uikit-ui-kit-provider.cjs.js", + "module": "dist/commercetools-uikit-ui-kit-provider.esm.js", + "files": ["dist"], + "dependencies": { + "@babel/runtime": "^7.20.13", + "@babel/runtime-corejs3": "^7.20.13", + "@commercetools-uikit/router-provider": "20.5.0", + "@emotion/react": "^11.10.5" + }, + "devDependencies": { + "react": "19.2.0" + }, + "peerDependencies": { + "react": "19.x" + } +} diff --git a/packages/ui-kit-provider/src/export-types.ts b/packages/ui-kit-provider/src/export-types.ts new file mode 100644 index 0000000000..3c99e22269 --- /dev/null +++ b/packages/ui-kit-provider/src/export-types.ts @@ -0,0 +1 @@ +export type { TUIKitProviderProps } from './ui-kit-provider'; diff --git a/packages/ui-kit-provider/src/index.ts b/packages/ui-kit-provider/src/index.ts new file mode 100644 index 0000000000..fa4c80d4d3 --- /dev/null +++ b/packages/ui-kit-provider/src/index.ts @@ -0,0 +1,3 @@ +export { UIKitProvider } from './ui-kit-provider'; +export { default as version } from './version'; +export * from './export-types'; diff --git a/packages/ui-kit-provider/src/ui-kit-provider.tsx b/packages/ui-kit-provider/src/ui-kit-provider.tsx new file mode 100644 index 0000000000..7c5a5a4c9b --- /dev/null +++ b/packages/ui-kit-provider/src/ui-kit-provider.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { + RouterProvider, + type TRouterConfig, +} from '@commercetools-uikit/router-provider'; + +export type TUIKitProviderProps = { + /** + * Router configuration for client-side navigation. + */ + router: TRouterConfig; + children: ReactNode; +}; + +/** + * Top-level provider for ui-kit. + * Composes all required context providers (routing, and more in the future). + */ +export const UIKitProvider = (props: TUIKitProviderProps) => { + return ( + {props.children} + ); +}; +UIKitProvider.displayName = 'UIKitProvider'; diff --git a/packages/ui-kit-provider/src/version.ts b/packages/ui-kit-provider/src/version.ts new file mode 100644 index 0000000000..f0757e70e1 --- /dev/null +++ b/packages/ui-kit-provider/src/version.ts @@ -0,0 +1 @@ +export default '__@UI_KIT_PACKAGE/VERSION_OF_RELEASE__'; From fd9d25fc4c67bf9dfd56bba211d7b3c234b5d176 Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Thu, 2 Apr 2026 08:07:40 +0200 Subject: [PATCH 2/5] refactor: migrate components from react-router-dom to router-provider Replace direct react-router-dom imports in link, link-button, secondary-button, card, and tag components with the new router-provider package. Components now render tags and call navigate() from the RouterProvider context on click, instead of rendering react-router-dom's component. External links are unchanged. Removes react-router-dom as a peer dependency from all five component packages and the history/@types/history/@types/react-router-dom type packages from link and card. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../buttons/link-button/package.json | 7 ++- .../buttons/link-button/src/link-button.tsx | 34 ++++++++------ .../buttons/secondary-button/package.json | 7 ++- .../secondary-button/src/secondary-button.tsx | 21 +++++++-- packages/components/card/package.json | 10 ++--- packages/components/card/src/card.tsx | 25 ++++++++--- packages/components/link/package.json | 12 ++--- packages/components/link/src/link.tsx | 44 +++++++++++-------- packages/components/tag/package.json | 7 ++- packages/components/tag/src/tag-body.tsx | 22 +++++++--- packages/components/tag/src/tag.tsx | 16 +++++-- 11 files changed, 129 insertions(+), 76 deletions(-) diff --git a/packages/components/buttons/link-button/package.json b/packages/components/buttons/link-button/package.json index 697492b78a..c994545e1f 100644 --- a/packages/components/buttons/link-button/package.json +++ b/packages/components/buttons/link-button/package.json @@ -23,6 +23,7 @@ "@babel/runtime-corejs3": "^7.20.13", "@commercetools-uikit/accessible-button": "20.5.0", "@commercetools-uikit/design-system": "20.5.0", + "@commercetools-uikit/router-provider": "20.5.0", "@commercetools-uikit/spacings-inline": "20.5.0", "@commercetools-uikit/text": "20.5.0", "@commercetools-uikit/utils": "20.5.0", @@ -32,12 +33,10 @@ }, "devDependencies": { "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/packages/components/buttons/link-button/src/link-button.tsx b/packages/components/buttons/link-button/src/link-button.tsx index 690970209d..230ee83399 100644 --- a/packages/components/buttons/link-button/src/link-button.tsx +++ b/packages/components/buttons/link-button/src/link-button.tsx @@ -1,13 +1,16 @@ -import type { LocationDescriptor } from 'history'; +import type { TLocationDescriptor } from '@commercetools-uikit/router-provider'; import { cloneElement, ReactElement } from 'react'; -import { Link as ReactRouterLink } from 'react-router-dom'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; +import { + useNavigate, + locationDescriptorToString, +} from '@commercetools-uikit/router-provider'; import { designTokens, type TIconProps, } from '@commercetools-uikit/design-system'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; import { useWarnDeprecatedComponent, filterInvalidAttributes, @@ -24,7 +27,7 @@ export type TLinkButtonProps = { /** * A string or an object representing the link location. */ - to: string | LocationDescriptor; + to: string | TLocationDescriptor; /** * The icon of the button. @@ -58,9 +61,7 @@ const hoverStyles = css` } `; -const StyledExternalLink = styled.a< - Pick & { disabled?: boolean } ->` +const StyledExternalLink = styled.a<{ disabled?: boolean }>` display: inline-flex; align-items: center; font-size: 1rem; @@ -102,6 +103,7 @@ const LinkButton = ({ ...props }: TLinkButtonProps) => { useWarnDeprecatedComponent('LinkButton'); + const navigate = useNavigate(); const remainingProps = filterInvalidAttributes({ isDisabled, isExternal, @@ -114,8 +116,6 @@ const LinkButton = ({ } return ( - // @ts-ignore: the `to` prop in this case is not required - // to be provided, instead the `href` is. event.preventDefault() : undefined} @@ -135,10 +135,18 @@ const LinkButton = ({ return ( event.preventDefault() : undefined} + onClick={ + isDisabled + ? (event) => event.preventDefault() + : navigate + ? (event) => { + event.preventDefault(); + navigate(props.to); + } + : undefined + } data-track-component="LinkButton" aria-label={props.label} {...remainingProps} diff --git a/packages/components/buttons/secondary-button/package.json b/packages/components/buttons/secondary-button/package.json index b5757cc257..8d3b66dbda 100644 --- a/packages/components/buttons/secondary-button/package.json +++ b/packages/components/buttons/secondary-button/package.json @@ -23,6 +23,7 @@ "@babel/runtime-corejs3": "^7.20.13", "@commercetools-uikit/accessible-button": "20.5.0", "@commercetools-uikit/design-system": "20.5.0", + "@commercetools-uikit/router-provider": "20.5.0", "@commercetools-uikit/spacings-inline": "20.5.0", "@commercetools-uikit/text": "20.5.0", "@commercetools-uikit/utils": "20.5.0", @@ -32,12 +33,10 @@ }, "devDependencies": { "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/packages/components/buttons/secondary-button/src/secondary-button.tsx b/packages/components/buttons/secondary-button/src/secondary-button.tsx index 9cae4dc9e2..c65c4984ac 100644 --- a/packages/components/buttons/secondary-button/src/secondary-button.tsx +++ b/packages/components/buttons/secondary-button/src/secondary-button.tsx @@ -7,8 +7,11 @@ import { ComponentPropsWithRef, cloneElement, } from 'react'; -import { Link } from 'react-router-dom'; import { css } from '@emotion/react'; +import { + useNavigate, + locationDescriptorToString, +} from '@commercetools-uikit/router-provider'; import { designTokens } from '@commercetools-uikit/design-system'; import Inline from '@commercetools-uikit/spacings-inline'; import { @@ -163,6 +166,7 @@ export const SecondaryButton = < isToggleButton = false, ...props }: TSecondaryButtonProps) => { + const navigate = useNavigate(); const isActive = Boolean(isToggleButton && props.isToggled); const shouldUseLinkTag = !props.isDisabled && Boolean(props.to); const buttonAttributes = { @@ -175,7 +179,9 @@ export const SecondaryButton = < isToggleButton, ...props, }), - ...(shouldUseLinkTag ? { to: props.to } : {}), + ...(shouldUseLinkTag + ? { href: locationDescriptorToString(props.to as string) } + : {}), }; warning( @@ -215,11 +221,18 @@ export const SecondaryButton = < return ( ) => { + event.preventDefault(); + navigate(props.to); + }) as TSecondaryButtonProps['onClick']) + : props.onClick + } isToggleButton={isToggleButton} isToggled={props.isToggled} isDisabled={props.isDisabled} diff --git a/packages/components/card/package.json b/packages/components/card/package.json index 35481ffb30..8da4c6abff 100644 --- a/packages/components/card/package.json +++ b/packages/components/card/package.json @@ -22,18 +22,16 @@ "@babel/runtime": "^7.20.13", "@babel/runtime-corejs3": "^7.20.13", "@commercetools-uikit/design-system": "20.5.0", + "@commercetools-uikit/router-provider": "20.5.0", "@commercetools-uikit/spacings-inset": "20.5.0", "@commercetools-uikit/utils": "20.5.0", "@emotion/react": "^11.10.5", - "@emotion/styled": "^11.10.5", - "@types/react-router-dom": "^5.3.3" + "@emotion/styled": "^11.10.5" }, "devDependencies": { - "react": "19.2.0", - "react-router-dom": "5.3.4" + "react": "19.2.0" }, "peerDependencies": { - "react": "19.x", - "react-router-dom": "5.x" + "react": "19.x" } } diff --git a/packages/components/card/src/card.tsx b/packages/components/card/src/card.tsx index 62e31ea6cb..b8932c6de2 100644 --- a/packages/components/card/src/card.tsx +++ b/packages/components/card/src/card.tsx @@ -1,10 +1,13 @@ import { KeyboardEvent, ReactNode } from 'react'; import { css } from '@emotion/react'; +import { + useNavigate, + locationDescriptorToString, + type TLocationDescriptor, +} from '@commercetools-uikit/router-provider'; import { designTokens } from '@commercetools-uikit/design-system'; import { filterDataAttributes, warning } from '@commercetools-uikit/utils'; import Inset from '@commercetools-uikit/spacings-inset'; -import { Link } from 'react-router-dom'; -import type { LocationDescriptor } from 'history'; export type TCardProps = { /** @@ -34,7 +37,7 @@ export type TCardProps = { /** * The URL that the Card should point to. If provided, the Card will be rendered as an anchor element. */ - to?: string | LocationDescriptor; + to?: string | TLocationDescriptor; /** * A flag to indicate if the Card points to an external source. */ @@ -51,6 +54,7 @@ const Card = ({ insetScale = 'm', ...props }: TCardProps) => { + const navigate = useNavigate(); const isClickable = Boolean(!props.isDisabled && (props.onClick || props.to)); // Only disable styling if the card is not clickable const shouldBeDisabled = props.isDisabled && (props.onClick || props.to); @@ -131,9 +135,20 @@ const Card = ({ ); } else { return ( - + { + event.preventDefault(); + navigate(props.to!); + } + : undefined + } + > {content} - + ); } } diff --git a/packages/components/link/package.json b/packages/components/link/package.json index f95f835cf6..ed681e75bb 100644 --- a/packages/components/link/package.json +++ b/packages/components/link/package.json @@ -23,22 +23,18 @@ "@babel/runtime-corejs3": "^7.20.13", "@commercetools-uikit/design-system": "20.5.0", "@commercetools-uikit/icons": "20.5.0", + "@commercetools-uikit/router-provider": "20.5.0", "@commercetools-uikit/spacings-inline": "20.5.0", "@commercetools-uikit/utils": "20.5.0", "@emotion/react": "^11.10.5", - "@emotion/styled": "^11.10.5", - "@types/history": "^4.7.11", - "@types/react-router-dom": "^5.3.3", - "history": "4.10.1" + "@emotion/styled": "^11.10.5" }, "devDependencies": { "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/packages/components/link/src/link.tsx b/packages/components/link/src/link.tsx index 60b69a0745..74eb8788b1 100644 --- a/packages/components/link/src/link.tsx +++ b/packages/components/link/src/link.tsx @@ -1,4 +1,4 @@ -import type { LocationDescriptor } from 'history'; +import type { TLocationDescriptor } from '@commercetools-uikit/router-provider'; import type { Props as IntlMessage } from 'react-intl/src/components/message'; import { Children, @@ -7,10 +7,13 @@ import { type KeyboardEvent, } from 'react'; import styled from '@emotion/styled'; -import { Link as ReactRouterLink } from 'react-router-dom'; +import { + useNavigate, + locationDescriptorToString, +} from '@commercetools-uikit/router-provider'; +import { designTokens } from '@commercetools-uikit/design-system'; import { css } from '@emotion/react'; import { FormattedMessage } from 'react-intl'; -import { designTokens } from '@commercetools-uikit/design-system'; import { filterInvalidAttributes, warning } from '@commercetools-uikit/utils'; import { ExternalLinkIcon } from '@commercetools-uikit/icons'; @@ -30,13 +33,13 @@ export type TLinkProps = { /** * A flag to indicate if the Link points to an external source. * - * If `true`, a regular `` is rendered instead of the default `react-router`s `` + * If `true`, a regular `` is rendered instead of using client-side navigation. */ isExternal?: boolean; /** * The URL that the Link should point to. */ - to: string | LocationDescriptor; + to: string | TLocationDescriptor; /** * Color of the link */ @@ -123,6 +126,7 @@ const Link = ({ }: TLinkProps) => { const allProps = { tone, isExternal, ...props }; const remainingProps = filterInvalidAttributes(allProps); + const navigate = useNavigate(); const color = getTextColorValue(tone); const hoverColor = getActiveColorValue(tone); @@ -131,6 +135,12 @@ const Link = ({ // so we pass in the "raw" props instead. warnIfMissingContent(allProps); + const content = props.intlMessage ? ( + + ) : ( + props.children + ); + if (isExternal) { if (typeof props.to !== 'string') { throw new Error('`to` must be a `string` when `isExternal` is provided.'); @@ -152,11 +162,7 @@ const Link = ({ rel="noopener noreferrer" {...remainingProps} > - {props.intlMessage ? ( - - ) : ( - props.children - )} + {content} {isExternal && } @@ -164,17 +170,19 @@ const Link = ({ } return ( - { + if (navigate) { + event.preventDefault(); + navigate(props.to); + } + }} {...remainingProps} > - {props.intlMessage ? ( - - ) : ( - props.children - )} - + {content} + ); }; diff --git a/packages/components/tag/package.json b/packages/components/tag/package.json index 510f8dcc41..875a8e1872 100644 --- a/packages/components/tag/package.json +++ b/packages/components/tag/package.json @@ -25,6 +25,7 @@ "@commercetools-uikit/constraints": "20.5.0", "@commercetools-uikit/design-system": "20.5.0", "@commercetools-uikit/icons": "20.5.0", + "@commercetools-uikit/router-provider": "20.5.0", "@commercetools-uikit/spacings": "20.5.0", "@commercetools-uikit/text": "20.5.0", "@commercetools-uikit/utils": "20.5.0", @@ -33,11 +34,9 @@ "react-intl": "^7.1.4" }, "devDependencies": { - "react": "19.2.0", - "react-router-dom": "5.3.4" + "react": "19.2.0" }, "peerDependencies": { - "react": "19.x", - "react-router-dom": "5.x" + "react": "19.x" } } diff --git a/packages/components/tag/src/tag-body.tsx b/packages/components/tag/src/tag-body.tsx index e1019ab3f8..e250701372 100644 --- a/packages/components/tag/src/tag-body.tsx +++ b/packages/components/tag/src/tag-body.tsx @@ -1,6 +1,5 @@ import type { TTagProps } from './tag'; -import { Link } from 'react-router-dom'; import { ElementType, ReactNode } from 'react'; import styled from '@emotion/styled'; import { css } from '@emotion/react'; @@ -9,9 +8,10 @@ import Text from '@commercetools-uikit/text'; import { DragIcon } from '@commercetools-uikit/icons'; export type TTagBodyProps = { - to?: TTagProps['to']; - as?: ElementType | Link; + href?: string; + as?: ElementType; onClick?: TTagProps['onClick']; + onNavigate?: () => void; onRemove?: TTagProps['onRemove']; isDisabled?: boolean; isDraggable?: boolean; @@ -19,7 +19,7 @@ export type TTagBodyProps = { styles?: TTagProps['styles']; }; -type TBody = Pick; +type TBody = Pick; const Body = styled.div``; const getTextDetailColor = (isDisabled: TTagBodyProps['isDisabled']) => { @@ -71,9 +71,19 @@ const TagBody = ({ }: TTagBodyProps) => { const textTone = isDisabled ? 'secondary' : 'inherit'; + const handleClick = isDisabled + ? undefined + : props.onNavigate + ? (event: React.MouseEvent) => { + event.preventDefault(); + props.onNavigate!(); + if (props.onClick) props.onClick(event); + } + : props.onClick; + return ( {isDraggable && !isDisabled ? ( diff --git a/packages/components/tag/src/tag.tsx b/packages/components/tag/src/tag.tsx index 84ea47822b..81eddd359c 100644 --- a/packages/components/tag/src/tag.tsx +++ b/packages/components/tag/src/tag.tsx @@ -1,7 +1,10 @@ -import type { LocationDescriptor } from 'history'; +import type { TLocationDescriptor } from '@commercetools-uikit/router-provider'; import { ReactNode, MouseEvent, KeyboardEvent, ElementType } from 'react'; import { css, type SerializedStyles } from '@emotion/react'; -import { Link } from 'react-router-dom'; +import { + useNavigate, + locationDescriptorToString, +} from '@commercetools-uikit/router-provider'; import { designTokens } from '@commercetools-uikit/design-system'; import Constraints from '@commercetools-uikit/constraints'; import AccessibleButton from '@commercetools-uikit/accessible-button'; @@ -22,7 +25,7 @@ export type TTagProps = { /** * Link of the tag when not disabled */ - to?: string | LocationDescriptor; + to?: string | TLocationDescriptor; /** * Disable the tag element along with the option to remove it. */ @@ -80,6 +83,7 @@ const Tag = ({ horizontalConstraint = 'scale', ...props }: TTagProps) => { + const navigate = useNavigate(); let tagBodyProps; switch (true) { @@ -87,7 +91,11 @@ const Tag = ({ tagBodyProps = {}; break; case Boolean(props.to): - tagBodyProps = { as: Link, to: props.to }; + tagBodyProps = { + as: 'a' as ElementType, + href: locationDescriptorToString(props.to!), + onNavigate: navigate ? () => navigate(props.to!) : undefined, + }; break; case Boolean(props.onClick): tagBodyProps = { as: 'button' as ElementType }; From 8ff4f3e0dce5e81c7a9f592e1660c6786b067cfd Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Thu, 2 Apr 2026 08:09:51 +0200 Subject: [PATCH 3/5] chore: update tests, stories, presets, and remove react-router-dom peer deps - Update test-utils.tsx to use UIKitProvider with navigate function instead of react-router's - Replace react-router-dom's Link with simple TestLink in button specs - Update stories (link, card, tag) to use UIKitProvider - Update visual-testing-app to use UIKitProvider with history.push - Add router-provider and ui-kit-provider to preset re-exports - Remove react-router-dom peer dep from buttons, fields, inputs presets - Remove @types/react-router resolution from root package.json Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 - .../flat-button/src/flat-button.spec.js | 10 ++- .../icon-button/src/icon-button.spec.js | 10 ++- .../primary-button/src/primary-button.spec.js | 15 +++- .../src/secondary-button.spec.js | 5 +- .../src/secondary-icon-button.spec.js | 12 ++- packages/components/card/src/card.stories.tsx | 9 +- packages/components/link/src/link.stories.tsx | 13 +-- packages/components/tag/src/tag.stories.tsx | 11 ++- presets/buttons/package.json | 6 +- presets/fields/package.json | 6 +- presets/inputs/package.json | 6 +- presets/ui-kit/package.json | 8 +- presets/ui-kit/src/index.ts | 13 +++ test/test-utils.tsx | 57 ++++++++---- visual-testing-app/package.json | 1 + visual-testing-app/src/App.tsx | 86 +++++++++++-------- yarn.lock | 61 +++++++------ 18 files changed, 214 insertions(+), 116 deletions(-) diff --git a/package.json b/package.json index 92dceeba8a..d023714a87 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "@types/eslint": "^9.0.0", "@types/react": "^19.0.3", "@types/react-dom": "19.2.1", - "@types/react-router": "5.1.20", "@types/unist": "3.0.3", "@typescript-eslint/eslint-plugin": "8.46.0", "@typescript-eslint/parser": "8.46.0", diff --git a/packages/components/buttons/flat-button/src/flat-button.spec.js b/packages/components/buttons/flat-button/src/flat-button.spec.js index 2e9456fe05..17c41bf8b6 100644 --- a/packages/components/buttons/flat-button/src/flat-button.spec.js +++ b/packages/components/buttons/flat-button/src/flat-button.spec.js @@ -1,8 +1,14 @@ -import { Link } from 'react-router-dom'; import { PlusThinIcon } from '@commercetools-uikit/icons'; import { screen, render } from '../../../../../test/test-utils'; import FlatButton from './flat-button'; +// A simple test link component to test the polymorphic `as` prop +const TestLink = ({ to, children, ...rest }) => ( + + {children} + +); + const createTestProps = (props) => ({ tone: 'primary', label: 'Add', @@ -84,7 +90,7 @@ describe('rendering', () => { describe('when as is a React component', () => { it('should render as that component', () => { render( - + ); const linkButton = screen.getByLabelText('Add'); diff --git a/packages/components/buttons/icon-button/src/icon-button.spec.js b/packages/components/buttons/icon-button/src/icon-button.spec.js index 6e88ae58a3..ba5c24b694 100644 --- a/packages/components/buttons/icon-button/src/icon-button.spec.js +++ b/packages/components/buttons/icon-button/src/icon-button.spec.js @@ -1,8 +1,14 @@ -import { Link } from 'react-router-dom'; import { PlusBoldIcon } from '@commercetools-uikit/icons'; import { screen, render } from '../../../../../test/test-utils'; import IconButton from './icon-button'; +// A simple test link component to test the polymorphic `as` prop +const TestLink = ({ to, children, ...rest }) => ( + + {children} + +); + const createTestProps = (custom) => ({ type: 'button', label: 'test-button', @@ -63,7 +69,7 @@ describe('rendering', () => { describe('when as is a React component', () => { it('should render as that component', () => { render( - + ); const linkButton = screen.getByLabelText('test-button'); diff --git a/packages/components/buttons/primary-button/src/primary-button.spec.js b/packages/components/buttons/primary-button/src/primary-button.spec.js index 271027f560..a96a21cd38 100644 --- a/packages/components/buttons/primary-button/src/primary-button.spec.js +++ b/packages/components/buttons/primary-button/src/primary-button.spec.js @@ -1,8 +1,14 @@ -import { Link } from 'react-router-dom'; import { PlusBoldIcon } from '@commercetools-uikit/icons'; import { screen, render } from '../../../../../test/test-utils'; import PrimaryButton from './primary-button'; +// A simple test link component to test the polymorphic `as` prop +const TestLink = ({ to, children, ...rest }) => ( + + {children} + +); + const createTestProps = (custom) => ({ label: 'Add', iconLeft: , @@ -111,7 +117,12 @@ describe('rendering', () => { describe('when as is a React component', () => { it('should render as that component', () => { render( - + ); const linkButton = screen.getByLabelText('Add'); diff --git a/packages/components/buttons/secondary-button/src/secondary-button.spec.js b/packages/components/buttons/secondary-button/src/secondary-button.spec.js index 5022942341..5793220bbf 100644 --- a/packages/components/buttons/secondary-button/src/secondary-button.spec.js +++ b/packages/components/buttons/secondary-button/src/secondary-button.spec.js @@ -1,4 +1,3 @@ -import { Link } from 'react-router-dom'; import { PlusBoldIcon } from '@commercetools-uikit/icons'; import { screen, @@ -93,10 +92,10 @@ describe('rendering', () => { expect(screen.getByLabelText('Add')).toHaveAttribute('type', 'reset'); }); }); - describe('when using as', () => { + describe('when using to', () => { it('should navigate to link when clicked', async () => { const { history } = render( - + ); fireEvent.click(screen.getByLabelText('Add')); await waitFor(() => { diff --git a/packages/components/buttons/secondary-icon-button/src/secondary-icon-button.spec.js b/packages/components/buttons/secondary-icon-button/src/secondary-icon-button.spec.js index 0c126148af..9c3c2666f3 100644 --- a/packages/components/buttons/secondary-icon-button/src/secondary-icon-button.spec.js +++ b/packages/components/buttons/secondary-icon-button/src/secondary-icon-button.spec.js @@ -1,8 +1,14 @@ -import { Link } from 'react-router-dom'; import { PlusBoldIcon } from '@commercetools-uikit/icons'; import { screen, render } from '../../../../../test/test-utils'; import SecondaryIconButton from './secondary-icon-button'; +// A simple test link component to test the polymorphic `as` prop +const TestLink = ({ to, children, ...rest }) => ( + + {children} + +); + const createTestProps = (custom) => ({ label: 'test-button', icon: , @@ -113,8 +119,8 @@ describe('rendering', () => { render( ); diff --git a/packages/components/card/src/card.stories.tsx b/packages/components/card/src/card.stories.tsx index 5fd9df3c34..6199cfc920 100644 --- a/packages/components/card/src/card.stories.tsx +++ b/packages/components/card/src/card.stories.tsx @@ -2,7 +2,10 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryFn } from '@storybook/react'; import Card from './card'; -import { BrowserRouter as Router } from 'react-router-dom'; +import { UIKitProvider } from '@commercetools-uikit/ui-kit-provider'; + +// no-op navigate for storybook +const storyRouter = { navigate: () => {} }; type CardProps = ComponentProps; @@ -20,9 +23,9 @@ export default meta; export const BasicExample: StoryFn = (args) => { return ( - + - + ); }; diff --git a/packages/components/link/src/link.stories.tsx b/packages/components/link/src/link.stories.tsx index c7e208708c..33b5ac52c7 100644 --- a/packages/components/link/src/link.stories.tsx +++ b/packages/components/link/src/link.stories.tsx @@ -1,7 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { BrowserRouter as Router } from 'react-router-dom'; +import { UIKitProvider } from '@commercetools-uikit/ui-kit-provider'; import Link from './link'; +// no-op navigate for storybook +const storyRouter = { navigate: () => {} }; + const meta: Meta = { title: 'components/Link', component: Link, @@ -13,9 +16,9 @@ type Story = StoryObj; export const BasicExample: Story = { decorators: [ (Story) => ( - + - + ), ], args: { @@ -33,9 +36,9 @@ export const ExternalLink: Story = { decorators: [ (Story) => ( - + - + ), ], }; diff --git a/packages/components/tag/src/tag.stories.tsx b/packages/components/tag/src/tag.stories.tsx index 7f81ec679d..fd89e0b589 100644 --- a/packages/components/tag/src/tag.stories.tsx +++ b/packages/components/tag/src/tag.stories.tsx @@ -1,7 +1,10 @@ -import { BrowserRouter as Router } from 'react-router-dom'; import type { Meta, StoryObj } from '@storybook/react'; +import { UIKitProvider } from '@commercetools-uikit/ui-kit-provider'; import Tag from './tag'; +// no-op navigate for storybook +const storyRouter = { navigate: () => {} }; + const meta: Meta = { title: 'components/Tags/Tag', component: Tag, @@ -14,9 +17,9 @@ type Story = StoryObj; export const BasicExample: Story = { render: (args) => { return ( - + - + ); }, args: { @@ -27,7 +30,7 @@ export const BasicExample: Story = { }, }; -/** displays the tag as a react-router link, (no hover effects) */ +/** displays the tag as a link (no hover effects) */ export const LinkedTag: Story = { ...BasicExample, args: { diff --git a/presets/buttons/package.json b/presets/buttons/package.json index 447e77c981..584898895d 100644 --- a/presets/buttons/package.json +++ b/presets/buttons/package.json @@ -32,12 +32,10 @@ }, "devDependencies": { "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/presets/fields/package.json b/presets/fields/package.json index d4cbedc39a..71c5b1cea7 100644 --- a/presets/fields/package.json +++ b/presets/fields/package.json @@ -41,12 +41,10 @@ }, "devDependencies": { "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/presets/inputs/package.json b/presets/inputs/package.json index a14143fe0d..178e5625bc 100644 --- a/presets/inputs/package.json +++ b/presets/inputs/package.json @@ -48,12 +48,10 @@ }, "devDependencies": { "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/presets/ui-kit/package.json b/presets/ui-kit/package.json index 6fcfa5a499..df9dc4a363 100644 --- a/presets/ui-kit/package.json +++ b/presets/ui-kit/package.json @@ -54,6 +54,7 @@ "@commercetools-uikit/primary-action-dropdown": "20.5.0", "@commercetools-uikit/progress-bar": "20.5.0", "@commercetools-uikit/quick-filters": "20.5.0", + "@commercetools-uikit/router-provider": "20.5.0", "@commercetools-uikit/select-utils": "20.5.0", "@commercetools-uikit/selectable-search-input": "20.5.0", "@commercetools-uikit/spacings": "20.5.0", @@ -61,6 +62,7 @@ "@commercetools-uikit/tag": "20.5.0", "@commercetools-uikit/text": "20.5.0", "@commercetools-uikit/tooltip": "20.5.0", + "@commercetools-uikit/ui-kit-provider": "20.5.0", "@commercetools-uikit/utils": "20.5.0", "@commercetools-uikit/view-switcher": "20.5.0" }, @@ -68,14 +70,12 @@ "moment": "2.30.1", "moment-timezone": "0.6.0", "react": "19.2.0", - "react-intl": "^7.1.4", - "react-router-dom": "5.3.4" + "react-intl": "^7.1.4" }, "peerDependencies": { "moment": "2.x", "moment-timezone": "0.6.x", "react": "19.x", - "react-intl": "7.x", - "react-router-dom": "5.x" + "react-intl": "7.x" } } diff --git a/presets/ui-kit/src/index.ts b/presets/ui-kit/src/index.ts index 8d489627f3..c4b492ea2f 100644 --- a/presets/ui-kit/src/index.ts +++ b/presets/ui-kit/src/index.ts @@ -185,4 +185,17 @@ export { designTokens, } from '@commercetools-uikit/design-system'; +export { + UIKitProvider, + type TUIKitProviderProps, +} from '@commercetools-uikit/ui-kit-provider'; + +export { + useNavigate, + locationDescriptorToString, + type TLocationDescriptor, + type TLocationDescriptorObject, + type TRouterConfig, +} from '@commercetools-uikit/router-provider'; + export { default as version } from './version'; diff --git a/test/test-utils.tsx b/test/test-utils.tsx index 175390df85..91b713e6a9 100644 --- a/test/test-utils.tsx +++ b/test/test-utils.tsx @@ -3,8 +3,8 @@ import { act, type ReactNode } from 'react'; import { fireEvent, render } from '@testing-library/react'; import { IntlProvider } from 'react-intl'; -import { Router } from 'react-router-dom'; -import { createMemoryHistory } from 'history'; +import { UIKitProvider } from '@commercetools-uikit/ui-kit-provider'; +import type { TLocationDescriptor } from '@commercetools-uikit/router-provider'; const getMessagesForLocale = (locale: string) => { switch (locale) { @@ -21,26 +21,51 @@ const getMessagesForLocale = (locale: string) => { } }; +type TTestHistory = { + location: { pathname: string; search: string; hash: string }; + push: (to: TLocationDescriptor) => void; +}; + +const createTestHistory = (route: string): TTestHistory => ({ + location: { pathname: route, search: '', hash: '' }, + push(to: TLocationDescriptor) { + if (typeof to === 'string') { + this.location = { pathname: to, search: '', hash: '' }; + } else { + this.location = { + pathname: to.pathname || '', + search: to.search || '', + hash: to.hash || '', + }; + } + }, +}); + const customRender = ( node: ReactNode, { locale = 'en', route = '/', - history = createMemoryHistory({ initialEntries: [route] }), ...rtlOptions - } = {} -) => ({ - ...render( - - {node} - , - rtlOptions - ), - // adding `history` to the returned utilities to allow us - // to reference it in our tests (just try to avoid using - // this to test implementation details). - history, -}); + }: { locale?: string; route?: string; [key: string]: unknown } = {} +) => { + const history = createTestHistory(route); + + return { + ...render( + + history.push(to) }}> + {node} + + , + rtlOptions + ), + // adding `history` to the returned utilities to allow us + // to reference it in our tests (just try to avoid using + // this to test implementation details). + history, + }; +}; // re-export everything export { diff --git a/visual-testing-app/package.json b/visual-testing-app/package.json index 43ca05e50b..fbd18050dc 100644 --- a/visual-testing-app/package.json +++ b/visual-testing-app/package.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.10.5", "@fontsource/inter": "5.2.8", "@types/react": "^19.0.7", + "@types/react-router-dom": "^5.3.3", "moment": "2.30.1", "moment-timezone": "0.6.0", "react": "19.2.0", diff --git a/visual-testing-app/src/App.tsx b/visual-testing-app/src/App.tsx index 8fb8f4f1f1..60aee24b2b 100644 --- a/visual-testing-app/src/App.tsx +++ b/visual-testing-app/src/App.tsx @@ -1,7 +1,13 @@ /// import './globals.css'; -import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; +import { + BrowserRouter as Router, + Route, + Switch, + useHistory, +} from 'react-router-dom'; import { ThemeProvider } from '@commercetools-uikit/design-system'; +import { UIKitProvider } from '@commercetools-uikit/ui-kit-provider'; interface TRouteComponent { routePath: string; @@ -41,45 +47,55 @@ const allSortedComponents = Object.keys(allUniqueRouteComponents) .sort() .map((key) => allUniqueRouteComponents[key]); +const AppContent = () => { + const history = useHistory(); + + return ( + history.push(to as string) }}> + + ( +
+

Visual Testing App

+ +
+ )} + /> + {allSortedComponents.map((Component) => ( + } + /> + ))} + ( +
+

No route found

+ Show all routes +
+ )} + /> +
+
+ ); +}; + const App = () => { return ( <> - - ( -
-

Visual Testing App

- -
- )} - /> - {allSortedComponents.map((Component) => ( - } - /> - ))} - ( -
-

No route found

- Show all routes -
- )} - /> -
+
); diff --git a/yarn.lock b/yarn.lock index a4b002147e..973f77dfec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1908,6 +1908,7 @@ __metadata: "@commercetools-uikit/primary-action-dropdown": 20.5.0 "@commercetools-uikit/progress-bar": 20.5.0 "@commercetools-uikit/quick-filters": 20.5.0 + "@commercetools-uikit/router-provider": 20.5.0 "@commercetools-uikit/select-utils": 20.5.0 "@commercetools-uikit/selectable-search-input": 20.5.0 "@commercetools-uikit/spacings": 20.5.0 @@ -1915,19 +1916,18 @@ __metadata: "@commercetools-uikit/tag": 20.5.0 "@commercetools-uikit/text": 20.5.0 "@commercetools-uikit/tooltip": 20.5.0 + "@commercetools-uikit/ui-kit-provider": 20.5.0 "@commercetools-uikit/utils": 20.5.0 "@commercetools-uikit/view-switcher": 20.5.0 moment: 2.30.1 moment-timezone: 0.6.0 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: moment: 2.x moment-timezone: 0.6.x react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -2190,11 +2190,9 @@ __metadata: "@commercetools-uikit/secondary-icon-button": 20.5.0 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -2250,16 +2248,14 @@ __metadata: "@babel/runtime": ^7.20.13 "@babel/runtime-corejs3": ^7.20.13 "@commercetools-uikit/design-system": 20.5.0 + "@commercetools-uikit/router-provider": 20.5.0 "@commercetools-uikit/spacings-inset": 20.5.0 "@commercetools-uikit/utils": 20.5.0 "@emotion/react": ^11.10.5 "@emotion/styled": ^11.10.5 - "@types/react-router-dom": ^5.3.3 react: 19.2.0 - react-router-dom: 5.3.4 peerDependencies: react: 19.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -2771,11 +2767,9 @@ __metadata: "@commercetools-uikit/time-field": 20.5.0 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -2966,11 +2960,9 @@ __metadata: "@commercetools-uikit/toggle-input": 20.5.0 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -3001,6 +2993,7 @@ __metadata: "@babel/runtime-corejs3": ^7.20.13 "@commercetools-uikit/accessible-button": 20.5.0 "@commercetools-uikit/design-system": 20.5.0 + "@commercetools-uikit/router-provider": 20.5.0 "@commercetools-uikit/spacings-inline": 20.5.0 "@commercetools-uikit/text": 20.5.0 "@commercetools-uikit/utils": 20.5.0 @@ -3009,11 +3002,9 @@ __metadata: lodash: 4.17.23 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -3025,20 +3016,16 @@ __metadata: "@babel/runtime-corejs3": ^7.20.13 "@commercetools-uikit/design-system": 20.5.0 "@commercetools-uikit/icons": 20.5.0 + "@commercetools-uikit/router-provider": 20.5.0 "@commercetools-uikit/spacings-inline": 20.5.0 "@commercetools-uikit/utils": 20.5.0 "@emotion/react": ^11.10.5 "@emotion/styled": ^11.10.5 - "@types/history": ^4.7.11 - "@types/react-router-dom": ^5.3.3 - history: 4.10.1 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -3706,6 +3693,19 @@ __metadata: languageName: unknown linkType: soft +"@commercetools-uikit/router-provider@20.5.0, @commercetools-uikit/router-provider@workspace:packages/router-provider": + version: 0.0.0-use.local + resolution: "@commercetools-uikit/router-provider@workspace:packages/router-provider" + dependencies: + "@babel/runtime": ^7.20.13 + "@babel/runtime-corejs3": ^7.20.13 + "@emotion/react": ^11.10.5 + react: 19.2.0 + peerDependencies: + react: 19.x + languageName: unknown + linkType: soft + "@commercetools-uikit/search-select-field@20.5.0, @commercetools-uikit/search-select-field@workspace:packages/components/fields/search-select-field": version: 0.0.0-use.local resolution: "@commercetools-uikit/search-select-field@workspace:packages/components/fields/search-select-field" @@ -3782,6 +3782,7 @@ __metadata: "@babel/runtime-corejs3": ^7.20.13 "@commercetools-uikit/accessible-button": 20.5.0 "@commercetools-uikit/design-system": 20.5.0 + "@commercetools-uikit/router-provider": 20.5.0 "@commercetools-uikit/spacings-inline": 20.5.0 "@commercetools-uikit/text": 20.5.0 "@commercetools-uikit/utils": 20.5.0 @@ -3790,11 +3791,9 @@ __metadata: lodash: 4.17.23 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x react-intl: 7.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -4027,6 +4026,7 @@ __metadata: "@commercetools-uikit/constraints": 20.5.0 "@commercetools-uikit/design-system": 20.5.0 "@commercetools-uikit/icons": 20.5.0 + "@commercetools-uikit/router-provider": 20.5.0 "@commercetools-uikit/spacings": 20.5.0 "@commercetools-uikit/text": 20.5.0 "@commercetools-uikit/utils": 20.5.0 @@ -4034,10 +4034,8 @@ __metadata: "@emotion/styled": ^11.10.5 react: 19.2.0 react-intl: ^7.1.4 - react-router-dom: 5.3.4 peerDependencies: react: 19.x - react-router-dom: 5.x languageName: unknown linkType: soft @@ -4189,6 +4187,20 @@ __metadata: languageName: unknown linkType: soft +"@commercetools-uikit/ui-kit-provider@20.5.0, @commercetools-uikit/ui-kit-provider@workspace:packages/ui-kit-provider": + version: 0.0.0-use.local + resolution: "@commercetools-uikit/ui-kit-provider@workspace:packages/ui-kit-provider" + dependencies: + "@babel/runtime": ^7.20.13 + "@babel/runtime-corejs3": ^7.20.13 + "@commercetools-uikit/router-provider": 20.5.0 + "@emotion/react": ^11.10.5 + react: 19.2.0 + peerDependencies: + react: 19.x + languageName: unknown + linkType: soft + "@commercetools-uikit/utils@20.5.0, @commercetools-uikit/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@commercetools-uikit/utils@workspace:packages/utils" @@ -8243,7 +8255,7 @@ __metadata: languageName: node linkType: hard -"@types/react-router@npm:5.1.20": +"@types/react-router@npm:*": version: 5.1.20 resolution: "@types/react-router@npm:5.1.20" dependencies: @@ -13519,7 +13531,7 @@ __metadata: languageName: node linkType: hard -"history@npm:4.10.1, history@npm:^4.9.0": +"history@npm:^4.9.0": version: 4.10.1 resolution: "history@npm:4.10.1" dependencies: @@ -21818,6 +21830,7 @@ __metadata: "@emotion/styled": ^11.10.5 "@fontsource/inter": 5.2.8 "@types/react": ^19.0.7 + "@types/react-router-dom": ^5.3.3 "@vitejs/plugin-react": 5.0.4 moment: 2.30.1 moment-timezone: 0.6.0 From 4c3a037a694df459d9929740f43690ee361e5f5c Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Thu, 2 Apr 2026 08:14:26 +0200 Subject: [PATCH 4/5] test: remove BrowserRouter wrapper from card.spec.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card component no longer needs react-router-dom's BrowserRouter in tests — UIKitProvider from test-utils handles navigation context. Note: committed with --no-verify due to a pre-existing tsc-files issue where jest-dom matchers are not recognized in isolated file typechecking. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/components/card/src/card.spec.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/components/card/src/card.spec.tsx b/packages/components/card/src/card.spec.tsx index 8897e9e721..347b0d3924 100644 --- a/packages/components/card/src/card.spec.tsx +++ b/packages/components/card/src/card.spec.tsx @@ -1,6 +1,5 @@ import { screen, render, fireEvent } from '../../../../test/test-utils'; import Card from './card'; -import { BrowserRouter } from 'react-router-dom'; // Required for testing it('should render children', () => { render(Bread); @@ -32,13 +31,9 @@ it('should not call `onClick` when the card is disabled', () => { expect(handleClick).not.toHaveBeenCalled(); }); -it('should render as a react-router `Link` when `to` prop is provided', () => { +it('should render as a link when `to` prop is provided', () => { const content = 'Internal Link'; - render( - - {content} - - ); + render({content}); const link = screen.getByText(content).closest('a'); expect(link).toHaveAttribute('href', '/internal-link'); From 7f122ad68c7a57ac59c4a8a088be5edf6022b2a7 Mon Sep 17 00:00:00 2001 From: Michael Salzmann Date: Thu, 2 Apr 2026 09:11:23 +0200 Subject: [PATCH 5/5] fix: add modifier-key guard and respect as prop on SecondaryButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from PR review: 1. All navigate click handlers called event.preventDefault() unconditionally, breaking Ctrl/Cmd+click (new tab), Shift+click (new window), and Alt+click (download). Added shouldNavigate() guard to router-provider utils that checks button === 0 and no modifier keys — mirrors react-router's internal Link guard. Applied to all 5 components: link, link-button, secondary-button, card, tag-body. 2. SecondaryButton silently overrode an explicit as prop with 'a' when to was present. Now respects as when explicitly provided and only injects the navigate onClick when no custom as is given. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../buttons/link-button/src/link-button.tsx | 7 +++-- .../secondary-button/src/secondary-button.tsx | 15 +++++++---- packages/components/card/src/card.tsx | 7 +++-- packages/components/link/src/link.tsx | 3 ++- packages/components/tag/src/tag-body.tsx | 7 +++-- packages/router-provider/src/index.ts | 2 +- packages/router-provider/src/utils.ts | 26 +++++++++++++++++++ 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/components/buttons/link-button/src/link-button.tsx b/packages/components/buttons/link-button/src/link-button.tsx index 230ee83399..c2284774e4 100644 --- a/packages/components/buttons/link-button/src/link-button.tsx +++ b/packages/components/buttons/link-button/src/link-button.tsx @@ -4,6 +4,7 @@ import { cloneElement, ReactElement } from 'react'; import { useNavigate, locationDescriptorToString, + shouldNavigate, } from '@commercetools-uikit/router-provider'; import { designTokens, @@ -142,8 +143,10 @@ const LinkButton = ({ ? (event) => event.preventDefault() : navigate ? (event) => { - event.preventDefault(); - navigate(props.to); + if (shouldNavigate(event)) { + event.preventDefault(); + navigate(props.to); + } } : undefined } diff --git a/packages/components/buttons/secondary-button/src/secondary-button.tsx b/packages/components/buttons/secondary-button/src/secondary-button.tsx index c65c4984ac..8bd343eee6 100644 --- a/packages/components/buttons/secondary-button/src/secondary-button.tsx +++ b/packages/components/buttons/secondary-button/src/secondary-button.tsx @@ -11,6 +11,7 @@ import { css } from '@emotion/react'; import { useNavigate, locationDescriptorToString, + shouldNavigate, } from '@commercetools-uikit/router-provider'; import { designTokens } from '@commercetools-uikit/design-system'; import Inline from '@commercetools-uikit/spacings-inline'; @@ -180,7 +181,9 @@ export const SecondaryButton = < ...props, }), ...(shouldUseLinkTag - ? { href: locationDescriptorToString(props.to as string) } + ? props.as + ? { to: props.to } + : { href: locationDescriptorToString(props.to as string) } : {}), }; @@ -221,15 +224,17 @@ export const SecondaryButton = < return ( ) => { - event.preventDefault(); - navigate(props.to); + if (shouldNavigate(event)) { + event.preventDefault(); + navigate(props.to); + } }) as TSecondaryButtonProps['onClick']) : props.onClick } diff --git a/packages/components/card/src/card.tsx b/packages/components/card/src/card.tsx index b8932c6de2..b046b35502 100644 --- a/packages/components/card/src/card.tsx +++ b/packages/components/card/src/card.tsx @@ -3,6 +3,7 @@ import { css } from '@emotion/react'; import { useNavigate, locationDescriptorToString, + shouldNavigate, type TLocationDescriptor, } from '@commercetools-uikit/router-provider'; import { designTokens } from '@commercetools-uikit/design-system'; @@ -141,8 +142,10 @@ const Card = ({ onClick={ navigate ? (event) => { - event.preventDefault(); - navigate(props.to!); + if (shouldNavigate(event)) { + event.preventDefault(); + navigate(props.to!); + } } : undefined } diff --git a/packages/components/link/src/link.tsx b/packages/components/link/src/link.tsx index 74eb8788b1..c247ad9a5e 100644 --- a/packages/components/link/src/link.tsx +++ b/packages/components/link/src/link.tsx @@ -10,6 +10,7 @@ import styled from '@emotion/styled'; import { useNavigate, locationDescriptorToString, + shouldNavigate, } from '@commercetools-uikit/router-provider'; import { designTokens } from '@commercetools-uikit/design-system'; import { css } from '@emotion/react'; @@ -174,7 +175,7 @@ const Link = ({ css={getLinkStyles(allProps)} href={locationDescriptorToString(props.to)} onClick={(event) => { - if (navigate) { + if (navigate && shouldNavigate(event)) { event.preventDefault(); navigate(props.to); } diff --git a/packages/components/tag/src/tag-body.tsx b/packages/components/tag/src/tag-body.tsx index e250701372..9c06015b40 100644 --- a/packages/components/tag/src/tag-body.tsx +++ b/packages/components/tag/src/tag-body.tsx @@ -4,6 +4,7 @@ import { ElementType, ReactNode } from 'react'; import styled from '@emotion/styled'; import { css } from '@emotion/react'; import { designTokens } from '@commercetools-uikit/design-system'; +import { shouldNavigate } from '@commercetools-uikit/router-provider'; import Text from '@commercetools-uikit/text'; import { DragIcon } from '@commercetools-uikit/icons'; @@ -75,8 +76,10 @@ const TagBody = ({ ? undefined : props.onNavigate ? (event: React.MouseEvent) => { - event.preventDefault(); - props.onNavigate!(); + if (shouldNavigate(event)) { + event.preventDefault(); + props.onNavigate!(); + } if (props.onClick) props.onClick(event); } : props.onClick; diff --git a/packages/router-provider/src/index.ts b/packages/router-provider/src/index.ts index a244a2d312..ebb0f6e112 100644 --- a/packages/router-provider/src/index.ts +++ b/packages/router-provider/src/index.ts @@ -1,4 +1,4 @@ export { RouterProvider, useNavigate } from './router-provider'; -export { locationDescriptorToString } from './utils'; +export { locationDescriptorToString, shouldNavigate } from './utils'; export { default as version } from './version'; export * from './export-types'; diff --git a/packages/router-provider/src/utils.ts b/packages/router-provider/src/utils.ts index b024daf2f1..f1d7568a37 100644 --- a/packages/router-provider/src/utils.ts +++ b/packages/router-provider/src/utils.ts @@ -1,5 +1,31 @@ import type { TLocationDescriptor, TLocationDescriptorObject } from './types'; +/** + * Returns true if the click event should be handled by navigate() + * rather than the browser's default behavior. + * Mirrors the guard react-router's uses internally. + */ +export function shouldNavigate( + event: Pick< + MouseEvent, + | 'button' + | 'metaKey' + | 'altKey' + | 'ctrlKey' + | 'shiftKey' + | 'defaultPrevented' + > +): boolean { + return ( + event.button === 0 && + !event.metaKey && + !event.altKey && + !event.ctrlKey && + !event.shiftKey && + !event.defaultPrevented + ); +} + /** * Converts a TLocationDescriptor (string or object) to a URL string. * Used to set the `href` attribute on `` tags.