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
|
||||
- `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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
8. Press `Esc` once with a non-empty search field and confirm search clears.
|
||||
9. Press `Esc` again and confirm the panel closes.
|
||||
10. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||
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.
|
||||
12. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||
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.
|
||||
14. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||
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.
|
||||
16. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
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.
|
||||
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.
|
||||
19. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||
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. 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` once with a non-empty search field and confirm search clears.
|
||||
10. Press `Esc` again and confirm the panel closes.
|
||||
11. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||
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. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||
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 the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||
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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
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. 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
|
||||
|
||||
|
||||
@@ -1205,6 +1205,28 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
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] {
|
||||
cardViews.map(\.debugPreviewSummary)
|
||||
}
|
||||
@@ -1334,7 +1356,20 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
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() {
|
||||
debugPressFocusedResponder(characters: " ", keyCode: 49)
|
||||
}
|
||||
|
||||
private func debugPressFocusedResponder(characters: String, keyCode: UInt16) {
|
||||
guard let window,
|
||||
let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
@@ -1343,10 +1378,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
timestamp: 0,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: " ",
|
||||
charactersIgnoringModifiers: " ",
|
||||
characters: characters,
|
||||
charactersIgnoringModifiers: characters,
|
||||
isARepeat: false,
|
||||
keyCode: 49
|
||||
keyCode: keyCode
|
||||
) else {
|
||||
return
|
||||
}
|
||||
@@ -2001,6 +2036,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
private weak var quickPasteBadgeLabel: NSTextField?
|
||||
private var isSelected = false
|
||||
private var isHovered = false
|
||||
private var isKeyboardFocused = false
|
||||
private var mouseDownLocation: NSPoint?
|
||||
private var trackingAreaRef: NSTrackingArea?
|
||||
|
||||
@@ -2045,11 +2081,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
|
||||
func setSelected(_ selected: Bool) {
|
||||
isSelected = selected
|
||||
contentView.layer?.borderWidth = 1
|
||||
contentView.layer?.borderColor = selected ? Palette.selectedBorder : Palette.border
|
||||
contentView.layer?.borderWidth = isKeyboardFocused ? 2 : 1
|
||||
if selected {
|
||||
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 {
|
||||
contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.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?.borderColor = Palette.border
|
||||
}
|
||||
layer?.shadowOpacity = selected ? 0.16 : (isHovered ? 0.12 : 0.08)
|
||||
layer?.shadowRadius = selected ? 16 : 12
|
||||
layer?.shadowOffset = NSSize(width: 0, height: selected ? 6 : 4)
|
||||
layer?.transform = selected ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity
|
||||
let emphasized = selected || isKeyboardFocused
|
||||
layer?.shadowOpacity = emphasized ? 0.16 : (isHovered ? 0.12 : 0.08)
|
||||
layer?.shadowRadius = emphasized ? 16 : 12
|
||||
layer?.shadowOffset = NSSize(width: 0, height: emphasized ? 6 : 4)
|
||||
layer?.transform = emphasized ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity
|
||||
setAccessibilityValue(selected ? "Selected" : "Not selected")
|
||||
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() {
|
||||
super.updateTrackingAreas()
|
||||
if let trackingAreaRef {
|
||||
@@ -2212,6 +2291,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
quickPasteBadgeLabel?.stringValue
|
||||
}
|
||||
|
||||
var debugIsKeyboardFocused: Bool {
|
||||
isKeyboardFocused
|
||||
}
|
||||
|
||||
var debugBorderWidth: CGFloat {
|
||||
contentView.layer?.borderWidth ?? 0
|
||||
}
|
||||
|
||||
var debugFooterDetailText: String {
|
||||
footerDetailLabel.stringValue
|
||||
}
|
||||
@@ -2630,9 +2717,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
setAccessibilityElement(true)
|
||||
setAccessibilityRole(.button)
|
||||
setAccessibilityLabel(accessibilityTitle(for: item))
|
||||
setAccessibilityHelp("Selects this clipboard item. Double-click to paste.")
|
||||
setAccessibilityHelp(accessibilityHelpText())
|
||||
widthAnchor.constraint(equalToConstant: layout.width).isActive = true
|
||||
heightAnchor.constraint(equalToConstant: layout.height).isActive = true
|
||||
focusRingType = .default
|
||||
|
||||
contentView.wantsLayer = true
|
||||
contentView.layer?.cornerRadius = 8
|
||||
@@ -3643,6 +3731,13 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
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 {
|
||||
let stack = NSStackView(views: views)
|
||||
stack.orientation = .horizontal
|
||||
|
||||
@@ -12,6 +12,11 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
let settings: SettingsModel
|
||||
let store: ClipboardStore
|
||||
let cacheService: ClipboardCacheService
|
||||
let previewProbe: PreviewProbe
|
||||
}
|
||||
|
||||
private final class PreviewProbe {
|
||||
var requestCount = 0
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
@@ -294,6 +299,46 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
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() {
|
||||
let fixture = makePanelFixture()
|
||||
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 store = makeStore(settings: settings, cacheService: cacheService)
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
let previewProbe = PreviewProbe()
|
||||
|
||||
let view = ClipboardPanelView(
|
||||
viewModel: viewModel,
|
||||
onClose: {},
|
||||
onSettings: {}
|
||||
onSettings: {},
|
||||
onPreview: { previewProbe.requestCount += 1 }
|
||||
)
|
||||
|
||||
let window = NSPanel(
|
||||
@@ -857,7 +904,8 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
viewModel: viewModel,
|
||||
settings: settings,
|
||||
store: store,
|
||||
cacheService: cacheService
|
||||
cacheService: cacheService,
|
||||
previewProbe: previewProbe
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user