mirror of
https://github.com/Redocly/redoc.git
synced 2025-08-01 19:00:21 +03:00
[DOP-3497]: Add arrow key functionality (#32)
This commit is contained in:
parent
1612c0ca89
commit
34a854b765
|
@ -3,9 +3,9 @@ import { StyledLi, StyledOptionText, StyledPlaceholder } from './styled.elements
|
||||||
import Checkmark from './CheckmarkSvg';
|
import Checkmark from './CheckmarkSvg';
|
||||||
import { OptionProps } from './types';
|
import { OptionProps } from './types';
|
||||||
|
|
||||||
export const Option = ({ option, value, selected, onClick }: OptionProps) => {
|
export const Option = ({ option, value, selected, onClick, focused }: OptionProps) => {
|
||||||
const KEY_ENTER = 'ENTER';
|
const KEY_ENTER = 'Enter';
|
||||||
const KEY_SPACE = 'SPACE';
|
const KEY_SPACE = ' ';
|
||||||
|
|
||||||
const handleKeyPress = (event: React.KeyboardEvent) => {
|
const handleKeyPress = (event: React.KeyboardEvent) => {
|
||||||
if (event.key === KEY_ENTER || event.key === KEY_SPACE) {
|
if (event.key === KEY_ENTER || event.key === KEY_SPACE) {
|
||||||
|
@ -14,7 +14,12 @@ export const Option = ({ option, value, selected, onClick }: OptionProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledLi onClick={() => onClick(value)} onKeyPress={handleKeyPress} selected={selected}>
|
<StyledLi
|
||||||
|
onClick={() => onClick(value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
selected={selected}
|
||||||
|
focused={focused}
|
||||||
|
>
|
||||||
{selected ? <Checkmark /> : <StyledPlaceholder />}
|
{selected ? <Checkmark /> : <StyledPlaceholder />}
|
||||||
<StyledOptionText>{option}</StyledOptionText>
|
<StyledOptionText>{option}</StyledOptionText>
|
||||||
</StyledLi>
|
</StyledLi>
|
||||||
|
|
|
@ -21,16 +21,32 @@ import { useOutsideClick } from './use-outside-click';
|
||||||
const VersionSelectorComponent = ({
|
const VersionSelectorComponent = ({
|
||||||
resourceVersions,
|
resourceVersions,
|
||||||
active,
|
active,
|
||||||
rootUrl,
|
|
||||||
description,
|
description,
|
||||||
|
rootUrl,
|
||||||
}: VersionSelectorProps): JSX.Element => {
|
}: VersionSelectorProps): JSX.Element => {
|
||||||
const [selectedIdx, setSelectedIdx] = React.useState(
|
const initialSelectedIdx = resourceVersions.indexOf(active.resourceVersion);
|
||||||
resourceVersions.indexOf(active.resourceVersion),
|
|
||||||
);
|
|
||||||
const [open, setOpen] = React.useState<boolean>(false);
|
const [open, setOpen] = React.useState<boolean>(false);
|
||||||
|
const [focusedIdx, setFocusedIdx] = React.useState<number>(-1);
|
||||||
|
const [selectedIdx, setSelectedIdx] = React.useState<number>(initialSelectedIdx);
|
||||||
|
|
||||||
const menuListRef = React.useRef(null);
|
const menuListRef = React.useRef(null);
|
||||||
|
|
||||||
|
const options = resourceVersions.map((option, i) => {
|
||||||
|
return (
|
||||||
|
<Option
|
||||||
|
key={`option-${i}`}
|
||||||
|
selected={i === selectedIdx}
|
||||||
|
value={option}
|
||||||
|
option={`${option}${i === resourceVersions.length - 1 ? ' (latest)' : ''}`}
|
||||||
|
onClick={option => handleClick(i, option)}
|
||||||
|
focused={i === focusedIdx}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
useOutsideClick(menuListRef, () => {
|
useOutsideClick(menuListRef, () => {
|
||||||
if (open) setOpen(false);
|
if (open) setOpen(false);
|
||||||
|
setFocusedIdx(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClick = (idx: number, resourceVersion: string) => {
|
const handleClick = (idx: number, resourceVersion: string) => {
|
||||||
|
@ -47,8 +63,43 @@ const VersionSelectorComponent = ({
|
||||||
return setOpen(false);
|
return setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocusChange = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const { key, shiftKey } = event;
|
||||||
|
|
||||||
|
if (key === 'ArrowDown' || (key === 'Tab' && !shiftKey)) {
|
||||||
|
// if we go down when we are already past the end, don't do anything
|
||||||
|
if (focusedIdx === resourceVersions.length) return;
|
||||||
|
|
||||||
|
if (focusedIdx === -1) {
|
||||||
|
// when first entering the dropdown via the down arrow key or tab,
|
||||||
|
// we want to open the modal
|
||||||
|
setOpen(true);
|
||||||
|
} else if (focusedIdx === resourceVersions.length - 1) {
|
||||||
|
// if we are at the last element of the dropdown, and attempt to go
|
||||||
|
// down again, we want to close the dropdown
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
setFocusedIdx(focusedIdx + 1);
|
||||||
|
} else if (key === 'ArrowUp' || (key === 'Tab' && shiftKey)) {
|
||||||
|
// if we go down when we are already past the end, don't do anything
|
||||||
|
if (focusedIdx === -1) return;
|
||||||
|
|
||||||
|
if (focusedIdx === resourceVersions.length) {
|
||||||
|
// in this scenario, we are entering the dropdown from below
|
||||||
|
// we open the dropdown and start from the bottom
|
||||||
|
setOpen(true);
|
||||||
|
} else if (focusedIdx === 0) {
|
||||||
|
// if we reach the first element in the drop down, and we attempt to go up again,
|
||||||
|
// we want to close the dropdown
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocusedIdx(focusedIdx - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledWrapper ref={menuListRef}>
|
<StyledWrapper onKeyDown={handleFocusChange} ref={menuListRef}>
|
||||||
<StyledSelectWrapper>
|
<StyledSelectWrapper>
|
||||||
<StyledLabel>Resource Version:</StyledLabel>
|
<StyledLabel>Resource Version:</StyledLabel>
|
||||||
{description && <StyledDescription>{description}</StyledDescription>}
|
{description && <StyledDescription>{description}</StyledDescription>}
|
||||||
|
@ -62,17 +113,7 @@ const VersionSelectorComponent = ({
|
||||||
|
|
||||||
<StyledDropdown open={open}>
|
<StyledDropdown open={open}>
|
||||||
<div>
|
<div>
|
||||||
<StyledMenuList>
|
<StyledMenuList>{options}</StyledMenuList>
|
||||||
{resourceVersions.map((option, i) => (
|
|
||||||
<Option
|
|
||||||
key={`option-${i}`}
|
|
||||||
selected={i === selectedIdx}
|
|
||||||
value={option}
|
|
||||||
option={`${option}${i === resourceVersions.length - 1 ? ' (latest)' : ''}`}
|
|
||||||
onClick={option => handleClick(i, option)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</StyledMenuList>
|
|
||||||
</div>
|
</div>
|
||||||
</StyledDropdown>
|
</StyledDropdown>
|
||||||
</StyledWrapper>
|
</StyledWrapper>
|
||||||
|
|
|
@ -121,26 +121,17 @@ export const enabledOptionStyle = css`
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${palette.gray.light2};
|
background-color: ${palette.gray.light2};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
color: ${palette.blue.dark2};
|
|
||||||
background-color: ${palette.blue.light3};
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scaleY(1);
|
|
||||||
background-color: ${palette.blue.base};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const StyledLi = styled.li.attrs<{ selected: boolean; disabled?: boolean }>(
|
export const StyledLi = styled.li.attrs<{
|
||||||
({ selected }) => ({
|
selected: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
}>(({ selected }) => ({
|
||||||
role: 'option',
|
role: 'option',
|
||||||
'aria-selected': selected,
|
'aria-selected': selected,
|
||||||
tabIndex: '0',
|
tabIndex: '0',
|
||||||
}),
|
}))<{ selected: boolean; disabled?: boolean; focused?: boolean }>`
|
||||||
)<{ selected: boolean; disabled?: boolean }>`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
@ -150,9 +141,11 @@ export const StyledLi = styled.li.attrs<{ selected: boolean; disabled?: boolean
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: ${palette.gray.dark3};
|
color: ${palette.gray.dark3};
|
||||||
|
${props =>
|
||||||
|
props.focused &&
|
||||||
|
`color: ${palette.blue.dark2};
|
||||||
|
background-color: ${palette.blue.light3};`}
|
||||||
font-weight: ${props => (props.selected ? `bold` : `normal`)};
|
font-weight: ${props => (props.selected ? `bold` : `normal`)};
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -165,8 +158,14 @@ export const StyledLi = styled.li.attrs<{ selected: boolean; disabled?: boolean
|
||||||
border-radius: 0px 4px 4px 0px;
|
border-radius: 0px 4px 4px 0px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all ${transitionDuration.default}ms ease-in-out;
|
transition: all ${transitionDuration.default}ms ease-in-out;
|
||||||
|
${props =>
|
||||||
|
props.focused &&
|
||||||
|
`
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleY(1);
|
||||||
|
background-color: ${palette.blue.base};
|
||||||
|
`}
|
||||||
}
|
}
|
||||||
|
|
||||||
${props => (props.disabled ? disabledOptionStyle : enabledOptionStyle)}
|
${props => (props.disabled ? disabledOptionStyle : enabledOptionStyle)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ export interface VersionSelectorProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionProps {
|
export interface OptionProps {
|
||||||
|
focused: boolean;
|
||||||
option: string;
|
option: string;
|
||||||
value: string;
|
value: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|
|
@ -54,7 +54,7 @@ exports[`VersionSelector should correctly render VersionSelector 1`] = `
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
class="sc-pNWdM glxGCd"
|
class="sc-pNWdM kSTcTW"
|
||||||
role="option"
|
role="option"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
@ -69,7 +69,7 @@ exports[`VersionSelector should correctly render VersionSelector 1`] = `
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
aria-selected="false"
|
aria-selected="false"
|
||||||
class="sc-pNWdM glxGCd"
|
class="sc-pNWdM kSTcTW"
|
||||||
role="option"
|
role="option"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
@ -84,7 +84,7 @@ exports[`VersionSelector should correctly render VersionSelector 1`] = `
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
aria-selected="true"
|
aria-selected="true"
|
||||||
class="sc-pNWdM jlRKTH"
|
class="sc-pNWdM derVNM"
|
||||||
role="option"
|
role="option"
|
||||||
selected=""
|
selected=""
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user