diff --git a/gatsby/package.json b/gatsby/package.json index 12d1562e754b3fa13be4f425523dce40f9cfff55..42f58315bb58d0d0fa0f0c7620549db2392cba0f 100644 --- a/gatsby/package.json +++ b/gatsby/package.json @@ -26,6 +26,7 @@ "@sikt/sds-logo": "^2.0.0", "@sikt/sds-section": "^3.0.0", "@sikt/sds-table": "^2.0.1", + "@sikt/sds-toggle": "^2.1.0", "@sikt/sds-tokens": "^1.0.0", "canvas-confetti": "^1.9.2", "clsx": "^2.1.0", diff --git a/gatsby/src/components/Footer.tsx b/gatsby/src/components/Footer.tsx index 8bd21fc176ab22e2544d74a29d46f980461be90c..3aebf79a200d1d9644d3784ad18f8358662e5a02 100644 --- a/gatsby/src/components/Footer.tsx +++ b/gatsby/src/components/Footer.tsx @@ -3,6 +3,7 @@ import { Footer as SdsFooter } from "@sikt/sds-footer"; import * as style from "./footer.module.css"; import { ButtonLink } from "@sikt/sds-button"; import { Link } from "@sikt/sds-core"; +import { ToggleSwitchColorScheme } from "@sikt/sds-toggle"; import clsx from "clsx"; const Footer = ({ className }: { className?: string }) => { @@ -47,6 +48,7 @@ const Footer = ({ className }: { className?: string }) => { </li> </ul> </div> + <ToggleSwitchColorScheme control="internal" label="Velg tema" /> </SdsFooter> ); }; diff --git a/gatsby/src/components/footer.module.css b/gatsby/src/components/footer.module.css index d1ea92630941b65cc9facc26cae4f56ad2b12254..5bfe0e68cf38cffb468ded55522375cdaff62b67 100644 --- a/gatsby/src/components/footer.module.css +++ b/gatsby/src/components/footer.module.css @@ -1,6 +1,7 @@ .footer { :global(.sds-footer__content) { align-items: center; + gap: var(--sds-space-gap-medium); } &__list { diff --git a/gatsby/src/layouts/global.css b/gatsby/src/layouts/global.css index 01dee4a1a81854396aebb15262ca0525c386ed83..0bbb66df6571eccf2ccdce3deb9808becc30d49e 100644 --- a/gatsby/src/layouts/global.css +++ b/gatsby/src/layouts/global.css @@ -10,6 +10,7 @@ @import url("@sikt/sds-badge"); @import url("@sikt/sds-table"); @import url("@sikt/sds-list"); +@import url("@sikt/sds-toggle"); .sds-sikt-button-group { flex-wrap: wrap; diff --git a/gatsby/src/layouts/index.tsx b/gatsby/src/layouts/index.tsx index 640f2d9f1ffc8fb94f6a4ac7ffdcf6c8d4a397da..e6a7634d881284b5c12364cf0a0d4b7b6a9d45a8 100644 --- a/gatsby/src/layouts/index.tsx +++ b/gatsby/src/layouts/index.tsx @@ -1,4 +1,5 @@ -import { ReactNode } from "react"; +import { useSetColorSchemeFromLocalStorage } from "@sikt/sds-toggle"; +import { ReactNode, useCallback, useState } from "react"; import Header from "../components/Header"; import Footer from "../components/Footer"; import "./global.css"; @@ -6,16 +7,34 @@ import * as style from "./layout.module.css"; interface LayoutProps { children: ReactNode; + pathname: string; } -const Layout = ({ location, children, pageContext }: LayoutProps) => { +const Layout = ({ pathname, children }: LayoutProps) => { + useSetColorSchemeFromLocalStorage(); + return ( - <div className={style.layoutWrapper}> - <Header currentHref={location.pathname} /> + <> + <Header currentHref={pathname} /> <main id="main">{children}</main> <Footer className={style.layoutFooter} /> + </> + ); +}; + +const LayoutWrapper = ({ location, children, pageContext }: LayoutProps) => { + const [mounted, setMounted] = useState(false); + const divRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + setMounted(true); + } + }, []); + + return ( + <div className={style.layoutWrapper} ref={divRef}> + {mounted && <Layout pathname={location.pathname}>{children}</Layout>} </div> ); }; -export default Layout; +export default LayoutWrapper; diff --git a/package-lock.json b/package-lock.json index ba1597986c5fab30f1b6745d2fe9827235807894..3ae7cfd814f5a2d11f31d7f9e196e44f771a9160 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "@sikt/sds-logo": "^2.0.0", "@sikt/sds-section": "^3.0.0", "@sikt/sds-table": "^2.0.1", + "@sikt/sds-toggle": "^2.1.0", "@sikt/sds-tokens": "^1.0.0", "canvas-confetti": "^1.9.2", "clsx": "^2.1.0", diff --git a/packages/icons/icons.config.mjs b/packages/icons/icons.config.mjs index 28fcf4b3317da342cdc20ca96f1c17d57b5f4a35..04028d7ab66f606fed7e36e2b5722b86e0ae1700 100644 --- a/packages/icons/icons.config.mjs +++ b/packages/icons/icons.config.mjs @@ -46,6 +46,7 @@ export const config = [ "magnifying-glass", "map-pin", "megaphone", + "moon", "minus", "minus-circle", "paperclip", @@ -62,6 +63,7 @@ export const config = [ "sort-ascending", "sort-descending", "spinner-gap", + "sun", "trash", "upload-simple", "user-circle", diff --git a/packages/toggle/ToggleSwitchColorScheme.test.tsx b/packages/toggle/ToggleSwitchColorScheme.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..111cc14f05faf2deb90866d74b8d0253783d1e0a --- /dev/null +++ b/packages/toggle/ToggleSwitchColorScheme.test.tsx @@ -0,0 +1,463 @@ +import { act, render, renderHook, screen } from "@testing-library/react"; +import { + COLOR_SCHEME_LOCAL_STORAGE_KEY, + DataColorScheme, + getClosestElementForDataColorScheme, + getIsDarkModeFromParentTag, + setClosestDataColorSchemeValue, + ToggleSwitchColorScheme, + ToggleSwitchColorSchemeProps, + useIsDarkMode, + useSetColorSchemeFromLocalStorage, +} from "./ToggleSwitchColorScheme"; +import { axe } from "jest-axe"; +import userEvent from "@testing-library/user-event"; +import React, { ReactElement } from "react"; + +/* https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom */ +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +const renderWithMode = ( + element: ReactElement< + ToggleSwitchColorSchemeProps, + React.JSXElementConstructor<ToggleSwitchColorSchemeProps> + >, + mode: string, +) => { + localStorage.setItem( + COLOR_SCHEME_LOCAL_STORAGE_KEY, + String(mode !== "light"), + ); + return render(element); +}; + +const renderWithLightMode = ( + element: ReactElement< + ToggleSwitchColorSchemeProps, + React.JSXElementConstructor<ToggleSwitchColorSchemeProps> + >, +) => renderWithMode(element, "light"); + +const renderWithDarkMode = ( + element: ReactElement< + ToggleSwitchColorSchemeProps, + React.JSXElementConstructor<ToggleSwitchColorSchemeProps> + >, +) => renderWithMode(element, "dark"); + +describe("Toggle Switch - color scheme", () => { + beforeEach(() => { + localStorage.removeItem(COLOR_SCHEME_LOCAL_STORAGE_KEY); + }); + + describe("a11y", () => { + it("should be accessible without label", async () => { + const { container } = renderWithLightMode( + <ToggleSwitchColorScheme control="internal" aria-label="aria-label" />, + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should be accessible with label", async () => { + const { container } = renderWithLightMode( + <ToggleSwitchColorScheme control="internal" label="Foo" />, + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + describe("init states", () => { + it(`should render toggle switch`, () => { + renderWithLightMode( + <ToggleSwitchColorScheme + control="internal" + data-testid="test" + label="Foo" + />, + ); + + expect(screen.getByTestId("test")).toHaveClass( + "sds-toggle-switch-color-scheme", + ); + expect(screen.getByTestId("test")).toHaveClass( + "sds-toggle-switch-color-scheme", + ); + expect(screen.getByTestId("test")).toBeInTheDocument(); + }); + + it("should not be checked when light scheme", () => { + renderWithLightMode( + <ToggleSwitchColorScheme control="internal" label="Foo" />, + ); + + expect(screen.getByLabelText("Foo")).not.toBeChecked(); + }); + + it("should be checked when dark scheme", () => { + renderWithDarkMode( + <ToggleSwitchColorScheme control="internal" label="Foo" />, + ); + + expect(screen.getByLabelText("Foo")).toBeChecked(); + }); + + it("should render the label after the control", () => { + renderWithLightMode( + <ToggleSwitchColorScheme + control="internal" + data-testid="test" + label="Foo" + />, + ); + + const container = screen.getByTestId("test"); + const label = container.getElementsByClassName( + "sds-toggle-switch-color-scheme__label-text", + )[0]; + const control = container.getElementsByClassName( + "sds-toggle-switch-color-scheme__inner", + )[0]; + expect(label.compareDocumentPosition(control)).toBe( + Node.DOCUMENT_POSITION_PRECEDING, + ); + }); + + it("should render the label in front of the control", () => { + renderWithLightMode( + <ToggleSwitchColorScheme + control="internal" + labelFirst + data-testid="test" + label="Foo" + />, + ); + + const container = screen.getByTestId("test"); + const label = container.getElementsByClassName( + "sds-toggle-switch-color-scheme__label-text", + )[0]; + const control = container.getElementsByClassName( + "sds-toggle-switch-color-scheme__inner", + )[0]; + expect(label.compareDocumentPosition(control)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + }); + + describe("change", () => { + it("calls change handler", async () => { + const user = userEvent.setup(); + const changeHandler = jest.fn(); + renderWithLightMode( + <ToggleSwitchColorScheme + control="internal" + onChange={changeHandler} + label="Foo" + />, + ); + + const label = screen.getByLabelText("Foo"); + await user.click(label); + + expect(changeHandler).toHaveBeenCalled(); + }); + }); + + describe("local storage", () => { + it("should have no entry on initial render", () => { + render( + <ToggleSwitchColorScheme + control="internal" + labelFirst + data-testid="test" + label="Foo" + />, + ); + + expect(localStorage.getItem(COLOR_SCHEME_LOCAL_STORAGE_KEY)).toBeNull(); + }); + + it("should have correct entry when toggled from light to dark", async () => { + const user = userEvent.setup(); + renderWithLightMode( + <ToggleSwitchColorScheme control="internal" label="Foo" />, + ); + + const label = screen.getByLabelText("Foo"); + await user.click(label); + + expect(localStorage.getItem(COLOR_SCHEME_LOCAL_STORAGE_KEY)).toBe("true"); + }); + + it("should have correct entry when toggled from dark to light", async () => { + const user = userEvent.setup(); + renderWithDarkMode( + <ToggleSwitchColorScheme control="internal" label="Foo" />, + ); + + const label = screen.getByLabelText("Foo"); + await user.click(label); + + expect(localStorage.getItem(COLOR_SCHEME_LOCAL_STORAGE_KEY)).toBe( + "false", + ); + }); + }); + + describe("external control", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should call changeHandler", async () => { + const user = userEvent.setup(); + const changeHandler = jest.fn(); + render( + <ToggleSwitchColorScheme + control="external" + isDarkMode={false} + onChange={changeHandler} + label="Foo" + />, + ); + + const label = screen.getByLabelText("Foo"); + await user.click(label); + + expect(changeHandler).toHaveBeenCalled(); + }); + + it("should be unchecked when isDarkMode is false", () => { + render( + <ToggleSwitchColorScheme + control="external" + onChange={jest.fn} + isDarkMode={false} + label="Foo" + />, + ); + + expect(screen.getByLabelText("Foo")).not.toBeChecked(); + }); + + it("should be checked when isDarkMode is true", () => { + render( + <ToggleSwitchColorScheme + control="external" + onChange={jest.fn} + label="Foo" + isDarkMode + />, + ); + + expect(screen.getByLabelText("Foo")).toBeChecked(); + }); + }); + + describe("getClosestElementForDataColorScheme", () => { + it("should find closest element with data-color-scheme set", () => { + const id = "super-div"; + + render( + <div data-color-scheme="light" id={id}> + <ToggleSwitchColorScheme control="internal" label="Foo" /> + </div>, + ); + + expect( + getClosestElementForDataColorScheme(screen.getByLabelText("Foo")).id, + ).toBe(id); + }); + + it("should find HTML tag", () => { + const id = "super-div"; + + render( + <div id={id}> + <ToggleSwitchColorScheme control="internal" label="Foo" /> + </div>, + ); + + expect( + getClosestElementForDataColorScheme(screen.getByLabelText("Foo")) + .tagName, + ).toBe("HTML"); + }); + }); + + describe("getIsDarkModeFromParentTag", () => { + it("should get false from closest parent with data-color-scheme light set", () => { + render( + <div data-color-scheme="dark"> + <div data-color-scheme="light"> + <ToggleSwitchColorScheme control="internal" label="Foo" /> + </div> + </div>, + ); + + expect(getIsDarkModeFromParentTag(screen.getByLabelText("Foo"))).toBe( + false, + ); + }); + + it("should get true from closest parent with data-color-scheme dark set", () => { + render( + <div data-color-scheme="light"> + <div data-color-scheme="dark"> + <ToggleSwitchColorScheme control="internal" label="Foo" /> + </div> + </div>, + ); + + expect(getIsDarkModeFromParentTag(screen.getByLabelText("Foo"))).toBe( + true, + ); + }); + + it("should get undefined when no parent has data-color-scheme set", () => { + render( + <div> + <ToggleSwitchColorScheme control="internal" label="Foo" /> + </div>, + ); + + expect( + getIsDarkModeFromParentTag(screen.getByLabelText("Foo")), + ).toBeUndefined(); + }); + }); + + describe("setClosestDataColorSchemeValue", () => { + it("should update value of parent div", () => { + render( + <div data-color-scheme="dark"> + <ToggleSwitchColorScheme control="internal" label="Foo" /> + </div>, + ); + + const element = screen.getByLabelText("Foo"); + + expect(getIsDarkModeFromParentTag(element)).toBe(true); + + setClosestDataColorSchemeValue(element, DataColorScheme.LIGHT); + + expect(getIsDarkModeFromParentTag(element)).toBe(false); + }); + + it("should update value of html tag", () => { + render(<ToggleSwitchColorScheme control="internal" label="Foo" />); + + const element = screen.getByLabelText("Foo"); + + expect(getIsDarkModeFromParentTag(element)).toBe(undefined); + + setClosestDataColorSchemeValue(element, DataColorScheme.DARK); + + expect(getIsDarkModeFromParentTag(element)).toBe(true); + + expect(document.documentElement.getAttribute("data-color-scheme")).toBe( + "dark", + ); + }); + }); + + describe("custom hook isDarkMode", () => { + const defaultMatchMedia = window.matchMedia("(prefers-color-scheme: dark)"); + let windowMatchMediaSpy: + | jest.SpyInstance<MediaQueryList, [query: string]> + | undefined; + + const setSystemThemeLight = () => { + windowMatchMediaSpy?.mockImplementation(() => ({ + ...defaultMatchMedia, + matches: false, + })); + }; + + const setSystemThemeDark = () => { + windowMatchMediaSpy?.mockImplementation(() => ({ + ...defaultMatchMedia, + matches: true, + })); + }; + + const getFromLocalStorage = () => + localStorage.getItem(COLOR_SCHEME_LOCAL_STORAGE_KEY); + + beforeEach(() => { + windowMatchMediaSpy?.mockRestore(); + windowMatchMediaSpy = jest.spyOn(window, "matchMedia"); + }); + + it("should be true when system theme is dark", () => { + setSystemThemeDark(); + const { result } = renderHook(useIsDarkMode); + + expect(result.current).toBe(true); + }); + + it("should be false when system theme is light", () => { + setSystemThemeLight(); + const { result } = renderHook(useIsDarkMode); + + expect(result.current).toBe(false); + }); + + it("should be able to change value using localStorage", () => { + setSystemThemeLight(); + const { result, rerender } = renderHook(useIsDarkMode); + + expect(getFromLocalStorage()).toBeNull(); + + act(() => { + localStorage.setItem(COLOR_SCHEME_LOCAL_STORAGE_KEY, String(true)); + }); + + expect(getFromLocalStorage()).toBe("true"); + + rerender(); // Seems like the useExternalSync-hook doesn't rerender on storage change in test env + expect(result.current).toBe(true); + }); + + it("should be able to change value of wrapper from value in localStorage", () => { + setSystemThemeLight(); + + const TestComponent = () => { + useSetColorSchemeFromLocalStorage(); + + return <body>Hello</body>; + }; + + const { container, rerender } = render(<TestComponent />, { + container: document.documentElement, + }); + + expect(getFromLocalStorage()).toBeNull(); + + act(() => { + localStorage.setItem(COLOR_SCHEME_LOCAL_STORAGE_KEY, String(true)); + }); + + expect(getFromLocalStorage()).toBe("true"); + + rerender(<TestComponent />); // Seems like the useExternalSync-hook doesn't rerender on storage change in test env + expect(container.getAttribute("data-color-scheme")).toBe("dark"); + }); + }); +}); diff --git a/packages/toggle/ToggleSwitchColorScheme.tsx b/packages/toggle/ToggleSwitchColorScheme.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ef7fe4b860a518be4731aa7c9aa199d0c54510f --- /dev/null +++ b/packages/toggle/ToggleSwitchColorScheme.tsx @@ -0,0 +1,377 @@ +import { + ChangeEvent, + ChangeEventHandler, + DetailedHTMLProps, + HTMLAttributes, + InputHTMLAttributes, + ReactNode, + useSyncExternalStore, +} from "react"; +import { MoonIcon, SunIcon } from "@sikt/sds-icons"; +import "./toggle-switch-color-scheme.pcss"; +import clsx from "clsx"; + +export const COLOR_SCHEME_LOCAL_STORAGE_KEY = "sds-color-scheme-is-dark-mode"; +const COLOR_SCHEME_ATTRIBUTE = "data-color-scheme"; + +export const DataColorScheme = { + LIGHT: "light", + DARK: "dark", +} as const; + +type DataColorSchemeType = + (typeof DataColorScheme)[keyof typeof DataColorScheme]; + +export const getClosestElementForDataColorScheme = ( + element?: HTMLElement, + colorSchemeAttribute: string = COLOR_SCHEME_ATTRIBUTE, +): HTMLElement => { + if (!element) return document.documentElement; + + return ( + element.closest(`[${colorSchemeAttribute}]`) ?? + element.closest("html") ?? + document.documentElement + ); +}; + +export const getClosestDataColorSchemeValue = ( + element: HTMLElement, + colorSchemeAttribute: string = COLOR_SCHEME_ATTRIBUTE, +): string | null | undefined => + getClosestElementForDataColorScheme(element).getAttribute( + colorSchemeAttribute, + ); + +export const setClosestDataColorSchemeValue = ( + element: HTMLElement, + value: DataColorSchemeType, + colorSchemeAttribute: string = COLOR_SCHEME_ATTRIBUTE, +) => { + getClosestElementForDataColorScheme(element).setAttribute( + colorSchemeAttribute, + value, + ); +}; + +export const getIsDarkModeFromSystem = (): boolean => + window.matchMedia("(prefers-color-scheme: dark)").matches; + +const getIsDarkModeFromLocalStorage = ( + localStorageKey: string, +): boolean | undefined => { + const fromLocalStorage = localStorage.getItem(localStorageKey); + + return fromLocalStorage ? fromLocalStorage === "true" : undefined; +}; + +// Finds and checks the state of the closest parent element with the attribute data-color-scheme set +export const getIsDarkModeFromParentTag = ( + wrapperElement: HTMLElement | undefined, +): boolean | undefined => { + if (!wrapperElement) return undefined; + + // Multiple tags might exist e.g. in storybook presentation + const fromTag = getClosestDataColorSchemeValue(wrapperElement); + + if (fromTag === DataColorScheme.LIGHT) return false; + if (fromTag === DataColorScheme.DARK) return true; + + return undefined; +}; + +/* + * Checks possible dark mode states in prioritized order and returns the value of the highest defined priority + * 1. The value of the closest parent element with data-color-scheme set + * 2. The system value + * */ +export const getIsDarkModeFromParentTagOrSystem = ( + parentTagState: boolean | undefined, + systemState: boolean, +): boolean => { + if (parentTagState !== undefined) return parentTagState; + + return systemState; +}; + +const storageChangeSubscribe = ( + callback: (this: Window, ev: StorageEvent) => unknown, +) => { + window.addEventListener("storage", callback); + + return () => { + window.removeEventListener("storage", callback); + }; +}; + +const matchMediaColorSchemeChangeSubscribe = ( + callback: (this: MediaQueryList, ev: MediaQueryListEvent) => unknown, +) => { + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", callback); + + return () => { + window + .matchMedia("(prefers-color-scheme: dark)") + .removeEventListener("change", callback); + }; +}; + +const getServerSnapshot = () => false; + +interface ColorSchemeHookParams { + localStorageKey?: string; + wrapperElement?: HTMLElement; +} + +export const useIsDarkMode = ({ + localStorageKey, + wrapperElement, +}: ColorSchemeHookParams = {}): boolean => { + const getLocalStorageSnapshot = () => { + const localStorageState = getIsDarkModeFromLocalStorage( + localStorageKey ?? COLOR_SCHEME_LOCAL_STORAGE_KEY, + ); + + if (localStorageState !== undefined) return localStorageState; + + const parentTagState = getIsDarkModeFromParentTag(wrapperElement); + const systemState = getIsDarkModeFromSystem(); + + return getIsDarkModeFromParentTagOrSystem(parentTagState, systemState); + }; + + const isDarkModeFromLocalStorage = useSyncExternalStore<boolean | undefined>( + storageChangeSubscribe, + getLocalStorageSnapshot, + getServerSnapshot, + ); + + const getMatchMediaColorSchemeChangeSnapshot = () => + window.matchMedia("(prefers-color-scheme: dark)").matches; + + const isDarkModeFromSystemChange = useSyncExternalStore<boolean>( + matchMediaColorSchemeChangeSubscribe, + getMatchMediaColorSchemeChangeSnapshot, + getServerSnapshot, + ); + + return isDarkModeFromLocalStorage ?? isDarkModeFromSystemChange; +}; + +export const useSetColorSchemeFromLocalStorage = ({ + localStorageKey, + wrapperElement = document.documentElement, +}: ColorSchemeHookParams = {}) => { + const isDarkMode = useIsDarkMode({ localStorageKey, wrapperElement }); + + setClosestDataColorSchemeValue( + wrapperElement, + isDarkMode ? DataColorScheme.DARK : DataColorScheme.LIGHT, + ); +}; + +type ToggleSwitchHTMLProps = Omit< + DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, + "onChange" | "aria-label" +>; + +interface Label { + /** + * A custom label. One must either pass in a `label` or an `aria-label` to maintain accessibility. + * */ + label: ReactNode; + /** + * A custom aria-label. One must either pass in a `label` or an `aria-label` to maintain accessibility. + * */ + "aria-label"?: never; +} + +interface AriaLabel { + /** + * A custom aria-label. One must either pass in a `label` or an `aria-label` to maintain accessibility. + * */ + "aria-label": string; + /** + * A custom label. One must either pass in a `label` or an `aria-label` to maintain accessibility. + * */ + label?: never; +} + +type LabelOrAriaLabel = Label | AriaLabel; + +interface ExternalControl { + /** + * Required prop defining whether the dark mode toggle state is set to be controlled externally or internally. + * */ + control: "external"; + /** + * Sets the checked value of the toggle switch. + * */ + isDarkMode: boolean; + /** + * Required to handle change event, when control is external. + * */ + onChange: ChangeEventHandler<HTMLInputElement>; +} + +interface InternalControl { + /** + * Required prop defining whether the dark mode toggle state is set to be controlled externally or internally. + * */ + control: "internal"; + isDarkMode?: never; + onChange?: ChangeEventHandler<HTMLInputElement>; +} + +type Control = ExternalControl | InternalControl; + +export type ToggleSwitchColorSchemeProps = ToggleSwitchHTMLProps & + LabelOrAriaLabel & + Control & { + /** + * Whether the `label`, if it exists, should be placed in front of the component. + * */ + labelFirst?: boolean; + /** + * Props sent directly to the wrapped input component. + * */ + inputProps?: Omit<InputHTMLAttributes<HTMLInputElement>, "aria-label">; + /** + * A custom key for local storage to use, instead of the default `sds-color-scheme`. + * Needed e.g. when using multiple `ToggleSwitchColorScheme` components and dark mode sections. + * */ + localStorageKey?: string; + }; + +type ToggleSwitchColorSchemeInnerProps = Pick< + ToggleSwitchColorSchemeProps, + keyof ToggleSwitchHTMLProps | "label" | "inputProps" | "aria-label" +> & + Pick< + Required<ToggleSwitchColorSchemeProps>, + "isDarkMode" | "onChange" | "labelFirst" + >; + +const ToggleSwitchColorSchemeInner = ({ + className, + label, + labelFirst, + onChange, + isDarkMode, + ...props +}: ToggleSwitchColorSchemeInnerProps) => { + const labelElement = label && ( + <div className="sds-toggle-switch-color-scheme__label-text">{label}</div> + ); + + return ( + <div + {...props} + className={clsx( + "sds-toggle-switch-color-scheme", + isDarkMode && "sds-toggle-switch-color-scheme--checked", + className, + )} + > + <label className="sds-toggle-switch-color-scheme__label"> + {labelFirst && labelElement} + <div className="sds-toggle-switch-color-scheme__inner"> + <input + type="checkbox" + role="switch" + className="sds-toggle-switch-color-scheme__track" + checked={isDarkMode} + onChange={onChange} + readOnly={!onChange} + {...{ + ...props.inputProps, + "aria-label": props["aria-label"], + }} + /> + <div className="sds-toggle-switch-color-scheme__thumb"> + {isDarkMode ? ( + <MoonIcon className="sds-toggle-switch-color-scheme__icon" /> + ) : ( + <SunIcon className="sds-toggle-switch-color-scheme__icon" /> + )} + </div> + </div> + {!labelFirst && labelElement} + </label> + </div> + ); +}; + +type ToggleSwitchColorSchemeInternalControlProps = Pick< + ToggleSwitchColorSchemeProps, + | keyof ToggleSwitchHTMLProps + | "label" + | "inputProps" + | "localStorageKey" + | "onChange" + | "aria-label" +> & + Pick<Required<ToggleSwitchColorSchemeProps>, "labelFirst">; + +const ToggleSwitchColorSchemeInternalControl = ({ + localStorageKey, + onChange: externalOnChange, + ...props +}: ToggleSwitchColorSchemeInternalControlProps) => { + const isDarkMode = useIsDarkMode({ localStorageKey }); + + const onChange = (event: ChangeEvent<HTMLInputElement>) => { + localStorage.setItem( + localStorageKey ?? COLOR_SCHEME_LOCAL_STORAGE_KEY, + String(event.currentTarget.checked), + ); + window.dispatchEvent(new Event("storage")); + + if (externalOnChange) externalOnChange(event); + }; + + return ( + <ToggleSwitchColorSchemeInner + {...props} + isDarkMode={isDarkMode} + onChange={onChange} + /> + ); +}; + +/** + * A component that makes it possible to toggle dark mode on/off in *@sikt/sds-* contexts. + * + * When `control` is set to `external`, all state management of the toggle component is handled by the `isDarkMode` + * and `onChange` props. This is necessary e.g. when using SSR rendering (NextJS). + * + * When `control` is set to `internal`, state management is handled internally in the component, and `localStorage` is + * used to keep track of state. + * Toggling dark mode from the value in `localStorage` can be done by the custom hook `useSetColorSchemeFromLocalStorage`. + * Examples of this are shown in the "Show code" sections of the stories. + * */ +export const ToggleSwitchColorScheme = ({ + localStorageKey = COLOR_SCHEME_LOCAL_STORAGE_KEY, + labelFirst = false, + control, + onChange, + isDarkMode, + ...props +}: ToggleSwitchColorSchemeProps) => + control === "external" ? ( + <ToggleSwitchColorSchemeInner + {...props} + onChange={onChange} + labelFirst={labelFirst} + isDarkMode={isDarkMode} + /> + ) : ( + <ToggleSwitchColorSchemeInternalControl + {...props} + localStorageKey={localStorageKey} + labelFirst={labelFirst} + onChange={onChange} + /> + ); diff --git a/packages/toggle/index.ts b/packages/toggle/index.ts index 44f83be279cd23622493b205038c9be3d3694585..782ac830ccb3a7e3f1f6451e4fe221bf91ddb9a7 100644 --- a/packages/toggle/index.ts +++ b/packages/toggle/index.ts @@ -6,3 +6,8 @@ export { ToggleSegment } from "./ToggleSegment"; export { ToggleSegmentOption } from "./ToggleSegmentOption"; export type { ToggleSegmentProps } from "./ToggleSegment"; export type { ToggleSegmentOptionProps } from "./ToggleSegmentOption"; +export type { ToggleSwitchColorSchemeProps } from "./ToggleSwitchColorScheme"; +export { + ToggleSwitchColorScheme, + useSetColorSchemeFromLocalStorage, +} from "./ToggleSwitchColorScheme"; diff --git a/packages/toggle/stories/ToggleSwitchColorScheme.stories.tsx b/packages/toggle/stories/ToggleSwitchColorScheme.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ecfb3e7a2e568386a156f6b240690eed112f878 --- /dev/null +++ b/packages/toggle/stories/ToggleSwitchColorScheme.stories.tsx @@ -0,0 +1,274 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { + ToggleSwitchColorScheme as ToggleSwitchColorSchemeToMask, + ToggleSwitchColorSchemeProps, +} from "../index"; +import { ChangeEvent, SyntheticEvent, useCallback, useState } from "react"; +import { + getIsDarkModeFromParentTag, + getIsDarkModeFromParentTagOrSystem, + getIsDarkModeFromSystem, +} from "../ToggleSwitchColorScheme"; + +const meta: Meta = { + title: "Components/Toggle/ToggleSwitchColorScheme", + component: ToggleSwitchColorSchemeToMask, +}; + +export default meta; + +type Story = StoryObj<ToggleSwitchColorSchemeProps>; + +const setValueOnClosestSBAnchor = (element: HTMLElement, value: boolean) => { + element + .closest(".sb-anchor") + ?.setAttribute("data-color-scheme", value ? "dark" : "light"); +}; + +// Mask default ToggleSwitchColorScheme internal implementation to work around storybook wrapper limitations, +// and make "Show code" have pretty presentation +const ToggleSwitchColorScheme = ({ ...args }: ToggleSwitchColorSchemeProps) => { + const [isDarkMode, setIsDarkMode] = useState( + args.isDarkMode ?? + getIsDarkModeFromParentTagOrSystem( + getIsDarkModeFromParentTag(undefined), + getIsDarkModeFromSystem(), + ), + ); + + const onChange = (event: ChangeEvent<HTMLInputElement>) => { + const { checked } = event.currentTarget; + + setIsDarkMode(checked); + setValueOnClosestSBAnchor(event.currentTarget, checked); + if (args.onChange) args.onChange(event); + }; + + const ref = useCallback((node: HTMLSpanElement | null) => { + if (node) { + const valueFromContext = + args.isDarkMode ?? + getIsDarkModeFromParentTagOrSystem( + getIsDarkModeFromParentTag(node), + getIsDarkModeFromSystem(), + ); + setIsDarkMode(valueFromContext); + setValueOnClosestSBAnchor(node, valueFromContext); + } + }, []); + + return ( + <span ref={ref}> + <ToggleSwitchColorSchemeToMask + {...args} + onChange={onChange} + isDarkMode={isDarkMode} + control="external" + /> + </span> + ); +}; + +const hookPrefix = ` +// Above in component hierarchy (not compatible with control set to "external") +useSetColorSchemeFromLocalStorage(); +// or +useSetColorSchemeFromLocalStorage({ wrapperElement: someElement, localStorageKey: someKey }); +`; + +const ariaLabel = "Toggle switch color scheme"; + +const defaultCode = ` +${hookPrefix} +<ToggleSwitchColorScheme control="internal" aria-label="${ariaLabel}" />`; + +export const Default: Story = { + render: (args: ToggleSwitchColorSchemeProps) => ( + <ToggleSwitchColorScheme {...args} /> + ), + args: { + control: "internal", + "aria-label": ariaLabel, + }, + parameters: { + docs: { + source: { + language: "tsx", + code: defaultCode, + }, + }, + }, +}; + +const withLabelCode = ` +${hookPrefix} +<ToggleSwitchColorScheme + control="internal" + label="Label" +/>`; + +export const WithLabel: Story = { + render: (args: ToggleSwitchColorSchemeProps) => ( + <ToggleSwitchColorScheme {...args} /> + ), + args: { + control: "internal", + label: "Label", + }, + parameters: { + docs: { + source: { + language: "tsx", + code: withLabelCode, + }, + }, + }, +}; + +const withLabelFirstCode = ` +${hookPrefix} +<ToggleSwitchColorScheme + control="internal" + label="Label" + labelFirst +/>`; + +export const WithLabelFirst: Story = { + render: (args: ToggleSwitchColorSchemeProps) => ( + <ToggleSwitchColorScheme {...args} /> + ), + args: { + control: "internal", + label: "Label", + labelFirst: true, + }, + parameters: { + docs: { + source: { + language: "tsx", + code: withLabelFirstCode, + }, + }, + }, +}; + +const externalCode = ` +const MyComponent = () => { + const [isDarkMode, setIsDarkMode] = useState(false); + + return ( + <ToggleSwitchColorScheme + control="external" + isDarkMode={isDarkMode} + onChange={({ currentTarget: { checked } }) => { + alert(\`Color scheme is updated to: \${checked ? "dark" : "light"}\`); + setIsDarkMode(checked); + }} + aria-label="${ariaLabel}" + /> + ); +} +`; + +export const ExternalControl: Story = { + render: (args: ToggleSwitchColorSchemeProps) => ( + <ToggleSwitchColorScheme {...args} /> + ), + args: { + control: "external", + isDarkMode: false, + onChange: (event: SyntheticEvent<HTMLInputElement>) => { + alert( + `Color scheme is updated to: ${ + event.currentTarget.checked ? "dark" : "light" + }`, + ); + }, + "aria-label": ariaLabel, + }, + parameters: { + docs: { + source: { + language: "tsx", + code: externalCode, + }, + }, + }, +}; + +const nextCode = ` +// app/i-am-root.tsx (where html tag is defined) +import { cookies } from "next/headers"; + +const initColorScheme = cookies().get("data-color-scheme")?.value; +const App = () => <html lang="en" data-color-scheme={initColorScheme ?? "light"}>{rest-of-app}</html>; + +// lib/colorSchemeServerActions.ts +"use server"; // Stable as of NextJS v14 - experimental in NextJS v13 (needs to be enabled in next.config.js) + +import { cookies } from "next/headers"; + +export const getCookieValueFromServer = async () => + cookies().get("data-color-scheme")?.value === "dark"; + +export const setCookieValueInServerContext = async (value: string) => { + cookies().set(COOKIE_KEY, value); +}; + +// lib/toggleSwitchColorSchemeClient.tsx +"use client"; + +import { ToggleSwitchColorScheme as SDSToggleSwitchColorScheme } from "@sikt/sds-toggle"; +import { getCookieValueFromServer, setCookieInServerContext } from "@/src/lib/colorSchemeServerActions"; + +const ToggleSwitchColorSchemeClient = () => { + const [isDarkMode, setIsDarkMode] = useState<boolean>(); + + useEffect(() => { + startTransition(() => { + getCookieValueFromServer().then((res) => setIsDarkMode(res)); + }); + }, []); + + const onChangeHandler = ({ + currentTarget: { checked }, + }: ChangeEvent<HTMLInputElement>) => { + startTransition(() => { + setCookieValueInServerContext(checked ? "dark" : "light").then(() => { + setIsDarkMode(checked); + }); + }); + }; + + return isDarkMode !== undefined ? ( + <SDSToggleSwitchColorScheme + control="external" + isDarkMode={isDarkMode} + onChange={onChangeHandler} + aria-label="${ariaLabel}" + /> + ) : <></>; +}; + +export default ToggleSwitchColorSchemeClient; + +// compopnent/that/needs/ToggleSwitchColorScheme.tsx +import ToggleSwitchColorScheme from 'lib/toggleSwitchColorSchemeClient'; + +<ToggleSwitchColorScheme /> +`; + +export const NextJSExampleImplementation: StoryObj = { + name: "Pseudo code of NextJS implementation (using cookies)", + parameters: { + docs: { + canvas: { + sourceState: "shown", + }, + source: { + language: "tsx", + code: nextCode, + }, + }, + }, +}; diff --git a/packages/toggle/toggle-switch-color-scheme.pcss b/packages/toggle/toggle-switch-color-scheme.pcss new file mode 100644 index 0000000000000000000000000000000000000000..d7dd1cf5c738d2f77be25db1b94f5362f8abd502 --- /dev/null +++ b/packages/toggle/toggle-switch-color-scheme.pcss @@ -0,0 +1,151 @@ +.sds-toggle-switch-color-scheme { + --toggle-transition-duration: var(--sds-effect-animation-duration-medium); + --toggle-track-width: var(--sds-base-size-l); + --toggle-thumb-size: var(--sds-base-size-s1); + --toggle-border-width: var(--sds-space-border-weight-regular); + --toggle-padding: var(--sds-space-padding-minimal); + --toggle-thumb-offset: var(--toggle-padding); + --toggle-track-background-color: color-mix( + in srgb, + white, + var(--sds-color-support-warning-default) + ); + --toggle-track-border-color: var( + --sds-color-interaction-neutral-strong-default + ); + --toggle-thumb-background-color: var( + --sds-color-interaction-neutral-strong-default + ); + --toggle-thumb-color: white; + --toggle-thumb-position: 0; + + &__label { + align-items: center; + cursor: pointer; + display: inline-flex; + gap: var(--sds-space-padding-small); + } + + &:hover, + &:active { + --toggle-track-background-color: color-mix( + in srgb, + white 70%, + var(--sds-color-support-warning-default) + ); + } + + &:hover { + --toggle-track-border-color: var( + --sds-color-interaction-neutral-strong-highlight + ); + --toggle-thumb-background-color: var( + --sds-color-interaction-neutral-strong-highlight + ); + } + + &:active { + --toggle-track-border-color: var( + --sds-color-interaction-neutral-strong-pressed + ); + --toggle-thumb-background-color: var( + --sds-color-interaction-neutral-strong-pressed + ); + } + + &--checked { + --toggle-track-background-color: var( + --sds-color-interaction-primary-strong-default + ); + --toggle-track-border-color: var(--toggle-track-background-color); + --toggle-thumb-background-color: var(--sds-color-layout-background-default); + --toggle-thumb-position: calc( + var(--toggle-track-width) - var(--toggle-thumb-size) - 2 * + var(--toggle-thumb-offset) + ); + + &:hover, + &:active { + --toggle-thumb-background-color: var( + --sds-color-layout-background-default + ); + } + + &:hover { + --toggle-track-background-color: var( + --sds-color-interaction-primary-strong-highlight + ); + --toggle-track-border-color: var( + --sds-color-interaction-primary-strong-highlight + ); + } + + &:active { + --toggle-track-background-color: var( + --sds-color-interaction-primary-strong-pressed + ); + --toggle-track-border-color: var( + --sds-color-interaction-primary-strong-pressed + ); + } + } + + &__inner { + align-items: center; + display: inline-flex; + padding: calc(var(--sds-space-padding-small) - var(--toggle-border-width)) 0; + position: relative; + } + + &__label-text { + align-items: center; + color: var(--sds-color-text-primary); + display: flex; + font-size: var(--sds-typography-body-fontsize-regular); + font-weight: var(--sds-typography-weight-regular); + line-height: var(--sds-typography-body-lineheight-regular); + padding: var(--sds-space-padding-tiny); + } + + &__track { + appearance: none; + cursor: pointer; + background-color: var(--toggle-track-background-color); + border: var(--toggle-border-width) solid var(--toggle-track-border-color); + border-radius: var(--toggle-thumb-size); + height: calc( + var(--toggle-thumb-size) + calc(2 * var(--toggle-thumb-offset)) + ); + width: var(--toggle-track-width); + padding: var(--toggle-padding); + transition: + background-color var(--toggle-transition-duration), + border-color var(--toggle-transition-duration); + + &:focus-visible { + outline: var(--sds-focus-outline); + outline-offset: 0; + } + } + + &__thumb { + display: inline-flex; + align-items: center; + justify-content: center; + position: absolute; + left: var(--toggle-thumb-offset); + height: var(--toggle-thumb-size); + width: var(--toggle-thumb-size); + border-radius: var(--sds-space-border-radius-full); + transition: all var(--toggle-transition-duration); + background-color: var(--toggle-thumb-background-color); + transform: translateX(var(--toggle-thumb-position)); + padding: var(--sds-space-border-weight-regular); + + & > .sds-toggle-switch-color-scheme__icon { + transition: all var(--toggle-transition-duration); + color: var(--toggle-thumb-color); + font-size: var(--sds-base-size-s); + } + } +}