GroupPanel
Preview
Basic
Loading…
Preview
Code
ts
import GroupPanel from '@/components/form/GroupPanel';src/components/form/GroupPanel.tsx
AI prompt
text
Build a multi-level "Group by" toolbar popover component (Airtable / Lark Base style) in React + TypeScript + Tailwind CSS.
## Look
- Trigger: a 32px icon-only button (`h-8 w-8 rounded-lg`) with the lucide `Group` icon at 16px. Idle: `text-slate-500 hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-700/60 dark:hover:text-slate-100`. While open OR while any level is set it is tinted AMBER — `bg-amber-100 text-amber-600 dark:bg-amber-500/20 dark:text-amber-300` (amber means "rearranges the same rows", as opposed to indigo for controls that narrow them).
- Count badge on the trigger when grouped: `absolute -right-0.5 -top-0.5 h-3.5 min-w-3.5 rounded-full bg-indigo-600 px-1 text-[9px] font-semibold leading-none text-white ring-2 ring-white dark:ring-slate-800`, showing the number of levels.
- Panel: in-flow, `absolute left-0 top-full mt-1 z-50`, width from a prop (default 420px) capped at `max-w-[calc(100vw-2rem)]`, opaque floating surface with `p-3`. Enters with a scale-in from the top-left: 0.22s `cubic-bezier(0.34, 1.56, 0.64, 1)`, from opacity 0 / scale 0.96 / translateY 6px.
- Heading: the `title` in 12px semibold slate-700 (dark slate-200), `mb-2`.
- Empty state: a strip `rounded-lg bg-slate-50 px-3 py-4 text-center text-xs text-slate-400 dark:bg-slate-900/40` reading "Not grouped. Rows appear in sort order."
- Each level is a row, `flex items-center gap-1.5`, rows `space-y-1.5`:
1. Drag grip (lucide `GripVertical`, 14px): `rounded p-1 text-slate-300 hover:text-slate-500 dark:text-slate-600 dark:hover:text-slate-300 cursor-grab active:cursor-grabbing touch-none`.
2. Connector word, `w-8 text-right text-[11px] text-slate-400`: "By" on the first row, "then" on the rest.
3. A native `<select>` of fields (text-input recipe, `py-1.5`, `flex-1 min-w-0`).
4. A two-segment direction control: `rounded-lg border border-slate-300 dark:border-slate-700 overflow-hidden`, segments "A → Z" / "Z → A" at `px-2 py-1 text-[11px] font-medium`; inactive `text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700`, active `bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-300`.
5. Remove ×: `rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600 dark:hover:bg-slate-700`.
- Footer: `mt-2 pt-2 border-t border-slate-100 dark:border-slate-700 flex items-center gap-2`: an amber text button "+ Add a level" (`px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/40`, disabled at 40% opacity); at the limit, a small ⓘ info tooltip "3 levels is the limit."; a ghost "Ungroup" pushed right with `ml-auto` when any level exists; and, only with `requireApply`, a primary "Apply" with its OWN `ml-auto` so it hugs the right edge whether or not Ungroup is shown.
## Behaviour
- Commits IMMEDIATELY by default: every add, remove, field change, direction change or reorder calls `onChange`. No Apply button.
- `requireApply`: edits go to a local draft; Apply commits and closes; closing any other way (outside click, Escape, trigger click) discards the draft. Re-sync the draft whenever `levels` changes from outside. Use one "effective list + commit function" pair so both modes share a code path.
- No duplicate fields: each row's select offers its own field plus the fields no other level uses. "Add a level" appends the first unused field, ascending; disabled at `maxLevels` (default 3) or when nothing is unused.
- Reorder by drag using native HTML5 drag-and-drop: a row becomes `draggable` only while the pointer is down on its grip (so the select stays usable). Rows reorder live as the dragged row enters another; the dragged row is `opacity-40` with `bg-slate-100 dark:bg-slate-700`. A drag that ends without a drop (Escape, released outside) restores the order from before it started.
- The trigger's label reflects the COMMITTED levels, never an open draft: "Grouped by team, then status" (labels lower-cased), or "Group".
## API
- Generic over the field id: `GroupPanel<TBy extends string>`. `type GroupLevel<TBy> = { by: TBy; dir: 'asc' | 'desc' }`.
- `levels: GroupLevel[]`, `onChange(levels)`, `options: readonly TBy[]` (every groupable field), `labelOf(by) => string`, `maxLevels = 3`, `title = 'Group by'`, `width = 420`, `requireApply = false`, `className`.
## Accessibility
- Trigger has `aria-expanded` and `aria-label`/`title` set to the "Grouped by …" sentence. Grip: "Drag to reorder this grouping level". Remove: "Remove this grouping level".
## Demo
A task table's toolbar: groupable fields Status, Priority, Team, Owner, Tags, Billable; start grouped by Team ascending, and print the current levels as JSON below.
## 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) `components/ui/GroupLevelsPanel.tsx`. */
import { useEffect, useRef, useState } from 'react';
import { GripVertical, Group, Plus, X } from 'lucide-react';
import { MAX_GROUP_LEVELS, type GroupLevel } from '@/lib/grouping';
import { cn } from '@/lib/cn';
import {
PANEL_EMPTY_CLASS,
PANEL_GRIP_CLASS,
PANEL_REMOVE_CLASS,
TOOLBAR_BADGE_CLASS,
TOOLBAR_PANEL_CLASS,
panelAddButtonClass,
segmentClass,
toolbarButtonClass,
} from '@/lib/toolbar';
import { useDismiss } from '@/lib/use-dismiss';
import { InfoTooltip } from '@/components/overlay/Tooltip';
import SortableList from '@/components/table/SortableList';
/**
* Group by several fields at once, each level with its own direction — the
* Lark Base / Airtable "group by" builder.
*
* Generic over the field id (`TBy`), so one component serves any table: the
* caller supplies the vocabulary (`options`) and how to name it (`labelOf`).
*
* Commits IMMEDIATELY by default — a grouping change is one deliberate act
* with one visible outcome, so there is no Apply button. `requireApply` opts
* into the other bargain (a local draft, an Apply button, revert on any close
* that is not Apply), for a grid where re-grouping is a round trip or where
* the other three toolbar panels already work that way and this one should
* not be the odd one out.
*
* Reordering is DRAG on `SortableList`: the grip at the start of each row is
* the handle, so nesting order (which level sits at which depth) is a drag
* rather than a remove-then-add-at-the-end round trip.
*/
export default function GroupPanel<TBy extends string = string>({
levels,
onChange,
options,
labelOf,
maxLevels = MAX_GROUP_LEVELS,
title = 'Group by',
width = 420,
requireApply = false,
className,
}: {
levels: GroupLevel<TBy>[];
onChange: (levels: GroupLevel<TBy>[]) => void;
/** Every field that MAY be grouped by — the caller's own vocabulary. */
options: readonly TBy[];
labelOf: (by: TBy) => string;
maxLevels?: number;
/** The popover's own heading. */
title?: string;
width?: number;
/** A local draft, an Apply button and revert-on-close, instead of committing on every change. */
requireApply?: boolean;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<GroupLevel<TBy>[]>(levels);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
setDraft(levels);
}, [levels]);
const close = () => {
if (requireApply) setDraft(levels);
setOpen(false);
};
useDismiss(root, open, close);
const apply = () => {
onChange(draft);
setOpen(false);
};
// THE EFFECTIVE LIST — the draft while `requireApply` holds one open, the
// committed `levels` otherwise. Every row reads and writes through this
// pair, so the two modes are one code path.
const effective = requireApply ? draft : levels;
const commit = requireApply ? setDraft : onChange;
/** A field already used at another level would make a band of one child. */
const unused = options.filter((g) => !effective.some((l) => l.by === g));
const setAt = (i: number, patch: Partial<GroupLevel<TBy>>) =>
commit(effective.map((l, j) => (j === i ? { ...l, ...patch } : l)));
const removeAt = (i: number) => commit(effective.filter((_, j) => j !== i));
const add = () => {
if (unused.length) commit([...effective, { by: unused[0], dir: 'asc' }]);
};
// The trigger reflects what is ACTUALLY GROUPED — the committed `levels`,
// never a draft sitting inside a still-open panel.
const label = levels.length
? `Grouped by ${levels.map((l) => labelOf(l.by).toLowerCase()).join(', then ')}`
: 'Group';
return (
<div ref={root} className={cn('relative inline-block', className)}>
<button
type="button"
aria-label={label}
aria-expanded={open}
title={label}
onClick={() => (open ? close() : setOpen(true))}
className={toolbarButtonClass(open || levels.length > 0, 'amber')}
>
<Group className="h-4 w-4" aria-hidden />
{levels.length > 0 && <span className={TOOLBAR_BADGE_CLASS}>{levels.length}</span>}
</button>
{open && (
<div style={{ width }} className={TOOLBAR_PANEL_CLASS}>
<p className="mb-2 text-xs font-semibold text-slate-700 dark:text-slate-200">{title}</p>
{effective.length === 0 && <p className={PANEL_EMPTY_CLASS}>Not grouped. Rows appear in sort order.</p>}
{effective.length > 0 && (
<SortableList<GroupLevel<TBy>>
items={effective}
getId={(l) => l.by}
onReorder={commit}
group="group-levels"
className="space-y-1.5"
renderItem={(level, { isDragging, handleProps }) => {
const i = effective.indexOf(level);
return (
<div className={cn('flex items-center gap-1.5 rounded', isDragging && 'bg-slate-100 dark:bg-slate-700')}>
<button type="button" aria-label="Drag to reorder this grouping level" {...handleProps} className={PANEL_GRIP_CLASS}>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</button>
<span className="w-8 shrink-0 text-right text-[11px] text-slate-400">{i === 0 ? 'By' : 'then'}</span>
<select
value={level.by}
onChange={(e) => setAt(i, { by: e.target.value as TBy })}
className="field-input min-w-0 flex-1 py-1.5 text-xs"
>
{/* The level's OWN field stays selectable while the others
already in use are not, so a duplicate cannot be built. */}
{options
.filter((g) => g === level.by || unused.includes(g))
.map((g) => (
<option key={g} value={g}>{labelOf(g)}</option>
))}
</select>
<div className="flex shrink-0 overflow-hidden rounded-lg border border-slate-300 dark:border-slate-700">
{(['asc', 'desc'] as const).map((dir) => (
<button key={dir} type="button" onClick={() => setAt(i, { dir })} className={segmentClass(level.dir === dir, 'amber')}>
{dir === 'asc' ? 'A → Z' : 'Z → A'}
</button>
))}
</div>
<button type="button" aria-label="Remove this grouping level" onClick={() => removeAt(i)} className={PANEL_REMOVE_CLASS}>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}}
/>
)}
<div className="mt-2 flex items-center gap-2 border-t border-slate-100 pt-2 dark:border-slate-700">
<button type="button" onClick={add} disabled={effective.length >= maxLevels || unused.length === 0} className={panelAddButtonClass('amber')}>
<Plus className="h-3.5 w-3.5" /> Add a level
</button>
{effective.length >= maxLevels && <InfoTooltip content={`${maxLevels} levels is the limit.`} label="Why can't I add more?" iconClassName="w-3 h-3" />}
{effective.length > 0 && (
<button type="button" onClick={() => commit([])} className="ml-auto btn-ghost text-xs">
Ungroup
</button>
)}
{requireApply && (
// Its OWN `ml-auto`: Ungroup is conditional, and Apply has to land
// at the right edge whether or not that button is there.
<button type="button" onClick={apply} className="ml-auto btn-primary">
Apply
</button>
)}
</div>
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
levels* | GroupLevel<TBy>[] | — | |
onChange* | (levels: GroupLevel<TBy>[]) => void | — | |
options* | readonly TBy[] | — | Every field that MAY be grouped by — the caller's own vocabulary. |
labelOf* | (by: TBy) => string | — | |
maxLevels | number | — | |
title | string | — | The popover's own heading. |
width | number | — | |
requireApply | boolean | — | A local draft, an Apply button and revert-on-close, instead of committing on every change. |
className | string | — |