Files
qbz/crates/qbz-slint/ui/primitives/JumpNavBar.slint
T
vicrodh 06a763443a feat(slint): jump-nav scroll-to-section + hide empty tabs + UX fixes
Brings the JUMP TO bar from a visual placeholder to a working nav,
plus two interaction fixes the user flagged on the previous build.

JUMP TO bar — functional scroll-to:
  - JumpNavTab gains an `anchor-y: length` field. JumpNavBar's
    tab-clicked callback now emits (id, anchor-y); the parent
    (ArtistPageView) scrolls page-flickable to land the section
    just below the sticky bar.
  - Tab list is built in Rust (`build_jump_tabs` in artist.rs)
    from the artist's actual data and pushed to a new
    `ArtistState.jump-tabs` model. Tabs are emitted only for
    sections that exist (top tracks present, "Discography" /
    "EPs & Singles" / "Live Albums" / "Compilations" / "Others"
    only when the corresponding release_sections entry is there);
    the Playlists tab — not implemented yet — never appears.
  - anchor-y estimates use layout-derived constants per section
    type (popular-tracks header + visible row count, release grid
    headers + ceil(album_count / 5) rows). Not pixel-perfect for
    long bios or grids that wrap differently, but lands the
    viewport inside the right section.
  - Removed the hardcoded ArtistPageView property — tabs and
    visibility are now data-driven.

Sticky positioning — dynamic:
  - jump-nav.natural-top now binds to
    `body-row.absolute-position.y - page-flickable.absolute-y -
    self.height`, so the bar always sits flush in the gap above
    the body row regardless of artist-header height (long-bio
    artists stop pushing the row out from under the bar).
  - Sidebar natural-top follows the same body-row anchor, so it
    too tracks where the body actually starts.

Search input UX:
  - The outer search-icon slot is now removed from layout (via
    `if !search-open`, not `visible:`) when the input opens, so
    the expanded input runs all the way to the bar's right
    padding — right under the user's cursor where the lupa just
    was, no leftover gap on the right.
  - `changed search-open` auto-focuses the inline TextInput, so
    typing starts immediately after clicking the lupa with no
    second click in the input.

cc
2026-05-20 14:20:47 -06:00

277 lines
11 KiB
Plaintext

// 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 <string> active-tab-id;
in-out property <bool> search-open: false;
in-out property <string> 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 <length> 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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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.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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: search-ta.has-hover
? Theme.text-primary
: Theme.text-muted;
}
search-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.search-clicked();
}
}
}
}
}
}