Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/responsive-value-utility.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -19,7 +20,7 @@ const isIconOnly = (children: React.ReactNode) => {
/**
* Design system Button component
*/
const DsButton: React.FC<DsButtonProps> = ({
const DsButton: React.FC<DsButtonBaseProps> = ({
buttonType,
variant = 'filled',
size = 'medium',
Expand Down
Original file line number Diff line number Diff line change
@@ -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];

Expand All @@ -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<HTMLButtonElement> {
export interface DsButtonBaseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Type of the button
* @default 'primary'
Expand Down Expand Up @@ -39,3 +41,11 @@ export interface DsButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElem
*/
contentClassName?: string;
}

export interface DsButtonProps extends Omit<DsButtonBaseProps, 'size'> {
/**
* Size of the button. Accepts a static value or a responsive object.
* @default 'medium'
*/
size?: ResponsiveValue<ButtonSize>;
}
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions packages/design-system/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 5 additions & 0 deletions packages/design-system/src/styles/_breakpoints.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@use 'variables' as var;

:export {
breakpointLg: #{var.$breakpoint-lg};
}
13 changes: 13 additions & 0 deletions packages/design-system/src/styles/_responsive.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions packages/design-system/src/styles/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,5 @@ $line-height-action-small: 20px;
$sidebar-spacing: 10px;
$sidebar-width: 64px;
$sidebar-width-expanded: 256px;

$breakpoint-lg: 1440px;
94 changes: 94 additions & 0 deletions packages/design-system/src/utils/responsive.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
79 changes: 79 additions & 0 deletions packages/design-system/src/utils/responsive.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pick question: Does it work in the build script? Just to be sure it's picked up, maybe something compiler/bundler related could be missing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

working well


export const breakpoints = ['lg', 'md'] as const;
export type Breakpoint = (typeof breakpoints)[number];

export type Responsive<T> = Partial<Record<Breakpoint, T>>;

export type ResponsiveValue<T> = T | Responsive<T>;

export const isResponsiveValue = <T>(value: ResponsiveValue<T>): value is Responsive<T> =>
value !== null && typeof value === 'object' && breakpoints.some((bp) => bp in value);

export const resolveResponsiveValue = <T>(value: ResponsiveValue<T>, 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';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It returns static value, why is that? Assumption server will always render the 'lg', right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we do not have info during SSR about screen size it is fallback for lg size


export const useBreakpoint = (): Breakpoint =>
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

/**
* Resolves a `ResponsiveValue<T>` to `T` using the current breakpoint.
* Use for conditional rendering or JS logic that depends on the breakpoint.
*/
export const useResponsiveValue = <T>(value: ResponsiveValue<T>): T =>
resolveResponsiveValue(value, useBreakpoint());

/**
* Wraps a component so that the specified props accept `ResponsiveValue<T>`.
* The wrapper resolves each responsive prop to a plain value before rendering,
* so the wrapped component stays completely unaware of breakpoints.
*/
export function withResponsiveProps<Props, const Keys extends readonly (keyof Props)[]>(
Component: FunctionComponent<Props>,
responsiveKeys: Keys,
) {
type EnhancedProps = {
[P in keyof Props]: P extends Keys[number] ? ResponsiveValue<NonNullable<Props[P]>> : Props[P];
};

const Wrapper = (props: EnhancedProps) => {
const breakpoint = useBreakpoint();
const resolved = { ...props } as Record<string, unknown>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of type casting. Can you verify whether it can be avoided?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • as T (line 22) — Partial makes every lookup T | undefined; TS can't track that isResponsiveValue guarantees at least one defined value. ! would be more precise but is banned by ESLint.
  • { ...props } as Record<string, unknown> (line 62) — generic EnhancedProps can't be indexed by string without widening
  • Component as FunctionComponent<...> (line 72) — widened Record<string, unknown> isn't assignable back to FunctionComponent

removed cast for string


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<Record<string, unknown>>, resolved);
};

Wrapper.displayName = `withResponsiveProps(${Component.displayName ?? Component.name})`;

return Wrapper;
}
54 changes: 54 additions & 0 deletions packages/design-system/src/utils/responsive.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading