Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions sidemantic-rs/ui/assets/index-DBXs5BBH.css

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion sidemantic-rs/ui/assets/index-DnTdRyzm.css

This file was deleted.

4 changes: 2 additions & 2 deletions sidemantic-rs/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-pE1zWf5r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
<script type="module" crossorigin src="/assets/index-BizYMIT7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DBXs5BBH.css">
</head>
<body>
<div id="root"></div>
Expand Down

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions sidemantic/ui/static/assets/index-DBXs5BBH.css

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion sidemantic/ui/static/assets/index-DnTdRyzm.css

This file was deleted.

4 changes: 2 additions & 2 deletions sidemantic/ui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-pE1zWf5r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
<script type="module" crossorigin src="/assets/index-BizYMIT7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DBXs5BBH.css">
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion sidemantic/widget/static/widget.css

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions sidemantic/widget/static/widget.js

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions webapp/playwright.ui.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineConfig, devices } from "@playwright/test";

// Controlled HTTP responses exercise real browser layout without a native backend build.
export default defineConfig({
testDir: "./tests",
testMatch: "filter-popovers.spec.ts",
workers: 1,
reporter: "list",
use: { baseURL: "http://127.0.0.1:4548", trace: "retain-on-failure" },
webServer: {
command: "bun run dev --host 127.0.0.1 --port 4548",
url: "http://127.0.0.1:4548",
reuseExistingServer: false,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
25 changes: 18 additions & 7 deletions webapp/src/components/AddFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { CatalogDimension, CatalogModel } from "../data/types";
import { joinedDimensionsForModel } from "../lib/catalog";
import { useExplorer } from "../state/ExplorerContext";
import { FilterEditor } from "./FilterEditor";
import { useFilterPopover } from "./useFilterPopover";

/** "+ Filter" affordance: pick a dimension, then edit its filter in the same popover. Non-time
* dimensions only (time is filtered via the date-range control). */
Expand All @@ -12,16 +14,22 @@ export function AddFilter({ model }: { model: CatalogModel }) {
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState<CatalogDimension | null>(null);
const rootRef = useRef<HTMLSpanElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useFilterPopover(menuRef, buttonRef, open && !picked);

useEffect(() => {
if (!open) return;
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") close();
if (event.key === "Escape") {
close();
buttonRef.current?.focus({ preventScroll: true });
}
}
function onPointer(event: MouseEvent) {
// The editor's own outside-click handler covers the picked state; only the menu needs this.
if (picked) return;
if (rootRef.current && !rootRef.current.contains(event.target as Node)) close();
if (!rootRef.current?.contains(event.target as Node) && !menuRef.current?.contains(event.target as Node)) close();
}
document.addEventListener("keydown", onKey, true);
document.addEventListener("mousedown", onPointer, true);
Expand All @@ -41,6 +49,7 @@ export function AddFilter({ model }: { model: CatalogModel }) {
return (
<span ref={rootRef} className="relative inline-flex">
<button
ref={buttonRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
Expand All @@ -49,26 +58,28 @@ export function AddFilter({ model }: { model: CatalogModel }) {
>
+ Filter
</button>
{open && !picked ? (
{open && !picked ? createPortal(
<div
ref={menuRef}
role="menu"
aria-label="Add a filter for a dimension"
className="absolute left-0 z-50 mt-1 max-h-64 w-48 overflow-y-auto rounded-xl border border-line bg-surface p-1.5 text-2xs shadow-[var(--shadow)]"
className="fixed z-30 max-h-[min(24rem,var(--filter-popover-height))] w-80 max-w-[calc(100vw-1rem)] overflow-y-auto rounded-xl border border-line bg-surface p-1.5 text-2xs shadow-[var(--shadow)]"
>
{dims.map((dim) => (
<button
key={dim.ref}
type="button"
role="menuitem"
onClick={() => setPicked(dim)}
className="block w-full truncate px-1.5 py-1 text-left text-muted hover:bg-surface-soft hover:text-ink"
className="block w-full break-words px-1.5 py-1 text-left text-muted hover:bg-surface-soft hover:text-ink"
>
{dim.label}
</button>
))}
</div>
</div>,
document.body,
) : null}
{open && picked ? <FilterEditor dim={picked} model={model} onClose={close} /> : null}
{open && picked ? <FilterEditor dim={picked} model={model} anchorRef={buttonRef} onClose={close} /> : null}
</span>
);
}
6 changes: 3 additions & 3 deletions webapp/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ export function AppShell({ brand, toolbar, filters, rail, children, drawer, show
</header>

{filters ? (
// Fixed height so the bar doesn't jump when pills replace the "No filters" text.
<div className="flex min-h-9 shrink-0 flex-wrap items-center gap-2 overflow-x-auto border-b border-line bg-surface px-3 py-2 md:h-9 md:flex-nowrap md:py-0">
// Keep the stage available with many filters; popovers render outside this scroll clip.
<div data-filter-strip role="region" aria-label="Active filters" className="relative z-30 flex max-h-[min(25dvh,8rem)] min-h-9 shrink-0 flex-wrap items-center gap-2 overflow-y-auto border-b border-line bg-surface px-3 py-1">
{filters}
</div>
) : null}
Expand Down Expand Up @@ -156,7 +156,7 @@ export function AppShell({ brand, toolbar, filters, rail, children, drawer, show
</aside>
</>
) : null}
<main className="relative min-h-0 min-w-0 overflow-hidden">
<main className="relative z-0 min-h-0 min-w-0 overflow-hidden">
<div className={`h-full overflow-y-auto ${drawer ? "pb-8" : ""}`}>{children}</div>
{drawer}
</main>
Expand Down
28 changes: 17 additions & 11 deletions webapp/src/components/FilterEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { aliasOf, NULL_TOKEN, type CatalogDimension, type CatalogModel } from "../data/types";
import { displayDimValue } from "../lib/format";
import { dashboardTabConfig } from "../lib/dashboard";
import { catalogDimTypes, composeFilters, distinctValues, containsExpr, type FilterMode } from "../lib/queries";
import { useExplorer } from "../state/ExplorerContext";
import { useQueryResult } from "../state/useQueryResult";
import { useFilterPopover } from "./useFilterPopover";

const MODES: { mode: FilterMode; label: string }[] = [
{ mode: "include", label: "Include" },
Expand Down Expand Up @@ -36,10 +38,12 @@ function useDebounced<T>(value: T, delayMs: number): T {
export function FilterEditor({
dim,
model,
anchorRef,
onClose,
}: {
dim: CatalogDimension;
model: CatalogModel;
anchorRef: RefObject<HTMLButtonElement>;
onClose: () => void;
}) {
const { state, dispatch, backend, catalog, dashboard } = useExplorer();
Expand All @@ -55,6 +59,7 @@ export function FilterEditor({
const selected = useMemo(() => new Set(filter?.mode !== "contains" ? (filter?.values ?? []) : []), [filter]);

const panelRef = useRef<HTMLDivElement>(null);
useFilterPopover(panelRef, anchorRef);
const searchRef = useRef<HTMLInputElement>(null);
const labelId = useId();

Expand All @@ -67,10 +72,10 @@ export function FilterEditor({

// Focus the search box on open; restore focus to the opener when the popover unmounts.
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
const opener = anchorRef.current;
searchRef.current?.focus();
return () => opener?.focus?.();
}, []);
return () => opener?.focus({ preventScroll: true });
}, [anchorRef]);

// Escape closes; outside pointer/click closes. Focus is trapped to the panel via onKeyDown below.
useEffect(() => {
Expand Down Expand Up @@ -162,22 +167,22 @@ export function FilterEditor({
}
}

return (
return createPortal(
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={labelId}
onKeyDown={onKeyDown}
className="absolute left-0 z-50 mt-1 w-64 border border-line bg-surface p-2 text-2xs shadow-lg"
className="fixed z-30 max-h-[var(--filter-popover-height)] w-96 max-w-[calc(100vw-1rem)] overflow-y-auto border border-line bg-surface p-2 text-2xs shadow-lg"
>
<div id={labelId} className="mb-2 flex items-baseline justify-between gap-2">
<span className="truncate font-semibold text-ink">{dim.label}</span>
<span className="min-w-0 break-words font-semibold text-ink">{dim.label}</span>
<button
type="button"
aria-label="Close filter editor"
onClick={onClose}
className="grid size-4 place-items-center rounded-full bg-surface-soft text-faint hover:bg-line hover:text-ink"
className="grid size-4 shrink-0 place-items-center rounded-full bg-surface-soft text-faint hover:bg-line hover:text-ink"
>
×
</button>
Expand Down Expand Up @@ -244,9 +249,9 @@ export function FilterEditor({
type="checkbox"
checked={checked}
onChange={() => dispatch({ type: "toggleFilter", dim: dim.ref, value, mode })}
className="size-3 accent-[var(--accent)]"
className="size-3 shrink-0 accent-[var(--accent)]"
/>
<span className="min-w-0 truncate text-ink">{displayDimValue(value)}</span>
<span className="min-w-0 break-words text-ink">{displayDimValue(value)}</span>
</label>
);
})
Expand All @@ -271,6 +276,7 @@ export function FilterEditor({
Done
</button>
</div>
</div>
</div>,
document.body,
);
}
6 changes: 4 additions & 2 deletions webapp/src/components/FilterPill.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import type { CatalogDimension, CatalogModel } from "../data/types";
import { filterSummary } from "../lib/format";
import type { DimFilter } from "../lib/queries";
Expand All @@ -24,6 +24,7 @@ type FilterPillProps = EditableFilterPillProps | PortableFilterPillProps;
* US", "name contains 'acme'"). Clicking the label opens the editor popover; the × clears it. */
export function FilterPill(props: FilterPillProps) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);

if (!("dim" in props)) {
return (
Expand All @@ -42,6 +43,7 @@ export function FilterPill(props: FilterPillProps) {
<span className="relative inline-flex max-w-full items-center" data-dimension={dim.ref} data-mode={filter.mode}>
<span className="inline-flex max-w-full items-center gap-1 rounded-full bg-surface-soft px-2 py-0.5 text-2xs leading-4 text-muted">
<button
ref={buttonRef}
type="button"
aria-label={`Edit filter ${dim.label}`}
aria-haspopup="dialog"
Expand All @@ -60,7 +62,7 @@ export function FilterPill(props: FilterPillProps) {
×
</button>
</span>
{open ? <FilterEditor dim={dim} model={model} onClose={() => setOpen(false)} /> : null}
{open ? <FilterEditor dim={dim} model={model} anchorRef={buttonRef} onClose={() => setOpen(false)} /> : null}
</span>
);
}
40 changes: 40 additions & 0 deletions webapp/src/components/useFilterPopover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useLayoutEffect, type RefObject } from "react";

/** Position a portalled filter popup below its chip, outside the strip's scroll clip. */
export function useFilterPopover(ref: RefObject<HTMLDivElement>, anchorRef: RefObject<HTMLButtonElement>, open = true) {
// Run after each render: adding a pill can move this anchor without resizing it.
useLayoutEffect(() => {
const panel = ref.current;
const anchor = anchorRef.current;
if (!open || !panel || !anchor) return;

function position() {
if (!panel || !anchor) return;
const bounds = anchor.getBoundingClientRect();
const strip = anchor.closest("[data-filter-strip]")?.getBoundingClientRect();
const gutter = 8;
const left = Math.max(gutter, Math.min(bounds.left, window.innerWidth - panel.offsetWidth - gutter));
// Keep an open editor attached to the visible strip edge when its chip scrolls out of view.
const bottom = strip ? Math.max(strip.top, Math.min(bounds.bottom, strip.bottom)) : bounds.bottom;
const top = Math.max(gutter, Math.min(bottom + 4, window.innerHeight - gutter));
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.setProperty("--filter-popover-height", `${Math.max(0, window.innerHeight - top - gutter)}px`);
}

position();
const observer = new ResizeObserver(position);
observer.observe(panel);
observer.observe(anchor);
// A sibling pill can wrap without changing this anchor's own size.
const strip = anchor.closest("[data-filter-strip]");
if (strip) observer.observe(strip);
window.addEventListener("resize", position);
window.addEventListener("scroll", position, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", position);
window.removeEventListener("scroll", position, true);
};
});
}
Loading
Loading