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/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/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/.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/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/.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/many-stars-count.md b/.changeset/many-stars-count.md new file mode 100644 index 0000000000..c35ae0b4a2 --- /dev/null +++ b/.changeset/many-stars-count.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": patch +--- + +Fixed the behavior responsible for restoring the AutocompleteInput's displayed value when it loses focus. 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/ninety-carrots-rule.md b/.changeset/ninety-carrots-rule.md new file mode 100644 index 0000000000..3225f16002 --- /dev/null +++ b/.changeset/ninety-carrots-rule.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": patch +--- + +Fixed the AutocompleteInput suggestion box incorrectly scrolling with the combobox input. 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/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": [] +} 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/.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/.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/.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/.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/.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/.changeset/strong-facts-march.md b/.changeset/strong-facts-march.md new file mode 100644 index 0000000000..489d276d9d --- /dev/null +++ b/.changeset/strong-facts-march.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Removed the `UNSAFE_DISABLE_ELEMENT_ERRORS` environment variable. The `as` prop on Headline and Display is now always required. diff --git a/.changeset/ten-parks-kneel.md b/.changeset/ten-parks-kneel.md new file mode 100644 index 0000000000..b1abd53525 --- /dev/null +++ b/.changeset/ten-parks-kneel.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Changed the Toggle component implementation to use a native input element instead of a button element under the hood. `onChange` now fires a native `ChangeEvent` instead of `ClickEvent`. 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/.changeset/warm-things-shine.md b/.changeset/warm-things-shine.md new file mode 100644 index 0000000000..c4c389be1c --- /dev/null +++ b/.changeset/warm-things-shine.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": patch +--- + +Fixed an overflow issue in the AutocompleteInput when using multi-selection mode. diff --git a/.changeset/weak-paths-cheat.md b/.changeset/weak-paths-cheat.md new file mode 100644 index 0000000000..ff9e5f521d --- /dev/null +++ b/.changeset/weak-paths-cheat.md @@ -0,0 +1,5 @@ +--- +"@sumup-oss/circuit-ui": major +--- + +Remove the deprecated `width` and `height` props from the Flag component. Use the `size` prop instead. 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0865014b2c..4936a10c8f 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.18.0] + node: [24, 26] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -55,7 +55,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Create release PR or publish to npm - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/canary' || startsWith(github.ref, 'refs/heads/release')) && matrix.node == '24.18.0' + if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/canary' || startsWith(github.ref, 'refs/heads/release')) && matrix.node == 24 uses: changesets/action@v1 with: publish: npm run release diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml index 059473d21a..ef013fa090 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/.storybook/main.ts b/.storybook/main.ts index dfece8fee5..899032f432 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -43,9 +43,6 @@ const config: StorybookConfig = { ]; return mergeConfig(viteConfig, { - define: { - 'process.env.UNSAFE_DISABLE_ELEMENT_ERRORS': false, - }, resolve: { alias: { 'storybook/internal/theming': 'storybook/theming', 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 235dcaec8a..723bc27f27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11932,12 +11932,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": { @@ -11945,6 +11946,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -34668,7 +34670,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22", + "node": ">=24", "typescript": ">=4.1" }, "peerDependencies": { @@ -34678,8 +34680,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" } }, @@ -34696,7 +34698,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22" + "node": ">=24" } }, "packages/design-tokens/node_modules/@esbuild/aix-ppc64": { @@ -35704,7 +35706,7 @@ "vitest": "^4.1.5" }, "engines": { - "node": ">=22" + "node": ">=24" }, "peerDependencies": { "react": ">=16.0.0 <20.0.0" diff --git a/package.json b/package.json index f533ac1f04..11d510ff8b 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.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..791d23bcab 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, useCallback, type KeyboardEvent } from 'react'; import type { ClickEvent } from '../../types/events.js'; import { Hr } from '../Hr/index.js'; @@ -44,100 +38,70 @@ 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); - const triggerKey = useRef(null); - const triggerId = useId(); - const menuId = useId(); - 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 handleActionMenuItemClick = - (onClick: ActionMenuItemProps['onClick']) => (event: ClickEvent) => { - onClick?.(event); - onToggle(false); - }; + const handleTriggerKeyDown = useCallback( + (event: KeyboardEvent) => { + if (isArrowDown(event)) { + triggerKey.current = 'ArrowDown'; + onToggle(true); + } + if (isArrowUp(event)) { + triggerKey.current = 'ArrowUp'; + onToggle((prev) => !prev); + } + }, + [onToggle], + ); - const isMenu = role === 'menu'; - const menuProps = isMenu - ? { 'role': 'menu', 'aria-labelledby': triggerId } - : {}; + 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) ? ( +
+ ) : ( + - )} - {...props} - > -
- {actions.map((action, index) => - 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.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.spec.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx index 521eb3e668..f754c95e3e 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx @@ -69,19 +69,15 @@ describe('AutocompleteInput', () => { it('should forward a ref', () => { const ref = createRef(); - const { container } = render(); - // eslint-disable-next-line testing-library/no-node-access - const input = container.querySelector('input'); + render(); + const input = screen.queryByRole('combobox'); expect(ref.current).toBe(input); }); it('should merge a custom class name with the default ones', () => { const className = 'foo'; - const { container } = render( - , - ); - // eslint-disable-next-line testing-library/no-node-access - const input = container.querySelector('input'); + render(); + const input = screen.queryByRole('combobox'); expect(input?.className).toContain(className); }); @@ -129,10 +125,8 @@ describe('AutocompleteInput', () => { }); expect(input).toHaveValue('baz'); - // simulate blur - act(() => { - input.blur(); - }); + // simulate click outside + await userEvent.click(document.body); expect(input).toHaveValue('Foo'); }); @@ -299,28 +293,34 @@ describe('AutocompleteInput', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); - it('should close the list box when the escape key is pressed', async () => { - render(); + it('should close the list box and restore value when the escape key is pressed', async () => { + render(); await userEvent.click( screen.getByRole('combobox', { name: props.label }), ); expect(screen.getByRole('listbox')).toBeVisible(); await userEvent.keyboard('{Escape}'); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: props.label })).toHaveValue( + 'Mochi', + ); }); - it('should close the list box on outside click', async () => { - render(); + it('should close the list box and restore value on outside click', async () => { + render(); await userEvent.click( screen.getByRole('combobox', { name: props.label }), ); expect(screen.getByRole('listbox')).toBeVisible(); await userEvent.click(document.body); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: props.label })).toHaveValue( + 'Mochi', + ); }); - it('should close the list box when Enter key is pressed', async () => { - render(); + it('should close the list box and restore value when Enter key is pressed', async () => { + render(); await userEvent.click(screen.getByLabelText(props.label)); @@ -330,6 +330,15 @@ describe('AutocompleteInput', () => { await userEvent.keyboard('{Enter}'); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + + expect(screen.getByRole('combobox', { name: props.label })).toHaveValue( + 'Mochi', + ); + // reopen the list box + await userEvent.keyboard('{ArrowDown}'); + expect(screen.getByRole('combobox', { name: props.label })).toHaveValue( + 'Mochi', + ); }); }); diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx index 57da66f217..46aea3f773 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.stories.tsx @@ -43,8 +43,10 @@ import type { AutocompleteInputOption } from './components/Option/Option.js'; export default { title: 'Forms/AutocompleteInput', component: AutocompleteInput, - tags: ['status:experimental'], + tags: ['status:stable'], argTypes: { + comboboxRef: { table: { disable: true } }, + // Value & change handling value: { table: { diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx index 42a0f254bf..08f8983dc1 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx @@ -17,8 +17,6 @@ import { type ChangeEvent, - type FocusEventHandler, - forwardRef, type KeyboardEventHandler, useCallback, useEffect, @@ -137,483 +135,461 @@ 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.', - ); + } + + const optionValues: string[] = useMemo( + () => + options + .flatMap((option) => (isGroup(option) ? option.options : option)) + .map((option) => option.value), + [options], + ); + + useEffect(() => { + if (isLoading) { + setActiveOption(undefined); } - - 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.', - ); + }, [isLoading]); + + const closeResults = useCallback(() => { + setIsOpen(false); + // when we close the listbox, set the search text to the value of the combobox, + // otherwise, reset it. + if (!Array.isArray(value)) { + changeInputValue(comboboxRef.current, value?.label ?? ''); } - - 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); + setActiveOption(undefined); + }, [value]); + + 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: 4, crossAxis: 0 }), + 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) { + closeResults(); + } } - update(); + 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, update, options.length, value]); - - const handleClickOutside = useCallback(() => { - if (!isImmersive) { - closeResults(); + }, + [ + isOpen, + activeOption, + onChange, + optionValues, + onOptionClick, + searchText, + options, + allowNewItems, + value, + 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 comboboxProps = { + ...props, + label, + size, + 'data-id': autocompleteId, + clearLabel, + value: !Array.isArray(value) && !isOpen ? value?.label : 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, + 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.module.css b/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.module.css index e8be5d9813..cb5bedce76 100644 --- a/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.module.css +++ b/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.module.css @@ -4,7 +4,6 @@ flex-wrap: wrap; gap: var(--cui-spacings-byte); width: 100%; - max-height: 200px; overflow-y: auto; background-color: var(--cui-bg-normal); border: 1px solid var(--cui-border-normal); @@ -42,6 +41,14 @@ text-overflow: ellipsis; } +.content { + display: inline-flex; + flex-wrap: wrap; + gap: var(--cui-spacings-byte); + width: 100%; + overflow-y: auto; +} + /* Validations */ .invalid { @@ -102,9 +109,24 @@ @media (max-width: 479px) { .base { - max-height: 150px; overflow-y: auto; } + + .m { + max-height: 150px; + } + + .m .content { + max-height: 124px; + } + + .s { + max-height: 120px; + } + + .s .content { + max-height: 108px; + } } /* Sizes */ @@ -112,10 +134,15 @@ /* s */ .s { + max-height: 150px; padding: var(--field-input-padding-block) var(--field-input-padding-inline); border-radius: var(--field-input-border-radius); } +.s .content { + max-height: 138px; +} + .s input { font-size: var(--field-input-font-size); line-height: var(--field-input-line-height); @@ -129,10 +156,15 @@ /* m */ .m { + max-height: 200px; padding: var(--field-input-padding-block) var(--field-input-padding-inline); border-radius: var(--field-input-border-radius); } +.m .content { + max-height: 174px; +} + .m input { font-size: var(--field-input-font-size); line-height: var(--field-input-line-height); diff --git a/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.tsx b/packages/circuit-ui/components/AutocompleteInput/components/ComboboxInput/ComboboxInput.tsx index 95e27fb5fa..ae9c083ee0 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 { type Ref, useEffect, useId, useRef, useState } from 'react'; import type { ReturnType } from '../../../../types/return-type.js'; import { idx } from '../../../../util/idx.js'; @@ -40,6 +40,7 @@ import { Button } from '../../../Button/index.js'; import type { AutocompleteInputOption } from '../Option/Option.js'; import classes from './ComboboxInput.module.css'; +import { utilClasses } from '../../../../styles/utility.js'; export interface ComboboxInputProps extends Omit< @@ -68,101 +69,99 @@ export interface ComboboxInputProps */ locale?: Locale; 'data-id'?: string; + comboboxRef?: Ref; } -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, + comboboxRef, + ...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 @@ -216,17 +215,15 @@ export const ComboboxInput = forwardRef( )}
- - - ); - }, -); - -ComboboxInput.displayName = 'ComboboxInput'; +
+ + + ); +} 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/legacy/InlineElements/index.ts b/packages/circuit-ui/components/AutocompleteInput/index.ts similarity index 58% rename from packages/circuit-ui/components/legacy/InlineElements/index.ts rename to packages/circuit-ui/components/AutocompleteInput/index.ts index ab4fa10446..94196b31f2 100644 --- a/packages/circuit-ui/components/legacy/InlineElements/index.ts +++ b/packages/circuit-ui/components/AutocompleteInput/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019, SumUp Ltd. + * 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 @@ -13,6 +13,8 @@ * limitations under the License. */ -export { InlineElements } from './InlineElements.js'; - -export type { InlineElementsProps } from './InlineElements.js'; +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/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/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.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..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`. */ @@ -52,20 +53,13 @@ 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`. */ color?: | 'normal' | 'subtle' - | 'placeholder' | 'on-strong' | 'on-strong-subtle' | 'accent' @@ -111,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); - - 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.`, - ); - } - } +export function Body({ + className, + as, + size: legacySize = 'm', + weight = getDefaultWeight(as), + decoration, + color = 'normal', + variant, + ...props +}: BodyProps) { + const Element = as || getHTMLElement(variant); - 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/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/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.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..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`. */ @@ -35,7 +36,6 @@ export interface CompactProps extends HTMLAttributes { color?: | 'normal' | 'subtle' - | 'placeholder' | 'on-strong' | 'on-strong-subtle' | 'accent' @@ -53,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..937183beff 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,318 @@ 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 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..4311fa5208 100644 --- a/packages/circuit-ui/components/Display/Display.tsx +++ b/packages/circuit-ui/components/Display/Display.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 { CircuitError } from '../../util/errors.js'; import { deprecate } from '../../util/logger.js'; -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 +95,58 @@ 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' && + !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.mdx b/packages/circuit-ui/components/Flag/Flag.mdx index 319be6e44d..dee2f33f1a 100644 --- a/packages/circuit-ui/components/Flag/Flag.mdx +++ b/packages/circuit-ui/components/Flag/Flag.mdx @@ -17,7 +17,7 @@ The Flag component is used to display a country flag given its ISO 3166-1 alpha- ## How to use - The Flag component accepts a `countryCode` prop, which should be a valid ISO 3166-1 alpha-2 code (e.g. `US`, `FR`, `DE`). The component will render the corresponding flag. -- Choose one of the three available sizes: `'s'` (16×12px), `'m'` (24×18px), and `'l'` (32×24px) . Flags have a 4:3 aspect ratio by default. If you need to render a flag in different dimensions, import the svg file directly using the `getIconURL` [helper](https://circuit.sumup.com/?path=/docs/packages-icons--docs&globals=theme:light#load-from-a-url). Avoid using `height` and `width` props as they are deprecated and will be removed soon. +- Choose one of the three available sizes: `'s'` (16×12px), `'m'` (24×18px), and `'l'` (32×24px). Flags have a 4:3 aspect ratio by default. If you need to render a flag in different dimensions, import the svg file directly using the `getIconURL` [helper](https://circuit.sumup.com/?path=/docs/packages-icons--docs&globals=theme:light#load-from-a-url). ## Accessibility diff --git a/packages/circuit-ui/components/Flag/Flag.module.css b/packages/circuit-ui/components/Flag/Flag.module.css index 165ad57f23..1581eb396d 100644 --- a/packages/circuit-ui/components/Flag/Flag.module.css +++ b/packages/circuit-ui/components/Flag/Flag.module.css @@ -4,18 +4,6 @@ justify-content: center; } -@supports not (aspect-ratio: 1 / 1) { - .wrapper { - height: var(--flag-wrapper-height); - } -} - -@supports (aspect-ratio: 1 / 1) { - .wrapper { - aspect-ratio: 1 / 1; - } -} - .base { border: 0.5px solid #ccc; border-radius: 2px; diff --git a/packages/circuit-ui/components/Flag/Flag.spec.tsx b/packages/circuit-ui/components/Flag/Flag.spec.tsx index ca99530518..0b6da1fd35 100644 --- a/packages/circuit-ui/components/Flag/Flag.spec.tsx +++ b/packages/circuit-ui/components/Flag/Flag.spec.tsx @@ -37,18 +37,6 @@ describe('Flag', () => { expect(ref.current).toBe(image); }); - it('should size the image correctly when given a width', () => { - render(); - const image = screen.getByAltText('Germany'); - expect(image.getAttribute('height')).toBe('75px'); - }); - - it('should size the image correctly when given a height', () => { - render(); - const image = screen.getByAltText('Germany'); - expect(image.getAttribute('width')).toBe('160px'); - }); - it('should have no accessibility violations', async () => { const { container } = render(); const actual = await axe(container); diff --git a/packages/circuit-ui/components/Flag/Flag.tsx b/packages/circuit-ui/components/Flag/Flag.tsx index 0e807d8dc8..66906aa382 100644 --- a/packages/circuit-ui/components/Flag/Flag.tsx +++ b/packages/circuit-ui/components/Flag/Flag.tsx @@ -13,47 +13,18 @@ * 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'; -import { deprecate } from '../../util/logger.js'; import classes from './Flag.module.css'; import type { FLAGS } from './constants.js'; type CountryCode = (typeof FLAGS)[number]; -type WithSize = { - /** - * Choose from 3 sizes. - */ - size?: 's' | 'm' | 'l'; - width?: never; - height?: never; -}; - -type WithHeightWidth = - | { - /** - * The width of the flag image. - * @deprecated Use the `size` prop instead. - */ - width?: number; - height?: never; - size?: never; - } - | { - /** - * The height of the flag image. - * @deprecated Use the `size` prop instead. - */ - height?: number; - width?: never; - size?: never; - }; - export type FlagProps = HTMLAttributes & { + ref?: Ref; countryCode: CountryCode; /** * The alt text for the flag image. @@ -67,77 +38,36 @@ export type FlagProps = HTMLAttributes & { * Additional class name to apply to the flag's inner image. */ imageClassName?: string; -} & (WithSize | WithHeightWidth); - -const ASPECT_RATIO = 4 / 3; + /** + * Choose from 3 sizes. + * @default m + */ + size?: 's' | 'm' | 'l'; +}; /** * Renders an SVG icon of a flag. Flags are sourced from: https://flagicons.lipis.dev/ */ -export const Flag = forwardRef( - ( - { - countryCode, - alt, - className, - imageClassName, - size, - width, - height, - ...props - }, - ref, - ) => { - const flagName = `flag_${countryCode.toLowerCase()}` as IconName; - - if (process.env.NODE_ENV !== 'production' && (width || height)) { - deprecate( - 'Flag', - 'The `width` and `height` props are deprecated. Use the `size` prop instead.', - ); - } - - if (size) { - return ( -
- {alt} -
- ); - } - // 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} -
- ); - }, -); +export function Flag({ + countryCode, + alt, + className, + imageClassName, + size = 'm', + ref, + ...props +}: FlagProps) { + const flagName = `flag_${countryCode.toLowerCase()}` as IconName; -Flag.displayName = 'Flag'; + 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..c245afb53d 100644 --- a/packages/circuit-ui/components/Headline/Headline.tsx +++ b/packages/circuit-ui/components/Headline/Headline.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 { CircuitError } from '../../util/errors.js'; import { deprecate } from '../../util/logger.js'; -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 +64,38 @@ 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' && + !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 646125b44d..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,102 +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 24d626951f..2cf4a855cc 100644 --- a/packages/circuit-ui/components/Modal/Modal.tsx +++ b/packages/circuit-ui/components/Modal/Modal.tsx @@ -15,10 +15,9 @@ 'use client'; -import { forwardRef, useCallback, useState, type Ref } from 'react'; +import { 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. @@ -59,23 +53,15 @@ export interface ModalProps extends Omit { export const ANIMATION_DURATION = 300; -export const Modal = forwardRef((props, ref) => { - const { - hideCloseButton, - variant = 'contextual', - className, - contentClassName, - preventClose = false, - children, - onClose, - ...rest - } = props; - if (process.env.NODE_ENV !== 'production' && hideCloseButton) { - deprecate( - 'Modal', - 'The `hideCloseButton` prop has been deprecated. Use the `preventClose` prop instead.', - ); - } +export function Modal({ + variant = 'contextual', + className, + contentClassName, + preventClose = false, + children, + onClose, + ...rest +}: ModalProps) { const [isClosing, setIsClosing] = useState(false); const handleModalCloseEnd = useCallback(() => { @@ -98,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.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..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`. */ @@ -31,7 +32,6 @@ export interface NumeralProps extends HTMLAttributes { color?: | 'normal' | 'subtle' - | 'placeholder' | 'on-strong' | 'on-strong-subtle' | 'accent' @@ -49,34 +49,24 @@ 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'; } /** * 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 ( - ( -