From b6f7bbe73346a9d4f32dd675b83fc2235fc06a08 Mon Sep 17 00:00:00 2001 From: missating Date: Thu, 28 May 2026 17:05:54 +0200 Subject: [PATCH 01/17] enter pre release mode --- .changeset/pre.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000000..ca35cc8aa7 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,15 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "@sumup-oss/circuit-ui": "11.4.1", + "@sumup-oss/design-tokens": "10.0.2", + "@sumup-oss/eslint-plugin-circuit-ui": "7.2.0", + "@sumup-oss/icons": "6.7.0", + "@sumup-oss/stylelint-plugin-circuit-ui": "4.2.0", + "@sumup-oss/astro-template-circuit-ui": "5.0.0", + "@sumup-oss/cna-template": "6.0.1", + "next-app": "2.0.2" + }, + "changesets": [] +} From 1ea4fe88cf5bb6e4574e3a0f1f322d969a70ebd3 Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Tue, 2 Jun 2026 15:52:53 +0200 Subject: [PATCH 02/17] Drop support for Node 22 (#3647) * drop support for node 22 * drop support for node 22 * add node 26 to the matrix --- .changeset/fuzzy-bees-enter.md | 7 +++++++ .github/workflows/ci.yml | 2 +- .github/workflows/templates.yml | 2 +- packages/circuit-ui/package.json | 2 +- packages/design-tokens/package.json | 2 +- packages/icons/package.json | 2 +- 6 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 .changeset/fuzzy-bees-enter.md diff --git a/.changeset/fuzzy-bees-enter.md b/.changeset/fuzzy-bees-enter.md new file mode 100644 index 0000000000..53924efd90 --- /dev/null +++ b/.changeset/fuzzy-bees-enter.md @@ -0,0 +1,7 @@ +--- +"@sumup-oss/design-tokens": major +"@sumup-oss/circuit-ui": major +"@sumup-oss/icons": major +--- + +Dropped support for Node.js 22. Node.js 24 or higher is now required. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 353da35342..98849373d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [22, 24] + node: [24, 26] steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml index 52cc391831..d844bcc763 100644 --- a/.github/workflows/templates.yml +++ b/.github/workflows/templates.yml @@ -17,7 +17,7 @@ jobs: strategy: # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs matrix: - node: [22, 24] + node: [24, 26] template: [astro, nextjs] include: - template: astro diff --git a/packages/circuit-ui/package.json b/packages/circuit-ui/package.json index 28276f0c2d..5af231678e 100644 --- a/packages/circuit-ui/package.json +++ b/packages/circuit-ui/package.json @@ -105,7 +105,7 @@ "temporal-polyfill": ">= 0.2.0 < 0.4.0" }, "engines": { - "node": ">=22", + "node": ">=24", "typescript": ">=4.1" } } diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 475bb391ce..82899afeed 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -42,6 +42,6 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22" + "node": ">=24" } } diff --git a/packages/icons/package.json b/packages/icons/package.json index 1235c05e31..40197a3272 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -52,6 +52,6 @@ "react": ">=16.0.0 <20.0.0" }, "engines": { - "node": ">=22" + "node": ">=24" } } From ded38de1e7860fd1241d1de2229f39c09c5a82a4 Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Wed, 3 Jun 2026 11:16:52 +0200 Subject: [PATCH 03/17] Remove "placeholder" color from Body and Compact components (#3646) * feat: remove placeholder color from body and compack component * add changeset * remove placeholdr color from numeral component * update changeset file --- .changeset/some-eggs-laugh.md | 5 +++++ packages/circuit-ui/components/Body/Body.module.css | 4 ---- packages/circuit-ui/components/Body/Body.stories.tsx | 1 - packages/circuit-ui/components/Body/Body.tsx | 1 - packages/circuit-ui/components/Compact/Compact.module.css | 4 ---- packages/circuit-ui/components/Compact/Compact.stories.tsx | 1 - packages/circuit-ui/components/Compact/Compact.tsx | 1 - packages/circuit-ui/components/Numeral/Numeral.module.css | 4 ---- packages/circuit-ui/components/Numeral/Numeral.stories.tsx | 1 - packages/circuit-ui/components/Numeral/Numeral.tsx | 1 - 10 files changed, 5 insertions(+), 18 deletions(-) create mode 100644 .changeset/some-eggs-laugh.md diff --git a/.changeset/some-eggs-laugh.md b/.changeset/some-eggs-laugh.md new file mode 100644 index 0000000000..fb93188217 --- /dev/null +++ b/.changeset/some-eggs-laugh.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the `placeholder` color option from the `Body`, `Numeral` and `Compact` components' `color` prop. The `--cui-fg-placeholder` token does not meet WCAG 2.1 contrast requirements for text content. diff --git a/packages/circuit-ui/components/Body/Body.module.css b/packages/circuit-ui/components/Body/Body.module.css index 2cd9a6d91e..b0d348d0b7 100644 --- a/packages/circuit-ui/components/Body/Body.module.css +++ b/packages/circuit-ui/components/Body/Body.module.css @@ -50,10 +50,6 @@ color: var(--cui-fg-subtle); } -.placeholder { - color: var(--cui-fg-placeholder); -} - .on-strong { color: var(--cui-fg-on-strong); } diff --git a/packages/circuit-ui/components/Body/Body.stories.tsx b/packages/circuit-ui/components/Body/Body.stories.tsx index b915125b43..775af50ac1 100644 --- a/packages/circuit-ui/components/Body/Body.stories.tsx +++ b/packages/circuit-ui/components/Body/Body.stories.tsx @@ -65,7 +65,6 @@ export const Decorations = (args: BodyProps) => ( const colors = [ 'normal', 'subtle', - 'placeholder', 'on-strong', 'on-strong-subtle', 'accent', diff --git a/packages/circuit-ui/components/Body/Body.tsx b/packages/circuit-ui/components/Body/Body.tsx index b6bc283369..a796067617 100644 --- a/packages/circuit-ui/components/Body/Body.tsx +++ b/packages/circuit-ui/components/Body/Body.tsx @@ -65,7 +65,6 @@ export interface BodyProps extends HTMLAttributes { color?: | 'normal' | 'subtle' - | 'placeholder' | 'on-strong' | 'on-strong-subtle' | 'accent' diff --git a/packages/circuit-ui/components/Compact/Compact.module.css b/packages/circuit-ui/components/Compact/Compact.module.css index f904e1e944..68094f3ca8 100644 --- a/packages/circuit-ui/components/Compact/Compact.module.css +++ b/packages/circuit-ui/components/Compact/Compact.module.css @@ -44,10 +44,6 @@ color: var(--cui-fg-subtle); } -.placeholder { - color: var(--cui-fg-placeholder); -} - .on-strong { color: var(--cui-fg-on-strong); } diff --git a/packages/circuit-ui/components/Compact/Compact.stories.tsx b/packages/circuit-ui/components/Compact/Compact.stories.tsx index 9d861c7295..817b6574a7 100644 --- a/packages/circuit-ui/components/Compact/Compact.stories.tsx +++ b/packages/circuit-ui/components/Compact/Compact.stories.tsx @@ -61,7 +61,6 @@ export const Weights = (args: CompactProps) => const colors = [ 'normal', 'subtle', - 'placeholder', 'on-strong', 'on-strong-subtle', 'accent', diff --git a/packages/circuit-ui/components/Compact/Compact.tsx b/packages/circuit-ui/components/Compact/Compact.tsx index 1015db1fbe..c45dfbe7c6 100644 --- a/packages/circuit-ui/components/Compact/Compact.tsx +++ b/packages/circuit-ui/components/Compact/Compact.tsx @@ -35,7 +35,6 @@ export interface CompactProps extends HTMLAttributes { color?: | 'normal' | 'subtle' - | 'placeholder' | 'on-strong' | 'on-strong-subtle' | 'accent' diff --git a/packages/circuit-ui/components/Numeral/Numeral.module.css b/packages/circuit-ui/components/Numeral/Numeral.module.css index 3f8bc92f65..b6c1a6e071 100644 --- a/packages/circuit-ui/components/Numeral/Numeral.module.css +++ b/packages/circuit-ui/components/Numeral/Numeral.module.css @@ -40,10 +40,6 @@ color: var(--cui-fg-subtle); } -.placeholder { - color: var(--cui-fg-placeholder); -} - .on-strong { color: var(--cui-fg-on-strong); } diff --git a/packages/circuit-ui/components/Numeral/Numeral.stories.tsx b/packages/circuit-ui/components/Numeral/Numeral.stories.tsx index 3516552470..e8c728464c 100644 --- a/packages/circuit-ui/components/Numeral/Numeral.stories.tsx +++ b/packages/circuit-ui/components/Numeral/Numeral.stories.tsx @@ -51,7 +51,6 @@ export const Sizes = (args: NumeralProps) => const colors = [ 'normal', 'subtle', - 'placeholder', 'on-strong', 'on-strong-subtle', 'accent', diff --git a/packages/circuit-ui/components/Numeral/Numeral.tsx b/packages/circuit-ui/components/Numeral/Numeral.tsx index 95186e0c68..2f92d693a2 100644 --- a/packages/circuit-ui/components/Numeral/Numeral.tsx +++ b/packages/circuit-ui/components/Numeral/Numeral.tsx @@ -31,7 +31,6 @@ export interface NumeralProps extends HTMLAttributes { color?: | 'normal' | 'subtle' - | 'placeholder' | 'on-strong' | 'on-strong-subtle' | 'accent' From c30f8860b1eee9616b1d334efb1c9d1aa4b99d20 Mon Sep 17 00:00:00 2001 From: sirineJ <112706079+sirineJ@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:27:42 +0200 Subject: [PATCH 04/17] Remove legacy tooltip inline elements (#3670) * removed legacy tooltip component * removed legacy inline elements component * add changesets --- .changeset/petite-rooms-trade.md | 5 + .changeset/ripe-symbols-film.md | 5 + .../InlineElements/InlineElements.spec.tsx | 65 ---- .../InlineElements/InlineElements.stories.tsx | 95 ----- .../legacy/InlineElements/InlineElements.tsx | 152 -------- .../InlineElements.spec.tsx.snap | 287 --------------- .../components/legacy/InlineElements/index.ts | 18 - .../components/legacy/Tooltip/Tooltip.mdx | 160 -------- .../legacy/Tooltip/Tooltip.spec.tsx | 64 ---- .../legacy/Tooltip/Tooltip.stories.tsx | 80 ---- .../components/legacy/Tooltip/Tooltip.tsx | 128 ------- .../__snapshots__/Tooltip.spec.tsx.snap | 345 ------------------ .../components/legacy/Tooltip/index.ts | 18 - packages/circuit-ui/legacy.ts | 6 - 14 files changed, 10 insertions(+), 1418 deletions(-) create mode 100644 .changeset/petite-rooms-trade.md create mode 100644 .changeset/ripe-symbols-film.md delete mode 100644 packages/circuit-ui/components/legacy/InlineElements/InlineElements.spec.tsx delete mode 100644 packages/circuit-ui/components/legacy/InlineElements/InlineElements.stories.tsx delete mode 100644 packages/circuit-ui/components/legacy/InlineElements/InlineElements.tsx delete mode 100644 packages/circuit-ui/components/legacy/InlineElements/__snapshots__/InlineElements.spec.tsx.snap delete mode 100644 packages/circuit-ui/components/legacy/InlineElements/index.ts delete mode 100644 packages/circuit-ui/components/legacy/Tooltip/Tooltip.mdx delete mode 100644 packages/circuit-ui/components/legacy/Tooltip/Tooltip.spec.tsx delete mode 100644 packages/circuit-ui/components/legacy/Tooltip/Tooltip.stories.tsx delete mode 100644 packages/circuit-ui/components/legacy/Tooltip/Tooltip.tsx delete mode 100644 packages/circuit-ui/components/legacy/Tooltip/__snapshots__/Tooltip.spec.tsx.snap delete mode 100644 packages/circuit-ui/components/legacy/Tooltip/index.ts diff --git a/.changeset/petite-rooms-trade.md b/.changeset/petite-rooms-trade.md new file mode 100644 index 0000000000..988d7ffe80 --- /dev/null +++ b/.changeset/petite-rooms-trade.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the legacy Tooltip component. Use the newer Tooltip or Toggletip components instead. diff --git a/.changeset/ripe-symbols-film.md b/.changeset/ripe-symbols-film.md new file mode 100644 index 0000000000..3d1edaf43f --- /dev/null +++ b/.changeset/ripe-symbols-film.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the deprecated InlineElements component. Use [CSS Flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) or [CSS Grid](https://css-tricks.com/css-grid-layout-guide/) instead. diff --git a/packages/circuit-ui/components/legacy/InlineElements/InlineElements.spec.tsx b/packages/circuit-ui/components/legacy/InlineElements/InlineElements.spec.tsx deleted file mode 100644 index 95279d38fd..0000000000 --- a/packages/circuit-ui/components/legacy/InlineElements/InlineElements.spec.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, expect, it } from 'vitest'; - -import { render, axe, type RenderFn } from '../../../util/test-utils.js'; - -import { InlineElements, type InlineElementsProps } from './InlineElements.js'; - -describe('InlineElements', () => { - function renderInlineElements( - renderFn: RenderFn, - props?: Omit, - ) { - return renderFn( - -
-
- , - ); - } - - /** - * Style tests. - */ - it('should render with default styles', () => { - const { container } = renderInlineElements(render); - expect(container).toMatchSnapshot(); - }); - - it('should render with ratio styles', () => { - const { container } = renderInlineElements(render, { - ratios: [2, 1], - }); - expect(container).toMatchSnapshot(); - }); - - it('should render with inlineMobile styles', () => { - const { container } = renderInlineElements(render, { - inlineMobile: true, - }); - expect(container).toMatchSnapshot(); - }); - - /** - * Accessibility tests. - */ - it('should meet accessibility guidelines', async () => { - const { container } = renderInlineElements(render); - const actual = await axe(container); - expect(actual).toHaveNoViolations(); - }); -}); diff --git a/packages/circuit-ui/components/legacy/InlineElements/InlineElements.stories.tsx b/packages/circuit-ui/components/legacy/InlineElements/InlineElements.stories.tsx deleted file mode 100644 index 2a95da0833..0000000000 --- a/packages/circuit-ui/components/legacy/InlineElements/InlineElements.stories.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @jsxImportSource @emotion/react */ - -import { css } from '@emotion/react'; - -import { Stack } from '../../../../../.storybook/components/index.js'; -import styled from '../../../styles/styled.js'; - -import { InlineElements } from './InlineElements.js'; - -export default { - title: 'Layout/InlineElements', - component: InlineElements, - tags: ['status:deprecated'], - parameters: { - controls: { hideNoControlsWarning: true }, - chromatic: { - disableSnapshot: true, - }, - }, -}; - -const Box = styled.div` - text-align: center; - font-size: 16px; - font-weight: bold; - line-height: 24px; - height: 48px; - padding: 12px; - - &:nth-of-type(n) { - background-color: var(--cui-bg-highlight); - } - - &:nth-of-type(2n) { - background-color: var(--cui-bg-subtle); - } -`; - -const inlineElementsStyles = css` - width: 95vw; - max-width: 600px; - margin: 0 auto; - border: 1px solid magenta; -`; - -export const Base = () => ( - - - - - - - - - - - - 2x - 1x - - -); - -export const InlineOnMobile = () => ( - - - - - - - - - - - - 2x - 1x - - -); diff --git a/packages/circuit-ui/components/legacy/InlineElements/InlineElements.tsx b/packages/circuit-ui/components/legacy/InlineElements/InlineElements.tsx deleted file mode 100644 index 71030d411f..0000000000 --- a/packages/circuit-ui/components/legacy/InlineElements/InlineElements.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @jsxImportSource @emotion/react */ - -import { Children, type ReactElement } from 'react'; -import { css } from '@emotion/react'; - -import styled from '../../../styles/styled.js'; -import { clearfix } from '../../../styles/style-mixins.js'; - -export interface InlineElementsProps { - /** - * The child elements to be displayed inline. - */ - children: ReactElement | ReactElement[]; - /** - * Let's children take up widths according to given ratios. The ratios are - * equivalent to the flex-grow parameter, which they are used with. - */ - ratios?: number[]; - /** - * Forces inline display even on mobile. - */ - inlineMobile?: boolean; -} - -const fallbackBaseStyles = ({ children }: InlineElementsProps) => { - const childrenCount = Children.count(children); - - return css` - > * { - display: block; - width: 100%; - } - @media (min-width: 480px) { - > * { - float: left; - width: ${(1 / childrenCount) * 95}%; - width: calc((100% - ${childrenCount - 1} * var(--cui-spacings-byte)) / ${childrenCount}); - } - ${clearfix()}; - } - `; -}; - -const baseStyles = ({ ratios = [], children }: InlineElementsProps) => { - const flexGrows = - ratios.length && - Children.map( - children, - (_, childIndex) => ` - > :nth-child(${childIndex + 1}) { - flex-grow: ${ratios[childIndex] || 1}; - width: auto; - } - `, - ).join('\n'); - - return css` - display: flex; - flex-direction: column; - width: 100%; - - > * { - &:not(:last-of-type) { - margin-bottom: var(--cui-spacings-byte); - } - } - - @media (min-width: 480px) { - align-items: stretch; - flex-direction: row; - justify-content: stretch; - - > * { - flex-grow: 1; - width: auto; - - &:not(:last-of-type) { - margin-bottom: 0; - margin-right: var(--cui-spacings-byte); - } - } - - ${flexGrows}; - } - `; -}; - -const fallbackInlineMobileStyles = ({ - inlineMobile, - children, -}: InlineElementsProps) => { - if (!inlineMobile) { - return null; - } - - const childrenCount = Children.count(children); - - return css` - @media (max-width: 479px) { - > * { - float: left; - width: ${(1 / childrenCount) * 95}%; - width: calc((100% - ${childrenCount - 1} * var(--cui-spacings-byte)) / ${childrenCount}); - } - ${clearfix()}; - } - `; -}; - -const inlineMobileStyles = ({ inlineMobile }: InlineElementsProps) => - inlineMobile && - css` - @media (max-width: 479px) { - flex-direction: row; - flex-grow: 1; - width: auto; - - > * { - &:not(:last-of-type) { - margin-bottom: 0; - margin-right: var(--cui-spacings-byte); - } - } - } - `; - -/** - * @deprecated Use [CSS Flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox/) or [CSS Grid](https://css-tricks.com/css-grid-layout-guide/) instead. - * - * Layout helper that displays child elements inline. Useful for form elements. - */ -export const InlineElements = styled('div')( - fallbackBaseStyles, - baseStyles, - fallbackInlineMobileStyles, - inlineMobileStyles, -); diff --git a/packages/circuit-ui/components/legacy/InlineElements/__snapshots__/InlineElements.spec.tsx.snap b/packages/circuit-ui/components/legacy/InlineElements/__snapshots__/InlineElements.spec.tsx.snap deleted file mode 100644 index c8f96f6f0d..0000000000 --- a/packages/circuit-ui/components/legacy/InlineElements/__snapshots__/InlineElements.spec.tsx.snap +++ /dev/null @@ -1,287 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`InlineElements > should render with default styles 1`] = ` -.circuit-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - width: 100%; -} - -.circuit-0>* { - display: block; - width: 100%; -} - -@media (min-width: 480px) { - .circuit-0>* { - float: left; - width: 47.5%; - width: calc((100% - 1 * var(--cui-spacings-byte)) / 2); - } - - .circuit-0::before, - .circuit-0::after { - content: ' '; - display: table; - } - - .circuit-0::after { - clear: both; - } -} - -.circuit-0>*:not(:last-of-type) { - margin-bottom: var(--cui-spacings-byte); -} - -@media (min-width: 480px) { - .circuit-0 { - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: stretch; - -ms-flex-pack: stretch; - -webkit-justify-content: stretch; - justify-content: stretch; - } - - .circuit-0>* { - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - width: auto; - } - - .circuit-0>*:not(:last-of-type) { - margin-bottom: 0; - margin-right: var(--cui-spacings-byte); - } -} - -
-
-
-
-
-
-`; - -exports[`InlineElements > should render with inlineMobile styles 1`] = ` -.circuit-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - width: 100%; -} - -.circuit-0>* { - display: block; - width: 100%; -} - -@media (min-width: 480px) { - .circuit-0>* { - float: left; - width: 47.5%; - width: calc((100% - 1 * var(--cui-spacings-byte)) / 2); - } - - .circuit-0::before, - .circuit-0::after { - content: ' '; - display: table; - } - - .circuit-0::after { - clear: both; - } -} - -.circuit-0>*:not(:last-of-type) { - margin-bottom: var(--cui-spacings-byte); -} - -@media (min-width: 480px) { - .circuit-0 { - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: stretch; - -ms-flex-pack: stretch; - -webkit-justify-content: stretch; - justify-content: stretch; - } - - .circuit-0>* { - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - width: auto; - } - - .circuit-0>*:not(:last-of-type) { - margin-bottom: 0; - margin-right: var(--cui-spacings-byte); - } -} - -@media (max-width: 479px) { - .circuit-0>* { - float: left; - width: 47.5%; - width: calc((100% - 1 * var(--cui-spacings-byte)) / 2); - } - - .circuit-0::before, - .circuit-0::after { - content: ' '; - display: table; - } - - .circuit-0::after { - clear: both; - } -} - -@media (max-width: 479px) { - .circuit-0 { - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - width: auto; - } - - .circuit-0>*:not(:last-of-type) { - margin-bottom: 0; - margin-right: var(--cui-spacings-byte); - } -} - -
-
-
-
-
-
-`; - -exports[`InlineElements > should render with ratio styles 1`] = ` -.circuit-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - width: 100%; -} - -.circuit-0>* { - display: block; - width: 100%; -} - -@media (min-width: 480px) { - .circuit-0>* { - float: left; - width: 47.5%; - width: calc((100% - 1 * var(--cui-spacings-byte)) / 2); - } - - .circuit-0::before, - .circuit-0::after { - content: ' '; - display: table; - } - - .circuit-0::after { - clear: both; - } -} - -.circuit-0>*:not(:last-of-type) { - margin-bottom: var(--cui-spacings-byte); -} - -@media (min-width: 480px) { - .circuit-0 { - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: stretch; - -ms-flex-pack: stretch; - -webkit-justify-content: stretch; - justify-content: stretch; - } - - .circuit-0>* { - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - width: auto; - } - - .circuit-0>*:not(:last-of-type) { - margin-bottom: 0; - margin-right: var(--cui-spacings-byte); - } - - .circuit-0>:nth-child(1) { - -webkit-box-flex: 2; - -webkit-flex-grow: 2; - -ms-flex-positive: 2; - flex-grow: 2; - width: auto; - } - - .circuit-0>:nth-child(2) { - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - width: auto; - } -} - -
-
-
-
-
-
-`; diff --git a/packages/circuit-ui/components/legacy/InlineElements/index.ts b/packages/circuit-ui/components/legacy/InlineElements/index.ts deleted file mode 100644 index ab4fa10446..0000000000 --- a/packages/circuit-ui/components/legacy/InlineElements/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { InlineElements } from './InlineElements.js'; - -export type { InlineElementsProps } from './InlineElements.js'; diff --git a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.mdx b/packages/circuit-ui/components/legacy/Tooltip/Tooltip.mdx deleted file mode 100644 index 062198ee3c..0000000000 --- a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.mdx +++ /dev/null @@ -1,160 +0,0 @@ -import { Meta, Status, Props, Story } from '../../../../../.storybook/components'; -import * as Stories from './Tooltip.stories'; - - - -# Tooltip - - - Use the [Tooltip](Components/Tooltip/Docs) or [Toggletip](Components/Toggletip/Docs) components instead. - - - - - - - -## Dependencies - -The Tooltip component depends on [Emotion.js](https://github.com/emotion-js/emotion) which isn't included in Circuit UI. Install it manually before using the components: - -```bash -# npm -npm install @emotion/react@^11 @emotion/styled@^11 -# yarn v1 -yarn add @emotion/react@^11 @emotion/styled@^11 -``` - -The Tooltip component depends on the legacy JSON theme. Wrap the the component or your entire application in the `ThemeProvider` from Emotion.js: - -```tsx -import { ThemeProvider } from '@emotion/react'; -import { light } from '@sumup-oss/design-tokens'; -import { Tooltip } from '@sumup-oss/circuit-ui/legacy'; - -export default function App() { - return ( - - - - ); -} -``` - -## Positions - - - -## Migration - -Tooltips and Toggletips hide information by default and require user interaction to be shown. They should be used as a last resort when space truly is limited. Often, it is preferable to use the [Input](Components/Input/Docs)'s `validationHint` or the [Checkbox](Components/Checkbox/Docs), [RadioButton](Components/RadioButton/Docs) or [Toggle](Components/Toggle/Docs)'s `explanation` prop instead to display the information inline. - -While the deprecated Tooltip component only focused on the look and position of its content, the new Tooltip and Toggletip components also take the semantic context and relationship to their reference element into account. - -### Tooltip - -Use the new [Tooltip](Components/Tooltip/Docs) component when it acts as the reference element's [main label](https://w3c.github.io/accname/#dfn-accessible-name) or [supplemental description](https://w3c.github.io/accname/#dfn-accessible-description) and appears on _hover_ or _focus_ of the reference element. - -Here's an example: - -```tsx -// Before -import { useState } from "react"; -import { Badge } from "@sumup-oss/circuit-ui"; -import { Tooltip } from "@sumup-oss/circuit-ui/legacy"; - -function Component() { - const [isVisible, setVisible] = useState(false); - return ( -
- {isVisible && ( - - A chargeback is a return of money to a payer of a transaction, - especially a credit card transaction. - - )} - {/* This implementation wasn't accessible to keyboard users */} - setVisible(true)} - onMouseLeave={() => setVisible(false)} - > - Chargeback - -
- ); -} -``` - -```tsx -// After -import { Badge, Tooltip } from "@sumup-oss/circuit-ui"; - -function Component() { - return ( - ( - // The reference element must be focusable - - Chargeback - - )} - /> - ); -} -``` - -### Toggletip - -Use the new [Toggletip](Components/Toggletip/Docs) component if the content should be structured or interactive and the reference element's sole purpose is toggling the tip. - -Here's an example: - -```tsx -// Before -import { useState } from "react"; -import { Tooltip } from "@sumup-oss/circuit-ui/legacy"; -import { Info } from "@sumup-oss/icons"; - -function Component() { - const [isVisible, setVisible] = useState(false); - return ( -
- {isVisible && ( - -

What is a chargeback?

-

- A chargeback is a return of money to a payer of a transaction, - especially a credit card transaction. -

-
- )} - {/* This implementation wasn't accessible because screenreader and - keyboard users couldn't perceive the interactive element. */} - setVisible((prev) => !prev)} size="16" /> -
- ); -} -``` - -```tsx -// After -import { IconButton, Toggletip } from "@sumup-oss/circuit-ui"; -import { Info } from "@sumup-oss/icons"; - -function Component() { - return ( - ( - // The reference element must be a button - - Show details - - )} - /> - ); -} -``` diff --git a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.spec.tsx b/packages/circuit-ui/components/legacy/Tooltip/Tooltip.spec.tsx deleted file mode 100644 index 6f1b9c3a97..0000000000 --- a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.spec.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, expect, it } from 'vitest'; - -import { render, axe } from '../../../util/test-utils.js'; - -import type { Position, Alignment } from './Tooltip.js'; -import { Tooltip } from './Tooltip.js'; - -describe('Tooltip', () => { - /** - * Style tests. - */ - const positions: Position[] = ['top', 'right', 'bottom', 'left']; - positions.forEach((position) => { - it(`should render with position ${position}, when passed "${position}" for the position prop`, () => { - const { container } = render( - Tooltip content, - ); - expect(container).toMatchSnapshot(); - }); - }); - - const alignments: Alignment[] = ['right', 'left', 'top', 'bottom', 'center']; - alignments.forEach((align) => { - it(`should render with align ${align}, when passed "${align}" for the align prop`, () => { - const { container } = render( - Tooltip content, - ); - expect(container).toMatchSnapshot(); - }); - }); - - it('should override alignment styles with position styles', () => { - const { container } = render( - - Tooltip content - , - ); - expect(container).toMatchSnapshot(); - }); - - /** - * Accessibility tests. - */ - it('should meet accessibility guidelines', async () => { - const { container } = render(Text); - const actual = await axe(container); - expect(actual).toHaveNoViolations(); - }); -}); diff --git a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.stories.tsx b/packages/circuit-ui/components/legacy/Tooltip/Tooltip.stories.tsx deleted file mode 100644 index 93a2e822a8..0000000000 --- a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.stories.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Info } from '@sumup-oss/icons'; - -import { Stack } from '../../../../../.storybook/components/index.js'; -import styled from '../../../styles/styled.js'; - -import { Tooltip, type TooltipProps } from './Tooltip.js'; - -export default { - title: 'Components/Tooltip/Legacy', - component: Tooltip, - tags: ['status:deprecated'], - parameters: { - chromatic: { - disableSnapshot: true, - }, - }, -}; - -const TooltipContainer = styled('div')` - position: relative; - line-height: 0; - margin: 1rem; - - div { - visibility: visible; - opacity: 1; - } -`; - -export const Base = (args: TooltipProps) => ( - - - I am a teeny, tiny tooltip. - - - -); - -Base.args = { - position: 'right', - align: 'center', -}; - -export const Positions = () => ( - - - - Left center - - - - - - Top left - - - - - - Bottom right - - - - -); diff --git a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.tsx b/packages/circuit-ui/components/legacy/Tooltip/Tooltip.tsx deleted file mode 100644 index 8dcd50f568..0000000000 --- a/packages/circuit-ui/components/legacy/Tooltip/Tooltip.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** @jsxImportSource @emotion/react */ - -import { css } from '@emotion/react'; - -import styled from '../../../styles/styled.js'; -import type { StyleProps } from '../../../styles/styled.js'; -import { typography } from '../../../styles/style-mixins.js'; - -const baseStyles = () => css` - display: inline-block; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - transition: opacity 0.3s; -`; - -const positionMap: Record = { - top: 'bottom', - right: 'left', - bottom: 'top', - left: 'right', -}; - -export type Position = 'top' | 'right' | 'bottom' | 'left'; - -const getPositionStyles = ({ - theme, - position, -}: StyleProps & Required) => { - const absolutePosition = positionMap[position]; - - return ` - ${absolutePosition}: calc(100% + ${theme.spacings.bit}); - `; -}; - -type VerticalAlignment = 'top' | 'center' | 'bottom'; - -function isVerticalAlignment(value: unknown): value is VerticalAlignment { - return value === 'top' || value === 'center' || value === 'bottom'; -} - -type HorizontalAlignment = 'left' | 'center' | 'right'; - -function isHorizontalAlignment(value: unknown): value is VerticalAlignment { - return value === 'left' || value === 'center' || value === 'right'; -} - -export type Alignment = VerticalAlignment | HorizontalAlignment; - -const getAlignmentStyles = ({ - theme, - position, - align, -}: StyleProps & Required) => { - const isHorizontal = position === 'bottom' || position === 'top'; - - if (isHorizontal && isVerticalAlignment(align)) { - return ` - left: 50%; - transform: translateX(-50%); - `; - } - - if (!isHorizontal && isHorizontalAlignment(align)) { - return ` - top: 50%; - transform: translateY(-50%); - `; - } - - // align is not 'center' and therefore has the same possible values - // as a Position. - const absolutePosition = positionMap[align as Position]; - - return ` - ${absolutePosition}: 50%; - ${absolutePosition}: calc(50% - (${theme.spacings.mega} + ${theme.spacings.bit})); - `; -}; - -const positionAndAlignStyles = ({ - theme, - position = 'right', - align = 'center', -}: StyleProps & TooltipProps) => css` - ${getAlignmentStyles({ theme, position, align })}; - ${getPositionStyles({ theme, position, align })}; -`; - -export interface TooltipProps { - position?: Position; - align?: Alignment; -} - -/** - * @deprecated - * - * Use the [`Tooltip`](https://circuit.sumup.com/?path=/docs/components-tooltip--docs) - * or [`Toggletip`](https://circuit.sumup.com/?path=/docs/components-toggletip--docs) components instead - * ([migration guide](https://circuit.sumup.com/?path=/docs/components-tooltip-legacy--docs#migration)). - */ -export const Tooltip = styled.div( - typography('two'), - baseStyles, - positionAndAlignStyles, -); diff --git a/packages/circuit-ui/components/legacy/Tooltip/__snapshots__/Tooltip.spec.tsx.snap b/packages/circuit-ui/components/legacy/Tooltip/__snapshots__/Tooltip.spec.tsx.snap deleted file mode 100644 index 6b6d62b8a5..0000000000 --- a/packages/circuit-ui/components/legacy/Tooltip/__snapshots__/Tooltip.spec.tsx.snap +++ /dev/null @@ -1,345 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Tooltip > should override alignment styles with position styles 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - -webkit-transform: translateY(-50%); - -moz-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - right: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with align bottom, when passed "bottom" for the align prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - top: calc(50% - (16px + 4px)); - left: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with align center, when passed "center" for the align prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - -webkit-transform: translateY(-50%); - -moz-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - left: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with align left, when passed "left" for the align prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - -webkit-transform: translateY(-50%); - -moz-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - left: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with align right, when passed "right" for the align prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - -webkit-transform: translateY(-50%); - -moz-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - left: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with align top, when passed "top" for the align prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - bottom: 50%; - bottom: calc(50% - (16px + 4px)); - left: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with position bottom, when passed "bottom" for the position prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - left: 50%; - -webkit-transform: translateX(-50%); - -moz-transform: translateX(-50%); - -ms-transform: translateX(-50%); - transform: translateX(-50%); - top: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with position left, when passed "left" for the position prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - -webkit-transform: translateY(-50%); - -moz-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - right: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with position right, when passed "right" for the position prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - top: 50%; - -webkit-transform: translateY(-50%); - -moz-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - left: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; - -exports[`Tooltip > should render with position top, when passed "top" for the position prop 1`] = ` -.circuit-0 { - font-size: 0.9375rem; - line-height: 1.25rem; - display: inline-block; - width: -webkit-max-content; - width: -moz-max-content; - width: max-content; - max-width: 360px; - background-color: var(--cui-bg-elevated); - border-radius: var(--cui-border-radius-byte); - border: var(--cui-border-width-kilo) solid var(--cui-border-subtle); - box-shadow: 0 2px 6px 0 rgb(0 0 0 / 8%); - padding: var(--cui-spacings-byte) var(--cui-spacings-kilo); - position: absolute; - z-index: var(--cui-z-index-tooltip); - -webkit-transition: opacity 0.3s; - transition: opacity 0.3s; - left: 50%; - -webkit-transform: translateX(-50%); - -moz-transform: translateX(-50%); - -ms-transform: translateX(-50%); - transform: translateX(-50%); - bottom: calc(100% + 4px); -} - -
-
- Tooltip content -
-
-`; diff --git a/packages/circuit-ui/components/legacy/Tooltip/index.ts b/packages/circuit-ui/components/legacy/Tooltip/index.ts deleted file mode 100644 index 94059f9bae..0000000000 --- a/packages/circuit-ui/components/legacy/Tooltip/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright 2019, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { Tooltip } from './Tooltip.js'; - -export type { TooltipProps } from './Tooltip.js'; diff --git a/packages/circuit-ui/legacy.ts b/packages/circuit-ui/legacy.ts index 9617b90712..a35aad5be2 100644 --- a/packages/circuit-ui/legacy.ts +++ b/packages/circuit-ui/legacy.ts @@ -16,12 +16,6 @@ // Layout export { Grid, Row, Col } from './components/legacy/Grid/index.js'; export type { ColProps } from './components/legacy/Grid/index.js'; -export { InlineElements } from './components/legacy/InlineElements/index.js'; -export type { InlineElementsProps } from './components/legacy/InlineElements/index.js'; - -// Miscellaneous -export { Tooltip } from './components/legacy/Tooltip/index.js'; -export type { TooltipProps } from './components/legacy/Tooltip/index.js'; export { cx, From 5377af030a7a9cc4d3074027ee96d9184586e80b Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Thu, 4 Jun 2026 16:11:59 +0200 Subject: [PATCH 05/17] Remove deprecated props from the Modal and Toggle components (#3675) * remove deprecated props * add changeset files * update changeset --- .changeset/nice-pillows-shake.md | 5 ++++ .changeset/soft-lies-invite.md | 5 ++++ .../circuit-ui/components/Modal/Modal.tsx | 14 +-------- .../circuit-ui/components/Toggle/Toggle.tsx | 29 ------------------- 4 files changed, 11 insertions(+), 42 deletions(-) create mode 100644 .changeset/nice-pillows-shake.md create mode 100644 .changeset/soft-lies-invite.md diff --git a/.changeset/nice-pillows-shake.md b/.changeset/nice-pillows-shake.md new file mode 100644 index 0000000000..3a9476f1e5 --- /dev/null +++ b/.changeset/nice-pillows-shake.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the deprecated `checkedLabel` and `uncheckedLabel` props from `Toggle`. diff --git a/.changeset/soft-lies-invite.md b/.changeset/soft-lies-invite.md new file mode 100644 index 0000000000..207d8ea3c1 --- /dev/null +++ b/.changeset/soft-lies-invite.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the deprecated `hideCloseButton` prop from `Modal` (use `preventClose` instead). diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index 24d626951f..90c181f4d8 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -18,7 +18,6 @@ import { forwardRef, useCallback, useState, type Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; -import { deprecate } from '../../util/logger.js'; import { Dialog, type DialogProps, @@ -39,11 +38,6 @@ export interface ModalProps extends Omit { * Callback function invoked when the modal closes. */ onClose?: DialogProps['onCloseEnd']; - /** - * @deprecated This prop was passed to `react-modal` and is no longer relevant. - * Use the `preventClose` prop instead. Also see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role#required_javascript_features - */ - hideCloseButton?: boolean; /** * Prevent users from closing the modal by clicking/tapping the overlay or * pressing the escape key, and hides the close button. @@ -61,7 +55,6 @@ export const ANIMATION_DURATION = 300; export const Modal = forwardRef((props, ref) => { const { - hideCloseButton, variant = 'contextual', className, contentClassName, @@ -70,12 +63,7 @@ export const Modal = forwardRef((props, ref) => { onClose, ...rest } = props; - if (process.env.NODE_ENV !== 'production' && hideCloseButton) { - deprecate( - 'Modal', - 'The `hideCloseButton` prop has been deprecated. Use the `preventClose` prop instead.', - ); - } + const [isClosing, setIsClosing] = useState(false); const handleModalCloseEnd = useCallback(() => { diff --git a/packages/circuit-ui/components/Toggle/Toggle.tsx b/packages/circuit-ui/components/Toggle/Toggle.tsx index f649257319..fd3a82257e 100644 --- a/packages/circuit-ui/components/Toggle/Toggle.tsx +++ b/packages/circuit-ui/components/Toggle/Toggle.tsx @@ -24,7 +24,6 @@ import { import { FieldDescription, FieldWrapper } from '../Field/index.js'; import { clsx } from '../../styles/clsx.js'; import { utilClasses } from '../../styles/utility.js'; -import { deprecate } from '../../util/logger.js'; import classes from './Toggle.module.css'; @@ -41,14 +40,6 @@ export interface ToggleProps extends ButtonHTMLAttributes { * Is the Switch on? */ checked?: boolean; - /** - * @deprecated This prop is no longer needed. - */ - checkedLabel?: string; - /** - * @deprecated This prop is no longer needed. - */ - uncheckedLabel?: string; } /** @@ -60,8 +51,6 @@ export const Toggle = forwardRef( label, description, 'aria-describedby': describedBy, - checkedLabel, - uncheckedLabel, checked = false, onChange, className, @@ -89,24 +78,6 @@ export const Toggle = forwardRef( ); } - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' - ) { - if (checkedLabel) { - deprecate( - 'Toggle', - 'The `checkedLabel` prop is deprecated and can be removed.', - ); - } - if (uncheckedLabel) { - deprecate( - 'Toggle', - 'The `uncheckedLabel` prop is deprecated and can be removed.', - ); - } - } - return ( Date: Fri, 5 Jun 2026 09:37:29 +0200 Subject: [PATCH 06/17] removed uniqueId export (#3680) --- .changeset/cold-papayas-tan.md | 5 +++++ packages/circuit-ui/legacy.ts | 2 -- templates/astro/src/components/DocCard.astro | 3 +-- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 .changeset/cold-papayas-tan.md diff --git a/.changeset/cold-papayas-tan.md b/.changeset/cold-papayas-tan.md new file mode 100644 index 0000000000..7682cb413d --- /dev/null +++ b/.changeset/cold-papayas-tan.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the `uniqueId` utility from the public API. Use React’s `useId` hook instead. diff --git a/packages/circuit-ui/legacy.ts b/packages/circuit-ui/legacy.ts index a35aad5be2..5c77180b04 100644 --- a/packages/circuit-ui/legacy.ts +++ b/packages/circuit-ui/legacy.ts @@ -31,5 +31,3 @@ export { typography, center, } from './styles/style-mixins.js'; - -export { uniqueId } from './util/id.js'; diff --git a/templates/astro/src/components/DocCard.astro b/templates/astro/src/components/DocCard.astro index 61fb16dca4..5193ada8c6 100644 --- a/templates/astro/src/components/DocCard.astro +++ b/templates/astro/src/components/DocCard.astro @@ -1,6 +1,5 @@ --- import { Body, Card, Headline } from '@sumup-oss/circuit-ui'; -import { uniqueId } from '@sumup-oss/circuit-ui/legacy'; interface Props { title: string; @@ -9,7 +8,7 @@ interface Props { } const { title, description, href } = Astro.props; -const descriptionId = uniqueId(); +const descriptionId = `${title}-description`; --- From d1ef7739548c856660048d80ab92f3df5d6508b6 Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Fri, 5 Jun 2026 11:57:14 +0200 Subject: [PATCH 07/17] Remove deprecated component props (#3679) * remnove role prop from ActionMenu * remove default alt prop from avatar * remove deprecated italic prop * update changeset files * remove role prop from action menu * fix lint errors * Update .changeset/curvy-snakes-heal.md Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> * Update packages/circuit-ui/components/ActionMenu/ActionMenu.tsx Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> * remove unused div block --------- Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> --- .changeset/bitter-dancers-grin.md | 5 ++ .changeset/curvy-snakes-heal.md | 5 ++ .changeset/hip-ducks-mix.md | 5 ++ .changeset/slow-bikes-admire.md | 5 ++ .../components/ActionMenu/ActionMenu.spec.tsx | 30 +------- .../components/ActionMenu/ActionMenu.tsx | 73 +++++-------------- .../circuit-ui/components/Avatar/Avatar.tsx | 2 +- packages/circuit-ui/components/Body/Body.tsx | 8 +- .../circuit-ui/components/Numeral/Numeral.tsx | 8 +- 9 files changed, 42 insertions(+), 99 deletions(-) create mode 100644 .changeset/bitter-dancers-grin.md create mode 100644 .changeset/curvy-snakes-heal.md create mode 100644 .changeset/hip-ducks-mix.md create mode 100644 .changeset/slow-bikes-admire.md diff --git a/.changeset/bitter-dancers-grin.md b/.changeset/bitter-dancers-grin.md new file mode 100644 index 0000000000..1afd260c34 --- /dev/null +++ b/.changeset/bitter-dancers-grin.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the deprecated `Italic` decoration prop from the `Numeral` component following the brand refresh. diff --git a/.changeset/curvy-snakes-heal.md b/.changeset/curvy-snakes-heal.md new file mode 100644 index 0000000000..7da7a9286a --- /dev/null +++ b/.changeset/curvy-snakes-heal.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the `menu` ARIA role from the `ActionMenu` component. The `role="menu"` pattern is reserved for complex, desktop-like applications and is not appropriate for the ActionMenu component. diff --git a/.changeset/hip-ducks-mix.md b/.changeset/hip-ducks-mix.md new file mode 100644 index 0000000000..c02f933eff --- /dev/null +++ b/.changeset/hip-ducks-mix.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the default value for the `alt` prop in the `Avatar` component. The `alt` prop is now required. diff --git a/.changeset/slow-bikes-admire.md b/.changeset/slow-bikes-admire.md new file mode 100644 index 0000000000..bf9b32817f --- /dev/null +++ b/.changeset/slow-bikes-admire.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the deprecated `Italic` decoration prop from the `Body` component following the brand refresh. diff --git a/packages/circuit-ui/components/ActionMenu/ActionMenu.spec.tsx b/packages/circuit-ui/components/ActionMenu/ActionMenu.spec.tsx index 8d777eb780..39be8b69f7 100644 --- a/packages/circuit-ui/components/ActionMenu/ActionMenu.spec.tsx +++ b/packages/circuit-ui/components/ActionMenu/ActionMenu.spec.tsx @@ -161,9 +161,7 @@ describe('ActionMenu', () => { it('should close the action menu when clicking a action menu item', async () => { renderActionMenu(baseProps); - const actionMenuItems = screen.getAllByRole('menuitem'); - - await userEvent.click(actionMenuItems[0]); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); expect(baseProps.onToggle).toHaveBeenCalledTimes(1); }); @@ -180,9 +178,7 @@ describe('ActionMenu', () => { rerender(); - const actionMenuItems = screen.getAllByRole('menuitem'); - - expect(actionMenuItems[0]).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Add' })).toHaveFocus(); await flushMicrotasks(); }); @@ -213,28 +209,6 @@ describe('ActionMenu', () => { }); }); - it('should render the action menu with menu semantics by default', async () => { - renderActionMenu(baseProps); - - const menu = screen.getByRole('menu'); - expect(menu).toBeVisible(); - const menuitems = screen.getAllByRole('menuitem'); - expect(menuitems.length).toBe(2); - - await flushMicrotasks(); - }); - - it('should render the action menu without menu semantics', async () => { - renderActionMenu({ ...baseProps, role: null }); - - const menu = screen.queryByRole('menu'); - expect(menu).toBeNull(); - const menuitems = screen.queryAllByRole('menuitem'); - expect(menuitems.length).toBe(0); - - await flushMicrotasks(); - }); - it('should hide dividers from the accessibility tree', async () => { const { baseElement } = renderActionMenu(baseProps); diff --git a/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx b/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx index ccd0850b71..8bd171a4d4 100644 --- a/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx +++ b/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx @@ -15,13 +15,7 @@ 'use client'; -import { - useId, - useRef, - forwardRef, - useCallback, - type KeyboardEvent, -} from 'react'; +import { useRef, forwardRef, useCallback, type KeyboardEvent } from 'react'; import type { ClickEvent } from '../../types/events.js'; import { Hr } from '../Hr/index.js'; @@ -44,39 +38,17 @@ function isDivider(action: Action): action is Divider { return 'type' in action && action.type === 'divider'; } -export interface ActionMenuProps extends Omit { +export interface ActionMenuProps extends PopoverProps { /** * An array of ActionMenuItem or Divider. */ actions: Action[]; - /** - * Remove the [`menu` role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/roles/menu_role) - * when its semantics aren't appropriate for the use case, for example when - * the ActionMenu is used as part of a navigation. - * @default 'menu'. - * - * Learn more: https://inclusive-components.design/menus-menu-buttons/ - */ - role?: 'menu' | null; } type TriggerKey = 'ArrowUp' | 'ArrowDown'; export const ActionMenu = forwardRef( - ( - { - actions, - role = 'menu', - className, - onToggle, - component: Component, - ...props - }, - ref, - ) => { - const menuEl = useRef(null); + ({ actions, className, onToggle, component: Component, ...props }, ref) => { const triggerKey = useRef(null); - const triggerId = useId(); - const menuId = useId(); const isMobile = useMedia('(max-width: 479px)'); const focusProps = useFocusList(); @@ -101,11 +73,6 @@ export const ActionMenu = forwardRef( onToggle(false); }; - const isMenu = role === 'menu'; - const menuProps = isMenu - ? { 'role': 'menu', 'aria-labelledby': triggerId } - : {}; - return ( ( hideCloseButton={!isMobile || props.disableModalOnMobile} onToggle={onToggle} component={(refProps) => ( - + )} {...props} > -
- {actions.map((action, index) => - isDivider(action) ? ( -
- ) : ( - - ), - )} -
+ {actions.map((action, index) => { + const key = isDivider(action) ? `divider-${index}` : action.children; + return isDivider(action) ? ( +
+ ) : ( + + ); + })}
); }, diff --git a/packages/circuit-ui/components/Avatar/Avatar.tsx b/packages/circuit-ui/components/Avatar/Avatar.tsx index 5ee960d2ca..761852cc7e 100644 --- a/packages/circuit-ui/components/Avatar/Avatar.tsx +++ b/packages/circuit-ui/components/Avatar/Avatar.tsx @@ -92,7 +92,7 @@ const legacyVariantMap: Record = { */ export const Avatar = ({ src, - alt = '', // This default should be removed in the next major + alt, variant: legacyVariant = 'object', size: legacySize = 'm', initials, diff --git a/packages/circuit-ui/components/Body/Body.tsx b/packages/circuit-ui/components/Body/Body.tsx index a796067617..cffd585121 100644 --- a/packages/circuit-ui/components/Body/Body.tsx +++ b/packages/circuit-ui/components/Body/Body.tsx @@ -52,13 +52,7 @@ export interface BodyProps extends HTMLAttributes { * Use the `as` prop to render the component as the `em` or `del` HTML * elements if appropriate. */ - decoration?: - | 'strikethrough' - /** - * @deprecated Since the brand refresh, italic text is no longer supported. - * The `italic` decoration value will be removed in the next major version. - */ - | 'italic'; + decoration?: 'strikethrough'; /** * Choose a foreground color token name. Default: `normal`. */ diff --git a/packages/circuit-ui/components/Numeral/Numeral.tsx b/packages/circuit-ui/components/Numeral/Numeral.tsx index 2f92d693a2..270c12b0c4 100644 --- a/packages/circuit-ui/components/Numeral/Numeral.tsx +++ b/packages/circuit-ui/components/Numeral/Numeral.tsx @@ -48,13 +48,7 @@ export interface NumeralProps extends HTMLAttributes { * Use the `as` prop to render the component as the `em` or `del` HTML * elements if appropriate. */ - decoration?: - | 'strikethrough' - /** - * @deprecated Since the brand refresh, italic text is no longer supported. - * The `italic` decoration value will be removed in the next major version. - */ - | 'italic'; + decoration?: 'strikethrough'; } /** From ac299a16fc343ad1efc696a35d5cd0ac4ec7fbe4 Mon Sep 17 00:00:00 2001 From: sirineJ <112706079+sirineJ@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:16:16 +0200 Subject: [PATCH 08/17] remove BodyLarge component (#3684) --- .changeset/salty-banks-serve.md | 5 ++ .../components/BodyLarge/BodyLarge.spec.tsx | 66 ------------------- .../components/BodyLarge/BodyLarge.tsx | 38 ----------- .../circuit-ui/components/BodyLarge/index.ts | 18 ----- packages/circuit-ui/index.ts | 2 - skills/circuit-ui/references/components.md | 1 - 6 files changed, 5 insertions(+), 125 deletions(-) create mode 100644 .changeset/salty-banks-serve.md delete mode 100644 packages/circuit-ui/components/BodyLarge/BodyLarge.spec.tsx delete mode 100644 packages/circuit-ui/components/BodyLarge/BodyLarge.tsx delete mode 100644 packages/circuit-ui/components/BodyLarge/index.ts diff --git a/.changeset/salty-banks-serve.md b/.changeset/salty-banks-serve.md new file mode 100644 index 0000000000..3eb51eca8c --- /dev/null +++ b/.changeset/salty-banks-serve.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": minor +--- + +Removed the deprecated BodyLarge component. Use Body with `size="l"` instead. diff --git a/packages/circuit-ui/components/BodyLarge/BodyLarge.spec.tsx b/packages/circuit-ui/components/BodyLarge/BodyLarge.spec.tsx deleted file mode 100644 index fb7e572cfe..0000000000 --- a/packages/circuit-ui/components/BodyLarge/BodyLarge.spec.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright 2021, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { describe, expect, it } from 'vitest'; -import { createRef } from 'react'; - -import { axe, render } from '../../util/test-utils.js'; - -import { BodyLarge } from './BodyLarge.js'; - -describe('BodyLarge', () => { - it('should merge a custom class name with the default ones', () => { - const className = 'foo'; - const { container } = render( - BodyLarge, - ); - const paragraph = container.querySelector('p'); - expect(paragraph?.className).toContain(className); - }); - - it('should forward a ref', () => { - const ref = createRef(); - const { container } = render(BodyLarge); - const paragraph = container.querySelector('p'); - expect(ref.current).toBe(paragraph); - }); - - const elements = ['p', 'article', 'div'] as const; - it.each(elements)('should render as a "%s" element', (as) => { - const { container } = render({as} BodyLarge); - const actual = container.querySelector(as); - expect(actual).toBeVisible(); - }); - - it('should render the "highlight" variant as a "strong" element', () => { - const { container } = render( - Highlight, - ); - const actual = container.querySelector('strong'); - expect(actual).toBeVisible(); - }); - - it('should render the "quote" variant as a "blockquote" element', () => { - const { container } = render(Quote); - const actual = container.querySelector('blockquote'); - expect(actual).toBeVisible(); - }); - - it('should meet accessibility guidelines', async () => { - const { container } = render(BodyLarge); - const actual = await axe(container); - expect(actual).toHaveNoViolations(); - }); -}); diff --git a/packages/circuit-ui/components/BodyLarge/BodyLarge.tsx b/packages/circuit-ui/components/BodyLarge/BodyLarge.tsx deleted file mode 100644 index 2b5b32342d..0000000000 --- a/packages/circuit-ui/components/BodyLarge/BodyLarge.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright 2021, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { forwardRef } from 'react'; - -import { deprecate } from '../../util/logger.js'; -import { Body, type BodyProps } from '../Body/Body.js'; - -export type BodyLargeProps = Omit; -/** - * @deprecated Use the Body component with `size="l"` instead. - */ -export const BodyLarge = forwardRef( - (props, ref) => { - if (process.env.NODE_ENV !== 'production') { - deprecate( - 'BodyLarge', - 'The BodyLarge component has been deprecated. Use the Body component in size `l` instead.', - ); - } - - return ; - }, -); - -BodyLarge.displayName = 'BodyLarge'; diff --git a/packages/circuit-ui/components/BodyLarge/index.ts b/packages/circuit-ui/components/BodyLarge/index.ts deleted file mode 100644 index b0d83fc817..0000000000 --- a/packages/circuit-ui/components/BodyLarge/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright 2021, SumUp Ltd. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { BodyLarge } from './BodyLarge.js'; - -export type { BodyLargeProps } from './BodyLarge.js'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index e989748ec9..41577e9d1b 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -26,8 +26,6 @@ export { Display } from './components/Display/index.js'; export type { DisplayProps } from './components/Display/index.js'; export { Body } from './components/Body/index.js'; export type { BodyProps } from './components/Body/index.js'; -export { BodyLarge } from './components/BodyLarge/index.js'; -export type { BodyLargeProps } from './components/BodyLarge/index.js'; export { Compact } from './components/Compact/index.js'; export type { CompactProps } from './components/Compact/index.js'; export { Numeral } from './components/Numeral/index.js'; diff --git a/skills/circuit-ui/references/components.md b/skills/circuit-ui/references/components.md index 8773e01db1..930c3c9dab 100644 --- a/skills/circuit-ui/references/components.md +++ b/skills/circuit-ui/references/components.md @@ -102,7 +102,6 @@ | --- | --- | --- | --- | --- | | `Anchor` | `stable` | `@sumup-oss/circuit-ui` | `./components/Anchor/index.js` | [Read MDX reference](components/Anchor.mdx) | | `Body` | `stable` | `@sumup-oss/circuit-ui` | `./components/Body/index.js` | [Read MDX reference](components/Body.mdx) | -| `BodyLarge` | Unknown | `@sumup-oss/circuit-ui` | `./components/BodyLarge/index.js` | Not available | | `Compact` | `stable` | `@sumup-oss/circuit-ui` | `./components/Compact/index.js` | [Read MDX reference](components/Compact.mdx) | | `Display` | `stable` | `@sumup-oss/circuit-ui` | `./components/Display/index.js` | [Read MDX reference](components/Display.mdx) | | `Headline` | `stable` | `@sumup-oss/circuit-ui` | `./components/Headline/index.js` | [Read MDX reference](components/Headline.mdx) | From bdfccf123ba6942c4dcb67f4d26517401c614d83 Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Tue, 9 Jun 2026 18:06:03 +0200 Subject: [PATCH 09/17] remove h4 from listitem label (#3686) * remove h4 from listitem label * add changeset --- .changeset/smart-rings-talk.md | 5 +++++ .../circuit-ui/components/ListItemGroup/ListItemGroup.tsx | 8 +------- 2 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 .changeset/smart-rings-talk.md diff --git a/.changeset/smart-rings-talk.md b/.changeset/smart-rings-talk.md new file mode 100644 index 0000000000..19ca989bc3 --- /dev/null +++ b/.changeset/smart-rings-talk.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": patch +--- + +Updated ListItemGroup to render the `label` prop as a plain Body component instead of an `h4` heading. diff --git a/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx b/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx index 646125b44d..cb831c7a1f 100644 --- a/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx +++ b/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx @@ -115,13 +115,7 @@ export const ListItemGroup = forwardRef( hideLabel && utilClasses.hideVisually, )} > - {isString(label) ? ( - - {label} - - ) : ( - label - )} + {isString(label) ? {label} : label}
)} {details && ( From f929e3f642bf9fb7c0d3279ef08639037ca11ee5 Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Tue, 9 Jun 2026 18:07:01 +0200 Subject: [PATCH 10/17] Mark AutocompleteInput component as stable (#3681) * makr autocomplete input as stable * add changeset * Update .changeset/young-otters-walk.md Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> * update changeset * Update .changeset/young-otters-walk.md Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> * Update .changeset/bumpy-tools-fall.md Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> * clean up changeset --------- Co-authored-by: sirineJ <112706079+sirineJ@users.noreply.github.com> --- .changeset/bumpy-tools-fall.md | 5 + .changeset/young-otters-walk.md | 5 + .../AutocompleteInput/AutocompleteInput.mdx | 2 +- .../AutocompleteInput.stories.tsx | 2 +- .../components/AutocompleteInput/index.ts | 20 +++ packages/circuit-ui/experimental.ts | 7 - packages/circuit-ui/index.ts | 9 ++ .../component-lifecycle-imports/index.spec.ts | 26 ++++ .../component-lifecycle-imports/index.ts | 10 +- skills/circuit-ui/references/components.md | 1 + .../components/AutocompleteInput.mdx | 131 ++++++++++++++++++ 11 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 .changeset/bumpy-tools-fall.md create mode 100644 .changeset/young-otters-walk.md create mode 100644 packages/circuit-ui/components/AutocompleteInput/index.ts create mode 100644 skills/circuit-ui/references/components/AutocompleteInput.mdx diff --git a/.changeset/bumpy-tools-fall.md b/.changeset/bumpy-tools-fall.md new file mode 100644 index 0000000000..36f399f63a --- /dev/null +++ b/.changeset/bumpy-tools-fall.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/eslint-plugin-circuit-ui": minor +--- + +Updated the `component-lifecycle-imports` rule to flag and fix imports of the now stable AutocompleteInput component and its related imports. diff --git a/.changeset/young-otters-walk.md b/.changeset/young-otters-walk.md new file mode 100644 index 0000000000..469cfeb530 --- /dev/null +++ b/.changeset/young-otters-walk.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Marked the AutocompleteInput component as stable. Import it from `@sumup-oss/circuit-ui` instead of `@sumup-oss/circuit-ui/experimental`. diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx index 7dc3e685e7..cb01aa00af 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx @@ -5,7 +5,7 @@ import * as Stories from "./AutocompleteInput.stories"; # AutocompleteInput - + An input field that allows users to filter and select one or many options from a provided set of options. diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx index 57da66f217..9721b7a776 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx @@ -43,7 +43,7 @@ import type { AutocompleteInputOption } from './components/Option/Option.js'; export default { title: 'Forms/AutocompleteInput', component: AutocompleteInput, - tags: ['status:experimental'], + tags: ['status:stable'], argTypes: { // Value & change handling value: { diff --git a/packages/circuit-ui/components/AutocompleteInput/index.ts b/packages/circuit-ui/components/AutocompleteInput/index.ts new file mode 100644 index 0000000000..94196b31f2 --- /dev/null +++ b/packages/circuit-ui/components/AutocompleteInput/index.ts @@ -0,0 +1,20 @@ +/** + * Copyright 2026, SumUp Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AutocompleteInput } from './AutocompleteInput.js'; +export type { AutocompleteInputProps } from './AutocompleteInput.js'; +export type { AutocompleteInputOption } from './components/Option/Option.js'; +export type { AutocompleteInputOptionGroup } from './components/Options/Options.js'; +export { updateMultipleSelectionValue } from './AutocompleteInputService.js'; diff --git a/packages/circuit-ui/experimental.ts b/packages/circuit-ui/experimental.ts index a21d7e0e62..b7cce6a91c 100644 --- a/packages/circuit-ui/experimental.ts +++ b/packages/circuit-ui/experimental.ts @@ -13,12 +13,5 @@ * limitations under the License. */ -/* AutocompleteInput */ -export { AutocompleteInput } from './components/AutocompleteInput/AutocompleteInput.js'; -export type { AutocompleteInputProps } from './components/AutocompleteInput/AutocompleteInput.js'; -export type { AutocompleteInputOption } from './components/AutocompleteInput/components/Option/Option.js'; -export type { AutocompleteInputOptionGroup } from './components/AutocompleteInput/components/Options/Options.js'; -export { updateMultipleSelectionValue } from './components/AutocompleteInput/AutocompleteInputService.js'; - export { TimeInput } from './components/TimeInput/TimeInput.js'; export type { TimeInputProps } from './components/TimeInput/TimeInput.js'; diff --git a/packages/circuit-ui/index.ts b/packages/circuit-ui/index.ts index 41577e9d1b..5438bea233 100644 --- a/packages/circuit-ui/index.ts +++ b/packages/circuit-ui/index.ts @@ -66,6 +66,15 @@ export { PhoneNumberInput } from './components/PhoneNumberInput/index.js'; export type { PhoneNumberInputProps } from './components/PhoneNumberInput/index.js'; export { ColorInput } from './components/ColorInput/index.js'; export type { ColorInputProps } from './components/ColorInput/index.js'; +export { + AutocompleteInput, + updateMultipleSelectionValue, +} from './components/AutocompleteInput/index.js'; +export type { + AutocompleteInputProps, + AutocompleteInputOption, + AutocompleteInputOptionGroup, +} from './components/AutocompleteInput/index.js'; // Actions export { Button } from './components/Button/index.js'; diff --git a/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.spec.ts b/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.spec.ts index b46f7f7f79..5b1938ddbf 100644 --- a/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.spec.ts +++ b/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.spec.ts @@ -43,6 +43,12 @@ ruleTester.run('component-lifecycle-imports', componentLifecycleImports, { import { Timestamp } from '@sumup-oss/circuit-ui'; `, }, + { + name: 'AutocompleteInput import from correct package', + code: ` + import { AutocompleteInput } from '@sumup-oss/circuit-ui'; + `, + }, { name: 'unrelated import from matching package', code: ` @@ -112,5 +118,25 @@ ruleTester.run('component-lifecycle-imports', componentLifecycleImports, { `, errors: [{ messageId: 'refactor' }], }, + { + name: 'AutocompleteInput import from experimental', + code: ` + import { AutocompleteInput } from '@sumup-oss/circuit-ui/experimental'; + `, + output: ` + import { AutocompleteInput } from '@sumup-oss/circuit-ui'; + `, + errors: [{ messageId: 'refactor' }], + }, + { + name: 'AutocompleteInput type import from experimental', + code: ` + import type { AutocompleteInputProps } from '@sumup-oss/circuit-ui/experimental'; + `, + output: ` + import type { AutocompleteInputProps } from '@sumup-oss/circuit-ui'; + `, + errors: [{ messageId: 'refactor' }], + }, ], }); diff --git a/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.ts b/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.ts index 13af8ab816..bfae84a8b7 100644 --- a/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.ts +++ b/packages/eslint-plugin-circuit-ui/component-lifecycle-imports/index.ts @@ -25,7 +25,15 @@ const mappings = [ { from: '@sumup-oss/circuit-ui/experimental', to: '@sumup-oss/circuit-ui', - specifiers: ['Timestamp', 'TimestampProps'], + specifiers: [ + 'Timestamp', + 'TimestampProps', + 'AutocompleteInput', + 'AutocompleteInputProps', + 'AutocompleteInputOption', + 'AutocompleteInputOptionGroup', + 'updateMultipleSelectionValue', + ], }, ]; diff --git a/skills/circuit-ui/references/components.md b/skills/circuit-ui/references/components.md index 930c3c9dab..611259a992 100644 --- a/skills/circuit-ui/references/components.md +++ b/skills/circuit-ui/references/components.md @@ -22,6 +22,7 @@ | Component | Status | Package | Source export | Usage reference | | --- | --- | --- | --- | --- | +| `AutocompleteInput` | `stable` | `@sumup-oss/circuit-ui` | `./components/AutocompleteInput/index.js` | [Read MDX reference](components/AutocompleteInput.mdx) | | `Calendar` | `stable` | `@sumup-oss/circuit-ui` | `./components/Calendar/index.js` | [Read MDX reference](components/Calendar.mdx) | | `Checkbox` | `stable` | `@sumup-oss/circuit-ui` | `./components/Checkbox/index.js` | [Read MDX reference](components/Checkbox.mdx) | | `CheckboxGroup` | `stable` | `@sumup-oss/circuit-ui` | `./components/CheckboxGroup/index.js` | [Read MDX reference](components/CheckboxGroup.mdx) | diff --git a/skills/circuit-ui/references/components/AutocompleteInput.mdx b/skills/circuit-ui/references/components/AutocompleteInput.mdx new file mode 100644 index 0000000000..cb01aa00af --- /dev/null +++ b/skills/circuit-ui/references/components/AutocompleteInput.mdx @@ -0,0 +1,131 @@ +import { Meta, Status, Props, Story } from "../../../../.storybook/components"; +import * as Stories from "./AutocompleteInput.stories"; + + + +# AutocompleteInput + + + +An input field that allows users to filter and select one or many options from a provided set of options. + + + + +## When to use + +Use the AutocompleteInput component when you want to help users find and select from a large set of options. Common use cases: + +- Location picker: Address or city field +- Product catalog: choosing from a long list of products + +For short lists, consider using the [Select](Forms/Select/Docs) component instead. + +While the AutocompleteInput component is accessible, it can be complex to interact with, especially for keyboard and screen reader users. Use it only when necessary and aim to keep the interaction simple and to respect the component usage recommendations as much as possible. + +## How to use + +- Use the `onSearch` prop to update the list of options as the user types. You can optionally set the `minQueryLength` prop to delay this behavior until a minimum number of characters has been typed. +- Use the `onChange` prop to handle the selection of an option and set the input's `value` prop to the selected option. After the selection, make sure to reset the value of the `options` prop. +- Use the `onClear` prop to handle the clearing of the input field, and reset the input's `value` prop to reflect this change. +- The component can receive a flat list or a list of grouped options via the `options` prop. To ensure a consistent and user-friendly experience, keep the visual format uniform. For example, make sure all options include (or not) an image or a description. Keep labels and descriptions short and easy to scan. +- To enable multiple selections, set the `multiple` prop to "true". In this mode, the `value` prop must be an array of `AutocompleteInputOption` objects. The `onChange` callback will still receive a single `AutocompleteInputOption`—the option that was just selected or removed. The `updateMultipleSelectionValue` utility function can be used to compute the new value of the field. +- To support the selection of the user's input as the field's value, enable the `allowNewItems` prop. This will display the user's input as an available option in the list box. + + + +- For a mobile-optimized experience, use the "immersive" `variant` to open the AutocompleteInput in a modal dialog on narrow viewports. + +## Customisation + +- The "no results" screen can be customised using the `noResultsMessage` prop. +- The "loading" message can be customised using the `loadingLabel` prop. +- The "load more" button label can be customised using the `loadMoreLabel` prop. +- The options can be customised by adding avatar images or icons and a description. +- Use the `action` prop to add a contextual action at the bottom of the list box, such as a button to create a new item. + + + +## Performance + +### Debouncing + +The component's `onSearch` callback is debounced by default to avoid excessive calls while the user types. The debounce delay is set to 300 ms. + +### Paginated results + +For better performance and scalability when loading dynamic, paginated results, use the `loadMore` prop to fetch additional options. When this prop is provided, a "Load more" button appears at the bottom of the list box. You can customize the button label with `loadMoreLabel`. Use the `isLoadingMore` prop to indicate that more options are being fetched, and `aria-setsize` to convey the total number of options available to assistive technologies. Learn more about `aria-setsize` in the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-setsize). + + + +### Fetching options + +To ensure you always show the most relevant options, consider using an [Abort Controller](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) to cancel previous fetch requests. This prevents showing outdated options and improves the user experience. + +```tsx +import { useState, useCallback, useRef } from "react"; +import { AutocompleteInput } from "@sumup-oss/circuit-ui"; + +const MyComponent = () => { + const [options, setOptions] = useState([]); + const abortControllerRef = useRef(null); + + const fetchOptions = useCallback(async (query: string) => { + // Abort previous request if it exists + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + // Create a new AbortController for the new request + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + const results = await getOptions(query, controller.signal); + setOptions(results); + } catch (error) { + if (error.name !== "AbortError") { + // Handle non-abort errors (e.g., server issues) + } + } + }, []); + + return ( + + ); +}; + +async function getOptions(query: string, signal: AbortSignal) { + const response = await fetch(`https://my-search-api.com?query=${query}`, { + signal, + }); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +} +``` + +## Accessibility + +The AutocompleteInput component is designed to be accessible, adopting ARIA's [Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). It includes: + +### Keyboard navigation + +- Arrow down key: Opens the list box +- Escape key: Closes the list box +- Arrow Up/Down keys: navigate through options +- Enter key: selects an option +- Backspace key: deletes a selected option in multi-selection mode. + +### Screen reader support + +The component announces the number of options available and the selected option, if any. + +### Focus management + +The component manages focus appropriately. The focus remains on the input field when the list box is open, and navigated options are announced properly as the user navigates through them. From 27c0eca4e7c5a3ae52bb91561db6714ac39730e2 Mon Sep 17 00:00:00 2001 From: Nkoyo Ating Date: Thu, 11 Jun 2026 10:25:18 +0200 Subject: [PATCH 11/17] Drop support for React 18 (#3674) * drop support for react 18 * remove forward ref from form input components * remove forwardref from layout components * remove forwardref from navigation component * remove forwardref from state component * remove wrapper element from popover and actionmenu * fix lint errors * fix failing test * update popover ref logic * add changeset * update changeset * update button ref to any * update react types overrides * remove ref from component --- .changeset/ten-tires-shake.md | 5 + .storybook/preview.tsx | 5 + package-lock.json | 36 +- package.json | 4 +- .../components/ActionMenu/ActionMenu.tsx | 109 +-- .../circuit-ui/components/Anchor/Anchor.tsx | 122 ++- .../components/AspectRatio/AspectRatio.tsx | 64 +- .../AutocompleteInput/AutocompleteInput.tsx | 884 +++++++++--------- .../ComboboxInput/ComboboxInput.tsx | 293 +++--- .../components/Results/Results.tsx | 55 +- .../circuit-ui/components/Badge/Badge.tsx | 46 +- packages/circuit-ui/components/Body/Body.tsx | 110 +-- .../circuit-ui/components/Button/Button.tsx | 11 +- .../components/Button/IconButton.tsx | 17 +- .../circuit-ui/components/Button/base.tsx | 17 +- .../components/ButtonGroup/ButtonGroup.tsx | 84 +- .../components/Calendar/Calendar.tsx | 347 ++++--- packages/circuit-ui/components/Card/Card.tsx | 19 +- .../Card/components/Footer/Footer.tsx | 23 +- .../Card/components/Header/Header.tsx | 52 +- .../Carousel/components/Slides/Slides.tsx | 14 +- .../CarouselPagination/CarouselPagination.tsx | 26 +- .../components/Checkbox/Checkbox.tsx | 115 ++- .../components/Checkbox/CheckboxInput.tsx | 119 ++- .../CheckboxGroup/CheckboxGroup.tsx | 166 ++-- .../components/CloseButton/CloseButton.tsx | 22 +- .../components/ColorInput/ColorInput.tsx | 304 +++--- .../circuit-ui/components/Compact/Compact.tsx | 31 +- .../ComparisonTable/ComparisonTable.tsx | 163 ++-- .../components/PlanTable/PlanTable.tsx | 339 ++++--- .../components/Link/Link.tsx | 16 +- .../CurrencyInput/CurrencyInput.tsx | 167 ++-- .../components/DateInput/DateInput.tsx | 591 ++++++------ .../circuit-ui/components/Dialog/Dialog.tsx | 639 +++++++------ .../circuit-ui/components/Display/Display.tsx | 104 ++- .../circuit-ui/components/Field/Field.tsx | 36 +- packages/circuit-ui/components/Flag/Flag.tsx | 87 +- .../components/Hamburger/Hamburger.tsx | 120 ++- .../components/Headline/Headline.tsx | 70 +- packages/circuit-ui/components/Hr/Hr.tsx | 27 +- .../circuit-ui/components/Image/Image.tsx | 19 +- .../circuit-ui/components/Input/Input.tsx | 216 +++-- packages/circuit-ui/components/List/List.tsx | 55 +- .../components/ListItem/ListItem.tsx | 237 +++-- .../ListItemGroup/ListItemGroup.tsx | 170 ++-- .../circuit-ui/components/Modal/Modal.tsx | 27 +- .../NotificationBanner/NotificationBanner.tsx | 155 ++- .../NotificationFullscreen.tsx | 25 +- .../NotificationInline/NotificationInline.tsx | 170 ++-- .../circuit-ui/components/Numeral/Numeral.tsx | 31 +- .../PercentageInput/PercentageInput.tsx | 124 ++- .../PhoneNumberInput/PhoneNumberInput.tsx | 484 +++++----- .../components/Popover/Popover.module.css | 4 - .../circuit-ui/components/Popover/Popover.tsx | 269 +++--- .../components/RadioButton/RadioButton.tsx | 111 ++- .../RadioButtonGroup/RadioButtonGroup.tsx | 176 ++-- .../RadioButtonGroup/RadioButtonInput.tsx | 83 +- .../components/SearchInput/SearchInput.tsx | 104 +-- .../circuit-ui/components/Select/Select.tsx | 210 ++--- .../components/Selector/Selector.tsx | 197 ++-- .../SelectorGroup/SelectorGroup.tsx | 187 ++-- .../SecondaryLinks/SecondaryLinks.tsx | 36 +- .../components/SidePanel/SidePanel.tsx | 152 +-- .../components/Skeleton/Skeleton.tsx | 100 +- .../circuit-ui/components/Spinner/Spinner.tsx | 39 +- .../circuit-ui/components/Status/Status.tsx | 77 +- .../components/SumUpLogo/SumUpLogo.tsx | 63 +- .../Table/components/TableCell/TableCell.tsx | 35 +- .../components/Tabs/components/Tab/Tab.tsx | 55 +- .../Tabs/components/TabList/TabList.tsx | 273 +++--- .../Tabs/components/TabPanel/TabPanel.tsx | 14 +- packages/circuit-ui/components/Tag/Tag.tsx | 166 ++-- .../components/TextArea/TextArea.tsx | 35 +- .../components/TextArea/useAutoExpand.ts | 4 +- .../components/TimeInput/TimeInput.tsx | 57 +- .../components/Timestamp/Timestamp.tsx | 119 ++- .../circuit-ui/components/Toggle/Toggle.tsx | 136 +-- .../components/Toggletip/Toggletip.tsx | 278 +++--- .../circuit-ui/components/Tooltip/Tooltip.tsx | 279 +++--- packages/circuit-ui/package.json | 4 +- packages/circuit-ui/util/type-check.ts | 7 +- .../react-number-format/numeric_format.d.ts | 1 + .../react-number-format/pattern_format.d.ts | 1 + 83 files changed, 5067 insertions(+), 5377 deletions(-) create mode 100644 .changeset/ten-tires-shake.md diff --git a/.changeset/ten-tires-shake.md b/.changeset/ten-tires-shake.md new file mode 100644 index 0000000000..96862b7dda --- /dev/null +++ b/.changeset/ten-tires-shake.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Dropped support for React 18. React 19 is now the minimum required version. `react` and `react-dom` peer dependencies now require `>=19.0.0` diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 888203db55..6bf8a7a0c7 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -100,3 +100,8 @@ export const globalTypes = { }; export const decorators = [withThemeProvider]; + +// hide React 19 ref prop on all function components +export const argTypes = { + ref: { table: { disable: true } }, +}; diff --git a/package-lock.json b/package-lock.json index faea122ffc..71cf5ffbd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12007,13 +12007,13 @@ } }, "node_modules/@types/react": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", - "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "peer": true, "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -34715,7 +34715,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22", + "node": ">=24", "typescript": ">=4.1" }, "peerDependencies": { @@ -34725,8 +34725,8 @@ "@sumup-oss/design-tokens": ">=10.0.0", "@sumup-oss/icons": ">=6.0.0", "@sumup-oss/intl": "^3.1.1", - "react": ">=18.0.0 <20.0.0", - "react-dom": ">=18.0.0 <20.0.0", + "react": ">=19.0.0 <20.0.0", + "react-dom": ">=19.0.0 <20.0.0", "temporal-polyfill": ">= 0.2.0 < 0.4.0" } }, @@ -34743,7 +34743,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22" + "node": ">=24" } }, "packages/design-tokens/node_modules/@esbuild/aix-ppc64": { @@ -35751,7 +35751,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22" + "node": ">=24" }, "peerDependencies": { "react": ">=16.0.0 <20.0.0" @@ -36519,24 +36519,6 @@ "undici-types": "~7.16.0" } }, - "templates/astro/node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "templates/astro/node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "templates/astro/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", diff --git a/package.json b/package.json index bbcaba7a52..6b087bb794 100644 --- a/package.json +++ b/package.json @@ -87,8 +87,8 @@ "vitest-github-actions-reporter": "^0.11.1" }, "overrides": { - "@types/react-dom": "^19.0.0", - "@types/react": "^19.0.0", + "@types/react-dom": "^19.2.0", + "@types/react": "^19.2.0", "nx": "22.6.3", "eslint": "^9.0.0", "storybook": "$storybook", diff --git a/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx b/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx index 8bd171a4d4..791d23bcab 100644 --- a/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx +++ b/packages/circuit-ui/components/ActionMenu/ActionMenu.tsx @@ -15,7 +15,7 @@ 'use client'; -import { useRef, forwardRef, useCallback, type KeyboardEvent } from 'react'; +import { useRef, useCallback, type KeyboardEvent } from 'react'; import type { ClickEvent } from '../../types/events.js'; import { Hr } from '../Hr/index.js'; @@ -46,59 +46,62 @@ export interface ActionMenuProps extends PopoverProps { } type TriggerKey = 'ArrowUp' | 'ArrowDown'; -export const ActionMenu = forwardRef( - ({ actions, className, onToggle, component: Component, ...props }, ref) => { - const triggerKey = useRef(null); - const isMobile = useMedia('(max-width: 479px)'); +export function ActionMenu({ + actions, + className, + onToggle, + component: Component, + ...props +}: ActionMenuProps) { + const triggerKey = useRef(null); + const isMobile = useMedia('(max-width: 479px)'); - const focusProps = useFocusList(); + const focusProps = useFocusList(); - const handleTriggerKeyDown = useCallback( - (event: KeyboardEvent) => { - if (isArrowDown(event)) { - triggerKey.current = 'ArrowDown'; - onToggle(true); - } - if (isArrowUp(event)) { - triggerKey.current = 'ArrowUp'; - onToggle((prev) => !prev); - } - }, - [onToggle], - ); + const handleTriggerKeyDown = useCallback( + (event: KeyboardEvent) => { + if (isArrowDown(event)) { + triggerKey.current = 'ArrowDown'; + onToggle(true); + } + if (isArrowUp(event)) { + triggerKey.current = 'ArrowUp'; + onToggle((prev) => !prev); + } + }, + [onToggle], + ); - const handleActionMenuItemClick = - (onClick: ActionMenuItemProps['onClick']) => (event: ClickEvent) => { - onClick?.(event); - onToggle(false); - }; + const handleActionMenuItemClick = + (onClick: ActionMenuItemProps['onClick']) => (event: ClickEvent) => { + onClick?.(event); + onToggle(false); + }; - return ( - ( - - )} - {...props} - > - {actions.map((action, index) => { - const key = isDivider(action) ? `divider-${index}` : action.children; - return isDivider(action) ? ( -
- ) : ( - - ); - })} -
- ); - }, -); + return ( + ( + + )} + {...props} + > + {actions.map((action, index) => { + const key = isDivider(action) ? `divider-${index}` : action.children; + return isDivider(action) ? ( +
+ ) : ( + + ); + })} +
+ ); +} diff --git a/packages/circuit-ui/components/Anchor/Anchor.tsx b/packages/circuit-ui/components/Anchor/Anchor.tsx index 94589948ea..1aa4a8a979 100644 --- a/packages/circuit-ui/components/Anchor/Anchor.tsx +++ b/packages/circuit-ui/components/Anchor/Anchor.tsx @@ -16,7 +16,6 @@ 'use client'; import { - forwardRef, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ReactNode, @@ -66,74 +65,48 @@ export type AnchorProps = BaseProps & LinkElProps & ButtonElProps; * The Anchor is used to display a link or button that visually looks like * a hyperlink. Based on the Body component, so it also supports its props. */ -export const Anchor = forwardRef( - ( - { - className, - externalLabel, - 'aria-describedby': descriptionId, - children, - ...props - }: AnchorProps, - ref?: BaseProps['ref'], - ): ReturnType => { - const components = useComponents(); - const Link = components.Link as AsPropType; - const isExternalLink = - props.rel === 'external' || props.target === '_blank'; +export function Anchor({ + className, + externalLabel, + 'aria-describedby': descriptionId, + children, + ref, + ...props +}: AnchorProps): ReturnType { + const components = useComponents(); + const Link = components.Link as AsPropType; + const isExternalLink = props.rel === 'external' || props.target === '_blank'; - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - isExternalLink && - !externalLabel - ) { - throw new AccessibilityError( - 'Anchor', - 'An external link is missing an alternative text. Provide an `externalLabel` prop to communicate that the link leads to an external page or opens in a new tab.', - ); - } - - const externalLabelId = useId(); - const descriptionIds = idx( - externalLabel && isExternalLink && externalLabelId, - descriptionId, + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + isExternalLink && + !externalLabel + ) { + throw new AccessibilityError( + 'Anchor', + 'An external link is missing an alternative text. Provide an `externalLabel` prop to communicate that the link leads to an external page or opens in a new tab.', ); + } - if (!props.href && !props.onClick) { - return ( - - {children} - - ); - } + const externalLabelId = useId(); + const descriptionIds = idx( + externalLabel && isExternalLink && externalLabelId, + descriptionId, + ); - if (props.href) { - return ( - - {children} - {isExternalLink && externalLabel && ( - - {externalLabel} - - )} - - ); - } + if (!props.href && !props.onClick) { + return ( + + {children} + + ); + } + if (props.href) { return ( ); - }, -); + } -Anchor.displayName = 'Anchor'; + return ( + + {children} + {isExternalLink && externalLabel && ( + + {externalLabel} + + )} + + ); +} diff --git a/packages/circuit-ui/components/AspectRatio/AspectRatio.tsx b/packages/circuit-ui/components/AspectRatio/AspectRatio.tsx index 07a208a8d2..5c8002f5ad 100644 --- a/packages/circuit-ui/components/AspectRatio/AspectRatio.tsx +++ b/packages/circuit-ui/components/AspectRatio/AspectRatio.tsx @@ -15,10 +15,10 @@ import { Children, - forwardRef, cloneElement, type ReactElement, type HTMLAttributes, + type Ref, } from 'react'; import { clsx } from '../../styles/clsx.js'; @@ -26,42 +26,46 @@ import { clsx } from '../../styles/clsx.js'; import classes from './AspectRatio.module.css'; export interface AspectRatioProps extends HTMLAttributes { + ref?: Ref; children?: ReactElement; aspectRatio?: number; } -export const AspectRatio = forwardRef( - ({ aspectRatio, className, style = {}, children, ...props }, ref) => { - if (!children) { - return null; - } +export function AspectRatio({ + aspectRatio, + className, + style = {}, + children, + ref, + ...props +}: AspectRatioProps) { + if (!children) { + return null; + } - const child = Children.only(children) as ReactElement< - HTMLAttributes - >; - - if (!aspectRatio) { - return ( -
- {child} -
- ); - } + const child = Children.only(children) as ReactElement< + HTMLAttributes + >; + if (!aspectRatio) { return ( -
- {cloneElement(child, { className: classes.child })} +
+ {child}
); - }, -); + } -AspectRatio.displayName = 'AspectRatio'; + return ( +
+ {cloneElement(child, { className: classes.child })} +
+ ); +} diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx index 42a0f254bf..d55db0fcea 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx @@ -18,7 +18,6 @@ import { type ChangeEvent, type FocusEventHandler, - forwardRef, type KeyboardEventHandler, useCallback, useEffect, @@ -137,483 +136,468 @@ const sizeOptions: SizeOptions = { }, }; -export const AutocompleteInput = forwardRef< - HTMLInputElement, - AutocompleteInputProps ->( - ( +export function AutocompleteInput({ + label, + value, + options, + onClear, + onSearch, + onChange, + isLoading, + isLoadingMore, + loadingLabel, + noResultsMessage, + locale, + readOnly, + disabled, + minQueryLength = 0, + action, + loadMore, + allowNewItems, + variant = 'contextual', + size = 'm', + 'aria-setsize': ariaSetSize, + multiple = false, + ref, + ...props +}: AutocompleteInputProps) { + const { + noResultsMessage: defaultNoResultsMessage, + loadMoreLabel, + resultsFound, + clearLabel, + moreResults, + removeTagButtonLabel, + } = useI18n( { - label, - value, - options, - onClear, - onSearch, - onChange, - isLoading, - isLoadingMore, - loadingLabel, - noResultsMessage, locale, - readOnly, - disabled, - minQueryLength = 0, - action, - loadMore, - allowNewItems, - variant = 'contextual', - size = 'm', - 'aria-setsize': ariaSetSize, - multiple = false, - ...props + loadMoreLabel: props.loadMoreLabel, + clearLabel: props.clearLabel, }, - ref, - ) => { - const { - noResultsMessage: defaultNoResultsMessage, - loadMoreLabel, - resultsFound, - clearLabel, - moreResults, - removeTagButtonLabel, - } = useI18n( - { - locale, - loadMoreLabel: props.loadMoreLabel, - clearLabel: props.clearLabel, - }, - translations, + translations, + ); + + const [searchText, setSearchText] = useState( + Array.isArray(value) ? '' : (value?.label ?? ''), + ); + + // used only for immmersive variant + const [presentationFieldValue, setPresentationFieldValue] = useState( + Array.isArray(value) ? '' : (value?.label ?? ''), + ); + const isMobile = useMedia('(max-width: 479px)'); + const hasTouch = !useMedia('(hover: hover) and (pointer: fine)'); + const isImmersive = isMobile && variant === 'immersive'; + const [isOpen, setIsOpen] = useState(false); + const [activeOption, setActiveOption] = useState(); + const comboboxRef = useRef(null); + const inputWrapperRef = useRef(null); + const presentationFieldRef = useRef(null); + const resultsRef = useRef(null); + const listboxId = useId(); + const autocompleteId = useId(); + + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + multiple && + value !== undefined && + !Array.isArray(value) + ) { + throw new CircuitError( + 'AutocompleteInput', + 'You have passed a non array value to a multiple selection AutocompleteInput. Please pass an array of values instead.', ); - - const [searchText, setSearchText] = useState( - Array.isArray(value) ? '' : (value?.label ?? ''), + } + + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !multiple && + value !== undefined && + Array.isArray(value) + ) { + throw new CircuitError( + 'AutocompleteInput', + 'You have passed an array value to a single selection AutocompleteInput. Please pass an object instead.', ); - - // used only for immmersive variant - const [presentationFieldValue, setPresentationFieldValue] = useState( - Array.isArray(value) ? '' : (value?.label ?? ''), - ); - const isMobile = useMedia('(max-width: 479px)'); - const hasTouch = !useMedia('(hover: hover) and (pointer: fine)'); - const isImmersive = isMobile && variant === 'immersive'; - const [isOpen, setIsOpen] = useState(false); - const [activeOption, setActiveOption] = useState(); - const comboboxRef = useRef(null); - const inputWrapperRef = useRef(null); - const presentationFieldRef = useRef(null); - const resultsRef = useRef(null); - const listboxId = useId(); - const autocompleteId = useId(); - - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - multiple && - value !== undefined && - !Array.isArray(value) - ) { - throw new CircuitError( - 'AutocompleteInput', - 'You have passed a non array value to a multiple selection AutocompleteInput. Please pass an array of values instead.', - ); - } - - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !multiple && - value !== undefined && - Array.isArray(value) - ) { - throw new CircuitError( - 'AutocompleteInput', - 'You have passed an array value to a single selection AutocompleteInput. Please pass an object instead.', - ); + } + + const optionValues: string[] = useMemo( + () => + options + .flatMap((option) => (isGroup(option) ? option.options : option)) + .map((option) => option.value), + [options], + ); + + useEffect(() => { + if (isLoading) { + setActiveOption(undefined); } - - const optionValues: string[] = useMemo( - () => - options - .flatMap((option) => (isGroup(option) ? option.options : option)) - .map((option) => option.value), - [options], - ); - - useEffect(() => { - if (isLoading) { - setActiveOption(undefined); - } - }, [isLoading]); - - const closeResults = useCallback(() => { - setIsOpen(false); + }, [isLoading]); + + const closeResults = useCallback(() => { + setIsOpen(false); + setActiveOption(undefined); + }, []); + + const debouncedOnSearch = useMemo( + () => + debounce( + (changeEvent: ChangeEvent) => + onSearch?.(changeEvent.target.value), + 300, + ), + [onSearch], + ); + + const onComboboxChange = useCallback( + (event: ChangeEvent) => { + setSearchText(event.target.value); setActiveOption(undefined); - }, []); - - const debouncedOnSearch = useMemo( - () => - debounce( - (changeEvent: ChangeEvent) => - onSearch?.(changeEvent.target.value), - 300, - ), - [onSearch], - ); - - const onComboboxChange = useCallback( - (event: ChangeEvent) => { - setSearchText(event.target.value); - setActiveOption(undefined); - if (event.target.value.length >= minQueryLength) { - debouncedOnSearch?.(event); - } - }, - [minQueryLength, debouncedOnSearch], - ); + if (event.target.value.length >= minQueryLength) { + debouncedOnSearch?.(event); + } + }, + [minQueryLength, debouncedOnSearch], + ); - const onComboboxClear = useCallback( - (event: ClickEvent) => { - changeInputValue(comboboxRef.current, ''); - onClear?.(event); - }, - [onClear], - ); + const onComboboxClear = useCallback( + (event: ClickEvent) => { + changeInputValue(comboboxRef.current, ''); + onClear?.(event); + }, + [onClear], + ); + + const onPresentationFieldClear = useCallback( + (event: ClickEvent) => { + setPresentationFieldValue(''); + setSearchText(''); + changeInputValue(presentationFieldRef.current, ''); + setIsOpen(true); + onClear?.(event); + }, + [onClear], + ); - const onPresentationFieldClear = useCallback( - (event: ClickEvent) => { - setPresentationFieldValue(''); - setSearchText(''); - changeInputValue(presentationFieldRef.current, ''); - setIsOpen(true); - onClear?.(event); - }, - [onClear], - ); + const onPresentationFieldKeyDown = useCallback(() => { + setIsOpen(true); + setActiveOption(0); + }, []); - const onPresentationFieldKeyDown = useCallback(() => { - setIsOpen(true); - setActiveOption(0); - }, []); + const onPresentationFieldClick = useCallback(() => { + setIsOpen(true); + }, []); - const onPresentationFieldClick = useCallback(() => { + const onComboboxClick = useCallback(() => { + if (!isOpen) { + comboboxRef?.current?.select(); setIsOpen(true); - }, []); - - const onComboboxClick = useCallback(() => { - if (!isOpen) { - comboboxRef?.current?.select(); - setIsOpen(true); - } - }, [isOpen]); - - const { floatingStyles, refs, update } = useFloating({ - open: isOpen, - placement: 'bottom', - strategy: 'fixed', - middleware: [ - offset({ mainAxis: size === 's' ? 10 : 17, crossAxis: 0 }), // bottom padding + 1px border + 4px gap - shift({ padding: boundaryPadding }), - flip({ - padding: boundaryPadding, - fallbackPlacements: multiple ? [] : ['top'], - }), - floatingSize(() => ({ - ...sizeOptions, - boundary: - typeof window !== 'undefined' - ? { - x: 0, - y: 0, - width: window.innerWidth, - height: window.innerHeight, - } - : undefined, - })), - ], - whileElementsMounted: autoUpdate, - }); - - useEffect(() => { - if (value && !Array.isArray(value)) { - setSearchText(value.label ?? ''); - if (isImmersive) { - setPresentationFieldValue(value.label ?? ''); - } + } + }, [isOpen]); + + const { floatingStyles, refs, update } = useFloating({ + open: isOpen, + placement: 'bottom', + strategy: 'fixed', + middleware: [ + offset({ mainAxis: size === 's' ? 10 : 17, crossAxis: 0 }), // bottom padding + 1px border + 4px gap + shift({ padding: boundaryPadding }), + flip({ + padding: boundaryPadding, + fallbackPlacements: multiple ? [] : ['top'], + }), + floatingSize(() => ({ + ...sizeOptions, + boundary: + typeof window !== 'undefined' + ? { + x: 0, + y: 0, + width: window.innerWidth, + height: window.innerHeight, + } + : undefined, + })), + ], + whileElementsMounted: autoUpdate, + }); + + useEffect(() => { + if (value && !Array.isArray(value)) { + setSearchText(value.label ?? ''); + if (isImmersive) { + setPresentationFieldValue(value.label ?? ''); } - }, [value, isImmersive]); - - const onOptionClick = useCallback( - (selectedValue: AutocompleteInputOption) => { - if (multiple) { - setSearchText(''); - // put focus back on the input field after selection - comboboxRef.current?.focus({ preventScroll: true }); - } else { - closeResults(); - } - onChange(selectedValue); - }, - [onChange, closeResults, multiple], - ); + } + }, [value, isImmersive]); - const onTagRemove = useCallback( - (tagValue: AutocompleteInputOption) => { - onChange(tagValue); + const onOptionClick = useCallback( + (selectedValue: AutocompleteInputOption) => { + if (multiple) { + setSearchText(''); + // put focus back on the input field after selection comboboxRef.current?.focus({ preventScroll: true }); - }, - [onChange], - ); + } else { + closeResults(); + } + onChange(selectedValue); + }, + [onChange, closeResults, multiple], + ); - const onComboboxKeyDown: KeyboardEventHandler = - useCallback( - (event) => { - if (isOpen) { - if (isArrowDown(event) || isArrowUp(event)) { - event.preventDefault(); - const totalDisplayedOptions = - resultsRef.current?.querySelectorAll('[role="option"]') - .length ?? 0; - - if (activeOption === undefined) { - setActiveOption( - isArrowDown(event) ? 0 : optionValues.length - 1, - ); - } else { - const nextOption = - (activeOption + - totalDisplayedOptions + - (isArrowDown(event) ? 1 : -1)) % - totalDisplayedOptions; - - setActiveOption(nextOption); - } - } - if (isEnter(event)) { - event.preventDefault(); - event.stopPropagation(); - if (allowNewItems && searchText !== '') { - onOptionClick({ - value: searchText, - label: searchText, - }); - } else if (activeOption !== undefined) { - onOptionClick( - getOptionByValue(options, optionValues[activeOption]), - ); - } - if (!activeOption || !searchText) { - setIsOpen(false); - } - } - if ( - isBackspace(event) && - !searchText && - Array.isArray(value) && - value.length > 0 - ) { - // if the search text is empty and the user presses backspace, - // remove the last selected value - onChange(value[value.length - 1]); - } - } else if (!isEnter(event)) { - setIsOpen(true); - setActiveOption(0); - } - }, - [ - isOpen, - activeOption, - onChange, - optionValues, - onOptionClick, - searchText, - options, - allowNewItems, - value, - ], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: we need to update the floating element styles if the options length changes - useEffect(() => { + const onTagRemove = useCallback( + (tagValue: AutocompleteInputOption) => { + onChange(tagValue); + comboboxRef.current?.focus({ preventScroll: true }); + }, + [onChange], + ); + + const onComboboxKeyDown: KeyboardEventHandler = useCallback( + (event) => { if (isOpen) { - if (isMobile && hasTouch) { - comboboxRef.current?.focus({ preventScroll: true }); + if (isArrowDown(event) || isArrowUp(event)) { + event.preventDefault(); + const totalDisplayedOptions = + resultsRef.current?.querySelectorAll('[role="option"]').length ?? 0; + + if (activeOption === undefined) { + setActiveOption(isArrowDown(event) ? 0 : optionValues.length - 1); + } else { + const nextOption = + (activeOption + + totalDisplayedOptions + + (isArrowDown(event) ? 1 : -1)) % + totalDisplayedOptions; + + setActiveOption(nextOption); + } + } + if (isEnter(event)) { + event.preventDefault(); + event.stopPropagation(); + if (allowNewItems && searchText !== '') { + onOptionClick({ + value: searchText, + label: searchText, + }); + } else if (activeOption !== undefined) { + onOptionClick( + getOptionByValue(options, optionValues[activeOption]), + ); + } + if (!activeOption || !searchText) { + setIsOpen(false); + } + } + if ( + isBackspace(event) && + !searchText && + Array.isArray(value) && + value.length > 0 + ) { + // if the search text is empty and the user presses backspace, + // remove the last selected value + onChange(value[value.length - 1]); } - update(); + } else if (!isEnter(event)) { + setIsOpen(true); + setActiveOption(0); } - }, [isOpen, update, options.length, value]); + }, + [ + isOpen, + activeOption, + onChange, + optionValues, + onOptionClick, + searchText, + options, + allowNewItems, + value, + ], + ); - const handleClickOutside = useCallback(() => { - if (!isImmersive) { - closeResults(); + // biome-ignore lint/correctness/useExhaustiveDependencies: we need to update the floating element styles if the options length changes + useEffect(() => { + if (isOpen) { + if (isMobile && hasTouch) { + comboboxRef.current?.focus({ preventScroll: true }); } - }, [closeResults, isImmersive]); - useClickOutside([inputWrapperRef, refs.floating], handleClickOutside); - - useEscapeKey(closeResults, isOpen); + update(); + } + }, [isOpen, update, options.length, value]); - useEffect(() => { - // if readOnly or disabled props become truthy, close the list box - if ((readOnly || disabled) && isOpen) { - closeResults(); - } - }, [readOnly, disabled, isOpen, closeResults]); - - const activeDescendant = - isOpen && activeOption !== undefined - ? `option-${autocompleteId}-${activeOption}` - : undefined; - - const noResults = useMemo( - () => - isString(noResultsMessage) || !noResultsMessage ? ( - - {noResultsMessage ?? defaultNoResultsMessage} - - ) : ( - noResultsMessage - ), - [noResultsMessage, defaultNoResultsMessage], - ); + const handleClickOutside = useCallback(() => { + if (!isImmersive) { + closeResults(); + } + }, [closeResults, isImmersive]); + useClickOutside([inputWrapperRef, refs.floating], handleClickOutside); - const results = - searchText || options.length ? ( - - ) : null; - - const restoreValue: FocusEventHandler = useCallback( - (event) => { - if (!Array.isArray(value) && searchText !== value?.value) { - setSearchText(value?.label ?? ''); - if (isImmersive) { - setPresentationFieldValue(value?.label ?? ''); - } - } - props.onBlur?.(event); - }, - [value, searchText, isImmersive, props.onBlur], - ); + useEscapeKey(closeResults, isOpen); - const comboboxProps = { - ...props, - label, - size, - 'data-id': autocompleteId, - clearLabel, - value: searchText, - onChange: onComboboxChange, - onClear: onClear && !multiple ? onComboboxClear : undefined, - onKeyDown: isLoading ? undefined : onComboboxKeyDown, - role: 'combobox', - 'aria-controls': listboxId, - autoComplete: 'off', - 'aria-autocomplete': 'list' as const, - 'aria-activedescendant': activeDescendant, - onClick: !readOnly && !disabled ? onComboboxClick : undefined, - readOnly, - disabled, - onBlur: allowNewItems ? undefined : restoreValue, - tags: Array.isArray(value) ? value : undefined, - onTagRemove, - isOpen, - moreResults, - removeTagButtonLabel, - }; - - if (isImmersive) { - return ( - <> - - -
- -
- {results} -
- - ); + useEffect(() => { + // if readOnly or disabled props become truthy, close the list box + if ((readOnly || disabled) && isOpen) { + closeResults(); } + }, [readOnly, disabled, isOpen, closeResults]); + + const activeDescendant = + isOpen && activeOption !== undefined + ? `option-${autocompleteId}-${activeOption}` + : undefined; + + const noResults = useMemo( + () => + isString(noResultsMessage) || !noResultsMessage ? ( + + {noResultsMessage ?? defaultNoResultsMessage} + + ) : ( + noResultsMessage + ), + [noResultsMessage, defaultNoResultsMessage], + ); + + const results = + searchText || options.length ? ( + + ) : null; + + const restoreValue: FocusEventHandler = useCallback( + (event) => { + if (!Array.isArray(value) && searchText !== value?.value) { + setSearchText(value?.label ?? ''); + if (isImmersive) { + setPresentationFieldValue(value?.label ?? ''); + } + } + props.onBlur?.(event); + }, + [value, searchText, isImmersive, props.onBlur], + ); + + const comboboxProps = { + ...props, + label, + size, + 'data-id': autocompleteId, + clearLabel, + value: searchText, + onChange: onComboboxChange, + onClear: onClear && !multiple ? onComboboxClear : undefined, + onKeyDown: isLoading ? undefined : onComboboxKeyDown, + role: 'combobox', + 'aria-controls': listboxId, + autoComplete: 'off', + 'aria-autocomplete': 'list' as const, + 'aria-activedescendant': activeDescendant, + onClick: !readOnly && !disabled ? onComboboxClick : undefined, + readOnly, + disabled, + onBlur: allowNewItems ? undefined : restoreValue, + tags: Array.isArray(value) ? value : undefined, + onTagRemove, + isOpen, + moreResults, + removeTagButtonLabel, + }; + if (isImmersive) { return ( <> -
- -
- {isOpen && ( -
- {results} + + +
+
- )} + {results} +
); - }, -); + } + + return ( + <> +
+ +
+ {isOpen && ( +
+ {results} +
+ )} + + ); +} diff --git a/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.tsx b/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.tsx index 95e27fb5fa..234db12006 100644 --- a/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import type { ReturnType } from '../../../../types/return-type.js'; import { idx } from '../../../../util/idx.js'; @@ -70,163 +70,154 @@ export interface ComboboxInputProps 'data-id'?: string; } -export const ComboboxInput = forwardRef( - ( - { - value, - tags = [], - onTagRemove, - isOpen, - validationHint, - optionalLabel, - required, - invalid, - hasWarning, - showValid, - disabled, - readOnly, - textAlign, - inputClassName, - label, - hideLabel, - 'id': customId, - className, - style, - size = 'm', - 'aria-describedby': descriptionId, - onClear, - clearLabel, - locale, - 'data-id': comboboxInputId, - removeTagButtonLabel, - moreResults, - ...props - }, - ref, - ): ReturnType => { - const id = useId(); - const inputId = customId || id; - const localRef = useRef(null); - const [showAllTags, setShowAllTags] = useState(true); +export function ComboboxInput({ + value, + tags = [], + onTagRemove, + isOpen, + validationHint, + optionalLabel, + required, + invalid, + hasWarning, + showValid, + disabled, + readOnly, + textAlign, + inputClassName, + label, + hideLabel, + 'id': customId, + className, + style, + size = 'm', + 'aria-describedby': descriptionId, + onClear, + clearLabel, + locale, + 'data-id': comboboxInputId, + removeTagButtonLabel, + moreResults, + ref, + ...props +}: ComboboxInputProps): ReturnType { + const id = useId(); + const inputId = customId || id; + const localRef = useRef(null); + const [showAllTags, setShowAllTags] = useState(true); - const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); + const validationHintId = useId(); + const descriptionIds = idx(descriptionId, validationHint && validationHintId); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - props.type !== 'hidden' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'AutocompleteInput', - 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + props.type !== 'hidden' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'AutocompleteInput', + 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', + ); + } - const onClearButtonClick = (event: ClickEvent) => { - onClear?.(event); - localRef.current?.focus(); - }; + const onClearButtonClick = (event: ClickEvent) => { + onClear?.(event); + localRef.current?.focus(); + }; - useEffect(() => { - if (!isOpen) { - setShowAllTags(false); - } - }, [isOpen]); + useEffect(() => { + if (!isOpen) { + setShowAllTags(false); + } + }, [isOpen]); - return ( - + + + +
- - - -
- {tags.slice(0, isOpen || showAllTags ? tags.length : 4).map((tag) => { - const onRemoveProps = - readOnly || disabled - ? {} - : { - onRemove: () => onTagRemove?.(tag), - removeButtonLabel: `${removeTagButtonLabel} ${tag.label}`, - }; - return ( - - {tag.label} - - ); - })} - {!showAllTags && !isOpen && tags.length > 4 && ( - - )} - - {value && !disabled && !readOnly && onClear && clearLabel && ( - - {clearLabel} - + {tag.label} + + ); + })} + {!showAllTags && !isOpen && tags.length > 4 && ( + + )} + - - - ); - }, -); - -ComboboxInput.displayName = 'ComboboxInput'; + {value && !disabled && !readOnly && onClear && clearLabel && ( + + {clearLabel} + + )} +
+ + + ); +} diff --git a/packages/circuit-ui/components/AutocompleteInput/components/Results/Results.tsx b/packages/circuit-ui/components/AutocompleteInput/components/Results/Results.tsx index 24b2a2bcb6..c0ea244075 100644 --- a/packages/circuit-ui/components/AutocompleteInput/components/Results/Results.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/components/Results/Results.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, type ReactNode } from 'react'; +import type { ReactNode, Ref } from 'react'; import { utilClasses } from '../../../../styles/utility.js'; import { Options, type OptionsProps } from '../Options/Options.js'; @@ -27,6 +27,7 @@ import { Button, type ButtonProps } from '../../../Button/index.js'; import classes from './Results.module.css'; export type ResultsProps = OptionsProps & { + ref?: Ref; /** * Custom label to display while loading options. */ @@ -47,31 +48,29 @@ export type ResultsProps = OptionsProps & { isImmersive?: boolean; }; -export const Results = forwardRef( - ( - { - isLoading, - isLoadingMore, - loadingLabel, - noResultsMessage, - options, - value, - onOptionClick, - label, - activeOption, - loadMore, - action, - optionIdPrefix, - allowNewItems, - searchText, - resultsSummary, - isImmersive, - loadMoreLabel, - listboxId, - ...rest - }, - ref, - ) => ( +export function Results({ + isLoading, + isLoadingMore, + loadingLabel, + noResultsMessage, + options, + value, + onOptionClick, + label, + activeOption, + loadMore, + action, + optionIdPrefix, + allowNewItems, + searchText, + resultsSummary, + isImmersive, + loadMoreLabel, + listboxId, + ref, + ...rest +}: ResultsProps) { + return (
( )}
- ), -); + ); +} diff --git a/packages/circuit-ui/components/Badge/Badge.tsx b/packages/circuit-ui/components/Badge/Badge.tsx index 8bf0fc4da4..7f3349fa41 100644 --- a/packages/circuit-ui/components/Badge/Badge.tsx +++ b/packages/circuit-ui/components/Badge/Badge.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import type { AsPropType } from '../../types/prop-types.js'; import { deprecate } from '../../util/logger.js'; @@ -23,6 +23,7 @@ import { Status, type StatusColor } from '../Status/Status.js'; * @deprecated Use the Status component instead. */ export interface BadgeProps extends HTMLAttributes { + ref?: Ref; /** * @deprecated Use the Status component's `color` prop instead. * Choose the style variant. Default: 'neutral'. @@ -53,26 +54,27 @@ const colorMap: Record, StatusColor> = { * A badge communicates the status of an element or the count of items * related to an element. */ -export const Badge = forwardRef( - ({ variant = 'neutral', circle, color: _color, children, ...props }, ref) => { - if (process.env.NODE_ENV !== 'production') { - deprecate( - 'Badge', - 'The Badge component is deprecated. Use the Status component with variant="pill" or variant="badge" instead.', - ); - } - - return ( - - {children as string | number | undefined} - +export function Badge({ + variant = 'neutral', + circle, + color: _color, + children, + ...props +}: BadgeProps) { + if (process.env.NODE_ENV !== 'production') { + deprecate( + 'Badge', + 'The Badge component is deprecated. Use the Status component with variant="pill" or variant="badge" instead.', ); - }, -); + } -Badge.displayName = 'Badge'; + return ( + + {children as string | number | undefined} + + ); +} diff --git a/packages/circuit-ui/components/Body/Body.tsx b/packages/circuit-ui/components/Body/Body.tsx index cffd585121..cbec438810 100644 --- a/packages/circuit-ui/components/Body/Body.tsx +++ b/packages/circuit-ui/components/Body/Body.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import type { AsPropType } from '../../types/prop-types.js'; import { clsx } from '../../styles/clsx.js'; @@ -24,6 +24,7 @@ import classes from './Body.module.css'; type Variant = 'highlight' | 'quote' | 'confirm' | 'alert' | 'subtle'; export interface BodyProps extends HTMLAttributes { + ref?: Ref; /** * Choose from 3 font sizes. Default `m`. */ @@ -104,71 +105,60 @@ export const deprecatedSizeMap: Record = { * The Body component is used to present the core textual content * to our users. */ -export const Body = forwardRef( - ( - { - className, - as, - size: legacySize = 'm', - weight = getDefaultWeight(as), - decoration, - color = 'normal', - variant, - ...props - }, - ref, - ) => { - const Element = as || getHTMLElement(variant); +export function Body({ + className, + as, + size: legacySize = 'm', + weight = getDefaultWeight(as), + decoration, + color = 'normal', + variant, + ...props +}: BodyProps) { + const Element = as || getHTMLElement(variant); - if (process.env.NODE_ENV !== 'production') { - if (variant) { - if (variant === 'highlight') { - deprecate( - 'Body', - 'The "highlight" variant has been deprecated. Use the new `weight` prop instead.', - ); - } else if (variant === 'quote') { - deprecate( - 'Body', - 'The "quote" variant has been deprecated. Use custom CSS instead.', - ); - } else { - deprecate( - 'Body', - `The "${variant}" variant has been deprecated. Use the new \`color\` prop instead.`, - ); - } - } - - if (legacySize in deprecatedSizeMap) { + if (process.env.NODE_ENV !== 'production') { + if (variant) { + if (variant === 'highlight') { + deprecate( + 'Body', + 'The "highlight" variant has been deprecated. Use the new `weight` prop instead.', + ); + } else if (variant === 'quote') { deprecate( 'Body', - `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, + 'The "quote" variant has been deprecated. Use custom CSS instead.', + ); + } else { + deprecate( + 'Body', + `The "${variant}" variant has been deprecated. Use the new \`color\` prop instead.`, ); } } - const size = (deprecatedSizeMap[legacySize] || legacySize) as - | 'l' - | 'm' - | 's'; + if (legacySize in deprecatedSizeMap) { + deprecate( + 'Body', + `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, + ); + } + } - return ( - - ); - }, -); + const size = (deprecatedSizeMap[legacySize] || legacySize) as 'l' | 'm' | 's'; -Body.displayName = 'Body'; + return ( + + ); +} diff --git a/packages/circuit-ui/components/Button/Button.tsx b/packages/circuit-ui/components/Button/Button.tsx index 26b8c69ce2..bf5de84660 100644 --- a/packages/circuit-ui/components/Button/Button.tsx +++ b/packages/circuit-ui/components/Button/Button.tsx @@ -15,12 +15,7 @@ 'use client'; -import type { - ForwardRefExoticComponent, - PropsWithoutRef, - ReactNode, - RefAttributes, -} from 'react'; +import type { ReactNode } from 'react'; import type { IconComponentType } from '@sumup-oss/icons'; import { CircuitError } from '../../util/errors.js'; @@ -64,9 +59,7 @@ export type ButtonProps = SharedButtonProps & { * The Button component enables the user to perform an action or navigate * to a different screen. */ -export const Button: ForwardRefExoticComponent< - PropsWithoutRef & RefAttributes -> = createButtonComponent( +export const Button = createButtonComponent( 'Button', ({ className, size: legacySize = 'm', stretch, variant, ...props }) => { const size = legacyButtonSizeMap[legacySize] || legacySize; diff --git a/packages/circuit-ui/components/Button/IconButton.tsx b/packages/circuit-ui/components/Button/IconButton.tsx index 462c27baa8..e353d5d5b5 100644 --- a/packages/circuit-ui/components/Button/IconButton.tsx +++ b/packages/circuit-ui/components/Button/IconButton.tsx @@ -15,14 +15,7 @@ 'use client'; -import { - Children, - cloneElement, - type ForwardRefExoticComponent, - type PropsWithoutRef, - type ReactElement, - type RefAttributes, -} from 'react'; +import { Children, cloneElement, type ReactElement } from 'react'; import type { IconComponentType, IconProps } from '@sumup-oss/icons'; import { clsx } from '../../styles/clsx.js'; @@ -62,9 +55,7 @@ export type IconButtonProps = SharedButtonProps & { * The IconButton component enables the user to perform an action or navigate * to a different screen. */ -export const IconButton: ForwardRefExoticComponent< - PropsWithoutRef & RefAttributes -> = createButtonComponent( +export const IconButton = createButtonComponent( 'IconButton', ({ className, @@ -110,7 +101,7 @@ export const IconButton: ForwardRefExoticComponent< return { className: clsx(classes.base, classes[size], className), - icon: (iconProps) => { + icon: (iconProps: IconProps) => { if (Icon) { return ; } @@ -130,5 +121,3 @@ export const IconButton: ForwardRefExoticComponent< }; }, ); - -IconButton.displayName = 'IconButton'; diff --git a/packages/circuit-ui/components/Button/base.tsx b/packages/circuit-ui/components/Button/base.tsx index 97c00cfc24..cca054f70a 100644 --- a/packages/circuit-ui/components/Button/base.tsx +++ b/packages/circuit-ui/components/Button/base.tsx @@ -15,11 +15,11 @@ 'use client'; -import { - forwardRef, - type ButtonHTMLAttributes, - type AnchorHTMLAttributes, - type ReactNode, +import type { + ButtonHTMLAttributes, + AnchorHTMLAttributes, + ReactNode, + Ref, } from 'react'; import type { IconComponentType } from '@sumup-oss/icons'; @@ -137,7 +137,8 @@ export function createButtonComponent( // TODO: Refactor to `mapClassName` once the deprecations have been removed. mapProps: (props: Props) => CreateButtonComponentProps, ) { - const Button = forwardRef((props, ref) => { + // biome-ignore lint/suspicious/noExplicitAny: Polymorphic component supports button, anchor, and custom elements + function Button({ ref, ...rest }: Props & { ref?: Ref }) { const { children, onClick, @@ -153,7 +154,7 @@ export function createButtonComponent( as, locale, ...sharedProps - } = useI18n(mapProps(props as Props), translations); + } = useI18n(mapProps(rest as Props), translations); const components = useComponents(); const Link = components.Link as AsPropType; @@ -235,7 +236,7 @@ export function createButtonComponent( ); - }); + } Button.displayName = componentName; diff --git a/packages/circuit-ui/components/ButtonGroup/ButtonGroup.tsx b/packages/circuit-ui/components/ButtonGroup/ButtonGroup.tsx index 937714c972..75442d2cc3 100644 --- a/packages/circuit-ui/components/ButtonGroup/ButtonGroup.tsx +++ b/packages/circuit-ui/components/ButtonGroup/ButtonGroup.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { Button, @@ -34,6 +34,7 @@ type Action = Omit & { export interface ButtonGroupProps extends Omit, 'align'> { + ref?: Ref; /** * The buttons to group. Expects a primary and optionally a secondary button. */ @@ -54,53 +55,46 @@ export interface ButtonGroupProps /** * The ButtonGroup component groups and formats two buttons. */ -export const ButtonGroup = forwardRef( - ( - { - actions, - className, - align = 'center', - size: legacySize = 'm', - ...props - }: ButtonGroupProps, - ref, - ) => { - if (process.env.NODE_ENV !== 'production') { - if (actions.primary.size) { - deprecate( - 'ButtonGroup', - 'The `actions.primary.size` prop has been deprecated. Use the top-level `size` prop instead.', - ); - } - if (actions.secondary?.size) { - deprecate( - 'ButtonGroup', - 'The `actions.secondary.size` prop has been deprecated. Use the top-level `size` prop instead.', - ); - } +export function ButtonGroup({ + actions, + className, + align = 'center', + size: legacySize = 'm', + ...props +}: ButtonGroupProps) { + if (process.env.NODE_ENV !== 'production') { + if (actions.primary.size) { + deprecate( + 'ButtonGroup', + 'The `actions.primary.size` prop has been deprecated. Use the top-level `size` prop instead.', + ); } + if (actions.secondary?.size) { + deprecate( + 'ButtonGroup', + 'The `actions.secondary.size` prop has been deprecated. Use the top-level `size` prop instead.', + ); + } + } - const size = legacyButtonSizeMap[legacySize] || legacySize; + const size = legacyButtonSizeMap[legacySize] || legacySize; - return ( -
-
+ return ( +
+
+
+ )}
- ); - }, -); - -ButtonGroup.displayName = 'ButtonGroup'; +
+ ); +} diff --git a/packages/circuit-ui/components/Calendar/Calendar.tsx b/packages/circuit-ui/components/Calendar/Calendar.tsx index e742987155..b3a83d3100 100644 --- a/packages/circuit-ui/components/Calendar/Calendar.tsx +++ b/packages/circuit-ui/components/Calendar/Calendar.tsx @@ -16,7 +16,6 @@ 'use client'; import { - forwardRef, useCallback, useEffect, useId, @@ -26,6 +25,7 @@ import { type HTMLAttributes, type KeyboardEvent, type MouseEvent, + type Ref, } from 'react'; import { Temporal } from 'temporal-polyfill'; import { ArrowLeft, ArrowRight } from '@sumup-oss/icons'; @@ -113,6 +113,7 @@ interface SharedProps { export interface CalendarProps extends SharedProps, Omit, 'onSelect'> { + ref?: Ref; /** * A callback that is called with the visible months on the initial render and * whenever a user navigates to different months. @@ -137,196 +138,193 @@ export interface CalendarProps * component for advanced use cases; you likely want to use the DateInput * component instead. */ -export const Calendar = forwardRef( - (props, ref) => { - const { - selection, - onSelect, - onMonthsChange, - minDate, - maxDate, - firstDayOfWeek = 1, - locale, - prevMonthButtonLabel, - nextMonthButtonLabel, - modifiers, - numberOfMonths = 1, - ...rest - } = useI18n(props, translations); - const [{ months, focusedDate, hoveredDate, today }, dispatch] = useReducer( - calendarReducer, - { selection, minDate, maxDate, numberOfMonths }, - initCalendar, - ); +export function Calendar(props: CalendarProps) { + const { + ref, + selection, + onSelect, + onMonthsChange, + minDate, + maxDate, + firstDayOfWeek = 1, + locale, + prevMonthButtonLabel, + nextMonthButtonLabel, + modifiers, + numberOfMonths = 1, + ...rest + } = useI18n(props, translations); + const [{ months, focusedDate, hoveredDate, today }, dispatch] = useReducer( + calendarReducer, + { selection, minDate, maxDate, numberOfMonths }, + initCalendar, + ); - useEffect(() => { - onMonthsChange?.(months); - }, [onMonthsChange, months]); + useEffect(() => { + onMonthsChange?.(months); + }, [onMonthsChange, months]); - useEffect(() => { - dispatch({ type: CalendarActionType.NUMBER_OF_MONTHS, numberOfMonths }); - }, [numberOfMonths]); + useEffect(() => { + dispatch({ type: CalendarActionType.NUMBER_OF_MONTHS, numberOfMonths }); + }, [numberOfMonths]); - const calendarRef = useRef(null); + const calendarRef = useRef(null); - const { daysInWeek } = focusedDate; + const { daysInWeek } = focusedDate; - const isPrevMonthDisabled = minDate - ? Temporal.PlainYearMonth.compare(months[0], minDate) <= 0 - : false; - const isNextMonthDisabled = maxDate - ? Temporal.PlainYearMonth.compare(last(months), maxDate) >= 0 - : false; + const isPrevMonthDisabled = minDate + ? Temporal.PlainYearMonth.compare(months[0], minDate) <= 0 + : false; + const isNextMonthDisabled = maxDate + ? Temporal.PlainYearMonth.compare(last(months), maxDate) >= 0 + : false; - const touchHandlers = useSwipe((direction) => { - if (direction === 'right' && !isPrevMonthDisabled) { - dispatch({ type: CalendarActionType.PREV_MONTH }); - } - if (direction === 'left' && !isNextMonthDisabled) { - dispatch({ type: CalendarActionType.NEXT_MONTH }); - } - }); + const touchHandlers = useSwipe((direction) => { + if (direction === 'right' && !isPrevMonthDisabled) { + dispatch({ type: CalendarActionType.PREV_MONTH }); + } + if (direction === 'left' && !isNextMonthDisabled) { + dispatch({ type: CalendarActionType.NEXT_MONTH }); + } + }); - const handleFocusDate = useCallback((date: Temporal.PlainDate) => { - dispatch({ type: CalendarActionType.FOCUS_DATE, date }); - // Focus the button on the next tick after React has rerendered the UI - window.requestAnimationFrame(() => { - calendarRef.current - ?.querySelector('button[tabindex="0"]') - ?.focus(); - }); - }, []); + const handleFocusDate = useCallback((date: Temporal.PlainDate) => { + dispatch({ type: CalendarActionType.FOCUS_DATE, date }); + // Focus the button on the next tick after React has rerendered the UI + window.requestAnimationFrame(() => { + calendarRef.current + ?.querySelector('button[tabindex="0"]') + ?.focus(); + }); + }, []); - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - let nextFocusedDate: Temporal.PlainDate; + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + let nextFocusedDate: Temporal.PlainDate; - switch (event.key) { - case 'ArrowRight': - nextFocusedDate = focusedDate.add({ days: 1 }); - break; - case 'ArrowLeft': - nextFocusedDate = focusedDate.subtract({ days: 1 }); - break; - case 'ArrowDown': - nextFocusedDate = focusedDate.add({ days: focusedDate.daysInWeek }); - break; - case 'ArrowUp': - nextFocusedDate = focusedDate.subtract({ - days: focusedDate.daysInWeek, - }); - break; - case 'PageUp': - nextFocusedDate = focusedDate.subtract( - event.shiftKey ? { years: 1 } : { months: 1 }, - ); - break; - case 'PageDown': - nextFocusedDate = focusedDate.add( - event.shiftKey ? { years: 1 } : { months: 1 }, - ); - break; - case 'Home': - nextFocusedDate = getFirstDateOfWeek(focusedDate, firstDayOfWeek); - break; - case 'End': - nextFocusedDate = getLastDateOfWeek(focusedDate, firstDayOfWeek); - break; - default: - return; - } + switch (event.key) { + case 'ArrowRight': + nextFocusedDate = focusedDate.add({ days: 1 }); + break; + case 'ArrowLeft': + nextFocusedDate = focusedDate.subtract({ days: 1 }); + break; + case 'ArrowDown': + nextFocusedDate = focusedDate.add({ days: focusedDate.daysInWeek }); + break; + case 'ArrowUp': + nextFocusedDate = focusedDate.subtract({ + days: focusedDate.daysInWeek, + }); + break; + case 'PageUp': + nextFocusedDate = focusedDate.subtract( + event.shiftKey ? { years: 1 } : { months: 1 }, + ); + break; + case 'PageDown': + nextFocusedDate = focusedDate.add( + event.shiftKey ? { years: 1 } : { months: 1 }, + ); + break; + case 'Home': + nextFocusedDate = getFirstDateOfWeek(focusedDate, firstDayOfWeek); + break; + case 'End': + nextFocusedDate = getLastDateOfWeek(focusedDate, firstDayOfWeek); + break; + default: + return; + } - event.preventDefault(); + event.preventDefault(); - if (isDateInMonthRange(nextFocusedDate, minDate, maxDate)) { - handleFocusDate(nextFocusedDate); - } - }, - [handleFocusDate, focusedDate, minDate, maxDate, firstDayOfWeek], - ); + if (isDateInMonthRange(nextFocusedDate, minDate, maxDate)) { + handleFocusDate(nextFocusedDate); + } + }, + [handleFocusDate, focusedDate, minDate, maxDate, firstDayOfWeek], + ); - const handleMouseEnter = useCallback((date: Temporal.PlainDate) => { - dispatch({ type: CalendarActionType.MOUSE_ENTER_DATE, date }); - }, []); - const handleMouseLeave = useCallback(() => { - dispatch({ type: CalendarActionType.MOUSE_LEAVE_DATE }); - }, []); + const handleMouseEnter = useCallback((date: Temporal.PlainDate) => { + dispatch({ type: CalendarActionType.MOUSE_ENTER_DATE, date }); + }, []); + const handleMouseLeave = useCallback(() => { + dispatch({ type: CalendarActionType.MOUSE_LEAVE_DATE }); + }, []); - if (process.env.NODE_ENV !== 'production' && modifiers) { - Object.keys(modifiers).forEach((key) => { - try { - Temporal.PlainDate.from(key); - } catch (_error) { - throw new CircuitError( - 'Calendar', - `The "${key}" key of the \`modifiers\` prop is not a valid ISO 8601 date string.`, - ); - } - }); - } + if (process.env.NODE_ENV !== 'production' && modifiers) { + Object.keys(modifiers).forEach((key) => { + try { + Temporal.PlainDate.from(key); + } catch (_error) { + throw new CircuitError( + 'Calendar', + `The "${key}" key of the \`modifiers\` prop is not a valid ISO 8601 date string.`, + ); + } + }); + } - return ( -
-
-
- { - dispatch({ type: CalendarActionType.PREV_MONTH }); - }} - > - {prevMonthButtonLabel} - -
-
- { - dispatch({ type: CalendarActionType.NEXT_MONTH }); - }} - > - {nextMonthButtonLabel} - -
+ return ( +
+
+
+ { + dispatch({ type: CalendarActionType.PREV_MONTH }); + }} + > + {prevMonthButtonLabel} +
-
- {months.map((month) => ( - - ))} +
+ { + dispatch({ type: CalendarActionType.NEXT_MONTH }); + }} + > + {nextMonthButtonLabel} +
- ); - }, -); - -Calendar.displayName = 'Calendar'; +
+ {months.map((month) => ( + + ))} +
+
+ ); +} interface MonthProps extends SharedProps { yearMonth: Temporal.PlainYearMonth; @@ -388,7 +386,6 @@ function Month({ {headline} {/** biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: 'grid' is an appropriate role for a table element */} - {/** biome-ignore lint/a11y/useSemanticElements: 'grid' is more specific than 'table */} diff --git a/packages/circuit-ui/components/Card/Card.tsx b/packages/circuit-ui/components/Card/Card.tsx index 0019c43ee6..8ff0653cc2 100644 --- a/packages/circuit-ui/components/Card/Card.tsx +++ b/packages/circuit-ui/components/Card/Card.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; import type { AsPropType } from '../../types/prop-types.js'; @@ -21,6 +21,7 @@ import type { AsPropType } from '../../types/prop-types.js'; import classes from './Card.module.css'; export interface CardProps extends HTMLAttributes { + ref?: Ref; /** * The padding of the Card. */ @@ -31,14 +32,16 @@ export interface CardProps extends HTMLAttributes { as?: AsPropType; } -export const Card = forwardRef( - ({ className, spacing = 'giga', as: Element = 'div', ...props }, ref) => ( +export function Card({ + className, + spacing = 'giga', + as: Element = 'div', + ...props +}: CardProps) { + return ( - ), -); - -Card.displayName = 'Card'; + ); +} diff --git a/packages/circuit-ui/components/Card/components/Footer/Footer.tsx b/packages/circuit-ui/components/Card/components/Footer/Footer.tsx index 257ab1f94e..1add1d9759 100644 --- a/packages/circuit-ui/components/Card/components/Footer/Footer.tsx +++ b/packages/circuit-ui/components/Card/components/Footer/Footer.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { clsx } from '../../../../styles/clsx.js'; import type { AsPropType } from '../../../../types/prop-types.js'; @@ -21,6 +21,7 @@ import type { AsPropType } from '../../../../types/prop-types.js'; import classes from './Footer.module.css'; export interface CardFooterProps extends HTMLAttributes { + ref?: Ref; /** * Direction to align the content. Either left/right */ @@ -32,19 +33,19 @@ export interface CardFooterProps extends HTMLAttributes { * Footer used in the Card component. Used for styling and alignment purposes * only. */ -export const CardFooter = forwardRef( - ( - { as: Element = 'div', children, className, align = 'right', ...props }, - ref, - ) => ( +export function CardFooter({ + as: Element = 'div', + children, + className, + align = 'right', + ...props +}: CardFooterProps) { + return ( {children} - ), -); - -CardFooter.displayName = 'CardFooter'; + ); +} diff --git a/packages/circuit-ui/components/Card/components/Header/Header.tsx b/packages/circuit-ui/components/Card/components/Header/Header.tsx index a7678a3604..9de7a08da9 100644 --- a/packages/circuit-ui/components/Card/components/Header/Header.tsx +++ b/packages/circuit-ui/components/Card/components/Header/Header.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, type ReactNode, type HTMLAttributes } from 'react'; +import type { ReactNode, HTMLAttributes, Ref } from 'react'; import type { ClickEvent } from '../../../../types/events.js'; import { CloseButton } from '../../../CloseButton/index.js'; @@ -40,6 +40,7 @@ type CloseProps = | { onClose?: never; closeButtonLabel?: never }; export type CardHeaderProps = { + ref?: Ref; /** * Headline to be shown. */ @@ -51,26 +52,29 @@ export type CardHeaderProps = { * Header used in the Card component. Used for styling and alignment * purposes only. */ -export const CardHeader = forwardRef( - ({ onClose, children, closeButtonLabel, className, ...props }, ref) => { - const noHeadline = isArray(children) && !children[0]; - return ( -
- {children} - {onClose && closeButtonLabel && ( - - {closeButtonLabel} - - )} -
- ); - }, -); +export function CardHeader({ + onClose, + children, + closeButtonLabel, + className, + ...props +}: CardHeaderProps) { + const noHeadline = isArray(children) && !children[0]; + return ( +
+ {children} + {onClose && closeButtonLabel && ( + + {closeButtonLabel} + + )} +
+ ); +} diff --git a/packages/circuit-ui/components/Carousel/components/Slides/Slides.tsx b/packages/circuit-ui/components/Carousel/components/Slides/Slides.tsx index 7870f6fc1d..9f1534a730 100644 --- a/packages/circuit-ui/components/Carousel/components/Slides/Slides.tsx +++ b/packages/circuit-ui/components/Carousel/components/Slides/Slides.tsx @@ -13,16 +13,16 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { clsx } from '../../../../styles/clsx.js'; import classes from './Slides.module.css'; -type SlidesProps = HTMLAttributes; +type SlidesProps = HTMLAttributes & { + ref?: Ref; +}; -export const Slides = forwardRef( - ({ className, ...props }, ref) => ( -
- ), -); +export function Slides({ className, ref, ...props }: SlidesProps) { + return
; +} diff --git a/packages/circuit-ui/components/CarouselPagination/CarouselPagination.tsx b/packages/circuit-ui/components/CarouselPagination/CarouselPagination.tsx index e9cc0a72e5..6eedac98aa 100644 --- a/packages/circuit-ui/components/CarouselPagination/CarouselPagination.tsx +++ b/packages/circuit-ui/components/CarouselPagination/CarouselPagination.tsx @@ -15,11 +15,11 @@ 'use client'; -import { - forwardRef, - type AnchorHTMLAttributes, - type ButtonHTMLAttributes, - type HTMLAttributes, +import type { + Ref, + AnchorHTMLAttributes, + ButtonHTMLAttributes, + HTMLAttributes, } from 'react'; import { useComponents } from '../ComponentsContext/useComponents.js'; @@ -46,6 +46,7 @@ type Slide = LinkElProps & export interface CarouselPaginationProps extends HTMLAttributes { + ref?: Ref; /** * The collection of slides that can be navigated using this component. */ @@ -70,15 +71,18 @@ export interface CarouselPaginationProps * The carousel pagination component allows users to navigate content that is * divided into multiple slides or pages. */ -export const CarouselPagination = forwardRef< - HTMLUListElement, - CarouselPaginationProps ->(({ slides, currentId, type, className, ...props }, ref) => { +export function CarouselPagination({ + slides, + currentId, + type, + className, + ...props +}: CarouselPaginationProps) { const components = useComponents(); const Link = components.Link as AsPropType; return ( -
    +
      {slides.map(({ id, label, ...item }) => { let Element: AsPropType; if (item.href) { @@ -103,4 +107,4 @@ export const CarouselPagination = forwardRef< })}
    ); -}); +} diff --git a/packages/circuit-ui/components/Checkbox/Checkbox.tsx b/packages/circuit-ui/components/Checkbox/Checkbox.tsx index 3dda01c9fa..1b653d5bda 100644 --- a/packages/circuit-ui/components/Checkbox/Checkbox.tsx +++ b/packages/circuit-ui/components/Checkbox/Checkbox.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useId } from 'react'; +import { useId } from 'react'; import { FieldValidationHint, FieldWrapper } from '../Field/index.js'; import { @@ -46,67 +46,58 @@ export interface CheckboxProps extends Omit { /** * Checkbox component for forms. */ -export const Checkbox = forwardRef( - ( - { - label, - disabled, - validationHint, - optionalLabel, - className, - style, - invalid, - indeterminate = false, - 'aria-describedby': descriptionId, - children, - ...props - }, - ref, - ) => { - const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); - - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'Checkbox', - 'The `label` prop is missing or invalid.', - ); - } +export function Checkbox({ + label, + disabled, + validationHint, + optionalLabel, + className, + style, + invalid, + indeterminate = false, + 'aria-describedby': descriptionId, + children, + ref, + ...props +}: CheckboxProps) { + const validationHintId = useId(); + const descriptionIds = idx(descriptionId, validationHint && validationHintId); - return ( - - - - {label || children} - {optionalLabel ? ( - {` (${optionalLabel})`} - ) : null} - - - - + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'Checkbox', + 'The `label` prop is missing or invalid.', ); - }, -); + } -Checkbox.displayName = 'Checkbox'; + return ( + + + + {label || children} + {optionalLabel ? ( + {` (${optionalLabel})`} + ) : null} + + + + + ); +} diff --git a/packages/circuit-ui/components/Checkbox/CheckboxInput.tsx b/packages/circuit-ui/components/Checkbox/CheckboxInput.tsx index f21be3776f..ee78aa7447 100644 --- a/packages/circuit-ui/components/Checkbox/CheckboxInput.tsx +++ b/packages/circuit-ui/components/Checkbox/CheckboxInput.tsx @@ -16,11 +16,11 @@ 'use client'; import { - forwardRef, useEffect, useId, useRef, type InputHTMLAttributes, + type Ref, } from 'react'; import { Checkmark } from '@sumup-oss/icons'; @@ -37,6 +37,7 @@ import classes from './CheckboxInput.module.css'; export interface CheckboxInputProps extends InputHTMLAttributes { + ref?: Ref; /** * Marks the input as invalid. */ @@ -53,69 +54,63 @@ export interface CheckboxInputProps align?: 'center' | 'start'; } -export const CheckboxInput = forwardRef( - ( - { - id: customId, - invalid, - indeterminate = false, - children, - className, - style, - align = 'center', - ...props - }, - ref, - ) => { - const inputRef = useRef(null); +export function CheckboxInput({ + id: customId, + invalid, + indeterminate = false, + children, + className, + style, + align = 'center', + ref, + ...props +}: CheckboxInputProps) { + const inputRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: Because it came from the props, we keep the `indeterminate` state even if the `checked` one is changed. - useEffect(() => { - if (inputRef.current) { - inputRef.current.indeterminate = indeterminate; - } - }, [props.checked, indeterminate]); - - const id = useId(); - const inputId = customId || id; - - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(children, props) - ) { - throw new AccessibilityError( - 'CheckboxInput', - 'The input is missing a valid label.', - ); + // biome-ignore lint/correctness/useExhaustiveDependencies: Because it came from the props, we keep the `indeterminate` state even if the `checked` one is changed. + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate; } + }, [props.checked, indeterminate]); - return ( - <> - - - + const id = useId(); + const inputId = customId || id; + + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(children, props) + ) { + throw new AccessibilityError( + 'CheckboxInput', + 'The input is missing a valid label.', ); - }, -); + } -CheckboxInput.displayName = 'CheckboxInput'; + return ( + <> + + + + ); +} diff --git a/packages/circuit-ui/components/CheckboxGroup/CheckboxGroup.tsx b/packages/circuit-ui/components/CheckboxGroup/CheckboxGroup.tsx index 415beea2b6..6476d2d815 100644 --- a/packages/circuit-ui/components/CheckboxGroup/CheckboxGroup.tsx +++ b/packages/circuit-ui/components/CheckboxGroup/CheckboxGroup.tsx @@ -16,7 +16,6 @@ 'use client'; import { - forwardRef, useId, type FieldsetHTMLAttributes, type InputHTMLAttributes, @@ -118,95 +117,84 @@ export interface CheckboxGroupProps /** * A group of Checkboxes. */ -export const CheckboxGroup = forwardRef( - ( - { - options, - value, - defaultValue, - onChange, - name, - label, - invalid, - validationHint, - showValid, - disabled, - hasWarning, - hideLabel, - optionalLabel, - required, - 'aria-describedby': descriptionId, - ...props - }: CheckboxGroupProps, - ref: CheckboxGroupProps['ref'], - ) => { - const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); +export function CheckboxGroup({ + options, + value, + defaultValue, + onChange, + name, + label, + invalid, + validationHint, + showValid, + disabled, + hasWarning, + hideLabel, + optionalLabel, + required, + 'aria-describedby': descriptionId, + ...props +}: CheckboxGroupProps) { + const validationHintId = useId(); + const descriptionIds = idx(descriptionId, validationHint && validationHintId); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'CheckboxGroup', - 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'CheckboxGroup', + 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', + ); + } - if (isEmpty(options)) { - return null; - } + if (isEmpty(options)) { + return null; + } - return ( -
    - - - -
      - {options.map((option) => ( -
    • - -
    • - ))} -
    - + + -
    - ); - }, -); - -CheckboxGroup.displayName = 'CheckboxGroup'; + +
      + {options.map((option) => ( +
    • + +
    • + ))} +
    + + + ); +} diff --git a/packages/circuit-ui/components/CloseButton/CloseButton.tsx b/packages/circuit-ui/components/CloseButton/CloseButton.tsx index 1c7fbfa6c5..c9d2d91400 100644 --- a/packages/circuit-ui/components/CloseButton/CloseButton.tsx +++ b/packages/circuit-ui/components/CloseButton/CloseButton.tsx @@ -13,19 +13,27 @@ * limitations under the License. */ -import { forwardRef } from 'react'; +import type { Ref } from 'react'; import { Close } from '@sumup-oss/icons'; import { IconButton, type IconButtonProps } from '../Button/IconButton.js'; -export type CloseButtonProps = Omit; +export type CloseButtonProps = Omit & { + // biome-ignore lint/suspicious/noExplicitAny: Inherits polymorphic ref from IconButton (button, anchor, or custom element) + ref?: Ref; +}; /** * A generic close button. */ -export const CloseButton = forwardRef( - ({ label = 'Close', children = label, ...props }, ref) => ( +export function CloseButton({ + label = 'Close', + children = label, + ref, + ...props +}: CloseButtonProps) { + return ( ( > {children} - ), -); - -CloseButton.displayName = 'CloseButton'; + ); +} diff --git a/packages/circuit-ui/components/ColorInput/ColorInput.tsx b/packages/circuit-ui/components/ColorInput/ColorInput.tsx index 1551c2813b..51f5339841 100644 --- a/packages/circuit-ui/components/ColorInput/ColorInput.tsx +++ b/packages/circuit-ui/components/ColorInput/ColorInput.tsx @@ -16,13 +16,13 @@ 'use client'; import { - forwardRef, useCallback, useEffect, useId, useRef, type ChangeEventHandler, type ClipboardEventHandler, + type Ref, } from 'react'; import { classes as inputClasses } from '../Input/index.js'; @@ -60,6 +60,7 @@ export interface ColorInputProps | 'textAlign' | 'renderSuffix' > { + ref?: Ref; /** * A short string that is shown inside the empty input. */ @@ -74,166 +75,157 @@ export interface ColorInputProps defaultValue?: string; } -export const ColorInput = forwardRef( - ( - { - 'aria-describedby': descriptionId, - className, - defaultValue, - disabled, - hasWarning, - showValid, - hideLabel, - invalid, - label, - onChange, - optionalLabel, - validationHint, - placeholder, - readOnly, - required, - inputClassName, - style, - value, - size = 'm', - ...props - }, - ref, - ) => { - const colorPickerRef = useRef(null); - const colorInputRef = useRef(null); - - const labelId = useId(); - const pickerId = useId(); - const validationHintId = useId(); - - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); - - const updatePickerValue = useCallback((color: string) => { - if (!colorPickerRef.current || !isValidColor(color)) { - return; - } - - changeInputValue(colorPickerRef.current, normalizeColor(color)); - }, []); - - const updateInputValue = useCallback((color?: string) => { - if (!colorInputRef.current || !color) { - return; - } - - const currentColor = colorInputRef.current.value; - - if (!isSameColor(currentColor, color)) { - changeInputValue(colorInputRef.current, color.trim().replace('#', '')); - } - }, []); - - useEffect(() => { - updateInputValue(value); - }, [updateInputValue, value]); - - const handlePaste: ClipboardEventHandler = (event) => { - if (!colorPickerRef.current || !colorInputRef.current || readOnly) { - return; - } - - const pastedText = event.clipboardData.getData('text/plain').trim(); - - if (!pastedText || !isValidColor(pastedText)) { - return; - } - - event.preventDefault(); - - updatePickerValue(pastedText); - updateInputValue(pastedText); - }; - - const onPickerChange: ChangeEventHandler = (event) => { - onChange?.(event); - updateInputValue(event.target.value); - }; - - const onInputChange: ChangeEventHandler = (event) => { - updatePickerValue(event.target.value); - }; - - return ( -
    - - - -
    - - # +export function ColorInput({ + 'aria-describedby': descriptionId, + className, + defaultValue, + disabled, + hasWarning, + showValid, + hideLabel, + invalid, + label, + onChange, + optionalLabel, + validationHint, + placeholder, + readOnly, + required, + inputClassName, + style, + value, + size = 'm', + ref, + ...props +}: ColorInputProps) { + const colorPickerRef = useRef(null); + const colorInputRef = useRef(null); + + const labelId = useId(); + const pickerId = useId(); + const validationHintId = useId(); + + const descriptionIds = idx(descriptionId, validationHint && validationHintId); + + const updatePickerValue = useCallback((color: string) => { + if (!colorPickerRef.current || !isValidColor(color)) { + return; + } + + changeInputValue(colorPickerRef.current, normalizeColor(color)); + }, []); + + const updateInputValue = useCallback((color?: string) => { + if (!colorInputRef.current || !color) { + return; + } + + const currentColor = colorInputRef.current.value; + + if (!isSameColor(currentColor, color)) { + changeInputValue(colorInputRef.current, color.trim().replace('#', '')); + } + }, []); + + useEffect(() => { + updateInputValue(value); + }, [updateInputValue, value]); + + const handlePaste: ClipboardEventHandler = (event) => { + if (!colorPickerRef.current || !colorInputRef.current || readOnly) { + return; + } + + const pastedText = event.clipboardData.getData('text/plain').trim(); + + if (!pastedText || !isValidColor(pastedText)) { + return; + } + + event.preventDefault(); + + updatePickerValue(pastedText); + updateInputValue(pastedText); + }; + + const onPickerChange: ChangeEventHandler = (event) => { + onChange?.(event); + updateInputValue(event.target.value); + }; + + const onInputChange: ChangeEventHandler = (event) => { + updatePickerValue(event.target.value); + }; + + return ( +
    + + + +
    +
    - + # + -
    - ); - }, -); - -ColorInput.displayName = 'ColorInput'; +
    + +
    + ); +} diff --git a/packages/circuit-ui/components/Compact/Compact.tsx b/packages/circuit-ui/components/Compact/Compact.tsx index c45dfbe7c6..1b34fedbdb 100644 --- a/packages/circuit-ui/components/Compact/Compact.tsx +++ b/packages/circuit-ui/components/Compact/Compact.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import type { AsPropType } from '../../types/prop-types.js'; import { clsx } from '../../styles/clsx.js'; @@ -21,6 +21,7 @@ import { clsx } from '../../styles/clsx.js'; import classes from './Compact.module.css'; export interface CompactProps extends HTMLAttributes { + ref?: Ref; /** * Choose from 3 font sizes. Default `m`. */ @@ -52,18 +53,16 @@ export interface CompactProps extends HTMLAttributes { * The Compact component is used to present the core textual content * to our users. */ -export const Compact = forwardRef( - ( - { - className, - as: Element = 'p', - size = 'm', - weight = 'regular', - color = 'normal', - ...props - }, - ref, - ) => ( +export function Compact({ + className, + as: Element = 'p', + size = 'm', + weight = 'regular', + color = 'normal', + ref, + ...props +}: CompactProps) { + return ( ( className, )} /> - ), -); - -Compact.displayName = 'Compact'; + ); +} diff --git a/packages/circuit-ui/components/ComparisonTable/ComparisonTable.tsx b/packages/circuit-ui/components/ComparisonTable/ComparisonTable.tsx index 92c1407bd3..63e03556d8 100644 --- a/packages/circuit-ui/components/ComparisonTable/ComparisonTable.tsx +++ b/packages/circuit-ui/components/ComparisonTable/ComparisonTable.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useCallback, useState } from 'react'; +import { useCallback, useState, type Ref } from 'react'; import { AccessibilityError, @@ -35,93 +35,88 @@ import classes from './ComparisonTable.module.css'; export interface ComparisonTableProps extends Omit, - Pick {} + Pick { + ref?: Ref; +} -export const ComparisonTable = forwardRef< - HTMLTableElement, - ComparisonTableProps ->( - ( - { - caption, - sections, - headers, - showAllFeaturesLabel, - selectFirstPlanLabel, - selectSecondPlanLabel, - className, - ...props - }, - ref, - ) => { - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' - ) { - if (!isSufficientlyLabelled(showAllFeaturesLabel)) { - throw new AccessibilityError( - 'ComparisonTable', - 'The `showAllFeaturesLabel` prop is missing or invalid.', - ); - } - if (!isSufficientlyLabelled(caption)) { - throw new AccessibilityError( - 'ComparisonTable', - 'The `caption` prop is missing or invalid.', - ); - } - if (!isSufficientlyLabelled(selectFirstPlanLabel)) { - throw new AccessibilityError( - 'ComparisonTable', - 'The `selectFirstPlanLabel` prop is missing or invalid.', - ); - } - if (!isSufficientlyLabelled(selectSecondPlanLabel)) { - throw new AccessibilityError( - 'ComparisonTable', - 'The `selectSecondPlanLabel` prop is missing or invalid.', - ); - } +export function ComparisonTable({ + caption, + sections, + headers, + showAllFeaturesLabel, + selectFirstPlanLabel, + selectSecondPlanLabel, + className, + ref, + ...props +}: ComparisonTableProps) { + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' + ) { + if (!isSufficientlyLabelled(showAllFeaturesLabel)) { + throw new AccessibilityError( + 'ComparisonTable', + 'The `showAllFeaturesLabel` prop is missing or invalid.', + ); } + if (!isSufficientlyLabelled(caption)) { + throw new AccessibilityError( + 'ComparisonTable', + 'The `caption` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(selectFirstPlanLabel)) { + throw new AccessibilityError( + 'ComparisonTable', + 'The `selectFirstPlanLabel` prop is missing or invalid.', + ); + } + if (!isSufficientlyLabelled(selectSecondPlanLabel)) { + throw new AccessibilityError( + 'ComparisonTable', + 'The `selectSecondPlanLabel` prop is missing or invalid.', + ); + } + } - const [activePlans, setActivePlans] = useState([ - headers[0].id, - headers[1].id, - ]); + const [activePlans, setActivePlans] = useState([ + headers[0].id, + headers[1].id, + ]); - const planOptions = headers.map((plan) => ({ - label: plan.title, - value: plan.id, - })); + const planOptions = headers.map((plan) => ({ + label: plan.title, + value: plan.id, + })); - const onPlanSelect = useCallback((value: string[]) => { - setActivePlans(value); - }, []); + const onPlanSelect = useCallback((value: string[]) => { + setActivePlans(value); + }, []); - return ( -
    - {headers.length >= 3 && ( - - )} - id === activePlans[0]), - headers.findIndex(({ id }) => id === activePlans[1]), - ]} - showAllFeaturesLabel={showAllFeaturesLabel} + return ( +
    + {headers.length >= 3 && ( + -
    - ); - }, -); + )} + id === activePlans[0]), + headers.findIndex(({ id }) => id === activePlans[1]), + ]} + showAllFeaturesLabel={showAllFeaturesLabel} + /> +
    + ); +} diff --git a/packages/circuit-ui/components/ComparisonTable/components/PlanTable/PlanTable.tsx b/packages/circuit-ui/components/ComparisonTable/components/PlanTable/PlanTable.tsx index ff5c9961fb..4342581803 100644 --- a/packages/circuit-ui/components/ComparisonTable/components/PlanTable/PlanTable.tsx +++ b/packages/circuit-ui/components/ComparisonTable/components/PlanTable/PlanTable.tsx @@ -16,8 +16,8 @@ 'use client'; import { - forwardRef, type HTMLAttributes, + type Ref, useCallback, useEffect, useMemo, @@ -64,6 +64,7 @@ export interface FeatureSection { } export interface PlanTableProps extends HTMLAttributes { + ref?: Ref; /** * A descriptive caption for the table. */ @@ -88,182 +89,180 @@ export interface PlanTableProps extends HTMLAttributes { activePlans: number[]; } -export const PlanTable = forwardRef( - ( - { caption, showAllFeaturesLabel, headers, sections, activePlans, ...props }, - ref, - ) => { - const tableRef = useRef(null); - const theadRef = useRef(null); - const isMobile = useMedia('(max-width: 767px)', true); - const isTablet = useMedia( - '(max-width: 767px) and (min-width: 480px)', - true, - ); - const [isCollapsed, setIsCollapsed] = useState( - sections.reduce( - // the table is collapsed if the total number of rows is greater than the threshold, section titles included. - (totalRows, section) => totalRows + section.features.length + 1, - 0, - ) > COLLAPSE_THRESHOLD, - ); - const [sectionOffset, setSectionOffset] = useState(0); - const isPlanPickerVisible = headers.length > 2; +export function PlanTable({ + caption, + showAllFeaturesLabel, + headers, + sections, + activePlans, + ref, + ...props +}: PlanTableProps) { + const tableRef = useRef(null); + const theadRef = useRef(null); + const isMobile = useMedia('(max-width: 767px)', true); + const isTablet = useMedia('(max-width: 767px) and (min-width: 480px)', true); + const [isCollapsed, setIsCollapsed] = useState( + sections.reduce( + // the table is collapsed if the total number of rows is greater than the threshold, section titles included. + (totalRows, section) => totalRows + section.features.length + 1, + 0, + ) > COLLAPSE_THRESHOLD, + ); + const [sectionOffset, setSectionOffset] = useState(0); + const isPlanPickerVisible = headers.length > 2; - useEffect(() => { - const tableHeaderElement = theadRef.current; - const tableElement = tableRef.current; - if ( - !tableHeaderElement || - typeof ResizeObserver === 'undefined' || - !tableElement - ) { - return undefined; - } + useEffect(() => { + const tableHeaderElement = theadRef.current; + const tableElement = tableRef.current; + if ( + !tableHeaderElement || + typeof ResizeObserver === 'undefined' || + !tableElement + ) { + return undefined; + } - // opt for progressive enhancement - // eslint-disable-next-line compat/compat - const headerSizeObserver = new ResizeObserver((entries) => { - /* account for sticky plan picker on mobile */ - const planPickerHeight = isPlanPickerVisible - ? (isMobile ? 80 : 0) + (isTablet ? 16 : 0) - : 0; - /* account for sticky top navigation, if it exists */ - const topNavigationHeight = Number( - getComputedStyle(tableElement) - .getPropertyValue('--top-navigation-height') - .replace('px', '') ?? 0, - ); - setSectionOffset( - entries[0].contentRect.height + - planPickerHeight + - topNavigationHeight, - ); - }); + // opt for progressive enhancement + // eslint-disable-next-line compat/compat + const headerSizeObserver = new ResizeObserver((entries) => { + /* account for sticky plan picker on mobile */ + const planPickerHeight = isPlanPickerVisible + ? (isMobile ? 80 : 0) + (isTablet ? 16 : 0) + : 0; + /* account for sticky top navigation, if it exists */ + const topNavigationHeight = Number( + getComputedStyle(tableElement) + .getPropertyValue('--top-navigation-height') + .replace('px', '') ?? 0, + ); + setSectionOffset( + entries[0].contentRect.height + planPickerHeight + topNavigationHeight, + ); + }); - headerSizeObserver.observe(tableHeaderElement); - return () => { - headerSizeObserver.unobserve(tableHeaderElement); - }; - }, [isPlanPickerVisible, isMobile, isTablet]); + headerSizeObserver.observe(tableHeaderElement); + return () => { + headerSizeObserver.unobserve(tableHeaderElement); + }; + }, [isPlanPickerVisible, isMobile, isTablet]); - const showFeatures = useCallback(() => { - setIsCollapsed(false); - }, []); + const showFeatures = useCallback(() => { + setIsCollapsed(false); + }, []); - useEffect(() => { - if (!isCollapsed) { - // set focus to the first row of the added rows when the table is expanded - tableRef.current - ?.querySelectorAll('tr') - ?.[COLLAPSE_THRESHOLD + 1]?.focus({ preventScroll: true }); - } - }, [isCollapsed]); + useEffect(() => { + if (!isCollapsed) { + // set focus to the first row of the added rows when the table is expanded + tableRef.current + ?.querySelectorAll('tr') + ?.[COLLAPSE_THRESHOLD + 1]?.focus({ preventScroll: true }); + } + }, [isCollapsed]); - const sectionsToDisplay = useMemo( - () => - isCollapsed ? getFirstNRows(sections, COLLAPSE_THRESHOLD) : sections, - [sections, isCollapsed], - ); + const sectionsToDisplay = useMemo( + () => + isCollapsed ? getFirstNRows(sections, COLLAPSE_THRESHOLD) : sections, + [sections, isCollapsed], + ); - const headersToDisplay = useMemo( - () => (isMobile ? generateFromIndex(headers, activePlans) : headers), - [headers, activePlans, isMobile], - ); + const headersToDisplay = useMemo( + () => (isMobile ? generateFromIndex(headers, activePlans) : headers), + [headers, activePlans, isMobile], + ); - return ( -
    -
- - - - {(isMobile ? activePlans : headers).map((_, index) => ( - + return ( +
+
- {caption} -
+ + + + {(isMobile ? activePlans : headers).map((_, index) => ( + + ))} + + + + - - - + + - {sectionsToDisplay.map((row, sectionIndex) => ( - - - - - {row.features.map((feature) => { - const featureId = feature.featureDescription.label.replace( - /\s+/g, - '', - ); - return ( - - - {feature.featureDescription.label} - - {(isMobile - ? generateFromIndex(feature.values, activePlans) - : feature.values - ).map((value, index) => ( - - ))} - - ); - })} - - ))} -
+ {caption} +
+ {headersToDisplay.map((plan, index) => ( + 0 && classes.border, + isPlanPickerVisible && classes.offset, + )} + /> ))} - -
- {headersToDisplay.map((plan, index) => ( - 0 && classes.border, - isPlanPickerVisible && classes.offset, - )} - /> - ))} -
- - {row.title} - -
- {isCollapsed && ( -
- -
- )} -
- ); - }, -); + {sectionsToDisplay.map((row, sectionIndex) => ( + + + + + {row.title} + + + + {row.features.map((feature) => { + const featureId = feature.featureDescription.label.replace( + /\s+/g, + '', + ); + return ( + + + {feature.featureDescription.label} + + {(isMobile + ? generateFromIndex(feature.values, activePlans) + : feature.values + ).map((value, index) => ( + + ))} + + ); + })} + + ))} + + {isCollapsed && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/circuit-ui/components/ComponentsContext/components/Link/Link.tsx b/packages/circuit-ui/components/ComponentsContext/components/Link/Link.tsx index 78649c434d..99b3b27c89 100644 --- a/packages/circuit-ui/components/ComponentsContext/components/Link/Link.tsx +++ b/packages/circuit-ui/components/ComponentsContext/components/Link/Link.tsx @@ -13,19 +13,19 @@ * limitations under the License. */ -import { forwardRef, type AnchorHTMLAttributes } from 'react'; +import type { AnchorHTMLAttributes, Ref } from 'react'; -export type LinkProps = AnchorHTMLAttributes; +export type LinkProps = AnchorHTMLAttributes & { + ref?: Ref; +}; /** * A barebones Link component that's basically just an `` tag */ -export const Link = forwardRef( - ({ children, ...props }, ref) => ( +export function Link({ children, ref, ...props }: LinkProps) { + return ( {children} - ), -); - -Link.displayName = 'Link'; + ); +} diff --git a/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx b/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx index bd894a3eec..509787d62f 100644 --- a/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx +++ b/packages/circuit-ui/components/CurrencyInput/CurrencyInput.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useId } from 'react'; +import { useId, type Ref } from 'react'; import { resolveCurrencyFormat } from '@sumup-oss/intl'; import { NumericFormat } from '../../vendor/react-number-format/index.js'; @@ -34,6 +34,7 @@ export interface CurrencyInputProps InputProps, 'placeholder' | 'ref' | 'value' | 'defaultValue' | 'type' > { + ref?: Ref; /** * A ISO 4217 currency code, such as 'USD' for the US dollar, * 'EUR' for the Euro, or 'CNY' for the Chinese RMB. @@ -80,92 +81,86 @@ const DUMMY_DELIMITER = '?'; * the symbol according to the locale. The corresponding service exports a * parser for formatting values automatically. */ -export const CurrencyInput = forwardRef( - ( - { - locale: customLocale, - currency, - placeholder, - 'aria-describedby': descriptionId, - ...props - }, - ref, - ) => { - const locale = useLocale(customLocale); - const currencySymbolId = useId(); - const descriptionIds = idx(currencySymbolId, descriptionId); +export function CurrencyInput({ + locale: customLocale, + currency, + placeholder, + 'aria-describedby': descriptionId, + ref, + ...props +}: CurrencyInputProps) { + const locale = useLocale(customLocale); + const currencySymbolId = useId(); + const descriptionIds = idx(currencySymbolId, descriptionId); - const currencyFormat = - resolveCurrencyFormat(locale, currency) || DEFAULT_FORMAT; - const { - currencyPosition, - currencySymbol, - minimumFractionDigits, - maximumFractionDigits, - decimalDelimiter, - groupDelimiter, - } = currencyFormat; - const placeholderString = formatPlaceholder(placeholder, locale, { - minimumFractionDigits, - maximumFractionDigits, - }); - // Allow common decimal signs as well as the one from resolveCurrencyFormat() - const allowedDecimalSeparators = [ - '.', - ',', - ...(decimalDelimiter ? [decimalDelimiter] : []), - ]; + const currencyFormat = + resolveCurrencyFormat(locale, currency) || DEFAULT_FORMAT; + const { + currencyPosition, + currencySymbol, + minimumFractionDigits, + maximumFractionDigits, + decimalDelimiter, + groupDelimiter, + } = currencyFormat; + const placeholderString = formatPlaceholder(placeholder, locale, { + minimumFractionDigits, + maximumFractionDigits, + }); + // Allow common decimal signs as well as the one from resolveCurrencyFormat() + const allowedDecimalSeparators = [ + '.', + ',', + ...(decimalDelimiter ? [decimalDelimiter] : []), + ]; - const renderPrefix = - currencyPosition === 'prefix' - ? (prefixProps: { className?: string }) => ( - - {currencySymbol} - - ) - : undefined; + const renderPrefix = + currencyPosition === 'prefix' + ? (prefixProps: { className?: string }) => ( + + {currencySymbol} + + ) + : undefined; - const renderSuffix = - currencyPosition === 'suffix' - ? (suffixProps: { className?: string }) => ( - - {currencySymbol} - - ) - : undefined; + const renderSuffix = + currencyPosition === 'suffix' + ? (suffixProps: { className?: string }) => ( + + {currencySymbol} + + ) + : undefined; - return ( - 0 - ? decimalDelimiter - : DUMMY_DELIMITER - } - decimalScale={maximumFractionDigits} - customInput={Input} - getInputRef={ref} - allowedDecimalSeparators={allowedDecimalSeparators} - // Circuit input props - renderPrefix={renderPrefix} - renderSuffix={renderSuffix} - placeholder={placeholderString} - textAlign="right" - type="text" - inputMode="decimal" - aria-describedby={descriptionIds} - {...props} - /> - ); - }, -); - -CurrencyInput.displayName = 'CurrencyInput'; + return ( + 0 + ? decimalDelimiter + : DUMMY_DELIMITER + } + decimalScale={maximumFractionDigits} + customInput={Input} + getInputRef={ref} + allowedDecimalSeparators={allowedDecimalSeparators} + // Circuit input props + renderPrefix={renderPrefix} + renderSuffix={renderSuffix} + placeholder={placeholderString} + textAlign="right" + type="text" + inputMode="decimal" + aria-describedby={descriptionIds} + {...props} + /> + ); +} diff --git a/packages/circuit-ui/components/DateInput/DateInput.tsx b/packages/circuit-ui/components/DateInput/DateInput.tsx index a45eb43121..0ea5a00b53 100644 --- a/packages/circuit-ui/components/DateInput/DateInput.tsx +++ b/packages/circuit-ui/components/DateInput/DateInput.tsx @@ -16,11 +16,11 @@ 'use client'; import { - forwardRef, useId, useRef, useState, type InputHTMLAttributes, + type Ref, useCallback, } from 'react'; import type { Temporal } from 'temporal-polyfill'; @@ -145,331 +145,320 @@ export interface DateInputProps * @default m */ size?: FieldSize; + ref?: Ref; } /** * The DateInput component allows users to type or select a specific date. * The input value is always a string in the format `YYYY-MM-DD`. */ -export const DateInput = forwardRef( - (props, ref) => { - const { - label, - value, - defaultValue, - min, - max, - locale, - firstDayOfWeek, - modifiers, - hideLabel, - required, - disabled, - readOnly, - invalid, - hasWarning, - showValid, - validationHint, - 'aria-describedby': descriptionId, - optionalLabel, - openCalendarButtonLabel, - closeCalendarButtonLabel, - applyDateButtonLabel, - clearDateButtonLabel, - prevMonthButtonLabel, - nextMonthButtonLabel, - yearInputLabel, - monthInputLabel, - dayInputLabel, - autoComplete, - placement = 'bottom-end', - className, - style, - size = 'm', - ...rest - } = useI18n(props, translations); - const isMobile = useMedia('(max-width: 479px)'); +export function DateInput({ ref, ...props }: DateInputProps) { + const { + label, + value, + defaultValue, + min, + max, + locale, + firstDayOfWeek, + modifiers, + hideLabel, + required, + disabled, + readOnly, + invalid, + hasWarning, + showValid, + validationHint, + 'aria-describedby': descriptionId, + optionalLabel, + openCalendarButtonLabel, + closeCalendarButtonLabel, + applyDateButtonLabel, + clearDateButtonLabel, + prevMonthButtonLabel, + nextMonthButtonLabel, + yearInputLabel, + monthInputLabel, + dayInputLabel, + autoComplete, + placement = 'bottom-end', + className, + style, + size = 'm', + ...rest + } = useI18n(props, translations); + const isMobile = useMedia('(max-width: 479px)'); - const inputRef = useRef(null); - const calendarButtonRef = useRef(null); - const dialogRef = useRef(null); + const inputRef = useRef(null); + const dialogRef = useRef(null); - const dialogId = useId(); - const headlineId = useId(); - const validationHintId = useId(); + const dialogId = useId(); + const headlineId = useId(); + const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); - const minDate = toPlainDate(min); - const maxDate = toPlainDate(max); - - const handleChange = (newValue: string) => { - changeInputValue(inputRef.current, newValue); - }; + const descriptionIds = idx(descriptionId, validationHint && validationHintId); + const minDate = toPlainDate(min); + const maxDate = toPlainDate(max); - const focus = useSegmentFocus(); - const state = usePlainDateState({ - value, - defaultValue, - onChange: handleChange, - minDate, - maxDate, - locale, - }); + const handleChange = (newValue: string) => { + changeInputValue(inputRef.current, newValue); + }; - const [open, setOpen] = useState(false); - const [selection, setSelection] = useState(); + const focus = useSegmentFocus(); + const state = usePlainDateState({ + value, + defaultValue, + onChange: handleChange, + minDate, + maxDate, + locale, + }); - // Focus the first date segment when clicking anywhere on the field... - const handleClick = (event: ClickEvent) => { - const element = event.target as HTMLElement; - // ...except when clicking on a specific segment input. - if (element.getAttribute('role') === 'spinbutton') { - return; - } - focus.next(); - }; + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState(); - const closeCalendar = useCallback(() => { - setOpen(false); - }, []); + // Focus the first date segment when clicking anywhere on the field... + const handleClick = (event: ClickEvent) => { + const element = event.target as HTMLElement; + // ...except when clicking on a specific segment input. + if (element.getAttribute('role') === 'spinbutton') { + return; + } + focus.next(); + }; - const handleSelect = (date: Temporal.PlainDate) => { - setSelection(date); + const closeCalendar = useCallback(() => { + setOpen(false); + }, []); - if (!isMobile) { - const { year, month, day } = date; - state.update({ year, month, day }); - closeCalendar(); - } - }; + const handleSelect = (date: Temporal.PlainDate) => { + setSelection(date); - const handleApply = () => { - if (selection) { - const { year, month, day } = selection; - state.update({ year, month, day }); - } + if (!isMobile) { + const { year, month, day } = date; + state.update({ year, month, day }); closeCalendar(); - }; + } + }; - const handleClear = () => { - state.update({ year: '', month: '', day: '' }); - closeCalendar(); - }; + const handleApply = () => { + if (selection) { + const { year, month, day } = selection; + state.update({ year, month, day }); + } + closeCalendar(); + }; + + const handleClear = () => { + state.update({ year: '', month: '', day: '' }); + closeCalendar(); + }; + + const segments = getDateSegments(locale); + const calendarButtonLabel = getCalendarButtonLabel( + openCalendarButtonLabel, + state.date, + locale, + ); - const segments = getDateSegments(locale); - const calendarButtonLabel = getCalendarButtonLabel( - openCalendarButtonLabel, - state.date, - locale, + if (process.env.NODE_ENV !== 'production' && !isSufficientlyLabelled(label)) { + throw new AccessibilityError( + 'DateInput', + 'The `label` prop is missing or invalid.', ); + } - if ( - process.env.NODE_ENV !== 'production' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'DateInput', - 'The `label` prop is missing or invalid.', - ); - } + return ( + +
+ + + +
+ + {/** biome-ignore lint/a11y/noStaticElementInteractions: Progressive enhancement */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: This element isn't keyboard-focusable */} +
+ {segments.map((segment, index) => { + const segmentProps = { + required, + invalid, + disabled, + readOnly, + focus, + // Only the first segment should be associated with the validation hint to reduce verbosity. + 'aria-describedby': index === 0 ? descriptionIds : undefined, + }; + switch (segment.type) { + case 'year': + return ( + + ); + case 'month': + return ( + + ); + case 'day': + return ( + + ); + case 'literal': + return ( + + ); + default: + return null; + } + })} +
+ ( + { + setSelection(state.date); + btnProps.onClick?.(event); + }} + > + {calendarButtonLabel} + + )} + > +
+ + {label} + +
- return ( - -
- - - -
- - {/** biome-ignore lint/a11y/noStaticElementInteractions: Progressive enhancement */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: This element isn't keyboard-focusable */} -
- {segments.map((segment, index) => { - const segmentProps = { - required, - invalid, - disabled, - readOnly, - focus, - // Only the first segment should be associated with the validation hint to reduce verbosity. - 'aria-describedby': index === 0 ? descriptionIds : undefined, - }; - switch (segment.type) { - case 'year': - return ( - - ); - case 'month': - return ( - - ); - case 'day': - return ( - - ); - case 'literal': - return ( - - ); - default: - return null; - } - })} -
- ( - { - setSelection(state.date); - btnProps.onClick?.(event); - }} - > - {calendarButtonLabel} - - )} - > -
- - {label} - -
- - + firstDayOfWeek={firstDayOfWeek} + modifiers={modifiers} + prevMonthButtonLabel={prevMonthButtonLabel} + nextMonthButtonLabel={nextMonthButtonLabel} + /> - {(!required || isMobile) && ( -
- {!required && ( - - )} + {(!required || isMobile) && ( +
+ {!required && ( -
- )} - -
- -
-
- ); - }, -); - -DateInput.displayName = 'DateInput'; + )} + +
+ )} + +
+ + + + ); +} diff --git a/packages/circuit-ui/components/Dialog/Dialog.tsx b/packages/circuit-ui/components/Dialog/Dialog.tsx index 12b92d1d03..db53f3c0a8 100644 --- a/packages/circuit-ui/components/Dialog/Dialog.tsx +++ b/packages/circuit-ui/components/Dialog/Dialog.tsx @@ -16,9 +16,9 @@ 'use client'; import { - forwardRef, type HTMLAttributes, type ReactNode, + type Ref, type RefObject, useCallback, useEffect, @@ -46,6 +46,7 @@ type DataAttribute = `data-${string}`; export interface PublicDialogProps extends Omit, 'children'> { + ref?: Ref; /** * Whether the modal dialog is open or not. Learn more about the `dialog` api https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/open. */ @@ -118,349 +119,347 @@ export interface DialogProps extends PublicDialogProps { | RefObject[]; } -export const Dialog = forwardRef( - (props, ref) => { - const { - open, - isModal = false, - children, - onCloseEnd, - closeButtonLabel, - className, - preventOutsideClickRefs, - preventOutsideClickClose = false, - hideCloseButton = false, - preventEscapeKeyClose = false, - animationDuration = 0, - onCloseStart, - locale, - style, - ...rest - } = useI18n(props, translations); - const dialogRef = useRef(null); - const openRef = useLatest(open); - const isModalRef = useLatest(isModal); - const animationDurationRef = useLatest(animationDuration); - const lastFocusedElementRef = useRef(null); - const [isClosing, setIsClosing] = useState(false); - - // Focus Management - useEffect(() => { - // save the opening element to restore focus after the dialog closes - if (open && document.activeElement instanceof HTMLElement) { - lastFocusedElementRef.current = document.activeElement; - } - return () => { - // restore focus to the opening element - if ( - !dialogRef.current?.contains(document.activeElement) && - document.body !== document.activeElement - ) { - return; - } - if (lastFocusedElementRef.current) { - setTimeout( - () => lastFocusedElementRef.current?.focus(), - animationDurationRef.current, - ); - } - }; - }, [open, animationDurationRef]); - - // Component opening/closing logic - const handleDialogClose = useCallback(() => { - const dialogElement = dialogRef.current; - if (!dialogElement) { +export function Dialog(props: DialogProps) { + const { + ref, + open, + isModal = false, + children, + onCloseEnd, + closeButtonLabel, + className, + preventOutsideClickRefs, + preventOutsideClickClose = false, + hideCloseButton = false, + preventEscapeKeyClose = false, + animationDuration = 0, + onCloseStart, + locale, + style, + ...rest + } = useI18n(props, translations); + const dialogRef = useRef(null); + const openRef = useLatest(open); + const isModalRef = useLatest(isModal); + const animationDurationRef = useLatest(animationDuration); + const lastFocusedElementRef = useRef(null); + const [isClosing, setIsClosing] = useState(false); + + // Focus Management + useEffect(() => { + // save the opening element to restore focus after the dialog closes + if (open && document.activeElement instanceof HTMLElement) { + lastFocusedElementRef.current = document.activeElement; + } + return () => { + // restore focus to the opening element + if ( + !dialogRef.current?.contains(document.activeElement) && + document.body !== document.activeElement + ) { return; } - onCloseStart?.(); - setIsClosing(true); - // trigger closing of the dialog after animation - setTimeout(() => { - if (dialogElement.open) { - dialogElement.close(); - setIsClosing(false); - } - }, animationDurationRef.current); - }, [animationDurationRef, onCloseStart]); - - useEffect(() => { - // register the dialog element with the polyfill - if (dialogRef.current) { - dialogPolyfill.registerDialog(dialogRef.current); + if (lastFocusedElementRef.current) { + setTimeout( + () => lastFocusedElementRef.current?.focus(), + animationDurationRef.current, + ); } - }, []); - - useEffect(() => { - const dialogElement = dialogRef.current; - - if (!dialogElement) { - return undefined; + }; + }, [open, animationDurationRef]); + + // Component opening/closing logic + const handleDialogClose = useCallback(() => { + const dialogElement = dialogRef.current; + if (!dialogElement) { + return; + } + onCloseStart?.(); + setIsClosing(true); + // trigger closing of the dialog after animation + setTimeout(() => { + if (dialogElement.open) { + dialogElement.close(); + setIsClosing(false); + } + }, animationDurationRef.current); + }, [animationDurationRef, onCloseStart]); + + useEffect(() => { + // register the dialog element with the polyfill + if (dialogRef.current) { + dialogPolyfill.registerDialog(dialogRef.current); + } + }, []); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return undefined; + } + + if (!openRef.current) { + dialogElement.returnValue = ''; + } + if (openRef.current && !dialogElement.open) { + if (isModal) { + dialogElement.showModal(); + } else { + dialogElement.show(); } + } - if (!openRef.current) { - dialogElement.returnValue = ''; + return () => { + if (dialogElement.open) { + dialogElement.close('skipOnClose'); } - if (openRef.current && !dialogElement.open) { - if (isModal) { - dialogElement.showModal(); - } else { - dialogElement.show(); - } + }; + }, [isModal, openRef]); + + useEffect(() => { + const dialogElement = dialogRef.current; + + if (!dialogElement) { + return; + } + + if (!open) { + dialogElement.returnValue = ''; + } + if (open && !dialogElement.open) { + if (isModalRef.current) { + dialogElement.showModal(); + } else { + dialogElement.show(); } + } + }, [isModalRef, open]); - return () => { - if (dialogElement.open) { - dialogElement.close('skipOnClose'); - } - }; - }, [isModal, openRef]); - - useEffect(() => { - const dialogElement = dialogRef.current; + useEffect(() => { + const dialogElement = dialogRef.current; - if (!dialogElement) { - return; + if (!dialogElement) { + return; + } + if (!open && dialogElement.open) { + handleDialogClose(); + } + }, [open, handleDialogClose]); + + useEffect(() => { + const dialogElement = dialogRef.current; + if (!dialogElement) { + return undefined; + } + + const handleClose = () => { + // the effect assigning the 'close' event listener to the dialog + // causes the `onCloseEnd` callback to be called if the effect's + // dependencies change. + // To avoid that we set the dialog's returnValue to 'skipOnClose' + // in the effect's cleanup fn and reset it afterward. + if (dialogElement?.returnValue !== 'skipOnClose') { + onCloseEnd?.(); } - - if (!open) { + if (dialogElement?.returnValue === 'skipOnClose') { dialogElement.returnValue = ''; } - if (open && !dialogElement.open) { - if (isModalRef.current) { - dialogElement.showModal(); - } else { - dialogElement.show(); - } - } - }, [isModalRef, open]); - - useEffect(() => { - const dialogElement = dialogRef.current; - - if (!dialogElement) { + }; + + if (onCloseEnd) { + dialogElement.addEventListener('close', handleClose); + } + + return () => { + dialogElement.removeEventListener('close', handleClose); + }; + }, [onCloseEnd]); + + // DOM manipulation and event handling + useScrollLock(open && isModal); + + useEffect(() => { + const dialogElement = dialogRef.current; + if (!preventEscapeKeyClose) { + return undefined; + } + const handleCancel = (event: Event) => { + event.preventDefault(); // Prevent dialog from closing + }; + if (dialogElement) { + dialogElement.addEventListener('cancel', handleCancel); + } + return () => dialogElement?.removeEventListener('cancel', handleCancel); + }, [preventEscapeKeyClose]); + + const handleEscapeKey = useCallback( + (e: Event) => { + // get all potential dialog elements in this dialog's composed path + // and check if it is topmost dialog . + const isTopMostDialog = + e + .composedPath() + .filter((el) => el instanceof Node && el.nodeName === 'DIALOG')[0] === + dialogRef.current; + if ( + // if the event happened on another dialog element that is not a child of the current dialog, we don't want to close it + !dialogRef.current?.contains(e.target as Node) || + // if the dialog is not the topmost dialog, we don't want to close it + !isTopMostDialog + ) { return; } - if (!open && dialogElement.open) { - handleDialogClose(); - } - }, [open, handleDialogClose]); + e.preventDefault(); + handleDialogClose(); + }, + [handleDialogClose], + ); - useEffect(() => { - const dialogElement = dialogRef.current; - if (!dialogElement) { - return undefined; - } + useEscapeKey(handleEscapeKey, open && !preventEscapeKeyClose); - const handleClose = () => { - // the effect assigning the 'close' event listener to the dialog - // causes the `onCloseEnd` callback to be called if the effect's - // dependencies change. - // To avoid that we set the dialog's returnValue to 'skipOnClose' - // in the effect's cleanup fn and reset it afterward. - if (dialogElement?.returnValue !== 'skipOnClose') { - onCloseEnd?.(); - } - if (dialogElement?.returnValue === 'skipOnClose') { - dialogElement.returnValue = ''; - } - }; - - if (onCloseEnd) { - dialogElement.addEventListener('close', handleClose); + useEffect( + () => () => { + if (dialogRef.current?.open) { + dialogRef.current?.close(); } - - return () => { - dialogElement.removeEventListener('close', handleClose); - }; - }, [onCloseEnd]); - - // DOM manipulation and event handling - useScrollLock(open && isModal); - - useEffect(() => { - const dialogElement = dialogRef.current; - if (!preventEscapeKeyClose) { - return undefined; + }, + [], + ); + + const handleSwipe = useCallback( + (direction: string) => { + const isScrolledToTop = dialogRef?.current?.scrollTop === 0; + if ( + isScrolledToTop && + open && + isModal && + direction === 'down' && + !hideCloseButton && + !preventOutsideClickClose + ) { + handleDialogClose(); } - const handleCancel = (event: Event) => { - event.preventDefault(); // Prevent dialog from closing - }; - if (dialogElement) { - dialogElement.addEventListener('cancel', handleCancel); + }, + [ + isModal, + open, + handleDialogClose, + hideCloseButton, + preventOutsideClickClose, + ], + ); + + const eventHandlers = useSwipe(handleSwipe, 300); + + const handleOutsideClick = useCallback(() => { + lastFocusedElementRef.current = null; + handleDialogClose(); + }, [handleDialogClose]); + + const useClickOutsideRefs = preventOutsideClickRefs + ? // eslint-disable-next-line compat/compat + [dialogRef, preventOutsideClickRefs].flat() + : [dialogRef]; + + useClickOutside( + useClickOutsideRefs, + handleOutsideClick, + open && !preventOutsideClickClose, + ); + + const onPolyfillBackdropClick = useCallback( + (event: MouseEvent) => { + if (preventOutsideClickClose) { + event.preventDefault(); } - return () => dialogElement?.removeEventListener('cancel', handleCancel); - }, [preventEscapeKeyClose]); - - const handleEscapeKey = useCallback( - (e: Event) => { - // get all potential dialog elements in this dialog's composed path - // and check if it is topmost dialog . - const isTopMostDialog = - e - .composedPath() - .filter( - (el) => el instanceof Node && el.nodeName === 'DIALOG', - )[0] === dialogRef.current; - if ( - // if the event happened on another dialog element that is not a child of the current dialog, we don't want to close it - !dialogRef.current?.contains(e.target as Node) || - // if the dialog is not the topmost dialog, we don't want to close it - !isTopMostDialog - ) { - return; - } - e.preventDefault(); - handleDialogClose(); - }, - [handleDialogClose], - ); - - useEscapeKey(handleEscapeKey, open && !preventEscapeKeyClose); + }, + [preventOutsideClickClose], + ); + + useEffect(() => { + // eslint-disable-next-line compat/compat + const hasNativeDialog = window.HTMLDialogElement !== undefined; + const dialogElement = dialogRef.current; + if (!dialogElement || hasNativeDialog) { + return undefined; + } + if (open && !hasNativeDialog && dialogElement?.nextSibling) { + // use the polyfill backdrop + (dialogElement.nextSibling as HTMLDivElement).classList.add( + classes.backdrop, + ); + // intercept and prevent modal closing if preventClose is true + (dialogElement.nextSibling as HTMLDivElement).addEventListener( + 'click', + onPolyfillBackdropClick, + ); + } + return () => { + (dialogElement?.nextSibling as HTMLDivElement)?.removeEventListener( + 'click', + onPolyfillBackdropClick, + ); + }; + }, [open, onPolyfillBackdropClick]); + + const onDialogClick = useCallback( + (event: ReactMouseEvent) => { + if (!preventOutsideClickClose) { + const isTargetChildNode = dialogRef.current?.contains( + event.target as Node, + ); - useEffect( - () => () => { - if (dialogRef.current?.open) { - dialogRef.current?.close(); + let isWithingDialogBoundingRect = false; + const rect = dialogRef.current?.getBoundingClientRect(); + if (rect) { + isWithingDialogBoundingRect = + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width; } - }, - [], - ); + const isBackdropClick = + event.target === dialogRef.current && !isWithingDialogBoundingRect; - const handleSwipe = useCallback( - (direction: string) => { - const isScrolledToTop = dialogRef?.current?.scrollTop === 0; if ( - isScrolledToTop && - open && - isModal && - direction === 'down' && - !hideCloseButton && - !preventOutsideClickClose + // if the click happened outside the dialog by an element that is not a child of the dialog + (!isWithingDialogBoundingRect && !isTargetChildNode) || + // if the click happened on the backdrop + isBackdropClick ) { handleDialogClose(); } - }, - [ - isModal, - open, - handleDialogClose, - hideCloseButton, - preventOutsideClickClose, - ], - ); - - const eventHandlers = useSwipe(handleSwipe, 300); - - const handleOutsideClick = useCallback(() => { - lastFocusedElementRef.current = null; - handleDialogClose(); - }, [handleDialogClose]); - - const useClickOutsideRefs = preventOutsideClickRefs - ? // eslint-disable-next-line compat/compat - [dialogRef, preventOutsideClickRefs].flat() - : [dialogRef]; - - useClickOutside( - useClickOutsideRefs, - handleOutsideClick, - open && !preventOutsideClickClose, - ); - - const onPolyfillBackdropClick = useCallback( - (event: MouseEvent) => { - if (preventOutsideClickClose) { - event.preventDefault(); - } - }, - [preventOutsideClickClose], - ); - - useEffect(() => { - // eslint-disable-next-line compat/compat - const hasNativeDialog = window.HTMLDialogElement !== undefined; - const dialogElement = dialogRef.current; - if (!dialogElement || hasNativeDialog) { - return undefined; } - if (open && !hasNativeDialog && dialogElement?.nextSibling) { - // use the polyfill backdrop - (dialogElement.nextSibling as HTMLDivElement).classList.add( - classes.backdrop, - ); - // intercept and prevent modal closing if preventClose is true - (dialogElement.nextSibling as HTMLDivElement).addEventListener( - 'click', - onPolyfillBackdropClick, - ); - } - return () => { - (dialogElement?.nextSibling as HTMLDivElement)?.removeEventListener( - 'click', - onPolyfillBackdropClick, - ); - }; - }, [open, onPolyfillBackdropClick]); - - const onDialogClick = useCallback( - (event: ReactMouseEvent) => { - if (!preventOutsideClickClose) { - const isTargetChildNode = dialogRef.current?.contains( - event.target as Node, - ); - - let isWithingDialogBoundingRect = false; - const rect = dialogRef.current?.getBoundingClientRect(); - if (rect) { - isWithingDialogBoundingRect = - rect.top <= event.clientY && - event.clientY <= rect.top + rect.height && - rect.left <= event.clientX && - event.clientX <= rect.left + rect.width; - } - const isBackdropClick = - event.target === dialogRef.current && !isWithingDialogBoundingRect; - - if ( - // if the click happened outside the dialog by an element that is not a child of the dialog - (!isWithingDialogBoundingRect && !isTargetChildNode) || - // if the click happened on the backdrop - isBackdropClick - ) { - handleDialogClose(); - } - } - }, - [handleDialogClose, preventOutsideClickClose], - ); - - return ( - <> - {/* biome-ignore lint/a11y/useKeyWithClickEvents: The click event captures outside clicks */} - - {(open || isClosing) && - (typeof children === 'function' - ? children?.({ onClose: onCloseEnd }) - : children)} - - {!hideCloseButton && ( - - {closeButtonLabel} - - )} - - - ); - }, -); + }, + [handleDialogClose, preventOutsideClickClose], + ); + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: The click event captures outside clicks */} + + {(open || isClosing) && + (typeof children === 'function' + ? children?.({ onClose: onCloseEnd }) + : children)} + + {!hideCloseButton && ( + + {closeButtonLabel} + + )} + + + ); +} diff --git a/packages/circuit-ui/components/Display/Display.tsx b/packages/circuit-ui/components/Display/Display.tsx index a9f9ec17f4..928d3ff0da 100644 --- a/packages/circuit-ui/components/Display/Display.tsx +++ b/packages/circuit-ui/components/Display/Display.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; import { CircuitError } from '../../util/errors.js'; @@ -23,6 +23,7 @@ import { getEnvVariable } from '../../util/env.js'; import classes from './Display.module.css'; export interface DisplayProps extends HTMLAttributes { + ref?: Ref; /** * Choose from two font weights. * @@ -95,58 +96,59 @@ function getWeight(weight: DisplayProps['weight'], size: DisplayProps['size']) { /** * A flexible title component capable of rendering any HTML heading element. */ -export const Display = forwardRef( - ({ className, as, size: legacySize = 'm', weight, ...props }, ref) => { - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !getEnvVariable('UNSAFE_DISABLE_ELEMENT_ERRORS') && - !as - ) { - throw new CircuitError('Display', 'The `as` prop is required.'); - } - - if ( - process.env.NODE_ENV !== 'production' && - (weight === 'regular' || weight === 'semibold') - ) { - deprecate( - 'Display', - `The "${weight}" weight has been deprecated. Use the "bold" or "black" weights instead.`, - ); - } +export function Display({ + className, + as, + size: legacySize = 'm', + weight, + ...props +}: DisplayProps) { + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !getEnvVariable('UNSAFE_DISABLE_ELEMENT_ERRORS') && + !as + ) { + throw new CircuitError('Display', 'The `as` prop is required.'); + } - if ( - process.env.NODE_ENV !== 'production' && - legacySize in deprecatedSizeMap - ) { - deprecate( - 'Display', - `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, - ); - } + if ( + process.env.NODE_ENV !== 'production' && + (weight === 'regular' || weight === 'semibold') + ) { + deprecate( + 'Display', + `The "${weight}" weight has been deprecated. Use the "bold" or "black" weights instead.`, + ); + } - const Element = as || 'h1'; + if ( + process.env.NODE_ENV !== 'production' && + legacySize in deprecatedSizeMap + ) { + deprecate( + 'Display', + `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, + ); + } - const size = (deprecatedSizeMap[legacySize] || legacySize) as - | 'xl' - | 'l' - | 'm' - | 's'; + const Element = as || 'h1'; - return ( - - ); - }, -); + const size = (deprecatedSizeMap[legacySize] || legacySize) as + | 'xl' + | 'l' + | 'm' + | 's'; -Display.displayName = 'Display'; + return ( + + ); +} diff --git a/packages/circuit-ui/components/Field/Field.tsx b/packages/circuit-ui/components/Field/Field.tsx index c6a76b0bca..ce5a8a7d44 100644 --- a/packages/circuit-ui/components/Field/Field.tsx +++ b/packages/circuit-ui/components/Field/Field.tsx @@ -13,11 +13,11 @@ * limitations under the License. */ -import { - forwardRef, - type FieldsetHTMLAttributes, - type HTMLAttributes, - type LabelHTMLAttributes, +import type { + FieldsetHTMLAttributes, + HTMLAttributes, + LabelHTMLAttributes, + Ref, } from 'react'; import { Confirm, Notify, Alert } from '@sumup-oss/icons'; @@ -33,6 +33,7 @@ import { getFieldValidity } from './FieldService.js'; */ export type FieldSize = 's' | 'm'; interface FieldWrapperProps extends HTMLAttributes { + ref?: Ref; /** * Trigger disabled styles on the component. */ @@ -47,20 +48,26 @@ interface FieldWrapperProps extends HTMLAttributes { /** * @private */ -export const FieldWrapper = forwardRef( - ({ children, disabled, size = 'm', className, ...props }, ref) => ( +export function FieldWrapper({ + children, + disabled, + size = 'm', + className, + ...props +}: FieldWrapperProps) { + return (
{children}
- ), -); + ); +} interface FieldSetProps extends FieldsetHTMLAttributes { + ref?: Ref; /** * The size of the Field component. */ @@ -70,15 +77,14 @@ interface FieldSetProps extends FieldsetHTMLAttributes { /** * @private */ -export const FieldSet = forwardRef( - ({ size = 'm', className, ...props }, ref) => ( +export function FieldSet({ size = 'm', className, ...props }: FieldSetProps) { + return (
- ), -); + ); +} interface FieldLabelProps extends LabelHTMLAttributes { /** diff --git a/packages/circuit-ui/components/Flag/Flag.tsx b/packages/circuit-ui/components/Flag/Flag.tsx index fc00a19351..7b6e47e017 100644 --- a/packages/circuit-ui/components/Flag/Flag.tsx +++ b/packages/circuit-ui/components/Flag/Flag.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { getIconURL, type IconName } from '@sumup-oss/icons'; import { clsx } from '../../styles/clsx.js'; @@ -46,6 +46,7 @@ type Dimensions = }; export type FlagProps = HTMLAttributes & { + ref?: Ref; countryCode: CountryCode; /** * The alt text for the flag image. @@ -66,44 +67,48 @@ const ASPECT_RATIO = 4 / 3; /** * Renders an SVG icon of a flag. Flags are sourced from: https://flagicons.lipis.dev/ */ -export const Flag = forwardRef( - ( - { countryCode, alt, className, imageClassName, width, height, ...props }, - ref, - ) => { - const flagName = `flag_${countryCode.toLowerCase()}` as IconName; - // default dimensions - const dimensions = { - width: 16, - height: 12, - }; - // for a consistent aspect ratio - if (height) { - dimensions.height = height; - dimensions.width = height * ASPECT_RATIO; - } - if (width) { - dimensions.width = width; - dimensions.height = width / ASPECT_RATIO; - } +export function Flag({ + countryCode, + alt, + className, + imageClassName, + width, + height, + ref, + ...props +}: FlagProps) { + const flagName = `flag_${countryCode.toLowerCase()}` as IconName; + // default dimensions + const dimensions = { + width: 16, + height: 12, + }; + // for a consistent aspect ratio + if (height) { + dimensions.height = height; + dimensions.width = height * ASPECT_RATIO; + } + if (width) { + dimensions.width = width; + dimensions.height = width / ASPECT_RATIO; + } - return ( -
- {alt} -
- ); - }, -); + return ( +
+ {alt} +
+ ); +} diff --git a/packages/circuit-ui/components/Hamburger/Hamburger.tsx b/packages/circuit-ui/components/Hamburger/Hamburger.tsx index aaa40bb9ba..8568af51b2 100644 --- a/packages/circuit-ui/components/Hamburger/Hamburger.tsx +++ b/packages/circuit-ui/components/Hamburger/Hamburger.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef } from 'react'; +import type React from 'react'; import { legacyButtonSizeMap } from '../Button/index.js'; import { IconButton, type IconButtonProps } from '../Button/IconButton.js'; @@ -31,6 +31,8 @@ import classes from './Hamburger.module.css'; export interface HamburgerProps extends Omit { + // biome-ignore lint/suspicious/noExplicitAny: ref can target button or anchor + ref?: React.Ref; /** * When active, the Hamburger transform into a close button. */ @@ -51,71 +53,63 @@ export interface HamburgerProps * A hamburger button for menus. Morphs into a close icon when active. */ -export const Hamburger = forwardRef( - ( - { - isActive = false, - activeLabel, - inactiveLabel, - size: legacySize = 'm', - className, - ...props - }, - ref, - ) => { - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' - ) { - if (!isSufficientlyLabelled(activeLabel)) { - throw new AccessibilityError( - 'Hamburger', - 'The `activeLabel` prop is missing or invalid.', - ); - } - if (!isSufficientlyLabelled(inactiveLabel)) { - throw new AccessibilityError( - 'Hamburger', - 'The `inactiveLabel` prop is missing or invalid.', - ); - } +export function Hamburger({ + isActive = false, + activeLabel, + inactiveLabel, + size: legacySize = 'm', + className, + ref, + ...props +}: HamburgerProps) { + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' + ) { + if (!isSufficientlyLabelled(activeLabel)) { + throw new AccessibilityError( + 'Hamburger', + 'The `activeLabel` prop is missing or invalid.', + ); } - - if ( - process.env.NODE_ENV !== 'production' && - legacyButtonSizeMap[legacySize] - ) { - deprecate( + if (!isSufficientlyLabelled(inactiveLabel)) { + throw new AccessibilityError( 'Hamburger', - `The \`${legacySize}\` size has been deprecated. Use the \`${legacyButtonSizeMap[legacySize]}\` size instead.`, + 'The `inactiveLabel` prop is missing or invalid.', ); } + } - const size = legacyButtonSizeMap[legacySize] || legacySize; - - return ( - ( - // @ts-expect-error This doesn't have to be an SVG element. - - - - )} - className={clsx(classes.button, className)} - size={size} - type="button" - ref={ref} - > - {isActive ? activeLabel : inactiveLabel} - + if ( + process.env.NODE_ENV !== 'production' && + legacyButtonSizeMap[legacySize] + ) { + deprecate( + 'Hamburger', + `The \`${legacySize}\` size has been deprecated. Use the \`${legacyButtonSizeMap[legacySize]}\` size instead.`, ); - }, -); + } + + const size = legacyButtonSizeMap[legacySize] || legacySize; + + return ( + ( + // @ts-expect-error This doesn't have to be an SVG element. + + + + )} + className={clsx(classes.button, className)} + size={size} + type="button" + ref={ref} + > + {isActive ? activeLabel : inactiveLabel} + + ); +} diff --git a/packages/circuit-ui/components/Headline/Headline.tsx b/packages/circuit-ui/components/Headline/Headline.tsx index 1d7973f3f0..d902d925d7 100644 --- a/packages/circuit-ui/components/Headline/Headline.tsx +++ b/packages/circuit-ui/components/Headline/Headline.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; import { CircuitError } from '../../util/errors.js'; @@ -23,6 +23,7 @@ import { getEnvVariable } from '../../util/env.js'; import classes from './Headline.module.css'; export interface HeadlineProps extends HTMLAttributes { + ref?: Ref; /** * Choose from 3 font sizes. Defaults to `m`. */ @@ -64,42 +65,39 @@ const deprecatedSizeMap: Record = { /** * A flexible headline component capable of rendering any HTML heading element. */ -export const Headline = forwardRef( - ({ className, as, size: legacySize = 'm', ...props }, ref) => { - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !getEnvVariable('UNSAFE_DISABLE_ELEMENT_ERRORS') && - !as - ) { - throw new CircuitError('Headline', 'The `as` prop is required.'); - } +export function Headline({ + className, + as, + size: legacySize = 'm', + ...props +}: HeadlineProps) { + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !getEnvVariable('UNSAFE_DISABLE_ELEMENT_ERRORS') && + !as + ) { + throw new CircuitError('Headline', 'The `as` prop is required.'); + } - if ( - process.env.NODE_ENV !== 'production' && - legacySize in deprecatedSizeMap - ) { - deprecate( - 'Headline', - `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, - ); - } - - const Element = as || 'h2'; + if ( + process.env.NODE_ENV !== 'production' && + legacySize in deprecatedSizeMap + ) { + deprecate( + 'Headline', + `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, + ); + } - const size = (deprecatedSizeMap[legacySize] || legacySize) as - | 'l' - | 'm' - | 's'; + const Element = as || 'h2'; - return ( - - ); - }, -); + const size = (deprecatedSizeMap[legacySize] || legacySize) as 'l' | 'm' | 's'; -Headline.displayName = 'Headline'; + return ( + + ); +} diff --git a/packages/circuit-ui/components/Hr/Hr.tsx b/packages/circuit-ui/components/Hr/Hr.tsx index e94b3a77a9..9749656070 100644 --- a/packages/circuit-ui/components/Hr/Hr.tsx +++ b/packages/circuit-ui/components/Hr/Hr.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import type { AsPropType } from '../../types/prop-types.js'; import { clsx } from '../../styles/clsx.js'; @@ -21,6 +21,7 @@ import { clsx } from '../../styles/clsx.js'; import classes from './Hr.module.css'; export interface HrProps extends HTMLAttributes { + ref?: Ref; /** * Render the text using any HTML element. */ @@ -30,23 +31,17 @@ export interface HrProps extends HTMLAttributes { /** * A horizontal rule to visually and semantically separate content. */ -export const Hr = forwardRef( - ( - { - className, - 'as': Element = 'hr', - 'aria-hidden': ariaHidden = 'true', - ...props - }, - ref, - ) => ( +export function Hr({ + className, + 'as': Element = 'hr', + 'aria-hidden': ariaHidden = 'true', + ...props +}: HrProps) { + return ( - ), -); - -Hr.displayName = 'Hr'; + ); +} diff --git a/packages/circuit-ui/components/Image/Image.tsx b/packages/circuit-ui/components/Image/Image.tsx index 92dc26c7d4..bb9e66408a 100644 --- a/packages/circuit-ui/components/Image/Image.tsx +++ b/packages/circuit-ui/components/Image/Image.tsx @@ -13,13 +13,14 @@ * limitations under the License. */ -import { forwardRef, type ImgHTMLAttributes } from 'react'; +import type { ImgHTMLAttributes, Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; import classes from './Image.module.css'; export interface ImageProps extends ImgHTMLAttributes { + ref?: Ref; /** * The source URL of the image. Do not use unauthorized images from external sources. */ @@ -43,16 +44,6 @@ export interface ImageProps extends ImgHTMLAttributes { /** * The Image component. Responsive by default. */ -export const Image = forwardRef( - ({ className, alt, ...props }, ref) => ( - // biome-ignore lint/a11y/useAltText: The `alt` prop is marked as required. - {alt} - ), -); - -Image.displayName = 'Image'; +export function Image({ className, alt, ...props }: ImageProps) { + return {alt}; +} diff --git a/packages/circuit-ui/components/Input/Input.tsx b/packages/circuit-ui/components/Input/Input.tsx index 03106f1ac8..a0b762e20f 100644 --- a/packages/circuit-ui/components/Input/Input.tsx +++ b/packages/circuit-ui/components/Input/Input.tsx @@ -16,10 +16,10 @@ 'use client'; import { - forwardRef, useId, type ComponentType, type InputHTMLAttributes, + type Ref, } from 'react'; import { @@ -125,6 +125,7 @@ export interface BaseInputProps { export interface InputProps extends BaseInputProps, Omit, 'size'> { + ref?: Ref; /** * @private * @@ -136,120 +137,111 @@ export interface InputProps /** * Input component for forms. Takes optional prefix and suffix as render props. */ -export const Input = forwardRef( - ( - { - value, - defaultValue, - 'renderPrefix': RenderPrefix, - 'renderSuffix': RenderSuffix, - validationHint, - optionalLabel, - required, - invalid, - hasWarning, - showValid, - disabled, - textAlign = 'left', - inputClassName, - passwordManagerIgnore, - 'as': Element = 'input', - label, - hideLabel, - 'id': customId, - className, - style, - 'aria-describedby': descriptionId, - size = 'm', - ...props - }, - ref, - ): ReturnType => { - const id = useId(); - const inputId = customId || id; - const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); +export function Input({ + value, + defaultValue, + 'renderPrefix': RenderPrefix, + 'renderSuffix': RenderSuffix, + validationHint, + optionalLabel, + required, + invalid, + hasWarning, + showValid, + disabled, + textAlign = 'left', + inputClassName, + passwordManagerIgnore, + 'as': Element = 'input', + label, + hideLabel, + 'id': customId, + className, + style, + 'aria-describedby': descriptionId, + size = 'm', + ref, + ...props +}: InputProps): ReturnType { + const id = useId(); + const inputId = customId || id; + const validationHintId = useId(); + const descriptionIds = idx(descriptionId, validationHint && validationHintId); - const prefix = RenderPrefix && ( - - ); - const suffix = RenderSuffix && ; + const prefix = RenderPrefix && ( + + ); + const suffix = RenderSuffix && ; - const hasPrefix = Boolean(prefix); - const hasSuffix = Boolean(suffix); + const hasPrefix = Boolean(prefix); + const hasSuffix = Boolean(suffix); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - props.type !== 'hidden' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'Input', - 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + props.type !== 'hidden' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'Input', + 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', + ); + } - return ( - - - - -
- {prefix} - - {suffix} -
- + + + +
+ {prefix} + - - ); - }, -); - -Input.displayName = 'Input'; + {suffix} +
+ +
+ ); +} diff --git a/packages/circuit-ui/components/List/List.tsx b/packages/circuit-ui/components/List/List.tsx index 0b5d123fa9..b0f5359ea4 100644 --- a/packages/circuit-ui/components/List/List.tsx +++ b/packages/circuit-ui/components/List/List.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type OlHTMLAttributes } from 'react'; +import type { OlHTMLAttributes, Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; import type { BodyProps } from '../Body/index.js'; @@ -23,6 +23,7 @@ import { deprecate } from '../../util/logger.js'; import classes from './List.module.css'; export interface ListProps extends OlHTMLAttributes { + ref?: Ref; /** * A Circuit UI Body size. Should match surrounding text. */ @@ -36,33 +37,27 @@ export interface ListProps extends OlHTMLAttributes { /** * A list, which can be ordered or unordered. */ -export const List = forwardRef( - ( - { className, variant = 'unordered', size: legacySize = 'm', ...props }, - ref, - ) => { - if ( - process.env.NODE_ENV !== 'production' && - legacySize in deprecatedSizeMap - ) { - deprecate( - 'List', - `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, - ); - } - const Element = variant === 'ordered' ? 'ol' : 'ul'; - const size = (deprecatedSizeMap[legacySize] || legacySize) as - | 'l' - | 'm' - | 's'; - return ( - +export function List({ + className, + variant = 'unordered', + size: legacySize = 'm', + ...props +}: ListProps) { + if ( + process.env.NODE_ENV !== 'production' && + legacySize in deprecatedSizeMap + ) { + deprecate( + 'List', + `The "${legacySize}" size has been deprecated. Use the "${deprecatedSizeMap[legacySize]}" size instead.`, ); - }, -); - -List.displayName = 'List'; + } + const Element = variant === 'ordered' ? 'ol' : 'ul'; + const size = (deprecatedSizeMap[legacySize] || legacySize) as 'l' | 'm' | 's'; + return ( + + ); +} diff --git a/packages/circuit-ui/components/ListItem/ListItem.tsx b/packages/circuit-ui/components/ListItem/ListItem.tsx index 434b3da6b9..fe02ebbc39 100644 --- a/packages/circuit-ui/components/ListItem/ListItem.tsx +++ b/packages/circuit-ui/components/ListItem/ListItem.tsx @@ -15,12 +15,12 @@ 'use client'; -import { - forwardRef, - type ReactNode, - type ButtonHTMLAttributes, - type AnchorHTMLAttributes, - type HTMLAttributes, +import type { + ReactNode, + ButtonHTMLAttributes, + AnchorHTMLAttributes, + HTMLAttributes, + Ref, } from 'react'; import { ChevronRight, type IconComponentType } from '@sumup-oss/icons'; @@ -94,137 +94,130 @@ type ButtonElProps = Omit, 'onClick'>; export type ListItemProps = BaseProps & DivElProps & LinkElProps & - ButtonElProps; + ButtonElProps & { + ref?: Ref; + }; /** * The ListItem component enables the user to render a list item with various * textual and visual elements. */ -export const ListItem = forwardRef< - HTMLDivElement & HTMLAnchorElement & HTMLButtonElement, - ListItemProps ->( - ( - { - variant = 'action', - leadingComponent: LeadingComponent, - label, - details, - trailingLabel, - trailingDetails, - trailingComponent, - className, - selected, - ...props - }, - ref, - ) => { - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' - ) { - if (trailingDetails && !trailingLabel) { - throw new CircuitError( - 'ListItem', - 'Using `trailingDetails` without `trailingLabel` is not supported. Use a custom `trailingComponent` if necessary.', - ); - } - if (trailingComponent && trailingLabel) { - throw new CircuitError( - 'ListItem', - 'Using `trailingLabel` and `trailingComponent` at the same time is not supported. Add a label to the custom `trailingComponent` if necessary.', - ); - } +export function ListItem({ + variant = 'action', + leadingComponent: LeadingComponent, + label, + details, + trailingLabel, + trailingDetails, + trailingComponent, + className, + selected, + ref, + ...props +}: ListItemProps) { + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' + ) { + if (trailingDetails && !trailingLabel) { + throw new CircuitError( + 'ListItem', + 'Using `trailingDetails` without `trailingLabel` is not supported. Use a custom `trailingComponent` if necessary.', + ); } - - const { Link } = useComponents(); - let Element: AsPropType = 'div'; - if (props.href) { - Element = Link as AsPropType; - } else if (props.onClick) { - Element = 'button'; + if (trailingComponent && trailingLabel) { + throw new CircuitError( + 'ListItem', + 'Using `trailingLabel` and `trailingComponent` at the same time is not supported. Add a label to the custom `trailingComponent` if necessary.', + ); } + } - const isNavigation = variant === 'navigation'; - const hasTrailing = !!trailingLabel || !!trailingComponent; - const shouldRenderTrailingContainer = hasTrailing || isNavigation; + const { Link } = useComponents(); + let Element: AsPropType = 'div'; + if (props.href) { + Element = Link as AsPropType; + } else if (props.onClick) { + Element = 'button'; + } - return ( - - {LeadingComponent && ( -
- {isReactComponent(LeadingComponent) ? ( -
- )} -
-
- {isString(label) ? ( - - {label} - - ) : ( - label + const isNavigation = variant === 'navigation'; + const hasTrailing = !!trailingLabel || !!trailingComponent; + const shouldRenderTrailingContainer = hasTrailing || isNavigation; + + return ( + + {LeadingComponent && ( +
+ {isReactComponent(LeadingComponent) ? ( +
+ )} +
+
+ {isString(label) ? ( + + {label} + + ) : ( + label + )} + {details && ( +
+ {isString(details) ? ( + + {details} + + ) : ( + details + )} +
+ )} +
+ {shouldRenderTrailingContainer && ( +
+
+ {isString(trailingLabel) ? ( + + {trailingLabel} + + ) : ( + trailingLabel + )} + {trailingComponent} + {isNavigation &&
+ {trailingDetails && (
- {isString(details) ? ( + {isString(trailingDetails) ? ( - {details} + {trailingDetails} ) : ( - details + trailingDetails )}
)}
- {shouldRenderTrailingContainer && ( -
-
- {isString(trailingLabel) ? ( - - {trailingLabel} - - ) : ( - trailingLabel - )} - {trailingComponent} - {isNavigation &&
- {trailingDetails && ( -
- {isString(trailingDetails) ? ( - - {trailingDetails} - - ) : ( - trailingDetails - )} -
- )} -
- )} -
-
- ); - }, -); - -ListItem.displayName = 'ListItem'; + )} +
+ + ); +} diff --git a/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx b/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx index cb831c7a1f..8e21bb5b0e 100644 --- a/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx +++ b/packages/circuit-ui/components/ListItemGroup/ListItemGroup.tsx @@ -15,12 +15,7 @@ 'use client'; -import { - forwardRef, - useState, - type HTMLAttributes, - type ReactNode, -} from 'react'; +import { useState, type HTMLAttributes, type ReactNode, type Ref } from 'react'; import { AccessibilityError, @@ -65,96 +60,91 @@ interface BaseProps { details?: ReactNode; } -export type ListItemGroupProps = BaseProps & HTMLAttributes; +export type ListItemGroupProps = BaseProps & + HTMLAttributes & { + ref?: Ref; + }; /** * The ListItemGroup component enables the user to render a named list of ListItem components. */ -export const ListItemGroup = forwardRef( - ( - { - variant = 'inset', - items, - label, - hideLabel, - details, - className, - ...props - }, - ref, - ) => { - const [focusedItemKey, setFocusedItemKey] = useState< - ItemProps['key'] | null - >(null); +export function ListItemGroup({ + variant = 'inset', + items, + label, + hideLabel, + details, + className, + ...props +}: ListItemGroupProps) { + const [focusedItemKey, setFocusedItemKey] = useState( + null, + ); - const isPlain = variant === 'plain'; - const isInteractive = items.some((item) => !!item.href || !!item.onClick); + const isPlain = variant === 'plain'; + const isInteractive = items.some((item) => !!item.href || !!item.onClick); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(label, props) - ) { - throw new AccessibilityError( - 'ListItemGroup', - 'The `label` prop, `aria-label` or `aria-labelledby` is missing. This is an accessibility requirement. Pass `hideLabel` if you intend to hide the label visually.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(label, props) + ) { + throw new AccessibilityError( + 'ListItemGroup', + 'The `label` prop, `aria-label` or `aria-labelledby` is missing. This is an accessibility requirement. Pass `hideLabel` if you intend to hide the label visually.', + ); + } - return ( -
-
- {label && ( -
- {isString(label) ? {label} : label} -
- )} - {details && ( -
- {isString(details) ? {details} : details} -
- )} -
-
    - {items.map(({ key, ...item }) => ( -
  • - { - try { - if (event.currentTarget.matches(':focus-visible')) { - setFocusedItemKey(key); - } - } catch (_error) { + return ( +
    +
    + {label && ( +
    + {isString(label) ? {label} : label} +
    + )} + {details && ( +
    + {isString(details) ? {details} : details} +
    + )} +
    +
      + {items.map(({ key, ...item }) => ( +
    • + { + try { + if (event.currentTarget.matches(':focus-visible')) { setFocusedItemKey(key); } - }} - onBlur={() => setFocusedItemKey(null)} - /> -
    • - ))} -
    -
    - ); - }, -); - -ListItemGroup.displayName = 'ListItemGroup'; + } catch (_error) { + setFocusedItemKey(key); + } + }} + onBlur={() => setFocusedItemKey(null)} + /> +
  • + ))} +
+
+ ); +} diff --git a/packages/circuit-ui/components/Modal/Modal.tsx b/packages/circuit-ui/components/Modal/Modal.tsx index 90c181f4d8..2cf4a855cc 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useCallback, useState, type Ref } from 'react'; +import { useCallback, useState, type Ref } from 'react'; import { clsx } from '../../styles/clsx.js'; import { @@ -53,17 +53,15 @@ export interface ModalProps extends Omit { export const ANIMATION_DURATION = 300; -export const Modal = forwardRef((props, ref) => { - const { - variant = 'contextual', - className, - contentClassName, - preventClose = false, - children, - onClose, - ...rest - } = props; - +export function Modal({ + variant = 'contextual', + className, + contentClassName, + preventClose = false, + children, + onClose, + ...rest +}: ModalProps) { const [isClosing, setIsClosing] = useState(false); const handleModalCloseEnd = useCallback(() => { @@ -86,7 +84,6 @@ export const Modal = forwardRef((props, ref) => { return ( ((props, ref) => {
); -}); - -Modal.displayName = 'Modal'; +} diff --git a/packages/circuit-ui/components/NotificationBanner/NotificationBanner.tsx b/packages/circuit-ui/components/NotificationBanner/NotificationBanner.tsx index 94f46d9803..4550dd1ecb 100644 --- a/packages/circuit-ui/components/NotificationBanner/NotificationBanner.tsx +++ b/packages/circuit-ui/components/NotificationBanner/NotificationBanner.tsx @@ -16,13 +16,13 @@ 'use client'; import { - forwardRef, useEffect, useRef, useState, type MouseEvent, type KeyboardEvent, type HTMLAttributes, + type Ref, } from 'react'; import { Button, type ButtonProps } from '../Button/index.js'; @@ -66,6 +66,7 @@ interface NotificationImageProps extends ImageProps { } interface BaseProps extends Omit, 'action'> { + ref?: Ref; /** * Use the `system` variant for system notification use cases, otherwise, * use the `promotional` variant for promotional notification use cases. @@ -119,89 +120,79 @@ function NotificationImage({ * The NotificationBanner displays a notification with text, a call-to-action, * and optionally an image. */ -export const NotificationBanner = forwardRef< - HTMLDivElement, - NotificationBannerProps ->( - ( - { - headline, - body, - action, - variant = 'system', - image, - onClose, - closeButtonLabel, - isVisible = true, - className, - ...props - }, - ref, - ) => { - const contentElement = useRef(null); - const [isOpen, setOpen] = useState(isVisible); - const [height, setHeight] = useState(getElementHeight(contentElement)); - const [, setAnimating] = useAnimation(); +export function NotificationBanner({ + headline, + body, + action, + variant = 'system', + image, + onClose, + closeButtonLabel, + isVisible = true, + className, + ref, + ...props +}: NotificationBannerProps) { + const contentElement = useRef(null); + const [isOpen, setOpen] = useState(isVisible); + const [height, setHeight] = useState(getElementHeight(contentElement)); + const [, setAnimating] = useAnimation(); - useEffect(() => { - setAnimating({ - duration: 200, - onStart: () => { - setHeight(getElementHeight(contentElement)); - // Delaying the state update until the next animation frame ensures - // that browsers render the new height before the animation starts. - window.requestAnimationFrame(() => { - setOpen(isVisible); - }); - }, - onEnd: () => { - setHeight(DEFAULT_HEIGHT); - }, - }); - }, [isVisible, setAnimating]); + useEffect(() => { + setAnimating({ + duration: 200, + onStart: () => { + setHeight(getElementHeight(contentElement)); + // Delaying the state update until the next animation frame ensures + // that browsers render the new height before the animation starts. + window.requestAnimationFrame(() => { + setOpen(isVisible); + }); + }, + onEnd: () => { + setHeight(DEFAULT_HEIGHT); + }, + }); + }, [isVisible, setAnimating]); - if ( - process.env.NODE_ENV !== 'production' && - action.variant === 'tertiary' - ) { - deprecate( - 'NotificationBanner', - "The action's `tertiary` variant has been deprecated. Use the `primary` variant instead.", - ); - } + if (process.env.NODE_ENV !== 'production' && action.variant === 'tertiary') { + deprecate( + 'NotificationBanner', + "The action's `tertiary` variant has been deprecated. Use the `primary` variant instead.", + ); + } - return ( -
-
-
- - {headline} - - {body && {body}} -
-
- ); - }, -); +
+ ); +} diff --git a/packages/circuit-ui/components/NotificationFullscreen/NotificationFullscreen.tsx b/packages/circuit-ui/components/NotificationFullscreen/NotificationFullscreen.tsx index 36387603b2..f31cb993eb 100644 --- a/packages/circuit-ui/components/NotificationFullscreen/NotificationFullscreen.tsx +++ b/packages/circuit-ui/components/NotificationFullscreen/NotificationFullscreen.tsx @@ -13,13 +13,7 @@ * limitations under the License. */ -import { - forwardRef, - type FC, - type HTMLAttributes, - type ReactNode, - type SVGProps, -} from 'react'; +import type { FC, HTMLAttributes, ReactNode, Ref, SVGProps } from 'react'; import { Body } from '../Body/index.js'; import { Headline } from '../Headline/index.js'; @@ -32,6 +26,7 @@ import classes from './NotificationFullscreen.module.css'; export interface NotificationFullscreenProps extends HTMLAttributes { + ref?: Ref; /** * An image to illustrate the notification. Supports either passing an image * source to `image.src` or an SVG component to `image.svg`. Pass an empty @@ -80,14 +75,18 @@ function NotificationImage(image: NotificationFullscreenProps['image']) { * The `NotificationFullscreen` component provides important information or * feedback as part of a process flow. */ -export const NotificationFullscreen = forwardRef< - HTMLDivElement, - NotificationFullscreenProps ->(({ image, headline, body, actions, className, ...props }, ref) => { +export function NotificationFullscreen({ + image, + headline, + body, + actions, + className, + ...props +}: NotificationFullscreenProps) { const headlineLabel = isString(headline) ? headline : headline.label; const headlineElement = isString(headline) ? 'h2' : headline.as; return ( -
+
{headlineLabel} @@ -96,4 +95,4 @@ export const NotificationFullscreen = forwardRef< {actions && }
); -}); +} diff --git a/packages/circuit-ui/components/NotificationInline/NotificationInline.tsx b/packages/circuit-ui/components/NotificationInline/NotificationInline.tsx index 75363c7a77..4fc2c64d26 100644 --- a/packages/circuit-ui/components/NotificationInline/NotificationInline.tsx +++ b/packages/circuit-ui/components/NotificationInline/NotificationInline.tsx @@ -16,13 +16,11 @@ 'use client'; import { - forwardRef, useEffect, useRef, useState, - type ForwardRefExoticComponent, type HTMLAttributes, - type RefAttributes, + type Ref, } from 'react'; import { useAnimation } from '../../hooks/useAnimation/index.js'; @@ -62,6 +60,7 @@ type CloseProps = | { onClose?: never; closeButtonLabel?: never }; type BaseProps = HTMLAttributes & { + ref?: Ref; /** * The notification's variant. Defaults to `info`. */ @@ -97,97 +96,88 @@ type BaseProps = HTMLAttributes & { export type NotificationInlineProps = BaseProps & CloseProps; -type NotificationInlineComponent = ForwardRefExoticComponent< - NotificationInlineProps & RefAttributes -> & { TIMEOUT: number }; +function NotificationInlineBase({ + variant = 'info', + body, + headline, + action, + onClose, + closeButtonLabel, + iconLabel = '', + isVisible = true, + className, + ref, + ...props +}: NotificationInlineProps) { + const contentElement = useRef(null); + const [isOpen, setOpen] = useState(isVisible); + const [height, setHeight] = useState(getElementHeight(contentElement)); + const [, setAnimating] = useAnimation(); -export const NotificationInline = forwardRef< - HTMLDivElement, - NotificationInlineProps ->( - ( - { - variant = 'info', - body, - headline, - action, - onClose, - closeButtonLabel, - iconLabel = '', - isVisible = true, - className, - ...props - }, - ref, - ) => { - const contentElement = useRef(null); - const [isOpen, setOpen] = useState(isVisible); - const [height, setHeight] = useState(getElementHeight(contentElement)); - const [, setAnimating] = useAnimation(); + useEffect(() => { + setAnimating({ + duration: TRANSITION_DURATION, + onStart: () => { + setHeight(getElementHeight(contentElement)); + // Delaying the state update until the next animation frame ensures that + // the browsers renders the new height before the animation starts. + window.requestAnimationFrame(() => { + setOpen(isVisible); + }); + }, + onEnd: () => { + setHeight(DEFAULT_HEIGHT); + }, + }); + }, [isVisible, setAnimating]); - useEffect(() => { - setAnimating({ - duration: TRANSITION_DURATION, - onStart: () => { - setHeight(getElementHeight(contentElement)); - // Delaying the state update until the next animation frame ensures that - // the browsers renders the new height before the animation starts. - window.requestAnimationFrame(() => { - setOpen(isVisible); - }); - }, - onEnd: () => { - setHeight(DEFAULT_HEIGHT); - }, - }); - }, [isVisible, setAnimating]); + const Icon = NOTIFICATION_ICONS[variant]; - const Icon = NOTIFICATION_ICONS[variant]; - - return ( -
-
-
-
- {iconLabel} -
- {headline && ( - - {isString(headline) ? headline : headline.label} - - )} - {body} - {action && ( - - )} -
- - {onClose && closeButtonLabel && ( - - {closeButtonLabel} - + return ( +
+
+
+
+ {iconLabel} +
+ {headline && ( + + {isString(headline) ? headline : headline.label} + + )} + {body} + {action && ( + )}
+ + {onClose && closeButtonLabel && ( + + {closeButtonLabel} + + )}
- ); - }, -) as NotificationInlineComponent; +
+ ); +} -NotificationInline.TIMEOUT = TRANSITION_DURATION; +export const NotificationInline = Object.assign(NotificationInlineBase, { + TIMEOUT: TRANSITION_DURATION, +}); diff --git a/packages/circuit-ui/components/Numeral/Numeral.tsx b/packages/circuit-ui/components/Numeral/Numeral.tsx index 270c12b0c4..353f4c771d 100644 --- a/packages/circuit-ui/components/Numeral/Numeral.tsx +++ b/packages/circuit-ui/components/Numeral/Numeral.tsx @@ -13,7 +13,7 @@ * limitations under the License. */ -import { forwardRef, type HTMLAttributes } from 'react'; +import type { HTMLAttributes, Ref } from 'react'; import type { AsPropType } from '../../types/prop-types.js'; import { clsx } from '../../styles/clsx.js'; @@ -21,6 +21,7 @@ import { clsx } from '../../styles/clsx.js'; import classes from './Numeral.module.css'; export interface NumeralProps extends HTMLAttributes { + ref?: Ref; /** * Choose from 3 font sizes. Default `m`. */ @@ -55,21 +56,17 @@ export interface NumeralProps extends HTMLAttributes { * The Numeral component is used to present the core textual content * to our users. */ -export const Numeral = forwardRef( - ( - { - className, - as: Element = 'p', - size = 'm', - color = 'normal', - decoration, - ...props - }, - ref, - ) => ( +export function Numeral({ + className, + as: Element = 'p', + size = 'm', + color = 'normal', + decoration, + ...props +}: NumeralProps) { + return ( ( className, )} /> - ), -); - -Numeral.displayName = 'Numeral'; + ); +} diff --git a/packages/circuit-ui/components/PercentageInput/PercentageInput.tsx b/packages/circuit-ui/components/PercentageInput/PercentageInput.tsx index 332e2d26d7..64a3d227e2 100644 --- a/packages/circuit-ui/components/PercentageInput/PercentageInput.tsx +++ b/packages/circuit-ui/components/PercentageInput/PercentageInput.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useId } from 'react'; +import { useId, type Ref } from 'react'; import { resolveNumberFormat } from '@sumup-oss/intl'; import { NumericFormat } from '../../vendor/react-number-format/index.js'; @@ -32,6 +32,7 @@ export interface PercentageInputProps InputProps, 'placeholder' | 'ref' | 'value' | 'defaultValue' | 'type' > { + ref?: Ref; /** * One or more Unicode BCP 47 locale identifiers, such as `'de-DE'` or * `['GB', 'en-US']` (the first supported locale is used). @@ -64,73 +65,64 @@ const DEFAULT_FORMAT = { /** * PercentageInput component for fractional values */ -export const PercentageInput = forwardRef< - HTMLInputElement, - PercentageInputProps ->( - ( - { - locale, - placeholder = '0', - decimalScale = 0, - 'aria-describedby': descriptionId, - ...props - }, - ref, - ) => { - const percentageSymbolId = useId(); - const descriptionIds = idx(percentageSymbolId, descriptionId); +export function PercentageInput({ + locale, + placeholder = '0', + decimalScale = 0, + 'aria-describedby': descriptionId, + ref, + ...props +}: PercentageInputProps) { + const percentageSymbolId = useId(); + const descriptionIds = idx(percentageSymbolId, descriptionId); - const { groupDelimiter, decimalDelimiter } = - resolveNumberFormat(locale, { - style: 'percent', - // There must be at least 1 decimal for the decimalDelimiter to be resolved - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }) || DEFAULT_FORMAT; + const { groupDelimiter, decimalDelimiter } = + resolveNumberFormat(locale, { + style: 'percent', + // There must be at least 1 decimal for the decimalDelimiter to be resolved + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) || DEFAULT_FORMAT; - const placeholderString = formatPlaceholder(placeholder, locale, { - minimumFractionDigits: decimalScale, - maximumFractionDigits: decimalScale, - }); + const placeholderString = formatPlaceholder(placeholder, locale, { + minimumFractionDigits: decimalScale, + maximumFractionDigits: decimalScale, + }); - // Allow common decimal signs as well as the one from resolveNumberFormat() - const allowedDecimalSeparators = [ - '.', - ',', - ...(decimalDelimiter ? [decimalDelimiter] : []), - ]; + // Allow common decimal signs as well as the one from resolveNumberFormat() + const allowedDecimalSeparators = [ + '.', + ',', + ...(decimalDelimiter ? [decimalDelimiter] : []), + ]; - const renderSuffix = (suffixProps: { className?: string }) => ( - - % - - ); + const renderSuffix = (suffixProps: { className?: string }) => ( + + % + + ); - return ( - - ); - }, -); - -PercentageInput.displayName = 'PercentageInput'; + return ( + + ); +} diff --git a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx index c0b0217caa..950c323086 100644 --- a/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx +++ b/packages/circuit-ui/components/PhoneNumberInput/PhoneNumberInput.tsx @@ -16,7 +16,6 @@ 'use client'; import { - forwardRef, useId, useMemo, useRef, @@ -26,6 +25,7 @@ import { type ComponentType, type InputHTMLAttributes, type ForwardedRef, + type Ref, type RefObject, } from 'react'; @@ -61,6 +61,7 @@ import classes from './PhoneNumberInput.module.css'; export interface PhoneNumberInputProps extends Omit, 'size'> { + ref?: Ref; /** * The normalized phone number in the [E.164 format](https://en.wikipedia.org/wiki/E.164). * @@ -210,288 +211,269 @@ const DefaultPrefix: ComponentType<{ * Provides a straightforward way for users to type their phone number in an * accurate, consistent format including the country code and subscriber number. */ -export const PhoneNumberInput = forwardRef< - HTMLInputElement, - PhoneNumberInputProps ->( - ( - { - label, - hideLabel, - value, - defaultValue, - countryCode, - subscriberNumber, - optionalLabel, - required, - invalid, - hasWarning, - showValid, - disabled, - validationHint, - readOnly, - 'aria-describedby': descriptionId, - locale, - size = 'm', - className, - style, - ...props - }, - ref, - ) => { - const hiddenInputRef = useRef(null); - const countryCodeRef = useRef(null); - const subscriberNumberRef = useRef(null); +export function PhoneNumberInput({ + label, + hideLabel, + value, + defaultValue, + countryCode, + subscriberNumber, + optionalLabel, + required, + invalid, + hasWarning, + showValid, + disabled, + validationHint, + readOnly, + ref, + 'aria-describedby': descriptionId, + locale, + size = 'm', + className, + style, + ...props +}: PhoneNumberInputProps) { + const hiddenInputRef = useRef(null); + const countryCodeRef = useRef(null); + const subscriberNumberRef = useRef(null); - // This state is used to trigger a re-render when selecting a different - // country with the same country code as the current one (e.g. Canada → USA). - // When the country codes match, the `value` prop doesn't change - // (which would normally cause a re-render). - const [, setVersion] = useState(1); + // This state is used to trigger a re-render when selecting a different + // country with the same country code as the current one (e.g. Canada → USA). + // When the country codes match, the `value` prop doesn't change + // (which would normally cause a re-render). + const [, setVersion] = useState(1); - const validationHintId = useId(); + const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); - - const options = useMemo( - () => mapCountryCodeOptions(countryCode.options, locale), - [countryCode.options, locale], - ); + const descriptionIds = idx(descriptionId, validationHint && validationHintId); - const handleChange = () => { - if (!countryCodeRef.current || !subscriberNumberRef.current) { - return; - } + const options = useMemo( + () => mapCountryCodeOptions(countryCode.options, locale), + [countryCode.options, locale], + ); - const selectedCountry = countryCodeRef?.current?.value; - if (!selectedCountry) { - return; - } - const code = countryCode.options.find( - ({ country }) => country === selectedCountry, - )?.code; - - if (!code) { - return; - } - const phoneNumber = normalizePhoneNumber( - code, - subscriberNumberRef.current.value, - ); + const handleChange = () => { + if (!countryCodeRef.current || !subscriberNumberRef.current) { + return; + } - changeInputValue(hiddenInputRef.current, phoneNumber); - setVersion((prev) => prev + 1); - }; + const selectedCountry = countryCodeRef?.current?.value; + if (!selectedCountry) { + return; + } + const code = countryCode.options.find( + ({ country }) => country === selectedCountry, + )?.code; - const handlePaste = (event: ClipboardEvent) => { - if ( - !countryCodeRef.current || - !subscriberNumberRef.current || - countryCodeRef.current.disabled || - (countryCodeRef.current as HTMLInputElement).readOnly - ) { - return; - } + if (!code) { + return; + } + const phoneNumber = normalizePhoneNumber( + code, + subscriberNumberRef.current.value, + ); - event.preventDefault(); + changeInputValue(hiddenInputRef.current, phoneNumber); + setVersion((prev) => prev + 1); + }; - const pastedPhoneNumber = parsePhoneNumber( - event.clipboardData.getData('text/plain'), - countryCode.options, - countryCodeRef.current.value, - ); + const handlePaste = (event: ClipboardEvent) => { + if ( + !countryCodeRef.current || + !subscriberNumberRef.current || + countryCodeRef.current.disabled || + (countryCodeRef.current as HTMLInputElement).readOnly + ) { + return; + } - if (pastedPhoneNumber.countryCode) { - changeInputValue(countryCodeRef.current, pastedPhoneNumber.countryCode); - } - if (pastedPhoneNumber.subscriberNumber) { - changeInputValue( - subscriberNumberRef.current, - pastedPhoneNumber.subscriberNumber, - ); - } - }; + event.preventDefault(); - const parsedValue = parsePhoneNumber( - value, - countryCode.options, - countryCodeRef.current?.value, - ); - const parsedDefaultValue = parsePhoneNumber( - defaultValue, + const pastedPhoneNumber = parsePhoneNumber( + event.clipboardData.getData('text/plain'), countryCode.options, + countryCodeRef.current.value, ); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' - ) { - if (!isSufficientlyLabelled(label)) { - throw new AccessibilityError( - 'PhoneNumberInput', - 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', - ); - } + if (pastedPhoneNumber.countryCode) { + changeInputValue(countryCodeRef.current, pastedPhoneNumber.countryCode); + } + if (pastedPhoneNumber.subscriberNumber) { + changeInputValue( + subscriberNumberRef.current, + pastedPhoneNumber.subscriberNumber, + ); + } + }; - if (!isSufficientlyLabelled(countryCode.label)) { - throw new AccessibilityError( - 'PhoneNumberInput', - 'The `countryCode.label` prop is missing or invalid.', - ); - } + const parsedValue = parsePhoneNumber( + value, + countryCode.options, + countryCodeRef.current?.value, + ); + const parsedDefaultValue = parsePhoneNumber( + defaultValue, + countryCode.options, + ); - if (!isSufficientlyLabelled(subscriberNumber.label)) { - throw new AccessibilityError( - 'PhoneNumberInput', - 'The `subscriberNumber.label` prop is missing or invalid.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' + ) { + if (!isSufficientlyLabelled(label)) { + throw new AccessibilityError( + 'PhoneNumberInput', + 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', + ); } - return ( -
- - - -
- + + + +
+ + {readOnly || countryCode.readonly ? ( + {}} + ref={applyMultipleRefs( + countryCodeRef as RefObject, + countryCode.ref as ForwardedRef, + )} + renderPrefix={ + (countryCode.renderPrefix as InputProps['renderPrefix']) ?? + (({ value: inputValue, ...rest }) => ( + + )) + } /> - {readOnly || countryCode.readonly ? ( - {}} - ref={applyMultipleRefs( - countryCodeRef as RefObject, - countryCode.ref as ForwardedRef, - )} - renderPrefix={ - (countryCode.renderPrefix as InputProps['renderPrefix']) ?? - (({ value: inputValue, ...rest }) => ( - - )) - } - /> - ) : ( - ]>([ - subscriberNumber.onChange, + invalid={invalid || countryCode.invalid} + aria-readonly={true} + options={options} + onChange={eachFn<[ChangeEvent]>([ + countryCode.onChange, handleChange, ])} - onPaste={handlePaste} - ref={applyMultipleRefs(subscriberNumberRef, subscriberNumber.ref)} + ref={applyMultipleRefs( + countryCodeRef as RefObject, + countryCode.ref as ForwardedRef, + )} + renderPrefix={countryCode.renderPrefix ?? DefaultPrefix} /> -
- ]>([ + subscriberNumber.onChange, + handleChange, + ])} + onPaste={handlePaste} + ref={applyMultipleRefs(subscriberNumberRef, subscriberNumber.ref)} /> -
- ); - }, -); - -PhoneNumberInput.displayName = 'PhoneNumberInput'; +
+ +
+ ); +} diff --git a/packages/circuit-ui/components/Popover/Popover.module.css b/packages/circuit-ui/components/Popover/Popover.module.css index 09fc2caa6e..7b3f4924eb 100644 --- a/packages/circuit-ui/components/Popover/Popover.module.css +++ b/packages/circuit-ui/components/Popover/Popover.module.css @@ -5,10 +5,6 @@ border-radius: var(--cui-border-radius-byte); } -.trigger { - display: inline-block; -} - .modal { top: unset; right: 0; diff --git a/packages/circuit-ui/components/Popover/Popover.tsx b/packages/circuit-ui/components/Popover/Popover.tsx index 5b9cb9fcf1..0f20b108d2 100644 --- a/packages/circuit-ui/components/Popover/Popover.tsx +++ b/packages/circuit-ui/components/Popover/Popover.tsx @@ -27,9 +27,9 @@ import { } from '@floating-ui/react-dom'; import { type ComponentType, - forwardRef, Fragment, type KeyboardEvent, + type RefObject, useCallback, useEffect, useId, @@ -59,6 +59,7 @@ export interface PopoverReferenceProps { 'id': string; 'aria-controls': string; 'aria-expanded': boolean; + 'ref'?: (el: Element | null) => void; } type OnToggle = (open: boolean | ((prevOpen: boolean) => boolean)) => void; @@ -120,152 +121,138 @@ const sizeOptions: SizeOptions = { }; const $activePopoverId = atom(null); -export const Popover = forwardRef( - ( - { - isOpen = false, - onToggle, - children, - placement = 'bottom', - fallbackPlacements = ['top', 'right', 'left'], - component: Component, - offset, - className, - contentClassName, - style, - disableModalOnMobile, - ...props - }, - ref, - ) => { - const dialogRef = useRef(null); - const triggerId = useId(); - const contentId = useId(); - const [isClosing, setClosing] = useState(false); - const isMobile = useMedia('(max-width: 479px)'); - const isModalOnMobile = !disableModalOnMobile && isMobile; - const animationDuration = isModalOnMobile ? 300 : 0; - const prevOpen = usePrevious(isOpen); +export function Popover({ + isOpen = false, + onToggle, + children, + placement = 'bottom', + fallbackPlacements = ['top', 'right', 'left'], + component: Component, + offset, + className, + contentClassName, + style, + disableModalOnMobile, + ref, + ...props +}: PopoverProps) { + const dialogRef = useRef(null); + const triggerId = useId(); + const contentId = useId(); + const [isClosing, setClosing] = useState(false); + const isMobile = useMedia('(max-width: 479px)'); + const isModalOnMobile = !disableModalOnMobile && isMobile; + const animationDuration = isModalOnMobile ? 300 : 0; + const prevOpen = usePrevious(isOpen); - const activePopoverId = useStore($activePopoverId); - const popoverId = useId(); + const activePopoverId = useStore($activePopoverId); + const popoverId = useId(); - const { floatingStyles, refs, update } = useFloating({ - open: isOpen, - placement, - strategy: 'fixed', - middleware: [ - offset ? offsetMiddleware(offset) : undefined, - shift({ padding: boundaryPadding }), - flip({ - padding: boundaryPadding, - fallbackPlacements, - }), - size(() => ({ - ...sizeOptions, - boundary: - typeof window !== 'undefined' - ? { - x: 0, - y: 0, - width: window.innerWidth, - height: window.innerHeight, - } - : undefined, - })), - ], - }); - - const handleTriggerClick = () => { - onToggle((prev) => { - if (prev) { - setClosing(true); - return false; - } - $activePopoverId.set(popoverId); - return true; - }); - }; + const { floatingStyles, refs, update } = useFloating({ + open: isOpen, + placement, + strategy: 'fixed', + middleware: [ + offset ? offsetMiddleware(offset) : undefined, + shift({ padding: boundaryPadding }), + flip({ + padding: boundaryPadding, + fallbackPlacements, + }), + size(() => ({ + ...sizeOptions, + boundary: + typeof window !== 'undefined' + ? { + x: 0, + y: 0, + width: window.innerWidth, + height: window.innerHeight, + } + : undefined, + })), + ], + }); - /** - * We can't use Floating UI's `whileElementsMounted` option because our - * implementation hides the floating element using CSS instead of using - * conditional rendering. - */ - useEffect(() => { - if (isOpen && refs.reference.current && refs.floating.current) { - return autoUpdate( - refs.reference.current, - refs.floating.current, - update, - ); + const handleTriggerClick = () => { + onToggle((prev) => { + if (prev) { + setClosing(true); + return false; } - return undefined; - }, [isOpen, refs.reference, refs.floating, update]); + $activePopoverId.set(popoverId); + return true; + }); + }; + + /** + * We can't use Floating UI's `whileElementsMounted` option because our + * implementation hides the floating element using CSS instead of using + * conditional rendering. + */ + useEffect(() => { + if (isOpen && refs.reference.current && refs.floating.current) { + return autoUpdate(refs.reference.current, refs.floating.current, update); + } + return undefined; + }, [isOpen, refs.reference, refs.floating, update]); - const handleCloseEnd = useCallback(() => { - setClosing(false); - onToggle(false); - }, [onToggle]); + const handleCloseEnd = useCallback(() => { + setClosing(false); + onToggle(false); + }, [onToggle]); - const handleCloseStart = useCallback(() => { - setClosing(true); - }, []); + const handleCloseStart = useCallback(() => { + setClosing(true); + }, []); - const outAnimation = isModalOnMobile - ? sharedClasses.animationSlideUpOut - : undefined; - const inAnimation = isModalOnMobile - ? sharedClasses.animationSlideUpIn - : undefined; + const outAnimation = isModalOnMobile + ? sharedClasses.animationSlideUpOut + : undefined; + const inAnimation = isModalOnMobile + ? sharedClasses.animationSlideUpIn + : undefined; - useEffect(() => { - // Focus the reference element after closing - if (prevOpen && !isOpen && activePopoverId === popoverId) { - const triggerButton = refs.reference.current - ?.firstElementChild as HTMLElement; - triggerButton.focus({ preventScroll: true }); - $activePopoverId.set(null); - } - }, [isOpen, prevOpen, refs.reference, activePopoverId, popoverId]); - return ( - -
- + useEffect(() => { + // Focus the reference element after closing + if (prevOpen && !isOpen && activePopoverId === popoverId) { + (refs.reference.current as HTMLElement)?.focus({ preventScroll: true }); + $activePopoverId.set(null); + } + }, [isOpen, prevOpen, refs.reference, activePopoverId, popoverId]); + return ( + + + } + > +
+ {typeof children === 'function' + ? children?.({ onClose: handleCloseEnd }) + : children}
- -
- {typeof children === 'function' - ? children?.({ onClose: handleCloseEnd }) - : children} -
-
-
- ); - }, -); + + + ); +} diff --git a/packages/circuit-ui/components/RadioButton/RadioButton.tsx b/packages/circuit-ui/components/RadioButton/RadioButton.tsx index f3608797c7..e207c62fae 100644 --- a/packages/circuit-ui/components/RadioButton/RadioButton.tsx +++ b/packages/circuit-ui/components/RadioButton/RadioButton.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useId } from 'react'; +import { useId, type Ref } from 'react'; import { AccessibilityError, @@ -32,6 +32,7 @@ import { import classes from './RadioButton.module.css'; export interface RadioButtonProps extends Omit { + ref?: Ref; /** * A clear and concise description of the option's purpose. */ @@ -43,64 +44,58 @@ export interface RadioButtonProps extends Omit { children?: never; } -export const RadioButton = forwardRef( - ( - { - label, - description, - disabled, - 'aria-describedby': describedBy, - 'id': customId, - className, - style, - ...props - }, - ref, - ) => { - const id = useId(); - const inputId = customId || id; - const descriptionId = useId(); +export function RadioButton({ + label, + description, + disabled, + 'aria-describedby': describedBy, + 'id': customId, + className, + style, + ref, + ...props +}: RadioButtonProps) { + const id = useId(); + const inputId = customId || id; + const descriptionId = useId(); - const descriptionIds = idx(describedBy, description && descriptionId); + const descriptionIds = idx(describedBy, description && descriptionId); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'RadioButton', - 'The `label` prop is missing or invalid.', - ); - } - - return ( - - - - {label} - {description && ( - - )} - - - {description && ( -

- {description} -

- )} -
+ if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'RadioButton', + 'The `label` prop is missing or invalid.', ); - }, -); + } -RadioButton.displayName = 'RadioButton'; + return ( + + + + {label} + {description && ( + + )} + + + {description && ( +

+ {description} +

+ )} +
+ ); +} diff --git a/packages/circuit-ui/components/RadioButtonGroup/RadioButtonGroup.tsx b/packages/circuit-ui/components/RadioButtonGroup/RadioButtonGroup.tsx index d69c8501bb..a4e34ae14d 100644 --- a/packages/circuit-ui/components/RadioButtonGroup/RadioButtonGroup.tsx +++ b/packages/circuit-ui/components/RadioButtonGroup/RadioButtonGroup.tsx @@ -16,7 +16,6 @@ 'use client'; import { - forwardRef, useId, type FieldsetHTMLAttributes, type InputHTMLAttributes, @@ -113,100 +112,89 @@ export interface RadioButtonGroupProps /** * A group of RadioButtons. */ -export const RadioButtonGroup = forwardRef( - ( - { - options, - onChange, - value, - defaultValue, - 'name': customName, - label, - invalid, - validationHint, - showValid, - disabled, - hasWarning, - hideLabel, - optionalLabel, - required, - 'aria-describedby': descriptionId, - ...props - }: RadioButtonGroupProps, - ref: RadioButtonGroupProps['ref'], - ) => { - const randomName = useId(); - const name = customName || randomName; - const validationHintId = useId(); - const descriptionIds = idx( - descriptionId, - validationHint && validationHintId, - ); +export function RadioButtonGroup({ + options, + onChange, + value, + defaultValue, + 'name': customName, + label, + invalid, + validationHint, + showValid, + disabled, + hasWarning, + hideLabel, + optionalLabel, + required, + 'aria-describedby': descriptionId, + ...props +}: RadioButtonGroupProps) { + const randomName = useId(); + const name = customName || randomName; + const validationHintId = useId(); + const descriptionIds = idx(descriptionId, validationHint && validationHintId); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(label) - ) { - throw new AccessibilityError( - 'RadioButtonGroup', - 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', - ); - } + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(label) + ) { + throw new AccessibilityError( + 'RadioButtonGroup', + 'The `label` prop is missing or invalid. Pass `hideLabel` if you intend to hide the label visually.', + ); + } - if (isEmpty(options)) { - return null; - } + if (isEmpty(options)) { + return null; + } - return ( -
- - - -
- {options.map((option) => ( - - ))} -
- + + -
- ); - }, -); - -RadioButtonGroup.displayName = 'RadioButtonGroup'; + +
+ {options.map((option) => ( + + ))} +
+ + + ); +} diff --git a/packages/circuit-ui/components/RadioButtonGroup/RadioButtonInput.tsx b/packages/circuit-ui/components/RadioButtonGroup/RadioButtonInput.tsx index fd72130c85..6c036b48ac 100644 --- a/packages/circuit-ui/components/RadioButtonGroup/RadioButtonInput.tsx +++ b/packages/circuit-ui/components/RadioButtonGroup/RadioButtonInput.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useId, type InputHTMLAttributes } from 'react'; +import { useId, type InputHTMLAttributes, type Ref } from 'react'; import { AccessibilityError, @@ -28,6 +28,7 @@ import classes from './RadioButtonInput.module.css'; export interface RadioButtonInputProps extends InputHTMLAttributes { + ref?: Ref; /** * Alignment of the radio button in relation to its label. * @default 'center' @@ -35,47 +36,45 @@ export interface RadioButtonInputProps align?: 'center' | 'start'; } -export const RadioButtonInput = forwardRef< - HTMLInputElement, - RadioButtonInputProps ->( - ( - { 'id': customId, className, style, children, align = 'center', ...props }, - ref, - ) => { - const id = useId(); - const inputId = customId || id; +export function RadioButtonInput({ + 'id': customId, + className, + style, + children, + align = 'center', + ref, + ...props +}: RadioButtonInputProps) { + const id = useId(); + const inputId = customId || id; - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - !isSufficientlyLabelled(children, props) - ) { - throw new AccessibilityError( - 'RadioButtonInput', - 'The input is missing a valid label.', - ); - } - - return ( - <> - - - + if ( + process.env.NODE_ENV !== 'production' && + process.env.NODE_ENV !== 'test' && + !isSufficientlyLabelled(children, props) + ) { + throw new AccessibilityError( + 'RadioButtonInput', + 'The input is missing a valid label.', ); - }, -); + } -RadioButtonInput.displayName = 'RadioButtonInput'; + return ( + <> + + + + ); +} diff --git a/packages/circuit-ui/components/SearchInput/SearchInput.tsx b/packages/circuit-ui/components/SearchInput/SearchInput.tsx index 023b6c5412..adc8174091 100644 --- a/packages/circuit-ui/components/SearchInput/SearchInput.tsx +++ b/packages/circuit-ui/components/SearchInput/SearchInput.tsx @@ -15,7 +15,7 @@ 'use client'; -import { forwardRef, useRef } from 'react'; +import { useRef } from 'react'; import { Search } from '@sumup-oss/icons'; import { Input, type InputProps } from '../Input/index.js'; @@ -55,59 +55,55 @@ export type SearchInputProps = InputProps & { /** * SearchInput component for forms. */ -export const SearchInput = forwardRef( - (props, ref) => { - const { value, onClear, clearLabel, inputClassName, locale, ...rest } = - useI18n(props, translations); - const localRef = useRef(null); +export function SearchInput(rawProps: SearchInputProps) { + const { value, onClear, clearLabel, inputClassName, locale, ref, ...rest } = + useI18n(rawProps, translations); + const localRef = useRef(null); - if ( - process.env.NODE_ENV !== 'production' && - process.env.NODE_ENV !== 'test' && - onClear && - !isSufficientlyLabelled(clearLabel) - ) { - throw new AccessibilityError( - 'SearchInput', - 'The `clearLabel` prop is missing or invalid. Omit the `onClear` prop if you intend to disable the input clearing functionality.', - ); - } - - const onClick = (event: ClickEvent) => { - onClear?.(event); - localRef.current?.focus(); - }; - - return ( - ( -