Skip to content

Commit

Permalink
Improve role and keyboard interaction for the settings sidebar
Browse files Browse the repository at this point in the history
Follows the aria tab pattern. See https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
  • Loading branch information
personalizedrefrigerator committed Aug 1, 2024
1 parent aabe55a commit d99dd09
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 15 deletions.
36 changes: 32 additions & 4 deletions packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
if (syncTargetMd.supportsConfigCheck) {
const messages = shared.checkSyncConfigMessages(this);
const statusComp = !messages.length ? null : (
<div style={statusStyle}>
<div style={statusStyle} aria-live='polite'>
{messages[0]}
{messages.length >= 1 ? <p>{messages[1]}</p> : null}
</div>
Expand Down Expand Up @@ -371,7 +371,7 @@ class ConfigScreenComponent extends React.Component<any, any> {

const settings = this.state.settings;

const containerStyle = {
const containerStyle: React.CSSProperties = {
overflow: 'auto',
padding: theme.configScreenPadding,
paddingTop: 0,
Expand Down Expand Up @@ -403,6 +403,35 @@ class ConfigScreenComponent extends React.Component<any, any> {
const rightStyle = { ...style, flex: 1 };
delete style.width;

const tabComponents: React.ReactNode[] = [];
for (const section of sections) {
const sectionId = `setting-section-${section.name}`;
let content = null;
const visible = section.name === this.state.selectedSectionName;
if (visible) {
content = (
<>
{screenComp}
<div style={containerStyle}>{settingComps}</div>
</>
);
}

tabComponents.push(
<div
key={sectionId}
id={sectionId}
className={`setting-tab-panel ${!visible ? '-hidden' : ''}`}
hidden={!visible}
aria-labelledby={`setting-tab-${section.name}`}
tabIndex={0}
role='tabpanel'
>
{content}
</div>,
);
}

return (
<div className="config-screen" style={{ display: 'flex', flexDirection: 'row', height: this.props.style.height }}>
<Sidebar
Expand All @@ -411,9 +440,8 @@ class ConfigScreenComponent extends React.Component<any, any> {
sections={sections}
/>
<div style={rightStyle}>
{screenComp}
{needRestartComp}
<div style={containerStyle}>{settingComps}</div>
{tabComponents}
<ButtonBar
hasChanges={hasChanges}
backButtonTitle={hasChanges && !screenComp ? _('Cancel') : _('Back')}
Expand Down
65 changes: 55 additions & 10 deletions packages/app-desktop/gui/ConfigScreen/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { AppType, SettingSectionSource } from '@joplin/lib/models/Setting';
import { AppType, MetadataBySection, SettingMetadataSection, SettingSectionSource } from '@joplin/lib/models/Setting';
import * as React from 'react';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
import { useCallback, useRef } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
const styled = require('styled-components').default;

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
type StyleProps = any;

interface SectionChangeEvent {
section: SettingMetadataSection;
}

interface Props {
selection: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onSelectionChange: Function;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
sections: any[];
onSelectionChange: (event: SectionChangeEvent)=> void;
sections: MetadataBySection;
}

export const StyledRoot = styled.div`
Expand Down Expand Up @@ -73,24 +77,63 @@ export const StyledListItemIcon = styled.i`
`;

export default function Sidebar(props: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
const buttons: any[] = [];
const buttonRefs = useRef<HTMLElement[]>([]);

// Making a tabbed region accessible involves supporting keyboard interaction.
// See https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ for details
const onKeyDown: React.KeyboardEventHandler<HTMLElement> = useCallback((event) => {
const selectedIndex = props.sections.findIndex(section => section.name === props.selection);
let newIndex = selectedIndex;

if (event.code === 'ArrowUp') {
newIndex --;
} else if (event.code === 'ArrowDown') {
newIndex ++;
} else if (event.code === 'Home') {
newIndex = 0;
} else if (event.code === 'End') {
newIndex = props.sections.length - 1;
}

if (newIndex < 0) newIndex += props.sections.length;
newIndex %= props.sections.length;

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
function renderButton(section: any) {
if (newIndex !== selectedIndex) {
event.preventDefault();
props.onSelectionChange({ section: props.sections[newIndex] });

const targetButton = buttonRefs.current[newIndex];
if (targetButton) {
focus('Sidebar', targetButton);
}
}
}, [props.sections, props.selection, props.onSelectionChange]);

const buttons: React.ReactNode[] = [];

function renderButton(section: SettingMetadataSection, index: number) {
const selected = props.selection === section.name;
return (
<StyledListItem
key={section.name}
href='#'
role='tab'
ref={(item: HTMLElement) => { buttonRefs.current[index] = item; }}

id={`setting-tab-${section.name}`}
aria-controls={`setting-section-${section.name}`}
aria-selected={selected}
tabIndex={selected ? 0 : -1}

isSubSection={Setting.isSubSection(section.name)}
selected={selected}
onClick={() => { props.onSelectionChange({ section: section }); }}
onKeyDown={onKeyDown}
>
<StyledListItemIcon
aria-label=''
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
role='img'
/>
<StyledListItemLabel>
{Setting.sectionNameToLabel(section.name)}
Expand All @@ -109,13 +152,15 @@ export default function Sidebar(props: Props) {

let pluginDividerAdded = false;

let index = 0;
for (const section of props.sections) {
if (section.source === SettingSectionSource.Plugin && !pluginDividerAdded) {
buttons.push(renderDivider('divider-plugins'));
pluginDividerAdded = true;
}

buttons.push(renderButton(section));
buttons.push(renderButton(section, index));
index ++;
}

return (
Expand Down
1 change: 1 addition & 0 deletions packages/app-desktop/gui/ConfigScreen/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
@use "./setting-description.scss";
@use "./setting-label.scss";
@use "./setting-header.scss";
@use "./setting-tab-panel.scss";
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

.setting-tab-panel {
display: flex;
flex-grow: 1;
flex-shrink: 1;
min-height: 0;

&.-hidden {
display: none;
}

&:focus-visible {
// Use a border rather than an outline -- an outline would be shown outside of the screen
// and thus invisible.
border: 1px solid var(--joplin-focus-outline-color);
outline: none;
}
}
2 changes: 1 addition & 1 deletion packages/app-desktop/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ a {


*:focus-visible {
outline: 1px solid var(--joplin-color-warn);
outline: 1px solid var(--joplin-focus-outline-color);
}

// The browser-default focus-visible indicator was originally removed for aesthetic
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export function extraStyles(theme: Theme) {
const bgColor4 = theme.backgroundColor4;
const borderColor4: string = Color(theme.color).alpha(0.3);
const iconColor = Color(theme.color).alpha(0.8);
const focusOutlineColor = theme.colorWarn;

const backgroundColor5 = theme.backgroundColor5 ?? theme.color4;
const backgroundColorHover5 = Color(backgroundColor5).darken(0.2).hex();
Expand Down Expand Up @@ -230,6 +231,7 @@ export function extraStyles(theme: Theme) {
backgroundColor5,
backgroundColorHover5,
backgroundColorActive5,
focusOutlineColor,

icon: {
...globalStyle.icon,
Expand Down

0 comments on commit d99dd09

Please sign in to comment.