WIP: make clipboard cards keyboard accessible
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user