v1.0

ColorRulesPanel

Preview

Basic

Loading…

Preview

Code

ts
import ColorRulesPanel from '@/components/form/ColorRulesPanel';

src/components/form/ColorRulesPanel.tsx

AI prompt

text
Build a conditional-colouring rules toolbar popover (Lark Base style) in React + TypeScript + Tailwind CSS: each rule is a filter condition with a colour, painting a cell or a whole row.

## Look
- Trigger: a 32px icon-only button (`h-8 w-8 rounded-lg`), lucide `PaintBucket` at 16px. Idle `text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-700/60`; tinted `bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300` when open or when any rule exists; a rule-count badge `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 text-white ring-2 ring-white dark:ring-slate-800`.
- Panel: `absolute left-0 top-full mt-1 z-50`, width prop (default 540px), `max-w-[calc(100vw-2rem)]`, opaque floating surface `p-3`, scale-in from the top-left (0.22s `cubic-bezier(0.34, 1.56, 0.64, 1)`).
- Heading row: the title in 12px semibold slate-700, then a 14px ⓘ info tooltip: "Rules are checked top to bottom and the FIRST match wins, so drag the one that should take precedence upwards. Cell tints just that column; Row tints the whole row."
- Empty strip: `rounded-lg bg-slate-50 px-3 py-4 text-center text-xs text-slate-400 dark:bg-slate-900/40` — "No colouring rules. Every row is drawn the same."
- Rule row (`flex items-center gap-1.5`, rows `space-y-1.5`), selects all use the text-input recipe with `py-1.5`:
  1. Drag grip (`GripVertical` 14px, `text-slate-300 hover:text-slate-500 cursor-grab`).
  2. Colour swatch: a 20px `rounded` square in the tone's 400 shade with `ring-1 ring-inset ring-black/10 dark:ring-white/10`, opening a popover grid (`absolute left-0 top-6 z-50 grid grid-cols-6 gap-1 p-1.5`, opaque surface) of 17 tones — sky, amber, slate, emerald, zinc, rose, violet, indigo, orange, yellow, lime, teal, cyan, blue, purple, fuchsia, pink — current one outlined `outline-2 outline-offset-1 outline-indigo-500`. It is a swatch grid on purpose, not a select of colour names.
  3. Scope select `w-20`: Cell / Row.
  4. Field select `w-36`.
  5. Operator select `w-32`.
  6. Value control, `flex-1 min-w-0` (below).
  7. Remove × (`rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600`).
- Footer (`mt-2 pt-2 border-t border-slate-100 dark:border-slate-700`): indigo text button "+ New rule", an ⓘ "20 rules is the limit." at the cap, a ghost "Clear all" (`ml-auto`) when rules exist, and a primary "Apply" (`ml-auto`).

## Behaviour
- Operators by field kind — text & select: is, is not, contains, doesn't contain, is empty, is not empty; number: is, is not, >, ≥, <, ≤, is empty, is not empty; date: is, is before, is after, is empty, is not empty; bool: is; ref (a person/record id): is, is not, is empty, is not empty.
- Value control: "is empty"/"is not empty" show a slate-400 "—" instead. A field with options → a select with "Select…" first; for a select field with contains / doesn't contain, a multi-pick dropdown instead (trigger shows "Select…", the one label, or "3 selected"; a searchable list of checkable options that stays open while picking), stored comma-separated. Bool → select Checked / Unchecked (`"true"`/`"false"`). Date + "is" → a mode select (Exact date, Today, Tomorrow, Yesterday, This / Last week, This / Last month, In the past / Within the next 7 and 30 days) plus a date input only for Exact; relative modes are stored as `"rel:today"` etc. Other dates → `type="date"`, numbers → `type="number"`, else a text input "Enter a value".
- Changing a rule's field keeps its operator if the new field supports it, else takes the first, and clears the value; changing the operator clears the value.
- New rule: scope CELL (a wrong cell rule tints one column, a wrong row rule repaints the grid), the first field, its first operator, empty value, and the first tone no other rule uses. Max 20 rules. Rule ids are random strings, stable across edits and drags.
- DRAFT + Apply: nothing reaches `onChange` until Apply; outside click, Escape or the trigger revert the draft. Re-sync when `rules` change from outside.
- Order is priority, so rows reorder by native HTML5 drag from the grip only (row draggable while the grip is pressed, live reordering, dragged row `opacity-40 bg-slate-100 dark:bg-slate-700`, a cancelled drag reverts).

