Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC: feat: add mode updater to SaltTheme #3237

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
useAriaAnnouncer,
useDensity,
useTheme,
ModeValues,
} from "@salt-ds/core";
import { WindowProvider } from "@salt-ds/window";
import { mount } from "cypress/react18";
import { type ReactNode, useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { Mode } from "fs";

Check failure on line 14 in packages/core/src/__tests__/__e2e__/salt-provider/SaltProvider.cy.tsx

View workflow job for this annotation

GitHub Actions / lint

lint/style/useNodejsImportProtocol

A Node.js builtin module should be imported with the node: protocol.

Check failure on line 14 in packages/core/src/__tests__/__e2e__/salt-provider/SaltProvider.cy.tsx

View workflow job for this annotation

GitHub Actions / lint

lint/style/useImportType

All these imports are only used as types.

const TestComponent = ({
id = "test-1",
Expand Down Expand Up @@ -316,6 +318,93 @@

cy.get("@consoleSpy").should("not.have.been.called");
});

describe("when the mode is set", () => {
const ThemeToggle = () => {
const { setMode } = useTheme();

const handleClick = () => {
setMode((prevState) =>
prevState === ModeValues[0] ? ModeValues[1] : ModeValues[0]
);
};
return <button onClick={handleClick}>Set Mode</button>;
};

it("should update the mode", () => {
mount(
<SaltProvider>
<ThemeToggle />
<TestComponent />
</SaltProvider>
);
cy.get("#test-1").should("exist").and("have.attr", "data-mode", "light");
cy.findByRole("button").realClick();
cy.get("#test-1").should("have.attr", "data-mode", "dark");
cy.findByRole("button").realClick();
cy.get("#test-1").should("have.attr", "data-mode", "light");
});
});

describe("when the mode is controlled by consumers", () => {
const ControlledModeSaltProvider = ({
children,
}: {
children: ReactNode;
}) => {
const [mode, setMode] = useState<Mode>(ModeValues[0]);

const handleClick = () => {
setMode((prevState) =>
prevState === ModeValues[0] ? ModeValues[1] : ModeValues[0]
);
};
return (
<SaltProvider mode={mode}>

Check failure on line 363 in packages/core/src/__tests__/__e2e__/salt-provider/SaltProvider.cy.tsx

View workflow job for this annotation

GitHub Actions / type-checks

Type 'Mode' is not assignable to type '"dark" | "light" | undefined'.
<button onClick={handleClick}>Set Mode</button>
{children}
</SaltProvider>
);
};

it("should update the mode", () => {
mount(
<ControlledModeSaltProvider>
<TestComponent />
</ControlledModeSaltProvider>
);
cy.get("#test-1").should("exist").and("have.attr", "data-mode", "light");
cy.findByRole("button").realClick();
cy.get("#test-1").should("have.attr", "data-mode", "dark");
cy.findByRole("button").realClick();
cy.get("#test-1").should("have.attr", "data-mode", "light");
});
});

describe("when the mode is inverted", () => {
const ThemeToggle = () => {
const { invertMode } = useTheme();

const handleClick = () => {
invertMode();
};
return <button onClick={handleClick}>Invert Mode</button>;
};

it("should update the mode", () => {
mount(
<SaltProvider>
<ThemeToggle />
<TestComponent />
</SaltProvider>
);
cy.get("#test-1").should("exist").and("have.attr", "data-mode", "light");
cy.findByRole("button").realClick();
cy.get("#test-1").should("have.attr", "data-mode", "dark");
cy.findByRole("button").realClick();
cy.get("#test-1").should("have.attr", "data-mode", "light");
});
});
});

