Skip to content
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
15 changes: 15 additions & 0 deletions .changeset/css-modules-action-mixin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@khanacademy/wonder-blocks-styles": minor
---

Add a CSS-mixin counterpart to the `actionStyles` JS export so CSS
Modules authors can opt into the same interactive-control styling
without Aphrodite (WB-2327, Phase 3). A new package subpath,
`@khanacademy/wonder-blocks-styles/action-styles.css`, ships the
`--wb-action-inverse` mixin (mirroring `actionStyles.inverse`), which
expands into the control's nested `:hover` / `:focus-visible` /
`:active` rules and reuses the shared `--wb-focus-visible` ring.

This is purely additive — the existing Aphrodite-shaped `actionStyles`
and `focusStyles` JS exports are unchanged and remain available for
packages that have not migrated.
32 changes: 32 additions & 0 deletions __docs__/css-modules-spike/spike.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
* - CSS Modules hashing yields stable, locally-scoped class names
* - declared inside @layer shared so consumer styles can win
* - token CSS variables resolve at runtime
*
* Phase 3 (WB-2327) additionally exercises the multi-state
* `--wb-action-inverse` mixin, which expands into nested `:hover` /
* `:focus-visible` / `:active` rules and reuses `--wb-focus-visible`.
*/
@import "@khanacademy/wonder-blocks-styles/focus-styles.css";
@import "@khanacademy/wonder-blocks-styles/action-styles.css";

.root {
display: inline-flex;
Expand All @@ -32,4 +37,31 @@
background: var(--wb-semanticColor-core-background-instructive-subtle);
color: var(--wb-semanticColor-core-foreground-instructive-strong);
font-size: var(--wb-sizing-size_120);
}

/* Dark backdrop so the inverse (knockout) control reads correctly. */
.inverseBackdrop {
display: inline-flex;
padding: var(--wb-sizing-size_160);
background: var(--wb-semanticColor-core-background-neutral-strong);
}

/*
* Inverse control — same layout as `.root`, but the interactive styling
* comes entirely from the `--wb-action-inverse` mixin. Applying the mixin
* here injects its nested `:hover` / `:focus-visible` / `:active` rules.
*/
.inverse {
display: inline-flex;
align-items: center;
gap: var(--wb-sizing-size_080);
padding: var(--wb-sizing-size_080) var(--wb-sizing-size_120);
border: var(--wb-border-width-thin) solid transparent;
border-radius: var(--wb-border-radius-radius_040);
background: transparent;
font-family: inherit;
font-size: var(--wb-sizing-size_160);
cursor: pointer;

@apply --wb-action-inverse;
}
14 changes: 14 additions & 0 deletions __docs__/css-modules-spike/spike.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,17 @@ export const WithBadge: Story = {
badge: "NEW",
},
};

/**
* Proves the cross-package `--wb-action-inverse` mixin (WB-2327, Phase 3)
* expands end-to-end: the control's interactive styling — including its
* nested `:hover` / `:focus-visible` / `:active` rules and the reused
* `--wb-focus-visible` ring — comes entirely from the mixin. Tab to the
* button to see the focus ring on the dark backdrop.
*/
export const Inverse: Story = {
args: {
label: "Inverse spike button",
inverse: true,
},
};
9 changes: 9 additions & 0 deletions __docs__/css-modules-spike/spike.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,13 @@ describe("CSS Modules Jest wiring", () => {
expect(screen.getByRole("button")).toHaveClass("root");
expect(screen.getByText("NEW")).toHaveClass("pill");
});

it("renders the inverse variant using the action mixin class", () => {
// The visual expansion of `--wb-action-inverse` is verified by the
// real PostCSS pipeline in Storybook; here we only assert the wiring
// that selects the inverse class (identity-obj-proxy stubs CSS).
render(<Spike label="Inverse spike button" inverse={true} />);

expect(screen.getByRole("button")).toHaveClass("inverse");
});
});
21 changes: 20 additions & 1 deletion __docs__/css-modules-spike/spike.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import styles from "./spike.module.css";
type Props = {
label: string;
badge?: string;
/**
* Render the control with the cross-package `--wb-action-inverse` mixin
* applied, sitting on a dark backdrop. Used to prove the multi-state
* action mixin expands (WB-2327, Phase 3).
*/
inverse?: boolean;
};