## API
- `type ColorRule = { id: string; scope: 'cell' | 'row'; field: string; op: Operator; value: string; tone: Tone }`; `type FilterField = { id: string; label: string; kind: 'text' | 'number' | 'date' | 'select' | 'bool' | 'ref'; options?: { value: string; label: string; tone?: string }[] }`.
- Props: `fields: FilterField[]`, `rules: ColorRule[]`, `onChange(rules)`, `title = 'Conditional colouring'`, `width = 540`, `className`.
- Ship a pure resolver too: per row, the row takes the first matching ROW rule; each cell takes the first matching CELL rule that names its column. Tints, written out per tone in full (no interpolated class names): row `bg-{tone}-50/60 dark:bg-{tone}-950/20`, cell (stronger) `bg-{tone}-100/70 dark:bg-{tone}-950/40`; slate and zinc one step darker in light mode and `-800` in dark.

## Accessibility
- Trigger `aria-label` "Conditional colouring — 2 rules", `aria-expanded`. Grip: "Reorder — the first matching rule wins". Scope select: "Where this colour is applied". Swatch "Colour: rose".

## Demo
Task fields Status (To do / In progress / In review / Done) and Priority (Low / Medium / High / Urgent); rules "Row · Priority is Urgent · rose" and "Cell · Status is Done · emerald"; under it a six-row task list painted by the resolver.

## 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/ColorRulesPanel.tsx`. */

import { useEffect, useRef, useState } from 'react';
import { GripVertical, PaintBucket, Plus, X } from 'lucide-react';
import { OPERATOR_LABELS, operatorsFor, type FilterField, type Operator } from '@/lib/conditions';
import { COLOR_TONES, MAX_COLOR_RULES, newRuleId, type ColorRule, type ColorScope } from '@/lib/coloring';
import { TONE_DOT, type Tone } from '@/lib/tones';
import { cn } from '@/lib/cn';
import {
  PANEL_EMPTY_CLASS,
  PANEL_GRIP_CLASS,
  PANEL_REMOVE_CLASS,
  TOOLBAR_BADGE_CLASS,
  TOOLBAR_PANEL_CLASS,
  panelAddButtonClass,
  toolbarButtonClass,
} from '@/lib/toolbar';
import { useDismiss } from '@/lib/use-dismiss';
import SortableList from '@/components/table/SortableList';
import { InfoTooltip } from '@/components/overlay/Tooltip';
import { ValueInput } from './FilterPanel';

const SCOPE_LABELS: Record<ColorScope, string> = { cell: 'Cell', row: 'Row' };

/**
 * CONDITIONAL COLOURING — `[colour] [scope] [field] [operator] [value]` rows.
 *
 * A colouring rule IS a filter predicate with a colour attached, so the field
 * select, the operator select and the value control are the same vocabulary
 * and — for the value — literally the same component (`ValueInput`, exported
 * from `FilterPanel`). Nothing here decides what an operator means;
 * `resolveRowColors` in `lib/coloring` does, from the same evaluator the
 * filter uses.
 *
 * Holds a DRAFT and commits on Apply, matching the other toolbar panels. A
 * reader still previews a rule's own swatch inline in the still-open panel;
 * what does not happen is the grid repainting mid-edit.
 *
 * ORDER IS PRIORITY. First match wins per target, so dragging a rule up is a
 * real edit rather than tidying — which is why the handle is here.
 */
