Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
3 changed files with 71 additions and 321 deletions

View file

@ -56,26 +56,3 @@ 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>
);
}

View file

@ -63,71 +63,12 @@
opacity: 1;
}
/* `.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);
/* 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-filter-input,
.ui-table-filter-select {

View file

@ -9,10 +9,8 @@
// 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 { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { createPortal } from "preact/compat";
import { useMemo, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js";
import "./Table.css";
export interface TableColumn<T> {
@ -53,16 +51,11 @@ export interface TableColumn<T> {
*/
sortBy?: (row: T) => string | number;
/**
* 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.
* 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`.
*/
filterValue?: (row: T) => string;
/**
@ -115,92 +108,10 @@ 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) {
@ -260,130 +171,74 @@ 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) => {
const hasFilterValue = (filters[c.key] ?? "") !== "";
const filterOpen = openFilterKey === c.key;
const headerContent = c.sortBy ? (
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
{columns.map((c) =>
c.sortBy ? (
<th key={c.key} aria-sort={ariaSortFor(c)}>
<button
type="button"
class="ui-table-sort-button"
onClick={() => toggleSort(c.key)}
>
{c.header}
<span class="ui-table-sort-glyph" aria-hidden="true">
{sort?.key === c.key
? sort.dir === "asc"
? "▲"
: "▼"
: "↕"}
</span>
</button>
</th>
) : (
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);
}}
>
{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;
});
<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 }));
}}
>
<FilterIcon />
</button>
<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 }));
}}
/>
) : null}
</th>
);
})}
</tr>
))}
</tr>
) : null}
</thead>
<tbody>
{rows.length === 0 && emptyMessage ? (
@ -414,29 +269,6 @@ 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>
);
}