Skip to content

Commit f3cdbc8

Browse files
committed
Add section search
1 parent 77e0ec1 commit f3cdbc8

10 files changed

Lines changed: 491 additions & 50 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,17 @@ When `interactive=True`, hovering over the top-right of the toolbar reveals util
598598

599599
In **locked mode**, Reset, Swap, and config import/export are hidden. `Export Data` remains available as a viewer action. The Settings gear remains visible, its popover shows read-only display status plus group expand/collapse actions, and header-menu sorting, filtering, and `Show Values As` stay enabled.
600600

601+
### Toolbar Field Search
602+
603+
When a toolbar picker has more than **8 available fields**, the `Rows`, `Columns`, or `Values` dropdown automatically shows a field search input at the top of the panel.
604+
605+
- Typing filters the available field list in place.
606+
- `ArrowDown` from the search box moves focus into the first matching option.
607+
- `Escape` closes the dropdown, even when the current query has zero matches.
608+
- In the `Values` dropdown, search only filters the field checklist. Synthetic-measure actions and per-measure aggregation controls remain visible below it.
609+
610+
This is a frontend-only convenience feature; no Python parameter is needed to enable it.
611+
601612
### Drag-and-Drop Field Configuration
602613

603614
When `interactive=True`, each chip in the Rows, Columns, and Values toolbar zones has a **grip-dots drag handle** on its left side. Drag chips to:

e2e_playwright/pivot_table_interactions_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,9 @@ def test_date_hierarchy_supports_drill_week_and_original(page_at_app: Page):
306306

307307
menu = open_header_menu(
308308
page,
309-
container.get_by_test_id("header-menu-trigger-order_date").first,
309+
container.locator("th")
310+
.filter(has_text="2024-W01")
311+
.get_by_test_id("header-menu-trigger-order_date"),
310312
"header-menu-order_date",
311313
)
312314
grain_select = menu.get_by_test_id("header-date-grain")

streamlit_app.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
_DATA_DIR = Path(__file__).parent / "tests" / "golden_data"
4040
df = pd.read_csv(_DATA_DIR / "small.csv")
4141
df_medium = pd.read_csv(_DATA_DIR / "medium.csv")
42+
df_field_search = df_medium.assign(
43+
Cost=(df_medium["Revenue"] - df_medium["Profit"]).round(2),
44+
RevenuePerUnit=(df_medium["Revenue"] / df_medium["Units"]).round(2),
45+
DiscountAmount=(df_medium["Revenue"] * df_medium["Discount"]).round(2),
46+
ProfitMargin=(df_medium["Profit"] / df_medium["Revenue"]).round(3),
47+
)
4248

4349
_rnd.seed(42)
4450
_date_regions = ["US", "EU", "APAC"]
@@ -141,6 +147,29 @@
141147
key="basic_auto_detect",
142148
)
143149

150+
st.markdown("#### Toolbar Field Search")
151+
st.markdown(
152+
"""
153+
The toolbar adds a **field search input** automatically when a picker has more than
154+
8 available fields. This wider demo includes enough dimensions and numeric measures
155+
to surface search in the **Rows**, **Columns**, and **Values** dropdowns.
156+
157+
**Try it:**
158+
- Open **Rows**, **Columns**, or **Values** and type part of a field name.
159+
- Press **ArrowDown** from the search box to jump into the filtered list.
160+
- Press **Escape** to close the dropdown.
161+
"""
162+
)
163+
st_pivot_table(
164+
df_field_search,
165+
key="basic_field_search",
166+
rows=["Region"],
167+
columns=["Year"],
168+
values=["Revenue"],
169+
interactive=True,
170+
show_totals=True,
171+
)
172+
144173

145174
# ---------------------------------------------------------------------------
146175
# Section 2: Multiple Measures and Sorting

streamlit_pivot/frontend/src/config/Toolbar.module.css

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,13 @@
132132
z-index: 20;
133133
max-height: 240px;
134134
overflow: auto;
135-
padding: 4px;
135+
padding: 6px 0;
136136
background: var(--st-background-color);
137137
border: 1px solid var(--st-border-color);
138138
border-radius: var(--st-base-radius);
139139
box-shadow: var(--pivot-dropdown-shadow, 0 6px 18px rgba(0, 0, 0, 0.08));
140+
font-family: var(--st-font);
141+
font-size: 0.75rem;
140142
}
141143

