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
113 changes: 113 additions & 0 deletions e2e/search-field-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/* oxlint-disable vitest/prefer-importing-vitest-globals -- Playwright spec */
/** Search fields must put one restrained focus treatment on their outer control. */
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';

import { loadDeck } from './support/deck';

test.use({ viewport: { width: 1440, height: 900 } });

async function focusStyle(input: Locator): Promise<{
inputOutline: string;
restingBorder: string;
containerBorder: string;
containerShadow: string;
containerHeight: number;
}> {
await input.evaluate((element) => element.blur());
const restingBorder = await input.evaluate((element) => {
if (!element.parentElement) {
throw new Error('Search input has no visual container');
}
return getComputedStyle(element.parentElement).borderColor;
});
await input.focus();
await expect(input).toBeFocused();
return input.evaluate((element, baseBorder) => {
const container = element.parentElement;
if (!container) {
throw new Error('Search input has no visual container');
}
const inputStyle = getComputedStyle(element);
const containerStyle = getComputedStyle(container);
return {
inputOutline: inputStyle.outlineStyle,
restingBorder: baseBorder,
containerBorder: containerStyle.borderColor,
containerShadow: containerStyle.boxShadow,
containerHeight: container.getBoundingClientRect().height,
};
}, restingBorder);
}

async function openBackstage(page: Page): Promise<void> {
await page
.getByRole('toolbar', { name: 'Presentation toolbar' })
.getByRole('tab', { name: 'File', exact: true })
.click();
await expect(page.getByRole('dialog', { name: 'File' })).toBeVisible();
}

test('title-bar and recent-files search show one restrained focus border', async ({ page }) => {
await loadDeck(page);
const titleInput = page.getByPlaceholder('Tell me what you want to do');
const title = await focusStyle(titleInput);
await titleInput.fill('save');
await expect(titleInput).toBeFocused();
await openBackstage(page);
const recentInput = page
.getByRole('dialog', { name: 'File' })
.getByPlaceholder('Search recent presentations');
const recent = await focusStyle(recentInput);
await recentInput.fill('example');
await expect(recentInput).toBeFocused();

for (const [name, style] of [
['title bar', title],
['recent files', recent],
] as const) {
expect(style.inputOutline, `${name}: inner input must not draw a second outline`).toBe('none');
expect(style.containerBorder, `${name}: outer border must respond to focus`).not.toBe(
style.restingBorder,
);
expect(style.containerShadow, `${name}: ordinary focus should not add a heavy halo`).toBe(
'none',
);
}
// Distinct sizes match the two chrome locations without binding-specific numbers.
expect(title.containerHeight).toBeGreaterThan(20);
expect(recent.containerHeight).toBeGreaterThan(title.containerHeight);
});

test('search focus remains visible in forced-colors mode', async ({ page }) => {
await page.emulateMedia({ forcedColors: 'active' });
await loadDeck(page);
const title = page.getByPlaceholder('Tell me what you want to do');
await title.focus();
await expect(title.locator('..')).toHaveCSS('outline-style', 'solid');
await openBackstage(page);
const recent = page
.getByRole('dialog', { name: 'File' })
.getByPlaceholder('Search recent presentations');
await recent.focus();
await expect(recent.locator('..')).toHaveCSS('outline-style', 'solid');
});

test('both search fields stay within a narrow desktop viewport', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 600 });
await loadDeck(page);
const title = page.getByPlaceholder('Tell me what you want to do');
await expect(title).toBeVisible();
const titleBox = await title.locator('..').boundingBox();
expect(titleBox).not.toBeNull();
expect(titleBox!.x + titleBox!.width).toBeLessThanOrEqual(800);

await openBackstage(page);
const recent = page
.getByRole('dialog', { name: 'File' })
.getByPlaceholder('Search recent presentations');
await expect(recent).toBeVisible();
const recentBox = await recent.locator('..').boundingBox();
expect(recentBox).not.toBeNull();
expect(recentBox!.x + recentBox!.width).toBeLessThanOrEqual(800);
});
14 changes: 12 additions & 2 deletions packages/angular/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@
mix-blend-mode: normal !important;
}

