You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Accessibility: Improve settings screen keyboard navigation and screen reader accessibility (#10812)
This commit is contained in:
		| @@ -167,9 +167,13 @@ packages/app-desktop/gui/Button/Button.js | ||||
| packages/app-desktop/gui/ClipperConfigScreen.js | ||||
| packages/app-desktop/gui/ConfigScreen/ButtonBar.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/controls/FontSearch.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/plugins/PluginBox.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/noteList.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/simpleBackup.spec.js | ||||
| packages/app-desktop/integration-tests/util/activateMainMenuItem.js | ||||
|   | ||||
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -146,9 +146,13 @@ packages/app-desktop/gui/Button/Button.js | ||||
| packages/app-desktop/gui/ClipperConfigScreen.js | ||||
| packages/app-desktop/gui/ConfigScreen/ButtonBar.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/controls/FontSearch.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/plugins/PluginBox.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/noteList.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/simpleBackup.spec.js | ||||
| packages/app-desktop/integration-tests/util/activateMainMenuItem.js | ||||
|   | ||||
| @@ -36,6 +36,9 @@ interface Props { | ||||
| 	isSquare?: boolean; | ||||
| 	iconOnly?: boolean; | ||||
| 	fontSize?: number; | ||||
|  | ||||
| 	'aria-controls'?: string; | ||||
| 	'aria-expanded'?: string; | ||||
| } | ||||
|  | ||||
| const StyledTitle = styled.span` | ||||
| @@ -220,7 +223,14 @@ const Button = React.forwardRef((props: Props, ref: any) => { | ||||
|  | ||||
| 	function renderIcon() { | ||||
| 		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() { | ||||
| @@ -234,7 +244,22 @@ const Button = React.forwardRef((props: Props, ref: any) => { | ||||
| 	} | ||||
|  | ||||
| 	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()} | ||||
| 			{renderTitle()} | ||||
| 		</StyledButton> | ||||
|   | ||||
| @@ -1,16 +1,14 @@ | ||||
| import * as React from 'react'; | ||||
| import Sidebar from './Sidebar'; | ||||
| import ButtonBar from './ButtonBar'; | ||||
| import Button, { ButtonLevel, ButtonSize } from '../Button/Button'; | ||||
| import Button, { ButtonLevel } from '../Button/Button'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import bridge from '../../services/bridge'; | ||||
| import Setting, { AppType, SettingItemSubType, SyncStartupOperation } from '@joplin/lib/models/Setting'; | ||||
| import control_PluginsStates from './controls/plugins/PluginsStates'; | ||||
| import Setting, { AppType, SettingValueType, SyncStartupOperation } from '@joplin/lib/models/Setting'; | ||||
| import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen'; | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| const { connect } = require('react-redux'); | ||||
| const { themeStyle } = require('@joplin/lib/theme'); | ||||
| import * as pathUtils from '@joplin/lib/path-utils'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; | ||||
| import * as shared from '@joplin/lib/components/shared/config/config-shared.js'; | ||||
| import ClipperConfigScreen from '../ClipperConfigScreen'; | ||||
| @@ -20,12 +18,8 @@ import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButto | ||||
| import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning'; | ||||
| import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink'; | ||||
| 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 { | ||||
| 	family: string; | ||||
| @@ -67,9 +61,6 @@ class ConfigScreenComponent extends React.Component<any, any> { | ||||
| 		this.onCancelClick = this.onCancelClick.bind(this); | ||||
| 		this.onSaveClick = this.onSaveClick.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); | ||||
| 	} | ||||
|  | ||||
| @@ -237,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> | ||||
| @@ -277,12 +268,14 @@ class ConfigScreenComponent extends React.Component<any, any> { | ||||
|  | ||||
| 		let advancedSettingsButton = null; | ||||
| 		const advancedSettingsSectionStyle = { display: 'none' }; | ||||
| 		const advancedSettingsGroupId = `advanced_settings_${key}`; | ||||
|  | ||||
| 		if (advancedSettingComps.length) { | ||||
| 			advancedSettingsButton = ( | ||||
| 				<ToggleAdvancedSettingsButton | ||||
| 					onClick={() => shared.advancedSettingsButton_click(this)} | ||||
| 					advancedSettingsVisible={this.state.showAdvancedSettings} | ||||
| 					aria-controls={advancedSettingsGroupId} | ||||
| 				/> | ||||
| 			); | ||||
| 			advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none'; | ||||
| @@ -293,425 +286,35 @@ class ConfigScreenComponent extends React.Component<any, any> { | ||||
| 				{this.renderSectionDescription(section)} | ||||
| 				<div>{settingComps}</div> | ||||
| 				{advancedSettingsButton} | ||||
| 				<div style={advancedSettingsSectionStyle}>{advancedSettingComps}</div> | ||||
| 				<div | ||||
| 					style={advancedSettingsSectionStyle} | ||||
| 					id={advancedSettingsGroupId} | ||||
| 					role='group' | ||||
| 				>{advancedSettingComps}</div> | ||||
| 			</div> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
| 	private labelStyle(themeId: number) { | ||||
| 		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); | ||||
| 		}; | ||||
|  | ||||
| 	private onUpdateSettingValue = ({ key, value }: UpdateSettingValueEvent) => { | ||||
| 		const md = Setting.settingMetadata(key); | ||||
|  | ||||
| 		const descriptionText = Setting.keyDescription(key, AppType.Desktop); | ||||
| 		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}`); | ||||
| 		if (md.needRestart) { | ||||
| 			this.setState({ needRestart: true }); | ||||
| 		} | ||||
| 		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() { | ||||
| @@ -768,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, | ||||
| @@ -800,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 | ||||
| @@ -808,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')} | ||||
|   | ||||
| @@ -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` | ||||
| @@ -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[]>([]); | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied; | ||||
| 	function renderButton(section: any) { | ||||
| 	// 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; | ||||
|  | ||||
| 		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)} | ||||
| @@ -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 ( | ||||
|   | ||||
| @@ -9,6 +9,7 @@ interface Props { | ||||
| 	style: CSSProperties; | ||||
| 	value: string; | ||||
| 	availableFonts: string[]; | ||||
| 	inputId: string; | ||||
| 	onChange: (font: string)=> void; | ||||
| 	subtype: string; | ||||
| } | ||||
| @@ -108,6 +109,7 @@ const FontSearch = (props: Props) => { | ||||
| 				onFocus={handleFocus} | ||||
| 				onBlur={handleBlur} | ||||
| 				spellCheck={false} | ||||
| 				id={props.inputId} | ||||
| 				ref={fontInputRef} | ||||
| 			/> | ||||
| 			<div | ||||
| @@ -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; | ||||
| @@ -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; | ||||
| @@ -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; | ||||
| @@ -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; | ||||
| @@ -6,6 +6,7 @@ import { _ } from '@joplin/lib/locale'; | ||||
| interface Props { | ||||
| 	onClick: ()=> void; | ||||
| 	advancedSettingsVisible: boolean; | ||||
| 	'aria-controls': string; | ||||
| } | ||||
|  | ||||
| const ToggleAdvancedSettingsButton: React.FunctionComponent<Props> = props => { | ||||
| @@ -16,6 +17,10 @@ const ToggleAdvancedSettingsButton: React.FunctionComponent<Props> = props => { | ||||
| 				level={ButtonLevel.Secondary} | ||||
| 				onClick={props.onClick} | ||||
| 				iconName={iconName} | ||||
|  | ||||
| 				aria-controls={props['aria-controls']} | ||||
| 				aria-expanded={props.advancedSettingsVisible} | ||||
|  | ||||
| 				title={_('Show Advanced Settings')} | ||||
| 			/> | ||||
| 		</div> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useMemo } from 'react'; | ||||
| import { useCallback, useId, useMemo } from 'react'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import styled from 'styled-components'; | ||||
| import ToggleButton from '../../../lib/ToggleButton/ToggleButton'; | ||||
| @@ -173,6 +173,7 @@ export default function(props: Props) { | ||||
| 			themeId={props.themeId} | ||||
| 			value={item.enabled} | ||||
| 			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>; | ||||
| 	} | ||||
|  | ||||
| 	const nameLabelId = useId(); | ||||
|  | ||||
| 	return ( | ||||
| 		<CellRoot isCompatible={props.isCompatible}> | ||||
| 		<CellRoot isCompatible={props.isCompatible} role='group' aria-labelledby={nameLabelId}> | ||||
| 			<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()} | ||||
| 				{renderRecommendedBadge()} | ||||
| 			</CellTop> | ||||
|   | ||||
| @@ -17,6 +17,8 @@ import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/use | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import StyledMessage from '../../../style/StyledMessage'; | ||||
| import StyledLink from '../../../style/StyledLink'; | ||||
| import SettingHeader from '../SettingHeader'; | ||||
| import SettingDescription from '../SettingDescription'; | ||||
| const { space } = require('styled-system'); | ||||
|  | ||||
| const logger = Logger.create('PluginState'); | ||||
| @@ -51,12 +53,6 @@ interface Props { | ||||
| 	themeId: number; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	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; | ||||
| @@ -281,7 +277,7 @@ export default function(props: Props) { | ||||
| 		if (!pluginItems.length || allDeleted) { | ||||
| 			return ( | ||||
| 				<UserPluginsRoot mb={'10px'}> | ||||
| 					{props.renderDescription(props.themeId, _('You do not have any installed plugin.'))} | ||||
| 					<SettingDescription text={_('You do not have any installed plugin.')}/> | ||||
| 				</UserPluginsRoot> | ||||
| 			); | ||||
| 		} else { | ||||
| @@ -311,7 +307,6 @@ export default function(props: Props) { | ||||
| 					pluginSettings={pluginSettings} | ||||
| 					onSearchQueryChange={onSearchQueryChange} | ||||
| 					onPluginSettingsChange={onSearchPluginSettingsChange} | ||||
| 					renderDescription={props.renderDescription} | ||||
| 					repoApi={repoApi} | ||||
| 				/> | ||||
| 			</div> | ||||
| @@ -333,7 +328,7 @@ export default function(props: Props) { | ||||
| 				<div style={{ display: 'flex', flexDirection: 'row', maxWidth }}> | ||||
| 					<ToolsButton size={ButtonSize.Small} tooltip={_('Plugin tools')} iconName="fas fa-cog" level={ButtonLevel.Secondary} onClick={onToolsClick}/> | ||||
| 					<div style={{ display: 'flex', flex: 1 }}> | ||||
| 						{props.renderHeader(props.themeId, _('Manage your plugins'))} | ||||
| 						<SettingHeader text={_('Manage your plugins')}/> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				{renderUserPlugins(pluginItems)} | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/Plug | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import SettingDescription from '../SettingDescription'; | ||||
|  | ||||
| const Root = styled.div` | ||||
| `; | ||||
| @@ -26,8 +27,6 @@ interface Props { | ||||
| 	pluginSettings: PluginSettings; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	onPluginSettingsChange(event: any): void; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	renderDescription: Function; | ||||
| 	maxWidth: number; | ||||
| 	repoApi(): RepositoryApi; | ||||
| 	disabled: boolean; | ||||
| @@ -81,7 +80,7 @@ export default function(props: Props) { | ||||
| 	function renderResults(query: string, manifests: PluginManifest[]) { | ||||
| 		if (query && !manifests.length) { | ||||
| 			if (searchResultCount === null) return ''; // Search in progress | ||||
| 			return props.renderDescription(props.themeId, _('No results')); | ||||
| 			return <SettingDescription text={_('No results')}/>; | ||||
| 		} else { | ||||
| 			const output = []; | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| @use "./styles/index.scss"; | ||||
|  | ||||
| .config-screen-content-wrapper { | ||||
| 	padding: 24px; | ||||
| 	overflow: auto; | ||||
|   | ||||
							
								
								
									
										5
									
								
								packages/app-desktop/gui/ConfigScreen/styles/index.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								packages/app-desktop/gui/ConfigScreen/styles/index.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
|  | ||||
| @use "./setting-description.scss"; | ||||
| @use "./setting-label.scss"; | ||||
| @use "./setting-header.scss"; | ||||
| @use "./setting-tab-panel.scss"; | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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); | ||||
| } | ||||
| @@ -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; | ||||
| 	} | ||||
| } | ||||
| @@ -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; | ||||
| 	} | ||||
| } | ||||
| @@ -10,7 +10,7 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types'; | ||||
| import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils'; | ||||
| import { getDefaultMasterKey, getMasterPasswordStatusMessage, masterPasswordIsValid, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils'; | ||||
| 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 { AppState } from '../../app.reducer'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| @@ -350,7 +350,7 @@ const EncryptionConfigScreen = (props: Props) => { | ||||
| 		t = `<p>${t}</p>`; | ||||
|  | ||||
| 		return ( | ||||
| 			<div> | ||||
| 			<> | ||||
| 				<h2>{_('Re-encryption')}</h2> | ||||
| 				<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p> | ||||
| 				<span style={{ marginRight: 10 }}> | ||||
| @@ -358,7 +358,7 @@ const EncryptionConfigScreen = (props: Props) => { | ||||
| 				</span> | ||||
|  | ||||
| 				{ !props.shouldReencrypt ? null : <button onClick={() => dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> } | ||||
| 			</div> | ||||
| 			</> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| @@ -368,6 +368,7 @@ const EncryptionConfigScreen = (props: Props) => { | ||||
| 		setShowAdvanced(!showAdvanced); | ||||
| 	}, [showAdvanced]); | ||||
|  | ||||
| 	const advancedSettingsId = useId(); | ||||
| 	const renderAdvancedSection = () => { | ||||
| 		const reEncryptSection = renderReencryptData(); | ||||
|  | ||||
| @@ -378,8 +379,12 @@ const EncryptionConfigScreen = (props: Props) => { | ||||
| 			<div> | ||||
| 				<ToggleAdvancedSettingsButton | ||||
| 					onClick={toggleAdvanced} | ||||
| 					advancedSettingsVisible={showAdvanced}/> | ||||
| 				{ showAdvanced ? reEncryptSection : null } | ||||
| 					advancedSettingsVisible={showAdvanced} | ||||
| 					aria-controls={advancedSettingsId} | ||||
| 				/> | ||||
| 				<div id={advancedSettingsId}> | ||||
| 					{ showAdvanced ? reEncryptSection : null } | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		); | ||||
| 	}; | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import * as React from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| const ReactToggleButton = require('react-toggle-button'); | ||||
| const Color = require('color'); | ||||
|  | ||||
| @@ -8,11 +9,27 @@ interface Props { | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	onToggle: Function; | ||||
| 	themeId: number; | ||||
| 	'aria-label': string; | ||||
| } | ||||
|  | ||||
| export default function(props: Props) { | ||||
| 	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 ( | ||||
| 		<ReactToggleButton | ||||
| 			value={props.value} | ||||
| @@ -33,6 +50,7 @@ export default function(props: Props) { | ||||
| 			}} | ||||
| 			inactiveLabel="" | ||||
| 			activeLabel="" | ||||
| 			passThroughInputProps={passThroughInputProps} | ||||
| 		/> | ||||
| 	); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import { test, expect } from './util/test'; | ||||
| import MainScreen from './models/MainScreen'; | ||||
| import SettingsScreen from './models/SettingsScreen'; | ||||
| import { _electron as electron } from '@playwright/test'; | ||||
| import { writeFile } from 'fs-extra'; | ||||
| import { join } from 'path'; | ||||
| @@ -130,36 +129,6 @@ test.describe('main', () => { | ||||
| 		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 }) => { | ||||
| 		const mainScreen = new MainScreen(mainWindow); | ||||
| 		await mainScreen.waitFor(); | ||||
|   | ||||
| @@ -17,7 +17,7 @@ export default class MainScreen { | ||||
| 		this.newNoteButton = page.locator('.new-note-button'); | ||||
| 		this.noteListContainer = page.locator('.rli-noteList'); | ||||
| 		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.goToAnything = new GoToAnything(page, this); | ||||
| 	} | ||||
|   | ||||
| @@ -11,7 +11,7 @@ export default class SettingsScreen { | ||||
| 	} | ||||
|  | ||||
| 	public getTabLocator(tabName: string) { | ||||
| 		return this.page.locator('a[role="tab"] > span', { hasText: tabName }); | ||||
| 		return this.page.getByRole('tab', { name: tabName }); | ||||
| 	} | ||||
|  | ||||
| 	public async waitFor() { | ||||
|   | ||||
							
								
								
									
										96
									
								
								packages/app-desktop/integration-tests/settings.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								packages/app-desktop/integration-tests/settings.spec.ts
									
									
									
									
									
										Normal 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'); | ||||
| 	}); | ||||
| }); | ||||
|  | ||||
| @@ -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 | ||||
|   | ||||
| @@ -17,7 +17,7 @@ const logger = Logger.create('models/Setting'); | ||||
|  | ||||
| export * from './settings/types'; | ||||
|  | ||||
| type SettingValueType<T extends string> = ( | ||||
| export type SettingValueType<T extends string> = ( | ||||
| 	T extends BuiltInMetadataKeys | ||||
| 		? BuiltInMetadataValues[T] | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code before rule was applied | ||||
|   | ||||
| @@ -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(); | ||||
| @@ -230,6 +231,7 @@ export function extraStyles(theme: Theme) { | ||||
| 		backgroundColor5, | ||||
| 		backgroundColorHover5, | ||||
| 		backgroundColorActive5, | ||||
| 		focusOutlineColor, | ||||
|  | ||||
| 		icon: { | ||||
| 			...globalStyle.icon, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user