142144
.valuesDropdownPanel {
@@ -149,10 +151,10 @@
149151
display: flex;
150152
align-items: center;
151153
gap: 6px;
152-
padding: 4px 8px;
154+
padding: 3px 7px;
153155
border-radius: calc(var(--st-base-radius) - 2px);
154156
cursor: pointer;
155-
font-size: 0.75em;
157+
font-size: 1em;
156158
white-space: nowrap;
157159
color: var(--st-text-color);
158160
border: none;
@@ -213,6 +215,40 @@
213215
text-align: center;
214216
}
215217

218+
.fieldSearchWrapper {
219+
position: sticky;
220+
top: 0;
221+
z-index: 1;
222+
margin: 0 0 4px;
223+
padding: 2px 6px;
224+
background: var(--st-background-color);
225+
}
226+
227+
.fieldSearchInput {
228+
box-sizing: border-box;
229+
width: 100%;
230+
padding: 4px 7px;
231+
border: 1px solid var(--st-border-color);
232+
border-radius: calc(var(--st-base-radius) - 2px);
233+
background: var(--st-background-color);
234+
color: var(--st-text-color);
235+
font-family: var(--st-font);
236+
font-size: 1em;
237+
outline: none;
238+
}
239+
240+
.fieldSearchInput:focus-visible {
241+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--st-primary-color) 50%, transparent);
242+
outline: none;
243+
}
244+
245+
.noSearchResults {
246+
padding: 8px;
247+
font-size: 0.78em;
248+
opacity: 0.55;
249+
text-align: center;
250+
}
251+
216252
/* --- Chips row (shows selected items below section header) --- */
217253

