WIP: make collection rail keyboard accessible
This commit is contained in:
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
- Copy and paste actions with Accessibility permission fallback
|
||||
- Image thumbnail cache with byte and file-count pruning
|
||||
|
||||
@@ -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.
|
||||
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. 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. Press `Esc` once with a non-empty search field and confirm search clears.
|
||||
8. Press `Esc` again and confirm the panel closes.
|
||||
9. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||
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.
|
||||
11. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||
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.
|
||||
13. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||
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.
|
||||
15. 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 card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||
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.
|
||||
18. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||
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.
|
||||
|
||||
## Copy And Paste
|
||||
|
||||
|
||||
@@ -1320,6 +1320,39 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
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] {
|
||||
viewModel.collectionNames
|
||||
}
|
||||
@@ -1613,6 +1646,7 @@ private final class CollectionChipView: NSView {
|
||||
private let countLabel = NSTextField(labelWithString: "0")
|
||||
private(set) var isSelected = false
|
||||
private(set) var count = 0
|
||||
private var isKeyboardFocused = false
|
||||
private var isDropTargeted = false
|
||||
var onPress: () -> Void = {}
|
||||
var onDropItem: ((UUID) -> Void)?
|
||||
@@ -1633,12 +1667,13 @@ private final class CollectionChipView: NSView {
|
||||
|
||||
private func configure() {
|
||||
wantsLayer = true
|
||||
focusRingType = .default
|
||||
layer?.cornerRadius = 13
|
||||
layer?.borderWidth = 0.6
|
||||
layer?.borderColor = NSColor.clear.cgColor
|
||||
setAccessibilityElement(true)
|
||||
setAccessibilityRole(.button)
|
||||
setAccessibilityLabel(titleText)
|
||||
setAccessibilityHelp("Press Return or Space to show \(titleText)")
|
||||
heightAnchor.constraint(equalToConstant: 26).isActive = true
|
||||
registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes)
|
||||
|
||||
@@ -1681,7 +1716,6 @@ private final class CollectionChipView: NSView {
|
||||
widthAnchor.constraint(greaterThanOrEqualToConstant: 70),
|
||||
widthAnchor.constraint(lessThanOrEqualToConstant: 164)
|
||||
])
|
||||
setAccessibilityLabel("\(titleText), count: \(count)")
|
||||
setSelected(false)
|
||||
}
|
||||
|
||||
@@ -1694,6 +1728,7 @@ private final class CollectionChipView: NSView {
|
||||
? NSColor.controlAccentColor.withAlphaComponent(0.16)
|
||||
: NSColor.labelColor.withAlphaComponent(0.07)
|
||||
).cgColor
|
||||
updateAccessibility()
|
||||
updateChrome()
|
||||
}
|
||||
|
||||
@@ -1709,7 +1744,10 @@ private final class CollectionChipView: NSView {
|
||||
layer?.borderColor = color.withAlphaComponent(0.68).cgColor
|
||||
} else if isSelected {
|
||||
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 {
|
||||
layer?.backgroundColor = NSColor.clear.cgColor
|
||||
layer?.borderColor = NSColor.clear.cgColor
|
||||
@@ -1719,9 +1757,32 @@ private final class CollectionChipView: NSView {
|
||||
func setCount(_ count: Int) {
|
||||
self.count = 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)")
|
||||
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 {
|
||||
@@ -1732,6 +1793,20 @@ private final class CollectionChipView: NSView {
|
||||
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? {
|
||||
guard onEdit != nil || onDelete != nil else { return nil }
|
||||
return contextMenu()
|
||||
|
||||
@@ -184,6 +184,22 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
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() {
|
||||
let fixture = makePanelFixture()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user