// JUMP TO bar — reusable sticky tab bar with an inline search input. // // Tauri shows this above each section-rich page (artist, library, // future label / musician views). The bar is normally tucked between // the page header and the body and pins to the top of its scroll // container once the user scrolls past the header. Per-tab click // callbacks let the parent decide what each tab scrolls to; the // search icon expands an inline search input that sits in the tab // row itself (Tauri's `.page-search.open` behaviour), animated with // a width transition so the tabs visually slide behind it. // // The component is presentation-only — sticky positioning is the // parent's responsibility. Reuse: drop the bar inside any layout, // pass `tabs` + the active id, wire `tab-clicked`, `search-clicked` // and `search-text-edited` to the page-side handlers. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { QbzIcon } from "QbzIcon.slint"; export struct JumpNavTab { id: string, label: string, // Page-local y the tab scrolls to when clicked. Measured from the // top of the Flickable content (not the viewport), so the parent // can set `viewport-y = -(anchor-y - sticky-bar-height)` to land // the section just below the sticky bar. anchor-y: length, } export component JumpNavBar inherits Rectangle { in property <[JumpNavTab]> tabs; in property active-tab-id; // Whether the inline page-search affordance is offered. Artist pages // have it; the label page does not (Tauri's label jump-nav has no // search), so the label view passes show-search: false. in property show-search: true; in-out property search-open: false; in-out property search-text; // When the input opens, focus it so the caret lands inside the // text field without an extra click. Without this the user has // to move the cursor from the search button into the input // before typing. changed search-open => { if (root.search-open) { search-input.focus(); } } callback tab-clicked(string /* tab-id */, length /* anchor-y */); callback search-clicked(); callback search-text-edited(string); height: 44px; background: Theme.surface-main; border-width: 0px; // Bottom border so the bar reads as a separate strip when // sticky over the body content. Rectangle { x: 0; y: parent.height - 1px; width: parent.width; height: 1px; background: Theme.border-subtle; } HorizontalLayout { padding-left: 32px; padding-right: 32px; spacing: 18px; alignment: stretch; // JUMP TO label VerticalLayout { alignment: center; horizontal-stretch: 0; Text { text: @tr("JUMP TO"); color: Theme.text-muted; font-size: Typography.legal; font-weight: Typography.semibold; letter-spacing: 0.5px; } } // Center region — tabs AND the animated search input share // this slot. The search input grows from 0 width on the // right edge, covering the tabs as it expands. center-region := Rectangle { horizontal-stretch: 1; clip: true; // Tabs — laid out as soon as the parent has size; the // search input draws on top of them when open. HorizontalLayout { x: 0; y: 0; width: parent.width; height: parent.height; spacing: 14px; alignment: start; for tab in root.tabs: VerticalLayout { alignment: center; Rectangle { height: 32px; width: tab-text.preferred-width + 4px; tab-text := Text { x: 2px; text: tab.label; color: tab.id == root.active-tab-id ? Theme.text-primary : (tab-ta.has-hover ? Theme.text-primary : Theme.text-secondary); font-size: Typography.body; font-weight: tab.id == root.active-tab-id ? Typography.semibold : Typography.regular; vertical-alignment: center; height: 100%; } Rectangle { x: 0; y: parent.height - 2px; width: parent.width; height: 2px; background: Theme.accent; visible: tab.id == root.active-tab-id; } tab-ta := TouchArea { mouse-cursor: pointer; enabled: !root.search-open; clicked => { root.tab-clicked(tab.id, tab.anchor-y); } } } } } // Animated search input — slides in from the right when // search-open flips to true and shrinks back to 0 width // when it goes false. surface-main background fully // hides the tabs while expanded. search-overlay := Rectangle { // 216px (= 60% of the previous 360 cap) keeps the // open input as a compact box pinned to the right // side of the bar — wide enough to read a few words // of query, narrow enough not to swallow the tabs // visually. min() guards narrow viewports. property max-open-width: 216px; x: parent.width - self.width; y: 4px; width: root.search-open ? min(parent.width, self.max-open-width) : 0px; height: parent.height - 8px; background: Theme.surface-card; border-radius: 6px; border-width: 1px; border-color: search-input.has-focus ? Theme.border-subtle : Theme.surface-card; clip: true; animate width { duration: 200ms; easing: ease-in-out; } HorizontalLayout { padding-left: 12px; padding-right: 6px; spacing: 8px; // Leading magnifier glyph. VerticalLayout { alignment: center; horizontal-stretch: 0; QbzIcon { source: @image-url("../assets/icons/search.svg"); width: 14px; height: 14px; tint: Theme.text-muted; } } // Input + placeholder share a stretched cell. Rectangle { horizontal-stretch: 1; search-input := TextInput { width: 100%; height: 100%; color: Theme.text-primary; font-size: Typography.body; vertical-alignment: center; single-line: true; text <=> root.search-text; edited => { root.search-text-edited(self.text); } } Text { visible: search-input.text == ""; width: 100%; height: 100%; text: @tr("Search this page…"); color: Theme.text-muted; font-size: Typography.body; vertical-alignment: center; } } // Trailing X — clears the query and closes the // input. Lives INSIDE the input so the close // affordance reads as part of the search widget // (not a stray button hanging off the side of // the bar). VerticalLayout { alignment: center; horizontal-stretch: 0; Rectangle { width: 24px; height: 24px; border-radius: 4px; background: clear-ta.has-hover ? Theme.surface-elevated : transparent; QbzIcon { source: @image-url("../assets/icons/x.svg"); width: 12px; height: 12px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted; } clear-ta := TouchArea { mouse-cursor: pointer; clicked => { root.search-clicked(); } } } } } } } // Outer search toggle — only present when the input is // closed. `if` (not `visible:`) so the slot is reclaimed by // the center region when the input opens, letting the // expanded input run all the way to the bar's right padding // (right where the user just clicked the lupa). The inline // input carries its own X close button. if root.show-search && !root.search-open: VerticalLayout { alignment: center; horizontal-stretch: 0; Rectangle { width: 32px; height: 32px; border-radius: 6px; background: search-ta.has-hover ? Theme.surface-elevated : transparent; QbzIcon { source: @image-url("../assets/icons/search.svg"); width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: search-ta.has-hover ? Theme.text-primary : Theme.text-muted; } search-ta := TouchArea { mouse-cursor: pointer; clicked => { root.search-clicked(); } } } } } }