v1.0

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

PropTypeDefaultDescription
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.
depthnumber0
nounstring'record'What one row is called in the count: "3 records", "3 tasks".