Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f3afae3e | ||
|
|
e234afa26a |
3 changed files with 321 additions and 71 deletions
|
|
@ -56,3 +56,26 @@ export function LinkIcon() {
|
||||||
</svg>
|
</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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The per-column filter inputs' row — same cell padding as a header row,
|
/* `.ui-table-th-filterable` (set on a filterable column's `<th>`, no
|
||||||
no bottom border of its own (the header row above already has one). */
|
rule of its own) exists purely as a `:hover`/`:focus-within` selector
|
||||||
.ui-table-filter-row th {
|
target for the fade below — nothing to do with positioning the
|
||||||
padding-top: 0;
|
popover itself, which is computed in JS and rendered via a portal
|
||||||
padding-bottom: 0.5em;
|
instead (see Table.tsx's own comment on `popoverPos` for why it
|
||||||
font-weight: 400;
|
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-input,
|
||||||
.ui-table-filter-select {
|
.ui-table-filter-select {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
// a `<table>` doesn't shrink below its content's natural width on its
|
// 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
|
// own, so without this the page itself would break, not just look
|
||||||
// cramped.
|
// 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 type { ComponentChildren } from "preact";
|
||||||
|
import { FilterIcon } from "@hive/shared/icons.js";
|
||||||
import "./Table.css";
|
import "./Table.css";
|
||||||
|
|
||||||
export interface TableColumn<T> {
|
export interface TableColumn<T> {
|
||||||
|
|
@ -51,11 +53,16 @@ export interface TableColumn<T> {
|
||||||
*/
|
*/
|
||||||
sortBy?: (row: T) => string | number;
|
sortBy?: (row: T) => string | number;
|
||||||
/**
|
/**
|
||||||
* Column is filterable (a control in its own row under the headers)
|
* Column is filterable (a filter-icon button in its own header cell,
|
||||||
* when present — extracts the string to filter this column against.
|
* opening a small popover with the actual control) when present —
|
||||||
* Same one-signal reasoning as `sortBy`: no separate `filterable`
|
* extracts the string to filter this column against. Same
|
||||||
* flag, the extractor's presence is the flag. How the extracted
|
* one-signal reasoning as `sortBy`: no separate `filterable` flag,
|
||||||
* string is matched against the operator's input is `filterMode`.
|
* 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;
|
filterValue?: (row: T) => string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -108,10 +115,92 @@ export function Table<T>({
|
||||||
// Filter text per filterable column key, only populated for a column
|
// Filter text per filterable column key, only populated for a column
|
||||||
// whose input the operator has actually typed into.
|
// whose input the operator has actually typed into.
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
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 !== "");
|
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(() => {
|
const visibleRows = useMemo(() => {
|
||||||
let out = rows;
|
let out = rows;
|
||||||
if (activeFilters.length > 0) {
|
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 (
|
return (
|
||||||
<div class="ui-table-scroll">
|
<div class="ui-table-scroll">
|
||||||
<table class="ui-table">
|
<table class="ui-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((c) =>
|
{columns.map((c) => {
|
||||||
c.sortBy ? (
|
const hasFilterValue = (filters[c.key] ?? "") !== "";
|
||||||
<th key={c.key} aria-sort={ariaSortFor(c)}>
|
const filterOpen = openFilterKey === c.key;
|
||||||
|
const headerContent = c.sortBy ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ui-table-sort-button"
|
class="ui-table-sort-button"
|
||||||
|
|
@ -193,52 +343,47 @@ export function Table<T>({
|
||||||
: "↕"}
|
: "↕"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</th>
|
|
||||||
) : (
|
) : (
|
||||||
<th key={c.key} aria-sort={ariaSortFor(c)}>
|
c.header
|
||||||
{c.header}
|
);
|
||||||
</th>
|
return (
|
||||||
),
|
<th
|
||||||
)}
|
key={c.key}
|
||||||
</tr>
|
aria-sort={ariaSortFor(c)}
|
||||||
{filterableColumns.length > 0 ? (
|
class={c.filterValue ? "ui-table-th-filterable" : undefined}
|
||||||
<tr class="ui-table-filter-row">
|
ref={(el) => {
|
||||||
{columns.map((c) => (
|
if (!c.filterValue) return;
|
||||||
<th key={c.key}>
|
if (el) thRefs.current.set(c.key, el);
|
||||||
{c.filterValue && c.filterMode === "select" ? (
|
else thRefs.current.delete(c.key);
|
||||||
<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 }));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">any</option>
|
{headerContent}
|
||||||
{selectOptionsFor(c).map((v) => (
|
{c.filterValue ? (
|
||||||
<option key={v} value={v}>
|
<button
|
||||||
{v}
|
type="button"
|
||||||
</option>
|
class={
|
||||||
))}
|
"ui-table-filter-icon" +
|
||||||
</select>
|
(filterOpen || hasFilterValue
|
||||||
) : c.filterValue ? (
|
? " ui-table-filter-icon-active"
|
||||||
<input
|
: "")
|
||||||
type="text"
|
}
|
||||||
class="ui-table-filter-input"
|
aria-label={filterLabel(c)}
|
||||||
placeholder="filter…"
|
aria-expanded={filterOpen}
|
||||||
aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
|
onClick={() => {
|
||||||
value={filters[c.key] ?? ""}
|
setOpenFilterKey((k) => {
|
||||||
onInput={(e) => {
|
const next = k === c.key ? null : c.key;
|
||||||
const v = (e.target as HTMLInputElement).value;
|
if (next !== null) computePopoverPos(next);
|
||||||
setFilters((prev) => ({ ...prev, [c.key]: v }));
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<FilterIcon />
|
||||||
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</th>
|
</th>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 && emptyMessage ? (
|
{rows.length === 0 && emptyMessage ? (
|
||||||
|
|
@ -269,6 +414,29 @@ export function Table<T>({
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue