Skip to content

Commit

Permalink
feat: use combobox (#157)
Browse files Browse the repository at this point in the history
* feat: use combobox

* feat: add Combobox component

* change translations for answer mode label

* fix lint

* fix new translation

* remove old select css

* move styling inside combo class

* use rem instead of px

* chore: update package-lock

* bump patch version

---------

Co-authored-by: Sindre Bøyum <[email protected]>
  • Loading branch information
henriettemoe and boyum authored Apr 21, 2023
1 parent 80f11be commit cc4edec
Show file tree
Hide file tree
Showing 11 changed files with 356 additions and 90 deletions.
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

0 comments on commit cc4edec

Please sign in to comment.