diff --git a/src/IsaacAppTypes.tsx b/src/IsaacAppTypes.tsx index a4f9be7184..ef925990c1 100644 --- a/src/IsaacAppTypes.tsx +++ b/src/IsaacAppTypes.tsx @@ -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} @@ -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 { @@ -462,6 +467,7 @@ export const DragAndDropRegionContext = React.createContext<( nonSelectedItems: Immutable[], allItems: Immutable[], zoneIds: Set, + dragAndDropEnabled: boolean; } | undefined>(undefined); export const InlineContext = React.createContext<{ diff --git a/src/app/components/content/IsaacClozeQuestion.tsx b/src/app/components/content/IsaacClozeQuestion.tsx index 33d0d58b04..fb3b5455ec 100644 --- a/src/app/components/content/IsaacClozeQuestion.tsx +++ b/src/app/components/content/IsaacClozeQuestion.tsx @@ -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"; @@ -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")); @@ -134,10 +132,11 @@ const useAutoScroll = ({active, acceleration, interval}: {active: boolean; accel }; const IsaacClozeQuestion = ({doc, questionId, readonly, validationResponse}: IsaacQuestionProps) => { - const deviceSize = useDeviceSize(); const { currentAttempt: rawCurrentAttempt, dispatchSetCurrentAttempt } = useCurrentQuestionAttempt(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; @@ -488,6 +487,7 @@ const IsaacClozeQuestion = ({doc, questionId, readonly, validationResponse}: Isa nonSelectedItems, allItems, zoneIds: new Set(), + dragAndDropEnabled }}> - {(!(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) */} {activeItem && diff --git a/src/app/components/content/IsaacDragAndDropQuestion.tsx b/src/app/components/content/IsaacDragAndDropQuestion.tsx index 4c31f12ed2..1c9d82fefa 100644 --- a/src/app/components/content/IsaacDragAndDropQuestion.tsx +++ b/src/app/components/content/IsaacDragAndDropQuestion.tsx @@ -9,6 +9,7 @@ import { DndItemDTO } from "../../../IsaacApiTypes"; import { + ACTION_TYPE, CLOZE_DROP_ZONE_ID_PREFIX, CLOZE_ITEM_SECTION_ID, NULL_CLOZE_ITEM_ID, @@ -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")); @@ -134,8 +136,24 @@ const useAutoScroll = ({active, acceleration, interval}: {active: boolean; accel }, [active]); }; -const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse}: IsaacQuestionProps) => { +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) => { const { currentAttempt: rawCurrentAttempt, dispatchSetCurrentAttempt } = useCurrentQuestionAttempt(questionId); const currentAttempt = useMemo(() => rawCurrentAttempt ? {...rawCurrentAttempt, items: replaceNullItems(rawCurrentAttempt.items)} : undefined, [rawCurrentAttempt]); @@ -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; @@ -511,6 +531,7 @@ const IsaacDragAndDropQuestion = ({doc, questionId, readonly, validationResponse nonSelectedItems, allItems, zoneIds: new Set(), + dragAndDropEnabled }}> - {(!(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) */} {activeItem && diff --git a/src/app/components/elements/PageMetadata.tsx b/src/app/components/elements/PageMetadata.tsx index 8e516238e1..0ab7aec7ec 100644 --- a/src/app/components/elements/PageMetadata.tsx +++ b/src/app/components/elements/PageMetadata.tsx @@ -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'; @@ -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; @@ -40,6 +44,23 @@ type PageMetadataProps = { } ); +export const DragAndDropInputModeToggle = ({dragAndDropEnabled, toggleDragAndDropEnabled}: {dragAndDropEnabled: boolean, toggleDragAndDropEnabled: () => void}) => { + return siteSpecific(
+ Question input mode + + +
, +
+ Use dropdowns for drag and drop questions} /> +
+ ); +}; + interface ActionButtonsProps extends React.HTMLAttributes { location: Location; isQuestion: boolean; @@ -73,13 +94,18 @@ interface TagStackProps extends React.HTMLAttributes { 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
{(isCrossTopic || pageContainsLLMFreeTextQuestion) &&
{isAda && isCrossTopic && } {pageContainsLLMFreeTextQuestion && } + {displayDragAndDropToggle && {}} />}
} - +
+ + {displayDragAndDropToggle && !(isCrossTopic || pageContainsLLMFreeTextQuestion) && {}} />} +
; }; @@ -117,6 +143,9 @@ export const PageMetadata = (props: PageMetadataProps) => { const deviceSize = useDeviceSize(); const actionButtonsFloat = noTitle && children; + const pageContainsClozeOrDragAndDropQuestion = useAppSelector(selectors.questions.includesClozeOrDragAndDropQuestion); + const { dragAndDropEnabled, toggleDragAndDropEnabled } = useDragAndDropAccessibility(); + return <> {isPhy && showSidebarButton && sidebarInTitle && below['md'](deviceSize) && }
@@ -140,6 +169,11 @@ export const PageMetadata = (props: PageMetadataProps) => {
{isPhy && } {isConcept && } + {isPhy && pageContainsClozeOrDragAndDropQuestion && <> + + + + }
{isPhy && } @@ -147,3 +181,4 @@ export const PageMetadata = (props: PageMetadataProps) => { {isPhy && showSidebarButton && !sidebarInTitle && below['md'](deviceSize) && } ; }; + diff --git a/src/app/components/elements/markup/portals/InlineDropZones.tsx b/src/app/components/elements/markup/portals/InlineDropZones.tsx index c739652836..a835cb34ae 100644 --- a/src/app/components/elements/markup/portals/InlineDropZones.tsx +++ b/src/app/components/elements/markup/portals/InlineDropZones.tsx @@ -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"; @@ -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(false); const droppableId = CLOZE_DROP_ZONE_ID_PREFIX + zoneId; const dropdownItems = dropRegionContext?.allItems ?? []; @@ -114,8 +113,7 @@ function InlineDropRegion({divId, zoneId, emptyWidth, emptyHeight, rootElement, ; 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; diff --git a/src/app/components/elements/panels/UserAccessibilitySettings.tsx b/src/app/components/elements/panels/UserAccessibilitySettings.tsx index af39216683..3a89bdf315 100644 --- a/src/app/components/elements/panels/UserAccessibilitySettings.tsx +++ b/src/app/components/elements/panels/UserAccessibilitySettings.tsx @@ -34,8 +34,7 @@ export const UserAccessibilitySettings = ({ accessibilitySettings, setAccessibil />

{`Enabling this will reduce motion effects on the platform. Browser preference will take priority over this setting.`}

-
- + { @@ -49,6 +48,20 @@ export const UserAccessibilitySettings = ({ accessibilitySettings, setAccessibil />

{`Enabling this will display warnings on certain content that may be inaccessible to assistive technologies.`}

+ + { + setAccessibilitySettings((oldDs) => ({...oldDs, NON_DRAGGING_INPUTS: e.target.checked})); + }} + color={siteSpecific("primary", "")} + label={

Enable non-dragging alternative inputs

} + id={"non-dragging-movement"} + aria-describedby="non-dragging-movement-helptext" + removeVerticalOffset + />
+

{`Enabling this will allow you to use alternative input methods that don't require dragging for certain question types (e.g. drag-and-drop).`}

+
+ {/* Seperate maths-specific setting from the general site-wide accessibility settings */}
diff --git a/src/app/services/constants.ts b/src/app/services/constants.ts index 7b71ff085a..bcb78a05e5 100644 --- a/src/app/services/constants.ts +++ b/src/app/services/constants.ts @@ -130,6 +130,8 @@ export enum ACTION_TYPE { USER_PREFERENCES_RESPONSE_SUCCESS= "USER_PREFERENCES_RESPONSE_SUCCESS", USER_PREFERENCES_RESPONSE_FAILURE = "USER_PREFERENCES_RESPONSE_FAILURE", + ACCESSIBILITY_TYPE_SET = "ACCESSIBILITY_TYPE_SET", + USER_PASSWORD_RESET_REQUEST= "USER_PASSWORD_RESET_REQUEST", USER_PASSWORD_RESET_RESPONSE_SUCCESS ="USER_PASSWORD_RESET_RESPONSE_SUCCESS", USER_PASSWORD_RESET_RESPONSE_FAILURE = "USER_PASSWORD_RESET_RESPONSE_FAILURE", diff --git a/src/app/state/reducers/index.ts b/src/app/state/reducers/index.ts index c0315fa53a..883b4dd27f 100644 --- a/src/app/state/reducers/index.ts +++ b/src/app/state/reducers/index.ts @@ -29,6 +29,7 @@ import { topicSlice, linkableSettingSlice, sidebarSlice, + accessibilityType, } from "../index"; export const rootReducer = combineReducers({ @@ -90,6 +91,9 @@ export const rootReducer = combineReducers({ // Linkable settings linkableSetting: linkableSettingSlice.reducer, + // Accessibility + accessibilityType, + // API reducer [isaacApi.reducerPath]: isaacApi.reducer }); diff --git a/src/app/state/reducers/userState.ts b/src/app/state/reducers/userState.ts index aa89ae9cdc..18b6b4a548 100644 --- a/src/app/state/reducers/userState.ts +++ b/src/app/state/reducers/userState.ts @@ -1,4 +1,4 @@ -import {Action, UserPreferencesDTO} from "../../../IsaacAppTypes"; +import {AccessibilitySettingsWithOverride, Action, UserPreferencesDTO} from "../../../IsaacAppTypes"; import {ACTION_TYPE} from "../../services"; import {UserAuthenticationSettingsDTO} from "../../../IsaacApiTypes"; import {userApi} from "../index"; @@ -40,3 +40,15 @@ export const totpChallengePending = (totpChallengePending: TotpChallengePendingS return totpChallengePending; } }; + +type AccessibilityTypeState = AccessibilitySettingsWithOverride | null; +export const accessibilityType = (accessibilityType: AccessibilityTypeState = null, action: Action) => { + switch (action.type) { + case ACTION_TYPE.USER_PREFERENCES_RESPONSE_SUCCESS: + return { ...action.userPreferences.ACCESSIBILITY, MANUAL_OVERRIDE: false }; + case ACTION_TYPE.ACCESSIBILITY_TYPE_SET: + return { ...action.accessibilityType, MANUAL_OVERRIDE: true }; + default: + return accessibilityType; + } +}; diff --git a/src/app/state/selectors.tsx b/src/app/state/selectors.tsx index 5f978da394..53fe49070b 100644 --- a/src/app/state/selectors.tsx +++ b/src/app/state/selectors.tsx @@ -46,6 +46,9 @@ export const selectors = { }, includesLLMFreeTextQuestion: (state: AppState) => { return !!state?.questions?.questions.some(q => q.type === "isaacLLMFreeTextQuestion"); + }, + includesClozeOrDragAndDropQuestion: (state: AppState) => { + return !!state?.questions?.questions.some(q => q.type === "isaacClozeQuestion" || q.type === "isaacDndQuestion"); } }, @@ -91,6 +94,10 @@ export const selectors = { previousContext: (state: AppState) => state?.pageContext?.previousContext ?? undefined, stage: (state: AppState) => state?.pageContext?.stage, subject: (state: AppState) => state?.pageContext?.subject, + }, + + accessibility: { + type: (state: AppState) => state?.accessibilityType } }; diff --git a/src/scss/common/questions.scss b/src/scss/common/questions.scss index d77f598974..1e115737c5 100644 --- a/src/scss/common/questions.scss +++ b/src/scss/common/questions.scss @@ -529,6 +529,10 @@ figure .inline-container { .cloze-dropdown > button { min-width: 40px !important; // Bootstrap thinks its important + &.btn-secondary, &.btn-outline-secondary { + background-color: white; + } + &.empty.empty { &, &:hover, &:active, &:focus { // Apply regardless of hover border: grey solid 1px !important; diff --git a/src/test/pages/__image_snapshots__/ada/My Account should have no visual regressions on Accessibility page #0.png b/src/test/pages/__image_snapshots__/ada/My Account should have no visual regressions on Accessibility page #0.png index e9b8942d7a..3ca209d6eb 100644 Binary files a/src/test/pages/__image_snapshots__/ada/My Account should have no visual regressions on Accessibility page #0.png and b/src/test/pages/__image_snapshots__/ada/My Account should have no visual regressions on Accessibility page #0.png differ diff --git a/src/test/pages/__image_snapshots__/ada/Question types' regression test page should have no visual regressions #0.png b/src/test/pages/__image_snapshots__/ada/Question types' regression test page should have no visual regressions #0.png index 964473dddb..ed4d854276 100644 Binary files a/src/test/pages/__image_snapshots__/ada/Question types' regression test page should have no visual regressions #0.png and b/src/test/pages/__image_snapshots__/ada/Question types' regression test page should have no visual regressions #0.png differ diff --git a/src/test/pages/__image_snapshots__/sci/My Account should have no visual regressions on Accessibility page #0.png b/src/test/pages/__image_snapshots__/sci/My Account should have no visual regressions on Accessibility page #0.png index 9dc289ee58..b71bf75b26 100644 Binary files a/src/test/pages/__image_snapshots__/sci/My Account should have no visual regressions on Accessibility page #0.png and b/src/test/pages/__image_snapshots__/sci/My Account should have no visual regressions on Accessibility page #0.png differ diff --git a/src/test/pages/__image_snapshots__/sci/Question types' regression test page should have no visual regressions #0.png b/src/test/pages/__image_snapshots__/sci/Question types' regression test page should have no visual regressions #0.png index eaa3d121cd..e9f2f242ad 100644 Binary files a/src/test/pages/__image_snapshots__/sci/Question types' regression test page should have no visual regressions #0.png and b/src/test/pages/__image_snapshots__/sci/Question types' regression test page should have no visual regressions #0.png differ