WIP: make clipboard cards keyboard accessible

This commit is contained in:
Akshay Kolli
2026-06-30 08:56:45 -07:00
parent a66798bf00
commit a540421fe9
4 changed files with 171 additions and 26 deletions

View File

@@ -16,6 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- `Shift + Command + N` creates a new collection - `Shift + Command + N` creates a new collection
- `Space` previews the selected card when the focused search field is empty - `Space` previews the selected card when the focused search field is empty
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references - Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
- Keyboard-focusable cards with Return-to-paste, Space-to-preview for Quick Look capable clips, visible focus chrome, and VoiceOver action hints
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads - SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
- Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images - Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items - Sort modes for recent, most used, images, links, text, files, audio, and pinned items

View File

@@ -37,19 +37,20 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
4. Clear the search field, press `Space`, and confirm the selected previewable clip opens in Quick Look instead of inserting a blank query. 4. Clear the search field, press `Space`, and confirm the selected previewable clip opens in Quick Look instead of inserting a blank query.
5. Use arrow keys to move selection while the search field is focused. 5. Use arrow keys to move selection while the search field is focused.
6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear. 6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear.
7. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent. 7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for previewable clips.
8. Press `Esc` once with a non-empty search field and confirm search clears. 8. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent.
9. Press `Esc` again and confirm the panel closes. 9. Press `Esc` once with a non-empty search field and confirm search clears.
10. Reopen the panel, change sort segments, and confirm each segment updates results. 10. Press `Esc` again and confirm the panel closes.
11. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view. 11. Reopen the panel, change sort segments, and confirm each segment updates results.
12. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. 12. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
13. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. 13. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
14. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. 14. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
15. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload. 15. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
16. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 16. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload.
17. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 17. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
18. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. 18. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
19. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 19. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
20. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
## Copy And Paste ## Copy And Paste

View File

