diff --git a/lib/dev-pages-utils/__tests__/app-modes.test.ts b/lib/dev-pages-utils/__tests__/app-modes.test.ts new file mode 100644 index 0000000..581187a --- /dev/null +++ b/lib/dev-pages-utils/__tests__/app-modes.test.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, test, vi } from "vitest"; + +import { Density, Mode } from "@cloudscape-design/global-styles"; + +import { appModesDefaults, formatAppModes, parseAppModes, updateAppModes } from "../app-modes"; + +describe("parseAppModes", () => { + test("casts boolean strings and applies defaults for absent params", () => { + const params = parseAppModes(new URLSearchParams("mode=dark&motionDisabled=true")); + expect(params.mode).toBe("dark"); + expect(params.motionDisabled).toBe(true); + expect(params.density).toBe("comfortable"); // default + expect(params.i18n).toBe(true); // default + }); + + test("keeps a valid theme", () => { + expect(parseAppModes(new URLSearchParams("theme=one-theme")).theme).toBe("one-theme"); + }); + + test("leaves theme undefined when absent", () => { + expect(parseAppModes(new URLSearchParams("")).theme).toBeUndefined(); + }); +}); + +describe("formatAppModes", () => { + test("serializes every param to a string (full URL, no omission)", () => { + expect(formatAppModes(appModesDefaults)).toEqual({ + mode: "light", + density: "comfortable", + direction: "ltr", + motionDisabled: "false", + i18n: "true", + screenshotMode: "false", + }); + }); + + test("skips undefined values", () => { + expect(formatAppModes({ mode: Mode.Dark, appLayoutToolbar: undefined })).toEqual({ mode: "dark" }); + }); +}); + +describe("updateAppModes", () => { + test("writes the full serialized query through setQuery", () => { + const setQuery = vi.fn(); + updateAppModes(appModesDefaults, { mode: Mode.Dark }, setQuery); + expect(setQuery).toHaveBeenCalledWith({ + mode: "dark", + density: "comfortable", + direction: "ltr", + motionDisabled: "false", + i18n: "true", + screenshotMode: "false", + }); + }); + + test("does not reload when direction is unchanged", () => { + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + updateAppModes(appModesDefaults, { mode: Mode.Dark }, vi.fn()); + expect(reload).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + test("reloads when direction changes via next", () => { + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + updateAppModes(appModesDefaults, { direction: "rtl" }, vi.fn()); + expect(reload).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); + + test("does not reload when next omits direction even if current is non-default", () => { + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + updateAppModes({ ...appModesDefaults, direction: "rtl" }, { density: Density.Compact }, vi.fn()); + expect(reload).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/lib/dev-pages-utils/app-modes-provider.tsx b/lib/dev-pages-utils/app-modes-provider.tsx new file mode 100644 index 0000000..f5020af --- /dev/null +++ b/lib/dev-pages-utils/app-modes-provider.tsx @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { AppContextType, appModesDefaults, applyAppModes, AppUrlParams, parseAppModes, updateAppModes } from "./app-modes.js"; + +const AppModesContext = createContext({ + urlParams: appModesDefaults, + setUrlParams: () => {}, +}); + +export function AppModesProvider({ children }: { children: ReactNode }) { + const [searchParams, setSearchParams] = useSearchParams(); + + // Depend on the whole object (memoized on the query) rather than enumerating params, so params + // added to parseAppModes/applyAppModes re-apply without touching this file or its consumers. + const urlParams = useMemo(() => parseAppModes(searchParams), [searchParams]); + + const setUrlParams = useCallback( + (newParams: Partial) => updateAppModes(urlParams, newParams, setSearchParams), + [urlParams, setSearchParams] + ); + + useEffect(() => { + applyAppModes(urlParams); + }, [urlParams]); + + const value = useMemo(() => ({ urlParams, setUrlParams }), [urlParams, setUrlParams]); + + return {children}; +} + +// Pass a type argument for package-specific params, e.g. useAppModes<{ myFlag: boolean }>(). +export function useAppModes(): AppContextType { + return useContext(AppModesContext) as AppContextType; +} diff --git a/lib/dev-pages-utils/app-modes.ts b/lib/dev-pages-utils/app-modes.ts new file mode 100644 index 0000000..72a0186 --- /dev/null +++ b/lib/dev-pages-utils/app-modes.ts @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + applyDensity, + applyMode, + applyTheme, + disableMotion, + Density, + Mode, + Theme, +} from "@cloudscape-design/global-styles"; +import mapValues from "lodash/mapValues"; + +export interface AppUrlParams { + mode: Mode; + density: Density; + direction: "ltr" | "rtl"; + motionDisabled: boolean; + theme?: Theme; + i18n?: boolean; + screenshotMode?: boolean; +} + +export interface AppContextType { + pageId?: string; + urlParams: AppUrlParams & T; + setUrlParams: (newParams: Partial) => void; +} + +export const appModesDefaults: AppUrlParams = { + mode: Mode.Light, + density: Density.Comfortable, + direction: "ltr", + motionDisabled: false, + i18n: true, + screenshotMode: false, +}; + +export function parseAppModes(searchParams: URLSearchParams): AppUrlParams { + const queryParams: Record = { ...appModesDefaults }; + searchParams.forEach((value, key) => (queryParams[key] = value)); + + return mapValues(queryParams, value => { + if (value === "true" || value === "false") { + return value === "true"; + } + return value; + }) as AppUrlParams; +} + +export function formatAppModes(params: T): Record { + const query: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue; + query[key] = String(value); + } + return query; +} + +// Reloads on direction change: some components read the document `dir` only at mount. +export function updateAppModes( + current: AppUrlParams, + next: Partial, + setQuery: (query: Record) => void, +): void { + const merged = { ...current, ...next }; + setQuery(formatAppModes(merged)); + if ((next.direction ?? current.direction) !== current.direction) { + window.location.reload(); + } +} + +export function applyAppModes(params: AppUrlParams, target: Element = document.body): void { + applyMode(params.mode, target); + applyDensity(params.density, target); + disableMotion(params.motionDisabled, target); + applyTheme(params.theme ?? null, target); + document.documentElement.setAttribute("dir", params.direction); +} diff --git a/lib/dev-pages-utils/index.ts b/lib/dev-pages-utils/index.ts index 95eb94d..c661b9c 100644 --- a/lib/dev-pages-utils/index.ts +++ b/lib/dev-pages-utils/index.ts @@ -4,4 +4,6 @@ import { PermutationsView, PermutationsViewProps } from "./permutations-view.js"; import { createPermutations, ComponentPermutations } from "./permutations.js"; +export { type AppUrlParams } from "./app-modes.js"; +export { AppModesProvider, useAppModes } from "./app-modes-provider.js"; export { createPermutations, PermutationsView, type ComponentPermutations, type PermutationsViewProps }; diff --git a/package-lock.json b/package-lock.json index dd3a9b7..c529d88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.0.0", "license": "Apache-2.0", "dependencies": { + "@cloudscape-design/global-styles": "^1.0.62", "lodash": "^4.18.1", "minimatch": "^10.2.4" }, @@ -37,10 +38,14 @@ "lodash": "^4.18.1", "prettier": "^3.3.3", "react": "16.14.0", + "react-dom": "16.14.0", + "react-router-dom": "^6.30.4", "typescript": "^5.0.0", "vitest": "^4.1.8" }, "peerDependencies": { + "react": ">=16.14.0", + "react-router-dom": ">=6.0.0", "stylelint": "^16.8.1" } }, @@ -576,6 +581,16 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -4407,9 +4422,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5874,6 +5899,22 @@ "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "16.14.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", + "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + }, + "peerDependencies": { + "react": "^16.14.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -5881,6 +5922,40 @@ "dev": true, "license": "MIT" }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -6286,6 +6361,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", diff --git a/package.json b/package.json index fb06a0e..d24b8bd 100644 --- a/package.json +++ b/package.json @@ -30,17 +30,27 @@ "minimatch": "^10.2.4" }, "peerDependencies": { + "@cloudscape-design/global-styles": "^1.0.62", + "react": ">=16.14.0", + "react-router-dom": "^5.3.0 || ^6.0.0", "stylelint": "^16.8.1" }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-router-dom": { - "optional": true - } + "peerDependenciesMeta": { + "@cloudscape-design/global-styles": { + "optional": true + }, + "react": { + "optional": true + }, + "react-router-dom": { + "optional": true + }, + "stylelint": { + "optional": true + } }, "devDependencies": { + "@cloudscape-design/global-styles": "^1.0.62", "@tony.ganchev/eslint-plugin-header": "^3.3.1", "@types/lodash": "^4.17.21", "@types/node": "^20.19.24", @@ -62,6 +72,8 @@ "lodash": "^4.18.1", "prettier": "^3.3.3", "react": "16.14.0", + "react-dom": "16.14.0", + "react-router-dom": "^6.30.4", "typescript": "^5.0.0", "vitest": "^4.1.8" }, @@ -71,6 +83,9 @@ ], "*.{scss,css}": [ "stylelint --fix" + ], + "package-lock.json": [ + "node ./lib/scripts/prepare-package-lock.js" ] } }