From 3835a393e59c80a093c1ab31cf5c7e258320c142 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Tue, 31 Mar 2026 15:42:16 +0200 Subject: [PATCH 01/10] fix(design-system): add responsive helper function [AR-53842] --- .changeset/responsive-value-utility.md | 0 .../versions/ds-button-new/ds-button-new.tsx | 6 +- .../ds-button-new/ds-button-new.types.ts | 4 +- packages/design-system/src/index.ts | 1 + .../src/utils/responsive.browser.test.ts | 66 +++++++++++++++++++ .../design-system/src/utils/responsive.ts | 35 ++++++++++ .../src/utils/responsive.unit.test.ts | 21 ++++++ 7 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 .changeset/responsive-value-utility.md create mode 100644 packages/design-system/src/utils/responsive.browser.test.ts create mode 100644 packages/design-system/src/utils/responsive.ts create mode 100644 packages/design-system/src/utils/responsive.unit.test.ts diff --git a/.changeset/responsive-value-utility.md b/.changeset/responsive-value-utility.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx index 756c7a54a..ffff74189 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import type React from 'react'; import { Children, isValidElement } from 'react'; +import { useResponsiveValue } from '../../../../utils/responsive'; import styles from './ds-button-new.module.scss'; import type { DsButtonProps } from './ds-button-new.types'; import { DsIcon } from '../../../ds-icon'; @@ -22,13 +23,14 @@ const isIconOnly = (children: React.ReactNode) => { const DsButton: React.FC = ({ buttonType, variant = 'filled', - size = 'medium', + size: sizeProp = 'medium', disabled = false, className, contentClassName, children, ...props }) => { + const size = useResponsiveValue(sizeProp); const type = buttonType ?? (variant === 'ghost' ? 'secondary' : 'primary'); const buttonClass = classNames( styles.button, @@ -37,7 +39,7 @@ const DsButton: React.FC = ({ className, // @ts-expect-error: we don't have all variations of classnames defined - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + styles[`${type}-${variant}`], ); diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts index 073f8ef44..085dd04bd 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts @@ -1,5 +1,7 @@ import type React from 'react'; +import type { ResponsiveValue } from '../../../../utils/responsive'; + export const buttonTypes = ['primary', 'secondary', 'secondary-light', 'tertiary'] as const; export type ButtonType = (typeof buttonTypes)[number]; @@ -26,7 +28,7 @@ export interface DsButtonProps extends React.ButtonHTMLAttributes; /** * Whether the button is disabled diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index ac8f389ec..2a52a0894 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -1,3 +1,4 @@ +export * from './utils/responsive'; export * from './components/ds-alert-banner'; export * from './components/ds-autocomplete'; export * from './components/ds-avatar'; diff --git a/packages/design-system/src/utils/responsive.browser.test.ts b/packages/design-system/src/utils/responsive.browser.test.ts new file mode 100644 index 000000000..069d9ba7a --- /dev/null +++ b/packages/design-system/src/utils/responsive.browser.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from 'vitest-browser-react'; + +import { useBreakpoint, useResponsiveValue } from './responsive'; + +const mockMatchMedia = (matches: boolean) => { + const listeners: Array<() => void> = []; + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue({ + matches, + addEventListener: (_: string, cb: () => void) => listeners.push(cb), + removeEventListener: (_: string, cb: () => void) => { + const idx = listeners.indexOf(cb); + + if (idx >= 0) { + listeners.splice(idx, 1); + } + }, + }), + }); + + return { + triggerChange: () => listeners.forEach((cb) => cb()), + }; +}; + +describe('useBreakpoint', () => { + it('should return lg when viewport >= 1440px', async () => { + mockMatchMedia(true); + const { result } = await renderHook(() => useBreakpoint()); + + expect(result.current).toBe('lg'); + }); + + it('should return md when viewport < 1440px', async () => { + mockMatchMedia(false); + const { result } = await renderHook(() => useBreakpoint()); + + expect(result.current).toBe('md'); + }); +}); + +describe('useResponsiveValue', () => { + it('should return a static value unchanged', async () => { + mockMatchMedia(true); + const { result } = await renderHook(() => useResponsiveValue('large')); + + expect(result.current).toBe('large'); + }); + + it('should resolve lg value on large screens', async () => { + mockMatchMedia(true); + const { result } = await renderHook(() => useResponsiveValue({ lg: 'large', md: 'small' })); + + expect(result.current).toBe('large'); + }); + + it('should resolve md value on small screens', async () => { + mockMatchMedia(false); + const { result } = await renderHook(() => useResponsiveValue({ lg: 'large', md: 'small' })); + + expect(result.current).toBe('small'); + }); +}); diff --git a/packages/design-system/src/utils/responsive.ts b/packages/design-system/src/utils/responsive.ts new file mode 100644 index 000000000..49109c26b --- /dev/null +++ b/packages/design-system/src/utils/responsive.ts @@ -0,0 +1,35 @@ +import { useSyncExternalStore } from 'react'; + +export const BREAKPOINT_LG = 1440; + +export const breakpoints = ['lg', 'md'] as const; +export type Breakpoint = (typeof breakpoints)[number]; + +export type ResponsiveValue = T | { lg: T; md: T }; + +export const isResponsiveValue = ( + value: ResponsiveValue, +): value is { lg: T; md: T } => + value !== null && typeof value === 'object' && 'lg' in value && 'md' in value; + +const MEDIA_QUERY = `(min-width: ${String(BREAKPOINT_LG)}px)`; + +const subscribe = (callback: () => void) => { + const mql = window.matchMedia(MEDIA_QUERY); + mql.addEventListener('change', callback); + return () => mql.removeEventListener('change', callback); +}; + +const getSnapshot = (): Breakpoint => + window.matchMedia(MEDIA_QUERY).matches ? 'lg' : 'md'; + +const getServerSnapshot = (): Breakpoint => 'lg'; + +export const useBreakpoint = (): Breakpoint => + useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + +const resolveResponsive = (value: ResponsiveValue, breakpoint: Breakpoint): T => + isResponsiveValue(value) ? value[breakpoint] : value; + +export const useResponsiveValue = (value: ResponsiveValue): T => + resolveResponsive(value, useBreakpoint()); diff --git a/packages/design-system/src/utils/responsive.unit.test.ts b/packages/design-system/src/utils/responsive.unit.test.ts new file mode 100644 index 000000000..a9b57fa10 --- /dev/null +++ b/packages/design-system/src/utils/responsive.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isResponsiveValue } from './responsive'; + +describe('isResponsiveValue', () => { + it('should return true for a responsive object', () => { + expect(isResponsiveValue({ lg: 'large', md: 'small' })).toBe(true); + }); + + it('should return false for a string', () => { + expect(isResponsiveValue('large')).toBe(false); + }); + + it('should return false for null', () => { + expect(isResponsiveValue(null as unknown as string)).toBe(false); + }); + + it('should return false for an object missing md', () => { + expect(isResponsiveValue({ lg: 'large' } as { lg: string; md: string })).toBe(false); + }); +}); From ef9b463a5f6da634d88a9eaba520231186a07bba Mon Sep 17 00:00:00 2001 From: vpolessky Date: Tue, 31 Mar 2026 15:43:22 +0200 Subject: [PATCH 02/10] fix(design-system): add eslint ignore [AR-53842] --- .../ds-button/versions/ds-button-new/ds-button-new.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx index ffff74189..bd940ed8f 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx @@ -39,7 +39,7 @@ const DsButton: React.FC = ({ className, // @ts-expect-error: we don't have all variations of classnames defined - + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument styles[`${type}-${variant}`], ); From c1bcad4e36516a03b94ecba1cfe7251654422262 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Tue, 31 Mar 2026 15:47:09 +0200 Subject: [PATCH 03/10] fix(design-system): add changeset [AR-53842] --- .changeset/responsive-value-utility.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.changeset/responsive-value-utility.md b/.changeset/responsive-value-utility.md index e69de29bb..7131e063a 100644 --- a/.changeset/responsive-value-utility.md +++ b/.changeset/responsive-value-utility.md @@ -0,0 +1,5 @@ +--- +'@drivenets/design-system': minor +--- + +Add `useResponsiveValue` hook and `ResponsiveValue` type for responsive props at 1440px breakpoint From 9d386cada41abc986866f0cbf4c6fc0fe00c37e7 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Tue, 31 Mar 2026 15:50:10 +0200 Subject: [PATCH 04/10] fix(design-system): format fix [AR-53842] --- packages/design-system/src/utils/responsive.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/design-system/src/utils/responsive.ts b/packages/design-system/src/utils/responsive.ts index 49109c26b..432831d5c 100644 --- a/packages/design-system/src/utils/responsive.ts +++ b/packages/design-system/src/utils/responsive.ts @@ -7,9 +7,7 @@ export type Breakpoint = (typeof breakpoints)[number]; export type ResponsiveValue = T | { lg: T; md: T }; -export const isResponsiveValue = ( - value: ResponsiveValue, -): value is { lg: T; md: T } => +export const isResponsiveValue = (value: ResponsiveValue): value is { lg: T; md: T } => value !== null && typeof value === 'object' && 'lg' in value && 'md' in value; const MEDIA_QUERY = `(min-width: ${String(BREAKPOINT_LG)}px)`; @@ -20,8 +18,7 @@ const subscribe = (callback: () => void) => { return () => mql.removeEventListener('change', callback); }; -const getSnapshot = (): Breakpoint => - window.matchMedia(MEDIA_QUERY).matches ? 'lg' : 'md'; +const getSnapshot = (): Breakpoint => (window.matchMedia(MEDIA_QUERY).matches ? 'lg' : 'md'); const getServerSnapshot = (): Breakpoint => 'lg'; From bcb5b7793bd9d781453f15755dd9adf34b33352e Mon Sep 17 00:00:00 2001 From: vpolessky Date: Tue, 31 Mar 2026 16:07:27 +0200 Subject: [PATCH 05/10] fix(design-system): fix export [AR-53842] --- packages/design-system/src/index.ts | 2 +- packages/design-system/tests/exports.unit.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index 2a52a0894..ec7c726f9 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -1,4 +1,3 @@ -export * from './utils/responsive'; export * from './components/ds-alert-banner'; export * from './components/ds-autocomplete'; export * from './components/ds-avatar'; @@ -59,3 +58,4 @@ export * from './components/ds-tooltip'; export * from './components/ds-tree'; export * from './components/ds-typography'; export * from './components/ds-vertical-tabs'; +export * from './utils/responsive'; diff --git a/packages/design-system/tests/exports.unit.test.ts b/packages/design-system/tests/exports.unit.test.ts index f07f6dfd8..4e73ca8a7 100644 --- a/packages/design-system/tests/exports.unit.test.ts +++ b/packages/design-system/tests/exports.unit.test.ts @@ -11,13 +11,14 @@ describe('Design System exports', () => { const actualContent = fs.readFileSync('./src/index.ts', 'utf-8'); const actualLines = actualContent.split('\n').filter((line) => line.trim().length > 0); + const componentLines = actualLines.filter((line) => line.includes('./components/')); const tests = expectedLines.map((expectedLine, index) => { - return [actualLines[index], expectedLine]; + return [componentLines[index], expectedLine]; }); it('should export all components', () => { - expect(actualLines.length).toBe(expectedLines.length); + expect(componentLines.length).toBe(expectedLines.length); }); // split expected by line and assert that each line is exported From 644fae56dee7898f8bc0c4768fa26e85da282bf3 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Tue, 31 Mar 2026 16:41:52 +0200 Subject: [PATCH 06/10] fix(design-system): update export test [AR-53842] --- packages/design-system/tests/exports.unit.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/design-system/tests/exports.unit.test.ts b/packages/design-system/tests/exports.unit.test.ts index 4e73ca8a7..ee2725810 100644 --- a/packages/design-system/tests/exports.unit.test.ts +++ b/packages/design-system/tests/exports.unit.test.ts @@ -9,19 +9,21 @@ describe('Design System exports', () => { return `export * from './components/${component}';`; }); + const utilityExports = ["export * from './utils/responsive';"]; + + const allExpectedLines = [...expectedLines, ...utilityExports]; + const actualContent = fs.readFileSync('./src/index.ts', 'utf-8'); const actualLines = actualContent.split('\n').filter((line) => line.trim().length > 0); - const componentLines = actualLines.filter((line) => line.includes('./components/')); - const tests = expectedLines.map((expectedLine, index) => { - return [componentLines[index], expectedLine]; + const tests = allExpectedLines.map((expectedLine, index) => { + return [actualLines[index], expectedLine]; }); - it('should export all components', () => { - expect(componentLines.length).toBe(expectedLines.length); + it('should export all components and utilities', () => { + expect(actualLines.length).toBe(allExpectedLines.length); }); - // split expected by line and assert that each line is exported it.each(tests)('"%s" is exported from the package', (actual, expected) => { expect(actual).toBe(expected); }); From a345b24ef2128187b702fa22f9e19338c7b73240 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Wed, 1 Apr 2026 10:42:09 +0200 Subject: [PATCH 07/10] fix(design-system): change approach for responsive [AR-53842] --- .changeset/responsive-value-utility.md | 2 +- .../ds-button-new/ds-button-new.module.scss | 34 ++++++++++---- .../versions/ds-button-new/ds-button-new.tsx | 9 ++-- .../ds-button-new/ds-button-new.types.ts | 12 ++++- .../ds-button/versions/ds-button-new/index.ts | 5 +- .../design-system/src/styles/_responsive.scss | 13 +++++ .../design-system/src/styles/_variables.scss | 2 + .../src/utils/responsive.browser.test.ts | 30 +++++++++++- .../design-system/src/utils/responsive.ts | 47 +++++++++++++++++-- .../src/utils/responsive.unit.test.ts | 17 ++++++- 10 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 packages/design-system/src/styles/_responsive.scss diff --git a/.changeset/responsive-value-utility.md b/.changeset/responsive-value-utility.md index 7131e063a..138cf931d 100644 --- a/.changeset/responsive-value-utility.md +++ b/.changeset/responsive-value-utility.md @@ -2,4 +2,4 @@ '@drivenets/design-system': minor --- -Add `useResponsiveValue` hook and `ResponsiveValue` type for responsive props at 1440px breakpoint +Add responsive prop support at 1440px breakpoint with CSS-first styling and `useResponsiveValue` hook for JS conditional rendering diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss index 07e367e2a..87fbba18a 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.module.scss @@ -5,6 +5,27 @@ $button-size-small: 28px; $button-size-default: 32px; $button-size-large: 40px; +@mixin content-size-tiny { + @include typography.body-xs-md; + height: $button-size-tiny; + border-radius: var(--spacing-4xs); + --icon-width-tiny: 13px; +} + +@mixin content-size-small { + @include typography.body-xs-md; + height: $button-size-small; +} + +@mixin content-size-medium { + height: $button-size-default; +} + +@mixin content-size-large { + @include typography.body-md-md; + height: $button-size-large; +} + .button { @include typography.body-sm-md; display: flex; @@ -31,24 +52,19 @@ $button-size-large: 40px; } &.tiny .content { - @include typography.body-xs-md; - height: $button-size-tiny; - border-radius: var(--spacing-4xs); - --icon-width-tiny: 13px; + @include content-size-tiny; } &.small .content { - @include typography.body-xs-md; - height: $button-size-small; + @include content-size-small; } &.medium .content { - height: $button-size-default; + @include content-size-medium; } &.large .content { - @include typography.body-md-md; - height: $button-size-large; + @include content-size-large; } &.iconButton { diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx index 41fbb4663..c9908bc03 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.tsx @@ -1,9 +1,9 @@ import classNames from 'classnames'; import type React from 'react'; import { Children, isValidElement } from 'react'; -import { useResponsiveValue } from '../../../../utils/responsive'; + import styles from './ds-button-new.module.scss'; -import type { DsButtonProps } from './ds-button-new.types'; +import type { DsButtonBaseProps } from './ds-button-new.types'; import { DsIcon } from '../../../ds-icon'; const isIconOnly = (children: React.ReactNode) => { @@ -20,17 +20,16 @@ const isIconOnly = (children: React.ReactNode) => { /** * Design system Button component */ -const DsButton: React.FC = ({ +const DsButton: React.FC = ({ buttonType, variant = 'filled', - size: sizeProp = 'medium', + size = 'medium', disabled = false, className, contentClassName, children, ...props }) => { - const size = useResponsiveValue(sizeProp); const type = buttonType ?? (variant === 'ghost' ? 'secondary' : 'primary'); const buttonClass = classNames( styles.button, diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts index 085dd04bd..31347b58d 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/ds-button-new.types.ts @@ -11,7 +11,7 @@ export type ButtonVariant = (typeof buttonVariants)[number]; export const buttonSizes = ['large', 'medium', 'small', 'tiny'] as const; export type ButtonSize = (typeof buttonSizes)[number]; -export interface DsButtonProps extends React.ButtonHTMLAttributes { +export interface DsButtonBaseProps extends React.ButtonHTMLAttributes { /** * Type of the button * @default 'primary' @@ -28,7 +28,7 @@ export interface DsButtonProps extends React.ButtonHTMLAttributes; + size?: ButtonSize; /** * Whether the button is disabled @@ -41,3 +41,11 @@ export interface DsButtonProps extends React.ButtonHTMLAttributes { + /** + * Size of the button. Accepts a static value or a responsive object. + * @default 'medium' + */ + size?: ResponsiveValue; +} diff --git a/packages/design-system/src/components/ds-button/versions/ds-button-new/index.ts b/packages/design-system/src/components/ds-button/versions/ds-button-new/index.ts index 5ed89773f..fd2a5f46e 100644 --- a/packages/design-system/src/components/ds-button/versions/ds-button-new/index.ts +++ b/packages/design-system/src/components/ds-button/versions/ds-button-new/index.ts @@ -1,2 +1,5 @@ -export { default as DsButtonNew } from './ds-button-new'; +import { withResponsiveProps } from '../../../../utils/responsive'; +import DsButtonBase from './ds-button-new'; + +export const DsButtonNew = withResponsiveProps(DsButtonBase, ['size']); export * from './ds-button-new.types'; diff --git a/packages/design-system/src/styles/_responsive.scss b/packages/design-system/src/styles/_responsive.scss new file mode 100644 index 000000000..6aad1d4a8 --- /dev/null +++ b/packages/design-system/src/styles/_responsive.scss @@ -0,0 +1,13 @@ +@use 'variables' as var; + +@mixin lg { + @media (min-width: #{var.$breakpoint-lg}) { + @content; + } +} + +@mixin md { + @media (max-width: #{var.$breakpoint-lg - 1}) { + @content; + } +} diff --git a/packages/design-system/src/styles/_variables.scss b/packages/design-system/src/styles/_variables.scss index 650edc1a6..03a142dd3 100644 --- a/packages/design-system/src/styles/_variables.scss +++ b/packages/design-system/src/styles/_variables.scss @@ -42,3 +42,5 @@ $line-height-action-small: 20px; $sidebar-spacing: 10px; $sidebar-width: 64px; $sidebar-width-expanded: 256px; + +$breakpoint-lg: 1440px; diff --git a/packages/design-system/src/utils/responsive.browser.test.ts b/packages/design-system/src/utils/responsive.browser.test.ts index 069d9ba7a..f2c92ec11 100644 --- a/packages/design-system/src/utils/responsive.browser.test.ts +++ b/packages/design-system/src/utils/responsive.browser.test.ts @@ -1,7 +1,9 @@ +import { createElement } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { page } from 'vitest/browser'; import { renderHook } from 'vitest-browser-react'; -import { useBreakpoint, useResponsiveValue } from './responsive'; +import { useBreakpoint, useResponsiveValue, withResponsiveProps } from './responsive'; const mockMatchMedia = (matches: boolean) => { const listeners: Array<() => void> = []; @@ -64,3 +66,29 @@ describe('useResponsiveValue', () => { expect(result.current).toBe('small'); }); }); + +describe('withResponsiveProps', () => { + const Base = ({ value }: { value?: string }) => createElement('span', null, value); + const Enhanced = withResponsiveProps(Base, ['value']); + + it('should resolve responsive prop to lg value on large screens', async () => { + mockMatchMedia(true); + await page.render(createElement(Enhanced, { value: { lg: 'desktop', md: 'mobile' } })); + + await expect.element(page.getByText('desktop')).toBeInTheDocument(); + }); + + it('should resolve responsive prop to md value on small screens', async () => { + mockMatchMedia(false); + await page.render(createElement(Enhanced, { value: { lg: 'desktop', md: 'mobile' } })); + + await expect.element(page.getByText('mobile')).toBeInTheDocument(); + }); + + it('should pass through static values unchanged', async () => { + mockMatchMedia(true); + await page.render(createElement(Enhanced, { value: 'static' })); + + await expect.element(page.getByText('static')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/utils/responsive.ts b/packages/design-system/src/utils/responsive.ts index 432831d5c..647b04c34 100644 --- a/packages/design-system/src/utils/responsive.ts +++ b/packages/design-system/src/utils/responsive.ts @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react'; +import { createElement, useSyncExternalStore, type FunctionComponent } from 'react'; export const BREAKPOINT_LG = 1440; @@ -10,6 +10,9 @@ export type ResponsiveValue = T | { lg: T; md: T }; export const isResponsiveValue = (value: ResponsiveValue): value is { lg: T; md: T } => value !== null && typeof value === 'object' && 'lg' in value && 'md' in value; +export const resolveResponsiveValue = (value: ResponsiveValue, breakpoint: Breakpoint): T => + isResponsiveValue(value) ? value[breakpoint] : value; + const MEDIA_QUERY = `(min-width: ${String(BREAKPOINT_LG)}px)`; const subscribe = (callback: () => void) => { @@ -25,8 +28,42 @@ const getServerSnapshot = (): Breakpoint => 'lg'; export const useBreakpoint = (): Breakpoint => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); -const resolveResponsive = (value: ResponsiveValue, breakpoint: Breakpoint): T => - isResponsiveValue(value) ? value[breakpoint] : value; - +/** + * Resolves a `ResponsiveValue` to `T` using the current breakpoint. + * Use for conditional rendering or JS logic that depends on the breakpoint. + */ export const useResponsiveValue = (value: ResponsiveValue): T => - resolveResponsive(value, useBreakpoint()); + resolveResponsiveValue(value, useBreakpoint()); + +/** + * Wraps a component so that the specified props accept `ResponsiveValue`. + * The wrapper resolves each responsive prop to a plain value before rendering, + * so the wrapped component stays completely unaware of breakpoints. + */ +export function withResponsiveProps( + Component: FunctionComponent, + responsiveKeys: Keys, +) { + type EnhancedProps = { + [P in keyof Props]: P extends Keys[number] ? ResponsiveValue> : Props[P]; + }; + + const Wrapper = (props: EnhancedProps) => { + const breakpoint = useBreakpoint(); + const resolved = { ...props } as Record; + + for (const key of responsiveKeys) { + const value = resolved[key as string]; + + if (value !== undefined) { + resolved[key as string] = resolveResponsiveValue(value as ResponsiveValue, breakpoint); + } + } + + return createElement(Component as FunctionComponent>, resolved); + }; + + Wrapper.displayName = `withResponsiveProps(${Component.displayName ?? Component.name})`; + + return Wrapper; +} diff --git a/packages/design-system/src/utils/responsive.unit.test.ts b/packages/design-system/src/utils/responsive.unit.test.ts index a9b57fa10..bad3579ae 100644 --- a/packages/design-system/src/utils/responsive.unit.test.ts +++ b/packages/design-system/src/utils/responsive.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isResponsiveValue } from './responsive'; +import { isResponsiveValue, resolveResponsiveValue } from './responsive'; describe('isResponsiveValue', () => { it('should return true for a responsive object', () => { @@ -19,3 +19,18 @@ describe('isResponsiveValue', () => { expect(isResponsiveValue({ lg: 'large' } as { lg: string; md: string })).toBe(false); }); }); + +describe('resolveResponsiveValue', () => { + it('should return static value unchanged for both breakpoints', () => { + expect(resolveResponsiveValue('large', 'lg')).toBe('large'); + expect(resolveResponsiveValue('large', 'md')).toBe('large'); + }); + + it('should resolve lg value from responsive object', () => { + expect(resolveResponsiveValue({ lg: 'large', md: 'small' }, 'lg')).toBe('large'); + }); + + it('should resolve md value from responsive object', () => { + expect(resolveResponsiveValue({ lg: 'large', md: 'small' }, 'md')).toBe('small'); + }); +}); From f109f5876ba9ceaa2db1110bbcc9a96bd7ed4b8e Mon Sep 17 00:00:00 2001 From: vpolessky Date: Thu, 2 Apr 2026 10:47:43 +0200 Subject: [PATCH 08/10] fix(design-system): take breakpoint value from scss [AR-53842] --- .../design-system/src/styles/_breakpoints.module.scss | 5 +++++ packages/design-system/src/utils/responsive.ts | 4 +++- packages/design-system/src/utils/responsive.unit.test.ts | 8 +++++++- packages/design-system/vitest.config.ts | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 packages/design-system/src/styles/_breakpoints.module.scss diff --git a/packages/design-system/src/styles/_breakpoints.module.scss b/packages/design-system/src/styles/_breakpoints.module.scss new file mode 100644 index 000000000..8fdb7fcf5 --- /dev/null +++ b/packages/design-system/src/styles/_breakpoints.module.scss @@ -0,0 +1,5 @@ +@use 'variables' as var; + +:export { + breakpointLg: #{var.$breakpoint-lg}; +} diff --git a/packages/design-system/src/utils/responsive.ts b/packages/design-system/src/utils/responsive.ts index 647b04c34..907025316 100644 --- a/packages/design-system/src/utils/responsive.ts +++ b/packages/design-system/src/utils/responsive.ts @@ -1,6 +1,8 @@ import { createElement, useSyncExternalStore, type FunctionComponent } from 'react'; -export const BREAKPOINT_LG = 1440; +import breakpointTokens from '../styles/_breakpoints.module.scss'; + +export const BREAKPOINT_LG = parseInt(breakpointTokens.breakpointLg, 10); export const breakpoints = ['lg', 'md'] as const; export type Breakpoint = (typeof breakpoints)[number]; diff --git a/packages/design-system/src/utils/responsive.unit.test.ts b/packages/design-system/src/utils/responsive.unit.test.ts index bad3579ae..0932ccb7b 100644 --- a/packages/design-system/src/utils/responsive.unit.test.ts +++ b/packages/design-system/src/utils/responsive.unit.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { isResponsiveValue, resolveResponsiveValue } from './responsive'; +import { BREAKPOINT_LG, isResponsiveValue, resolveResponsiveValue } from './responsive'; + +describe('BREAKPOINT_LG', () => { + it('should be sourced from the SCSS variable and equal 1440', () => { + expect(BREAKPOINT_LG).toBe(1440); + }); +}); describe('isResponsiveValue', () => { it('should return true for a responsive object', () => { diff --git a/packages/design-system/vitest.config.ts b/packages/design-system/vitest.config.ts index 0df4de330..fe895dffa 100644 --- a/packages/design-system/vitest.config.ts +++ b/packages/design-system/vitest.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ test: { name: 'unit', include: [testPattern('unit')], + css: true, }, }, { From 569cfcbdbaecf0e8de14952fc123bde556cece91 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Thu, 2 Apr 2026 10:56:07 +0200 Subject: [PATCH 09/10] fix(design-system): update types [AR-53842] --- packages/design-system/src/utils/responsive.ts | 17 ++++++++++++----- .../src/utils/responsive.unit.test.ts | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/design-system/src/utils/responsive.ts b/packages/design-system/src/utils/responsive.ts index 907025316..c6ef13664 100644 --- a/packages/design-system/src/utils/responsive.ts +++ b/packages/design-system/src/utils/responsive.ts @@ -7,13 +7,20 @@ export const BREAKPOINT_LG = parseInt(breakpointTokens.breakpointLg, 10); export const breakpoints = ['lg', 'md'] as const; export type Breakpoint = (typeof breakpoints)[number]; -export type ResponsiveValue = T | { lg: T; md: T }; +export type Responsive = Partial>; -export const isResponsiveValue = (value: ResponsiveValue): value is { lg: T; md: T } => - value !== null && typeof value === 'object' && 'lg' in value && 'md' in value; +export type ResponsiveValue = T | Responsive; -export const resolveResponsiveValue = (value: ResponsiveValue, breakpoint: Breakpoint): T => - isResponsiveValue(value) ? value[breakpoint] : value; +export const isResponsiveValue = (value: ResponsiveValue): value is Responsive => + value !== null && typeof value === 'object' && breakpoints.some((bp) => bp in value); + +export const resolveResponsiveValue = (value: ResponsiveValue, breakpoint: Breakpoint): T => { + if (!isResponsiveValue(value)) { + return value; + } + + return (value[breakpoint] ?? value.lg ?? value.md) as T; +}; const MEDIA_QUERY = `(min-width: ${String(BREAKPOINT_LG)}px)`; diff --git a/packages/design-system/src/utils/responsive.unit.test.ts b/packages/design-system/src/utils/responsive.unit.test.ts index 0932ccb7b..1ebae14f3 100644 --- a/packages/design-system/src/utils/responsive.unit.test.ts +++ b/packages/design-system/src/utils/responsive.unit.test.ts @@ -21,8 +21,12 @@ describe('isResponsiveValue', () => { expect(isResponsiveValue(null as unknown as string)).toBe(false); }); - it('should return false for an object missing md', () => { - expect(isResponsiveValue({ lg: 'large' } as { lg: string; md: string })).toBe(false); + it('should return true for an object with only lg', () => { + expect(isResponsiveValue({ lg: 'large' })).toBe(true); + }); + + it('should return true for an object with only md', () => { + expect(isResponsiveValue({ md: 'small' })).toBe(true); }); }); @@ -39,4 +43,12 @@ describe('resolveResponsiveValue', () => { it('should resolve md value from responsive object', () => { expect(resolveResponsiveValue({ lg: 'large', md: 'small' }, 'md')).toBe('small'); }); + + it('should fall back to lg when md is not specified', () => { + expect(resolveResponsiveValue({ lg: 'large' }, 'md')).toBe('large'); + }); + + it('should fall back to md when lg is not specified', () => { + expect(resolveResponsiveValue({ md: 'small' }, 'lg')).toBe('small'); + }); }); From 77cd41eec8836cb41a0c7216f898fdaa0c5b5fd4 Mon Sep 17 00:00:00 2001 From: vpolessky Date: Thu, 2 Apr 2026 12:47:35 +0200 Subject: [PATCH 10/10] fix(design-system): remove extra type casting [AR-53842] --- packages/design-system/src/utils/responsive.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/design-system/src/utils/responsive.ts b/packages/design-system/src/utils/responsive.ts index c6ef13664..338d04405 100644 --- a/packages/design-system/src/utils/responsive.ts +++ b/packages/design-system/src/utils/responsive.ts @@ -62,10 +62,11 @@ export function withResponsiveProps; for (const key of responsiveKeys) { - const value = resolved[key as string]; + const k = String(key); + const value = resolved[k]; if (value !== undefined) { - resolved[key as string] = resolveResponsiveValue(value as ResponsiveValue, breakpoint); + resolved[k] = resolveResponsiveValue(value, breakpoint); } }