WIP: make collection rail keyboard accessible

This commit is contained in:
Akshay Kolli
2026-06-30 08:50:21 -07:00
parent 1ceb3c5a4e
commit a66798bf00
4 changed files with 111 additions and 19 deletions

View File

@@ -19,7 +19,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- 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
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu - Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
- Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload - Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload
- Copy and paste actions with Accessibility permission fallback - Copy and paste actions with Accessibility permission fallback
- Image thumbnail cache with byte and file-count pruning - Image thumbnail cache with byte and file-count pruning

View File

@@ -36,19 +36,20 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
3. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf` and confirm only clips from those collections and content types remain. 3. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf` and confirm only clips from those collections and content types remain.
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. 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. 6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear.
7. Press `Esc` once with a non-empty search field and confirm search clears. 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` again and confirm the panel closes. 8. Press `Esc` once with a non-empty search field and confirm search clears.
9. Reopen the panel, change sort segments, and confirm each segment updates results. 9. Press `Esc` again and confirm the panel closes.
10. 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. 10. Reopen the panel, change sort segments, and confirm each segment updates results.
11. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases. 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. 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. 12. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
13. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update. 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 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. 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. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 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. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 16. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
17. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. 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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 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.
## Copy And Paste ## Copy And Paste

View File

@@ -1320,6 +1320,39 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.count } return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.count }
} }
var debugCollectionChipAccessibilityLabels: [String] {
updateCollectionButtons()
return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.accessibilityLabel() }
}
var debugCollectionChipAcceptsFirstResponder: [Bool] {
ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.acceptsFirstResponder }
}
func debugFocusCollectionChip(_ mode: ClipboardSortMode) -> Bool {
guard let chip = collectionButtons[mode] else { return false }
return window?.makeFirstResponder(chip) ?? false
}
func debugPressFocusedResponderWithSpace() {
guard let window,
let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: 0,
windowNumber: window.windowNumber,
context: nil,
characters: " ",
charactersIgnoringModifiers: " ",
isARepeat: false,
keyCode: 49
) else {
return
}
window.firstResponder?.keyDown(with: event)
}
var debugCustomCollectionTitles: [String] { var debugCustomCollectionTitles: [String] {
viewModel.collectionNames viewModel.collectionNames
} }
@@ -1613,6 +1646,7 @@ private final class CollectionChipView: NSView {
private let countLabel = NSTextField(labelWithString: "0") private let countLabel = NSTextField(labelWithString: "0")
private(set) var isSelected = false private(set) var isSelected = false
private(set) var count = 0 private(set) var count = 0
private var isKeyboardFocused = false
private var isDropTargeted = false private var isDropTargeted = false
var onPress: () -> Void = {} var onPress: () -> Void = {}
var onDropItem: ((UUID) -> Void)? var onDropItem: ((UUID) -> Void)?
@@ -1633,12 +1667,13 @@ private final class CollectionChipView: NSView {
private func configure() { private func configure() {
wantsLayer = true wantsLayer = true
focusRingType = .default
layer?.cornerRadius = 13 layer?.cornerRadius = 13
layer?.borderWidth = 0.6 layer?.borderWidth = 0.6
layer?.borderColor = NSColor.clear.cgColor layer?.borderColor = NSColor.clear.cgColor
setAccessibilityElement(true) setAccessibilityElement(true)
setAccessibilityRole(.button) setAccessibilityRole(.button)
setAccessibilityLabel(titleText) setAccessibilityHelp("Press Return or Space to show \(titleText)")
heightAnchor.constraint(equalToConstant: 26).isActive = true heightAnchor.constraint(equalToConstant: 26).isActive = true
registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes) registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes)
@@ -1681,7 +1716,6 @@ private final class CollectionChipView: NSView {
widthAnchor.constraint(greaterThanOrEqualToConstant: 70), widthAnchor.constraint(greaterThanOrEqualToConstant: 70),
widthAnchor.constraint(lessThanOrEqualToConstant: 164) widthAnchor.constraint(lessThanOrEqualToConstant: 164)
]) ])
setAccessibilityLabel("\(titleText), count: \(count)")
setSelected(false) setSelected(false)
} }
@@ -1694,6 +1728,7 @@ private final class CollectionChipView: NSView {
? NSColor.controlAccentColor.withAlphaComponent(0.16) ? NSColor.controlAccentColor.withAlphaComponent(0.16)
: NSColor.labelColor.withAlphaComponent(0.07) : NSColor.labelColor.withAlphaComponent(0.07)
).cgColor ).cgColor
updateAccessibility()
updateChrome() updateChrome()
} }
@@ -1709,7 +1744,10 @@ private final class CollectionChipView: NSView {
layer?.borderColor = color.withAlphaComponent(0.68).cgColor layer?.borderColor = color.withAlphaComponent(0.68).cgColor
} else if isSelected { } else if isSelected {
layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor
layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.34).cgColor layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(isKeyboardFocused ? 0.74 : 0.34).cgColor
} else if isKeyboardFocused {
layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.34).cgColor
layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.52).cgColor
} else { } else {
layer?.backgroundColor = NSColor.clear.cgColor layer?.backgroundColor = NSColor.clear.cgColor
layer?.borderColor = NSColor.clear.cgColor layer?.borderColor = NSColor.clear.cgColor
@@ -1719,9 +1757,32 @@ private final class CollectionChipView: NSView {
func setCount(_ count: Int) { func setCount(_ count: Int) {
self.count = count self.count = count
countLabel.stringValue = count > 999 ? "999+" : "\(count)" countLabel.stringValue = count > 999 ? "999+" : "\(count)"
setAccessibilityLabel("\(titleText), \(count) \(count == 1 ? "clip" : "clips")") updateAccessibility()
}
private func updateAccessibility() {
let noun = count == 1 ? "clip" : "clips"
let selectedText = isSelected ? "selected, " : ""
setAccessibilityLabel("\(titleText), \(selectedText)\(count) \(noun)")
setAccessibilityValue("\(count)") setAccessibilityValue("\(count)")
toolTip = "\(titleText), \(count) \(count == 1 ? "clip" : "clips")" setAccessibilityHelp("Press Return or Space to show \(titleText)")
toolTip = "\(titleText), \(selectedText)\(count) \(noun)"
}
override var acceptsFirstResponder: Bool {
true
}
override func becomeFirstResponder() -> Bool {
isKeyboardFocused = true
updateChrome()
return true
}
override func resignFirstResponder() -> Bool {
isKeyboardFocused = false
updateChrome()
return true
} }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
@@ -1732,6 +1793,20 @@ private final class CollectionChipView: NSView {
onPress() onPress()
} }
override func keyDown(with event: NSEvent) {
switch event.keyCode {
case 36, 49:
onPress()
default:
super.keyDown(with: event)
}
}
override func accessibilityPerformPress() -> Bool {
onPress()
return true
}
override func menu(for event: NSEvent) -> NSMenu? { override func menu(for event: NSEvent) -> NSMenu? {
guard onEdit != nil || onDelete != nil else { return nil } guard onEdit != nil || onDelete != nil else { return nil }
return contextMenu() return contextMenu()

View File

@@ -184,6 +184,22 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links") XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
} }
func testCollectionRailChipsAreKeyboardFocusableAndVoiceOverDescriptive() {
let fixture = makePanelFixture()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCollectionChipAcceptsFirstResponder, Array(repeating: true, count: ClipboardSortMode.allCases.count))
XCTAssertEqual(fixture.view.debugCollectionChipAccessibilityLabels.first, "Clipboard, selected, 0 clips")
XCTAssertTrue(fixture.view.debugFocusCollectionChip(.links))
fixture.view.debugPressFocusedResponderWithSpace()
drainMainQueue()
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
XCTAssertTrue(fixture.view.debugCollectionChipAccessibilityLabels.contains("Links, selected, 0 clips"))
}
func testCollectionRailAddButtonCreatesEmptyCollection() { func testCollectionRailAddButtonCreatesEmptyCollection() {
let fixture = makePanelFixture() let fixture = makePanelFixture()