Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions .changeset/shiny-dodos-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/perseus-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"devDependencies": {
"@khanacademy/mathjax-renderer": "catalog:devDeps",
"@khanacademy/wonder-blocks-accordion": "catalog:devDeps",
"@khanacademy/wonder-blocks-badge": "catalog:devDeps",
"@khanacademy/wonder-blocks-banner": "catalog:devDeps",
"@khanacademy/wonder-blocks-button": "catalog:devDeps",
"@khanacademy/wonder-blocks-clickable": "catalog:devDeps",
Expand Down Expand Up @@ -83,6 +84,7 @@
"peerDependencies": {
"@khanacademy/mathjax-renderer": "catalog:peerDeps",
"@khanacademy/wonder-blocks-accordion": "catalog:peerDeps",
"@khanacademy/wonder-blocks-badge": "catalog:peerDeps",
"@khanacademy/wonder-blocks-banner": "catalog:peerDeps",
"@khanacademy/wonder-blocks-button": "catalog:peerDeps",
"@khanacademy/wonder-blocks-clickable": "catalog:peerDeps",
Expand Down
223 changes: 223 additions & 0 deletions packages/perseus-editor/src/components/segmented-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import Clickable from "@khanacademy/wonder-blocks-clickable";
import {View} from "@khanacademy/wonder-blocks-core";
import {
border,
font,
semanticColor,
sizing,
} from "@khanacademy/wonder-blocks-tokens";
import {StyleSheet} from "aphrodite";
import * as React from "react";

import type {StyleType} from "@khanacademy/wonder-blocks-core";

/**
* A single selectable segment. Built on Wonder Blocks `Clickable` (the same
* primitive `Pill` uses) so it gets focus handling and a real `disabled` /
* `aria-disabled` state for free, while letting us render arbitrary children
* (math, icons) and set a `radio`/`checkbox` role.
*
* Disabled styling intentionally uses the WB disabled tokens so it reads as
* disabled consistently with the rest of the design system (no opacity hack).
*/
type ToggleButtonProps = {
selected: boolean;
onClick: () => void;
disabled?: boolean;
role?: "radio" | "checkbox";
"aria-label"?: string;
children: React.ReactNode;
style?: StyleType;
};

export function ToggleButton({
selected,
onClick,
disabled = false,
role = "radio",
children,
style,
"aria-label": ariaLabel,
}: ToggleButtonProps): React.ReactElement {
return (
<Clickable
role={role}
aria-checked={selected}
aria-label={ariaLabel}
disabled={disabled}
onClick={onClick}
style={[styles.reset, style]}
>
{({hovered, pressed}) => (
<View
style={[
styles.segment,
selected && styles.segmentSelected,
!disabled &&
!selected &&
(hovered || pressed) &&
styles.segmentHovered,
disabled && styles.segmentDisabled,
disabled && selected && styles.segmentDisabledSelected,
]}
>
{children}
</View>
)}
</Clickable>
);
}

/**
* A row of mutually-exclusive (single-select) segments — a segmented control.
* Replaces the deprecated `Pill`-as-button pattern. Pass `disabled` to disable
* the whole group (e.g. `editingDisabled`).
*/
type Option = {
value: string;
label: React.ReactNode;
ariaLabel?: string;
};

type SegmentedControlProps = {
options: ReadonlyArray<Option>;
selectedValue: string | null | undefined;
onChange: (value: string) => void;
disabled?: boolean;
"aria-label"?: string;
};

export function SegmentedControl({
options,
selectedValue,
onChange,
disabled = false,
"aria-label": ariaLabel,
}: SegmentedControlProps): React.ReactElement {
return (
<View role="radiogroup" aria-label={ariaLabel} style={styles.group}>
{options.map((option) => (
<ToggleButton
key={option.value}
role="radio"
selected={option.value === selectedValue}
aria-label={option.ariaLabel}
disabled={disabled}
onClick={() => onChange(option.value)}
>
{option.label}
</ToggleButton>
))}
</View>
);
}

/**
* A group of independent (multi-select) toggles — i.e. a set of checkboxes
* styled as buttons. Unlike `SegmentedControl`, more than one can be selected,
* and the group wraps onto multiple rows as an aligned grid (consistent row and
* column gaps) when it doesn't fit on one line. `onToggle` is called with the
* value that was clicked; the caller flips it in/out of `selectedValues`.
*/
type ToggleButtonGroupProps = {
options: ReadonlyArray<Option>;
selectedValues: ReadonlyArray<string>;
onToggle: (value: string) => void;
disabled?: boolean;
"aria-label"?: string;
};

export function ToggleButtonGroup({
options,
selectedValues,
onToggle,
disabled = false,
"aria-label": ariaLabel,
}: ToggleButtonGroupProps): React.ReactElement {
return (
<View role="group" aria-label={ariaLabel} style={styles.wrapGroup}>
{options.map((option) => (
<ToggleButton
key={option.value}
role="checkbox"
selected={selectedValues.includes(option.value)}
aria-label={option.ariaLabel}
disabled={disabled}
onClick={() => onToggle(option.value)}
>
{option.label}
</ToggleButton>
))}
</View>
);
}

const styles = StyleSheet.create({
// inline-flex so the (single-select) group flows inline with adjacent
// labels/badges (e.g. the "Status" label) instead of wrapping onto its own
// line. It stays cohesive — its segments never split across lines; the whole
// group wraps below the label as a unit if there isn't room.
group: {
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
verticalAlign: "middle",
gap: sizing.size_080,
},
// Multi-select group: wraps onto multiple rows as an aligned grid with
// consistent row + column gaps. Stretch to the full available width so it
// only wraps when the segments genuinely don't fit on one line (not because
// a flex parent shrank it to content width).
wrapGroup: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
alignSelf: "stretch",
gap: sizing.size_080,
},
// Reset the Clickable's native <button> chrome; the visual lives on the
// inner View so it can react to hovered/pressed state. The keyboard focus
// ring is a rounded box-shadow (follows the border radius, unlike `outline`)
// shown only on `:focus-visible` so it doesn't appear after a mouse click.
reset: {
borderRadius: border.radius.radius_040,
":focus-visible": {
outline: "none",
boxShadow: `0 0 0 ${border.width.medium} ${semanticColor.focus.outer}`,
},
},
segment: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingBlock: sizing.size_040,
paddingInline: sizing.size_120,
borderRadius: border.radius.radius_040,
border: `${border.width.thin} solid ${semanticColor.core.border.neutral.subtle}`,
backgroundColor: semanticColor.core.background.base.default,
color: semanticColor.core.foreground.neutral.strong,
fontSize: font.body.size.small,
lineHeight: font.body.lineHeight.small,
whiteSpace: "nowrap",
},
segmentSelected: {
backgroundColor: semanticColor.core.background.instructive.default,
borderColor: semanticColor.core.background.instructive.default,
color: semanticColor.core.foreground.knockout.default,
},
segmentHovered: {
borderColor: semanticColor.core.border.neutral.default,
backgroundColor: semanticColor.core.background.neutral.subtle,
},
segmentDisabled: {
backgroundColor: semanticColor.core.background.disabled.subtle,
borderColor: semanticColor.core.border.disabled.default,
color: semanticColor.core.foreground.disabled.default,
cursor: "not-allowed",
},
segmentDisabledSelected: {
backgroundColor: semanticColor.core.background.disabled.strong,
borderColor: semanticColor.core.background.disabled.strong,
color: semanticColor.core.foreground.disabled.strong,
},
});
26 changes: 17 additions & 9 deletions packages/perseus-editor/src/styles/perseus-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -1062,25 +1062,33 @@ breaks the layout depending on the widget editors used.*/
* Applied when editingDisabled=true to prevent content creators from making
* changes while still allowing them to view the editor interface.
*
* Targets all form controls and interactive elements within disabled editors.
* The editor content is wrapped in a <fieldset disabled>, and the interactive
* controls are Wonder Blocks components that render their own accessible
* disabled state. So we deliberately DO NOT dim everything with `opacity`
* anymore — opacity is inherited by the content (text and math) and dropped it
* below a readable contrast (see LEMS-4131). We only add a muted background and
* a not-allowed cursor to the non-WB native and MathQuill inputs, using the WB
* disabled tokens so they read as disabled consistently with the design system.
*/
.perseus-single-editor.perseus-editor-disabled input,
.perseus-single-editor.perseus-editor-disabled textarea,
.perseus-single-editor.perseus-editor-disabled select,
.perseus-single-editor.perseus-editor-disabled checkbox,
.perseus-single-editor.perseus-editor-disabled .perseus-math-input,
.perseus-single-editor.perseus-editor-disabled .keypad-input {
opacity: 0.6 !important;
background-color: var(
--wb-semanticColor-core-background-neutral-subtle
--wb-semanticColor-input-disabled-background
) !important;
cursor: not-allowed !important;
border-color: var(--wb-semanticColor-core-border-neutral-subtle) !important;
border-color: var(--wb-semanticColor-input-disabled-border) !important;
}

