Skip to content

Commit 1e2200f

Browse files
authored
feat: Add useMobile hook and breakpoints (#173)
1 parent d7ff55d commit 1e2200f

File tree

5 files changed

+128
-10
lines changed

5 files changed

+128
-10
lines changed

src/internal/breakpoints.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export type Breakpoint = 'default' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl';
5+
6+
const BREAKPOINT_MAPPING: [Breakpoint, number][] = [
7+
['xl', 1840],
8+
['l', 1320],
9+
['m', 1120],
10+
['s', 912],
11+
['xs', 688],
12+
['xxs', 465],
13+
['default', -1],
14+
];
15+
16+
export const mobileBreakpoint = BREAKPOINT_MAPPING.filter(b => b[0] === 'xs')[0][1];
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useRef } from 'react';
5+
import { act, render } from '@testing-library/react';
6+
7+
import { useMobile } from '../index';
8+
9+
function Demo() {
10+
const renderCount = useRef(0);
11+
const isMobile = useMobile();
12+
renderCount.current++;
13+
return (
14+
<div>
15+
<span data-testid="mobile">{String(isMobile)}</span>
16+
<span data-testid="render-count">{renderCount.current}</span>
17+
</div>
18+
);
19+
}
20+
21+
function resizeWindow(width: number) {
22+
act(() => {
23+
Object.defineProperty(window, 'innerWidth', { value: width });
24+
window.dispatchEvent(new CustomEvent('resize'));
25+
});
26+
}
27+
28+
test('should report mobile width on the initial render', () => {
29+
resizeWindow(400);
30+
const { getByTestId } = render(<Demo />);
31+
expect(getByTestId('mobile').textContent).toBe('true');
32+
});
33+
34+
test('should report desktop width on the initial render', () => {
35+
resizeWindow(1200);
36+
const { getByTestId } = render(<Demo />);
37+
expect(getByTestId('mobile').textContent).toBe('false');
38+
});
39+
40+
test('should report the updated value after resize', () => {
41+
resizeWindow(400);
42+
const { getByTestId } = render(<Demo />);
43+
const countBefore = getByTestId('render-count').textContent;
44+
resizeWindow(1200);
45+
const countAfter = getByTestId('render-count').textContent;
46+
expect(getByTestId('mobile').textContent).toBe('false');
47+
expect(countBefore).not.toEqual(countAfter);
48+
});
49+
50+
test('no renders when resize does not hit the breakpoint', () => {
51+
resizeWindow(1000);
52+
const { getByTestId } = render(<Demo />);
53+
const countBefore = getByTestId('render-count').textContent;
54+
resizeWindow(1200);
55+
const countAfter = getByTestId('render-count').textContent;
56+
expect(countBefore).toEqual(countAfter);
57+
});

src/internal/use-mobile/index.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { createSingletonState } from '../singleton-handler';
5+
6+
import { mobileBreakpoint } from '../breakpoints';
7+
import { safeMatchMedia } from '../utils/safe-match-media';
8+
9+
export const forceMobileModeSymbol = Symbol.for('awsui-force-mobile-mode');
10+
11+
function getIsMobile() {
12+
// allow overriding the mobile mode in tests
13+
// any is needed because of this https://github.com/microsoft/TypeScript/issues/36813
14+
const forceMobileMode = (globalThis as any)[forceMobileModeSymbol];
15+
if (typeof forceMobileMode !== 'undefined') {
16+
return forceMobileMode;
17+
}
18+
if (typeof window === 'undefined') {
19+
// assume desktop in server-rendering
20+
return false;
21+
}
22+
23+
/**
24+
* Some browsers include the scrollbar width in their media query calculations, but
25+
* some browsers don't. Thus we can't use `window.innerWidth` or
26+
* `document.documentElement.clientWidth` to get a very accurate result (since we
27+
* wouldn't know which one of them to use).
28+
* Instead, we use the media query here in JS too.
29+
*/
30+
return safeMatchMedia(document.body, `(max-width: ${mobileBreakpoint}px)`);
31+
}
32+
33+
export const useMobile = createSingletonState<boolean>({
34+
initialState: () => getIsMobile(),
35+
factory: handler => {
36+
const listener = () => handler(getIsMobile());
37+
window.addEventListener('resize', listener);
38+
return () => {
39+
window.removeEventListener('resize', listener);
40+
};
41+
},
42+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
export function safeMatchMedia(element: HTMLElement, query: string) {
5+
try {
6+
const targetWindow = element.ownerDocument?.defaultView ?? window;
7+
return targetWindow.matchMedia?.(query).matches ?? false;
8+
} catch (error) {
9+
console.warn(error);
10+
return false;
11+
}
12+
}

src/internal/visual-mode/index.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,7 @@ import { useStableCallback } from '../stable-callback';
88
import { isDevelopment } from '../is-development';
99
import { warnOnce } from '../logging';
1010
import { awsuiVisualRefreshFlag, getGlobal } from '../global-flags';
11-
12-
function safeMatchMedia(element: HTMLElement, query: string) {
13-
try {
14-
const targetWindow = element.ownerDocument?.defaultView ?? window;
15-
return targetWindow.matchMedia?.(query).matches ?? false;
16-
} catch (error) {
17-
console.warn(error);
18-
return false;
19-
}
20-
}
11+
import { safeMatchMedia } from '../utils/safe-match-media';
2112

2213
export function isMotionDisabled(element: HTMLElement): boolean {
2314
return (

0 commit comments

Comments
 (0)