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> </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; opacity: 1;
} }
/* `.ui-table-th-filterable` (set on a filterable column's `<th>`, no /* The per-column filter inputs' row — same cell padding as a header row,
rule of its own) exists purely as a `:hover`/`:focus-within` selector no bottom border of its own (the header row above already has one). */
target for the fade below — nothing to do with positioning the .ui-table-filter-row th {
popover itself, which is computed in JS and rendered via a portal padding-top: 0;
instead (see Table.tsx's own comment on `popoverPos` for why it padding-bottom: 0.5em;
isn't just `position: absolute` under this `<th>`). font-weight: 400;
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 {

View file

@ -9,10 +9,8 @@
// 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 { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { useMemo, 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> {
@ -53,16 +51,11 @@ export interface TableColumn<T> {
*/ */
sortBy?: (row: T) => string | number; sortBy?: (row: T) => string | number;
/** /**
* Column is filterable (a filter-icon button in its own header cell, * Column is filterable (a control in its own row under the headers)
* opening a small popover with the actual control) when present — * when present — extracts the string to filter this column against.
* extracts the string to filter this column against. Same * Same one-signal reasoning as `sortBy`: no separate `filterable`
* one-signal reasoning as `sortBy`: no separate `filterable` flag, * flag, the extractor's presence is the flag. How the extracted
* the extractor's presence is the flag. How the extracted string is * string is matched against the operator's input is `filterMode`.
* 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;
/** /**
@ -115,92 +108,10 @@ 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) {
@ -260,75 +171,14 @@ 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) =>
const hasFilterValue = (filters[c.key] ?? "") !== ""; c.sortBy ? (
const filterOpen = openFilterKey === c.key; <th key={c.key} aria-sort={ariaSortFor(c)}>
const headerContent = c.sortBy ? (
<button <button
type="button" type="button"
class="ui-table-sort-button" class="ui-table-sort-button"
@ -343,47 +193,52 @@ export function Table<T>({
: "↕"} : "↕"}
</span> </span>
</button> </button>
</th>
) : ( ) : (
c.header <th key={c.key} aria-sort={ariaSortFor(c)}>
); {c.header}
return ( </th>
<th ),
key={c.key} )}
aria-sort={ariaSortFor(c)} </tr>
class={c.filterValue ? "ui-table-th-filterable" : undefined} {filterableColumns.length > 0 ? (
ref={(el) => { <tr class="ui-table-filter-row">
if (!c.filterValue) return; {columns.map((c) => (
if (el) thRefs.current.set(c.key, el); <th key={c.key}>
else thRefs.current.delete(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 }));
}} }}
> >
{headerContent} <option value="">any</option>
{c.filterValue ? ( {selectOptionsFor(c).map((v) => (
<button <option key={v} value={v}>
type="button" {v}
class={ </option>
"ui-table-filter-icon" + ))}
(filterOpen || hasFilterValue </select>
? " ui-table-filter-icon-active" ) : c.filterValue ? (
: "") <input
} type="text"
aria-label={filterLabel(c)} class="ui-table-filter-input"
aria-expanded={filterOpen} placeholder="filter…"
onClick={() => { aria-label={`filter by ${typeof c.header === "string" ? c.header : c.key}`}
setOpenFilterKey((k) => { value={filters[c.key] ?? ""}
const next = k === c.key ? null : c.key; onInput={(e) => {
if (next !== null) computePopoverPos(next); const v = (e.target as HTMLInputElement).value;
return next; setFilters((prev) => ({ ...prev, [c.key]: v }));
});
}} }}
> />
<FilterIcon />
</button>
) : null} ) : null}
</th> </th>
); ))}
})}
</tr> </tr>
) : null}
</thead> </thead>
<tbody> <tbody>
{rows.length === 0 && emptyMessage ? ( {rows.length === 0 && emptyMessage ? (
@ -414,29 +269,6 @@ 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>
); );
} }