@@ -1205,6 +1205,28 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
cardViews.compactMap { $0.accessibilityLabel() } cardViews.compactMap { $0.accessibilityLabel() }
} }
var debugCardAccessibilityValues: [String] {
cardViews.compactMap { $0.accessibilityValue() as? String }
}
var debugCardAccessibilityHelps: [String] {
cardViews.compactMap { $0.accessibilityHelp() }
}
var debugCardAcceptsFirstResponder: [Bool] {
cardViews.map(\.acceptsFirstResponder)
}
var debugKeyboardFocusedCardIndexes: [Int] {
cardViews.enumerated().compactMap { index, card in
card.debugIsKeyboardFocused ? index : nil
}
}
var debugCardBorderWidths: [CGFloat] {
cardViews.map(\.debugBorderWidth)
}
var debugCardPreviewSummaries: [String] { var debugCardPreviewSummaries: [String] {
cardViews.map(\.debugPreviewSummary) cardViews.map(\.debugPreviewSummary)
} }
@@ -1334,7 +1356,20 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
return window?.makeFirstResponder(chip) ?? false return window?.makeFirstResponder(chip) ?? false
} }
func debugFocusCard(at index: Int) -> Bool {
guard index >= 0, index < cardViews.count else { return false }
return window?.makeFirstResponder(cardViews[index]) ?? false
}
func debugPressFocusedResponderWithReturn() {
debugPressFocusedResponder(characters: "\r", keyCode: 36)
}
func debugPressFocusedResponderWithSpace() { func debugPressFocusedResponderWithSpace() {
debugPressFocusedResponder(characters: " ", keyCode: 49)
}
private func debugPressFocusedResponder(characters: String, keyCode: UInt16) {
guard let window, guard let window,
let event = NSEvent.keyEvent( let event = NSEvent.keyEvent(
with: .keyDown, with: .keyDown,
@@ -1343,10 +1378,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
timestamp: 0, timestamp: 0,
windowNumber: window.windowNumber, windowNumber: window.windowNumber,
context: nil, context: nil,
characters: " ", characters: characters,
charactersIgnoringModifiers: " ", charactersIgnoringModifiers: characters,
isARepeat: false, isARepeat: false,
keyCode: 49 keyCode: keyCode
) else { ) else {
return return
} }
@@ -2001,6 +2036,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private weak var quickPasteBadgeLabel: NSTextField? private weak var quickPasteBadgeLabel: NSTextField?
private var isSelected = false private var isSelected = false
private var isHovered = false private var isHovered = false
private var isKeyboardFocused = false
private var mouseDownLocation: NSPoint? private var mouseDownLocation: NSPoint?
private var trackingAreaRef: NSTrackingArea? private var trackingAreaRef: NSTrackingArea?
@@ -2045,11 +2081,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
func setSelected(_ selected: Bool) { func setSelected(_ selected: Bool) {
isSelected = selected isSelected = selected
contentView.layer?.borderWidth = 1 contentView.layer?.borderWidth = isKeyboardFocused ? 2 : 1
contentView.layer?.borderColor = selected ? Palette.selectedBorder : Palette.border
if selected { if selected {
contentView.layer?.backgroundColor = Palette.selectedSurface contentView.layer?.backgroundColor = Palette.selectedSurface
contentView.layer?.borderColor = Palette.selectedBorder contentView.layer?.borderColor = isKeyboardFocused
? NSColor.controlAccentColor.withAlphaComponent(0.86).cgColor
: Palette.selectedBorder
} else if isKeyboardFocused {
contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.58).cgColor
} else if isHovered { } else if isHovered {
contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.28).cgColor contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.28).cgColor
@@ -2057,13 +2097,52 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
contentView.layer?.backgroundColor = Palette.cardSurface contentView.layer?.backgroundColor = Palette.cardSurface
contentView.layer?.borderColor = Palette.border contentView.layer?.borderColor = Palette.border
} }
layer?.shadowOpacity = selected ? 0.16 : (isHovered ? 0.12 : 0.08) let emphasized = selected || isKeyboardFocused
layer?.shadowRadius = selected ? 16 : 12 layer?.shadowOpacity = emphasized ? 0.16 : (isHovered ? 0.12 : 0.08)
layer?.shadowOffset = NSSize(width: 0, height: selected ? 6 : 4) layer?.shadowRadius = emphasized ? 16 : 12
layer?.transform = selected ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity layer?.shadowOffset = NSSize(width: 0, height: emphasized ? 6 : 4)
layer?.transform = emphasized ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity
setAccessibilityValue(selected ? "Selected" : "Not selected")
updateActionRailVisibility() updateActionRailVisibility()
} }
override var acceptsFirstResponder: Bool {
true
}
override func becomeFirstResponder() -> Bool {
isKeyboardFocused = true
onSelect(index)
setSelected(isSelected)
return true
}
override func resignFirstResponder() -> Bool {
isKeyboardFocused = false
setSelected(isSelected)
return true
}
override func keyDown(with event: NSEvent) {
switch event.keyCode {
case 36, 76:
onPaste(index)
case 49:
if canPreview {
onPreview(index)
} else {
onPaste(index)
}
default:
super.keyDown(with: event)
}
}
override func accessibilityPerformPress() -> Bool {
onPaste(index)
return true
}
override func updateTrackingAreas() { override func updateTrackingAreas() {
super.updateTrackingAreas() super.updateTrackingAreas()
if let trackingAreaRef { if let trackingAreaRef {
@@ -2212,6 +2291,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
quickPasteBadgeLabel?.stringValue quickPasteBadgeLabel?.stringValue
} }
var debugIsKeyboardFocused: Bool {
isKeyboardFocused
}
var debugBorderWidth: CGFloat {
contentView.layer?.borderWidth ?? 0
}
var debugFooterDetailText: String { var debugFooterDetailText: String {
footerDetailLabel.stringValue footerDetailLabel.stringValue
} }
@@ -2630,9 +2717,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
setAccessibilityElement(true) setAccessibilityElement(true)
setAccessibilityRole(.button) setAccessibilityRole(.button)
setAccessibilityLabel(accessibilityTitle(for: item)) setAccessibilityLabel(accessibilityTitle(for: item))
setAccessibilityHelp("Selects this clipboard item. Double-click to paste.") setAccessibilityHelp(accessibilityHelpText())
widthAnchor.constraint(equalToConstant: layout.width).isActive = true widthAnchor.constraint(equalToConstant: layout.width).isActive = true
heightAnchor.constraint(equalToConstant: layout.height).isActive = true heightAnchor.constraint(equalToConstant: layout.height).isActive = true
focusRingType = .default
contentView.wantsLayer = true contentView.wantsLayer = true
contentView.layer?.cornerRadius = 8 contentView.layer?.cornerRadius = 8
@@ -3643,6 +3731,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return "\(kindLabel(for: item.kind)): \(summary)" return "\(kindLabel(for: item.kind)): \(summary)"
} }
private func accessibilityHelpText() -> String {
if canPreview {
return "Press Return to paste. Press Space for Quick Look."
}
return "Press Return or Space to paste."
}
private func row(_ views: [NSView]) -> NSStackView { private func row(_ views: [NSView]) -> NSStackView {
let stack = NSStackView(views: views) let stack = NSStackView(views: views)
stack.orientation = .horizontal stack.orientation = .horizontal

View File

@@ -12,6 +12,11 @@ final class ClipboardPanelViewTests: XCTestCase {
let settings: SettingsModel let settings: SettingsModel
let store: ClipboardStore let store: ClipboardStore
let cacheService: ClipboardCacheService let cacheService: ClipboardCacheService
let previewProbe: PreviewProbe
}
private final class PreviewProbe {
var requestCount = 0
} }
override func tearDown() { override func tearDown() {
@@ -294,6 +299,46 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
} }
func testCardsAreKeyboardFocusableAndReturnPastesFocusedCard() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Older text card", store: fixture.store))
fixture.store.upsert(makeTextItem("Newest text card", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAcceptsFirstResponder, [true, true])
XCTAssertTrue(fixture.view.debugFocusCard(at: 1))
drainMainQueue()
XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "Older text card")
XCTAssertEqual(fixture.view.debugKeyboardFocusedCardIndexes, [1])
XCTAssertEqual(fixture.view.debugCardBorderWidths[1], 2)
XCTAssertEqual(fixture.view.debugCardAccessibilityValues[1], "Selected")
XCTAssertEqual(fixture.view.debugCardAccessibilityHelps[1], "Press Return or Space to paste.")
fixture.view.debugPressFocusedResponderWithReturn()
drainMainQueue()
XCTAssertEqual(fixture.viewModel.statusMessage, "Copied")
}
func testFocusedPreviewableCardSpaceOpensQuickLook() {
let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com/read", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertTrue(fixture.view.debugFocusCard(at: 0))
drainMainQueue()
XCTAssertEqual(fixture.view.debugCardAccessibilityHelps.first, "Press Return to paste. Press Space for Quick Look.")
fixture.view.debugPressFocusedResponderWithSpace()
drainMainQueue()
XCTAssertEqual(fixture.previewProbe.requestCount, 1)
XCTAssertEqual(fixture.viewModel.selectedItem?.payload, "https://example.com/read")
}
func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() { func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store)) fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))
@@ -836,11 +881,13 @@ final class ClipboardPanelViewTests: XCTestCase {
let cacheService = ClipboardCacheService() let cacheService = ClipboardCacheService()
let store = makeStore(settings: settings, cacheService: cacheService) let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
let previewProbe = PreviewProbe()
let view = ClipboardPanelView( let view = ClipboardPanelView(
viewModel: viewModel, viewModel: viewModel,
onClose: {}, onClose: {},
onSettings: {} onSettings: {},
onPreview: { previewProbe.requestCount += 1 }
) )
let window = NSPanel( let window = NSPanel(
@@ -857,7 +904,8 @@ final class ClipboardPanelViewTests: XCTestCase {
viewModel: viewModel, viewModel: viewModel,
settings: settings, settings: settings,
store: store, store: store,
cacheService: cacheService cacheService: cacheService,
previewProbe: previewProbe
) )
} }