Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4e96dcc
Add setting to enable non-dragging movements
sjd210 May 12, 2026
d523a32
Add hook to enable non-dragging drop zones
sjd210 May 12, 2026
e3455d2
Add toggle to switch drag-and-drop mode per page
sjd210 May 12, 2026
a652e2a
Clean up toggling so it respects dropzone portals
sjd210 May 12, 2026
3b77a84
Rename setting to NON_DRAGGING_INPUTS
sjd210 May 13, 2026
a90da56
Move dnd input toggle to question metadata
sjd210 May 13, 2026
547e8e6
Add basic accessibility type redux structures
sjd210 May 13, 2026
602c7f7
Update test and add test with hydrate
sjd210 May 14, 2026
3a16039
Revert "Update test and add test with hydrate"
sjd210 May 14, 2026
03de124
Add rest of accessibility_type reducer settings
sjd210 May 15, 2026
24f8065
Clean up components and imports between files
sjd210 May 15, 2026
59a3be7
Add white background to non-empty cloze-dropdowns
sjd210 May 15, 2026
e0e0026
Add checkbox instead of toggle for Ada
sjd210 May 15, 2026
ad2a286
Also apply white background to Ada dropzones
sjd210 May 18, 2026
40ee28a
Add default to reducer case to fix tests
sjd210 May 18, 2026
8fe0158
Move checkbox on Ada question pages
sjd210 May 18, 2026
f32e355
Remove second input toggle from Isaac
sjd210 May 18, 2026
c189897
Update VRT baselines
actions-user May 18, 2026
d89b2ba
Merge pull request #2160 from isaacphysics/vrt/feature/non-dragging-i…
sjd210 May 18, 2026
7bfa1c4
Update VRT baselines
actions-user May 19, 2026
959615a
Merge pull request #2162 from isaacphysics/vrt/feature/non-dragging-i…
sjd210 May 19, 2026
95f5618
Re-allow questions switching input mode for width
sjd210 May 20, 2026
b330b29
Replace repeated deviceSize logic with hook
sjd210 May 21, 2026
d2b59aa
Prioritise mode by manual > settings > deviceSize
sjd210 May 21, 2026
7f3cf51
Remove several unused imports
sjd210 May 21, 2026
4adb824
Move hook inside toggle for page metadata
sjd210 May 21, 2026
d86adf3
Update VRT baselines
actions-user May 21, 2026
0ca28c5
Merge pull request #2164 from isaacphysics/vrt/feature/non-dragging-i…
sjd210 May 21, 2026
643d57f
Add input toggle to top of quiz content page
sjd210 May 22, 2026
163baa9
Combine repeated preamble code into component
sjd210 May 22, 2026
aec330f
Remove unused imports
sjd210 May 22, 2026
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
8 changes: 7 additions & 1 deletion src/IsaacAppTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export type Action =
| {type: ACTION_TYPE.USER_PREFERENCES_REQUEST}
| {type: ACTION_TYPE.USER_PREFERENCES_RESPONSE_SUCCESS; userPreferences: UserPreferencesDTO}
| {type: ACTION_TYPE.USER_PREFERENCES_RESPONSE_FAILURE; errorMessage: string}

| {type: ACTION_TYPE.ACCESSIBILITY_TYPE_SET; accessibilityType: AccessibilitySettings}
| {type: ACTION_TYPE.USER_LOG_IN_REQUEST; provider: ApiTypes.AuthenticationProvider}
| {type: ACTION_TYPE.USER_LOG_IN_RESPONSE_SUCCESS; authResponse: ApiTypes.AuthenticationResponseDTO}
| {type: ACTION_TYPE.USER_LOG_IN_RESPONSE_FAILURE; errorMessage: string}
Expand Down Expand Up @@ -232,6 +232,11 @@ export interface AccessibilitySettings {
PREFER_MATHML?: boolean;
REDUCED_MOTION?: boolean;
SHOW_INACCESSIBLE_WARNING?: boolean;
NON_DRAGGING_INPUTS?: boolean;
}

