diff --git a/src/internal/breakpoints.ts b/src/internal/breakpoints.ts new file mode 100644 index 0000000..54f3c96 --- /dev/null +++ b/src/internal/breakpoints.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type Breakpoint = 'default' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl'; + +const BREAKPOINT_MAPPING: [Breakpoint, number][] = [ + ['xl', 1840], + ['l', 1320], + ['m', 1120], + ['s', 912], + ['xs', 688], + ['xxs', 465], + ['default', -1], +]; + +export const mobileBreakpoint = BREAKPOINT_MAPPING.filter(b => b[0] === 'xs')[0][1]; diff --git a/src/internal/use-mobile/_tests__/use-mobile.test.tsx b/src/internal/use-mobile/_tests__/use-mobile.test.tsx new file mode 100644 index 0000000..dc087be --- /dev/null +++ b/src/internal/use-mobile/_tests__/use-mobile.test.tsx @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef } from 'react'; +import { act, render } from '@testing-library/react'; + +import { useMobile } from '../index'; + +function Demo() { + const renderCount = useRef(0); + const isMobile = useMobile(); + renderCount.current++; + return ( +
+ {String(isMobile)} + {renderCount.current} +
+ ); +} + +function resizeWindow(width: number) { + act(() => { + Object.defineProperty(window, 'innerWidth', { value: width }); + window.dispatchEvent(new CustomEvent('resize')); + }); +} + +test('should report mobile width on the initial render', () => { + resizeWindow(400); + const { getByTestId } = render(); + expect(getByTestId('mobile').textContent).toBe('true'); +}); + +test('should report desktop width on the initial render', () => { + resizeWindow(1200); + const { getByTestId } = render(); + expect(getByTestId('mobile').textContent).toBe('false'); +}); + +test('should report the updated value after resize', () => { + resizeWindow(400); + const { getByTestId } = render(); + const countBefore = getByTestId('render-count').textContent; + resizeWindow(1200); + const countAfter = getByTestId('render-count').textContent; + expect(getByTestId('mobile').textContent).toBe('false'); + expect(countBefore).not.toEqual(countAfter); +}); + +test('no renders when resize does not hit the breakpoint', () => { + resizeWindow(1000); + const { getByTestId } = render(); + const countBefore = getByTestId('render-count').textContent; + resizeWindow(1200); + const countAfter = getByTestId('render-count').textContent; + expect(countBefore).toEqual(countAfter); +}); diff --git a/src/internal/use-mobile/index.ts b/src/internal/use-mobile/index.ts new file mode 100644 index 0000000..92ba85a --- /dev/null +++ b/src/internal/use-mobile/index.ts @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createSingletonState } from '../singleton-handler'; + +import { mobileBreakpoint } from '../breakpoints'; +import { safeMatchMedia } from '../utils/safe-match-media'; + +export const forceMobileModeSymbol = Symbol.for('awsui-force-mobile-mode'); + +function getIsMobile() { + // allow overriding the mobile mode in tests + // any is needed because of this https://github.com/microsoft/TypeScript/issues/36813 + const forceMobileMode = (globalThis as any)[forceMobileModeSymbol]; + if (typeof forceMobileMode !== 'undefined') { + return forceMobileMode; + } + if (typeof window === 'undefined') { + // assume desktop in server-rendering + return false; + } + + /** + * Some browsers include the scrollbar width in their media query calculations, but + * some browsers don't. Thus we can't use `window.innerWidth` or + * `document.documentElement.clientWidth` to get a very accurate result (since we + * wouldn't know which one of them to use). + * Instead, we use the media query here in JS too. + */ + return safeMatchMedia(document.body, `(max-width: ${mobileBreakpoint}px)`); +} + +export const useMobile = createSingletonState({ + initialState: () => getIsMobile(), + factory: handler => { + const listener = () => handler(getIsMobile()); + window.addEventListener('resize', listener); + return () => { + window.removeEventListener('resize', listener); + }; + }, +}); diff --git a/src/internal/utils/safe-match-media.ts b/src/internal/utils/safe-match-media.ts new file mode 100644 index 0000000..0f41701 --- /dev/null +++ b/src/internal/utils/safe-match-media.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function safeMatchMedia(element: HTMLElement, query: string) { + try { + const targetWindow = element.ownerDocument?.defaultView ?? window; + return targetWindow.matchMedia?.(query).matches ?? false; + } catch (error) { + console.warn(error); + return false; + } +} diff --git a/src/internal/visual-mode/index.ts b/src/internal/visual-mode/index.ts index 173ec0e..5b37f22 100644 --- a/src/internal/visual-mode/index.ts +++ b/src/internal/visual-mode/index.ts @@ -8,16 +8,7 @@ import { useStableCallback } from '../stable-callback'; import { isDevelopment } from '../is-development'; import { warnOnce } from '../logging'; import { awsuiVisualRefreshFlag, getGlobal } from '../global-flags'; - -function safeMatchMedia(element: HTMLElement, query: string) { - try { - const targetWindow = element.ownerDocument?.defaultView ?? window; - return targetWindow.matchMedia?.(query).matches ?? false; - } catch (error) { - console.warn(error); - return false; - } -} +import { safeMatchMedia } from '../utils/safe-match-media'; export function isMotionDisabled(element: HTMLElement): boolean { return (