Skip to content

Commit 0cffa81

Browse files
authored
WB-1808: Button - Use CSS pseudo-classes for styling states (hover, focus, etc) (#2404)
## Summary: Changing the way we style different states. We are going to rely on CSS pseudo-classes for styling states (`:hover`, `:focus-visible`). Note that we have to keep some `ClickableBehavior` states for programmatic focus and preserve active/pressed overrides. Also took the opportunity to modify the primary variant to use `outline + outlineOffset` instead of `boxShadow`. Issue: https://khanacademy.atlassian.net/browse/WB-1808 ## Test plan: Navigate to `/?path=/docs/packages-button--docs` and verify that the buttons are styled correctly. <img width="1634" alt="Screenshot 2024-12-19 at 5 28 22 PM" src="https://github.com/user-attachments/assets/067b8c6d-2a2d-4be8-80b7-3a1ba01108c4" /> Author: jandrade Reviewers: jandrade, beaesguerra Required Reviewers: Approved By: beaesguerra Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ⏭️ Chromatic - Skip on Release PR (changesets), ✅ gerald, ⏭️ dependabot Pull Request URL: #2404
1 parent 7516b23 commit 0cffa81

7 files changed

Lines changed: 328 additions & 3593 deletions

File tree

.changeset/rich-days-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/wonder-blocks-button": patch
3+
---
4+
5+
Use pseudo-classes for styling states (:hover, :focus-visible). Keep some clickable states for programmatic focus and preserve active/pressed overrides.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import * as React from "react";
2+
import {StyleSheet} from "aphrodite";
3+
import {action} from "@storybook/addon-actions";
4+
import type {Meta, StoryObj} from "@storybook/react";
5+
6+
import paperPlaneIcon from "@phosphor-icons/core/fill/paper-plane-tilt-fill.svg";
7+
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
8+
import {ThemeSwitcherContext} from "@khanacademy/wonder-blocks-theming";
9+
import {color, semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
10+
import {HeadingLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography";
11+
import Button from "@khanacademy/wonder-blocks-button";
12+
13+
/**
14+
* The following stories are used to generate the pseudo states for the
15+
* Button component. This is only used for visual testing in Chromatic.
16+
*/
17+
export default {
18+
title: "Packages / Button / All Variants",
19+
parameters: {
20+
docs: {
21+
autodocs: false,
22+
},
23+
chromatic: {
24+
// NOTE: This is required to prevent Chromatic from cutting off the
25+
// dark background in screenshots (accounts for all the space taken
26+
// by the variants).
27+
viewports: [1700],
28+
},
29+
},
30+
} as Meta;
31+
32+
type StoryComponentType = StoryObj<typeof Button>;
33+
34+
type ButtonProps = PropsFor<typeof Button>;
35+
36+
const sizes: Array<ButtonProps["size"]> = ["medium", "small", "large"];
37+
const kinds: Array<ButtonProps["kind"]> = ["primary", "secondary", "tertiary"];
38+
39+
const colors: Array<ButtonProps["color"]> = ["default", "destructive"];
40+
41+
function VariantsGroup({
42+
color = "default",
43+
disabled = false,
44+
label = "Button",
45+
light,
46+
size,
47+
}: {
48+
color?: ButtonProps["color"];
49+
disabled?: ButtonProps["disabled"];
50+
label?: string;
51+
light: boolean;
52+
size: ButtonProps["size"];
53+
}) {
54+
const theme = React.useContext(ThemeSwitcherContext);
55+
const category = disabled ? "disabled" : color;
56+
57+
return (
58+
<View
59+
style={[
60+
styles.variants,
61+
light &&
62+
(theme === "khanmigo"
63+
? styles.darkKhanmigo
64+
: styles.darkDefault),
65+
]}
66+
>
67+
<LabelMedium style={[styles.label, light && styles.inverseLabel]}>
68+
{size} / {category}
69+
</LabelMedium>
70+
{kinds.map((kind) => (
71+
<React.Fragment key={kind}>
72+
<Button
73+
onClick={action("clicked")}
74+
disabled={disabled}
75+
kind={kind}
76+
light={light}
77+
color={color}
78+
size={size}
79+
>
80+
{label}
81+
</Button>
82+
{/* startIcon */}
83+
<Button
84+
onClick={action("clicked")}
85+
disabled={disabled}
86+
kind={kind}
87+
light={light}
88+
color={color}
89+
size={size}
90+
startIcon={paperPlaneIcon}
91+
>
92+
{label}
93+
</Button>
94+
{/* endIcon */}
95+
<Button
96+
onClick={action("clicked")}
97+
disabled={disabled}
98+
kind={kind}
99+
light={light}
100+
color={color}
101+
size={size}
102+
endIcon={paperPlaneIcon}
103+
>
104+
{label}
105+
</Button>
106+
</React.Fragment>
107+
))}
108+
</View>
109+
);
110+
}
111+
112+
const KindVariants = ({light}: {light: boolean}) => {
113+
return (
114+
<>
115+
{sizes.map((size) => (
116+
<>
117+
{colors.map((color) => (
118+
<VariantsGroup
119+
size={size}
120+
color={color}
121+
light={light}
122+
/>
123+
))}
124+
<VariantsGroup size={size} disabled={true} light={light} />
125+
</>
126+
))}
127+
</>
128+
);
129+
};
130+
131+
const VariantsByTheme = ({themeName = "Default"}: {themeName?: string}) => (
132+
<View style={{marginBottom: spacing.large_24}}>
133+
<HeadingLarge>{themeName} theme</HeadingLarge>
134+
<KindVariants light={false} />
135+
<KindVariants light={true} />
136+
</View>
137+
);
138+
139+
const AllVariants = () => (
140+
<>
141+
<VariantsByTheme />
142+
<ThemeSwitcherContext.Provider value="khanmigo">
143+
<VariantsByTheme themeName="Khanmigo" />
144+
</ThemeSwitcherContext.Provider>
145+
</>
146+
);
147+
148+
export const Default: StoryComponentType = {
149+
render: AllVariants,
150+
};
151+
152+
export const Hover: StoryComponentType = {
153+
render: AllVariants,
154+
parameters: {pseudo: {hover: true}},
155+
};
156+
157+
export const Focus: StoryComponentType = {
158+
render: AllVariants,
159+
parameters: {pseudo: {focusVisible: true}},
160+
};
161+
162+
export const HoverFocus: StoryComponentType = {
163+
name: "Hover + Focus",
164+
render: AllVariants,
165+
parameters: {pseudo: {hover: true, focusVisible: true}},
166+
};
167+
168+
export const Active: StoryComponentType = {
169+
render: AllVariants,
170+
parameters: {pseudo: {active: true}},
171+
};
172+
173+
const styles = StyleSheet.create({
174+
darkDefault: {
175+
backgroundColor: color.darkBlue,
176+
},
177+
darkKhanmigo: {
178+
backgroundColor: color.eggplant,
179+
},
180+
variants: {
181+
justifyContent: "flex-start",
182+
padding: spacing.medium_16,
183+
display: "flex",
184+
flexDirection: "row",
185+
alignItems: "center",
186+
gap: spacing.xLarge_32,
187+
},
188+
label: {
189+
minWidth: 150,
190+
},
191+
inverseLabel: {
192+
color: semanticColor.text.inverse,
193+
},
194+
});

__docs__/wonder-blocks-button/button.stories.tsx

Lines changed: 24 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from "react";
22
import {StyleSheet} from "aphrodite";
33
import type {Meta, StoryObj} from "@storybook/react";
4-
import {expect, userEvent, within} from "@storybook/test";
54

65
import {MemoryRouter, Route, Switch} from "react-router-dom";
76

@@ -27,26 +26,6 @@ import ComponentInfo from "../components/component-info";
2726
import ButtonArgTypes from "./button.argtypes";
2827
import {ThemeSwitcherContext} from "@khanacademy/wonder-blocks-theming";
2928

30-
/**
31-
* Reusable button component.
32-
*
33-
* Consisting of a [`ClickableBehavior`](#clickablebehavior) surrounding a
34-
* `ButtonCore`. `ClickableBehavior` handles interactions and state changes.
35-
* `ButtonCore` is a stateless component which displays the different states the
36-
* `Button` can take.
37-
*
38-
* ### Usage
39-
*
40-
* ```tsx
41-
* import Button from "@khanacademy/wonder-blocks-button";
42-
*
43-
* <Button
44-
* onClick={(e) => console.log("Hello, world!")}
45-
* >
46-
* Hello, world!
47-
* </Button>
48-
* ```
49-
*/
5029
export default {
5130
title: "Packages / Button",
5231
component: Button,
@@ -80,61 +59,14 @@ export const Default: StoryComponentType = {
8059
},
8160
},
8261
parameters: {
83-
design: {
84-
type: "figma",
85-
url: "https://www.figma.com/file/VbVu3h2BpBhH80niq101MHHE/%F0%9F%92%A0-Main-Components?type=design&node-id=389-0&mode=design",
86-
},
8762
chromatic: {
88-
// We already have screenshots of other stories that cover more of the button states
63+
// We already have screenshots of other stories that cover more of
64+
// the button states
8965
disableSnapshot: true,
9066
},
9167
},
9268
};
9369

94-
export const Tertiary: StoryComponentType = {
95-
args: {
96-
onClick: () => {},
97-
kind: "tertiary",
98-
testId: "test-button",
99-
children: "Hello, world!",
100-
},
101-
play: async ({canvasElement}) => {
102-
const canvas = within(canvasElement);
103-
104-
// Get HTML elements
105-
const button = canvas.getByRole("button");
106-
const innerLabel = canvas.getByTestId("test-button-inner-label");
107-
const computedStyleLabel = getComputedStyle(innerLabel, ":after");
108-
109-
// Resting style
110-
await expect(button).toHaveStyle(`color: ${color.blue}`);
111-
await expect(button).toHaveTextContent("Hello, world!");
112-
113-
// Hover style
114-
await userEvent.hover(button);
115-
await expect(computedStyleLabel.height).toBe("2px");
116-
await expect(computedStyleLabel.color).toBe("rgb(24, 101, 242)");
117-
118-
// TODO(WB-1808, somewhatabstract): This isn't working. I got it passing
119-
// locally by calling `button.focus()` as well, but it was super flaky
120-
// and never passed first time.
121-
// Focus style
122-
// const computedStyleButton = getComputedStyle(button);
123-
// await fireEvent.focus(button);
124-
// await expect(computedStyleButton.outlineColor).toBe(
125-
// "rgb(24, 101, 242)",
126-
// );
127-
// await expect(computedStyleButton.outlineWidth).toBe("2px");
128-
129-
// // Active (mouse down) style
130-
// // eslint-disable-next-line testing-library/prefer-user-event
131-
// await fireEvent.mouseDown(button);
132-
// await expect(innerLabel).toHaveStyle("color: rgb(27, 80, 179)");
133-
// await expect(computedStyleLabel.color).toBe("rgb(27, 80, 179)");
134-
// await expect(computedStyleLabel.height).toBe("2px");
135-
},
136-
};
137-
13870
export const styles: StyleDeclaration = StyleSheet.create({
13971
row: {
14072
flexDirection: "row",
@@ -203,6 +135,11 @@ Variants.parameters = {
203135
story: "There are three kinds of buttons: `primary` (default), `secondary`, and `tertiary`.",
204136
},
205137
},
138+
chromatic: {
139+
// We already have screenshots of other stories that cover more of
140+
// the button states
141+
disableSnapshot: true,
142+
},
206143
};
207144

208145
export const WithColor: StoryComponentType = {
@@ -324,6 +261,11 @@ Dark.parameters = {
324261
story: "Buttons on a `darkBlue` background should set `light` to `true`.",
325262
},
326263
},
264+
chromatic: {
265+
// We already have screenshots of other stories that cover more of
266+
// the button states
267+
disableSnapshot: true,
268+
},
327269
};
328270

329271
const kinds = ["primary", "secondary", "tertiary"] as const;
@@ -539,6 +481,11 @@ Size.parameters = {
539481
story: "Buttons have a size that's either `medium` (default), `small`, or `large`.",
540482
},
541483
},
484+
chromatic: {
485+
// We already have screenshots of other stories that cover more of
486+
// the button states
487+
disableSnapshot: true,
488+
},
542489
};
543490

544491
export const Spinner: StoryComponentType = () => (
@@ -789,4 +736,11 @@ export const KhanmigoTheme: StoryComponentType = {
789736
</ThemeSwitcherContext.Provider>
790737
);
791738
},
739+
parameters: {
740+
chromatic: {
741+
// We already have screenshots of other stories that cover more of
742+
// the button states
743+
disableSnapshot: true,
744+
},
745+
},
792746
};

0 commit comments

Comments
 (0)