:where(.pptx-ng-viewer) :where(button, a, input, select, textarea, [tabindex]):focus-visible {
:where(.pptx-ng-viewer)
:where(button, a, input, select, textarea, [tabindex]):not(
[data-pptx-search-input]
):focus-visible {
outline: 2px solid var(--pptx-ring, #818cf8) !important;
outline-offset: 2px;
}
Expand All @@ -192,7 +195,14 @@
}

@media (forced-colors: active) {
:where(.pptx-ng-viewer) :where(button, a, input, select, textarea, [tabindex]):focus-visible {
:where(.pptx-ng-viewer)
:where(button, a, input, select, textarea, [tabindex]):not(
[data-pptx-search-input]
):focus-visible {
outline-color: Highlight;
}
:where(.pptx-ng-viewer) [data-pptx-search-surface]:focus-within {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
14 changes: 0 additions & 14 deletions packages/angular/src/viewer/ribbon-file-section.component.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,6 @@ h2 {
color: var(--pptx-muted-foreground, #777);
font-size: 10px;
}
.search {
display: block;
width: min(540px, 100%);
height: 40px;
margin-top: 32px;
padding: 0 14px;
border: 1px solid var(--pptx-input, #888);
background: var(--pptx-card, #fff);
color: var(--pptx-card-foreground, #242424);
}
.search:focus {
border-color: var(--pptx-ring, #c43e1c);
outline: none;
}
.primary {
margin-top: 16px;
padding: 10px 20px;
Expand Down
19 changes: 12 additions & 7 deletions packages/angular/src/viewer/ribbon-file-section.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ <h2>{{ 'pptx.backstage.newHeading' | translate }}</h2>
</div>
}
@if (page() === 'home' || page() === 'open') {
<input
class="search"
type="search"
[attr.placeholder]="'pptx.backstage.searchPlaceholder' | translate"
[value]="query()"
(input)="query.set($any($event.target).value)"
/>
<div data-pptx-search-surface [class]="searchClasses.box">
<svg lucideSearch [class]="searchClasses.icon" aria-hidden="true"></svg>
<input
data-pptx-search-input
[class]="searchClasses.input"
type="search"
[attr.placeholder]="'pptx.backstage.searchPlaceholder' | translate"
[attr.aria-label]="'pptx.backstage.searchPlaceholder' | translate"
[value]="query()"
(input)="query.set($any($event.target).value)"
/>
</div>
@if (page() === 'open') {
<button class="primary" type="button" (click)="run(openFile)">
{{ 'pptx.backstage.browseDevice' | translate }}
Expand Down
5 changes: 4 additions & 1 deletion packages/angular/src/viewer/ribbon-file-section.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import {
output,
signal,
} from '@angular/core';
import { LucideSearch } from '@lucide/angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';

import {
BACKSTAGE_NAV,
BACKSTAGE_SEARCH_CLASSES,
BACKSTAGE_TEMPLATES,
backstageCardsFor,
formatBackstageDate,
Expand Down Expand Up @@ -74,11 +76,12 @@ export function visibleMainNav(
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'contents' },
imports: [BackstageNavIconComponent, AccountPageComponent, TranslatePipe],
imports: [BackstageNavIconComponent, AccountPageComponent, TranslatePipe, LucideSearch],
templateUrl: './ribbon-file-section.component.html',
styleUrl: './ribbon-file-section.component.css',
})
export class RibbonFileSectionComponent {
protected readonly searchClasses = BACKSTAGE_SEARCH_CLASSES;
readonly fileName = input<string>();
readonly slideCount = input(0);
readonly exporting = input(false);
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/src/viewer/title-bar-search.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ const BLUR_GRACE_MS = 150;
host: { class: 'relative w-full max-w-md' },
template: `
<div
data-pptx-search-surface
[class]="tb.searchBox"
[ngClass]="searchFocused() || findReplaceOpen() ? 'text-foreground bg-background' : ''"
>
<span [class]="tb.searchIcon" aria-hidden="true"
><svg lucideSearch class="h-3.5 w-3.5"></svg
></span>
<input
data-pptx-search-input
type="text"
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value)"
Expand Down
14 changes: 12 additions & 2 deletions packages/react/src/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@
z-index: 999;
}

:where([data-pptx-viewer]) :where(button, a, input, select, textarea, [tabindex]):focus-visible {
:where([data-pptx-viewer])
:where(button, a, input, select, textarea, [tabindex]):not(
[data-pptx-search-input]
):focus-visible {
outline: 2px solid var(--pptx-ring, #818cf8) !important;
outline-offset: 2px;
}
Expand All @@ -177,7 +180,14 @@
}

@media (forced-colors: active) {
:where([data-pptx-viewer]) :where(button, a, input, select, textarea, [tabindex]):focus-visible {
:where([data-pptx-viewer])
:where(button, a, input, select, textarea, [tabindex]):not(
[data-pptx-search-input]
):focus-visible {
outline-color: Highlight;
}
:where([data-pptx-viewer]) [data-pptx-search-surface]:focus-within {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}
15 changes: 11 additions & 4 deletions packages/react/src/viewer/components/toolbar/FileSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { BACKSTAGE_NAV, listBackstageRecentFiles } from 'pptx-viewer-shared';
import {
BACKSTAGE_NAV,
BACKSTAGE_SEARCH_CLASSES,
listBackstageRecentFiles,
} from 'pptx-viewer-shared';
import type { BackstagePage, BackstageRecentFile } from 'pptx-viewer-shared';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -101,13 +105,16 @@ export function FileSection(p: FileSectionProps): React.ReactElement {
)}
{(page === 'home' || page === 'open') && (
<>
<div className='mt-8 flex max-w-[540px] items-center border border-input bg-card px-3 focus-within:border-ring'>
<LuSearch className='text-muted-foreground' />
<div data-pptx-search-surface className={BACKSTAGE_SEARCH_CLASSES.box}>
<LuSearch className={BACKSTAGE_SEARCH_CLASSES.icon} aria-hidden='true' />
<input
data-pptx-search-input
type='search'
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('pptx.backstage.searchPlaceholder')}
className='h-10 min-w-0 flex-1 bg-transparent px-3 text-[13px] outline-none'
className={BACKSTAGE_SEARCH_CLASSES.input}
aria-label={t('pptx.backstage.searchPlaceholder')}
/>
</div>
{page === 'open' && (
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/viewer/components/toolbar/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,15 @@ export function TitleBar(p: TitleBarProps): React.ReactElement {
{(p.mode === 'edit' || p.mode === 'master') && (
<div ref={searchRef} className='relative w-full max-w-md'>
<div
data-pptx-search-surface
className={cn(
TB.searchBox,
(searchFocused || p.findReplaceOpen) && 'text-foreground bg-background',
)}
>
<LuSearch className={TB.searchIcon} />
<input
data-pptx-search-input
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/render/backstage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { getAutosaveSnapshot, listAutosaveSnapshots } from './autosave-store';
import { rememberSessionDeck } from './session-restore';
import { createBlankSlide } from './slide-operations';

/** The recent-files search control is visually owned by its outer frame. */
export const BACKSTAGE_SEARCH_CLASSES = {
box: 'mt-8 flex h-10 w-full max-w-[540px] items-center gap-2 border border-input bg-card px-3 text-muted-foreground focus-within:border-ring',
icon: 'size-4 shrink-0',
input:
'h-full min-w-0 flex-1 border-0 bg-transparent text-[13px] text-card-foreground outline-none placeholder:text-muted-foreground',
} as const;

export type BackstagePage =
| 'home'
| 'new'
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/render/title-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export const TITLE_BAR_CLASSES = {
/** Centred search area. */
searchWrap: 'flex-1 flex justify-center min-w-0 px-2',
searchBox:
'flex items-center gap-2 w-full max-w-md px-3 py-[3px] rounded-md bg-background/70 border border-border/60 text-muted-foreground hover:bg-background hover:text-foreground transition-colors',
'flex items-center gap-2 w-full max-w-md px-3 py-[3px] rounded-md bg-background/70 border border-border/60 text-muted-foreground hover:bg-background hover:text-foreground focus-within:border-ring transition-colors',
searchIcon: 'w-3 h-3 shrink-0',
searchLabel: 'text-[11px] truncate',
/** Right-hand spacer mirroring the left block so search stays centred. */
Expand Down
9 changes: 5 additions & 4 deletions packages/svelte/src/viewer/components/TitleBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@
{/if}
<span class="pptx-svelte-titlebar-file"><strong>{fileName || t('pptx.titleBar.defaultFileName')}</strong>{#if editable}<span aria-hidden="true">&bull;</span><span class:error={autosaveStatus === 'error' && autosaveEnabled} class:saving={autosaveStatus === 'saving' && autosaveEnabled}>{t(statusKey)}</span>{/if}</span>
{#if editable}<div class="pptx-svelte-titlebar-search">
<div class:active={focused || findReplaceOpen} class="pptx-svelte-titlebar-searchbox">
<div data-pptx-search-surface class:active={focused || findReplaceOpen} class="pptx-svelte-titlebar-searchbox">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="3.7" fill="none" stroke="currentColor" stroke-width="1.4" /><path d="m10 10 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" /></svg>
<input type="text" bind:value={query} onfocus={() => (focused = true)} onblur={() => setTimeout(() => (focused = false), 120)} onkeydown={onSearchKeydown} placeholder={t('pptx.titleBar.searchPlaceholder')} aria-label={t('pptx.titleBar.search')} />
<input data-pptx-search-input type="text" bind:value={query} onfocus={() => (focused = true)} onblur={() => setTimeout(() => (focused = false), 120)} onkeydown={onSearchKeydown} placeholder={t('pptx.titleBar.searchPlaceholder')} aria-label={t('pptx.titleBar.search')} />
</div>
{#if focused && query.trim()}
<div class="pptx-svelte-titlebar-results">
Expand Down Expand Up @@ -184,12 +184,13 @@
.pptx-svelte-titlebar-actions button:hover:not(:disabled) { background:var(--pptx-accent,#33334d); }
.pptx-svelte-titlebar-actions button:disabled { opacity:.4; cursor:default; }
.pptx-svelte-titlebar-actions button small { font-size:10px; white-space:nowrap; }
.pptx-svelte-titlebar-actions svg,.pptx-svelte-titlebar-searchbox svg { width:15px; height:15px; }
.pptx-svelte-titlebar-actions svg { width:15px; height:15px; }.pptx-svelte-titlebar-searchbox svg { width:12px; height:12px; flex:none; }
.pptx-svelte-titlebar-file { min-width:0; color:var(--pptx-muted-foreground,#a5a5b5); overflow:hidden; }
.pptx-svelte-titlebar-file strong { max-width:200px; overflow:hidden; color:inherit; font-size:var(--pptx-tb-file-size); font-weight:var(--pptx-tb-file-weight); text-overflow:ellipsis; }
.pptx-svelte-titlebar-file .error { color:#f87171; }.pptx-svelte-titlebar-file .saving { color:#facc15; }
.pptx-svelte-titlebar-search { position:relative; display:flex; flex:1; justify-content:center; min-width:0; }
.pptx-svelte-titlebar-searchbox { display:flex; align-items:center; gap:7px; width:min(100%,448px); padding:4px 10px; border:1px solid var(--pptx-border,#33334d); border-radius:6px; background:var(--pptx-background,#11111b); color:var(--pptx-muted-foreground,#a5a5b5); }
.pptx-svelte-titlebar-searchbox { display:flex; align-items:center; gap:8px; width:min(100%,448px); padding:3px 12px; border:1px solid var(--pptx-border,#33334d); border-radius:6px; background:var(--pptx-background,#11111b); color:var(--pptx-muted-foreground,#a5a5b5); }
.pptx-svelte-titlebar-searchbox:focus-within { border-color:var(--pptx-ring,#6366f1); }
.pptx-svelte-titlebar-searchbox.active { border-color:var(--pptx-primary,#6366f1); color:var(--pptx-card-foreground,#e2e8f0); }.pptx-svelte-titlebar-searchbox input { width:100%; border:0; outline:0; background:transparent; color:inherit; font:inherit; }
.pptx-svelte-titlebar-results { position:absolute; top:calc(100% + 4px); z-index:20; width:min(100%,448px); overflow:hidden; border:1px solid var(--pptx-border,#33334d); border-radius:7px; background:var(--pptx-card,#1e1e2e); box-shadow:0 14px 28px #0006; }.pptx-svelte-titlebar-results > span { display:block; padding:7px 10px; color:var(--pptx-muted-foreground,#a5a5b5); font-size:10px; font-weight:700; text-transform:uppercase; }.pptx-svelte-titlebar-results button { display:flex; width:100%; align-items:center; gap:8px; border:0; padding:6px 10px; background:transparent; color:inherit; text-align:left; cursor:pointer; }.pptx-svelte-titlebar-results button:hover { background:var(--pptx-accent,#33334d); }.pptx-svelte-titlebar-results small { margin-left:auto; color:var(--pptx-muted-foreground,#a5a5b5); text-transform:capitalize; }
@media (max-width: 767px), (max-width: 1023px) and (max-height: 520px) { .pptx-svelte-titlebar { display:none; } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
-->

<style>
:global(.pptx-svelte-viewer :is(button, a, input, select, textarea, [tabindex]):focus-visible) {
:global(.pptx-svelte-viewer :is(button, a, input, select, textarea, [tabindex]):not([data-pptx-search-input]):focus-visible) {
outline: 2px solid var(--pptx-ring, #818cf8) !important;
outline-offset: 2px;
}
Expand Down Expand Up @@ -91,9 +91,13 @@
}

@media (forced-colors: active) {
:global(.pptx-svelte-viewer :is(button, a, input, select, textarea, [tabindex]):focus-visible) {
:global(.pptx-svelte-viewer :is(button, a, input, select, textarea, [tabindex]):not([data-pptx-search-input]):focus-visible) {
outline-color: Highlight;
}
:global(.pptx-svelte-viewer [data-pptx-search-surface]:focus-within) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
}

@media (max-width: 767px), (max-width: 1023px) and (max-height: 520px) {
Expand Down
Loading
Loading