Skip to content
19 changes: 18 additions & 1 deletion apps/ui/src/components/settings-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useConnector } from '@/data/core';
import { persister } from '@/data/core/query-client';
import { useInstalledApps } from '@/data/queries/use-installed-apps';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
import { SettingsView } from './index';
import { SettingsView, isSettingsTab } from './index';
import type { ButtonHTMLAttributes, ReactNode } from 'react';

vi.mock( '@wordpress/ui', () => ( {
Expand Down Expand Up @@ -209,6 +209,23 @@ describe( 'SettingsView', () => {
expect( mutate ).not.toHaveBeenCalled();
} );

it( 'recognizes the keyboard tab id', () => {
expect( isSettingsTab( 'keyboard' ) ).toBe( true );
expect( isSettingsTab( 'unknown' ) ).toBe( false );
} );

it( 'renders keyboard shortcut sections', () => {
render( <SettingsView activeTab="keyboard" onTabChange={ vi.fn() } /> );

expect( screen.getByRole( 'button', { name: 'Keyboard' } ) ).toBeInTheDocument();
expect( screen.getByRole( 'heading', { name: 'Composer' } ) ).toBeInTheDocument();
expect( screen.getByRole( 'heading', { name: 'Site preview' } ) ).toBeInTheDocument();
expect( screen.getByText( 'New chat' ) ).toBeInTheDocument();
expect( screen.getByText( 'Send message' ) ).toBeInTheDocument();
expect( screen.getByLabelText( 'Control + Comma' ) ).toBeInTheDocument();
expect( screen.getByLabelText( 'Alt + Left arrow' ) ).toBeInTheDocument();
} );

it( 'saves the default site directory as soon as one is picked', async () => {
selectDefaultSiteDirectory.mockResolvedValue( '/Users/example/Sites' );

Expand Down
11 changes: 9 additions & 2 deletions apps/ui/src/components/settings-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-u
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { AccountSection } from './account-section';
import { KeyboardPanel } from './keyboard-panel';
import { McpPanel } from './mcp-panel';
import { UNSET, toPreferencesFormData, toPreferencesPatch } from './preferences';
import { SkillsPanel } from './skills-panel';
Expand All @@ -29,10 +30,12 @@ import type {
} from '@/data/core';
import type { ReactNode } from 'react';

type TabId = 'preferences' | 'skills' | 'mcp';
const SETTINGS_TABS = [ 'preferences', 'keyboard', 'skills', 'mcp' ] as const;

type TabId = ( typeof SETTINGS_TABS )[ number ];

export function isSettingsTab( value: string ): value is TabId {
return value === 'preferences' || value === 'skills' || value === 'mcp';
return SETTINGS_TABS.some( ( tab ) => tab === value );
}

export type SettingsTabId = TabId;
Expand Down Expand Up @@ -100,6 +103,7 @@ function SettingsHeader() {
<div className={ styles.headerTabs }>
<Tabs.List className={ styles.headerTabList }>
<Tabs.Tab tabId="preferences">{ __( 'Settings' ) }</Tabs.Tab>
<Tabs.Tab tabId="keyboard">{ __( 'Keyboard' ) }</Tabs.Tab>
<Tabs.Tab tabId="skills">{ __( 'Skills' ) }</Tabs.Tab>
<Tabs.Tab tabId="mcp">{ __( 'MCP' ) }</Tabs.Tab>
</Tabs.List>
Expand Down Expand Up @@ -406,6 +410,9 @@ export function SettingsView( {
onChange={ handleChange }
/>
</Tabs.Panel>
<Tabs.Panel tabId="keyboard">
<KeyboardPanel />
</Tabs.Panel>
<Tabs.Panel tabId="skills">
<SkillsPanel />
</Tabs.Panel>
Expand Down
93 changes: 93 additions & 0 deletions apps/ui/src/components/settings-view/keyboard-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { __ } from '@wordpress/i18n';
import { isAppleOS } from '@wordpress/keycodes';
import styles from './style.module.css';

type ShortcutSection = {
title: string;
shortcuts: { label: string; keys: string[] }[];
};

function getShortcutKeyAriaLabel( key: string ): string {
switch ( key ) {
case '⌘':
return __( 'Command' );
case 'Ctrl':
return __( 'Control' );
case '↩':
return __( 'Return' );
case 'Esc':
return __( 'Escape' );
case ',':
return __( 'Comma' );
case '←':
return __( 'Left arrow' );
case '→':
return __( 'Right arrow' );
default:
return key;
}
}

function getShortcutSections( isApple: boolean ): ShortcutSection[] {
const modifierKey = isApple ? '⌘' : 'Ctrl';
const navModifierKey = isApple ? '⌘' : 'Alt';
return [
{
title: __( 'General' ),
shortcuts: [ { label: __( 'Open settings' ), keys: [ modifierKey, ',' ] } ],
},
{
title: __( 'Composer' ),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Composer is not really intuitive for me, although we can educate users and I don't have better alternatives.

shortcuts: [
{ label: __( 'New chat' ), keys: [ modifierKey, 'N' ] },
{ label: __( 'Send message' ), keys: [ '↩' ] },
{ label: __( 'Insert newline' ), keys: [ 'Shift', '↩' ] },
{ label: __( 'Stop response' ), keys: [ 'Esc' ] },
],
},
{
title: __( 'Site preview' ),
shortcuts: [
{ label: __( 'Toggle site preview' ), keys: [ modifierKey, 'Shift', 'B' ] },
{ label: __( 'Reload preview' ), keys: [ modifierKey, 'R' ] },
{ label: __( 'Go back in preview' ), keys: [ navModifierKey, '←' ] },
{ label: __( 'Go forward in preview' ), keys: [ navModifierKey, '→' ] },
],
},
];
}

function ShortcutKeys( { keys }: { keys: string[] } ) {
return (
<span
className={ styles.shortcutKeys }
aria-label={ keys.map( getShortcutKeyAriaLabel ).join( ' + ' ) }
>
{ keys.map( ( key, index ) => (
<kbd key={ `${ key }-${ index }` } className={ styles.shortcutKey } aria-hidden="true">
{ key }
</kbd>
) ) }
</span>
);
}

export function KeyboardPanel() {
return (
<div className={ styles.preferencesPanel }>
{ getShortcutSections( isAppleOS() ).map( ( section ) => (
<section key={ section.title } className={ styles.preferenceSectionGroup }>
<h2 className={ styles.preferenceSectionHeading }>{ section.title }</h2>
<ul className={ styles.shortcutList }>
{ section.shortcuts.map( ( shortcut ) => (
<li key={ shortcut.label } className={ styles.shortcutRow }>
<span className={ styles.shortcutName }>{ shortcut.label }</span>
<ShortcutKeys keys={ shortcut.keys } />
</li>
) ) }
</ul>
</section>
) ) }
</div>
);
}
53 changes: 53 additions & 0 deletions apps/ui/src/components/settings-view/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,59 @@
fill: currentColor;
}

.shortcutList {
list-style: none;
margin: 0;
padding: 0;
}

.shortcutRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--wpds-dimension-padding-xl);
padding-block: var(--settings-row-padding-block);
border-bottom: var(--settings-divider-border);
}

.shortcutRow:last-child {
padding-block-end: 0;
border-bottom: 0;
}

.shortcutName {
color: var(--wpds-color-fg-content-neutral);
font-size: var(--wpds-typography-font-size-md);
font-weight: 400;
line-height: var(--wpds-typography-line-height-sm);
}

.shortcutKeys {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--wpds-dimension-padding-xs);
flex-wrap: wrap;
}

.shortcutKey {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
min-inline-size: 26px;
min-block-size: 26px;
padding: 0 var(--wpds-dimension-padding-xs);
border: 1px solid var(--wpds-color-stroke-surface-neutral);
border-radius: var(--wpds-border-radius-sm);
background: var(--wpds-color-bg-surface-neutral);
color: var(--wpds-color-fg-content-neutral);
font-family: var(--wpds-typography-font-family-body);
font-size: var(--wpds-typography-font-size-xs);
font-weight: 500;
line-height: 1;
}

.errorMessage {
margin-block-end: var(--wpds-dimension-padding-md);
padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
Expand Down
73 changes: 72 additions & 1 deletion apps/ui/src/components/site-preview/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { Tooltip } from '@wordpress/ui';
import { describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { getPathFromPreviewUrl, getToolbarPageTitle, SitePreview } from './index';
import {
getBrowserShortcutCommand,
getPathFromPreviewUrl,
getToolbarPageTitle,
SitePreview,
} from './index';
import type { SiteDetails } from '@/data/core';
import type { ReactNode } from 'react';

Expand Down Expand Up @@ -129,6 +134,17 @@ describe( 'SitePreview', () => {
expect( refreshButton ).toBeEnabled();
expect( refreshButton ).toHaveAttribute( 'aria-keyshortcuts', expect.stringMatching( /\+R$/ ) );

// jsdom reports a non-Apple platform: the navigation alias is Alt+arrow,
// with the bracket chord kept as a secondary shortcut.
expect( screen.getByRole( 'button', { name: 'Back' } ) ).toHaveAttribute(
'aria-keyshortcuts',
'Alt+ArrowLeft Control+['
);
expect( screen.getByRole( 'button', { name: 'Forward' } ) ).toHaveAttribute(
'aria-keyshortcuts',
'Alt+ArrowRight Control+]'
);

const initialIframe = container.querySelector( 'iframe' );
expect( initialIframe ).toBeInTheDocument();

Expand Down Expand Up @@ -189,6 +205,61 @@ describe( 'SitePreview', () => {
} );
} );

describe( 'getBrowserShortcutCommand', () => {
// jsdom reports a non-Apple platform: primary modifier is Ctrl and the
// navigation-arrow alias uses Alt.
function makeEvent( overrides: Record< string, unknown > ) {
return {
defaultPrevented: false,
repeat: false,
key: '',
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
target: null,
...overrides,
} as unknown as KeyboardEvent;
}

it( 'maps the primary-modifier chords to commands', () => {
expect( getBrowserShortcutCommand( makeEvent( { key: 'r', ctrlKey: true } ) ) ).toBe(
'reload'
);
expect( getBrowserShortcutCommand( makeEvent( { key: '[', ctrlKey: true } ) ) ).toBe( 'back' );
expect( getBrowserShortcutCommand( makeEvent( { key: ']', ctrlKey: true } ) ) ).toBe(
'forward'
);
} );

it( 'maps the Alt+arrow aliases to back/forward', () => {
expect( getBrowserShortcutCommand( makeEvent( { key: 'ArrowLeft', altKey: true } ) ) ).toBe(
'back'
);
expect( getBrowserShortcutCommand( makeEvent( { key: 'ArrowRight', altKey: true } ) ) ).toBe(
'forward'
);
} );

it( 'ignores arrows with the wrong modifier, extra modifiers, or while editing text', () => {
expect( getBrowserShortcutCommand( makeEvent( { key: 'ArrowLeft', ctrlKey: true } ) ) ).toBe(
null
);
expect(
getBrowserShortcutCommand( makeEvent( { key: 'ArrowLeft', altKey: true, shiftKey: true } ) )
).toBe( null );
expect(
getBrowserShortcutCommand(
makeEvent( {
key: 'ArrowLeft',
altKey: true,
target: document.createElement( 'textarea' ),
} )
)
).toBe( null );
} );
} );

describe( 'getToolbarPageTitle', () => {
it( 'strips the WordPress admin suffix from document titles', () => {
expect( getToolbarPageTitle( 'Dashboard ‹ Example Site — WordPress', 'Example Site' ) ).toBe(
Expand Down
Loading