1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-02 12:47:41 +02:00

Desktop: Accessibility: Improve settings screen keyboard navigation and screen reader accessibility (#10812)

This commit is contained in:
Henry Heino 2024-08-02 06:49:15 -07:00 committed by GitHub
parent 65ef700fdc
commit 14cc053094
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 795 additions and 498 deletions

View File

@ -167,9 +167,13 @@ packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/FontSearch.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
packages/app-desktop/gui/ConfigScreen/controls/SettingLabel.js
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
@ -463,6 +467,7 @@ packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js packages/app-desktop/integration-tests/noteList.spec.js
packages/app-desktop/integration-tests/richTextEditor.spec.js packages/app-desktop/integration-tests/richTextEditor.spec.js
packages/app-desktop/integration-tests/settings.spec.js
packages/app-desktop/integration-tests/sidebar.spec.js packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js packages/app-desktop/integration-tests/util/activateMainMenuItem.js

7
.gitignore vendored
View File

@ -146,9 +146,13 @@ packages/app-desktop/gui/Button/Button.js
packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ClipperConfigScreen.js
packages/app-desktop/gui/ConfigScreen/ButtonBar.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/FontSearch.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js
packages/app-desktop/gui/ConfigScreen/controls/SettingHeader.js
packages/app-desktop/gui/ConfigScreen/controls/SettingLabel.js
packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
@ -442,6 +446,7 @@ packages/app-desktop/integration-tests/models/SettingsScreen.js
packages/app-desktop/integration-tests/models/Sidebar.js packages/app-desktop/integration-tests/models/Sidebar.js
packages/app-desktop/integration-tests/noteList.spec.js packages/app-desktop/integration-tests/noteList.spec.js
packages/app-desktop/integration-tests/richTextEditor.spec.js packages/app-desktop/integration-tests/richTextEditor.spec.js
packages/app-desktop/integration-tests/settings.spec.js
packages/app-desktop/integration-tests/sidebar.spec.js packages/app-desktop/integration-tests/sidebar.spec.js
packages/app-desktop/integration-tests/simpleBackup.spec.js packages/app-desktop/integration-tests/simpleBackup.spec.js
packages/app-desktop/integration-tests/util/activateMainMenuItem.js packages/app-desktop/integration-tests/util/activateMainMenuItem.js

View File

@ -36,6 +36,9 @@ interface Props {
isSquare?: boolean; isSquare?: boolean;
iconOnly?: boolean; iconOnly?: boolean;
fontSize?: number; fontSize?: number;
'aria-controls'?: string;
'aria-expanded'?: string;
} }
const StyledTitle = styled.span` const StyledTitle = styled.span`
@ -220,7 +223,14 @@ const Button = React.forwardRef((props: Props, ref: any) => {
function renderIcon() { function renderIcon() {
if (!props.iconName) return null; if (!props.iconName) return null;
return <StyledIcon aria-label={props.iconLabel} animation={props.iconAnimation} mr={iconOnly ? '0' : '6px'} color={props.color} className={props.iconName}/>; return <StyledIcon
aria-label={props.iconLabel ?? ''}
animation={props.iconAnimation}
mr={iconOnly ? '0' : '6px'}
color={props.color}
className={props.iconName}
role='img'
/>;
} }
function renderTitle() { function renderTitle() {
@ -234,7 +244,22 @@ const Button = React.forwardRef((props: Props, ref: any) => {
} }
return ( return (
<StyledButton ref={ref} fontSize={props.fontSize} isSquare={props.isSquare} size={props.size} style={props.style} disabled={props.disabled} title={props.tooltip} className={props.className} iconOnly={iconOnly} onClick={onClick}> <StyledButton
ref={ref}
fontSize={props.fontSize}
isSquare={props.isSquare}
size={props.size}
style={props.style}
disabled={props.disabled}
title={props.tooltip}
className={props.className}
iconOnly={iconOnly}
onClick={onClick}
aria-disabled={props.disabled}
aria-expanded={props['aria-expanded']}
aria-controls={props['aria-controls']}
>
{renderIcon()} {renderIcon()}
{renderTitle()} {renderTitle()}
</StyledButton> </StyledButton>

View File

@ -1,16 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import ButtonBar from './ButtonBar'; import ButtonBar from './ButtonBar';
import Button, { ButtonLevel, ButtonSize } from '../Button/Button'; import Button, { ButtonLevel } from '../Button/Button';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import bridge from '../../services/bridge'; import bridge from '../../services/bridge';
import Setting, { AppType, SettingItemSubType, SyncStartupOperation } from '@joplin/lib/models/Setting'; import Setting, { AppType, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting';
import control_PluginsStates from './controls/plugins/PluginsStates';
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen'; import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
const { themeStyle } = require('@joplin/lib/theme'); import { themeStyle } from '@joplin/lib/theme';
import * as pathUtils from '@joplin/lib/path-utils';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import * as shared from '@joplin/lib/components/shared/config/config-shared.js'; import * as shared from '@joplin/lib/components/shared/config/config-shared.js';
import ClipperConfigScreen from '../ClipperConfigScreen'; import ClipperConfigScreen from '../ClipperConfigScreen';
@ -20,12 +18,8 @@ import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButto
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning'; import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink'; import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink';
const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen'); const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen');
import FontSearch from './FontSearch'; import SettingComponent, { UpdateSettingValueEvent } from './controls/SettingComponent';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const settingKeyToControl: any = {
'plugins.states': control_PluginsStates,
};
interface Font { interface Font {
family: string; family: string;
@ -67,9 +61,6 @@ class ConfigScreenComponent extends React.Component<any, any> {
this.onCancelClick = this.onCancelClick.bind(this); this.onCancelClick = this.onCancelClick.bind(this);
this.onSaveClick = this.onSaveClick.bind(this); this.onSaveClick = this.onSaveClick.bind(this);
this.onApplyClick = this.onApplyClick.bind(this); this.onApplyClick = this.onApplyClick.bind(this);
this.renderLabel = this.renderLabel.bind(this);
this.renderDescription = this.renderDescription.bind(this);
this.renderHeader = this.renderHeader.bind(this);
this.handleSettingButton = this.handleSettingButton.bind(this); this.handleSettingButton = this.handleSettingButton.bind(this);
} }
@ -237,7 +228,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
if (syncTargetMd.supportsConfigCheck) { if (syncTargetMd.supportsConfigCheck) {
const messages = shared.checkSyncConfigMessages(this); const messages = shared.checkSyncConfigMessages(this);
const statusComp = !messages.length ? null : ( const statusComp = !messages.length ? null : (
<div style={statusStyle}> <div style={statusStyle} aria-live='polite'>
{messages[0]} {messages[0]}
{messages.length >= 1 ? <p>{messages[1]}</p> : null} {messages.length >= 1 ? <p>{messages[1]}</p> : null}
</div> </div>
@ -277,12 +268,14 @@ class ConfigScreenComponent extends React.Component<any, any> {
let advancedSettingsButton = null; let advancedSettingsButton = null;
const advancedSettingsSectionStyle = { display: 'none' }; const advancedSettingsSectionStyle = { display: 'none' };
const advancedSettingsGroupId = `advanced_settings_${key}`;
if (advancedSettingComps.length) { if (advancedSettingComps.length) {
advancedSettingsButton = ( advancedSettingsButton = (
<ToggleAdvancedSettingsButton <ToggleAdvancedSettingsButton
onClick={() => shared.advancedSettingsButton_click(this)} onClick={() => shared.advancedSettingsButton_click(this)}
advancedSettingsVisible={this.state.showAdvancedSettings} advancedSettingsVisible={this.state.showAdvancedSettings}
aria-controls={advancedSettingsGroupId}
/> />
); );
advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none'; advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none';
@ -293,425 +286,35 @@ class ConfigScreenComponent extends React.Component<any, any> {
{this.renderSectionDescription(section)} {this.renderSectionDescription(section)}
<div>{settingComps}</div> <div>{settingComps}</div>
{advancedSettingsButton} {advancedSettingsButton}
<div style={advancedSettingsSectionStyle}>{advancedSettingComps}</div> <div
style={advancedSettingsSectionStyle}
id={advancedSettingsGroupId}
role='group'
>{advancedSettingComps}</div>
</div> </div>
); );
} }
private labelStyle(themeId: number) { private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent) => {
const theme = themeStyle(themeId);
return { ...theme.textStyle, display: 'block',
color: theme.color,
fontSize: theme.fontSize * 1.083333,
fontWeight: 500,
marginBottom: theme.mainPadding / 2 };
}
private descriptionStyle(themeId: number) {
const theme = themeStyle(themeId);
return { ...theme.textStyle, color: theme.colorFaded,
fontStyle: 'italic',
maxWidth: '70em',
marginTop: 5 };
}
private renderLabel(themeId: number, label: string) {
const labelStyle = this.labelStyle(themeId);
return (
<div style={labelStyle}>
<label>{label}</label>
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private renderHeader(themeId: number, label: string, style: any = null) {
const theme = themeStyle(themeId);
const labelStyle = { ...theme.textStyle, display: 'block',
color: theme.color,
fontSize: theme.fontSize * 1.25,
fontWeight: 500,
marginBottom: theme.mainPadding,
...style };
return (
<div style={labelStyle}>
<label>{label}</label>
</div>
);
}
private renderDescription(themeId: number, description: string) {
return description ? <div style={this.descriptionStyle(themeId)}>{description}</div> : null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public settingToComponent(key: string, value: any) {
const theme = themeStyle(this.props.themeId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const output: any = null;
const rowStyle = {
marginBottom: theme.mainPadding * 1.5,
};
const labelStyle = this.labelStyle(this.props.themeId);
const subLabel = { ...labelStyle, display: 'block',
opacity: 0.7,
marginBottom: labelStyle.marginBottom };
const checkboxLabelStyle = { ...labelStyle, marginLeft: 8,
display: 'inline',
backgroundColor: 'transparent' };
const controlStyle = {
display: 'inline-block',
color: theme.color,
fontFamily: theme.fontFamily,
backgroundColor: theme.backgroundColor,
};
const textInputBaseStyle = { ...controlStyle, fontFamily: theme.fontFamily,
border: '1px solid',
padding: '4px 6px',
boxSizing: 'border-box',
borderColor: theme.borderColor4,
borderRadius: 3,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const updateSettingValue = (key: string, value: any) => {
const md = Setting.settingMetadata(key);
if (md.needRestart) {
this.setState({ needRestart: true });
}
shared.updateSettingValue(this, key, value);
};
const md = Setting.settingMetadata(key); const md = Setting.settingMetadata(key);
if (md.needRestart) {
const descriptionText = Setting.keyDescription(key, AppType.Desktop); this.setState({ needRestart: true });
const descriptionComp = this.renderDescription(this.props.themeId, descriptionText);
if (settingKeyToControl[key]) {
const SettingComponent = settingKeyToControl[key];
const label = md.label ? this.renderLabel(this.props.themeId, md.label()) : null;
return (
<div key={key} style={rowStyle}>
{label}
{this.renderDescription(this.props.themeId, md.description ? md.description(AppType.Desktop) : null)}
<SettingComponent
metadata={md}
value={value}
themeId={this.props.themeId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
updateSettingValue(key, event.value);
}}
renderLabel={this.renderLabel}
renderDescription={this.renderDescription}
renderHeader={this.renderHeader}
/>
</div>
);
} else if (md.isEnum) {
const items = [];
const settingOptions = md.options();
const array = Setting.enumOptionsToValueLabels(settingOptions, md.optionsOrder ? md.optionsOrder() : [], {
valueKey: 'key',
labelKey: 'label',
});
for (let i = 0; i < array.length; i++) {
const e = array[i];
items.push(
<option value={e.key.toString()} key={e.key}>
{settingOptions[e.key]}
</option>,
);
}
const selectStyle = { ...controlStyle, paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
borderColor: theme.borderColor4,
borderRadius: 3 };
return (
<div key={key} style={rowStyle}>
<div style={labelStyle}>
<label>{md.label()}</label>
</div>
<select
value={value}
style={selectStyle}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
updateSettingValue(key, event.target.value);
}}
>
{items}
</select>
{descriptionComp}
</div>
);
} else if (md.type === Setting.TYPE_BOOL) {
const onCheckboxClick = () => {
updateSettingValue(key, !value);
};
const checkboxSize = theme.fontSize * 1.1666666666666;
// Hack: The {key+value.toString()} is needed as otherwise the checkbox doesn't update when the state changes.
// There's probably a better way to do this but can't figure it out.
return (
<div key={key + (`${value}`).toString()} style={rowStyle}>
<div style={{ ...controlStyle, backgroundColor: 'transparent', display: 'flex', alignItems: 'center' }}>
<input
id={`setting_checkbox_${key}`}
type="checkbox"
checked={!!value}
onChange={() => {
onCheckboxClick();
}}
style={{ marginLeft: 0, width: checkboxSize, height: checkboxSize }}
/>
<label
onClick={() => {
onCheckboxClick();
}}
style={{ ...checkboxLabelStyle, marginLeft: 5, marginBottom: 0 }}
htmlFor={`setting_checkbox_${key}`}
>
{md.label()}
</label>
</div>
{descriptionComp}
</div>
);
} else if (md.type === Setting.TYPE_STRING) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const inputStyle: any = { ...textInputBaseStyle, width: '50%',
minWidth: '20em' };
const inputType = md.secure === true ? 'password' : 'text';
if (md.subType === 'file_path_and_args' || md.subType === 'file_path' || md.subType === 'directory_path') {
inputStyle.marginBottom = subLabel.marginBottom;
const splitCmd = (cmdString: string) => {
// Normally not necessary but certain plugins found a way to
// set the set the value to "undefined", leading to a crash.
// This is now fixed at the model level but to be sure we
// check here too, to handle any already existing data.
// https://github.com/laurent22/joplin/issues/7621
if (!cmdString) cmdString = '';
const path = pathUtils.extractExecutablePath(cmdString);
const args = cmdString.substr(path.length + 1);
return [pathUtils.unquotePath(path), args];
};
const joinCmd = (cmdArray: string[]) => {
if (!cmdArray[0] && !cmdArray[1]) return '';
let cmdString = pathUtils.quotePath(cmdArray[0]);
if (!cmdString) cmdString = '""';
if (cmdArray[1]) cmdString += ` ${cmdArray[1]}`;
return cmdString;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onPathChange = (event: any) => {
if (md.subType === 'file_path_and_args') {
const cmd = splitCmd(this.state.settings[key]);
cmd[0] = event.target.value;
updateSettingValue(key, joinCmd(cmd));
} else {
updateSettingValue(key, event.target.value);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onArgsChange = (event: any) => {
const cmd = splitCmd(this.state.settings[key]);
cmd[1] = event.target.value;
updateSettingValue(key, joinCmd(cmd));
};
const browseButtonClick = async () => {
if (md.subType === 'directory_path') {
const paths = await bridge().showOpenDialog({
properties: ['openDirectory'],
});
if (!paths || !paths.length) return;
updateSettingValue(key, paths[0]);
} else {
const paths = await bridge().showOpenDialog();
if (!paths || !paths.length) return;
if (md.subType === 'file_path') {
updateSettingValue(key, paths[0]);
} else {
const cmd = splitCmd(this.state.settings[key]);
cmd[0] = paths[0];
updateSettingValue(key, joinCmd(cmd));
}
}
};
const cmd = splitCmd(this.state.settings[key]);
const path = md.subType === 'file_path_and_args' ? cmd[0] : this.state.settings[key];
const argComp = md.subType !== 'file_path_and_args' ? null : (
<div style={{ ...rowStyle, marginBottom: 5 }}>
<div style={subLabel}>{_('Arguments:')}</div>
<input
type={inputType}
style={inputStyle}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
onArgsChange(event);
}}
value={cmd[1]}
spellCheck={false}
/>
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
);
return (
<div key={key} style={rowStyle}>
<div style={labelStyle}>
<label>{md.label()}</label>
</div>
<div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
<div style={{ ...rowStyle, marginBottom: 5 }}>
<div style={subLabel}>{_('Path:')}</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', marginBottom: inputStyle.marginBottom }}>
<input
type={inputType}
style={{ ...inputStyle, marginBottom: 0, marginRight: 5 }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
onPathChange(event);
}}
value={path}
spellCheck={false}
/>
<Button
level={ButtonLevel.Secondary}
title={_('Browse...')}
onClick={browseButtonClick}
size={ButtonSize.Small}
/>
</div>
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
</div>
</div>
{argComp}
</div>
);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onTextChange = (event: any) => {
updateSettingValue(key, event.target.value);
};
return (
<div key={key} style={rowStyle}>
<div style={labelStyle}>
<label>{md.label()}</label>
</div>
{
md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ?
<FontSearch
type={inputType}
style={inputStyle}
value={this.state.settings[key]}
availableFonts={this.state.fonts}
onChange={fontFamily => updateSettingValue(key, fontFamily)}
subtype={md.subType}
/> :
<input
type={inputType}
style={inputStyle}
value={this.state.settings[key]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
onTextChange(event);
}}
spellCheck={false}
/>
}
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
);
}
} else if (md.type === Setting.TYPE_INT) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const onNumChange = (event: any) => {
updateSettingValue(key, event.target.value);
};
const label = [md.label()];
if (md.unitLabel) label.push(`(${md.unitLabel(md.value)})`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const inputStyle: any = { ...textInputBaseStyle };
return (
<div key={key} style={rowStyle}>
<div style={labelStyle}>
<label>{label.join(' ')}</label>
</div>
<input
type="number"
style={inputStyle}
value={this.state.settings[key]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
onNumChange(event);
}}
min={md.minimum}
max={md.maximum}
step={md.step}
spellCheck={false}
/>
{descriptionComp}
</div>
);
} else if (md.type === Setting.TYPE_BUTTON) {
const labelComp = md.hideLabel ? null : (
<div style={labelStyle}>
<label>{md.label()}</label>
</div>
);
return (
<div key={key} style={rowStyle}>
{labelComp}
<Button level={ButtonLevel.Secondary} title={md.label()} onClick={md.onClick ? md.onClick : () => this.handleSettingButton(key)}/>
{descriptionComp}
</div>
);
} else {
console.warn(`Type not implemented: ${key}`);
} }
shared.updateSettingValue(this, key, value);
};
return output; public settingToComponent<T extends string>(key: T, value: SettingValueType<T>) {
return (
<SettingComponent
themeId={this.props.themeId}
key={key}
settingKey={key}
value={value}
fonts={this.state.fonts}
onUpdateSettingValue={this.onUpdateSettingValue}
onSettingButtonClick={this.handleSettingButton}
/>
);
} }
private restartMessage() { private restartMessage() {
@ -768,7 +371,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
const settings = this.state.settings; const settings = this.state.settings;
const containerStyle = { const containerStyle: React.CSSProperties = {
overflow: 'auto', overflow: 'auto',
padding: theme.configScreenPadding, padding: theme.configScreenPadding,
paddingTop: 0, paddingTop: 0,
@ -800,6 +403,35 @@ class ConfigScreenComponent extends React.Component<any, any> {
const rightStyle = { ...style, flex: 1 }; const rightStyle = { ...style, flex: 1 };
delete style.width; 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 ( return (
<div className="config-screen" style={{ display: 'flex', flexDirection: 'row', height: this.props.style.height }}> <div className="config-screen" style={{ display: 'flex', flexDirection: 'row', height: this.props.style.height }}>
<Sidebar <Sidebar
@ -808,9 +440,8 @@ class ConfigScreenComponent extends React.Component<any, any> {
sections={sections} sections={sections}
/> />
<div style={rightStyle}> <div style={rightStyle}>
{screenComp}
{needRestartComp} {needRestartComp}
<div style={containerStyle}>{settingComps}</div> {tabComponents}
<ButtonBar <ButtonBar
hasChanges={hasChanges} hasChanges={hasChanges}
backButtonTitle={hasChanges && !screenComp ? _('Cancel') : _('Back')} backButtonTitle={hasChanges && !screenComp ? _('Cancel') : _('Back')}

View File

@ -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 * as React from 'react';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { useCallback, useRef } from 'react';
import { focus } from '@joplin/lib/utils/focusHandler';
const styled = require('styled-components').default; const styled = require('styled-components').default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
type StyleProps = any; type StyleProps = any;
interface SectionChangeEvent {
section: SettingMetadataSection;
}
interface Props { interface Props {
selection: string; selection: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied onSelectionChange: (event: SectionChangeEvent)=> void;
onSelectionChange: Function; sections: MetadataBySection;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
sections: any[];
} }
export const StyledRoot = styled.div` export const StyledRoot = styled.div`
@ -73,24 +77,63 @@ export const StyledListItemIcon = styled.i`
`; `;
export default function Sidebar(props: Props) { export default function Sidebar(props: Props) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied; const buttonRefs = useRef<HTMLElement[]>([]);
const buttons: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied; // Making a tabbed region accessible involves supporting keyboard interaction.
function renderButton(section: any) { // 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;
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; const selected = props.selection === section.name;
return ( return (
<StyledListItem <StyledListItem
key={section.name} key={section.name}
href='#' href='#'
role='tab' role='tab'
ref={(item: HTMLElement) => { buttonRefs.current[index] = item; }}
id={`setting-tab-${section.name}`}
aria-controls={`setting-section-${section.name}`}
aria-selected={selected} aria-selected={selected}
tabIndex={selected ? 0 : -1}
isSubSection={Setting.isSubSection(section.name)} isSubSection={Setting.isSubSection(section.name)}
selected={selected} selected={selected}
onClick={() => { props.onSelectionChange({ section: section }); }} onClick={() => { props.onSelectionChange({ section: section }); }}
onKeyDown={onKeyDown}
> >
<StyledListItemIcon <StyledListItemIcon
aria-label=''
className={Setting.sectionNameToIcon(section.name, AppType.Desktop)} className={Setting.sectionNameToIcon(section.name, AppType.Desktop)}
role='img'
/> />
<StyledListItemLabel> <StyledListItemLabel>
{Setting.sectionNameToLabel(section.name)} {Setting.sectionNameToLabel(section.name)}
@ -109,13 +152,15 @@ export default function Sidebar(props: Props) {
let pluginDividerAdded = false; let pluginDividerAdded = false;
let index = 0;
for (const section of props.sections) { for (const section of props.sections) {
if (section.source === SettingSectionSource.Plugin && !pluginDividerAdded) { if (section.source === SettingSectionSource.Plugin && !pluginDividerAdded) {
buttons.push(renderDivider('divider-plugins')); buttons.push(renderDivider('divider-plugins'));
pluginDividerAdded = true; pluginDividerAdded = true;
} }
buttons.push(renderButton(section)); buttons.push(renderButton(section, index));
index ++;
} }
return ( return (

View File

@ -9,6 +9,7 @@ interface Props {
style: CSSProperties; style: CSSProperties;
value: string; value: string;
availableFonts: string[]; availableFonts: string[];
inputId: string;
onChange: (font: string)=> void; onChange: (font: string)=> void;
subtype: string; subtype: string;
} }
@ -108,6 +109,7 @@ const FontSearch = (props: Props) => {
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
spellCheck={false} spellCheck={false}
id={props.inputId}
ref={fontInputRef} ref={fontInputRef}
/> />
<div <div

View File

@ -0,0 +1,381 @@
import Setting, { AppType, SettingItemSubType } from '@joplin/lib/models/Setting';
import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useCallback, useId } from 'react';
import control_PluginsStates from './plugins/PluginsStates';
import bridge from '../../../services/bridge';
import { _ } from '@joplin/lib/locale';
import Button, { ButtonLevel, ButtonSize } from '../../Button/Button';
import FontSearch from './FontSearch';
import * as pathUtils from '@joplin/lib/path-utils';
import SettingLabel from './SettingLabel';
import SettingDescription from './SettingDescription';
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
'plugins.states': control_PluginsStates,
};
export interface UpdateSettingValueEvent {
key: string;
value: unknown;
}
interface Props {
themeId: number;
settingKey: string;
value: unknown;
fonts: string[];
onUpdateSettingValue: (event: UpdateSettingValueEvent)=> void;
onSettingButtonClick: (key: string)=> void;
}
const SettingComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const output: React.ReactNode = null;
const updateSettingValue = useCallback((key: string, value: unknown) => {
props.onUpdateSettingValue({ key, value });
}, [props.onUpdateSettingValue]);
const rowStyle = {
marginBottom: theme.mainPadding * 1.5,
};
const controlStyle = {
display: 'inline-block',
color: theme.color,
fontFamily: theme.fontFamily,
backgroundColor: theme.backgroundColor,
};
const textInputBaseStyle: React.CSSProperties = {
...controlStyle,
fontFamily: theme.fontFamily,
border: '1px solid',
padding: '4px 6px',
boxSizing: 'border-box',
borderColor: theme.borderColor4,
borderRadius: 3,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
};
const key = props.settingKey;
const md = Setting.settingMetadata(key);
const descriptionText = Setting.keyDescription(key, AppType.Desktop);
const inputId = useId();
const descriptionId = useId();
const descriptionComp = <SettingDescription id={descriptionId} text={descriptionText}/>;
if (key in settingKeyToControl) {
const CustomSettingComponent = settingKeyToControl[key];
const label = md.label ? <SettingLabel text={md.label()} htmlFor={null} /> : null;
return (
<div style={rowStyle}>
{label}
<SettingDescription id={descriptionId} text={md.description ? md.description(AppType.Desktop) : null}/>
<CustomSettingComponent
value={props.value}
themeId={props.themeId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onChange={(event: any) => {
updateSettingValue(key, event.value);
}}
/>
</div>
);
} else if (md.isEnum) {
const value = props.value as string;
const items = [];
const settingOptions = md.options();
const array = Setting.enumOptionsToValueLabels(settingOptions, md.optionsOrder ? md.optionsOrder() : [], {
valueKey: 'key',
labelKey: 'label',
});
for (let i = 0; i < array.length; i++) {
const e = array[i];
items.push(
<option value={e.key.toString()} key={e.key}>
{settingOptions[e.key]}
</option>,
);
}
const selectStyle = { ...controlStyle, paddingLeft: 6,
paddingRight: 6,
paddingTop: 4,
paddingBottom: 4,
borderColor: theme.borderColor4,
borderRadius: 3 };
return (
<div style={rowStyle}>
<SettingLabel htmlFor={inputId} text={md.label()}/>
<select
value={value}
style={selectStyle}
onChange={(event) => {
updateSettingValue(key, event.target.value);
}}
id={inputId}
aria-describedby={descriptionId}
>
{items}
</select>
{descriptionComp}
</div>
);
} else if (md.type === Setting.TYPE_BOOL) {
const value = props.value as boolean;
const checkboxSize = theme.fontSize * 1.1666666666666;
return (
<div style={rowStyle}>
<div style={{ ...controlStyle, backgroundColor: 'transparent', display: 'flex', alignItems: 'center' }}>
<input
id={inputId}
type="checkbox"
checked={!!value}
onChange={event => updateSettingValue(key, event.target.checked)}
style={{ marginLeft: 0, width: checkboxSize, height: checkboxSize }}
// Prefer aria-details to aria-describedby for checkbox inputs --
// on MacOS, VoiceOver reads "checked"/"unchecked" only after reading the
// potentially-lengthy description. For other input types, the input value
// is read first.
aria-details={descriptionId}
/>
<label
className='setting-label -for-checkbox'
htmlFor={inputId}
>
{md.label()}
</label>
</div>
{descriptionComp}
</div>
);
} else if (md.type === Setting.TYPE_STRING) {
const value = props.value as string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const inputStyle: any = { ...textInputBaseStyle, width: '50%',
minWidth: '20em' };
const inputType = md.secure === true ? 'password' : 'text';
if (md.subType === 'file_path_and_args' || md.subType === 'file_path' || md.subType === 'directory_path') {
inputStyle.marginBottom = theme.mainPadding / 2;
const splitCmd = (cmdString: string) => {
// Normally not necessary but certain plugins found a way to
// set the set the value to "undefined", leading to a crash.
// This is now fixed at the model level but to be sure we
// check here too, to handle any already existing data.
// https://github.com/laurent22/joplin/issues/7621
if (!cmdString) cmdString = '';
const path = pathUtils.extractExecutablePath(cmdString);
const args = cmdString.substr(path.length + 1);
return [pathUtils.unquotePath(path), args];
};
const joinCmd = (cmdArray: string[]) => {
if (!cmdArray[0] && !cmdArray[1]) return '';
let cmdString = pathUtils.quotePath(cmdArray[0]);
if (!cmdString) cmdString = '""';
if (cmdArray[1]) cmdString += ` ${cmdArray[1]}`;
return cmdString;
};
const onPathChange: React.ChangeEventHandler<HTMLInputElement> = event => {
if (md.subType === 'file_path_and_args') {
const cmd = splitCmd(value);
cmd[0] = event.target.value;
updateSettingValue(key, joinCmd(cmd));
} else {
updateSettingValue(key, event.target.value);
}
};
const onArgsChange: React.ChangeEventHandler<HTMLInputElement> = event => {
const cmd = splitCmd(value);
cmd[1] = event.target.value;
updateSettingValue(key, joinCmd(cmd));
};
const browseButtonClick = async () => {
if (md.subType === 'directory_path') {
const paths = await bridge().showOpenDialog({
properties: ['openDirectory'],
});
if (!paths || !paths.length) return;
updateSettingValue(key, paths[0]);
} else {
const paths = await bridge().showOpenDialog();
if (!paths || !paths.length) return;
if (md.subType === 'file_path') {
updateSettingValue(key, paths[0]);
} else {
const cmd = splitCmd(value);
cmd[0] = paths[0];
updateSettingValue(key, joinCmd(cmd));
}
}
};
const cmd = splitCmd(value);
const path = md.subType === 'file_path_and_args' ? cmd[0] : value;
const argInputId = `setting_path_arg_${key}`;
const argComp = md.subType !== 'file_path_and_args' ? null : (
<div style={{ ...rowStyle, marginBottom: 5 }}>
<label
className='setting-label -sub-label'
htmlFor={argInputId}
>{_('Arguments:')}</label>
<input
type={inputType}
style={inputStyle}
onChange={onArgsChange}
value={cmd[1]}
spellCheck={false}
id={argInputId}
aria-describedby={descriptionId}
/>
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
);
const pathDescriptionId = `setting_path_label_${key}`;
return (
<div style={rowStyle}>
<SettingLabel text={md.label()} htmlFor={inputId}/>
<div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
<div style={{ ...rowStyle, marginBottom: 5 }}>
<div
className='setting-label -sub-label'
id={pathDescriptionId}
>{_('Path:')}</div>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', marginBottom: inputStyle.marginBottom }}>
<input
type={inputType}
style={{ ...inputStyle, marginBottom: 0, marginRight: 5 }}
onChange={onPathChange}
value={path}
spellCheck={false}
id={inputId}
aria-describedby={pathDescriptionId}
aria-details={descriptionId}
/>
<Button
level={ButtonLevel.Secondary}
title={_('Browse...')}
onClick={browseButtonClick}
size={ButtonSize.Small}
/>
</div>
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
</div>
</div>
{argComp}
</div>
);
} else {
const onTextChange: React.ChangeEventHandler<HTMLInputElement> = event => {
updateSettingValue(key, event.target.value);
};
return (
<div style={rowStyle}>
<SettingLabel text={md.label()} htmlFor={inputId}/>
{
md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ?
<FontSearch
type={inputType}
style={inputStyle}
value={props.value as string}
availableFonts={props.fonts}
onChange={fontFamily => updateSettingValue(key, fontFamily)}
subtype={md.subType}
inputId={inputId}
/> :
<input
type={inputType}
style={inputStyle}
value={props.value as string|number}
onChange={onTextChange}
spellCheck={false}
id={inputId}
aria-describedby={descriptionId}
/>
}
<div style={{ width: inputStyle.width, minWidth: inputStyle.minWidth }}>
{descriptionComp}
</div>
</div>
);
}
} else if (md.type === Setting.TYPE_INT) {
const value = props.value as number;
const onNumChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
updateSettingValue(key, event.target.value);
};
const label = [md.label()];
if (md.unitLabel) label.push(`(${md.unitLabel(md.value)})`);
return (
<div style={rowStyle}>
<SettingLabel htmlFor={inputId} text={label.join(' ')}/>
<input
type="number"
style={textInputBaseStyle}
value={value}
onChange={onNumChange}
min={md.minimum}
max={md.maximum}
step={md.step}
spellCheck={false}
id={inputId}
aria-describedby={descriptionId}
/>
{descriptionComp}
</div>
);
} else if (md.type === Setting.TYPE_BUTTON) {
const labelComp = md.hideLabel ? null : (
<SettingLabel text={md.label()} htmlFor={null} />
);
return (
<div style={rowStyle}>
{labelComp}
<Button
level={ButtonLevel.Secondary}
title={md.label()}
onClick={md.onClick ? md.onClick : () => props.onSettingButtonClick(key)}
/>
{descriptionComp}
</div>
);
} else {
console.warn(`Type not implemented: ${key}`);
}
return output;
};
export default SettingComponent;

View File

@ -0,0 +1,12 @@
import * as React from 'react';
interface Props {
text: string;
id?: string;
}
const SettingDescription: React.FC<Props> = props => {
return props.text ? <div className='setting-description' id={props.id}>{props.text}</div> : null;
};
export default SettingDescription;

View File

@ -0,0 +1,15 @@
import * as React from 'react';
interface Props {
text: string;
}
const SettingHeader: React.FC<Props> = props => {
return (
<div className='setting-header'>
<label>{props.text}</label>
</div>
);
};
export default SettingHeader;

View File

@ -0,0 +1,16 @@
import * as React from 'react';
interface Props {
htmlFor: string|null;
text: string;
}
const SettingLabel: React.FC<Props> = props => {
return (
<div className='setting-label'>
<label htmlFor={props.htmlFor}>{props.text}</label>
</div>
);
};
export default SettingLabel;

View File

@ -6,6 +6,7 @@ import { _ } from '@joplin/lib/locale';
interface Props { interface Props {
onClick: ()=> void; onClick: ()=> void;
advancedSettingsVisible: boolean; advancedSettingsVisible: boolean;
'aria-controls': string;
} }
const ToggleAdvancedSettingsButton: React.FunctionComponent<Props> = props => { const ToggleAdvancedSettingsButton: React.FunctionComponent<Props> = props => {
@ -16,6 +17,10 @@ const ToggleAdvancedSettingsButton: React.FunctionComponent<Props> = props => {
level={ButtonLevel.Secondary} level={ButtonLevel.Secondary}
onClick={props.onClick} onClick={props.onClick}
iconName={iconName} iconName={iconName}
aria-controls={props['aria-controls']}
aria-expanded={props.advancedSettingsVisible}
title={_('Show Advanced Settings')} title={_('Show Advanced Settings')}
/> />
</div> </div>

View File

@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useId, useMemo } from 'react';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import styled from 'styled-components'; import styled from 'styled-components';
import ToggleButton from '../../../lib/ToggleButton/ToggleButton'; import ToggleButton from '../../../lib/ToggleButton/ToggleButton';
@ -173,6 +173,7 @@ export default function(props: Props) {
themeId={props.themeId} themeId={props.themeId}
value={item.enabled} value={item.enabled}
onToggle={() => props.onToggle({ item })} onToggle={() => props.onToggle({ item })}
aria-label={_('Enabled')}
/>; />;
} }
@ -256,10 +257,17 @@ export default function(props: Props) {
return <RecommendedBadge href="#" title={_('The Joplin team has vetted this plugin and it meets our standards for security and performance.')} onClick={onRecommendedClick}><i className="fas fa-crown"></i></RecommendedBadge>; return <RecommendedBadge href="#" title={_('The Joplin team has vetted this plugin and it meets our standards for security and performance.')} onClick={onRecommendedClick}><i className="fas fa-crown"></i></RecommendedBadge>;
} }
const nameLabelId = useId();
return ( return (
<CellRoot isCompatible={props.isCompatible}> <CellRoot isCompatible={props.isCompatible} role='group' aria-labelledby={nameLabelId}>
<CellTop> <CellTop>
<StyledNameAndVersion mb={'5px'}><StyledName onClick={onNameClick} href="#" style={{ marginRight: 5 }}>{item.manifest.name} {item.deleted ? _('(%s)', 'Deleted') : ''}</StyledName><StyledVersion>v{item.manifest.version}</StyledVersion></StyledNameAndVersion> <StyledNameAndVersion mb={'5px'}>
<StyledName onClick={onNameClick} href="#" style={{ marginRight: 5 }} id={nameLabelId}>
{item.manifest.name} {item.deleted ? _('(%s)', 'Deleted') : ''}
</StyledName>
<StyledVersion>v{item.manifest.version}</StyledVersion>
</StyledNameAndVersion>
{renderToggleButton()} {renderToggleButton()}
{renderRecommendedBadge()} {renderRecommendedBadge()}
</CellTop> </CellTop>

View File

@ -17,6 +17,8 @@ import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/use
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import StyledMessage from '../../../style/StyledMessage'; import StyledMessage from '../../../style/StyledMessage';
import StyledLink from '../../../style/StyledLink'; import StyledLink from '../../../style/StyledLink';
import SettingHeader from '../SettingHeader';
import SettingDescription from '../SettingDescription';
const { space } = require('styled-system'); const { space } = require('styled-system');
const logger = Logger.create('PluginState'); const logger = Logger.create('PluginState');
@ -51,12 +53,6 @@ interface Props {
themeId: number; themeId: number;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onChange: Function; onChange: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
renderLabel: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
renderDescription: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
renderHeader: Function;
} }
let repoApi_: RepositoryApi = null; let repoApi_: RepositoryApi = null;
@ -281,7 +277,7 @@ export default function(props: Props) {
if (!pluginItems.length || allDeleted) { if (!pluginItems.length || allDeleted) {
return ( return (
<UserPluginsRoot mb={'10px'}> <UserPluginsRoot mb={'10px'}>
{props.renderDescription(props.themeId, _('You do not have any installed plugin.'))} <SettingDescription text={_('You do not have any installed plugin.')}/>
</UserPluginsRoot> </UserPluginsRoot>
); );
} else { } else {
@ -311,7 +307,6 @@ export default function(props: Props) {
pluginSettings={pluginSettings} pluginSettings={pluginSettings}
onSearchQueryChange={onSearchQueryChange} onSearchQueryChange={onSearchQueryChange}
onPluginSettingsChange={onSearchPluginSettingsChange} onPluginSettingsChange={onSearchPluginSettingsChange}
renderDescription={props.renderDescription}
repoApi={repoApi} repoApi={repoApi}
/> />
</div> </div>
@ -333,7 +328,7 @@ export default function(props: Props) {
<div style={{ display: 'flex', flexDirection: 'row', maxWidth }}> <div style={{ display: 'flex', flexDirection: 'row', maxWidth }}>
<ToolsButton size={ButtonSize.Small} tooltip={_('Plugin tools')} iconName="fas fa-cog" level={ButtonLevel.Secondary} onClick={onToolsClick}/> <ToolsButton size={ButtonSize.Small} tooltip={_('Plugin tools')} iconName="fas fa-cog" level={ButtonLevel.Secondary} onClick={onToolsClick}/>
<div style={{ display: 'flex', flex: 1 }}> <div style={{ display: 'flex', flex: 1 }}>
{props.renderHeader(props.themeId, _('Manage your plugins'))} <SettingHeader text={_('Manage your plugins')}/>
</div> </div>
</div> </div>
{renderUserPlugins(pluginItems)} {renderUserPlugins(pluginItems)}

View File

@ -10,6 +10,7 @@ import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/Plug
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler';
import { themeStyle } from '@joplin/lib/theme'; import { themeStyle } from '@joplin/lib/theme';
import SettingDescription from '../SettingDescription';
const Root = styled.div` const Root = styled.div`
`; `;
@ -26,8 +27,6 @@ interface Props {
pluginSettings: PluginSettings; pluginSettings: PluginSettings;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onPluginSettingsChange(event: any): void; onPluginSettingsChange(event: any): void;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
renderDescription: Function;
maxWidth: number; maxWidth: number;
repoApi(): RepositoryApi; repoApi(): RepositoryApi;
disabled: boolean; disabled: boolean;
@ -81,7 +80,7 @@ export default function(props: Props) {
function renderResults(query: string, manifests: PluginManifest[]) { function renderResults(query: string, manifests: PluginManifest[]) {
if (query && !manifests.length) { if (query && !manifests.length) {
if (searchResultCount === null) return ''; // Search in progress if (searchResultCount === null) return ''; // Search in progress
return props.renderDescription(props.themeId, _('No results')); return <SettingDescription text={_('No results')}/>;
} else { } else {
const output = []; const output = [];

View File

@ -1,3 +1,5 @@
@use "./styles/index.scss";
.config-screen-content-wrapper { .config-screen-content-wrapper {
padding: 24px; padding: 24px;
overflow: auto; overflow: auto;

View File

@ -0,0 +1,5 @@
@use "./setting-description.scss";
@use "./setting-label.scss";
@use "./setting-header.scss";
@use "./setting-tab-panel.scss";

View File

@ -0,0 +1,9 @@
.setting-description {
color: var(--joplin-color-faded);
font-size: var(--joplin-font-size);
line-height: var(--joplin-line-height);
font-style: italic;
max-width: 70em;
margin-top: 5px;
}

View File

@ -0,0 +1,9 @@
.setting-header {
display: block;
color: var(--joplin-color);
font-size: calc(var(--joplin-font-size) * 1.25);
font-weight: 500;
margin-bottom: var(--joplin-main-padding);
line-height: var(--joplin-line-height);
}

View File

@ -0,0 +1,20 @@
.setting-label {
display: block;
color: var(--joplin-color);
font-size: calc(var(--joplin-font-size) * 1.083333);
font-weight: 500;
margin-bottom: calc(var(--joplin-main-padding) / 2);
line-height: var(--joplin-line-height);
&.-sub-label {
opacity: 0.7;
}
&.-for-checkbox {
margin-left: 5px;
margin-bottom: 0;
display: inline;
background-color: transparent;
}
}

View File

@ -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;
}
}

View File

@ -10,7 +10,7 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils'; import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils'; import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
import Button, { ButtonLevel } from '../Button/Button'; import Button, { ButtonLevel } from '../Button/Button';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useId, useMemo, useState } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import Setting from '@joplin/lib/models/Setting'; import Setting from '@joplin/lib/models/Setting';
@ -350,7 +350,7 @@ const EncryptionConfigScreen = (props: Props) => {
t = `<p>${t}</p>`; t = `<p>${t}</p>`;
return ( return (
<div> <>
<h2>{_('Re-encryption')}</h2> <h2>{_('Re-encryption')}</h2>
<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p> <p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p>
<span style={{ marginRight: 10 }}> <span style={{ marginRight: 10 }}>
@ -358,7 +358,7 @@ const EncryptionConfigScreen = (props: Props) => {
</span> </span>
{ !props.shouldReencrypt ? null : <button onClick={() => dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> } { !props.shouldReencrypt ? null : <button onClick={() => dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> }
</div> </>
); );
}; };
@ -368,6 +368,7 @@ const EncryptionConfigScreen = (props: Props) => {
setShowAdvanced(!showAdvanced); setShowAdvanced(!showAdvanced);
}, [showAdvanced]); }, [showAdvanced]);
const advancedSettingsId = useId();
const renderAdvancedSection = () => { const renderAdvancedSection = () => {
const reEncryptSection = renderReencryptData(); const reEncryptSection = renderReencryptData();
@ -378,8 +379,12 @@ const EncryptionConfigScreen = (props: Props) => {
<div> <div>
<ToggleAdvancedSettingsButton <ToggleAdvancedSettingsButton
onClick={toggleAdvanced} onClick={toggleAdvanced}
advancedSettingsVisible={showAdvanced}/> advancedSettingsVisible={showAdvanced}
{ showAdvanced ? reEncryptSection : null } aria-controls={advancedSettingsId}
/>
<div id={advancedSettingsId}>
{ showAdvanced ? reEncryptSection : null }
</div>
</div> </div>
); );
}; };

View File

@ -1,5 +1,6 @@
import { themeStyle } from '@joplin/lib/theme'; import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react'; import * as React from 'react';
import { useMemo } from 'react';
const ReactToggleButton = require('react-toggle-button'); const ReactToggleButton = require('react-toggle-button');
const Color = require('color'); const Color = require('color');
@ -8,11 +9,27 @@ interface Props {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onToggle: Function; onToggle: Function;
themeId: number; themeId: number;
'aria-label': string;
} }
export default function(props: Props) { export default function(props: Props) {
const theme = themeStyle(props.themeId); const theme = themeStyle(props.themeId);
const ariaLabel = props['aria-label'];
const passThroughInputProps = useMemo(() => {
return {
'aria-label': ariaLabel,
// Works around a bug in ReactToggleButton -- the hidden checkbox input associated
// with the toggle is always read as "unchecked" by screen readers.
checked: props.value,
// Silences a ReactJS warning: "You provided a `checked` prop to a form field without an `onChange` handler."
// Change events are handled by ReactToggleButton.
onChange: ()=>{},
};
}, [ariaLabel, props.value]);
return ( return (
<ReactToggleButton <ReactToggleButton
value={props.value} value={props.value}
@ -33,6 +50,7 @@ export default function(props: Props) {
}} }}
inactiveLabel="" inactiveLabel=""
activeLabel="" activeLabel=""
passThroughInputProps={passThroughInputProps}
/> />
); );
} }

View File

@ -1,6 +1,5 @@
import { test, expect } from './util/test'; import { test, expect } from './util/test';
import MainScreen from './models/MainScreen'; import MainScreen from './models/MainScreen';
import SettingsScreen from './models/SettingsScreen';
import { _electron as electron } from '@playwright/test'; import { _electron as electron } from '@playwright/test';
import { writeFile } from 'fs-extra'; import { writeFile } from 'fs-extra';
import { join } from 'path'; import { join } from 'path';
@ -130,36 +129,6 @@ test.describe('main', () => {
expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]); expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]);
}); });
test('should be possible to remove sort order buttons in settings', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
// Sort order buttons should be visible by default
await expect(mainScreen.noteListContainer.locator('[title^="Toggle sort order"]')).toBeVisible();
await mainScreen.openSettings(electronApp);
// Should be on the settings screen
const settingsScreen = new SettingsScreen(mainWindow);
await settingsScreen.waitFor();
// Open the appearance tab
await settingsScreen.appearanceTabButton.click();
// Find the sort order visible checkbox
const sortOrderVisibleCheckbox = mainWindow.getByLabel(/^Show sort order/);
await expect(sortOrderVisibleCheckbox).toBeChecked();
await sortOrderVisibleCheckbox.click();
await expect(sortOrderVisibleCheckbox).not.toBeChecked();
// Save settings & close
await settingsScreen.okayButton.click();
await mainScreen.waitFor();
await expect(mainScreen.noteListContainer.locator('[title^="Toggle sort order"]')).not.toBeVisible();
});
test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => { test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow); const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor(); await mainScreen.waitFor();

View File

@ -17,7 +17,7 @@ export default class MainScreen {
this.newNoteButton = page.locator('.new-note-button'); this.newNoteButton = page.locator('.new-note-button');
this.noteListContainer = page.locator('.rli-noteList'); this.noteListContainer = page.locator('.rli-noteList');
this.sidebar = new Sidebar(page, this); this.sidebar = new Sidebar(page, this);
this.dialog = page.locator('.dialog-root'); this.dialog = page.locator('.dialog-modal-layer');
this.noteEditor = new NoteEditorScreen(page); this.noteEditor = new NoteEditorScreen(page);
this.goToAnything = new GoToAnything(page, this); this.goToAnything = new GoToAnything(page, this);
} }

View File

@ -11,7 +11,7 @@ export default class SettingsScreen {
} }
public getTabLocator(tabName: string) { public getTabLocator(tabName: string) {
return this.page.locator('a[role="tab"] > span', { hasText: tabName }); return this.page.getByRole('tab', { name: tabName });
} }
public async waitFor() { public async waitFor() {

View File

@ -0,0 +1,96 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
import SettingsScreen from './models/SettingsScreen';
test.describe('settings', () => {
test('should be possible to remove sort order buttons in settings', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
// Sort order buttons should be visible by default
const sortOrderLocator = mainScreen.noteListContainer.locator('[title^="Toggle sort order"]');
await expect(sortOrderLocator).toBeVisible();
await mainScreen.openSettings(electronApp);
// Should be on the settings screen
const settingsScreen = new SettingsScreen(mainWindow);
await settingsScreen.waitFor();
// Open the appearance tab
await settingsScreen.appearanceTabButton.click();
// Find the sort order visible checkbox
const sortOrderVisibleCheckbox = mainWindow.getByLabel(/^Show sort order/);
await expect(sortOrderVisibleCheckbox).toBeChecked();
await sortOrderVisibleCheckbox.click();
await expect(sortOrderVisibleCheckbox).not.toBeChecked();
// Save settings & close
await settingsScreen.okayButton.click();
await mainScreen.waitFor();
await expect(sortOrderLocator).not.toBeVisible();
});
test('clicking the sync wizard button in settings should open a dialog', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
await mainScreen.openSettings(electronApp);
const settingsScreen = new SettingsScreen(mainWindow);
const generalTab = settingsScreen.getTabLocator('Synchronisation');
await generalTab.click();
await expect(mainScreen.dialog).not.toBeVisible();
const syncWizardButton = mainWindow.getByRole('button', { name: 'Open Sync Wizard' });
await syncWizardButton.click();
await expect(mainScreen.dialog).toBeVisible();
});
test('should be possible to navigate settings screen tabs with the arrow keys', async ({ electronApp, mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.waitFor();
await mainScreen.openSettings(electronApp);
const settingsScreen = new SettingsScreen(mainWindow);
await settingsScreen.waitFor();
const generalTab = settingsScreen.getTabLocator('General');
await generalTab.click();
const focusedItem = mainWindow.locator(':focus');
// Up/Down arrows should move to the next and previous items
await expect(focusedItem).toHaveText('General');
await mainWindow.keyboard.press('ArrowDown');
await expect(focusedItem).toHaveText('Application');
await mainWindow.keyboard.press('ArrowUp');
await expect(focusedItem).toHaveText('General');
// Pressing Up when the first item is focused should focus the last item
await mainWindow.keyboard.press('ArrowUp');
await expect(focusedItem).toHaveText('Backup');
await mainWindow.keyboard.press('ArrowDown');
await mainWindow.keyboard.press('ArrowDown');
await expect(focusedItem).toHaveText('Application');
// Pressing Tab should focus the tab container
await mainWindow.keyboard.press('Tab');
await expect(focusedItem).toHaveAttribute('role', 'tabpanel');
// The correct tab should be visible
await expect(mainWindow.getByLabel('Show tray icon')).toBeVisible();
// Shift+Tab should focus the sidebar again
await mainWindow.keyboard.press('Shift+Tab');
await expect(focusedItem).toHaveAttribute('role', 'tab');
await expect(focusedItem).toHaveText('Application');
});
});

View File

@ -143,7 +143,7 @@ a {
*:focus-visible { *: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 // The browser-default focus-visible indicator was originally removed for aesthetic

View File

@ -17,7 +17,7 @@ const logger = Logger.create('models/Setting');
export * from './settings/types'; export * from './settings/types';
type SettingValueType<T extends string> = ( export type SettingValueType<T extends string> = (
T extends BuiltInMetadataKeys T extends BuiltInMetadataKeys
? BuiltInMetadataValues[T] ? BuiltInMetadataValues[T]
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied

View File

@ -110,6 +110,7 @@ export function extraStyles(theme: Theme) {
const bgColor4 = theme.backgroundColor4; const bgColor4 = theme.backgroundColor4;
const borderColor4: string = Color(theme.color).alpha(0.3); const borderColor4: string = Color(theme.color).alpha(0.3);
const iconColor = Color(theme.color).alpha(0.8); const iconColor = Color(theme.color).alpha(0.8);
const focusOutlineColor = theme.colorWarn;
const backgroundColor5 = theme.backgroundColor5 ?? theme.color4; const backgroundColor5 = theme.backgroundColor5 ?? theme.color4;
const backgroundColorHover5 = Color(backgroundColor5).darken(0.2).hex(); const backgroundColorHover5 = Color(backgroundColor5).darken(0.2).hex();
@ -230,6 +231,7 @@ export function extraStyles(theme: Theme) {
backgroundColor5, backgroundColor5,
backgroundColorHover5, backgroundColorHover5,
backgroundColorActive5, backgroundColorActive5,
focusOutlineColor,
icon: { icon: {
...globalStyle.icon, ...globalStyle.icon,