diff --git a/surface/web/src/sessions/Gallery.test.ts b/surface/web/src/sessions/Gallery.test.ts index b8bbafa3..7ddd5f24 100644 --- a/surface/web/src/sessions/Gallery.test.ts +++ b/surface/web/src/sessions/Gallery.test.ts @@ -472,8 +472,7 @@ describe('Gallery ported affordances', () => { expect(await screen.findByRole('button', { name: 'Unstar file' })).toBeTruthy(); }); - it('adds a label from the tile via the prompt', async () => { - vi.spyOn(window, 'prompt').mockReturnValue('draft'); + it('adds a label from the tile via the inline popover', async () => { const fetchMock = affordanceFetch({ files: [galleryFile({ labels: [] })], labels: { labels: ['draft'] } }); vi.stubGlobal('fetch', fetchMock); @@ -481,6 +480,10 @@ describe('Gallery ported affordances', () => { fireEvent.click(await screen.findByRole('button', { name: 'Add label' })); + const input = await screen.findByPlaceholderText('Add label'); + fireEvent.change(input, { target: { value: 'draft' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect( fetchMock.mock.calls.some( @@ -488,9 +491,31 @@ describe('Gallery ported affordances', () => { ), ).toBe(true), ); + // The popover closes on submit and the optimistic chip appears. + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Add label' })).toBeNull()); expect(await screen.findByText('draft')).toBeTruthy(); }); + it('closes the label popover on Escape without adding a label', async () => { + const fetchMock = affordanceFetch({ files: [galleryFile({ labels: [] })], labels: { labels: ['draft'] } }); + vi.stubGlobal('fetch', fetchMock); + + render(createElement(Gallery, { workspaceId: WORKSPACE_ID })); + + fireEvent.click(await screen.findByRole('button', { name: 'Add label' })); + + const input = await screen.findByPlaceholderText('Add label'); + fireEvent.change(input, { target: { value: 'draft' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Add label' })).toBeNull()); + expect( + fetchMock.mock.calls.some( + ([input, init]) => requestPath(input) === '/api/files/art_memo/labels' && (init?.method ?? 'GET') === 'POST', + ), + ).toBe(false); + }); + it('removes an existing label from the tile', async () => { const fetchMock = affordanceFetch({ files: [galleryFile({ labels: ['draft'] })] }); vi.stubGlobal('fetch', fetchMock); diff --git a/surface/web/src/sessions/Gallery.tsx b/surface/web/src/sessions/Gallery.tsx index ff8bc605..5cf5782e 100644 --- a/surface/web/src/sessions/Gallery.tsx +++ b/surface/web/src/sessions/Gallery.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { JSX, KeyboardEvent } from 'react'; +import type { CSSProperties, JSX, KeyboardEvent, RefObject } from 'react'; import { containsCriticMarkup, FILE_CATEGORIES, @@ -23,6 +23,7 @@ import type { LightboxCallbacks, PreviewFile } from '../components/media'; import { effectiveMediaKind, isAppFile } from '../components/media/utils'; import { showErrorToast } from '../components/Toasts'; import { navigate, URL_PARAMS, useLocation } from '../router'; +import { useDialog } from '../useDialog'; import { EmptyState } from './EmptyState'; import { artifactEntryHandle, @@ -290,9 +291,97 @@ function nestedControlKeyDown(event: KeyboardEvent, run: () => void run(); } +const LABEL_POPOVER_WIDTH = 192; + +/** + * Inline "Add label" popover anchored under the card's "+ label" chip. Built on + * the same `useDialog` primitive as the other anchored menus (ChannelMembersMenu, + * MessageActionMenu): it focuses the input on open, returns focus to the chip on + * close, and owns Escape (isModalDialogOpen makes the layered-escape dispatcher + * stand down, so Escape here never reaches the lightbox/route behind the grid). + * Positioned `fixed` off the chip's rect so it escapes the card's overflow clip. + */ +function LabelPopover({ + anchorRef, + suggestions, + suggestionsId, + onSubmit, + onClose, +}: { + anchorRef: RefObject; + suggestions: string[]; + suggestionsId: string; + onSubmit: (label: string) => void; + onClose: () => void; +}) { + const containerRef = useRef(null); + const inputRef = useRef(null); + const [draft, setDraft] = useState(''); + + useDialog({ + open: true, + containerRef, + initialFocusRef: inputRef, + invokerRef: anchorRef, + closeOnOutsidePointer: true, + onClose, + }); + + const submit = () => { + const trimmed = draft.trim(); + // Empty and duplicate handling lives in the card's addLabel; submit always closes. + if (trimmed) onSubmit(trimmed); + onClose(); + }; + + const rect = anchorRef.current?.getBoundingClientRect(); + const viewportWidth = typeof window === 'undefined' ? 0 : window.innerWidth; + const style: CSSProperties = rect + ? { top: rect.bottom + 4, left: Math.max(8, Math.min(rect.left, viewportWidth - LABEL_POPOVER_WIDTH - 8)) } + : { top: 0, left: 0 }; + + return ( + // Clicks/keys inside the popover are stopped so they never reach the + // keyboard-activatable card behind it (which would open the lightbox). +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + className="fixed z-dropdown w-48 rounded-md border border-edge-strong bg-surface-overlay p-1.5 shadow-lg" + > + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + }} + list={suggestions.length > 0 ? suggestionsId : undefined} + placeholder="Add label" + aria-label="Add label" + className="w-full rounded border border-edge bg-surface px-2 py-1 text-2xs text-fg-body outline-none focus:border-edge-focus placeholder:text-fg-faint" + /> + {suggestions.length > 0 && ( + + {suggestions.map((label) => ( + + )} +
+ ); +} + function GalleryCard({ file, references, + knownLabels, onOpen, onToggleStar, onAddLabel, @@ -302,9 +391,10 @@ function GalleryCard({ }: { file: HubFile; references?: EntryReferenceSummary | null; + knownLabels: string[]; onOpen: () => void; onToggleStar: () => void; - onAddLabel: () => void; + onAddLabel: (label: string) => void; onRemoveLabel: (label: string) => void; onRestore: () => void; previewHydrated: boolean; @@ -313,6 +403,12 @@ function GalleryCard({ // stable preview object so text renderers do not refetch behind the overlay. const preview = useMemo(() => hubFileToPreview(file), [file]); const showPath = effectiveMediaKind(preview) !== 'image'; + const [labelPopoverOpen, setLabelPopoverOpen] = useState(false); + const addLabelRef = useRef(null); + const labelSuggestions = useMemo( + () => knownLabels.filter((label) => !file.labels.includes(label)), + [file.labels, knownLabels], + ); return ( // biome-ignore lint/a11y/useSemanticElements: keyboard-activatable file card; a