/**
 * Non-side-effect class export for `<table-toolbar-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/table-toolbar`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <table-toolbar-ui for="emps" text="All Employees" count="32"></table-toolbar-ui>
 *
 * Companion / header bar for a sibling table-ui. Renders:
 *   • title + optional count badge
 *   • filter / sort / columns popover buttons
 *   • search input
 *   • optional [slot="actions"] trailing region
 *
 * [for] resolution mirrors chart-legend-ui — the toolbar mounts an element by
 * id, then dispatches state changes against it. When [for] is absent, falls
 * back to the first table-ui sibling under the same parent.
 *
 * Popovers use the platform Popover API + core/anchor.js, the same primitives
 * that menu-ui / popover-ui / toolbar-ui already use in this package.
 *
 * State flow:
 *   search        → table.search       (string property)
 *   filters       → table.setFilter()  (per-key)
 *   sort          → simulated click on table's [data-sort-key] header
 *   column hidden → table.columns =    (clone with hidden flag flipped)
 */

import { UIElement } from '../../core/element.js';
import { anchorPopover } from '../../core/anchor.js';

const SEARCH_DEBOUNCE = 200;

function emptyHint(text) {
  const el = document.createElement('text-ui');
  el.setAttribute('data-popover-empty', '');
  el.setAttribute('color', 'subtle');
  el.setAttribute('variant', 'caption');
  el.textContent = text;
  return el;
}

function popoverHead(text) {
  const el = document.createElement('text-ui');
  el.setAttribute('data-popover-head', '');
  el.setAttribute('variant', 'kicker');
  el.setAttribute('color', 'subtle');
  el.textContent = text;
  return el;
}

const SELECT_THRESHOLD = 50;
const SAMPLE_LIMIT     = 500;

function getCellValue(row, col) {
  if (typeof col?.accessor === 'function') return col.accessor(row);
  const path = col?.key;
  if (!path || row == null) return undefined;
  const parts = String(path).split('.');
  let cur = row;
  for (const p of parts) {
    if (cur == null) return undefined;
    cur = cur[p];
  }
  return cur;
}

function looksLikeIdKey(key) {
  if (!key) return false;
  const k = String(key);
  if (k === 'id' || k === 'ID' || k === 'uuid' || k === 'guid') return true;
  if (/[a-z](Id|ID)$/.test(k)) return true;
  if (/_id$/i.test(k)) return true;
  return false;
}

function detectFilterShape(col, data) {
  if (col?.filter === 'select' || col?.filter === 'text') return col.filter;
  if (looksLikeIdKey(col?.key)) return 'text';

  const values = [];
  const limit = Math.min(data?.length ?? 0, SAMPLE_LIMIT);
  const unique = new Set();
  for (let i = 0; i < limit; i++) {
    const v = getCellValue(data[i], col);
    if (v == null || v === '') continue;
    values.push(v);
    unique.add(String(v));
    if (unique.size > SELECT_THRESHOLD) return 'text';
  }
  if (!values.length) return 'text';
  return 'select';
}

function collectUniqueValues(col, data) {
  const set = new Set();
  const limit = Math.min(data?.length ?? 0, SAMPLE_LIMIT);
  for (let i = 0; i < limit; i++) {
    const v = getCellValue(data[i], col);
    if (v == null || v === '') continue;
    set.add(String(v));
  }
  return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}

export class UITableToolbar extends UIElement {
  static properties = {
    for:         { type: String,  default: '',         reflect: true },
    text:        { type: String,  default: '',         reflect: false },
    count:       { type: String,  default: '',         reflect: false },
    noFilter:    { type: Boolean, default: false,      reflect: true, attribute: 'no-filter' },
    noSort:      { type: Boolean, default: false,      reflect: true, attribute: 'no-sort' },
    noColumns:   { type: Boolean, default: false,      reflect: true, attribute: 'no-columns' },
    noSearch:    { type: Boolean, default: false,      reflect: true, attribute: 'no-search' },
    placeholder: { type: String,  default: 'Search...', reflect: false },
    variant:     { type: String,  default: 'default',  reflect: true },
  };

  // §205 (v0.5.7): dynamic sort-indicator icons (table-toolbar.class.js:576 — nested ternary
  // `dir === 'asc' ? 'arrow-up' : dir === 'desc' ? 'arrow-down' : 'caret-up-down'`).
  // Per FEEDBACK-16 §1 + §209 slot-11 ternary-walker discovery.
  static requiredIcons = ['arrow-up', 'arrow-down', 'caret-up-down'];

