diff --git a/README.md b/README.md index fd825a8..3a20bbd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index dadac23..ee3dff0 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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 diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 45f699f..ac958fa 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 06a122d..9e2931c 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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()