The board search item becomes the stock NSSearchToolbarItem — the field grows on focus

The toolbar's search field now lives in AppKit's own NSSearchToolbarItem:
em(17) is the focused width (preferredWidthForSearchField), the resting
width is the item's own, and ⌘F becomes beginSearchInteraction — one call
that expands and focuses. Escape's empty-field exit collapses the field as
the keyboard leaves. resignsFirstResponderWithCancel is off so the staged
Escape survives the cancel button. The item ships its own live overflow row
and an above-.high visibility priority, so both custom-view workarounds
retire. The palette copy stays inert through an enablement round-trip the
item's searchField setter would otherwise overwrite.

The centering half of the card is blocked on a Design ruling (03 settles
"trailing") and is not built; the two-homes width question is filed.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 11:16:58 -04:00
parent 31fee00c73
commit 5e6417e749
4 changed files with 354 additions and 53 deletions
+48 -7
View File
@@ -59,8 +59,13 @@ final class BoardSearchPresentation {
/// arrives one SwiftUI update later, and claims the keyboard when it does.
private var wantsFocusOnAppear = false
/// Makes the toolbar's field first responder, **answering whether it could**. `nil` until that
/// field has been made, which is also exactly when the toolbar has no search item to focus.
/// Puts the keyboard in the toolbar's field, **answering whether it could**. `nil` until the
/// item that hosts that field has been made, which is also exactly when the toolbar has no
/// search item to focus.
///
/// It is `NSSearchToolbarItem.beginSearchInteraction()` underneath (`adopt`): one call that
/// expands the item to its focused width *and* moves the keyboard in, rather than a bare
/// `makeFirstResponder` that would focus a field still at its resting width.
///
/// The answer matters because an installed item is not always a reachable one: pushed into the
/// system overflow by a narrow window, the field is in no window and cannot take the keyboard.
@@ -284,7 +289,10 @@ struct FindCommand: View {
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
/// Which of the field's two homes this one is the only thing that differs between them, and it
/// differs in exactly one place: which focus handle the presentation gets.
/// differs in exactly one place: which focus handle the presentation gets, and who fills it in.
/// The transient field fills its own in, because a field inside a SwiftUI view is the whole of
/// that home; the toolbar's is the *item's* to give (`adopt`), because expanding the item and
/// putting the keyboard in it is one call on `NSSearchToolbarItem` and the field cannot make it.
enum Home {
case toolbar
case transient
@@ -297,6 +305,10 @@ final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
/// lives exactly as long as whoever holds the field a toolbar item, or a SwiftUI view.
private weak var field: FocusReportingSearchField?
/// The item this field lives in, in the toolbar home `nil` in the transient one, which has no
/// item and so nothing to collapse. **Weak**: the item owns the field, which owns this.
private weak var item: NSSearchToolbarItem?
/// Makes the field, wires it, and hands it back. The caller owns the result and, through it,
/// everything here.
static func makeField(
@@ -322,10 +334,9 @@ final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
// outlives neither its window nor its item.
switch home {
case .toolbar:
presentation.focusField = { [weak field] in
guard let field, let window = field.window else { return false }
return window.makeFirstResponder(field)
}
// Filled in by `adopt` when the item that hosts this field is built: F's handle there
// is the item's expand-and-focus, which is not the field's to offer.
break
case .transient:
presentation.focusTransientField = { [weak field] in
guard let field, let window = field.window else { return }
@@ -335,9 +346,36 @@ final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
return field
}
/// Binds a field made for the toolbar to the `NSSearchToolbarItem` that hosts it everything
/// about that home the field alone cannot answer.
///
/// F becomes `beginSearchInteraction()`, which expands the item to its focused width and moves
/// the keyboard into the field in one call. **It still answers whether it could**: an item
/// pushed into the system overflow has no window, and a field in no window cannot take the
/// keyboard, so F falls through to the transient strip exactly as a removed item does
/// (`BoardSearchPresentation.focusField`).
///
/// Called for the installed item only. The customization palette's copy is inert
/// (`makePaletteField`), so there is no controller here to adopt it and no handle for it to
/// claim a palette item that answered F would be a second live field.
static func adopt(_ item: NSSearchToolbarItem, presentation: BoardSearchPresentation) {
let field = item.searchField
(field as? FocusReportingSearchField)?.controller?.item = item
presentation.focusField = { [weak item, weak field] in
guard let item, let field, field.window != nil else { return false }
item.beginSearchInteraction()
return true
}
}
/// An inert field for the customization palette's copy of the search item: it looks like the
/// real one and does nothing, because a palette item that wrote to the store or claimed F's
/// handle would be a second live field.
///
/// Three things make it inert and each is load-bearing: no delegate, so a keystroke reaches no
/// store; no controller, so nothing observes the query; and no `adopt`, so F's handle stays the
/// installed item's. The disable is the part a *user* can see
/// `NSSearchToolbarItem` overwrites it on assignment, and `WindowToolbarController` puts it back.
static func makePaletteField() -> NSSearchField {
let field = NSSearchField()
field.placeholderString = "Search"
@@ -402,6 +440,9 @@ final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
case #selector(NSResponder.cancelOperation(_:)):
if store.searchQuery.isEmpty {
presentation.focusBoard?()
// The keyboard has left, so the field goes back to its resting width with it
// grow-on-focus, undone. A no-op in the transient home, which has no item.
item?.endSearchInteraction()
} else {
store.clearSearch()
control.stringValue = ""