Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions config/tailwind/buttons.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ module.exports = plugin(({ addComponents }) => {
// Variants
'.btn': {
...base,
'@apply bg-blue-500 text-white enabled:hover:bg-blue-600 enabled:active:bg-blue-700 aria-expanded:bg-blue-700':
'@apply bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 aria-expanded:bg-blue-700':
{},
},
'.btn-outline': {
...base,
'@apply bg-transparent text-gray-600 border border-gray-600 enabled:hover:bg-black/5 enabled:active:bg-black/10 aria-expanded:bg-black/10':
'@apply bg-transparent text-gray-600 border border-gray-600 hover:bg-black/5 active:bg-black/10 aria-expanded:bg-black/10':
{},
},
'.btn-subtle': {
...base,
'@apply bg-transparent text-gray-600 enabled:hover:bg-black/5 enabled:active:bg-black/10 aria-expanded:bg-black/10 dark:text-gray-300 dark:active:text-white dark:enabled:active:bg-black/30 dark:enabled:hover:bg-black/20 dark:aria-expanded:bg-black/30 dark:aria-expanded:text-white':
'@apply bg-transparent text-gray-600 hover:bg-black/5 active:bg-black/10 aria-expanded:bg-black/10 dark:text-gray-300 dark:active:text-white dark:active:bg-black/30 dark:hover:bg-black/20 dark:aria-expanded:bg-black/30 dark:aria-expanded:text-white':
{},
},

Expand Down
89 changes: 89 additions & 0 deletions e2e/keyboard-shortcuts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test'

test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.getByTestId('app')).toBeVisible()
})

