Skip to content

Latest commit

 

History

History
45 lines (27 loc) · 13.3 KB

File metadata and controls

45 lines (27 loc) · 13.3 KB

Column definitions

Computed columns

ColumnDefBase.value?: (row: TRow) => unknown decouples a column's cell value from its key. key stays a plain string (loosened from keyof TRow & string) and is only ever used as the column's identity — for sort/filter/group/visibility state and list keys. value governs how the cell value is actually read: omitted reads row[key] (unchanged default behavior), a function computes the value from the whole row — covering both simple aliasing (value: (row) => row.name) and true computed columns with no single backing property (value: (row) => row.price * row.qty). A string form (reading a named property instead of key) was considered and rejected: it's strictly redundant with the function form (which already covers aliasing, plus nested access like value: (row) => row.address.city, at no extra API cost), so it would just be a second way to do the same thing. getColumnValue(col, row) in logic.ts is the single accessor implementing this and is used everywhere a cell value is read: processData's filter/range-filter/sort, groupData, computeStringValues, searchData, and computeAggregate. All of these already accepted a columns array except groupData, which gained one (groupData(data, groupBy, columns, emptyLabel)) so groupBy columns can be computed too.

Each adapter's own cell/group-header rendering (React cellValue(), Vue cellText()/groupValue(), Solid cellValue()) calls getColumnValue instead of reading row[col.key] directly. rowKey (React/vanilla prop, Vue prop) is a separate, unrelated concept — it identifies a row for list keys/DOM identity and still reads a real row property directly, untouched by this.

Global search

searchData(data, query, columns) filters rows before processData runs — it matches any column's string value (using col.format when defined) case-insensitively against the query. Every adapter exposes table.search.query/setQuery; clearAll resets it. Solid's SearchBox reuses the same <input> across updates, so focus persists with no restore step. ColumnDefBase.searchable?: boolean (default true) excludes a column from this match entirely — for a column whose underlying value isn't user-facing text (e.g. an image URL rendered via render), matching against it would surface rows on an accidental substring hit the user has no way to anticipate. Independent of filterable/sortable/groupable, which gate unrelated concerns.

Column categories

ColumnDefBase.category?: string lets a table with many columns group them for discoverability in the Columns/Sort/Group/Filter dropdowns' own column lists, instead of one long flat run of rows. groupColumnsByCategory(cols) (core) buckets a column list into { uncategorized, categories } (first-appearance order for both the uncategorized bucket and the category order itself); columnMatchesSearch(col, term) (core) matches a dropdown's own search box against a column's label or its category name, so typing a category surfaces every column filed under it even if none of their own labels match; categorizedAlphabetizedByLabel(cols, term) (core) composes search + alphabetize + bucket + alphabetize-categories into the one pipeline Sort/Group's addable lists use (Columns/Filter have their own ordering — see below — so they call groupColumnsByCategory/columnMatchesSearch directly instead).

