GroupBandRow
Preview
Basic
Loading…
Preview
Code
ts
import GroupBandRow from '@/components/table/GroupBandRow';src/components/table/GroupBandRow.tsx
AI prompt
text
Build a collapsible group header row (a "group band") component for HTML tables in React + TypeScript + Tailwind CSS.
## Look
- One `<tr>` in `bg-slate-50/80 dark:bg-slate-800/40`, containing a single `<td colSpan={columnCount}>` with its padding removed (`!p-0`).
- Inside: a flex row whose content span is `sticky left-0 flex items-stretch pl-3 text-xs font-medium text-slate-700 dark:text-slate-200`. The band spans the whole table, so without this its label would sit at the TABLE's left edge and scroll out of view when the table scrolls sideways. `sticky left-0` keeps it at the left of the scroll box.
- A fixed 20px chevron slot (`w-5 py-1.5`, centred) that is never indented, so the chevron lines up with the row numbers below at every depth. Only the label indents.
- Chevron button: `rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300`.
- Icon: 14px ChevronDown when open, ChevronRight when collapsed.
- The label span has `padding-left: depth × 18px`, `py-1.5 pr-4` and `gap-1.5`. After the label comes the count in `text-[11px] font-normal text-slate-400 whitespace-nowrap`: "3 tasks", or "1 task" (a plain "s" plural of `noun`).
## Behaviour
- Only the chevron toggles, not the whole band. With the whole band clickable, a click meant for the edge of a row, or a drag to select the group name, would fold the group. The chevron is a real `<button>`, so Enter and Space work.
- Nesting is flat. The caller emits bands between the rows they head, each with a depth, and never nests `<tbody>` elements.
- `count` is the group's REAL size (every row under it), not only the rows on screen.
## API
- Props: `label: string`, `count: number`, `depth = 0`, `collapsed: boolean`, `onToggle()`, `columnCount: number`, `noun = 'record'`.
- Export `INDENT_STEP = 18`.
## Accessibility
- `aria-expanded={!collapsed}` on the chevron.
- `aria-label`: "Collapse Engineering" / "Expand Engineering".
## Demo
A Task / Owner / Status table with two bands, "Engineering" (3 tasks) and "Design" (2 tasks). Each band toggles its own rows, and Status is shown as coloured pills.
Table shell for the demo: `text-xs` text, 10px uppercase semibold slate-600 headers on `bg-slate-50/95`, and `divide-y divide-slate-100 dark:divide-slate-800` rows.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: ticket-management (96S2) `tickets/GroupBandRow.tsx`. */
import { ChevronDown, ChevronRight } from 'lucide-react';
/** Pixels of indent per nesting depth, for a band's label. */
export const INDENT_STEP = 18;
/**
* A GROUP BAND — one `<tr>` spanning every column, carrying the group's
* label, its size and a collapse chevron. `flattenGroups` (`lib/grouping`)
* emits these between the rows they head, each with a depth, so nesting is a
* matter of indent rather than of nested tbodies.
*
* THE CHEVRON is the control, not the band. The band used to be one button
* all the way across, and a click meant for a row's edge, or to drag the
* pointer over a group's name, folded the group instead. The chevron is a
* real `<button>`, so Enter and Space toggle it from the keyboard.
*
* THE LABEL IS PINNED TO THE VISIBLE EDGE. The band spans every column, so
* its content would sit at the TABLE's left edge — which scrolls away the
* moment you move sideways, taking the group name with it. `sticky left-0`
* inside the full-width cell keeps it at the left of the SCROLL BOX instead.
*/
export default function GroupBandRow({
label,
count,
depth = 0,
collapsed,
onToggle,
columnCount,
noun = 'record',
}: {
label: string;
/** The group's REAL size — every row under it, not only those on screen. */
count: number;
depth?: number;
collapsed: boolean;
onToggle: () => void;
/** Every column the table draws, so the band spans them all. */
columnCount: number;
/** What one row is called in the count: "3 records", "3 tasks". */
noun?: string;
}) {
return (
<tr className="bg-slate-50/80 dark:bg-slate-800/40">
<td colSpan={columnCount} className="!p-0">
<div className="flex w-full text-left">
<span className="sticky left-0 flex items-stretch pl-3 text-xs font-medium text-slate-700 dark:text-slate-200">
{/* A fixed-width slot for the chevron, never indented, so it lines
up with the row numbers beneath at every depth; only the label
indents. */}
<span className="flex w-5 shrink-0 items-center justify-center py-1.5">
<button
type="button"
onClick={onToggle}
aria-expanded={!collapsed}
aria-label={`${collapsed ? 'Expand' : 'Collapse'} ${label}`}
className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
>
{collapsed ? <ChevronRight className="h-3.5 w-3.5" aria-hidden /> : <ChevronDown className="h-3.5 w-3.5" aria-hidden />}
</button>
</span>
<span style={{ paddingLeft: depth * INDENT_STEP }} className="inline-flex items-center gap-1.5 py-1.5 pr-4">
{label}
<span className="whitespace-nowrap text-[11px] font-normal text-slate-400">
{count} {noun}{count === 1 ? '' : 's'}
</span>
</span>
</span>
</div>
</td>
</tr>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | |
count* | number | — | The group's REAL size — every row under it, not only those on screen. |
collapsed* | boolean | — | |
onToggle* | () => void | — | |
columnCount* | number | — | Every column the table draws, so the band spans them all. |
depth | number | 0 | |
noun | string | 'record' | What one row is called in the count: "3 records", "3 tasks". |