Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 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
13 changes: 12 additions & 1 deletion packages/perseus-editor/src/components/coordinate-pair-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,23 @@ type Props = {
coord: [number, number];
labels?: [string, string];
error?: boolean;
disabled?: boolean;
style?: StyleType;
// TODO(LEMS-3995) simplifying styling after custom label work + change deprecated WonderBlocks component / aphrodite
labelStyle?: StyleType;
onChange: (newCoord: Coord) => void;
};

const CoordinatePairInput = (props: Props) => {
const {coord, labels, error, style, labelStyle, onChange} = props;
const {
coord,
labels,
error,
disabled = false,
style,
labelStyle,
onChange,
} = props;

const xLabel = labels ? labels[0] : "x coord";
const yLabel = labels ? labels[1] : "y coord";
Expand Down Expand Up @@ -71,6 +80,7 @@ const CoordinatePairInput = (props: Props) => {
<Strut size={spacing.xxSmall_6} />
<ScrolllessNumberTextField
value={coordState[0]}
disabled={disabled}
onChange={(newValue) => handleCoordChange(newValue, 0)}
style={[
styles.textField,
Expand All @@ -91,6 +101,7 @@ const CoordinatePairInput = (props: Props) => {
<Strut size={spacing.xxSmall_6} />
<ScrolllessNumberTextField
value={coordState[1]}
disabled={disabled}
onChange={(newValue) => handleCoordChange(newValue, 1)}
style={[
styles.textField,
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
Loading
Loading