Skip to content
Merged
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
4 changes: 3 additions & 1 deletion src/lib/components/ThemeSwitch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ let matrix: Item[] = [
['Light', 'dark', 'light'],
['Light', 'light', 'light'],
['System', 'dark', 'system'],
['System', 'light', 'system']
['System', 'light', 'system'],
['Naked', 'dark', 'naked'],
['Naked', 'light', 'naked']
]

test.describe('stored theme and OS color scheme', () => {
Expand Down
43 changes: 16 additions & 27 deletions src/lib/components/ThemeSwitch.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { on } from 'svelte/events'
import type { Theme } from '#lib/theme.ts'
import { apply_theme, on_theme_change, resolve_current_theme, save_theme, type Theme } from '#lib/theme.ts'
import Icon from '#lib/components/Icon.svelte'
import ThemePreview from './ThemePreview.svelte'
import { MediaQuery } from 'svelte/reactivity'
Expand All @@ -16,24 +16,17 @@
let popover_open = $state(false)
let popover: HTMLElement | undefined = undefined

function set_theme(theme: Theme) {
document.documentElement.dataset.theme = theme
}

$effect(() => {
if (!initial_theme) {
// Respect cookie-based theme already set by the inline script in <head>
const dom_theme = document.documentElement.dataset.theme as Theme | undefined
if (dom_theme && dom_theme !== 'system') {
theme = dom_theme
} else {
// No cookie preference — resolve system to actual light/dark
theme = prefers_light.current ? 'light' : 'dark'
set_theme(theme)
}
theme = resolve_current_theme(prefers_light.current)
}
})

// keep in sync when the theme is changed elsewhere (e.g. the command palette)
$effect(() => {
on_theme_change((new_theme) => (theme = new_theme))
})

$effect(() => {
if (!popover) {
return
Expand All @@ -43,24 +36,20 @@

function ontoggle(event: ToggleEvent) {
popover_open = event.newState === 'open'
}

let save_timer: ReturnType<typeof setTimeout>
// The `autofocus` attribute only applies when an element is first inserted into the document.
// These radios are already connected (just hidden inside a closed popover), so toggling `autofocus`
// reactively has no effect once the popover opens — focus the checked radio explicitly instead.
if (popover_open) {
popover?.querySelector<HTMLInputElement>('input[type="radio"]:checked')?.focus()
}
}

function save_preference() {
if (theme) {
set_theme(theme)
apply_theme(theme)
save_theme(theme)
}
clearTimeout(save_timer)
save_timer = setTimeout(() => {
fetch('/api/theme', {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: theme
})
}, 300)
}

function get_theme_icon(theme?: Theme): 'sun' | 'moon' {
Expand Down
60 changes: 39 additions & 21 deletions src/lib/components/cmd-k/ComboBox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,15 @@
return shortcuts
}

let new_results = structuredClone(shortcuts)
new_results.forEach((section) => {
section.items = section.items.filter((item) => {
// no structuredClone: items can hold functions (`action`), which can't be cloned
return shortcuts.map((section) => ({
...section,
items: section.items.filter((item) => {
return (
item.title.toLowerCase().includes(normalized_search_query) || item.keywords?.includes(normalized_search_query)
)
})
})

return new_results
}))
})

let no_results = $derived(results.every((section) => section.items.length === 0))
Expand All @@ -35,17 +34,17 @@
const group = focusable_children(listbox)

// when using arrow keys (as opposed to tab), don't focus buttons
const selector = 'a.shortcut, input'
const selector = '.shortcut, input'

if (event.key === 'ArrowDown') {
if (event.metaKey || event.ctrlKey) {
group.last('a.shortcut')
group.last('.shortcut')
} else {
group.next(selector)
}
} else {
if (event.metaKey || event.ctrlKey) {
group.first('a.shortcut')
group.first('.shortcut')
} else {
group.prev(selector)
}
Expand Down Expand Up @@ -93,20 +92,34 @@
<li class="section">
<div class="section-title title">{section.title}</div>
<ol class="items">
{#each section.items as list_item (list_item.href)}
{#each section.items as list_item (list_item.title)}
<li class="item">
<a class="shortcut" href={list_item.href}>
<span class="icon">
{#if section.title.includes('Website')}
<Icon name="code-window" size={15} />
{:else if section.title.includes('One-off')}
{#if 'href' in list_item}
<a class="shortcut" href={list_item.href}>
<span class="icon">
{#if section.title.includes('Website')}
<Icon name="code-window" size={15} />
{:else if section.title.includes('One-off')}
<Icon name="tools" size={15} />
{:else}
<Icon name="file" size={15} />
{/if}
</span>
{list_item.title}
</a>
{:else}
<button
class="shortcut"
onclick={() => {
list_item.action()
}}
>
<span class="icon">
<Icon name="tools" size={15} />
{:else}
<Icon name="file" size={15} />
{/if}
</span>
{list_item.title}
</a>
</span>
{list_item.title}
</button>
{/if}
</li>
{/each}
</ol>
Expand Down Expand Up @@ -162,6 +175,11 @@
display: block;
padding-block: var(--space-2);

/* buttons need more care than links: */
inline-size: stretch;
text-align: start;
cursor: pointer;

& .icon {
color: var(--fg-300);
margin-block-end: var(--space-3);
Expand Down
41 changes: 22 additions & 19 deletions src/lib/components/cmd-k/cmd-k.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import type { Locator } from '@playwright/test'
import { expect, test } from '../../../../tests/fixtures'
import { shortcuts } from './shortcuts'

const LINK_COUNT = shortcuts.reduce((acc, curr) => {
acc += curr.items.length
return acc
}, 0)
// Not every shortcut is a link — some (e.g. "Toggle website theme") are actions triggered by a button.
const LINK_COUNT = shortcuts.reduce((acc, curr) => acc + curr.items.filter((item) => 'href' in item).length, 0)
const ITEM_COUNT = shortcuts.reduce((acc, curr) => acc + curr.items.length, 0)

test.describe('CMD+K', () => {
let dialog: Locator
let links: Locator
// All focusable shortcuts: links and action buttons alike. Arrow-key navigation moves through all of them.
let items: Locator
let search_field: Locator

test.beforeEach(async ({ page }) => {
Expand All @@ -19,6 +20,7 @@ test.describe('CMD+K', () => {
await page.getByRole('main').press('Meta+KeyK')
dialog = page.getByRole('menu', { name: 'Command menu' })
links = dialog.getByRole('link')
items = dialog.locator('.shortcut')
search_field = dialog.getByRole('combobox')

// Wait for the dialog to be visible, otherwise the tests might be flaky
Expand All @@ -28,8 +30,9 @@ test.describe('CMD+K', () => {
test('shows command bar', async () => {
await expect.soft(dialog).toBeInViewport()
await expect.soft(links).toHaveCount(LINK_COUNT)
await expect.soft(items).toHaveCount(ITEM_COUNT)

for (let shortcut of await links.all()) {
for (let shortcut of await items.all()) {
await expect.soft(shortcut).toBeVisible()
}
})
Expand All @@ -52,50 +55,50 @@ test.describe('CMD+K', () => {
})

test.describe('keyboard navigation', () => {
test('pressing ArrowUp on the first link moves focus to the combobox', async ({ page }) => {
await links.first().focus()
test('pressing ArrowUp on the first item moves focus to the combobox', async ({ page }) => {
await items.first().focus()
await page.keyboard.press('ArrowUp')

await expect.soft(search_field).toBeFocused()
await expect.soft(links.first()).not.toBeFocused()
await expect.soft(items.first()).not.toBeFocused()
})

test('pressing ArrowDown on the last link moves focus to the combobox', async ({ page }) => {
await links.last().focus()
test('pressing ArrowDown on the last item moves focus to the combobox', async ({ page }) => {
await items.last().focus()
await page.keyboard.press('ArrowDown')

await expect.soft(search_field).toBeFocused()
await expect.soft(links.last()).not.toBeFocused()
await expect.soft(items.last()).not.toBeFocused()
})

test('pressing ArrowDown in the combobox moves focus to the first link', async ({ page }) => {
test('pressing ArrowDown in the combobox moves focus to the first item', async ({ page }) => {
await search_field.focus()
await page.keyboard.press('ArrowDown')

await expect.soft(links.first()).toBeFocused()
await expect.soft(items.first()).toBeFocused()
await expect.soft(search_field).not.toBeFocused()
})

test('pressing ArrowUp in the combobox moves focus to the last link', async ({ page }) => {
test('pressing ArrowUp in the combobox moves focus to the last item', async ({ page }) => {
await search_field.focus()
await page.keyboard.press('ArrowUp')

await expect.soft(links.last()).toBeFocused()
await expect.soft(items.last()).toBeFocused()
await expect.soft(search_field).not.toBeFocused()
})

// TODO: add test for Windows Home/End keys
test('pressing CMD+ArrowUp moves focus to the first link', async ({ page }) => {
test('pressing CMD+ArrowUp moves focus to the first item', async ({ page }) => {
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Meta+ArrowUp')
await expect.soft(links.nth(0)).toBeFocused()
await expect.soft(items.nth(0)).toBeFocused()
})

test('pressing CMD+ArrowDown moves focus to the last link', async ({ page }) => {
test('pressing CMD+ArrowDown moves focus to the last item', async ({ page }) => {
await page.keyboard.press('Meta+ArrowDown')
await expect.soft(links.last()).toBeFocused()
await expect.soft(items.last()).toBeFocused()
})
})

Expand Down
Loading
Loading