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
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user