export interface AccessibilitySettingsWithOverride extends AccessibilitySettings {
MANUAL_OVERRIDE?: boolean;
}

export interface UserConsent {
Expand Down Expand Up @@ -462,6 +467,7 @@ export const DragAndDropRegionContext = React.createContext<(
nonSelectedItems: Immutable<ReplaceableItem>[],
allItems: Immutable<ReplaceableItem>[],
zoneIds: Set<string>,
dragAndDropEnabled: boolean;
} | undefined>(undefined);

export const InlineContext = React.createContext<{
Expand Down
10 changes: 5 additions & 5 deletions src/app/components/content/IsaacClozeQuestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ import {
CLOZE_ITEM_SECTION_ID,
NULL_CLOZE_ITEM,
NULL_CLOZE_ITEM_ID,
below,
isDefined,
isTouchDevice,
useCurrentQuestionAttempt,
useDeviceSize
} from "../../services";
import {customKeyboardCoordinates} from "../../services/clozeQuestionKeyboardCoordinateGetter";
import {IsaacContentValueOrChildren} from "./IsaacContentValueOrChildren";
Expand All @@ -42,6 +39,7 @@ import {DragAndDropRegionContext, IsaacQuestionProps, ReplaceableItem} from "../
import {v4 as uuid_v4} from "uuid";
import {Immutable} from "immer";
import {arraySwap, SortableContext} from "@dnd-kit/sortable";
import { useDragAndDropAccessibility } from "./IsaacDragAndDropQuestion";

const DropZoneItem = lazy(() => import("../elements/DnDItem"));

Expand Down Expand Up @@ -134,10 +132,11 @@ const useAutoScroll = ({active, acceleration, interval}: {active: boolean; accel
};

const IsaacClozeQuestion = ({doc, questionId, readonly, validationResponse}: IsaacQuestionProps<IsaacClozeQuestionDTO, ItemValidationResponseDTO>) => {
const deviceSize = useDeviceSize();
const { currentAttempt: rawCurrentAttempt, dispatchSetCurrentAttempt } = useCurrentQuestionAttempt<ItemChoiceDTO>(questionId);
const currentAttempt = useMemo(() => rawCurrentAttempt ? {...rawCurrentAttempt, items: replaceNullItems(rawCurrentAttempt.items)} : undefined, [rawCurrentAttempt]);

const { dragAndDropEnabled } = useDragAndDropAccessibility();

const cssFriendlyQuestionPartId = questionId?.replace(/\|/g, '-') ?? ""; // Maybe we should clean up IDs more?
const withReplacement = doc.withReplacement ?? false;

Expand Down Expand Up @@ -488,6 +487,7 @@ const IsaacClozeQuestion = ({doc, questionId, readonly, validationResponse}: Isa
nonSelectedItems,
allItems,
zoneIds: new Set<string>(),
dragAndDropEnabled
}}>
<DndContext
sensors={sensors}
Expand All @@ -502,7 +502,7 @@ const IsaacClozeQuestion = ({doc, questionId, readonly, validationResponse}: Isa
{doc.children}
</IsaacContentValueOrChildren>

{(!(deviceSize === "xs" || (isTouchDevice() && below['md'](deviceSize)))) && <>
{dragAndDropEnabled && <>
{/* The item attached to the users cursor while dragging (just for display, shouldn't contain useDraggable/useSortable hooks) */}
<DragOverlay>
{activeItem && <Badge className="p-1 cloze-item cloze-bg is-dragging" color="theme">
Expand Down
25 changes: 23 additions & 2 deletions src/app/components/content/IsaacDragAndDropQuestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DndItemDTO
} from "../../../IsaacApiTypes";
import {
ACTION_TYPE,
CLOZE_DROP_ZONE_ID_PREFIX,
CLOZE_ITEM_SECTION_ID,
NULL_CLOZE_ITEM_ID,
Expand Down Expand Up @@ -42,6 +43,7 @@ import {DragAndDropRegionContext, IsaacQuestionProps, ReplaceableItem} from "../
import {v4 as uuid_v4} from "uuid";
import {Immutable} from "immer";
import {arraySwap, SortableContext} from "@dnd-kit/sortable";
import { selectors, useAppDispatch, useAppSelector } from "../../state";

const DropZoneItem = lazy(() => import("../elements/DnDItem"));

Expand Down Expand Up @@ -134,8 +136,24 @@ const useAutoScroll = ({active, acceleration, interval}: {active: boolean; accel
}, [active]);
};

const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse}: IsaacQuestionProps<IsaacDragAndDropQuestionDTO, DndValidationResponseDTO>) => {
export function useDragAndDropAccessibility() {
const dispatch = useAppDispatch();
const deviceSize = useDeviceSize();
const accessibilityType = useAppSelector(selectors.accessibility.type);

// Drag and drop is disabled if the user has selected a manual accessibility override, or if they have selected non-dragging inputs as an accessibility preference,
// or if they are on a touch device or very small screen and haven't explicitly enabled drag and drop.
const dragAndDropEnabled = (isDefined(accessibilityType) && (accessibilityType.MANUAL_OVERRIDE || accessibilityType?.NON_DRAGGING_INPUTS))
? !accessibilityType?.NON_DRAGGING_INPUTS
: !(deviceSize === "xs" || (isTouchDevice() && below['md'](deviceSize)));
const toggleDragAndDropEnabled = () => {
dispatch({type: ACTION_TYPE.ACCESSIBILITY_TYPE_SET, accessibilityType: {"NON_DRAGGING_INPUTS": dragAndDropEnabled}});
};

return { dragAndDropEnabled, toggleDragAndDropEnabled };
}

const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse}: IsaacQuestionProps<IsaacDragAndDropQuestionDTO, DndValidationResponseDTO>) => {
const { currentAttempt: rawCurrentAttempt, dispatchSetCurrentAttempt } = useCurrentQuestionAttempt<DndChoiceDTO>(questionId);
const currentAttempt = useMemo(() => rawCurrentAttempt ? {...rawCurrentAttempt, items: replaceNullItems(rawCurrentAttempt.items)} : undefined, [rawCurrentAttempt]);

Expand All @@ -155,6 +173,8 @@ const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse
.map(({replacementId: _, ...item}) => item);
};

const { dragAndDropEnabled } = useDragAndDropAccessibility();

const cssFriendlyQuestionPartId = questionId?.replace(/\|/g, '-') ?? ""; // Maybe we should clean up IDs more?
const withReplacement = doc.withReplacement ?? false;

Expand Down Expand Up @@ -511,6 +531,7 @@ const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse
nonSelectedItems,
allItems,
zoneIds: new Set<string>(),
dragAndDropEnabled
}}>
<DndContext
sensors={sensors}
Expand All @@ -525,7 +546,7 @@ const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse
{doc.children}
</IsaacContentValueOrChildren>

{(!(deviceSize === "xs" || (isTouchDevice() && below['md'](deviceSize)))) && <>
{dragAndDropEnabled && <>
{/* The item attached to the users cursor while dragging (just for display, shouldn't contain useDraggable/useSortable hooks) */}
<DragOverlay>
{activeItem && <Badge className="p-1 cloze-item cloze-bg is-dragging" color="theme">
Expand Down
39 changes: 37 additions & 2 deletions src/app/components/elements/PageMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { TeacherNotes } from './TeacherNotes';
import { useLocation } from 'react-router';
import { SidebarButton } from './SidebarButton';
import { HelpButton } from './HelpButton';
import { above, below, isAda, isPhy, useDeviceSize } from '../../services';
import { above, below, isAda, isPhy, siteSpecific, useDeviceSize } from '../../services';
import type { Location } from 'history';
import classNames from 'classnames';
import { UserContextPicker } from './inputs/UserContextPicker';
Expand All @@ -18,6 +18,10 @@ import { CrossTopicQuestionIndicator } from './CrossTopicQuestionIndicator';
import { selectors, useAppSelector } from '../../state';
import { BookmarkButton } from './BookmarkButton';
import { FeatureFlag, FeatureFlagWrapper } from '../../services/featureFlag';
import { Spacer } from './Spacer';
import StyledToggle from "../elements/inputs/StyledToggle";
import { StyledCheckbox } from './inputs/StyledCheckbox';
import { useDragAndDropAccessibility } from '../content/IsaacDragAndDropQuestion';

type PageMetadataProps = {
doc?: SeguePageDTO;
Expand All @@ -40,6 +44,25 @@ type PageMetadataProps = {
}
);

export const DragAndDropInputModeToggle = ({className}: {className?: string}) => {
const { dragAndDropEnabled, toggleDragAndDropEnabled } = useDragAndDropAccessibility();

return siteSpecific(<div className={classNames("d-flex flex-column align-items-center w-min-content", className)}>
<span>Question input mode</span>
<Spacer />
<StyledToggle
checked={dragAndDropEnabled}
falseLabel="Dropdown"
trueLabel="Drag and drop"
onChange={toggleDragAndDropEnabled}
/>
</div>,
<div className={className}>
<StyledCheckbox checked={!dragAndDropEnabled} onChange={toggleDragAndDropEnabled} label={<span className="text-muted">Use dropdowns for drag and drop questions</span>} />
</div>
);
};

interface ActionButtonsProps extends React.HTMLAttributes<HTMLDivElement> {
location: Location;
isQuestion: boolean;
Expand Down Expand Up @@ -73,13 +96,18 @@ interface TagStackProps extends React.HTMLAttributes<HTMLDivElement> {
const TagStack = ({doc, className}: TagStackProps) => {
const isCrossTopic = doc?.tags?.includes("cross_topic");
const pageContainsLLMFreeTextQuestion = useAppSelector(selectors.questions.includesLLMFreeTextQuestion);
const displayDragAndDropToggle = useAppSelector(selectors.questions.includesClozeOrDragAndDropQuestion) && isAda;

return <div className={className}>
{(isCrossTopic || pageContainsLLMFreeTextQuestion) && <div className="d-lg-flex align-items-center gap-3 me-3">
{isAda && isCrossTopic && <CrossTopicQuestionIndicator/>}
{pageContainsLLMFreeTextQuestion && <LLMFreeTextQuestionIndicator/>}
{displayDragAndDropToggle && <DragAndDropInputModeToggle className="mt-1 ms-1"/>}
</div>}
<EditContentButton doc={doc}/>
<div>
<EditContentButton doc={doc}/>
{displayDragAndDropToggle && !(isCrossTopic || pageContainsLLMFreeTextQuestion) && <DragAndDropInputModeToggle className="mt-1 ms-1"/>}
</div>
</div>;
};

Expand Down Expand Up @@ -116,6 +144,7 @@ export const PageMetadata = (props: PageMetadataProps) => {
const location = useLocation();
const deviceSize = useDeviceSize();
const actionButtonsFloat = noTitle && children;
const pageContainsClozeOrDragAndDropQuestion = useAppSelector(selectors.questions.includesClozeOrDragAndDropQuestion);

return <>
{isPhy && showSidebarButton && sidebarInTitle && below['md'](deviceSize) && <SidebarButton buttonTitle={sidebarButtonText} absolute/>}
Expand All @@ -140,10 +169,16 @@ export const PageMetadata = (props: PageMetadataProps) => {
<div className="d-flex align-items-end">
{isPhy && <TagStack doc={doc} className="d-flex align-items-end gap-3"/>}
{isConcept && <UserContextPicker className={classNames("flex-grow-1", {"mt-3": isAda})}/>}
{isPhy && pageContainsClozeOrDragAndDropQuestion && <>
<Spacer />
<DragAndDropInputModeToggle className="mb-1"/>
</>
}
</div>

{isPhy && <TeacherNotes notes={doc?.teacherNotes} />}
</div>
{isPhy && showSidebarButton && !sidebarInTitle && below['md'](deviceSize) && <SidebarButton className="my-2" buttonTitle={sidebarButtonText}/>}
</>;
};

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, {useContext, useEffect, useRef, useState} from "react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownToggle} from "reactstrap";
import {useDroppable} from "@dnd-kit/core";
import classNames from "classnames";
import {CLOZE_DROP_ZONE_ID_PREFIX, NULL_CLOZE_ITEM, below, isAda, isDefined, isPhy, isTouchDevice, useDeviceSize} from "../../../../services";
import {CLOZE_DROP_ZONE_ID_PREFIX, NULL_CLOZE_ITEM, isAda, isDefined, isPhy} from "../../../../services";
import { Markup } from "..";
import DropZoneItem from "../../DnDItem";

Expand All @@ -20,7 +20,6 @@ interface InlineDropRegionProps {
// Inline droppables rendered for each registered drop region
function InlineDropRegion({divId, zoneId, emptyWidth, emptyHeight, rootElement, skipPortalling}: InlineDropRegionProps) {
const dropRegionContext = useContext(DragAndDropRegionContext);
const deviceSize = useDeviceSize();
const [isOpen, setIsOpen] = useState<boolean>(false);
const droppableId = CLOZE_DROP_ZONE_ID_PREFIX + zoneId;
const dropdownItems = dropRegionContext?.allItems ?? [];
Expand Down Expand Up @@ -114,8 +113,7 @@ function InlineDropRegion({divId, zoneId, emptyWidth, emptyHeight, rootElement,
</Dropdown>;

if (dropRegionContext && droppableTarget) {
const result = (deviceSize === "xs" || (isTouchDevice() && below['md'](deviceSize)))
? dropdownZone : draggableDropZone;
const result = dropRegionContext?.dragAndDropEnabled ? draggableDropZone : dropdownZone;
return skipPortalling ? result : ReactDOM.createPortal(result, droppableTarget);
}
return null;
Expand Down
17 changes: 15 additions & 2 deletions src/app/components/elements/panels/UserAccessibilitySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ export const UserAccessibilitySettings = ({ accessibilitySettings, setAccessibil
/></b>
<p>{`Enabling this will reduce motion effects on the platform. Browser preference will take priority over this setting.`}</p>
</WithLinkableSetting>
<div className="pt-2"/>
<WithLinkableSetting id={"prefer-mathml-feature"}>
<WithLinkableSetting id={"show-inaccessible-warning-feature"}>
<b><StyledCheckbox
checked={accessibilitySettings.SHOW_INACCESSIBLE_WARNING ?? isTeacherOrAbove(user)}
onChange={e => {
Expand All @@ -49,6 +48,20 @@ export const UserAccessibilitySettings = ({ accessibilitySettings, setAccessibil
/></b>
<p id="show-inaccessible-helptext">{`Enabling this will display warnings on certain content that may be inaccessible to assistive technologies.`}</p>
</WithLinkableSetting>
<WithLinkableSetting id={"non-dragging-movement-feature"}>
<b><StyledCheckbox checked={accessibilitySettings.NON_DRAGGING_INPUTS ?? false}
onChange={e => {
setAccessibilitySettings((oldDs) => ({...oldDs, NON_DRAGGING_INPUTS: e.target.checked}));
}}
color={siteSpecific("primary", "")}
label={<p>Enable non-dragging alternative inputs</p>}
id={"non-dragging-movement"}
aria-describedby="non-dragging-movement-helptext"
removeVerticalOffset
/></b>
<p id="non-dragging-helptext">{`Enabling this will allow you to use alternative input methods that don't require dragging for certain question types (e.g. drag-and-drop).`}</p>
</WithLinkableSetting>
{/* Seperate maths-specific setting from the general site-wide accessibility settings */}
<div className="section-divider" />
<div className="pt-2"/>
<WithLinkableSetting id={"prefer-mathml-feature"}>
Expand Down
28 changes: 18 additions & 10 deletions src/app/components/elements/quiz/QuizContentsComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import classNames from "classnames";
import { MainContent, SidebarLayout } from "../layout/SidebarLayout";
import { SetQuizzesModal } from "../modals/SetQuizzesModal";
import { QuizSidebar, QuizSidebarAttemptProps, QuizSidebarViewProps } from "../sidebar/QuizSidebar";
import { DragAndDropInputModeToggle } from "../PageMetadata";

type PageLinkCreator = (page?: number) => string;
export type QuizView = { quiz?: DetailedQuizSummaryDTO & { subjectId?: SUBJECTS | TAG_ID }, quizId: string | undefined };
Expand Down Expand Up @@ -189,14 +190,23 @@ export function QuizRubricButton({attempt}: {attempt: QuizAttemptDTO}) {
}));
};

if (rubric && renderRubric) {
return <Button color={siteSpecific("keyline", "tertiary")} outline={isAda} className={siteSpecific("btn-lg text-nowrap", "mb-4 bg-light")}
if (!(rubric && renderRubric)) {
return <Button color={siteSpecific("keyline", "tertiary")} outline={isAda} className={classNames("ms-3", siteSpecific("btn-lg text-nowrap", "mb-4 bg-light"))}
alt="Show instructions" title="Show instructions in a modal" onClick={() => {openQuestionModal(attempt);}}> Show instructions
</Button>;
}
}

function QuizSection({attempt, page, studentUser, user, quizAssignmentId}: QuizAttemptProps & {page: number}) {
export function QuizSectionPreamble({attempt, questions}: {attempt: QuizAttemptDTO; questions: QuestionDTO[]}) {
const containsClozeOrDragAndDropQuestions = questions.some(q => ["isaacClozeQuestion", "isaacDragAndDropQuestion"].includes(q.type as string));

return <Col className="d-flex justify-content-end">
{containsClozeOrDragAndDropQuestions && <DragAndDropInputModeToggle/>}
<QuizRubricButton attempt={attempt}/>
</Col>;
}

function QuizSection({attempt, page, studentUser, user, quizAssignmentId, questions}: QuizAttemptProps & {page: number}) {
const deviceSize = useDeviceSize();
const sections = attempt.quiz?.children;
const section = sections && sections[page - 1];
Expand All @@ -209,13 +219,10 @@ function QuizSection({attempt, page, studentUser, user, quizAssignmentId}: QuizA
{viewingAsSomeoneElse && <div className="mb-2">
You are viewing this test as <b>{studentUser?.givenName} {studentUser?.familyName}</b>.{quizAssignmentId && <> <Link to={`/test/assignment/${quizAssignmentId}/feedback`}>Click here</Link> to return to the teacher test feedback page.</>}
</div>}
<Row>
<Col className="d-flex flex-column align-items-end">
{(isAda || above["lg"](deviceSize)) && <div className="mb-3">
<QuizRubricButton attempt={attempt}/>
</div>}
</Col>
</Row>

{(isAda || above["lg"](deviceSize)) && <Row className={classNames({"mb-3": isPhy})}>
<QuizSectionPreamble attempt={attempt} questions={questions}/>
</Row>}

<WithFigureNumbering doc={section}>
<IsaacContent doc={section}/>
Expand Down Expand Up @@ -342,6 +349,7 @@ export function QuizContentsComponent(props: QuizAttemptProps | QuizViewProps) {
currentSection: props.page ? props.page : undefined,
sectionStates: Object.values(sections).map(section => sectionState(section)),
sectionTitles: Object.keys(sections).map(k => sections[k].title || "Section " + k),
questions
}, attempt ? {attempt} : {view});

return <>
Expand Down
Loading
Loading