  static template = () => null;

  #target = null;
  #targetListeners = [];
  #activePopover = null;       // { btn, panel, cleanup }
  #docListenersBound = false;
  #docListenerRaf = null;
  #sortIndicatorRafs = new Set();

  // §381 (v0.6.10, slice F): one-shot dev-mode warning when consumers
  // set common-but-unknown opt-out attributes (e.g. `searchable`,
  // `exportable`, `sortable`) thinking they control table-toolbar
  // visibility. Canonical attrs are `no-search` / `no-filter` /
  // `no-sort` / `no-columns`. WeakSet pinned static so each element
  // warns at most once even across reconnects.
  static _warnedUnknownAttrs = new WeakSet();
  static _CANONICAL_OPT_OUT_ATTRS = new Set(['no-search', 'no-filter', 'no-sort', 'no-columns']);
  static _OPT_OUT_PREFIX_RE = /^(search|export|sort|filter|column)/i;

  // ── Lifecycle ────────────────────────────────────────────────────────────

  connected() {
    this.setAttribute('role', 'toolbar');
    this.#warnUnknownOptOutAttrs();
    this.#stamp();
    this.#resolveTarget();
    this.#syncFromTarget();
  }

  disconnected() {
    if (this.#docListenerRaf != null) {
      cancelAnimationFrame(this.#docListenerRaf);
      this.#docListenerRaf = null;
    }
    for (const id of this.#sortIndicatorRafs) cancelAnimationFrame(id);
    this.#sortIndicatorRafs.clear();
    this.#closePopover();
    this.#detachTarget();
  }

  render() {
    // [for] / count / text changes come through here.
    this.#resolveTarget();
    this.#syncFromTarget();
    this.#updateTitle();
  }

  // ── Unknown opt-out attribute warning (§381, v0.6.10 slice F) ────────────

  /**
   * Warn once (per element) when the consumer sets attributes that look
   * like opt-outs but aren't canonical. Triggered by claims-ui FB-04:
   * consumers set `searchable` / `exportable` / `sortable` thinking they
   * toggle visibility; the canonical attrs are `no-search`/`no-filter`/
   * `no-sort`/`no-columns`. Silent no-op was the previous behavior —
   * confusing UX. Pattern: warn-once-per-element via static WeakSet.
   *
   * Scope: attributes whose names start with `search`, `export`, `sort`,
   * `filter`, `column` (case-insensitive). Canonical attrs are
   * subtracted before the warn fires. Standard HTML/ARIA attrs (`id`,
   * `class`, `role`, `aria-*`, `data-*`, etc.) are never flagged.
   *
   * Pinned by table-toolbar.test.js.
   */
  #warnUnknownOptOutAttrs() {
    if (UITableToolbar._warnedUnknownAttrs.has(this)) return;
    const unknowns = [];
    for (const attr of this.attributes) {
      const name = attr.name.toLowerCase();
      if (UITableToolbar._CANONICAL_OPT_OUT_ATTRS.has(name)) continue;
      if (!UITableToolbar._OPT_OUT_PREFIX_RE.test(name)) continue;
      unknowns.push(name);
    }
    if (unknowns.length === 0) return;
    UITableToolbar._warnedUnknownAttrs.add(this);
    const list = unknowns.map((n) => `"${n}"`).join(', ');
    // eslint-disable-next-line no-console
    console.warn(
      `[table-toolbar-ui] Unknown opt-out attribute(s) ${list}. ` +
      `Canonical opt-out attrs are: no-search, no-filter, no-sort, no-columns. ` +
      `See packages/web-components/components/table-toolbar/table-toolbar.yaml ` +
      `or run \`npx adia-ui-doc table-toolbar\` for the full attribute reference.`,
    );
  }

  // ── Target resolution ────────────────────────────────────────────────────

  #resolveTarget() {
    const next = this.#findTarget();
    if (next === this.#target) return;
    this.#detachTarget();
    if (!next) return;
    this.#target = next;

    const onSort = () => this.#refreshSortPanel();
    const onFilter = () => this.#refreshFilterPanel();
    next.addEventListener('sort', onSort);
    next.addEventListener('filter-change', onFilter);
    this.#targetListeners.push(['sort', onSort], ['filter-change', onFilter]);
  }

  #findTarget() {
    if (this.for) {
      const root = this.getRootNode?.();
      const byId = root?.getElementById?.(this.for) || document.getElementById(this.for);
      if (byId && byId.tagName?.toLowerCase() === 'table-ui') return byId;
      return null;
    }
    // Fallback — first sibling table-ui in the same parent.
    const parent = this.parentElement;
    if (!parent) return null;
    return parent.querySelector(':scope table-ui') || null;
  }

