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 */}
+
+ {matching.map((tag) => {
+ const selected = tags.includes(tag)
+ return (
+ -
+
+
+ )
+ })}
+ {canCreate && (
+ -
+
+
+ )}
+ {matching.length === 0 && !canCreate && (
+ -
+ No tags yet — type to create one
+
+ )}
+
+ >
+ )
+}
+
/**
* 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 */}
-
- {/* Footer — save is always visible, never under the keyboard */}
-
-
- Enter to save · Esc to cancel
-
-
-
-
+ {/* Footer — save is always visible, never under the keyboard */}
+
+
+ Enter to save · Esc to cancel
+
+
+
+
+ >
+ )}
diff --git a/src/components/bookmarks/AddBookmarkDialog.test.jsx b/src/components/bookmarks/AddBookmarkDialog.test.jsx
index d7bc347..0d6671b 100644
--- a/src/components/bookmarks/AddBookmarkDialog.test.jsx
+++ b/src/components/bookmarks/AddBookmarkDialog.test.jsx
@@ -137,6 +137,179 @@ describe('AddBookmarkDialog', () => {
screen.queryByRole('button', { name: 'Paste link from clipboard' })
).not.toBeInTheDocument()
})
+
+ // iOS Safari exposes a copied link as text/uri-list with no text/plain
+ // flavor, so readText() alone resolves to '' ("Clipboard is empty").
+ it('reads a URL that only exists as text/uri-list (iOS share sheet copy)', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ read: vi.fn(async () => [
+ {
+ types: ['text/uri-list'],
+ getType: vi.fn(async () => ({
+ text: async () => '# comment line\r\nhttps://example.com/from-ios\r\n',
+ })),
+ },
+ ]),
+ readText: vi.fn(async () => ''),
+ },
+ configurable: true,
+ })
+ renderDialog()
+ fireEvent.click(screen.getByRole('button', { name: 'Paste link from clipboard' }))
+ await waitFor(() => {
+ expect(getUrlInput()).toHaveValue('https://example.com/from-ios')
+ })
+ })
+
+ it('reads text/plain via read() when uri-list is absent', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ read: vi.fn(async () => [
+ {
+ types: ['text/plain'],
+ getType: vi.fn(async () => ({
+ text: async () => 'example.com/plain',
+ })),
+ },
+ ]),
+ },
+ configurable: true,
+ })
+ renderDialog()
+ fireEvent.click(screen.getByRole('button', { name: 'Paste link from clipboard' }))
+ await waitFor(() => {
+ expect(getUrlInput()).toHaveValue('https://example.com/plain')
+ })
+ })
+
+ it('falls back to readText() when read() is rejected', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ read: vi.fn(async () => {
+ throw new Error('NotAllowedError')
+ }),
+ readText: vi.fn(async () => 'https://example.com/fallback'),
+ },
+ configurable: true,
+ })
+ renderDialog()
+ fireEvent.click(screen.getByRole('button', { name: 'Paste link from clipboard' }))
+ await waitFor(() => {
+ expect(getUrlInput()).toHaveValue('https://example.com/fallback')
+ })
+ })
+
+ it('is rendered when only clipboard.read() exists', () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { read: vi.fn(async () => []) },
+ configurable: true,
+ })
+ renderDialog()
+ expect(
+ screen.getByRole('button', { name: 'Paste link from clipboard' })
+ ).toBeInTheDocument()
+ })
+ })
+
+ describe('mobile tag picker', () => {
+ let originalMatchMedia
+
+ beforeEach(() => {
+ originalMatchMedia = window.matchMedia
+ // Pretend we're below the sm breakpoint
+ window.matchMedia = vi.fn(() => ({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }))
+ })
+
+ afterEach(() => {
+ window.matchMedia = originalMatchMedia
+ })
+
+ const openPicker = () => {
+ fireEvent.click(screen.getByRole('button', { name: 'Add tags' }))
+ }
+
+ it('replaces the inline tag dropdown with a trigger button on mobile', () => {
+ renderDialog()
+ expect(screen.getByRole('button', { name: 'Add tags' })).toBeInTheDocument()
+ expect(screen.queryByPlaceholderText('Add tags...')).not.toBeInTheDocument()
+ })
+
+ it('opens a full-sheet picker listing all existing tags', () => {
+ renderDialog()
+ openPicker()
+ expect(screen.getByPlaceholderText('Search or add tags')).toHaveFocus()
+ expect(screen.getByRole('button', { name: /react/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /design/ })).toBeInTheDocument()
+ // The form is swapped out while picking
+ expect(screen.queryByText('New bookmark')).not.toBeInTheDocument()
+ })
+
+ it('selects tags by tapping and returns to the form via Done', () => {
+ renderDialog()
+ openPicker()
+ fireEvent.click(screen.getByRole('button', { name: /react/ }))
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }))
+ expect(screen.getByText('New bookmark')).toBeInTheDocument()
+ expect(screen.getByText('react')).toBeInTheDocument()
+
+ fireEvent.change(getUrlInput(), { target: { value: 'https://example.com' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save bookmark' }))
+ expect(createBookmark).toHaveBeenCalledWith(
+ expect.objectContaining({ tags: ['react'] })
+ )
+ })
+
+ it('tapping an already-selected tag deselects it', () => {
+ renderDialog()
+ openPicker()
+ const reactRow = () => screen.getByRole('button', { name: /react/ })
+ fireEvent.click(reactRow())
+ expect(reactRow()).toHaveAttribute('aria-pressed', 'true')
+ fireEvent.click(reactRow())
+ expect(reactRow()).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ it('filters the list and offers to create an unknown tag', () => {
+ renderDialog()
+ openPicker()
+ const search = screen.getByPlaceholderText('Search or add tags')
+ fireEvent.change(search, { target: { value: 'Newtag' } })
+ expect(screen.queryByRole('button', { name: /design/ })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /Create/ }))
+ // Tag added (lowercased), query cleared, full list visible again
+ expect(search).toHaveValue('')
+ expect(screen.getByRole('button', { name: /design/ })).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }))
+ expect(screen.getByText('newtag')).toBeInTheDocument()
+ })
+
+ it('Enter picks the first matching tag; Enter on empty query closes the picker', () => {
+ renderDialog()
+ openPicker()
+ const search = screen.getByPlaceholderText('Search or add tags')
+ fireEvent.change(search, { target: { value: 'rea' } })
+ fireEvent.keyDown(search, { key: 'Enter' })
+ expect(search).toHaveValue('')
+ fireEvent.keyDown(search, { key: 'Enter' })
+ // Back on the form with the tag chip present
+ expect(screen.getByText('New bookmark')).toBeInTheDocument()
+ expect(screen.getByText('react')).toBeInTheDocument()
+ })
+
+ it('Escape closes the picker, not the dialog', () => {
+ renderDialog()
+ openPicker()
+ fireEvent.keyDown(screen.getByPlaceholderText('Search or add tags'), {
+ key: 'Escape',
+ })
+ expect(screen.getByText('New bookmark')).toBeInTheDocument()
+ expect(onClose).not.toHaveBeenCalled()
+ })
})
describe('saving', () => {
diff --git a/src/hooks/useMediaQuery.js b/src/hooks/useMediaQuery.js
new file mode 100644
index 0000000..4913ada
--- /dev/null
+++ b/src/hooks/useMediaQuery.js
@@ -0,0 +1,30 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * Reactive media-query hook. Returns false in environments without
+ * matchMedia (SSR, older test runners).
+ */
+export function useMediaQuery(query) {
+ const [matches, setMatches] = useState(
+ () =>
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia(query).matches
+ )
+
+ useEffect(() => {
+ if (typeof window.matchMedia !== 'function') return
+ const mql = window.matchMedia(query)
+ const onChange = () => setMatches(mql.matches)
+ onChange()
+ // Safari < 14 only has the deprecated addListener API
+ if (mql.addEventListener) {
+ mql.addEventListener('change', onChange)
+ return () => mql.removeEventListener('change', onChange)
+ }
+ mql.addListener(onChange)
+ return () => mql.removeListener(onChange)
+ }, [query])
+
+ return matches
+}