Skip to content

Commit 361a72c

Browse files
committed
New: Add cascading option
1 parent f62792e commit 361a72c

12 files changed

Lines changed: 308 additions & 147 deletions

.eslintrc.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,10 @@ module.exports = {
33
globals: {
44
__DEV__: 'readonly',
55
},
6-
rules: { '@typescript-eslint/ban-ts-ignore': 'off' },
6+
rules: {
7+
'@typescript-eslint/ban-ts-ignore': 'off',
8+
// we've abstracted our expects into a help fn
9+
// so we don't necessarily need an `expect` inside of each test block
10+
'jest/expect-expect': 'off',
11+
},
712
};

README.md

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ import Button from './components/Button';
146146

147147
<Button
148148
buttonText="Default text"
149-
sm={{ buttonText: 'Small screen text', buttonSize: 'mini' }}
150-
lg={{ buttonText: 'Large screen text', buttonSize: 'large' }}
149+
sm={{ buttonText: 'Small screen text', buttonSize: 'large' }}
150+
lg={{ buttonText: 'Large screen text', buttonSize: 'normal' }}
151151
/>;
152152
```
153153

@@ -158,6 +158,86 @@ import Button from './components/Button';
158158
[TypeScript](https://github.com/tripphamm/react-responsive-system/tree/master/examples/typescript)
159159
[Gatsby](https://github.com/tripphamm/react-responsive-system/tree/master/examples/gatsby)
160160

161+
## Cascading
162+
163+
By default, Responsive System overrides do not cascade, which is to say: if you add an override for screen class `X`, those overrides will only be applied when the user's browser/device matches screen class `X`. But in some cases, you might want to apply overrides on `X` and also anything larger/smaller than `X`.
164+
165+
This is where `cascadeMode` comes in.
166+
167+
Let's take an example. We'll assume that we have 4 screen classes, "xs", "sm", "md", and "lg" and that we have a `Button` component that can be either `normal` or `large`. On most screen sizes, we want our button to be `normal` size, but on `xs` screens (like mobile phones) we want to make the button nice and big so that it's easier to tap.
168+
169+
There are 2 ways that you can do this with the default configuration of Responsive System:
170+
171+
```jsx
172+
<Button
173+
// default is "normal"
174+
buttonSize="normal"
175+
// override on xs screens
176+
xs={{ buttonSize: 'large' }}
177+
/>
178+
```
179+
180+
This approach is called "desktop-first" because the default values pertain to larger screens, and we provide overrides for smaller screens.
181+
182+
We could also write this in a "mobile-first" way like this:
183+
184+
```jsx
185+
<Button
186+
// default to "large"
187+
buttonSize="large"
188+
// override on sm, md, and lg screens
189+
sm={{ buttonSize: 'normal' }}
190+
md={{ buttonSize: 'normal' }}
191+
lg={{ buttonSize: 'normal' }}
192+
/>
193+
```
194+
195+
In this particular case, the "desktop-first" approach is shorter and easier to understand, but sometimes the reverse will be true. Using the default `cascadeMode` ("no-cascade") allows you to choose which approach works best on a case-by-case basis. You pick your default values and then apply overrides for any screen sizes that need special treatment.
196+
197+
For folks who find value in having a consistent "desktop-first" or "mobile-first" approach throughout their apps, we also provide `cascadeMode = "desktop-first"` and `cascadeMode = "mobile-first"`. In these modes, you always provide default values for either your largest screen class ("desktop-first") or your smallest ("mobile-first") and your overrides will _cascade_.
198+
199+
Going back to our example, if we had used `cascadeMode = "desktop-first"` we would still write:
200+
201+
```jsx
202+
<Button
203+
// desktop-first default value
204+
buttonSize="normal"
205+
// override on xs screens AND anything smaller
206+
xs={{ buttonSize: 'large' }}
207+
/>
208+
```
209+
210+
The desktop-first cascade would automatically add our overrides on any screen classes smaller that `xs`, but no such screen classes exist, so the cascade doesn't come into play here.
211+
212+
On the other hand, if we had used `cascadeMode = "mobile-first"`, we could take advantage of the mobile-first cascade:
213+
214+
```jsx
215+
<Button
216+
// mobile-first default value
217+
buttonSize="large"
218+
// override on sm AND anything larger
219+
sm={{ buttonSize: 'normal' }}
220+
/>
221+
```
222+
223+
The mobile-first cascade says "apply these overrides on _this_ screen class _and_ any larger screen classes too".
224+
225+
Put another way:
226+
227+
- "no-cascade" - `sm` overrides apply only on `sm` screens
228+
- "desktop-first" - `sm` overrides apply on `sm` and `xs` screens
229+
- "mobile-first" = `sm` overrides apply on `sm`, `md`, and `lg`
230+
231+
If you'd like to enable desktop/mobile-first cascading, you can pass the `cascadeMode` option to `createResponsiveSystem`.
232+
233+
```js
234+
export const { ScreenClassProvider, responsive } = createResponsiveSystem({
235+
breakpoints,
236+
defaultScreenClass: 'lg',
237+
cascadeMode: 'mobile-first', // or "desktop-first"
238+
});
239+
```
240+
161241
## Custom Merge Function
162242

163243
By default, Responsive System will apply any screen-size-specific overrides right on top of your base props, but sometimes you need more control over how your overrides are applied.

examples/typescript/index.tsx

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,37 +21,26 @@ const App = () => {
2121
<FunctionComponentWithHOC
2222
someColor="#000000"
2323
someText="overridden"
24-
xs={{ someColor: 'rebeccapurple', someText: 'xs' }}
2524
sm={{ someColor: 'palevioletred', someText: 'sm' }}
2625
md={{ someColor: 'brown', someText: 'md' }}
27-
lg={{ someColor: 'green', someText: 'lg' }}
2826
/>
2927
<ForwardRefComponentWithHOC
3028
ref={forwardRefCompRef}
3129
someColor="#000000"
3230
someText="overridden"
33-
xs={{ someColor: 'rebeccapurple', someText: 'xs' }}
3431
sm={{ someColor: 'palevioletred', someText: 'sm' }}
3532
md={{ someColor: 'brown', someText: 'md' }}
36-
lg={{ someColor: 'green', someText: 'lg' }}
3733
/>
3834
<ClassComponentWithHOC
3935
ref={classCompRef}
4036
someColor="#000000"
4137
someText="overridden"
42-
xs={{ someColor: 'rebeccapurple', someText: 'xs' }}
4338
sm={{ someColor: 'palevioletred', someText: 'sm' }}
4439
md={{ someColor: 'brown', someText: 'md' }}
45-
lg={{ someColor: 'green', someText: 'lg' }}
4640
/>
4741
<HostComponentWithHOC
4842
ref={hostCompRef}
4943
style={{ color: 'white', height: 100, width: 100, backgroundColor: '#000000' }}
50-
xs={(baseProps) => ({
51-
...baseProps,
52-
style: { ...baseProps.style, backgroundColor: 'rebeccapurple' },
53-
children: 'xs',
54-
})}
5544
sm={(baseProps) => ({
5645
...baseProps,
5746
style: { ...baseProps.style, backgroundColor: 'palevioletred' },
@@ -62,19 +51,14 @@ const App = () => {
6251
style: { ...baseProps.style, backgroundColor: 'brown' },
6352
children: 'md',
6453
})}
65-
lg={(baseProps) => ({
66-
...baseProps,
67-
style: { ...baseProps.style, backgroundColor: 'green' },
68-
children: 'lg',
69-
})}
70-
/>
54+
>
55+
overridden
56+
</HostComponentWithHOC>
7157
<CustomComponentWithHook
7258
someColor="#000000"
7359
someText="overridden"
74-
xs={{ someColor: 'rebeccapurple', someText: 'xs' }}
7560
sm={{ someColor: 'palevioletred', someText: 'sm' }}
7661
md={{ someColor: 'brown', someText: 'md' }}
77-
lg={{ someColor: 'green', someText: 'lg' }}
7862
/>
7963
</ScreenClassProvider>
8064
);

examples/typescript/responsiveSystem.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const breakpoints = {
1010
export const { ScreenClassProvider, useResponsiveProps, responsive } = createResponsiveSystem({
1111
defaultScreenClass: 'lg',
1212
breakpoints,
13+
mode: 'desktop-first',
1314
});
1415

1516
// ResponsiveProps takes 2 type args: the breakpoints, and the props

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ module.exports = {
44
globals: {
55
__DEV__: true,
66
},
7+
setupFilesAfterEnv: ['./test/setup.ts'],
78
};

src/index.tsx

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ export type ScreenClassConfiguration<B extends ScreenClassBreakpoints> = {
7979
* }
8080
*/
8181
breakpoints: B;
82+
83+
/**
84+
* Controls the way that overrides are applied
85+
*
86+
* "no-cascade" -> only apply overrides on the exact screen class
87+
* "mobile-first" -> override on matching screen class and larger
88+
* "desktop-first" -> override on matching screen class and smaller
89+
*
90+
* @default "no-cascade"
91+
*/
92+
cascadeMode?: 'no-cascade' | 'mobile-first' | 'desktop-first';
8293
};
8394

8495
export type ScreenClass<B extends ScreenClassBreakpoints> = keyof B;
@@ -95,7 +106,7 @@ export type ResponsiveProps<B extends ScreenClassBreakpoints, P extends {}> = Om
95106
export function createResponsiveSystem<B extends ScreenClassBreakpoints>(
96107
screenClassConfiguration: ScreenClassConfiguration<B>,
97108
) {
98-
const { defaultScreenClass, breakpoints } = screenClassConfiguration;
109+
const { defaultScreenClass, breakpoints, cascadeMode = 'no-cascade' } = screenClassConfiguration;
99110

100111
//
101112
// ─── VALIDATE ───────────────────────────────────────────────────────────────────
@@ -135,6 +146,24 @@ export function createResponsiveSystem<B extends ScreenClassBreakpoints>(
135146

136147
const sortedScreenClasses = sortedScreenClassBreakpoints.map(([screenClass]) => screenClass);
137148

149+
/**
150+
* Mobile-First Screen Classes include the current screen class and all smaller
151+
*
152+
* The should be applied smallest to largest
153+
*/
154+
function getMobileFirstScreenClasses(breakpoint: keyof B) {
155+
return sortedScreenClasses.slice(0, sortedScreenClasses.indexOf(breakpoint) + 1);
156+
}
157+
158+
/**
159+
* Desktop-First Screen Classes include the current screen class and all larger
160+
*
161+
* They should be applied from largest to smallest
162+
*/
163+
function getDesktopFirstScreenClasses(breakpoint: keyof B) {
164+
return sortedScreenClasses.slice(sortedScreenClasses.indexOf(breakpoint)).reverse();
165+
}
166+
138167
//
139168
// ─── CONTEXT ────────────────────────────────────────────────────────────────────
140169
//
@@ -242,14 +271,33 @@ export function createResponsiveSystem<B extends ScreenClassBreakpoints>(
242271

243272
// TypeScript: this is correct, but TS is having trouble confirming that it will be type P
244273
const baseProps = omit(props, sortedScreenClasses) as P;
245-
const overrides =
246-
props[currentScreenClass] !== undefined ? props[currentScreenClass] : ({} as Partial<P>);
247274

248-
if (typeof overrides === 'function') {
249-
return overrides(baseProps);
275+
let applicableScreenClasses = [];
276+
switch (cascadeMode) {
277+
case 'mobile-first':
278+
applicableScreenClasses = getMobileFirstScreenClasses(currentScreenClass);
279+
break;
280+
case 'desktop-first':
281+
applicableScreenClasses = getDesktopFirstScreenClasses(currentScreenClass);
282+
break;
283+
case 'no-cascade':
284+
default:
285+
applicableScreenClasses = [currentScreenClass];
250286
}
251287

252-
return { ...baseProps, ...overrides };
288+
// apply each screen class on top of the baseProps
289+
// the screenClasses should be sorted in the order in which they should be applied
290+
// e.g. mobile-first should apply smallest -> largest
291+
// e.g. desktop-first should apply largest -> smallest
292+
// we assume that the sorting is already done
293+
return applicableScreenClasses.reduce((o, sc) => {
294+
const override = props[sc] ?? {};
295+
if (typeof override === 'function') {
296+
return override(o);
297+
} else {
298+
return { ...o, ...override };
299+
}
300+
}, baseProps);
253301
}
254302

255303
// In order to support refs, we need to use forwardRef

test/common.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as React from 'react';
2+
import { ResponsiveProps, createResponsiveSystem, ScreenClassConfiguration } from '../src';
3+
import { render } from '@testing-library/react';
4+
5+
const breakpoints = {
6+
xs: 500,
7+
sm: 750,
8+
md: 1000,
9+
lg: Infinity,
10+
};
11+
12+
const expectedMediaQueries: { [K in keyof typeof breakpoints]: string } = {
13+
xs: '(max-width: 500px)',
14+
sm: '(min-width: 501px) and (max-width: 750px)',
15+
md: '(min-width: 751px) and (max-width: 1000px)',
16+
lg: '(min-width: 1001px)',
17+
};
18+
19+
/**
20+
* Mocks the window.matchMedia fn such that it will return `matches = true` when asked to match the given screen class's media query
21+
*
22+
* This mock will assert that every input passed to matchMedia is one of our expected media queries
23+
* and will return `matches = true` only if the media query matches the expected media query
24+
* for the screenClass provided to `mockMatchMedia`
25+
*/
26+
function mockMatchMedia(screenClass: keyof typeof breakpoints) {
27+
window.matchMedia = jest.fn().mockImplementation((query) => {
28+
// assert that every media query that we try to match is an "expected" media query
29+
expect(query).toBeInList(Object.values(expectedMediaQueries));
30+
31+
return {
32+
// match only if we get the media query for the given screen class
33+
matches: query === expectedMediaQueries[screenClass],
34+
media: query,
35+
onchange: null,
36+
addListener: jest.fn(), // deprecated
37+
removeListener: jest.fn(), // deprecated
38+
addEventListener: jest.fn(),
39+
removeEventListener: jest.fn(),
40+
dispatchEvent: jest.fn(),
41+
};
42+
});
43+
}
44+
45+
export function makeScreenClassTester(
46+
responsiveSystemConfiguration: Omit<ScreenClassConfiguration<typeof breakpoints>, 'breakpoints'>,
47+
) {
48+
const { ScreenClassProvider, responsive, useResponsiveProps } = createResponsiveSystem({
49+
breakpoints,
50+
...responsiveSystemConfiguration,
51+
});
52+
53+
const ResponsiveCompWithHOC = responsive((props: React.PropsWithChildren<{ text: string }>) => (
54+
<div data-testid="hoc-comp">{props.text}</div>
55+
));
56+
57+
const ResponsiveCompWithHook: React.FC<ResponsiveProps<typeof breakpoints, { text: string }>> = (
58+
props,
59+
) => {
60+
const responsiveProps = useResponsiveProps(props);
61+
62+
return <div data-testid="hook-comp">{responsiveProps.text}</div>;
63+
};
64+
65+
return function testScreenClass(
66+
screenClass: keyof typeof breakpoints,
67+
expected: keyof typeof breakpoints | 'base',
68+
) {
69+
mockMatchMedia(screenClass);
70+
71+
const { getByTestId } = render(
72+
<ScreenClassProvider>
73+
<ResponsiveCompWithHOC text="base" sm={{ text: 'sm' }} md={{ text: 'md' }} />
74+
<ResponsiveCompWithHook text="base" sm={{ text: 'sm' }} md={{ text: 'md' }} />
75+
</ScreenClassProvider>,
76+
);
77+
78+
expect(getByTestId('hoc-comp').innerHTML).toBe(expected);
79+
expect(getByTestId('hook-comp').innerHTML).toBe(expected);
80+
};
81+
}

test/desktopFirst.spec.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { makeScreenClassTester } from './common';
2+
3+
const testScreenClass = makeScreenClassTester({
4+
defaultScreenClass: 'lg',
5+
cascadeMode: 'desktop-first',
6+
});
7+
8+
describe('desktop-first', () => {
9+
it('no xs inherits sm', () => {
10+
testScreenClass('xs', 'sm');
11+
});
12+
it('sm inherits sm', () => {
13+
testScreenClass('sm', 'sm');
14+
});
15+
it('md inherits md', () => {
16+
testScreenClass('md', 'md');
17+
});
18+
it('no lg inherits base', () => {
19+
testScreenClass('lg', 'base');
20+
});
21+
});

0 commit comments

Comments
 (0)