Implement live search filtering
The board's live title+body filter per 04-interactions.md § Search: - SearchFilter — a pure value folding the query once (case- and diacritic-insensitive substring, locale-stable); title OR body matches, attachment filenames never searched; only the literal empty string is inactive. - One universe: the filter threads through SelectionGrammar's order lists as a defaulted parameter, so ranges, Select All, arrow navigation, the marquee, drop zones, count badges, and the shown trash all read the same filtered set by construction; lanes are deliberately never filtered out (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the selection through the existing constrain primitive, run on every query change and as the last line of the reload resolve; the delete successor is filtered so ⌫ never selects a hidden neighbour. - The field: an NSSearchField-backed toolbar item (the toolbar's sole default item); Edit ▸ Find ⌘F focuses it through a focused-value presentation; stock field-editor dispatch — Return swallowed, Tab is the keep-filter path to the board, board commands stay enabled except the caret-chord pair, now one shared caretChordsYield expression. - Escape is staged: clear the non-empty query (focus stays), hand an empty field back to the board, clear an active search from board focus — before Escape's clear-selection meaning. - Creating a card clears the search (the placeholder funnel); a rename deliberately gets no carve-out; filter reflow rides the content spring keyed narrowly on the query. 903 unit tests (24 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -79,6 +79,34 @@ extension BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The caret-chords rule, as one expression** (04-interactions.md ▸ Grammar, settled):
|
||||
///
|
||||
/// > Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation
|
||||
/// > whenever *any* text control has keyboard focus — inline title editors, the board search field,
|
||||
/// > board-popover fields (rename, git identity, remote), and card-window fields — because an
|
||||
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
||||
/// > standard line-start/end caret chords.
|
||||
///
|
||||
/// Four text surfaces, answered four ways, and only two of them are here:
|
||||
///
|
||||
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
||||
/// broader lockdown that already covers these two items.
|
||||
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
||||
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
||||
/// move belongs under it.
|
||||
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
||||
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
||||
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
||||
/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items
|
||||
/// are already scopeless there.
|
||||
///
|
||||
/// Stated once because the two command groups must not drift: a rule with two implementations is a
|
||||
/// rule with two chances to forget a surface.
|
||||
@MainActor
|
||||
func caretChordsYield(boardInfo: BoardInfoPresentation?, search: BoardSearchPresentation?) -> Bool {
|
||||
boardInfo?.isPresented == true || search?.isFocused == true
|
||||
}
|
||||
|
||||
// MARK: - Open Card
|
||||
|
||||
/// Board ▸ Open Card (⌘↩) — 11-command-nexus.md's first Board row, and **the one board command
|
||||
@@ -205,32 +233,30 @@ struct MoveCardCommands: View {
|
||||
///
|
||||
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
||||
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
||||
/// field ever sees the key. The inline title editors are covered by `acceptsBoardMutations`; the
|
||||
/// board popover's fields are covered by disabling while the popover is open at all — coarser than
|
||||
/// per-field focus, but the popover is a configuration surface (04's carve-out) and no lane move
|
||||
/// belongs under it. The search field (m5-search) and the card window's fields (whose windows never
|
||||
/// publish a `boardStore` in the first place) extend the same rule with their own cards.
|
||||
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
||||
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the width pair below.
|
||||
struct MoveLaneCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardInfo) private var boardInfo
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
Button("Move Left") {
|
||||
move(by: -1)
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: .command)
|
||||
.disabled(caretChordsYield || destination(-1) == nil)
|
||||
.disabled(yieldsCaretChords || destination(-1) == nil)
|
||||
|
||||
Button("Move Right") {
|
||||
move(by: 1)
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: .command)
|
||||
.disabled(caretChordsYield || destination(1) == nil)
|
||||
.disabled(yieldsCaretChords || destination(1) == nil)
|
||||
}
|
||||
|
||||
private var caretChordsYield: Bool {
|
||||
boardInfo?.isPresented == true
|
||||
private var yieldsCaretChords: Bool {
|
||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
||||
}
|
||||
|
||||
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
||||
@@ -431,23 +457,24 @@ struct LaneWidthCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardInfo) private var boardInfo
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
Button("Increase Lane Width") {
|
||||
step(by: 1)
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
||||
.disabled(caretChordsYield || selectedLanes.isEmpty)
|
||||
.disabled(yieldsCaretChords || selectedLanes.isEmpty)
|
||||
|
||||
Button("Decrease Lane Width") {
|
||||
step(by: -1)
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
|
||||
.disabled(caretChordsYield || !canDecrease)
|
||||
.disabled(yieldsCaretChords || !canDecrease)
|
||||
}
|
||||
|
||||
private var caretChordsYield: Bool {
|
||||
boardInfo?.isPresented == true
|
||||
private var yieldsCaretChords: Bool {
|
||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
||||
}
|
||||
|
||||
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
||||
|
||||
Reference in New Issue
Block a user