diff --git a/.changeset/responsive-value-utility.md b/.changeset/responsive-value-utility.md new file mode 100644 index 000000000..138cf931d --- /dev/null +++ b/.changeset/responsive-value-utility.md @@ -0,0 +1,5 @@ +--- +'@drivenets/design-system': minor +--- + +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 e49fb9296..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,8 +1,9 @@ import classNames from 'classnames'; import type React from 'react'; import { Children, isValidElement } from 'react'; + 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) => { @@ -19,7 +20,7 @@ const isIconOnly = (children: React.ReactNode) => { /** * Design system Button component */ -const DsButton: React.FC = ({ +const DsButton: React.FC = ({ buttonType, variant = 'filled', size = 'medium', 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..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 @@ -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]; @@ -9,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' @@ -39,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/index.ts b/packages/design-system/src/index.ts index ac8f389ec..ec7c726f9 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -58,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/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/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 new file mode 100644 index 000000000..f2c92ec11 --- /dev/null +++ b/packages/design-system/src/utils/responsive.browser.test.ts @@ -0,0 +1,94 @@ +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, withResponsiveProps } 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'); + }); +}); + +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 new file mode 100644 index 000000000..338d04405 --- /dev/null +++ b/packages/design-system/src/utils/responsive.ts @@ -0,0 +1,79 @@ +import { createElement, useSyncExternalStore, type FunctionComponent } from 'react'; + +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]; + +export type Responsive = Partial>; + +export type ResponsiveValue = T | Responsive; + +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)`; + +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); + +/** + * 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 => + 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 k = String(key); + const value = resolved[k]; + + if (value !== undefined) { + resolved[k] = resolveResponsiveValue(value, 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 new file mode 100644 index 000000000..1ebae14f3 --- /dev/null +++ b/packages/design-system/src/utils/responsive.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +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', () => { + 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 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); + }); +}); + +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'); + }); + + 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'); + }); +}); diff --git a/packages/design-system/tests/exports.unit.test.ts b/packages/design-system/tests/exports.unit.test.ts index f07f6dfd8..ee2725810 100644 --- a/packages/design-system/tests/exports.unit.test.ts +++ b/packages/design-system/tests/exports.unit.test.ts @@ -9,18 +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 tests = expectedLines.map((expectedLine, index) => { + const tests = allExpectedLines.map((expectedLine, index) => { return [actualLines[index], expectedLine]; }); - it('should export all components', () => { - expect(actualLines.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); }); 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, }, }, {