/**
Expand All @@ -18,7 +24,20 @@ type Props = {
*
* Delete once Phase 1 migrates the first real component to CSS Modules.
*/
export function Spike({label, badge}: Props): React.ReactElement {
export function Spike({label, badge, inverse}: Props): React.ReactElement {
if (inverse) {
return (
<div className={styles.inverseBackdrop}>
<button type="button" className={styles.inverse}>
<span>{label}</span>
{badge ? (
<span className={styles.pill}>{badge}</span>
) : null}
</button>
</div>
);
}

return (
<button type="button" className={styles.root}>
<span>{label}</span>
Expand Down
85 changes: 85 additions & 0 deletions packages/wonder-blocks-styles/src/styles/action-styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

⚠️ 1 of 1: New @mixin --wb-action-inverse() in the published wonder-blocks-styles shared library, mirroring actionStyles.inverse. It expands into nested :hover / :focus-visible / :active rules and reuses --wb-focus-visible. Source-distributed (raw, like focus-styles.css) so the @mixin survives un-expanded until the consumer build runs its own postcss-import → postcss-mixins chain. Additive only — the existing Aphrodite-shaped JS exports are untouched, so no consumer is affected.

* Cross-package action-style mixins.
*
* Mirrors the Aphrodite-shaped `actionStyles` JS exports so CSS Modules
* authors can opt into the same interactive-control styling without
* pulling in Aphrodite.
*
* Authors include a mixin via:
*
* @import "@khanacademy/wonder-blocks-styles/action-styles.css";
*
* .my-control {
* @apply --wb-action-inverse;
* }
*
* postcss-import resolves the @import through the workspace and
* @csstools/postcss-mixins expands the @apply at build time
* (CSS Mixins Level 1 syntax — note the leading `--` on the name).
*/

/*
* Pull in `--wb-focus-visible` so `--wb-action-inverse` can reuse the shared
* focus ring (parity with the JS `inverse` style, which spreads `focus`).
* postcss-import dedupes by resolved path, so consumers that also import
* focus-styles.css directly won't get a duplicate definition.
*/
@import "./focus-styles.css";

/*
* The inverse styles for an interactive control, for special cases where the
* element sits on a dark background.
*
* NOTE: This mirrors `actionStyles.inverse`, which is slated to be deprecated
* in the future.
*
* The `&` selectors below have no scoping root at the mixin *definition*
* site, but they resolve to the consumer's selector once the mixin is
* `@apply`-ed inside a rule. Stylelint can't see that context, so the
* `nesting-selector-no-missing-scoping-root` rule is disabled for the block.
*/
/* stylelint-disable nesting-selector-no-missing-scoping-root */
@mixin --wb-action-inverse() {
/*
* Overriding border-color only to preserve the visual integrity of the
* control, as there might be some cases where the interactive element
* already includes a border.
*/
&:not([aria-disabled="true"]) {
border-color: var(--wb-semanticColor-core-border-knockout-default);
color: var(--wb-semanticColor-core-foreground-knockout-default);
}

&:hover:not([aria-disabled="true"]) {
color: var(--wb-semanticColor-core-foreground-knockout-default);

/*
* Overriding border-color only to preserve the visual integrity of
* the control, as there might be some cases where the interactive
* element already includes a border.
*/
border-color: var(--wb-semanticColor-core-border-knockout-default);
}

/* Use the shared focus styles to keep the focus state consistent. */
&:focus-visible {
@apply --wb-focus-visible;
}

&:active:not([aria-disabled="true"]) {
border-radius: var(--wb-border-radius-radius_080);

/* A slightly darker color than the inverse border color. */
border-color: color-mix(
in srgb,
var(--wb-semanticColor-core-border-neutral-default) 55%,
var(--wb-semanticColor-core-border-knockout-default)
);
background: color-mix(
in srgb,
var(--wb-semanticColor-core-background-base-default) 5%,
transparent
);
}
}
/* stylelint-enable nesting-selector-no-missing-scoping-root */
Loading