test.describe('Keyboard Shortcuts', () => {
test('F toggles fullscreen and Escape exits', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Go Fullscreen [F]' })).toBeVisible()

await page.keyboard.press('f')
await expect(page.getByRole('button', { name: 'Exit Fullscreen [F]' })).toBeVisible()

// Verify that the nav content is hidden and the grid has collapsed the nav column
await expect(page.getByRole('searchbox', { name: 'search' })).not.toBeVisible()
const grid = page.getByTestId('app-grid')
await expect(grid).toHaveClass(/.*!grid-cols-\[0px,1fr\].*/)

// Confirm toggle by pressing f again
await page.keyboard.press('f')
await expect(page.getByRole('button', { name: 'Go Fullscreen [F]' })).toBeVisible()

await page.keyboard.press('f')
await expect(page.getByRole('button', { name: 'Exit Fullscreen [F]' })).toBeVisible()

// Confirm exit by pressing escape
await page.keyboard.press('Escape')
await expect(page.getByRole('button', { name: 'Go Fullscreen [F]' })).toBeVisible()
})

test('F does not toggle while typing in the search input', async ({ page }) => {
// Ensure we are not in fullscreen to start
await expect(page.getByRole('button', { name: 'Go Fullscreen [F]' })).toBeVisible()

const search = page.getByRole('searchbox', { name: 'search' })
await search.click()
await search.fill('foo')
await page.keyboard.press('f')

// Should still show "Go Fullscreen" while focused in input
await expect(page.getByRole('button', { name: 'Go Fullscreen [F]' })).toBeVisible()

// Blur input and verify toggle works
await page.getByRole('heading', { name: /welcome/i }).click()
await page.keyboard.press('f')
await expect(page.getByRole('button', { name: 'Exit Fullscreen [F]' })).toBeVisible()

// Reset state
await page.keyboard.press('Escape')
await expect(page.getByRole('button', { name: 'Go Fullscreen [F]' })).toBeVisible()
})

test('Shift+V toggles viewport size dropdown', async ({ page }) => {
// Sanity check the dropdown is invisible
await expect(page.getByRole('menuitemradio', { name: 'Mobile' })).toBeHidden()
await expect(page.getByRole('menuitemradio', { name: 'Tablet' })).toBeHidden()
await expect(page.getByRole('menuitemradio', { name: 'Desktop' })).toBeHidden()
await expect(page.getByRole('menuitem', { name: 'Responsive' })).toBeHidden()

// Toggle dropdown
await page.keyboard.press('Shift+V')

// Check that the dropdown is visible
await expect(page.getByRole('menuitemradio', { name: 'Mobile' })).toBeVisible()
await expect(page.getByRole('menuitemradio', { name: 'Tablet' })).toBeVisible()
await expect(page.getByRole('menuitemradio', { name: 'Desktop' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Responsive' })).toBeVisible()
})

test('Shift+S opens settings', async ({ page }) => {
await page.keyboard.press('Shift+S')
await expect(page.getByTestId('SettingsPanel')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible()
})

test('Shift+T toggles theme dark and back to light', async ({ page }) => {
const app = page.getByTestId('app')
// Toggle to dark
await page.keyboard.press('Shift+T')
await expect(app).toHaveClass(/dark/)
// Toggle back to light
await page.keyboard.press('Shift+T')
await expect(app).toHaveClass(/light/)
})
})


2 changes: 2 additions & 0 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function App(props: AppProps) {
})}
>
<div
data-testid="app-grid"
className={cx(
'grid h-screen grid-cols-[250px,1fr] bg-gray-100 transition-all duration-500',
{
Expand All @@ -146,6 +147,7 @@ export function App(props: AppProps) {
<UtilityBar
isDoc={activeNavItem?.doc ?? false}
showSettings={!hasConfigUrl}
partUrl={activeNavItem?.url ?? null}
/>

<div className="flex flex-grow items-stretch justify-center">
Expand Down
24 changes: 22 additions & 2 deletions src/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,30 @@ export interface SimpleKeyboardEvent {
preventDefault?(): void
}

/**
* Finds the active element even in the shadow DOM.
*
* The app is rendered inside a parts-kit custom element with a shadow DOM.
* document.activeElement is the host element (<parts-kit>), not the inner <input>.
*/
function getDeepActiveElement(root: Document | ShadowRoot = document): Element | null {
// Start with the active element on the provided root (document by default)
let active: Element | null = (root as Document).activeElement ?? null

// If the active element hosts a shadow root, drill down to that shadow root's active element
while (active && (active as HTMLElement).shadowRoot) {
const shadow = (active as HTMLElement).shadowRoot as ShadowRoot
active = shadow.activeElement
}

return active
}

export function canHandleKeyboard(): boolean {
const active = getDeepActiveElement()
return !(
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement
)
}

Expand Down
28 changes: 28 additions & 0 deletions src/features/utility-bar/DirectLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Link1Icon } from '@radix-ui/react-icons'

interface DirectLinkProps {
url?: string
}

export function DirectLink({
url,
}: DirectLinkProps) {
if (!url) {
return null
}

// TODO eventually refactor to use a button component
return (
<a
className="btn-subtle btn-icon"
href={url}
target="_blank"
rel="noreferrer noopener"
title="Open direct link"
>
<Link1Icon />
</a>
)
}


5 changes: 5 additions & 0 deletions src/features/utility-bar/UtilityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import ToggleFullscreen from './ToggleFullscreen'
import ToggleTheme from './ToggleTheme'
import ToggleViewportDropdown from './ToggleViewportDropdown'
import ToggleSettingsDialog from './ToggleSettingsDialog'
import { DirectLink } from './DirectLink'

interface UtilityBarProps {
showSettings: boolean
isDoc: boolean
partUrl?: string|null
}

export default function (props: UtilityBarProps) {
Expand Down Expand Up @@ -36,6 +38,9 @@ export default function (props: UtilityBarProps) {
{/* Settings Control */}
{props.showSettings && <ToggleSettingsDialog />}

{/* Direct Link */}
{props.partUrl && <DirectLink url={props.partUrl} />}

{/* Fullscreen control */}
<ToggleFullscreen />
</div>
Expand Down
Loading