Columns/Sort/Group render a category as a flyout submenu (CategorySubmenu, one per adapter) — a trigger row that opens a small floating panel listing that category's own columns, positioned via computeSubmenuPosition (core) at the trigger's own screen rect, flipping/clamping to stay inside the viewport the same way the main dropdown panel itself does (see Dropdown viewport clamping). It renders as a plain position: fixed sibling of the trigger, nested inside the panel like any other row — NOT position: absolute, and NOT portaled to document.body/root (Solid's <Portal>, React's createPortal, Vue's <Teleport to="body"> were all tried and reverted, GitHub issue #23 — see below). position: absolute was the first approach, and it doesn't work: a positioned descendant that overflows a scrollable ancestor horizontally still counts toward that ancestor's own scrollable region even when taken out of flow, which both clips the flyout and adds a spurious horizontal scrollbar to the whole panel. position: fixed alone fixes that with no portal needed — a fixed element's containing block is the viewport, not any scrolling ancestor, unless that ancestor sets transform/filter/perspective/will-change: transform (none of the panel's own chrome does). Opens on hover-intent (a native OS/app-menu-style delay — OPEN_DELAY = 100ms before opening, CLOSE_DELAY = 250ms before closing, giving the pointer room to travel diagonally into the submenu) or immediately on click/Enter/ArrowRight/Escape; only one submenu is open at a time per dropdown (a single openXxxCategory state value owned by the parent dropdown, not one per CategorySubmenu instance, so opening one always closes any sibling that was open).

A portal/teleport to document.body was the original design (through the 0.13.0 release) — it does avoid the scrollable-ancestor clipping above, but at a real cost: a portaled node is no longer a DOM descendant of whatever ancestor a consumer scopes theme CSS custom properties to (if that ancestor is narrower than <body>), so the submenu silently fell back to the library's baked-in default colors instead of inheriting the consumer's theme (GitHub issue #23). Since position: fixed alone already solves the original clipping problem, the portal bought nothing once found, and rendering the submenu as a plain nested descendant fixed the theming bug for free.

Because the submenu is a real DOM descendant of the panel, its rows are reachable by the panel's own generic roving Up/Down/Home/End nav — which is exactly why that nav explicitly excludes anything inside the submenu (each adapter's Dropdown panel-level keydown handler filters .dt-dd-submenu/[data-category-submenu] out of its focusables list) rather than letting arrow nav from a focused trigger wander into an open submenu's rows instead of moving to the next top-level entry. CategorySubmenu implements its own independent Up/Down/Home/End nav scoped to its own submenu element, calling stopPropagation() so the panel's own handler never double-handles the same keydown. The panel's outside-click-to-close listener needed no equivalent fix — a click inside the (now non-portaled) submenu is already inside the panel's own contains() check.

The Filter dropdown doesn't use a submenu for its own left column pane — ArrowRight is already taken there for left-pane→detail-pane crossing — so a category instead renders as an inline collapsible section (a toggle button + its columns, shown/hidden by a collapsedCategories set). Categories start collapsed, except one containing an active filter at the moment the dropdown opens (so opening it never hides the filter currently in use behind a collapsed section with no visual sign why) — seeded as a snapshot on the same closed→open transition as the pane's own active-filtered-first column order (orderFilterColumnsByActive), not recomputed live while the panel stays open. Filter's own categorized list is deliberately not re-alphabetized after bucketing, unlike Sort/Group's — the pane is already active-filtered-first-then-alphabetical, and sorting categories alphabetically on top would undo that bubbling for whichever category contains the column currently being filtered on.

The Columns dropdown was redesigned from a single flat all-columns-with-a-checkbox list into a "Visible columns" section (every shown column, draggable/Alt+↑↓-reorderable — see Column reordering) above an "Available columns" section (hidden columns, click to show; a categorized one collapses into a CategorySubmenu like Sort/Group) — a checkbox no longer fit once shown/hidden needed visually distinct rows (draggable + × vs. plain click-to-add), the same reason Sort/Group never used one either. Reordering only ever happens within Visible; Available is click-only, so nesting it into category submenus (impossible for Visible, since a submenu's rows can't also be a drag surface) creates no conflict. Showing/hiding a column always keeps whatever position it already has in columnOrder — visibility and order are independent, so a re-shown column reappears exactly where it was, not appended at the end. Available is search-narrowed but, unlike Sort/Group's addable lists, deliberately not alphabetized — this dropdown's whole identity is real column/definition order.

Each dropdown's search box narrows only what's being added (Sort/Group's addable list, Columns' Available section, Filter's whole left pane) — never the already-active section — matching the existing "active state keeps its own order, search only helps you find something new" convention.

Delete/Backspace on a focused active row (a Sort active-sort/"Group order" row, a Group active row, a Columns Visible row) removes/hides it, the keyboard equivalent of clicking that row's own × button — matching the Filter dropdown's own pre-existing Delete/Backspace-clears-this-column's-filter shortcut. A focused/hovered active row also gets a background tint, not just the browser's native focus outline (Solid/Vue: a :hover/:focus CSS rule, same tokens the Filter dropdown's own selected-column row already used; React has no per-element CSS at all, so this is instead a pair of hoveredDdRowKey/focusedDdRowKey state values shared across Sort/Group/Columns, since only one dropdown is ever open at a time). Sort's own non-draggable "Group order" rows cancel just the hover cue (a signal that dragging would do something there, which it wouldn't — the row's own section heading/hint text already explain why) while keeping the focus one, since the row is still keyboard-toggleable.

Header menu

Each header has a ▾ button (data-col-menu) at its end, opening a menu of what that column supports, so a column is acted on where it is rather than picked again from a toolbar dropdown (UC11). Shared logic lives in core's headerMenu.ts; adapters only render it.

  • Sorting stays on the header: the label is a button (when sortable !== false) whose click bubbles to the <th>'s sort handler, so Enter sorts and Shift+Enter adds a sort, like a click and shift-click.
  • Items: getHeaderMenuItems(col, groupBy, visibleColumnCount, filtered) — Filter (filterable !== false), Clear filter (while the column is filtered; clears its include, exclude and range filters), Group by this column (groupable: true), or Remove group once grouped (only a keepVisibleWhenGrouped column still has its header then), Hide column (more than one visible column). None → no ▾. Icons and label keys: HEADER_MENU_ITEMS.
  • Filter is a category-submenu-style flyout (hover, click, → or Enter) holding the column's FilterPane — the Filter dropdown's right pane, extracted so both share it. The pane owns its view state (value search, value order, shift-click anchor, expanded date nodes), so the dropdown renders it keyed by column and a column's value search resets on switching columns. Escape clears a non-empty value search before closing; ← in a text field moves the caret instead of closing the flyout.
  • Narrow screen (FILTER_NARROW_QUERY, as the Filter dropdown): no room for a flyout beside the menu, so Filter swaps the panel's items for its FilterPane under a "‹ " back row (data-menu-back); the panel is capped at the viewport width and re-placed on each swap. Focus goes to the back row, not the search box, so the on-screen keyboard waits for a tap; back, Esc or ← return to the items, focusing Filter.
  • Positioning: position: fixed at placeMenu(trigger, menu) (computeMenuPosition: below the ▾, slid inside the viewport, flipped above when needed); same no-portal reasoning as category submenus above. onMenuDismiss closes it on an outside mousedown or any scroll.
  • Dragging: the menu renders inside the draggable <th>, so the <th> stops being draggable while its menu is open — otherwise dragging the flyout's range slider dragged the column.
  • Role: a non-modal role="dialog" named " options" (columnMenu) holding plain buttons, not a role="menu": the Filter flyout holds a search box, checkboxes and buttons, which a menu can't contain. The flyout is a role="group" named "Filter" (CategorySubmenu's groupLabel).
  • Keyboard: label and ▾ are the header's Tab stops; in the panel ↑/↓/Home/End (moveMenuIndex) move between items, Esc closes back to ▾, Tab moves through every control in DOM order (the flyout's after Filter), and focus leaving the panel closes it. Group by and Hide can remove the header, so keepHeaderMenuFocus moves focus to the ▾ now at the same position.
  • Filtered marker: while its column has an active filter (columnHasActiveFilter), the ▾ becomes the menu's funnel icon in the info color (dt-th-menu--filtered), and its name says so (columnMenu(column, filtered): "Department options, filtered") — shape and name, not color alone.