Desktop: Accessibility: Improve "change application layout" screen keyboard accessibility (#11718)

pull/11717/head
Henry Heino 2025-01-27 08:18:37 -08:00 committed by GitHub
parent 827233605e
commit 762daa5a68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 337 additions and 153 deletions

View File

@ -355,6 +355,7 @@ packages/app-desktop/gui/PasswordInput/types.js
packages/app-desktop/gui/PdfViewer.js
packages/app-desktop/gui/PluginNotification/PluginNotification.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
packages/app-desktop/gui/ResizableLayout/ResizableLayout.js
packages/app-desktop/gui/ResizableLayout/utils/findItemByKey.js
@ -500,6 +501,7 @@ packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
@ -508,6 +510,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/pluginApi.spec.js
packages/app-desktop/integration-tests/resizableLayout.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

3
.gitignore vendored
View File

@ -330,6 +330,7 @@ packages/app-desktop/gui/PasswordInput/types.js
packages/app-desktop/gui/PdfViewer.js
packages/app-desktop/gui/PluginNotification/PluginNotification.js
packages/app-desktop/gui/PromptDialog.js
packages/app-desktop/gui/ResizableLayout/LayoutItemContainer.js
packages/app-desktop/gui/ResizableLayout/MoveButtons.js
packages/app-desktop/gui/ResizableLayout/ResizableLayout.js
packages/app-desktop/gui/ResizableLayout/utils/findItemByKey.js
@ -475,6 +476,7 @@ packages/app-desktop/gulpfile.js
packages/app-desktop/integration-tests/goToAnything.spec.js
packages/app-desktop/integration-tests/main.spec.js
packages/app-desktop/integration-tests/markdownEditor.spec.js
packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js
packages/app-desktop/integration-tests/models/GoToAnything.js
packages/app-desktop/integration-tests/models/MainScreen.js
packages/app-desktop/integration-tests/models/NoteEditorScreen.js
@ -483,6 +485,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/pluginApi.spec.js
packages/app-desktop/integration-tests/resizableLayout.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

View File

@ -18,28 +18,21 @@ export enum ButtonSize {
Normal = 2,
}
interface Props {
type ReactButtonProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
interface Props extends Omit<ReactButtonProps, 'onClick'> {
title?: string;
iconName?: string;
level?: ButtonLevel;
iconLabel?: string;
className?: string;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
onClick?: Function;
onClick?: ()=> void;
color?: string;
iconAnimation?: string;
tooltip?: string;
disabled?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
style?: any;
size?: ButtonSize;
isSquare?: boolean;
iconOnly?: boolean;
fontSize?: number;
'aria-controls'?: string;
'aria-describedby'?: string;
'aria-expanded'?: string;
}
const StyledTitle = styled.span`
@ -216,55 +209,52 @@ function buttonClass(level: ButtonLevel) {
return StyledButtonSecondary;
}
const Button = React.forwardRef(({
iconName, iconLabel, iconAnimation, color, title, level, fontSize, isSquare, tooltip, disabled, onClick: propsOnClick, ...unusedProps
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied;
const Button = React.forwardRef((props: Props, ref: any) => {
const iconOnly = props.iconName && !props.title;
}: Props, ref: any) => {
const iconOnly = iconName && !title;
const StyledButton = buttonClass(props.level);
const StyledButton = buttonClass(level);
function renderIcon() {
if (!props.iconName) return null;
if (!iconName) return null;
return <StyledIcon
aria-label={props.iconLabel ?? undefined}
aria-hidden={!props.iconLabel}
animation={props.iconAnimation}
aria-label={iconLabel ?? undefined}
aria-hidden={!iconLabel}
animation={iconAnimation}
mr={iconOnly ? '0' : '6px'}
color={props.color}
className={props.iconName}
color={color}
className={iconName}
role='img'
/>;
}
function renderTitle() {
if (!props.title) return null;
return <StyledTitle color={props.color}>{props.title}</StyledTitle>;
if (!title) return null;
return <StyledTitle color={color}>{title}</StyledTitle>;
}
function onClick() {
if (props.disabled) return;
props.onClick();
if (disabled) return;
propsOnClick();
}
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}
fontSize={fontSize}
isSquare={isSquare}
disabled={disabled}
title={tooltip}
iconOnly={iconOnly}
onClick={onClick}
// When there's no title, the button needs a label. In this case, fall back
// to the tooltip.
aria-label={props.title ? undefined : props.tooltip}
aria-disabled={props.disabled}
aria-expanded={props['aria-expanded']}
aria-controls={props['aria-controls']}
aria-describedby={props['aria-describedby']}
aria-label={title ? undefined : tooltip}
aria-disabled={disabled}
{...unusedProps}
>
{renderIcon()}
{renderTitle()}

View File

@ -43,6 +43,7 @@ import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
const ipcRenderer = require('electron').ipcRenderer;
@ -121,6 +122,18 @@ const defaultLayout: LayoutItem = {
],
};
const layoutKeyToLabel = (key: string, plugins: PluginStates) => {
if (key === 'sideBar') return _('Sidebar');
if (key === 'noteList') return _('Note list');
if (key === 'editor') return _('Editor');
const viewInfo = pluginUtils.viewInfoByViewId(plugins, key);
if (viewInfo) {
return PluginService.instance().safePluginNameById(viewInfo.plugin.id);
}
return key;
};
class MainScreenComponent extends React.Component<Props, State> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@ -728,6 +741,10 @@ class MainScreenComponent extends React.Component<Props, State> {
);
}
private layoutKeyToLabel = (key: string) => {
return layoutKeyToLabel(key, this.props.plugins);
};
public render() {
const theme = themeStyle(this.props.themeId);
const style = {
@ -746,6 +763,7 @@ class MainScreenComponent extends React.Component<Props, State> {
onResize={this.resizableLayout_resize}
onMoveButtonClick={this.resizableLayout_moveButtonClick}
renderItem={this.resizableLayout_renderItem}
layoutKeyToLabel={this.layoutKeyToLabel}
moveMode={this.props.layoutMoveMode}
moveModeMessage={_('Use the arrows to move the layout items. Press "Escape" to exit.')}
/>

View File

@ -0,0 +1,69 @@
import * as React from 'react';
import { Resizable, ResizeCallback, ResizeStartCallback, Size } from 're-resizable';
import { LayoutItem } from './utils/types';
import { itemMinHeight, itemMinWidth, itemSize, LayoutItemSizes } from './utils/useLayoutItemSizes';
interface Props {
item: LayoutItem;
parent: LayoutItem|null;
sizes: LayoutItemSizes;
resizedItemMaxSize: Size|null;
onResizeStart: ResizeStartCallback;
onResize: ResizeCallback;
onResizeStop: ResizeCallback;
children: React.ReactNode;
isLastChild: boolean;
visible: boolean;
}
const LayoutItemContainer: React.FC<Props> = ({
item, visible, parent, sizes, resizedItemMaxSize, onResize, onResizeStart, onResizeStop, children, isLastChild,
}) => {
const style: React.CSSProperties = {
display: visible ? 'flex' : 'none',
flexDirection: item.direction,
};
const size: Size = itemSize(item, parent, sizes, true);
const className = `resizableLayoutItem rli-${item.key}`;
if (item.resizableRight || item.resizableBottom) {
const enable = {
top: false,
right: !!item.resizableRight && !isLastChild,
bottom: !!item.resizableBottom && !isLastChild,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
>
{children}
</Resizable>
);
} else {
return (
<div key={item.key} className={className} style={{ ...style, ...size }}>
{children}
</div>
);
}
};
export default LayoutItemContainer;

View File

@ -1,8 +1,9 @@
import * as React from 'react';
import { useCallback } from 'react';
import { useCallback, useId } from 'react';
import Button, { ButtonLevel } from '../Button/Button';
import { MoveDirection } from './utils/movements';
import styled from 'styled-components';
import { _ } from '@joplin/lib/locale';
const StyledRoot = styled.div`
display: flex;
@ -10,6 +11,11 @@ const StyledRoot = styled.div`
padding: 5px;
background-color: ${props => props.theme.backgroundColor};
border-radius: 5px;
> .label {
// Used only for accessibility tools
display: none;
}
`;
const ButtonRow = styled.div`
@ -26,23 +32,32 @@ const ArrowButton = styled(Button)`
opacity: ${props => props.disabled ? 0.2 : 1};
`;
type ButtonKey = string;
export interface MoveButtonClickEvent {
direction: MoveDirection;
itemKey: string;
buttonKey: ButtonKey;
}
interface Props {
onClick(event: MoveButtonClickEvent): void;
itemKey: string;
itemLabel: string;
canMoveLeft: boolean;
canMoveRight: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
// Specifies which button to auto-focus (if any). Clicking a "Move ..." button changes the app's layout. By default, this
// causes focus to jump to the start of the move dialog. Providing the key of the last-clicked button allows focus
// to be restored after changing the app layout:
autoFocusKey: ButtonKey|null;
}
export default function MoveButtons(props: Props) {
const onButtonClick = useCallback((direction: MoveDirection) => {
props.onClick({ direction, itemKey: props.itemKey });
props.onClick({ direction, itemKey: props.itemKey, buttonKey: `${props.itemKey}-${direction}` });
}, [props.onClick, props.itemKey]);
function canMove(dir: MoveDirection) {
@ -53,28 +68,64 @@ export default function MoveButtons(props: Props) {
throw new Error('Unreachable');
}
const iconLabel = (dir: MoveDirection) => {
if (dir === MoveDirection.Up) return _('Move up');
if (dir === MoveDirection.Down) return _('Move down');
if (dir === MoveDirection.Left) return _('Move left');
if (dir === MoveDirection.Right) return _('Move right');
const unreachable: never = dir;
throw new Error(`Invalid direction: ${unreachable}`);
};
const descriptionId = useId();
const buttonKey = (dir: MoveDirection) => `${props.itemKey}-${dir}`;
const autoFocusDirection = (() => {
if (!props.autoFocusKey) return undefined;
const buttonDirections = [MoveDirection.Up, MoveDirection.Down, MoveDirection.Left, MoveDirection.Right];
const autoFocusDirection = buttonDirections.find(
direction => buttonKey(direction) === props.autoFocusKey,
);
if (!autoFocusDirection) {
return null;
}
const autoFocusDirectionEnabled = autoFocusDirection && canMove(autoFocusDirection);
if (autoFocusDirectionEnabled) {
return autoFocusDirection;
} else {
// Select an enabled direction instead
return buttonDirections.find(dir => canMove(dir));
}
})();
function renderButton(dir: MoveDirection) {
return <ArrowButton
disabled={!canMove(dir)}
level={ButtonLevel.Primary}
iconName={`fas fa-arrow-${dir}`}
iconLabel={iconLabel(dir)}
autoFocus={autoFocusDirection === dir}
onClick={() => onButtonClick(dir)}
/>;
}
return (
<StyledRoot>
<StyledRoot role='group' aria-labelledby={descriptionId}>
<ButtonRow>
{renderButton(MoveDirection.Up)}
</ButtonRow>
<ButtonRow>
{renderButton(MoveDirection.Left)}
<EmptyButton iconName="fas fa-arrow-down" disabled={true}/>
<EmptyButton iconName="fas fa-arrow-down" aria-hidden={true} disabled={true}/>
{renderButton(MoveDirection.Right)}
</ButtonRow>
<ButtonRow>
{renderButton(MoveDirection.Down)}
</ButtonRow>
<div className='label' id={descriptionId}>{props.itemLabel}</div>
</StyledRoot>
);
}

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { useRef, useState, useEffect } from 'react';
import { useRef, useState, useEffect, useCallback } from 'react';
import useWindowResizeEvent from './utils/useWindowResizeEvent';
import setLayoutItemProps from './utils/setLayoutItemProps';
import useLayoutItemSizes, { LayoutItemSizes, itemSize, calculateMaxSizeAvailableForItem, itemMinWidth, itemMinHeight } from './utils/useLayoutItemSizes';
@ -7,16 +7,26 @@ import validateLayout from './utils/validateLayout';
import { Size, LayoutItem } from './utils/types';
import { canMove, MoveDirection } from './utils/movements';
import MoveButtons, { MoveButtonClickEvent } from './MoveButtons';
import { StyledWrapperRoot, StyledMoveOverlay, MoveModeRootWrapper, MoveModeRootMessage } from './utils/style';
import { Resizable } from 're-resizable';
const EventEmitter = require('events');
import { StyledWrapperRoot, StyledMoveOverlay, MoveModeRootMessage } from './utils/style';
import type { ResizeCallback, ResizeStartCallback } from 're-resizable';
import Dialog from '../Dialog';
import * as EventEmitter from 'events';
import LayoutItemContainer from './LayoutItemContainer';
interface OnResizeEvent {
layout: LayoutItem;
}
interface ResizedItem {
key: string;
initialWidth: number;
initialHeight: number;
maxSize: Size;
}
interface Props {
layout: LayoutItem;
layoutKeyToLabel: (key: string)=> string;
onResize(event: OnResizeEvent): void;
width?: number;
height?: number;
@ -33,101 +43,57 @@ function itemVisible(item: LayoutItem, moveMode: boolean) {
return item.visible !== false;
}
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
function renderContainer(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, resizedItemMaxSize: Size | null, onResizeStart: Function, onResize: Function, onResizeStop: Function, children: any[], isLastChild: boolean, moveMode: boolean): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const style: any = {
display: itemVisible(item, moveMode) ? 'flex' : 'none',
flexDirection: item.direction,
};
const size: Size = itemSize(item, parent, sizes, true);
const className = `resizableLayoutItem rli-${item.key}`;
if (item.resizableRight || item.resizableBottom) {
const enable = {
top: false,
right: !!item.resizableRight && !isLastChild,
bottom: !!item.resizableBottom && !isLastChild,
left: false,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
};
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onResizeStart={onResizeStart as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onResize={onResize as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onResizeStop={onResizeStop as any}
enable={enable}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
maxWidth={resizedItemMaxSize?.width}
maxHeight={resizedItemMaxSize?.height}
>
{children}
</Resizable>
);
} else {
return (
<div key={item.key} className={className} style={{ ...style, ...size }}>
{children}
</div>
);
}
}
function ResizableLayout(props: Props) {
const eventEmitter = useRef(new EventEmitter());
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const [resizedItem, setResizedItem] = useState<any>(null);
const [resizedItem, setResizedItem] = useState<ResizedItem|null>(null);
const lastUsedMoveButtonKey = useRef<string|null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function renderItemWrapper(comp: any, item: LayoutItem, parent: LayoutItem | null, size: Size, moveMode: boolean) {
const moveOverlay = moveMode ? (
<StyledMoveOverlay>
<MoveButtons
itemKey={item.key}
onClick={props.onMoveButtonClick}
canMoveLeft={canMove(MoveDirection.Left, item, parent)}
canMoveRight={canMove(MoveDirection.Right, item, parent)}
canMoveUp={canMove(MoveDirection.Up, item, parent)}
canMoveDown={canMove(MoveDirection.Down, item, parent)}
/>
</StyledMoveOverlay>
) : null;
const onMoveButtonClick = useCallback((event: MoveButtonClickEvent) => {
lastUsedMoveButtonKey.current = event.buttonKey;
props.onMoveButtonClick(event);
}, [props.onMoveButtonClick]);
const renderMoveControls = (item: LayoutItem, parent: LayoutItem | null, size: Size) => {
return (
<StyledWrapperRoot key={item.key} size={size}>
<StyledMoveOverlay>
<MoveButtons
autoFocusKey={lastUsedMoveButtonKey.current}
itemKey={item.key}
itemLabel={props.layoutKeyToLabel(item.key)}
onClick={onMoveButtonClick}
canMoveLeft={canMove(MoveDirection.Left, item, parent)}
canMoveRight={canMove(MoveDirection.Right, item, parent)}
canMoveUp={canMove(MoveDirection.Up, item, parent)}
canMoveDown={canMove(MoveDirection.Down, item, parent)}
/>
</StyledMoveOverlay>
</StyledWrapperRoot>
);
};
function renderItemWrapper(comp: React.ReactNode, item: LayoutItem, size: Size) {
return (
<StyledWrapperRoot key={item.key} size={size}>
{moveOverlay}
{comp}
</StyledWrapperRoot>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function renderLayoutItem(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean): any {
function onResizeStart() {
function renderLayoutItem(
item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean, onlyMoveControls: boolean,
): React.ReactNode {
const onResizeStart: ResizeStartCallback = () => {
setResizedItem({
key: item.key,
initialWidth: sizes[item.key].width,
initialHeight: sizes[item.key].height,
maxSize: calculateMaxSizeAvailableForItem(item, parent, sizes),
});
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function onResize(_event: any, direction: string, _refToElement: any, delta: any) {
const onResize: ResizeCallback = (_event, direction, _refToElement, delta) => {
const newWidth = Math.max(itemMinWidth, resizedItem.initialWidth + delta.width);
const newHeight = Math.max(itemMinHeight, resizedItem.initialHeight + delta.height);
@ -147,15 +113,18 @@ function ResizableLayout(props: Props) {
props.onResize({ layout: newLayout });
eventEmitter.current.emit('resize');
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function onResizeStop(_event: any, _direction: any, _refToElement: any, delta: any) {
const onResizeStop: ResizeCallback = (_event, _direction, _refToElement, delta) => {
onResize(_event, _direction, _refToElement, delta);
setResizedItem(null);
}
};
const resizedItemMaxSize = resizedItem && item.key === resizedItem.key ? resizedItem.maxSize : null;
const visible = itemVisible(item, props.moveMode);
const itemContainerProps = {
key: item.key, item, parent, sizes, resizedItemMaxSize, onResizeStart, onResizeStop, onResize, isLastChild, visible,
};
if (!item.children) {
const size = itemSize(item, parent, sizes, false);
@ -166,17 +135,22 @@ function ResizableLayout(props: Props) {
visible: isVisible,
});
const wrapper = renderItemWrapper(comp, item, parent, size, props.moveMode);
return renderContainer(item, parent, sizes, resizedItemMaxSize, onResizeStart, onResize, onResizeStop, [wrapper], isLastChild, props.moveMode);
const wrapper = onlyMoveControls ? renderMoveControls(item, parent, size) : renderItemWrapper(comp, item, size);
return <LayoutItemContainer {...itemContainerProps}>
{wrapper}
</LayoutItemContainer>;
} else {
const childrenComponents = [];
for (let i = 0; i < item.children.length; i++) {
const child = item.children[i];
childrenComponents.push(renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1));
childrenComponents.push(
renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1, onlyMoveControls),
);
}
return renderContainer(item, parent, sizes, resizedItemMaxSize, onResizeStart, onResize, onResizeStop, childrenComponents, isLastChild, props.moveMode);
return <LayoutItemContainer {...itemContainerProps}>
{childrenComponents}
</LayoutItemContainer>;
}
}
@ -187,22 +161,24 @@ function ResizableLayout(props: Props) {
useWindowResizeEvent(eventEmitter);
const sizes = useLayoutItemSizes(props.layout, props.moveMode);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function renderMoveModeBox(rootComp: any) {
return (
<MoveModeRootWrapper>
const renderRoot = (moveControlsOnly: boolean) => {
return renderLayoutItem(props.layout, null, sizes, itemVisible(props.layout, props.moveMode), true, moveControlsOnly);
};
function renderMoveModeBox() {
return <div>
<Dialog contentFillsScreen={true} className='change-app-layout-dialog'>
<MoveModeRootMessage>{props.moveModeMessage}</MoveModeRootMessage>
{rootComp}
</MoveModeRootWrapper>
);
{renderRoot(true)}
</Dialog>
{renderRoot(false)}
</div>;
}
const rootComp = renderLayoutItem(props.layout, null, sizes, itemVisible(props.layout, props.moveMode), true);
if (props.moveMode) {
return renderMoveModeBox(rootComp);
return renderMoveModeBox();
} else {
return rootComp;
return renderRoot(false);
}
}

View File

@ -28,18 +28,12 @@ export const StyledMoveOverlay = styled.div`
height: 100%;
`;
export const MoveModeRootWrapper = styled.div`
position:relative;
display: flex;
align-items: center;
justify-content: center;
`;
export const MoveModeRootMessage = styled.div`
position:absolute;
export const MoveModeRootMessage = styled.h1`
position: absolute;
bottom: 10px;
font-size: 1em;
z-index:200;
background-color: ${props => props.theme.backgroundColor};
padding: 10px;
border-radius: 5;
`;

View File

@ -1,6 +1,6 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import dialogs from '../../dialogs';
import shim from '@joplin/lib/shim';
export const declaration: CommandDeclaration = {
name: 'resetLayout',
@ -12,7 +12,7 @@ export const runtime = (): CommandRuntime => {
execute: async (context: CommandContext) => {
const message = _('Are you sure you want to return to the default layout? The current layout configuration will be lost.');
const isConfirmed = await dialogs.confirm(message);
const isConfirmed = await shim.showConfirmationDialog(message);
if (!isConfirmed) return;

View File

@ -0,0 +1,14 @@
.change-app-layout-dialog {
padding: 0;
> .content {
position:relative;
display: flex;
align-items: center;
justify-content: center;
}
&::backdrop {
background-color: transparent !important;
}
}

View File

@ -32,13 +32,16 @@
}
&.-fullscreen {
max-width: 100vw;
max-height: 100vh;
&::backdrop {
background-color: var(--joplin-background-color);
}
> .content {
width: calc(100% - 20px);
padding: 10px;
margin: 0;
padding: 0;
border-radius: 0;
box-shadow: none;
background-color: transparent;

View File

@ -11,3 +11,4 @@
@use './dialog-anchor-node.scss';
@use './note-editor-wrapper.scss';
@use './text-input.scss';
@use './change-app-layout-dialog.scss';

View File

@ -0,0 +1,23 @@
import { ElectronApplication, Locator, Page } from '@playwright/test';
import MainScreen from './MainScreen';
import activateMainMenuItem from '../util/activateMainMenuItem';
export default class ChangeAppLayoutScreen {
public readonly containerLocator: Locator;
public constructor(page: Page, private readonly mainScreen: MainScreen) {
this.containerLocator = page.locator('.change-app-layout-dialog[open]');
}
public async open(electronApp: ElectronApplication) {
await this.mainScreen.waitFor();
await activateMainMenuItem(electronApp, 'Change application layout');
return this.waitFor();
}
public async waitFor() {
await this.containerLocator.waitFor();
}
}

View File

@ -6,6 +6,7 @@ import GoToAnything from './GoToAnything';
import setFilePickerResponse from '../util/setFilePickerResponse';
import NoteList from './NoteList';
import { expect } from '../util/test';
import ChangeAppLayoutScreen from './ChangeAppLayoutScreen';
export default class MainScreen {
public readonly newNoteButton: Locator;
@ -14,6 +15,7 @@ export default class MainScreen {
public readonly dialog: Locator;
public readonly noteEditor: NoteEditorScreen;
public readonly goToAnything: GoToAnything;
public readonly changeLayoutScreen: ChangeAppLayoutScreen;
public constructor(private page: Page) {
this.newNoteButton = page.locator('.new-note-button');
@ -22,6 +24,7 @@ export default class MainScreen {
this.dialog = page.locator('.dialog-modal-layer');
this.noteEditor = new NoteEditorScreen(page);
this.goToAnything = new GoToAnything(page, this);
this.changeLayoutScreen = new ChangeAppLayoutScreen(page, this);
}
public async setup() {

View File

@ -0,0 +1,22 @@
import { test, expect } from './util/test';
import MainScreen from './models/MainScreen';
test.describe('resizableLayout', () => {
test('right/left buttons should retain keyboard focus after use', async ({ electronApp, mainWindow }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
const changeLayoutScreen = mainScreen.changeLayoutScreen;
await changeLayoutScreen.open(electronApp);
const moveSidebarControls = changeLayoutScreen.containerLocator.getByRole('group', { name: 'Sidebar' });
const moveSidebarRight = moveSidebarControls.getByRole('button', { name: 'Move right' });
await expect(moveSidebarRight).not.toBeDisabled();
// Should refocus (or keep focused) after clicking
await moveSidebarRight.click();
await expect(moveSidebarRight).toBeFocused();
await moveSidebarRight.click();
await expect(moveSidebarRight).toBeFocused();
});
});

View File

@ -67,5 +67,11 @@ test.describe('wcag', () => {
await expectNoViolations(mainWindow);
});
test('should not detect significant issues in the change app layout screen', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.changeLayoutScreen.open(electronApp);
await expectNoViolations(mainWindow);
});
});

View File

@ -203,6 +203,14 @@ export default class PluginService extends BaseService {
return this.plugins_[id];
}
public safePluginNameById(id: string) {
if (!this.plugins_[id]) {
return id;
}
return this.pluginById(id).manifest?.name ?? 'Unknown';
}
public viewControllerByViewId(id: string): ViewController|null {
for (const [, plugin] of Object.entries(this.plugins_)) {
if (plugin.hasViewController(id)) return plugin.viewController(id);