export default function ColorRulesPanel({
  fields,
  rules,
  onChange,
  title = 'Conditional colouring',
  width = 540,
  className,
}: {
  fields: FilterField[];
  rules: ColorRule[];
  onChange: (rules: ColorRule[]) => void;
  title?: string;
  width?: number;
  className?: string;
}) {
  const [open, setOpen] = useState(false);
  const [draft, setDraft] = useState<ColorRule[]>(rules);
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    setDraft(rules);
  }, [rules]);

  const revertAndClose = () => {
    setDraft(rules);
    setOpen(false);
  };
  useDismiss(root, open, revertAndClose);

  const apply = () => {
    onChange(draft);
    setOpen(false);
  };

  const byId = new Map(fields.map((f) => [f.id, f]));
  const setAt = (id: string, patch: Partial<ColorRule>) =>
    setDraft((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
  const removeAt = (id: string) => setDraft((prev) => prev.filter((r) => r.id !== id));

  const add = () => {
    const first = fields[0];
    if (!first) return;
    setDraft((prev) => [
      ...prev,
      {
        id: newRuleId(),
        // CELL is the default: a wrong cell rule tints one column, a wrong
        // row rule repaints the grid.
        scope: 'cell',
        field: first.id,
        op: operatorsFor(first.kind)[0],
        value: '',
        // The first tone not already spoken for, so three rules added in a
        // row are three different colours.
        tone: COLOR_TONES.find((t) => !prev.some((r) => r.tone === t)) ?? COLOR_TONES[0],
      },
    ]);
  };

  // Changing the FIELD can invalidate the operator — the filter panel's rule.
  const changeField = (id: string, fieldId: string) => {
    const field = byId.get(fieldId);
    const current = draft.find((r) => r.id === id);
    if (!field || !current) return;
    const ops = operatorsFor(field.kind);
    setAt(id, { field: fieldId, op: ops.includes(current.op) ? current.op : ops[0], value: '' });
  };

  const label = rules.length ? `${title} — ${rules.length} rule${rules.length === 1 ? '' : 's'}` : title;

  return (
    <div ref={root} className={cn('relative inline-block', className)}>
      <button
        type="button"
        aria-label={label}
        aria-expanded={open}
        title={label}
        onClick={() => (open ? revertAndClose() : setOpen(true))}
        className={toolbarButtonClass(open || rules.length > 0)}
      >
        <PaintBucket className="h-4 w-4" aria-hidden />
        {rules.length > 0 && <span className={TOOLBAR_BADGE_CLASS}>{rules.length}</span>}
      </button>

      {open && (
        <div style={{ width }} className={TOOLBAR_PANEL_CLASS}>
          <div className="mb-2 flex items-center gap-1.5 text-xs">
            <span className="font-semibold text-slate-700 dark:text-slate-200">{title}</span>
            <InfoTooltip
              label="How colouring works"
              content="Rules are checked top to bottom and the FIRST match wins, so drag the one that should take precedence upwards. Cell tints just that column; Row tints the whole row."
            />
          </div>

          {draft.length === 0 && <p className={PANEL_EMPTY_CLASS}>No colouring rules. Every row is drawn the same.</p>}

          <SortableList
            group="color-rules"
            items={draft}
            getId={(r) => r.id}
            onReorder={setDraft}
            className="space-y-1.5"
            renderItem={(rule, { isDragging, handleProps }) => {
              const field = byId.get(rule.field);
              const ops = operatorsFor(field?.kind ?? 'text');
              return (
                <div className={cn('flex items-center gap-1.5 rounded', isDragging && 'bg-slate-100 dark:bg-slate-700')}>
                  <button type="button" aria-label="Reorder — the first matching rule wins" {...handleProps} className={PANEL_GRIP_CLASS}>
                    <GripVertical className="h-3.5 w-3.5" aria-hidden />
                  </button>

                  <SwatchPicker tone={rule.tone} onPick={(tone) => setAt(rule.id, { tone })} />

                  <select
                    aria-label="Where this colour is applied"
                    value={rule.scope}
                    onChange={(e) => setAt(rule.id, { scope: e.target.value as ColorScope })}
                    className="field-input w-20 shrink-0 py-1.5 text-xs"
                  >
                    {(Object.keys(SCOPE_LABELS) as ColorScope[]).map((s) => (
                      <option key={s} value={s}>{SCOPE_LABELS[s]}</option>
                    ))}
                  </select>

                  <select value={rule.field} onChange={(e) => changeField(rule.id, e.target.value)} className="field-input w-36 shrink-0 py-1.5 text-xs">
                    {fields.map((f) => (
                      <option key={f.id} value={f.id}>{f.label}</option>
                    ))}
                  </select>

                  <select
                    value={rule.op}
                    onChange={(e) => setAt(rule.id, { op: e.target.value as Operator, value: '' })}
                    className="field-input w-32 shrink-0 py-1.5 text-xs"
                  >
                    {ops.map((op) => (
                      <option key={op} value={op}>{OPERATOR_LABELS[op]}</option>
                    ))}
                  </select>

                  {/* THE FILTER PANEL'S OWN CONTROL — see its export note. */}
                  <ValueInput field={field} op={rule.op} value={rule.value} onChange={(v) => setAt(rule.id, { value: v })} />

                  <button type="button" aria-label="Remove this rule" onClick={() => removeAt(rule.id)} 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={draft.length >= MAX_COLOR_RULES || fields.length === 0} className={panelAddButtonClass()}>
              <Plus className="h-3.5 w-3.5" /> New rule
            </button>
            {draft.length >= MAX_COLOR_RULES && <InfoTooltip content={`${MAX_COLOR_RULES} rules is the limit.`} label="Why can't I add more?" iconClassName="w-3 h-3" />}
            {draft.length > 0 && (
              <button type="button" onClick={() => setDraft([])} className="ml-auto btn-ghost text-xs">
                Clear all
              </button>
            )}
            <button type="button" onClick={apply} className="ml-auto btn-primary">
              Apply
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

/**
 * The colour swatch, opening a grid of every tone.
 *
 * NOT a `<select>`: the thing being chosen is the colour itself, and a
 * dropdown of the WORD "amber" makes the reader translate a name into a hue
 * that is sitting right there. The names are still the accessible label.
 */
export function SwatchPicker({ tone, onPick }: { tone: Tone; onPick: (tone: Tone) => void }) {
  const [open, setOpen] = useState(false);
  const box = useRef<HTMLDivElement>(null);
  useDismiss(box, open, () => setOpen(false));

  return (
    <div ref={box} className="relative shrink-0">
      <button
        type="button"
        aria-label={`Colour: ${tone}`}
        title={tone}
        onClick={() => setOpen((v) => !v)}
        className={cn('h-5 w-5 rounded ring-1 ring-inset ring-black/10 dark:ring-white/10', TONE_DOT[tone] ?? TONE_DOT.slate)}
      />
      {open && (
        <div className="absolute left-0 top-6 z-50 grid w-max grid-cols-6 gap-1 panel panel-solid p-1.5">
          {COLOR_TONES.map((t) => (
            <button
              key={t}
              type="button"
              aria-label={t}
              title={t}
              onClick={() => {
                onPick(t);
                setOpen(false);
              }}
              className={cn(
                'h-5 w-5 rounded ring-1 ring-inset ring-black/10 dark:ring-white/10',
                TONE_DOT[t],
                t === tone && 'outline outline-2 outline-offset-1 outline-indigo-500',
              )}
            />
          ))}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
fields*FilterField[]—
rules*ColorRule[]—
onChange*(rules: ColorRule[]) => void—
titlestring'Conditional colouring'
widthnumber540
classNamestring—