describe("Given a SaltProviderNext", () => {
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/salt-provider/SaltProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
import { clsx } from "clsx";
import {
type HTMLAttributes,
type Dispatch,
type SetStateAction,
type ReactElement,
type ReactNode,
cloneElement,
createContext,
isValidElement,
useContext,
useMemo,
useState,
} from "react";
import { AriaAnnouncerProvider } from "../aria-announcer";
import {
Expand Down Expand Up @@ -64,6 +67,14 @@
UNSTABLE_actionFont: ActionFont;
}

export const invertMode = (mode: string): "light" | "dark" =>
mode === "light" ? "dark" : "light";

export interface ThemeContextValue extends ThemeContextProps {
setMode: Dispatch<SetStateAction<Mode>>;
invertMode: () => void;
}

export const DensityContext = createContext<Density>(DEFAULT_DENSITY);

export const ThemeContext = createContext<ThemeContextProps>({
Expand Down Expand Up @@ -262,14 +273,18 @@
const density = densityProp ?? inheritedDensity ?? DEFAULT_DENSITY;
const themeName =
themeProp ?? (inheritedTheme === "" ? DEFAULT_THEME_NAME : inheritedTheme);
const mode = modeProp ?? inheritedMode;
const breakpoints = breakpointsProp ?? DEFAULT_BREAKPOINTS;
const corner = cornerProp ?? inheritedCorner ?? DEFAULT_CORNER;
const headingFont =
headingFontProp ?? inheritedHeadingFont ?? DEFAULT_HEADING_FONT;
const accent = accentProp ?? inheritedAccent ?? DEFAULT_ACCENT;
const actionFont =
actionFontProp ?? inheritedActionFont ?? DEFAULT_ACTION_FONT;
const [modeFromState, setMode] = useState<Mode>(
modeProp ?? (inheritedMode ?? DEFAULT_MODE)
);

const mode = modeProp || modeFromState;

const applyClassesTo =
applyClassesToProp ?? (isRootProvider ? "root" : "scope");
Expand All @@ -281,7 +296,7 @@
window: targetWindow,
});

const themeContextValue = useMemo(
const themeContextValue = useMemo<ThemeContextValue>(
() => ({
theme: themeName,
mode,
Expand All @@ -291,6 +306,8 @@
headingFont: headingFont,
accent: accent,
actionFont: actionFont,
setMode,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to include this in the dep array as React guarantees stable refs for useState setters

invertMode: () => setMode((prevState) => invertMode(prevState)),
// Backward compatibility
UNSTABLE_corner: corner,
UNSTABLE_headingFont: headingFont,
Expand Down Expand Up @@ -454,10 +471,10 @@
/** @deprecated use `SaltProviderNext` */
export const UNSTABLE_SaltProviderNext = SaltProviderNext;

export const useTheme = (): ThemeContextProps => {
export const useTheme = (): Omit<ThemeContextValue, "window"> => {
const { window, ...contextWithoutWindow } = useContext(ThemeContext);

return contextWithoutWindow;

Check failure on line 477 in packages/core/src/salt-provider/SaltProvider.tsx

View workflow job for this annotation

GitHub Actions / type-checks

Type '{ theme: string; mode: "dark" | "light"; themeNext: boolean; corner: "sharp" | "rounded"; UNSTABLE_corner: "sharp" | "rounded"; headingFont: "Open Sans" | "Amplitude"; UNSTABLE_headingFont: "Open Sans" | "Amplitude"; accent: "blue" | "teal"; UNSTABLE_accent: "blue" | "teal"; actionFont: "Open Sans" | "Amplitude"; UN...' is missing the following properties from type 'Omit<ThemeContextValue, "window">': setMode, invertMode
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SaltProvider,
ToggleButton,
ToggleButtonGroup,
useTheme,
} from "@salt-ds/core";
import { type SyntheticEvent, useState } from "react";

Expand All @@ -31,7 +32,7 @@ export const Default = () => {
};

export const ToggleTheme = () => {
const [mode, setMode] = useState<Mode>("light");
const { mode, setMode } = useTheme();

const handleChangeTheme = (event: SyntheticEvent<HTMLButtonElement>) => {
setMode(event.currentTarget.value as Mode);
Expand Down
Loading