diff --git a/language/.en.json b/language/.en.json index e9f2c565..6fe44412 100644 --- a/language/.en.json +++ b/language/.en.json @@ -81,7 +81,7 @@ }, { "label": "Answer mode label", - "default": "Answer mode" + "default": "Change answer mode" }, { "label": "Language mode label", diff --git a/language/nb.json b/language/nb.json index 3fd1dd7b..b2f2b24a 100644 --- a/language/nb.json +++ b/language/nb.json @@ -81,7 +81,7 @@ }, { "label": "Etikett for svarmodus", - "default": "Svarmodus" + "default": "Endre svarmodus" }, { "label": "Etikett for språkmodus", diff --git a/language/nn.json b/language/nn.json index d5f049e9..df59ba5c 100644 --- a/language/nn.json +++ b/language/nn.json @@ -81,7 +81,7 @@ }, { "label": "Etikett for svarmodus", - "default": "Svarmodus" + "default": "Endre svarmodus" }, { "label": "Etikett for språkmodus", diff --git a/library.json b/library.json index ad2cbae8..18e3d5a6 100644 --- a/library.json +++ b/library.json @@ -3,7 +3,7 @@ "machineName": "H5P.VocabularyDrill", "majorVersion": 1, "minorVersion": 0, - "patchVersion": 16, + "patchVersion": 17, "runnable": 1, "license": "MIT", "author": "NDLA", diff --git a/library.json.d.ts b/library.json.d.ts index 51f732a3..91a61581 100644 --- a/library.json.d.ts +++ b/library.json.d.ts @@ -3,7 +3,7 @@ declare const json: { "machineName": "H5P.VocabularyDrill", "majorVersion": 1, "minorVersion": 0, - "patchVersion": 16, + "patchVersion": 17, "runnable": 1, "license": "MIT", "author": "NDLA", diff --git a/package-lock.json b/package-lock.json index 92b5ccfa..5300b7db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "h5p-vocabulary-drill", - "version": "1.0.0", + "version": "1.0.16", "license": "ISC", "dependencies": { "h5p-utils": "^3.2.1", diff --git a/semantics.json b/semantics.json index 0bb1a090..b78ccc47 100644 --- a/semantics.json +++ b/semantics.json @@ -135,7 +135,7 @@ { "label": "Answer mode label", "name": "answerModeLabel", - "default": "Answer mode", + "default": "Change answer mode", "type": "text" }, { diff --git a/semantics.json.d.ts b/semantics.json.d.ts index 6981c934..a8bccb6c 100644 --- a/semantics.json.d.ts +++ b/semantics.json.d.ts @@ -135,7 +135,7 @@ declare const json: [ { "label": "Answer mode label", "name": "answerModeLabel", - "default": "Answer mode", + "default": "Change answer mode", "type": "text" }, { diff --git a/src/components/Combobox/Combobox.tsx b/src/components/Combobox/Combobox.tsx new file mode 100644 index 00000000..610edfcf --- /dev/null +++ b/src/components/Combobox/Combobox.tsx @@ -0,0 +1,226 @@ +import React from 'react'; + +type ComboboxOption = { + index?: number; + value: any; + label: string; + className: string; +}; + +type ComboboxProps = { + id: string; + className: string; + label: string; + active: any; + options: ComboboxOption[]; + onChange: () => void; + disabled: boolean; +}; + +export const Combobox: React.FC = ({ + id, + className, + label, + active, + options, + onChange, + disabled, +}) => { + const comboRef = React.useRef(null); + const listboxRef = React.useRef(null); + + const activeOption = options.find((option) => option.value === active) ?? options[0]; + + const [openMenu, setOpenMenu] = React.useState(false); + const [selectedOption, setSelectedOption] = React.useState(activeOption); + + + const SelectActions = { + Close: 0, + CloseSelect: 1, + First: 2, + Last: 3, + Next: 4, + Open: 5, + PageDown: 6, + PageUp: 7, + Previous: 8, + Select: 9, + }; + + const handleOpenMenu = () => { + if (disabled) { + return; + } + setOpenMenu(!openMenu); + }; + + const handleSelectedOption = (option: ComboboxOption) => { + if (selectedOption.value === option.value) { + return; + } + setSelectedOption(option); + }; + + const handleChangeOption = (option: ComboboxOption) => { + if (active !== option.value) { + onChange(); + } + handleSelectedOption(option); + setOpenMenu(false); + }; + + const getActionFromKey = (event: React.KeyboardEvent, menuOpen: boolean) => { + const { key, altKey } = event; + const openKeys = [' ', 'Enter', 'ArrowDown', 'ArrowUp']; + const closeKeys = ['Escape', 'Tab']; + + if (!menuOpen && openKeys.includes(key)) { + return SelectActions.Open; + } + + if (menuOpen && closeKeys.includes(key)) { + return SelectActions.Close; + } + + if (key === 'Home') { + return SelectActions.First; + } + if (key === 'End') { + return SelectActions.Last; + } + + if (menuOpen) { + if (key === 'ArrowUp' && altKey) { + return SelectActions.CloseSelect; + } + else if (key === 'ArrowDown' && !altKey) { + return SelectActions.Next; + } + else if (key === 'ArrowUp') { + return SelectActions.Previous; + } + else if (key === 'PageDown') { + return SelectActions.PageDown; + } + else if (key === 'PageUp') { + return SelectActions.PageUp; + } + else if (key === 'Enter' || key === ' ') { + return SelectActions.CloseSelect; + } + } + return; + }; + + const getUpdatedIndex = (action: number, currentIndex: number, maxIndex: number) => { + const pageSize = 10; + + switch (action) { + case SelectActions.First: + return 0; + case SelectActions.Last: + return maxIndex; + case SelectActions.Next: + return Math.min(maxIndex, currentIndex + 1); + case SelectActions.Previous: + return Math.max(0, currentIndex - 1); + case SelectActions.PageDown: + return Math.min(maxIndex, currentIndex + pageSize); + case SelectActions.PageUp: + return Math.max(0, currentIndex - pageSize); + default: + return currentIndex; + } + }; + + const handleComboKeyDown = (event: React.KeyboardEvent) => { + const action = getActionFromKey(event, openMenu); + + switch (action) { + case SelectActions.Open: + case SelectActions.Close: + event.preventDefault(); + return handleOpenMenu(); + case SelectActions.CloseSelect: + event.preventDefault(); + return handleChangeOption(selectedOption); + case SelectActions.First: + case SelectActions.Last: + case SelectActions.Next: + case SelectActions.Previous: + case SelectActions.PageDown: + case SelectActions.PageUp: + event.preventDefault(); + const newIndex = getUpdatedIndex(action, selectedOption.index ?? 0, options.length - 1); + const newOption = options[newIndex]; + return handleSelectedOption(newOption); + default: + return; + } + }; + + const handleBlur = (event: { relatedTarget: any; }) => { + if (openMenu) { + // Blur events are fired before click events, so we need to check if the click was inside the listbox + const clickedObject = event.relatedTarget; + if (clickedObject && clickedObject.contains(listboxRef.current)) { + return; + } + setOpenMenu(false); + } + }; + + // TODO: make id-s unique + return ( +
+ +
+
+ {activeOption.label} +
+
+ {options.map((option, index) => { + option.index = index; + return ( +
handleChangeOption(option)} + onMouseMove={() => handleSelectedOption(option)} + > + {option.label} +
+ ); + })} +
+
+
+ ); +}; diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index d93eca5a..f61dac84 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useTranslation } from '../../hooks/useTranslation/useTranslation'; import { AnswerModeType } from '../../types/types'; +import { Combobox } from '../Combobox/Combobox'; type ToolbarProps = { title: string; @@ -25,10 +26,10 @@ export const Toolbar: React.FC = ({ const enableTools = enableAnswerMode || enableLanguageMode; - const classes = { - [AnswerModeType.FillIn]: 'h5p-vocabulary-drill-fill-in', - [AnswerModeType.DragText]: 'h5p-vocabulary-drill-drag-text', - }; + const answerModeOptions = [ + { value: AnswerModeType.FillIn, label: t('fillInLabel'), className: 'h5p-vocabulary-drill-fill-in' }, + { value: AnswerModeType.DragText, label: t('dragTextLabel'), className: 'h5p-vocabulary-drill-drag-text' }, + ]; return (
@@ -36,27 +37,15 @@ export const Toolbar: React.FC = ({ {enableTools && (
{enableAnswerMode && ( -
- - -
+ )} {enableLanguageMode && (