218254
.chipRow {

streamlit_pivot/frontend/src/config/Toolbar.test.tsx

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,36 @@
1616
*/
1717

1818
import { describe, expect, it, vi } from "vitest";
19-
import { render, screen, fireEvent } from "@testing-library/react";
19+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
2020
import Toolbar, { applyDragMove } from "./Toolbar";
2121
import { PivotData, type DataRecord } from "../engine/PivotData";
2222
import { makeConfig } from "../test-utils";
2323

2424
const ALL_COLUMNS = ["region", "year", "revenue", "profit", "category"];
2525
const NUMERIC_COLUMNS = ["revenue", "profit"];
26+
const MANY_FIELDS = [
27+
"region",
28+
"year",
29+
"category",
30+
"segment",
31+
"state",
32+
"city",
33+
"country",
34+
"quarter",
35+
"month",
36+
"channel",
37+
];
38+
const MANY_NUMERIC_COLUMNS = [
39+
"revenue",
40+
"profit",
41+
"cost",
42+
"margin",
43+
"units",
44+
"discount",
45+
"tax",
46+
"freight",
47+
"returns",
48+
];
2649

2750
describe("Toolbar - rendering", () => {
2851
it("renders toolbar with data-testid", () => {
@@ -466,6 +489,162 @@ describe("Toolbar - interactions", () => {
466489
});
467490
});
468491

492+
describe("Toolbar - field search", () => {
493+
it("does not render search input when options are at or below the threshold", () => {
494+
render(
495+
<Toolbar
496+
config={makeConfig({ rows: [] })}
497+
allColumns={ALL_COLUMNS}
498+
numericColumns={NUMERIC_COLUMNS}
499+
onConfigChange={vi.fn()}
500+
/>,
501+
);
502+
503+
fireEvent.click(screen.getByTestId("toolbar-rows-select"));
504+
expect(screen.queryByTestId("toolbar-rows-search")).not.toBeInTheDocument();
505+
});
506+
507+
it("renders search input above threshold and filters options", () => {
508+
render(
509+
<Toolbar
510+
config={makeConfig({ rows: [] })}
511+
allColumns={MANY_FIELDS}
512+
numericColumns={NUMERIC_COLUMNS}
513+
onConfigChange={vi.fn()}
514+
/>,
515+
);
516+
517+
fireEvent.click(screen.getByTestId("toolbar-rows-select"));
518+
const search = screen.getByTestId("toolbar-rows-search");
519+
expect(search).toBeInTheDocument();
520+
521+
fireEvent.change(search, { target: { value: "cat" } });
522+
expect(
523+
screen.getByTestId("toolbar-rows-option-category"),
524+
).toBeInTheDocument();
525+
expect(
526+
screen.queryByTestId("toolbar-rows-option-region"),
527+
).not.toBeInTheDocument();
528+
529+
fireEvent.change(search, { target: { value: "" } });
530+
expect(
531+
screen.getByTestId("toolbar-rows-option-region"),
532+
).toBeInTheDocument();
533+
});
534+
535+
it("does not move focus into the search input when the dropdown opens", () => {
536+
render(
537+
<Toolbar
538+
config={makeConfig({ rows: [] })}
539+
allColumns={MANY_FIELDS}
540+
numericColumns={NUMERIC_COLUMNS}
541+
onConfigChange={vi.fn()}
542+
/>,
543+
);
544+
545+
const trigger = screen.getByTestId("toolbar-rows-select");
546+
trigger.focus();
547+
fireEvent.click(trigger);
548+
const search = screen.getByTestId("toolbar-rows-search");
549+
550+
expect(trigger).toHaveFocus();
551+
expect(search).not.toHaveFocus();
552+
});
553+
554+
it("closes the dropdown on Escape even when search yields zero results", () => {
555+
render(
556+
<Toolbar
557+
config={makeConfig({ rows: [] })}
558+
allColumns={MANY_FIELDS}
559+
numericColumns={NUMERIC_COLUMNS}
560+
onConfigChange={vi.fn()}
561+
/>,
562+
);
563+
564+
fireEvent.click(screen.getByTestId("toolbar-rows-select"));
565+
const search = screen.getByTestId("toolbar-rows-search");
566+
fireEvent.change(search, { target: { value: "zzz" } });
567+
568+
expect(
569+
screen.getByTestId("toolbar-rows-no-search-results"),
570+
).toBeInTheDocument();
571+
fireEvent.keyDown(search, { key: "Escape" });
572+
expect(screen.queryByTestId("toolbar-rows-panel")).not.toBeInTheDocument();
573+
});
574+
575+
it("moves focus to the first option on ArrowDown from the search input", () => {
576+
render(
577+
<Toolbar
578+
config={makeConfig({ rows: [] })}
579+
allColumns={MANY_FIELDS}
580+
numericColumns={NUMERIC_COLUMNS}
581+
onConfigChange={vi.fn()}
582+
/>,
583+
);
584+
585+
fireEvent.click(screen.getByTestId("toolbar-rows-select"));
586+
const search = screen.getByTestId("toolbar-rows-search");
587+
const firstOption = screen.getByTestId("toolbar-rows-option-region");
588+
589+
search.focus();
590+
fireEvent.keyDown(search, { key: "ArrowDown" });
591+
expect(firstOption).toHaveFocus();
592+
});
593+
594+
it("filters value-field options without hiding synthetic or aggregation controls", () => {
595+
render(
596+
<Toolbar
597+
config={makeConfig({ values: ["revenue", "profit"] })}
598+
allColumns={[...MANY_FIELDS, ...MANY_NUMERIC_COLUMNS]}
599+
numericColumns={MANY_NUMERIC_COLUMNS}
600+
onConfigChange={vi.fn()}
601+
/>,
602+
);
603+
604+
fireEvent.click(screen.getByTestId("toolbar-values-select"));
605+
const search = screen.getByTestId("toolbar-values-search");
606+
fireEvent.change(search, { target: { value: "tax" } });
607+
608+
expect(screen.getByTestId("toolbar-values-option-tax")).toBeInTheDocument();
609+
expect(
610+
screen.queryByTestId("toolbar-values-option-revenue"),
611+
).not.toBeInTheDocument();
612+
expect(
613+
screen.getByTestId("toolbar-values-add-synthetic"),
614+
).toBeInTheDocument();
615+
expect(
616+
screen.getByTestId("toolbar-values-aggregation-controls"),
617+
).toBeInTheDocument();
618+
});
619+
620+
it("clears the values search when opening the synthetic measure builder", () => {
621+
render(
622+
<Toolbar
623+
config={makeConfig({ values: ["revenue", "profit"] })}
624+
allColumns={[...MANY_FIELDS, ...MANY_NUMERIC_COLUMNS]}
625+
numericColumns={MANY_NUMERIC_COLUMNS}
626+
onConfigChange={vi.fn()}
627+
/>,
628+
);
629+
630+
fireEvent.click(screen.getByTestId("toolbar-values-select"));
631+
fireEvent.change(screen.getByTestId("toolbar-values-search"), {
632+
target: { value: "tax" },
633+
});
634+
fireEvent.click(screen.getByTestId("toolbar-values-add-synthetic"));
635+
636+
expect(
637+
screen.queryByTestId("toolbar-values-panel"),
638+
).not.toBeInTheDocument();
639+
640+
fireEvent.click(screen.getByTestId("toolbar-values-select"));
641+
expect(screen.getByTestId("toolbar-values-search")).toHaveValue("");
642+
expect(
643+
screen.getByTestId("toolbar-values-option-revenue"),
644+
).toBeInTheDocument();
645+
});
646+
});
647+
469648
describe("Toolbar - reset", () => {
470649
it("shows reset button when config differs from initial", () => {
471650
const initial = makeConfig({ aggregation: "sum" });

0 commit comments

Comments
 (0)