Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f3afae3e | ||
|
|
e234afa26a |
3 changed files with 321 additions and 71 deletions
|
|
@ -56,3 +56,26 @@ export function LinkIcon() {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// A funnel, the standard "filter" glyph — smaller than the other two
|
||||
// (1em not 1.3em) since its first caller sits inside a table header
|
||||
// cell, not a nav trigger button; scale via the caller's own
|
||||
// `font-size` like the others rather than a hardcoded size prop, same
|
||||
// reasoning as `GearIcon`/`LinkIcon` above (`currentColor` stroke).
|
||||
export function FilterIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,12 +63,71 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The per-column filter inputs' row — same cell padding as a header row,
|
||||
no bottom border of its own (the header row above already has one). */
|
||||
.ui-table-filter-row th {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0.5em;
|
||||
font-weight: 400;
|
||||
/* `.ui-table-th-filterable` (set on a filterable column's `<th>`, no
|
||||
rule of its own) exists purely as a `:hover`/`:focus-within` selector
|
||||
target for the fade below — nothing to do with positioning the
|
||||
popover itself, which is computed in JS and rendered via a portal
|
||||
instead (see Table.tsx's own comment on `popoverPos` for why it
|
||||
isn't just `position: absolute` under this `<th>`).
|
||||
|
||||
The filter-icon trigger — invisible by default, `opacity` transition
|
||||
rather than a hard show/hide (mara: "fade in out animation"). Shown
|
||||
on three signals, matching Table.tsx's own comment on `filterValue`:
|
||||
hovering/focusing the header cell (so it's discoverable without a
|
||||
filter already set), the popover being open (still visible while
|
||||
you're using it, even if the pointer has moved off the header), or
|
||||
`.ui-table-filter-icon-active` — a filter is *set* on this column,
|
||||
so the icon stays put as a persistent "this column is filtered"
|
||||
indicator rather than disappearing the moment the header isn't
|
||||
hovered (mara: "when a filter is set, the filter icon does not
|
||||
disappear"). `:focus-within` (not just `:hover`) so a keyboard user
|
||||
tabbing to the icon sees it appear too, not just a mouse hovering. */
|
||||
.ui-table-filter-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0.35em;
|
||||
padding: 0.15em;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.ui-table-th-filterable:hover .ui-table-filter-icon,
|
||||
.ui-table-th-filterable:focus-within .ui-table-filter-icon,
|
||||
.ui-table-filter-icon-active {
|
||||
opacity: 1;
|
||||
}
|
||||
.ui-table-filter-icon:hover {
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* A filter is live on this column — `--purple`, theme.css's own "active
|
||||
tabs, links, highlights" accent, so an operator scanning the header
|
||||
row reads it the same way anything else marked active does. */
|
||||
.ui-table-filter-icon-active {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
/* `position`/`top`/`left` are inline styles, not here — Table.tsx
|
||||
computes them from the anchor `<th>`'s own `getBoundingClientRect()`
|
||||
and renders this via a portal onto `document.body` (see its own
|
||||
`popoverPos` comment for the full reasoning: escaping
|
||||
`.ui-table-scroll`'s clip). `z-index: 10` matches the existing
|
||||
overlay-menu convention (`LinksMenu`/`UserMenu`), now that this
|
||||
sits alongside them as a `document.body`-level sibling rather than
|
||||
nested inside the table. */
|
||||
.ui-table-filter-popover {
|
||||
z-index: 10;
|
||||
min-width: 12em;
|
||||
padding: 0.5em;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.ui-table-filter-input,
|
||||
.ui-table-filter-select {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
// a `<table>` doesn't shrink below its content's natural width on its
|
||||
// own, so without this the page itself would break, not just look
|
||||
// cramped.
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { createPortal } from "preact/compat";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { FilterIcon } from "@hive/shared/icons.js";
|
||||
import "./Table.css";
|
||||
|
||||
export interface TableColumn<T> {
|
||||
|
|
@ -51,11 +53,16 @@ export interface TableColumn<T> {
|
|||
*/
|
||||
sortBy?: (row: T) => string | number;
|
||||
/**
|
||||
* Column is filterable (a control in its own row under the headers)
|
||||
* when present — extracts the string to filter this column against.
|
||||
* Same one-signal reasoning as `sortBy`: no separate `filterable`
|
||||
* flag, the extractor's presence is the flag. How the extracted
|
||||
* string is matched against the operator's input is `filterMode`.
|
||||
* Column is filterable (a filter-icon button in its own header cell,
|
||||
* opening a small popover with the actual control) when present —
|
||||
* extracts the string to filter this column against. Same
|
||||
* one-signal reasoning as `sortBy`: no separate `filterable` flag,
|
||||
* the extractor's presence is the flag. How the extracted string is
|
||||
* matched against the operator's input is `filterMode`. The icon
|
||||
* fades in on header hover/focus, or stays visible outright once
|
||||
* this column has an active filter (mara: "when a filter is set,
|
||||
* the filter icon does not disappear") — see `Table.css`'s
|
||||
* `.ui-table-filter-icon` for the actual rule.
|
||||
*/
|
||||
filterValue?: (row: T) => string;
|
||||
/**
|
||||
|
|
@ -108,10 +115,92 @@ export function Table<T>({
|
|||
// Filter text per filterable column key, only populated for a column
|
||||
// whose input the operator has actually typed into.
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
// Which column's filter popover is open, if any — at most one at a
|
||||
// time (opening a second closes the first) so the header row never
|
||||
// shows more than one panel at once.
|
||||
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null);
|
||||
// Viewport coordinates for the open popover, or null while closed.
|
||||
// Recomputed on open and on scroll/resize below — see the portal
|
||||
// rendering further down for why this exists at all: `.ui-table-scroll`
|
||||
// sets `overflow-x: auto`, and per the CSS overflow spec an axis left
|
||||
// unset computes to `auto` too once the *other* axis isn't `visible`,
|
||||
// so this box silently clips vertically as well. A popover positioned
|
||||
// `absolute`/`top: 100%` under its header — the first shape this
|
||||
// shipped with — gets cut off by that clip on any table whose height
|
||||
// is shorter than header-plus-popover (argus's review: reproduced,
|
||||
// not speculative, on a 1-row table). `position: fixed` computed from
|
||||
// the anchor `<th>`'s own `getBoundingClientRect()`, rendered via a
|
||||
// portal outside `.ui-table-scroll`'s subtree entirely, escapes that
|
||||
// clip the same way any `position: fixed` element escapes an
|
||||
// ancestor's `overflow` (unless that ancestor establishes a new
|
||||
// containing block via `transform`/`filter`/`will-change` — neither
|
||||
// `.ui-table-scroll` nor `.ui-table` do).
|
||||
const [popoverPos, setPopoverPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
// One `<th>` ref per filterable column key, so the scroll/resize
|
||||
// effect below can recompute the *currently open* column's position
|
||||
// without needing the click that opened it to have happened again.
|
||||
const thRefs = useRef<Map<string, HTMLTableCellElement>>(new Map());
|
||||
|
||||
function computePopoverPos(key: string) {
|
||||
const th = thRefs.current.get(key);
|
||||
if (!th) return;
|
||||
const rect = th.getBoundingClientRect();
|
||||
setPopoverPos({ top: rect.bottom + 4, left: rect.left });
|
||||
}
|
||||
|
||||
// Keeps the popover visually anchored to its header while it's open,
|
||||
// rather than only positioning it once at click time — a page/scroll
|
||||
// container scroll or a viewport resize while the popover is open
|
||||
// would otherwise leave it floating over the wrong spot. `scroll`
|
||||
// doesn't bubble, so this listens on `window` with `capture: true`,
|
||||
// which *does* see a scroll on `.ui-table-scroll` (or any other
|
||||
// nested scroll container) during the capture phase — the standard
|
||||
// technique for "detect scroll anywhere in the tree" without binding
|
||||
// a listener to every individual scrollable ancestor by hand.
|
||||
useEffect(() => {
|
||||
if (openFilterKey === null) return;
|
||||
function recompute() {
|
||||
if (openFilterKey !== null) computePopoverPos(openFilterKey);
|
||||
}
|
||||
window.addEventListener("scroll", recompute, true);
|
||||
window.addEventListener("resize", recompute);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", recompute, true);
|
||||
window.removeEventListener("resize", recompute);
|
||||
};
|
||||
}, [openFilterKey]);
|
||||
|
||||
const filterableColumns = columns.filter((c) => c.filterValue);
|
||||
const activeFilters = Object.entries(filters).filter(([, v]) => v !== "");
|
||||
|
||||
// Close on outside click or Escape — same contract `Dropdown` gives
|
||||
// its own popover (../.../shared/src/dropdown/Dropdown.tsx), but
|
||||
// checked by CSS class rather than a ref: unlike `Dropdown`, there's
|
||||
// one shared listener across every column's icon+popover pair here,
|
||||
// not one component instance per trigger, so "is this click part of
|
||||
// the open popover" is answered by `closest()` against the pair's
|
||||
// classes instead of a per-column ref map.
|
||||
useEffect(() => {
|
||||
if (openFilterKey === null) return;
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (!(e.target instanceof Element)) return;
|
||||
if (e.target.closest(".ui-table-filter-icon, .ui-table-filter-popover"))
|
||||
return;
|
||||
setOpenFilterKey(null);
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpenFilterKey(null);
|
||||
}
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [openFilterKey]);
|
||||
|
||||
const visibleRows = useMemo(() => {
|
||||
let out = rows;
|
||||
if (activeFilters.length > 0) {
|
||||
|
|
@ -171,14 +260,75 @@ export function Table<T>({
|
|||
);
|
||||
}
|
||||
|
||||
// Only one popover is ever open at a time, so one ref (rather than a
|
||||
// per-column ref map) is enough to focus whichever control just
|
||||
// mounted — typing immediately after the click that opened it,
|
||||
// without a separate click into the field first. A callback ref
|
||||
// (not `useRef`'s object form attached directly to the element)
|
||||
// because the element is one of two different concrete types
|
||||
// depending on `filterMode` — `RefObject<HTMLInputElement |
|
||||
// HTMLSelectElement>` doesn't structurally satisfy either element's
|
||||
// own narrower `ref` prop type, but a plain callback taking the
|
||||
// union does.
|
||||
const popoverControlRef = useRef<HTMLElement | null>(null);
|
||||
function setPopoverControlRef(el: HTMLElement | null) {
|
||||
popoverControlRef.current = el;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (openFilterKey !== null) popoverControlRef.current?.focus();
|
||||
}, [openFilterKey]);
|
||||
|
||||
function filterLabel(c: TableColumn<T>): string {
|
||||
return `filter by ${typeof c.header === "string" ? c.header : c.key}`;
|
||||
}
|
||||
|
||||
function renderFilterControl(c: TableColumn<T>) {
|
||||
if (c.filterMode === "select") {
|
||||
return (
|
||||
<select
|
||||
ref={setPopoverControlRef}
|
||||
class="ui-table-filter-select"
|
||||
aria-label={filterLabel(c)}
|
||||
value={filters[c.key] ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
||||
}}
|
||||
>
|
||||
<option value="">any</option>
|
||||
{selectOptionsFor(c).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
ref={setPopoverControlRef}
|
||||
type="text"
|
||||
class="ui-table-filter-input"
|
||||
placeholder="filter…"
|
||||
aria-label={filterLabel(c)}
|
||||
value={filters[c.key] ?? ""}
|
||||
onInput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="ui-table-scroll">
|
||||
<table class="ui-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) =>
|
||||
c.sortBy ? (
|
||||
<th key={c.key} aria-sort={ariaSortFor(c)}>
|
||||
{columns.map((c) => {
|
||||
const hasFilterValue = (filters[c.key] ?? "") !== "";
|
||||
const filterOpen = openFilterKey === c.key;
|
||||
const headerContent = c.sortBy ? (
|
||||
<button
|
||||
type="button"
|
||||
class="ui-table-sort-button"
|
||||
|
|
@ -193,52 +343,47 @@ export function Table<T>({
|
|||
: "↕"}
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
) : (
|
||||
<th key={c.key} aria-sort={ariaSortFor(c)}>
|
||||
{c.header}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
{filterableColumns.length > 0 ? (
|
||||
<tr class="ui-table-filter-row">
|
||||
{columns.map((c) => (
|
||||
<th key={c.key}>
|
||||
{c.filterValue && c.filterMode === "select" ? (
|
||||
<select
|
||||
class="ui-table-filter-select"
|
||||
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
|
||||
value={filters[c.key] ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
||||
c.header
|
||||
);
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
aria-sort={ariaSortFor(c)}
|
||||
class={c.filterValue ? "ui-table-th-filterable" : undefined}
|
||||
ref={(el) => {
|
||||
if (!c.filterValue) return;
|
||||
if (el) thRefs.current.set(c.key, el);
|
||||
else thRefs.current.delete(c.key);
|
||||
}}
|
||||
>
|
||||
<option value="">any</option>
|
||||
{selectOptionsFor(c).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : c.filterValue ? (
|
||||
<input
|
||||
type="text"
|
||||
class="ui-table-filter-input"
|
||||
placeholder="filter…"
|
||||
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
|
||||
value={filters[c.key] ?? ""}
|
||||
onInput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
||||
{headerContent}
|
||||
{c.filterValue ? (
|
||||
<button
|
||||
type="button"
|
||||
class={
|
||||
"ui-table-filter-icon" +
|
||||
(filterOpen || hasFilterValue
|
||||
? " ui-table-filter-icon-active"
|
||||
: "")
|
||||
}
|
||||
aria-label={filterLabel(c)}
|
||||
aria-expanded={filterOpen}
|
||||
onClick={() => {
|
||||
setOpenFilterKey((k) => {
|
||||
const next = k === c.key ? null : c.key;
|
||||
if (next !== null) computePopoverPos(next);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<FilterIcon />
|
||||
</button>
|
||||
) : null}
|
||||
</th>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
) : null}
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && emptyMessage ? (
|
||||
|
|
@ -269,6 +414,29 @@ export function Table<T>({
|
|||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{openFilterKey !== null && popoverPos
|
||||
? createPortal(
|
||||
(() => {
|
||||
const openColumn = columns.find((c) => c.key === openFilterKey);
|
||||
if (!openColumn) return null;
|
||||
return (
|
||||
<div
|
||||
class="ui-table-filter-popover"
|
||||
role="dialog"
|
||||
aria-label={filterLabel(openColumn)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${popoverPos.top}px`,
|
||||
left: `${popoverPos.left}px`,
|
||||
}}
|
||||
>
|
||||
{renderFilterControl(openColumn)}
|
||||
</div>
|
||||
);
|
||||
})(),
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue