From 07cb39774cf1bba5295640665e20bd6ef5f7a3f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 06:59:01 +0000 Subject: [PATCH] redesign: Linear-style full-sheet tag picker + working Paste on mobile The add-bookmark sheet had two mobile-breaking problems: - The tag autocomplete rendered as a dropdown *below* the input, which inside a bottom sheet lands under the footer and soft keyboard - you had to select and scroll up blindly to see any results. - The Paste button reported 'Clipboard is empty' for links copied from the iOS share sheet: those live on the pasteboard as text/uri-list with no text/plain flavor, so clipboard.readText() resolves to ''. Tags on mobile now open a full-sheet picker (modeled on Linear's label picker): search pinned at the top, selected tags as chips, and the tag list filling all remaining space above the keyboard. Tapping toggles, Enter picks the first match, Esc/Done back out to the form. Desktop keeps the inline autocomplete. Paste now prefers clipboard.read(), checking text/uri-list before text/plain, and falls back to readText() for browsers without read(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SPjfx8k7FcETAoYVVPy1kQ --- src/app.css | 7 + .../bookmarks/AddBookmarkDialog.jsx | 637 +++++++++++++----- .../bookmarks/AddBookmarkDialog.test.jsx | 173 +++++ src/hooks/useMediaQuery.js | 30 + 4 files changed, 662 insertions(+), 185 deletions(-) create mode 100644 src/hooks/useMediaQuery.js diff --git a/src/app.css b/src/app.css index 3e64bc1..b9407d4 100644 --- a/src/app.css +++ b/src/app.css @@ -72,6 +72,13 @@ body { animation: add-sheet-in 260ms cubic-bezier(0.16, 1, 0.3, 1); } + /* Tag-picker mode: pin the sheet at full available height so the tag + list fills the space between the search field and the keyboard, + instead of the sheet collapsing to fit its content. */ + .add-dialog-content--full { + height: min(85dvh, calc(var(--visual-viewport-height, 100dvh) - 12px)); + } + @keyframes add-sheet-in { from { transform: translateY(100%); diff --git a/src/components/bookmarks/AddBookmarkDialog.jsx b/src/components/bookmarks/AddBookmarkDialog.jsx index 5f4505a..1021c18 100644 --- a/src/components/bookmarks/AddBookmarkDialog.jsx +++ b/src/components/bookmarks/AddBookmarkDialog.jsx @@ -1,6 +1,15 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' -import { X, Link2, ClipboardPaste, Loader2, Plus } from 'lucide-react' +import { + X, + Link2, + ClipboardPaste, + Loader2, + Plus, + Hash, + Search, + Check, +} from 'lucide-react' import { TagInput } from '../ui/TagInput' import { Tag } from '../ui/Tag' import { @@ -10,8 +19,218 @@ import { normalizeUrl, } from '../../services/bookmarks' import { useContentSuggestion } from '../../hooks/useContentSuggestion' +import { useMediaQuery } from '../../hooks/useMediaQuery' import { cn } from '@/utils/cn' +// Matches the sheet/dialog breakpoint in app.css (Tailwind `sm` = 40rem) +const MOBILE_QUERY = '(max-width: 39.99rem)' + +function firstNonEmptyLine(text, { skipComments = false } = {}) { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line && (!skipComments || !line.startsWith('#'))) || '' + ) +} + +/** + * Read text from the clipboard, preferring the richer read() API. + * + * iOS Safari puts a link copied from the share sheet on the pasteboard as + * text/uri-list with no text/plain flavor, so readText() resolves to an + * empty string ("Clipboard is empty") even though a URL is right there. + * read() sees the URL flavor. + */ +async function readClipboardText() { + const clipboard = navigator.clipboard + if (clipboard?.read) { + try { + const items = await clipboard.read() + for (const item of items) { + // uri-list first: it's the flavor that actually holds the link + if (item.types?.includes('text/uri-list')) { + const blob = await item.getType('text/uri-list') + const line = firstNonEmptyLine(await blob.text(), { skipComments: true }) + if (line) return line + } + if (item.types?.includes('text/plain')) { + const blob = await item.getType('text/plain') + const line = firstNonEmptyLine(await blob.text()) + if (line) return line + } + } + return '' + } catch { + // Fall through — some browsers gate read() harder than readText() + } + } + if (clipboard?.readText) { + return firstNonEmptyLine((await clipboard.readText()) || '') + } + return '' +} + +/** + * TagPickerView - Full-sheet tag picking mode for mobile (Linear-style). + * + * The dropdown-under-the-input pattern is unusable inside a bottom sheet: + * the list renders below the field, under the footer and soft keyboard. + * Instead, the whole sheet becomes the picker: search pinned at the top, + * selected tags as chips, and the tag list filling all remaining space + * above the keyboard. + */ +function TagPickerView({ tags, allTags, onAddTag, onRemoveTag, onDone }) { + const [query, setQuery] = useState('') + const searchRef = useRef(null) + + useEffect(() => { + searchRef.current?.focus() + }, []) + + const normalized = query.trim().toLowerCase() + const matching = useMemo( + () => allTags.filter((tag) => !normalized || tag.toLowerCase().includes(normalized)), + [allTags, normalized] + ) + const canCreate = + normalized !== '' && !allTags.some((tag) => tag.toLowerCase() === normalized) + + const pickTag = (tag) => { + onAddTag(tag) + setQuery('') + searchRef.current?.focus() + } + + const toggleTag = (tag) => { + if (tags.includes(tag)) { + onRemoveTag(tag) + } else { + pickTag(tag) + } + } + + // Enter picks the first visible option; on an empty query it means "done" + const handleSearchKeyDown = (e) => { + if (e.key !== 'Enter') return + e.preventDefault() + if (!normalized) { + onDone() + return + } + const firstMatch = matching.find((tag) => !tags.includes(tag)) + if (firstMatch) { + pickTag(firstMatch) + } else if (canCreate) { + pickTag(normalized) + } + } + + return ( + <> + {/* Picker header */} +
+ + Add tags + + +
+ + {/* Search — pinned at the top so results list downward, always visible */} +
+
+ + setQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="flex-1 min-w-0 h-full bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground/60 focus:ring-0" + /> + {query && ( + + )} +
+
+ + {/* Selected tags */} + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + onRemoveTag(tag)}> + {tag} + + ))} +
+ )} + + {/* Tag list fills everything left above the keyboard */} + + + ) +} + /** * AddBookmarkDialog - The add-bookmark flow, redesigned around one job: * capture a link fast. @@ -20,6 +239,8 @@ import { cn } from '@/utils/cn' * Paste when the clipboard is available. * - Renders as a bottom sheet on mobile (save button always visible above * the keyboard) and a centered dialog on desktop. + * - On mobile, tags are picked in a full-sheet picker (search on top, list + * below) instead of a dropdown that would clip under the keyboard. * - If content suggestions are enabled, committing a URL auto-fills the * title/description and offers suggested tags. * - Enter in URL/title saves; Enter in the description inserts a newline @@ -36,16 +257,22 @@ export function AddBookmarkDialog({ open, onClose, onSaved }) { const [tags, setTags] = useState([]) const [readLater, setReadLater] = useState(false) const [urlError, setUrlError] = useState('') + const [tagPickerOpen, setTagPickerOpen] = useState(false) // Callback-ref state (not a plain ref): Radix assigns the forwarded ref // after parent effects run, so the keyboard-inset effect keys off this. const [contentEl, setContentEl] = useState(null) const [allTags, setAllTags] = useState([]) // Async Clipboard API is unavailable in some browsers (e.g. Firefox - // without permission) — only offer the Paste button when readText exists. + // without permission) — only offer the Paste button when it exists. const [canPasteFromClipboard] = useState( - () => typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText) + () => + typeof navigator !== 'undefined' && + Boolean(navigator.clipboard?.read || navigator.clipboard?.readText) ) + const isMobile = useMediaQuery(MOBILE_QUERY) + const showTagPicker = isMobile && tagPickerOpen + const { suggestions, loading: suggestionsLoading, @@ -68,6 +295,7 @@ export function AddBookmarkDialog({ open, onClose, onSaved }) { setTags([]) setReadLater(false) setUrlError('') + setTagPickerOpen(false) userEditedRef.current = { title: false, description: false } lastSuggestedUrlRef.current = '' clearSuggestions() @@ -162,7 +390,7 @@ export function AddBookmarkDialog({ open, onClose, onSaved }) { const handlePasteUrl = async () => { let text = '' try { - text = (await navigator.clipboard.readText())?.trim() || '' + text = await readClipboardText() } catch { setUrlError("Couldn't read clipboard") return @@ -235,11 +463,15 @@ export function AddBookmarkDialog({ open, onClose, onSaved }) { } } - const addSuggestedTag = (tag) => { - if (!tags.includes(tag)) setTags([...tags, tag]) - } + const addTag = useCallback((tag) => { + const normalized = tag.trim().toLowerCase() + if (!normalized) return + setTags((prev) => (prev.includes(normalized) ? prev : [...prev, normalized])) + }, []) - const removeTag = (tag) => setTags(tags.filter((t) => t !== tag)) + const removeTag = useCallback((tag) => { + setTags((prev) => prev.filter((t) => t !== tag)) + }, []) const canSave = url.trim() !== '' @@ -253,9 +485,16 @@ export function AddBookmarkDialog({ open, onClose, onSaved }) { e.preventDefault() urlInputRef.current?.focus() }} + onEscapeKeyDown={(e) => { + // Esc in the tag picker backs out to the form, not out of the dialog + if (showTagPicker) { + e.preventDefault() + setTagPickerOpen(false) + } + }} onInteractOutside={(e) => { // Don't silently discard a non-empty draft on an outside tap - if (isDirty) e.preventDefault() + if (isDirty || showTagPicker) e.preventDefault() }} onKeyDown={handleContentKeyDown} aria-describedby={undefined} @@ -265,199 +504,227 @@ export function AddBookmarkDialog({ open, onClose, onSaved }) { 'inset-x-0 bottom-0 max-h-[85dvh] rounded-t-2xl border-t border-border', // Desktop: top-anchored centered panel (doesn't jump when suggestions fill in) 'sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-[14vh] sm:-translate-x-1/2', - 'sm:w-full sm:max-w-lg sm:max-h-[72vh] sm:rounded-xl sm:border' + 'sm:w-full sm:max-w-lg sm:max-h-[72vh] sm:rounded-xl sm:border', + // Tag picker: fix the sheet at full height so the list is stable + showTagPicker && 'add-dialog-content--full' )} > - {/* Header */} -
- - New bookmark - - - - -
- - {/* Body */} -
- {/* URL — the only required field, gets the hero spot */} -
-
- {faviconUrl ? ( - { - e.target.style.opacity = 0 - }} - /> - ) : ( - - )} + {showTagPicker ? ( + setTagPickerOpen(false)} + /> + ) : ( + <> + {/* Header */} +
+ + New bookmark + + + + +
+ + {/* Body */} +
+ {/* URL — the only required field, gets the hero spot */} +
+
+ {faviconUrl ? ( + { + e.target.style.opacity = 0 + }} + /> + ) : ( + + )} + { + setUrl(e.target.value) + setUrlError('') + }} + onBlur={handleUrlBlur} + onKeyDown={handleEnterSaves} + className="flex-1 min-w-0 h-full bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground/60 focus:ring-0" + placeholder="Paste or type a link" + /> + {canPasteFromClipboard && !url.trim() && ( + + )} +
+ {urlError && ( +

{urlError}

+ )} + {suggestionsLoading && ( +

+ + Fetching page info… +

+ )} +
+ + {/* Title */} { - setUrl(e.target.value) - setUrlError('') + userEditedRef.current.title = true + setTitle(e.target.value) }} - onBlur={handleUrlBlur} onKeyDown={handleEnterSaves} - className="flex-1 min-w-0 h-full bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground/60 focus:ring-0" - placeholder="Paste or type a link" + className="w-full h-12 sm:h-11 rounded-lg border border-input bg-background px-3 text-sm font-medium placeholder:text-muted-foreground/60 placeholder:font-normal outline-none focus:ring-2 focus:ring-ring transition-shadow" + placeholder="Title (optional)" /> - {canPasteFromClipboard && !url.trim() && ( - - )} -
- {urlError && ( -

{urlError}

- )} - {suggestionsLoading && ( -

- - Fetching page info… -

- )} -
- - {/* Title */} - { - userEditedRef.current.title = true - setTitle(e.target.value) - }} - onKeyDown={handleEnterSaves} - className="w-full h-12 sm:h-11 rounded-lg border border-input bg-background px-3 text-sm font-medium placeholder:text-muted-foreground/60 placeholder:font-normal outline-none focus:ring-2 focus:ring-ring transition-shadow" - placeholder="Title (optional)" - /> - {/* Description — Enter inserts a newline, like any textarea */} -