/* We want to keep the button colors as it often helps denote correctness. */
.perseus-single-editor.perseus-editor-disabled button {
opacity: 0.6 !important;
/* The markdown field renders widget-reference highlights in a transparent
underlay that sits behind the textarea, so tint the container — not the
textarea — to keep the highlights visible while still reading as disabled. */
.perseus-single-editor.perseus-editor-disabled .perseus-textarea-pair {
background-color: var(
--wb-semanticColor-input-disabled-background
) !important;
border-color: var(--wb-semanticColor-input-disabled-border) !important;
cursor: not-allowed !important;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("numeric-input-editor", () => {
render(<NumericInputEditor onChange={onChangeMock} />);

await userEvent.click(
within(screen.getByRole("group", {name: /^Width/})).getByRole(
within(screen.getByRole("radiogroup", {name: /^Width/})).getByRole(
"radio",
{name: "Normal (80px)"},
),
Expand All @@ -55,7 +55,7 @@ describe("numeric-input-editor", () => {
render(<NumericInputEditor onChange={onChangeMock} />);

await userEvent.click(
within(screen.getByRole("group", {name: /^Width/})).getByRole(
within(screen.getByRole("radiogroup", {name: /^Width/})).getByRole(
"radio",
{name: "Small (40px)"},
),
Expand All @@ -73,10 +73,9 @@ describe("numeric-input-editor", () => {
render(<NumericInputEditor onChange={onChangeMock} />);

await userEvent.click(
within(screen.getByRole("group", {name: /^Alignment/})).getByRole(
"radio",
{name: "Right"},
),
within(
screen.getByRole("radiogroup", {name: /^Alignment/}),
).getByRole("radio", {name: "Right"}),
);

expect(onChangeMock).toHaveBeenCalledWith({rightAlign: true});
Expand All @@ -89,7 +88,7 @@ describe("numeric-input-editor", () => {

await userEvent.click(
within(
screen.getByRole("group", {name: /^Number style/}),
screen.getByRole("radiogroup", {name: /^Number style/}),
).getByRole("radio", {name: "Coefficient"}),
);

Expand All @@ -103,7 +102,7 @@ describe("numeric-input-editor", () => {

await userEvent.click(
within(
screen.getByRole("group", {name: /^Answer formats are/}),
screen.getByRole("radiogroup", {name: /^Answer formats are/}),
).getByRole("radio", {name: "Required"}),
);

Expand Down Expand Up @@ -156,7 +155,7 @@ describe("numeric-input-editor", () => {
render(<StatefulNumericInputEditor />);

const input = screen.getByRole("textbox", {
name: "User input:",
name: "User input",
});

await userEvent.type(input, "6/8");
Expand All @@ -171,7 +170,9 @@ describe("numeric-input-editor", () => {

await userEvent.click(
within(
screen.getByRole("group", {name: /^Unsimplified answers are/}),
screen.getByRole("radiogroup", {
name: /^Unsimplified answers are/,
}),
).getByRole("radio", {name: "Ungraded"}),
);

Expand All @@ -191,7 +192,9 @@ describe("numeric-input-editor", () => {

await userEvent.click(
within(
screen.getByRole("group", {name: /^Unsimplified answers are/}),
screen.getByRole("radiogroup", {
name: /^Unsimplified answers are/,
}),
).getByRole("radio", {name: "Accepted"}),
);

Expand All @@ -211,7 +214,9 @@ describe("numeric-input-editor", () => {

await userEvent.click(
within(
screen.getByRole("group", {name: /^Unsimplified answers are/}),
screen.getByRole("radiogroup", {
name: /^Unsimplified answers are/,
}),
).getByRole("radio", {name: "Wrong"}),
);

Expand Down
Loading
Loading