  #detachTarget() {
    if (this.#target) {
      for (const [evt, fn] of this.#targetListeners) {
        this.#target.removeEventListener(evt, fn);
      }
    }
    this.#targetListeners = [];
    this.#target = null;
  }

  // ── DOM stamp ────────────────────────────────────────────────────────────

  #stamp() {
    if (this.querySelector(':scope > [data-toolbar]')) return;

    const root = document.createElement('div');
    root.setAttribute('data-toolbar', '');

    // Title cluster
    const title = document.createElement('div');
    title.setAttribute('data-title', '');

    const heading = document.createElement('span');
    heading.setAttribute('data-heading', '');
    title.appendChild(heading);

    const badge = document.createElement('badge-ui');
    badge.setAttribute('data-count-badge', '');
    badge.setAttribute('size', 'sm');
    badge.setAttribute('variant', 'muted');
    badge.hidden = true;
    title.appendChild(badge);

    // Controls cluster
    const controls = document.createElement('div');
    controls.setAttribute('data-controls', '');
    controls.appendChild(this.#mkButton('filter', 'Filter', 'funnel-simple'));
    controls.appendChild(this.#mkButton('sort', 'Sort', 'arrows-down-up'));
    controls.appendChild(this.#mkButton('columns', 'Columns', 'columns'));

    // Search — compose <search-ui>, which already stamps input-ui with
    // the magnifying-glass prefix + clear suffix and debounces a `search`
    // event. Rolling our own from input-ui would re-derive that wiring
    // and (as discovered) hit a first-paint timing race where the icon
    // name renders as literal text before the icon registry resolves.
    const search = document.createElement('search-ui');
    search.setAttribute('data-search', '');
    search.setAttribute('size', 'sm');  // match toolbar buttons + badge + filter chips
    search.setAttribute('placeholder', this.placeholder);
    search.setAttribute('debounce', String(SEARCH_DEBOUNCE));
    search.addEventListener('search', this.#onSearch);

    // Actions slot passthrough — we move any pre-existing [slot="actions"] children here
    const actionsSlot = document.createElement('div');
    actionsSlot.setAttribute('data-actions', '');
    for (const node of [...this.children]) {
      if (node === root) continue;
      if (node.getAttribute?.('slot') === 'actions') {
        actionsSlot.appendChild(node);
      }
    }

    root.appendChild(title);
    root.appendChild(controls);
    root.appendChild(search);
    root.appendChild(actionsSlot);

    this.appendChild(root);
    this.#updateTitle();
    this.#updateControlVisibility();
  }

  #mkPopoverAction(label, onClick) {
    const btn = document.createElement('button-ui');
    btn.setAttribute('data-popover-action', '');
    btn.setAttribute('text', label);
    btn.setAttribute('variant', 'ghost');
    btn.setAttribute('size', 'sm');
    btn.setAttribute('stretch', '');
    btn.addEventListener('click', onClick);
    return btn;
  }

  #mkButton(kind, label, icon) {
    const btn = document.createElement('button-ui');
    btn.setAttribute('data-toolbar-btn', kind);
    btn.setAttribute('icon', icon);
    btn.setAttribute('text', label);
    btn.setAttribute('variant', 'outline');
    btn.setAttribute('size', 'sm');
    btn.setAttribute('aria-haspopup', 'menu');
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      this.#togglePopover(kind, btn);
    });
    return btn;
  }

  #updateTitle() {
    const heading = this.querySelector(':scope [data-heading]');
    if (!heading) return;
    if (this.text) {
      heading.textContent = this.text;
      heading.hidden = false;
    } else if (heading.textContent.trim()) {
      heading.hidden = false;
    } else {
      heading.hidden = true;
    }

    const badge = this.querySelector(':scope [data-count-badge]');
    if (!badge) return;
    const explicit = this.count?.toString().trim();
    const fallback = this.#target?.data?.length;
    const value = explicit || (Number.isFinite(fallback) ? String(fallback) : '');
    if (value) {
      badge.setAttribute('text', value);
      badge.hidden = false;
    } else {
      badge.hidden = true;
    }
  }

  #updateControlVisibility() {
    const root = this.querySelector(':scope > [data-toolbar]');
    if (!root) return;

    const setHidden = (sel, hidden) => {
      const el = root.querySelector(sel);
      if (el) el.hidden = hidden;
    };

    setHidden('[data-toolbar-btn="filter"]',  this.noFilter);
    setHidden('[data-toolbar-btn="sort"]',    this.noSort);
    setHidden('[data-toolbar-btn="columns"]', this.noColumns);
    setHidden('[data-search]',                this.noSearch);
  }

  // Re-run on attribute changes for boolean flags.
  attributeChanged(name) {
    if (['no-filter', 'no-sort', 'no-columns', 'no-search'].includes(name)) {
      this.#updateControlVisibility();
    }
    if (name === 'placeholder') {
      const search = this.querySelector(':scope [data-search]');
      search?.setAttribute('placeholder', this.placeholder);
    }
  }

  // ── Search ───────────────────────────────────────────────────────────────

  #onSearch = (e) => {
    const value = e.detail?.value ?? '';
    if (this.#target) this.#target.search = value;
    this.dispatchEvent(new CustomEvent('search', {
      bubbles: true,
      detail: { value },
    }));
  };

  // ── Sync from target (initial paint) ─────────────────────────────────────

  #syncFromTarget() {
    if (!this.#target) return;
    const search = this.querySelector(':scope [data-search]');
    if (search && this.#target.search) {
      search.value = this.#target.search;
    }
    this.#updateTitle();
  }

  // ── Popovers ─────────────────────────────────────────────────────────────

  #togglePopover(kind, btn) {
    if (this.#activePopover?.kind === kind) {
      this.#closePopover();
      return;
    }
    this.#closePopover();

    const panel = document.createElement('div');
    panel.setAttribute('data-toolbar-popover', kind);
    panel.setAttribute('popover', 'manual');
    panel.setAttribute('role', 'menu');

    if (kind === 'filter')  this.#fillFilterPanel(panel);
    if (kind === 'sort')    this.#fillSortPanel(panel);
    if (kind === 'columns') this.#fillColumnsPanel(panel);

    document.body.appendChild(panel);

    try { panel.showPopover(); } catch { /* popover API unavailable */ }
    // ADR-0034 Rule 2: filter/sort/columns panels (200-320px) >> icon trigger (~32px) → center.
    // Placement is internal — not consumer-overridable on table-toolbar-ui.
    const cleanup = anchorPopover(btn, panel, { placement: 'bottom', gap: 4 });

    this.#activePopover = { kind, btn, panel, cleanup };

    if (!this.#docListenersBound) {
      this.#docListenersBound = true;
      this.#docListenerRaf = requestAnimationFrame(() => {
        this.#docListenerRaf = null;
        if (!this.isConnected || !this.#docListenersBound) return;
        document.addEventListener('pointerdown', this.#onDocDown, true);
        document.addEventListener('keydown', this.#onDocKey, true);
      });
    }
  }

  #closePopover() {
    const ap = this.#activePopover;
    if (!ap) return;
    ap.cleanup?.();
    if (ap.panel?.matches?.(':popover-open')) {
      try { ap.panel.hidePopover(); } catch { /* noop */ }
    }
    ap.panel?.remove();
    this.#activePopover = null;
    if (this.#docListenersBound) {
      this.#docListenersBound = false;
      document.removeEventListener('pointerdown', this.#onDocDown, true);
      document.removeEventListener('keydown', this.#onDocKey, true);
    }
  }

  #onDocDown = (e) => {
    const ap = this.#activePopover;
    if (!ap) return;
    if (ap.btn.contains(e.target)) return;
    if (ap.panel.contains(e.target)) return;
    this.#closePopover();
  };

  #onDocKey = (e) => {
    if (e.key !== 'Escape') return;
    e.stopPropagation();
    this.#closePopover();
    this.#activePopover?.btn?.focus?.({ preventScroll: true });
  };

  // ── Filter panel ─────────────────────────────────────────────────────────

  #fillFilterPanel(panel) {
    const target = this.#target;
    if (!target?.columns?.length) {
      panel.appendChild(emptyHint('No filterable columns'));
      return;
    }
    const filters = target.filters || {};

    panel.appendChild(popoverHead('Filter rows'));

    const list = document.createElement('div');
    list.setAttribute('data-popover-list', '');

    const data = target.data || [];
    for (const col of target.columns) {
      if (col.hidden) continue;
      // <field-ui inline label="…"><…control…></…></field-ui> — canonical
      // label-binds-to-control pair, mints id + [for]. The CONTROL is
      // chosen per-column from the column's filter shape:
      //   • 'select' → <select-ui multiple searchable> populated from data
      //   • 'text'   → <input-ui type="text"> (contains match)
      // Shape comes from col.filter when set, otherwise auto-detected
      // from the data (id-like keys → text; ≤ 50 distinct values → select).
      const row = document.createElement('field-ui');
      row.setAttribute('data-filter-row', '');
      row.setAttribute('inline', '');
      row.setAttribute('label', col.label || col.key);

      const shape = detectFilterShape(col, data);
      const current = filters[col.key];

      if (shape === 'select') {
        const sel = document.createElement('select-ui');
        sel.setAttribute('data-filter-input', '');
        sel.setAttribute('multiple', '');
        sel.setAttribute('placeholder', '—');

        const uniqueValues = collectUniqueValues(col, data);
        // Searchable kicks in at 12+ options — below that the listbox is
        // fully scannable and a search field is overhead.
        if (uniqueValues.length >= 12) sel.setAttribute('searchable', '');

        for (const val of uniqueValues) {
          const opt = document.createElement('option');
          opt.setAttribute('value', val);
          opt.textContent = val;
          sel.appendChild(opt);
        }
        if (current?.op === 'select' && current.value) {
          sel.value = current.value;
        }
        sel.addEventListener('change', () => {
          const v = sel.value || '';
          if (v) target.setFilter(col.key, v, 'select');
          else target.setFilter(col.key, null);
          this.dispatchEvent(new CustomEvent('filter-change', {
            bubbles: true,
            detail: { filters: target.filters },
          }));
        });
        row.appendChild(sel);
      } else {
        const input = document.createElement('input-ui');
        input.setAttribute('type', 'text');
        input.setAttribute('size', 'sm');
        input.setAttribute('data-filter-input', '');
        input.setAttribute('placeholder', '—');
        if (current?.op === 'contains') input.value = current.value ?? '';
        input.addEventListener('input', () => {
          const v = input.value;
          if (v) target.setFilter(col.key, v, 'contains');
          else target.setFilter(col.key, null);
          this.dispatchEvent(new CustomEvent('filter-change', {
            bubbles: true,
            detail: { filters: target.filters },
          }));
        });
        row.appendChild(input);
      }

      list.appendChild(row);
    }

    panel.appendChild(list);

    if (Object.keys(filters).length) {
      const clear = this.#mkPopoverAction('Clear all filters', () => {
        target.clearFilters();
        this.dispatchEvent(new CustomEvent('filter-change', {
          bubbles: true,
          detail: { filters: {} },
        }));
        this.#refreshFilterPanel();
      });
      panel.appendChild(clear);
    }
  }

  #refreshFilterPanel() {
    const ap = this.#activePopover;
    if (!ap || ap.kind !== 'filter') return;
    ap.panel.replaceChildren();
    this.#fillFilterPanel(ap.panel);
  }

  // ── Sort panel ───────────────────────────────────────────────────────────

  #fillSortPanel(panel) {
    const target = this.#target;
    if (!target?.columns?.length) {
      panel.appendChild(emptyHint('No sortable columns'));
      return;
    }

    panel.appendChild(popoverHead('Sort by'));

    const sortState = target.sortState || [];
    const dirByKey = new Map(sortState.map((s) => [s.key, s.dir]));

    const list = document.createElement('div');
    list.setAttribute('data-popover-list', '');

    for (const col of target.columns) {
      if (col.hidden) continue;
      if (col.sortable === false) continue;

      // <menu-item-ui> is the canonical action-row primitive (role="menuitem",
      // built-in icon + text slots, hover/focus tokens, danger variant). Append
      // a trailing <icon-ui> after the auto-stamped [slot="text"] — its flex:1
      // pushes any later child to the trailing edge.
      const dir = dirByKey.get(col.key);
      const row = document.createElement('menu-item-ui');
      row.setAttribute('data-sort-row', '');
      row.setAttribute('text', col.label || col.key);
      row.dataset.key = col.key;
      if (dir) row.dataset.active = dir;

      // Trailing direction indicator — append AFTER menu-item-ui's connected()
      // has run its #stamp() (defers via rAF so the [slot="text"] span exists).
      const rafId = requestAnimationFrame(() => {
        this.#sortIndicatorRafs.delete(rafId);
        if (!this.isConnected) return;
        const indicator = document.createElement('icon-ui');
        indicator.setAttribute('data-sort-indicator', '');
        indicator.setAttribute('size', 'xs');
        indicator.setAttribute('name', dir === 'asc' ? 'arrow-up' : dir === 'desc' ? 'arrow-down' : 'caret-up-down');
        row.appendChild(indicator);
      });
      this.#sortIndicatorRafs.add(rafId);

      row.addEventListener('click', (e) => {
        // Forward to the bound table by simulating a header click — the table
        // already owns the asc / desc / clear cycle, so we don't duplicate state.
        const headerCell = target.querySelector(`:scope > [data-header] [data-sort-key="${col.key}"]`);
        if (!headerCell) return;
        const evt = new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: e.shiftKey });
        headerCell.dispatchEvent(evt);
        this.dispatchEvent(new CustomEvent('sort-change', {
          bubbles: true,
          detail: { sortState: target.sortState },
        }));
        this.#refreshSortPanel();
      });

      list.appendChild(row);
    }

    panel.appendChild(list);

    if (sortState.length) {
      const clear = this.#mkPopoverAction('Clear sort', () => {
        // Simulate cycle-through clicks until empty — but the table only stores
        // one sort entry per key, and a non-shift click on an already-active key
        // cycles to the opposite dir before clearing. Cleanest: clone columns and
        // clear via a microtask using clicks on each active key until empty.
        for (const s of [...sortState]) {
          const headerCell = target.querySelector(`:scope > [data-header] [data-sort-key="${s.key}"]`);
          if (!headerCell) continue;
          // Two clicks toggle through asc → desc → clear when single-sort. With
          // multi-sort entries we shift-click to remove individually.
          const fire = (shift) => headerCell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: shift }));
          if (sortState.length > 1) {
            // Shift-clicking through default→opposite→remove.
            fire(true); fire(true);
          } else {
            // Single-sort: click twice non-shift to cycle through both dirs and clear.
            fire(false); fire(false);
          }
        }
        this.dispatchEvent(new CustomEvent('sort-change', {
          bubbles: true,
          detail: { sortState: target.sortState },
        }));
        this.#refreshSortPanel();
      });
      panel.appendChild(clear);
    }
  }

  #refreshSortPanel() {
    const ap = this.#activePopover;
    if (!ap || ap.kind !== 'sort') return;
    ap.panel.replaceChildren();
    this.#fillSortPanel(ap.panel);
  }

  // ── Columns panel ────────────────────────────────────────────────────────

  #fillColumnsPanel(panel) {
    const target = this.#target;
    if (!target?.columns?.length) {
      panel.appendChild(emptyHint('No columns'));
      return;
    }

    panel.appendChild(popoverHead('Visible columns'));

    const list = document.createElement('div');
    list.setAttribute('data-popover-list', '');

    for (const col of target.columns) {
      // <field-ui inline label="…"><check-ui></check-ui></field-ui> —
      // canonical label-binds-to-control pair, mints id + [for] so clicking
      // the label toggles the check. Same primitive used for filter rows.
      const row = document.createElement('field-ui');
      row.setAttribute('data-columns-row', '');
      row.setAttribute('inline', '');
      row.setAttribute('label', col.label || col.key);

      const check = document.createElement('check-ui');
      if (!col.hidden) check.setAttribute('checked', '');
      check.dataset.key = col.key;
      check.addEventListener('change', () => {
        const next = target.columns.map((c) => (
          c.key === col.key ? { ...c, hidden: !check.hasAttribute('checked') } : { ...c }
        ));
        target.columns = next;
        this.dispatchEvent(new CustomEvent('columns-change', {
          bubbles: true,
          detail: { hiddenColumns: next.filter((c) => c.hidden).map((c) => c.key) },
        }));
      });
      row.appendChild(check);

      list.appendChild(row);
    }

    panel.appendChild(list);
  }
}
