Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use combobox #157

Merged
merged 10 commits into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from all 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: 1 addition & 1 deletion language/.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
},
{
"label": "Answer mode label",
"default": "Answer mode"
"default": "Change answer mode"
},
{
"label": "Language mode label",
Expand Down
2 changes: 1 addition & 1 deletion language/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
},
{
"label": "Etikett for svarmodus",
"default": "Svarmodus"
"default": "Endre svarmodus"
},
{
"label": "Etikett for språkmodus",
Expand Down
2 changes: 1 addition & 1 deletion language/nn.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
},
{
"label": "Etikett for svarmodus",
"default": "Svarmodus"
"default": "Endre svarmodus"
},
{
"label": "Etikett for språkmodus",
Expand Down
2 changes: 1 addition & 1 deletion library.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"machineName": "H5P.VocabularyDrill",
"majorVersion": 1,
"minorVersion": 0,
"patchVersion": 16,
"patchVersion": 17,
"runnable": 1,
"license": "MIT",
"author": "NDLA",
Expand Down
2 changes: 1 addition & 1 deletion library.json.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ declare const json: {
"machineName": "H5P.VocabularyDrill",
"majorVersion": 1,
"minorVersion": 0,
"patchVersion": 16,
"patchVersion": 17,
"runnable": 1,
"license": "MIT",
"author": "NDLA",
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion semantics.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
{
"label": "Answer mode label",
"name": "answerModeLabel",
"default": "Answer mode",
"default": "Change answer mode",
"type": "text"
},
{
Expand Down
2 changes: 1 addition & 1 deletion semantics.json.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ declare const json: [
{
"label": "Answer mode label",
"name": "answerModeLabel",
"default": "Answer mode",
"default": "Change answer mode",
"type": "text"
},
{
Expand Down
226 changes: 226 additions & 0 deletions src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -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<ComboboxProps> = ({
id,
className,
label,
active,
options,
onChange,
disabled,
}) => {
const comboRef = React.useRef<HTMLDivElement>(null);
const listboxRef = React.useRef<HTMLDivElement>(null);

const activeOption = options.find((option) => option.value === active) ?? options[0];

const [openMenu, setOpenMenu] = React.useState(false);
const [selectedOption, setSelectedOption] = React.useState<ComboboxOption>(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<HTMLDivElement>, 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<HTMLDivElement>) => {
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 (
<div className={className}>
<label id={`${id}-label`} className="visually-hidden combo-label" htmlFor={id}>
{label}
</label>
<div className={`combo ${openMenu ? 'open' : ''} ${activeOption.className} ${disabled ? 'disabled' : ''}`}>
<div
aria-controls={`${id}-listbox`}
aria-expanded={openMenu}
aria-haspopup="listbox"
aria-labelledby={`${id}-label`}
id={id}
className="combo-input"
role="combobox"
tabIndex={disabled ? -1 : 0}
aria-activedescendant={`${id}-option-${selectedOption.index}`}
onClick={handleOpenMenu}
onKeyDown={handleComboKeyDown}
onBlur={handleBlur}
ref={comboRef}
>
{activeOption.label}
</div>
<div
role="listbox"
ref={listboxRef}
className="combo-menu"
id={`${id}-listbox`}
aria-aria-labelledby={`${id}-label`}
onChange={onChange}
tabIndex={-1}
>
{options.map((option, index) => {
option.index = index;
return (
<div
role="option"
id={`${id}-option-${index}`}
className={`combo-option ${selectedOption.index === option.index ? 'option-current' : ''}`}
aria-selected={activeOption.index === option.index}
onClick={() => handleChangeOption(option)}
onMouseMove={() => handleSelectedOption(option)}
>
{option.label}
</div>
);
})}
</div>
</div>
</div>
);
};
39 changes: 14 additions & 25 deletions src/components/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,38 +26,26 @@ export const Toolbar: React.FC<ToolbarProps> = ({

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 (
<div className="h5p-vocabulary-drill-toolbar">
<p>{title}</p>
{enableTools && (
<div className="h5p-vocabulary-drill-toolbar-tools">
{enableAnswerMode && (
<div
className={`h5p-vocabulary-drill-toolbar-select ${classes[activeAnswerMode]} ${disableTools ? 'disabled' : ''}`}
>
<label className="visually-hidden" htmlFor="answerMode">
{t('answerModeLabel')}
</label>
<select
id="answerMode"
name="answerMode"
onChange={onAnswerModeChange}
value={activeAnswerMode}
disabled={disableTools}
>
<option value={AnswerModeType.FillIn}>
{t('fillInLabel')}
</option>
<option value={AnswerModeType.DragText}>
{t('dragTextLabel')}
</option>
</select>
</div>
<Combobox
id="h5p-vocabulary-drill-answermode-combobox"
className="h5p-vocabulary-drill-combobox"
label={t('answerModeLabel')}
active={activeAnswerMode}
options={answerModeOptions}
onChange={onAnswerModeChange}
disabled={disableTools}
/>
)}
{enableLanguageMode && (
<button
Expand Down
Loading