import ComponentBaseStyles from "../styles/ComponentBaseStyles.wrn"

//
// DataTable -- columns, sorting, filtering, pagination and selection over an
// array of records.
//
//   <DataTable
//     columns='[{"key":"name","label":"Name","sortable":true},
//               {"key":"plan","label":"Plan"},
//               {"key":"seats","label":"Seats","align":"end","sortable":true}]'
//     rows='[{"id":1,"name":"Acme","plan":"Scale","seats":42}]'
//     pageSize={10}
//     selectable={true}
//   />
//
// Columns are plain objects so they survive being passed as an attribute:
//   key       which field of the row to show (required)
//   label     header text; defaults to the key
//   align     start | center | end
//   width     any CSS width for the column
//   sortable  show a sort control on the header
//
// Rows are matched by `rowKey` (default "id"). Sorting, filtering and paging
// are all derived through shared functions, so the first paint is server
// rendered and the same code keeps it live in the browser.
//
// Turn pagination off with paginated={false} to render every row.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
import Dropdown from "./Dropdown.wrn"

component DataTable {
  outputs {
    sort(payload: { key: string; direction: string })
    search(payload: { query: string })
    pageChange(payload: { page: number; pageSize: number })
    select(payload: { selected: Array<string | number>; all: boolean })
    change(payload: { page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string })
    rowClick(payload: { row: object; sourceEvent: Event })
    action(payload: { id: string; selected: Array<string | number>; rows: object[]; sourceEvent: Event })
    request(payload: { instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string })
  }

  props {
    color: string = "primary"
    size: string = "default"
    columns: unknown[] = []
    rows: unknown[] = []
    rowKey: string = "id"
    // Async source.
    //
    // Set remote={true} and the table stops deriving anything locally: it
    // emits a `request` output carrying { instanceId, page, pageSize,
    // sortKey, sortDirection, query } whenever the view changes, and waits to
    // be handed rows back. Sorting, filtering and paging all become the
    // server's job, because the table only ever sees the page it was given.
    //
    // Answer it by dispatching the result back with the SAME instanceId:
    //
    //   function loadRows(payload) {
    //     fetch("/api/rows?page=" + payload.page)
    //       .then(function (r) { return r.json() })
    //       .then(function (data) {
    //         window.dispatchEvent(new CustomEvent("wrnexus:datatable:rows", {
    //           detail: { instanceId: payload.instanceId,
    //                     rows: data.rows, total: data.total }
    //         }))
    //       })
    //   }
    //
    // It is an event rather than a `load` function prop because props travel
    // as HTML attributes: a function passed that way arrives as its own source
    // text, not something callable.
    remote: boolean = false
    loadingLabel: string = "Loading"
    errorLabel: string = "Could not load this data"
    retryLabel: string = "Try again"
    caption: string = ""
    description: string = ""
    // Filtering
    searchable: boolean = true
    searchPlaceholder: string = "Search"
    // Pagination. paginated={false} renders every row and hides the footer.
    paginated: boolean = true
    pageSize: number = 10
    // compact = previous/next arrows only. numbered = clickable page numbers.
    paginationStyle: string = "compact"
    // Typed concretely, not unknown[]: the showcase generator fills an
    // unknown[] prop with rich demo objects, and every option in the picker
    // then rendered as [object Object].
    pageSizes: number[] = [10, 25, 50]
    // Selection
    selectable: boolean = false
    // Toolbar actions. Each entry is an object:
    //   id       returned with the action output so a host can tell them apart
    //   label    button text
    //   icon     optional icon class
    //   tone     "danger" styles it destructively
    //   when     "always" (default) or "selection" -- selection-only actions
    //            stay hidden until at least one row is ticked
    //   onClick  optional callback, receives the selected rows
    actions: unknown[] = []
    // Presentation
    striped: boolean = true
    bordered: boolean = true
    // rows = horizontal rules only. grid = full row and column gridlines.
    gridlines: string = "rows"
    density: string = "default"
    emptyLabel: string = "No records to show"
    // Shown when a filter matches nothing, as opposed to there being no data
    // at all -- the two need different wording and different remedies.
    noResultsLabel: string = "No records match your search"
    clearSearchLabel: string = "Clear search"
    // Keeps the first column in view while the rest scrolls sideways.
    stickyFirstColumn: boolean = false
    // rows = one record per row. comparison = transposed, fields down the
    // left and one column per record.
    layout: string = "rows"
    class: string = ""
  }

  state query = ""
  state sortKey = ""
  state sortDirection = "asc"
  state paginationPage = 1
  state perPage = pageSize
  state selectedKeys = []
  state remoteRows = []
  state remoteTotal = 0
  state loading = false
  state loadError = ""
  // Distinguishes this instance from any other table on the page, since the
  // async result comes back through a window event.
  state instanceId = 0
  state lastAnchor = ""

  functions {
    // --- derivation -------------------------------------------------------
    // Shared so the server renders the first page and the browser keeps it in
    // step from the same source. Each step is a pure function of state, which
    // is what lets the view call them straight from data-for.
    shared function columnList() {
      return Array.isArray(columns) ? columns : []
    }

    shared function isRemote() {
      return !!remote
    }

    shared function allRows() {
      if (isRemote()) {
        return Array.isArray(remoteRows) ? remoteRows : []
      }
      return Array.isArray(rows) ? rows : []
    }

    // Falls back to the row's position when it carries no key. Returning a
    // constant made every keyless row share one identity, so ticking one
    // ticked them all -- a silent, and very confusing, data bug.
    shared function keyOf(row, index) {
      if (row && row[rowKey] !== undefined && row[rowKey] !== null) {
        return row[rowKey]
      }
      return "__row:" + (index === undefined ? allRows().indexOf(row) : index)
    }

    shared function cellText(row, column) {
      var value = row ? row[column.key] : ""
      return value === undefined || value === null ? "" : String(value)
    }

    // A column may render markup instead of text, either by naming a field
    // that already holds markup (html: "statusBadge") or by supplying a
    // render(row) function. Returns "" when the column is plain text, which
    // is what keeps the escaping default in place for everything else.
    shared function cellMarkup(row, column) {
      if (!column || !row) {
        return ""
      }
      if (typeof column.render === "function") {
        try {
          var rendered = column.render(row)
          return rendered === undefined || rendered === null ? "" : String(rendered)
        } catch (error) {
          console.error("[wrnexus] data table column render failed", error)
          return ""
        }
      }
      if (column.html) {
        var field = column.html === true ? column.key : column.html
        var value = row[field]
        return value === undefined || value === null ? "" : String(value)
      }
      return ""
    }

    shared function matchesQuery(row) {
      if (!query) {
        return true
      }
      var needle = String(query).toLowerCase()
      return columnList().some(function (column) {
        return cellText(row, column).toLowerCase().indexOf(needle) !== -1
      })
    }

    shared function filteredRows() {
      // The server did the filtering; re-filtering here would hide rows it
      // deliberately returned.
      if (isRemote()) {
        return allRows()
      }
      return allRows().filter(function (row) { return matchesQuery(row) })
    }

    shared function sortedRows() {
      var list = filteredRows().slice()
      if (isRemote() || !sortKey) {
        return list
      }
      var factor = sortDirection === "desc" ? -1 : 1
      var kind = sortColumnType()
      return list.sort(function (left, right) {
        var a = left ? left[sortKey] : ""
        var b = right ? right[sortKey] : ""

        // Dates compared as text put "9 Feb" after "10 Jan"; parse them.
        if (kind === "date") {
          var at = Date.parse(a)
          var bt = Date.parse(b)
          if (!isNaN(at) && !isNaN(bt)) {
            return (at - bt) * factor
          }
        }

        if (kind === "number" || (typeof a === "number" && typeof b === "number")) {
          return ((Number(a) || 0) - (Number(b) || 0)) * factor
        }

        var as = a === undefined || a === null ? "" : String(a)
        var bs = b === undefined || b === null ? "" : String(b)
        return as.localeCompare(bs) * factor
      })
    }

    // A column may declare type: "date" | "number" | "text".
    shared function sortColumnType() {
      var found = ""
      columnList().forEach(function (column) {
        if (column.key === sortKey && column.type) {
          found = column.type
        }
      })
      return found
    }

    shared function totalCount() {
      // Only the source knows how many records exist beyond this page.
      if (isRemote()) {
        return Number(remoteTotal) || 0
      }
      return sortedRows().length
    }

    shared function pageCount() {
      if (!paginated) {
        return 1
      }
      var rowsPerPage = Number(perPage) > 0 ? Number(perPage) : 10
      return Math.max(1, Math.ceil(totalCount() / rowsPerPage))
    }

    // Clamped rather than stored: deleting or filtering rows can strand the
    // page number past the end, and a table showing nothing with rows
    // available is worse than one that quietly lands on the last page.
    shared function currentPage() {
      return Math.min(Math.max(1, Number(paginationPage) || 1), pageCount())
    }

    shared function visibleRows() {
      var list = sortedRows()
      if (isRemote()) {
        return list
      }
      if (!paginated) {
        return list
      }
      var rowsPerPage = Number(perPage) > 0 ? Number(perPage) : 10
      var start = (currentPage() - 1) * rowsPerPage
      return list.slice(start, start + rowsPerPage)
    }

    // Cells are built per row so the view can nest a loop inside its rows.
    shared function cellsFor(row) {
      return columnList().map(function (column) {
        return {
          key: column.key,
          label: columnLabel(column),
          value: cellText(row, column),
          html: cellMarkup(row, column),
          align: columnAlign(column),
          width: column.width || ""
        }
      })
    }

    /*
     * One view object per visible row, carrying everything the row renders.
     *
     * Same rule as headerColumns: a loop only re-runs when something its LIST
     * expression reads changes. The row loop read visibleRows(), which knows
     * nothing about selection, so ticking a checkbox left every row exactly as
     * it was built. Reading selectedKeys here ties the two together, and the
     * cells come along so the nested loop has nothing left to compute.
     */
    shared function rowViews() {
      var keys = selectedKeys
      return visibleRows().map(function (row, index) {
        var identity = keyOf(row, index)
        return {
          key: identity,
          raw: row,
          selected: keys.indexOf(identity) !== -1 ? "true" : "false",
          cells: cellsFor(row)
        }
      })
    }

    // Comparison layout: the table is transposed, so a rendered row is a
    // FIELD and each of its cells is one record. Built here rather than in the
    // view because a loop can only iterate one list.
    shared function comparisonRows() {
      var records = visibleRows()
      return columnList().map(function (column, columnIndex) {
        return {
          key: column.key || String(columnIndex),
          label: columnLabel(column),
          align: columnAlign(column),
          cells: records.map(function (record, recordIndex) {
            return {
              key: keyOf(record, recordIndex),
              value: cellText(record, column),
              html: cellMarkup(record, column),
              align: columnAlign(column)
            }
          })
        }
      })
    }

    // Column headings for the transposed table: the first record field makes
    // a readable caption for each record column.
    shared function comparisonHeadings() {
      var first = columnList()[0]
      return visibleRows().map(function (record, index) {
        return {
          key: keyOf(record, index),
          label: first ? cellText(record, first) : String(index + 1)
        }
      })
    }

    shared function rangeStart() {
      return totalCount() === 0 ? 0 : (currentPage() - 1) * (Number(perPage) || 10) + 1
    }

    shared function rangeEnd() {
      if (!paginated) {
        return totalCount()
      }
      return Math.min(currentPage() * (Number(perPage) || 10), totalCount())
    }

    // No data at all, versus a filter that happens to match nothing.
    shared function isFiltered() {
      return !!query
    }

    shared function emptyMessage() {
      return isFiltered() ? noResultsLabel : emptyLabel
    }

    shared function rangeLabel() {
      if (totalCount() === 0) {
        return emptyLabel
      }
      return "Showing " + rangeStart() + " to " + rangeEnd() + " of " + totalCount()
    }

    shared function columnAlign(column) {
      return column && column.align ? column.align : "start"
    }

    shared function columnLabel(column) {
      return column && column.label ? column.label : (column ? column.key : "")
    }

    /*
     * Headers carry their own sort state rather than each cell asking for it.
     * Attributes inside a data-for row are resolved when the row is built, and
     * this loop only depends on the column list -- so changing the sort left
     * aria-sort and the active highlight frozen at whatever they were on first
     * render. Reading sortKey/sortDirection here makes the header loop itself
     * depend on them, so the row is rebuilt with fresh values.
     */
    shared function headerColumns() {
      var activeKey = sortKey
      var direction = sortDirection
      return columnList().map(function (column) {
        var isActive = activeKey === column.key
        return {
          key: column.key,
          label: columnLabel(column),
          align: columnAlign(column),
          width: column.width || "",
          sortable: column.sortable ? true : false,
          active: isActive ? "true" : "false",
          ariaSort: isActive ? (direction === "desc" ? "descending" : "ascending") : "none"
        }
      })
    }

    // Windowed around the current page: a thousand-page table should not try
    // to render a thousand buttons.
    shared function actionList() {
      return Array.isArray(actions) ? actions : []
    }

    // The rows behind the current selection, in the order they appear in the
    // data, so a handler receives records rather than bare keys.
    shared function selectedRows() {
      var keys = selectedKeys
      return allRows().filter(function (row, index) {
        return keys.indexOf(keyOf(row, index)) !== -1
      })
    }

    shared function selectedCount() {
      return selectedRows().length
    }

    // Reads selectedKeys so the toolbar re-renders as the selection changes;
    // a loop only re-runs when its list expression depends on what changed.
    shared function visibleActions() {
      var count = selectedKeys.length
      return actionList()
        .filter(function (action) {
          return action.when === "selection" ? count > 0 : true
        })
        .map(function (action) {
          return {
            id: action.id || action.label,
            label: action.label || "",
            icon: action.icon || "",
            tone: action.tone || "",
            raw: action
          }
        })
    }

    shared function pageSizeItems() {
      var current = Number(perPage) || 10
      return (Array.isArray(pageSizes) ? pageSizes : [10, 25, 50]).map(function (option) {
        return {
          label: String(option),
          value: Number(option),
          selected: Number(option) === current
        }
      })
    }

    shared function pageNumbers() {
      var total = pageCount()
      var active = currentPage()
      var first = Math.max(1, active - 2)
      var last = Math.min(total, active + 2)
      var list = []
      var index = first
      while (index <= last) {
        list.push({ number: index, active: index === active ? "true" : "false" })
        index = index + 1
      }
      return list
    }

    shared function sortState(key) {
      if (sortKey !== key) {
        return "none"
      }
      return sortDirection === "desc" ? "descending" : "ascending"
    }

    shared function isSelectedKey(identity) {
      return selectedKeys.indexOf(identity) !== -1
    }

    shared function isSelected(row, index) {
      return isSelectedKey(keyOf(row, index))
    }

    shared function allVisibleSelected() {
      var visible = visibleRows()
      if (!visible.length) {
        return false
      }
      return visible.every(function (row, index) { return isSelected(row, index) })
    }

    // A snapshot of the view, sent with `change` after anything that alters
    // what the table is showing, so a host can mirror the state (deep links,
    // a server query) without wiring up five separate outputs.
    shared function viewState() {
      return {
        page: currentPage(),
        pageSize: Number(perPage) || 10,
        total: totalCount(),
        query: query,
        sortKey: sortKey,
        sortDirection: sortDirection
      }
    }

    // --- async source -----------------------------------------------------
    //
    // The result of load() cannot be written to state from its own .then():
    // a client function copies state in at entry and flushes it back when the
    // body returns, so anything a promise callback assigns lands in a dead
    // local. The callback therefore only DISPATCHES, carrying the instance id
    // so two tables on one page cannot answer each other, and the declarative
    // @window handlers below apply the result with live state.
    // Begins a load: flags the pending state and asks for rows. The answer
    // arrives later on a window event, because a promise callback cannot write
    // state -- see applyRows.
    client function reload() {
      if (!remote) {
        return
      }
      loading = true
      loadError = ""
      output.request({
        instanceId: instanceId,
        page: currentPage(),
        pageSize: Number(perPage) || 10,
        sortKey: sortKey,
        sortDirection: sortDirection,
        query: query
      })
    }

    client function applyRows(sourceEvent) {
      var detail = sourceEvent.detail || {}
      if (detail.instanceId !== instanceId) {
        return
      }
      remoteRows = detail.rows
      remoteTotal = detail.total
      loading = false
      loadError = ""
    }

    client function applyError(sourceEvent) {
      var detail = sourceEvent.detail || {}
      if (detail.instanceId !== instanceId) {
        return
      }
      loading = false
      loadError = detail.message || errorLabel
    }

    // --- interaction ------------------------------------------------------
    // Every write below is synchronous: a client function only flushes its
    // state when its body returns, so anything deferred would be lost.
    client function sortBy(key) {
      if (sortKey === key) {
        sortDirection = sortDirection === "asc" ? "desc" : "asc"
      } else {
        sortKey = key
        sortDirection = "asc"
      }
      paginationPage = 1
      output.sort({ key: sortKey, direction: sortDirection })
      reload()
      output.change(viewState())
    }

    client function updateQuery(sourceEvent) {
      query = sourceEvent.target.value
      paginationPage = 1
      output.search({ query: query })
      reload()
      output.change(viewState())
    }

    client function goToPage(next) {
      var target = Math.min(Math.max(1, next), pageCount())
      paginationPage = target
      output.pageChange({ page: target, pageSize: Number(perPage) || 10 })
      reload()
      output.change(viewState())
    }

    // Component outputs deliver a payload rather than a DOM event, so this
    // takes the Dropdown select detail directly.
    client function pickPageSize(detail) {
      perPage = Number(detail && detail.value) || 10
      paginationPage = 1
      output.pageChange({ page: 1, pageSize: perPage })
      reload()
      output.change(viewState())
    }

    // Shift-click extends from the previous click, the way every file list and
    // mail client behaves; without it selecting twenty rows means twenty
    // clicks.
    client function clearSearch() {
      query = ""
      paginationPage = 1
      output.search({ query: "" })
      reload()
    }

    client function toggleRowAt(identity, sourceEvent) {
      if (sourceEvent && sourceEvent.shiftKey && lastAnchor) {
        var order = rowViews().map(function (view) { return view.key })
        var from = order.indexOf(lastAnchor)
        var to = order.indexOf(identity)
        if (from !== -1 && to !== -1) {
          var start = Math.min(from, to)
          var end = Math.max(from, to)
          var next = selectedKeys.slice()
          var step = start
          while (step <= end) {
            if (next.indexOf(order[step]) === -1) {
              next.push(order[step])
            }
            step = step + 1
          }
          selectedKeys = next
          output.select({ selected: next, all: allVisibleSelected() })
          return
        }
      }
      lastAnchor = identity
      toggleKey(identity)
    }

    client function toggleKey(key) {
      var next = []
      var found = false
      selectedKeys.forEach(function (existing) {
        if (existing === key) {
          found = true
        } else {
          next.push(existing)
        }
      })
      if (!found) {
        next.push(key)
      }
      selectedKeys = next
      output.select({ selected: next, all: allVisibleSelected() })
    }

    client function toggleAll() {
      var visible = visibleRows()
      var everySelected = visible.every(function (row, index) { return isSelected(row, index) })
      var next = []
      if (!everySelected) {
        selectedKeys.forEach(function (existing) { next.push(existing) })
        visible.forEach(function (row, index) {
          var key = keyOf(row, index)
          if (next.indexOf(key) === -1) {
            next.push(key)
          }
        })
      } else {
        selectedKeys.forEach(function (existing) {
          var stillVisible = visible.some(function (row, index) { return keyOf(row, index) === existing })
          if (!stillVisible) {
            next.push(existing)
          }
        })
      }
      selectedKeys = next
      output.select({ selected: next, all: !everySelected })
    }

    // Runs a toolbar action with the selected records.
    //
    // NOTHING may call a peer function after the callback: it is application
    // code and may change state (clearing the selection, adding a row), and a
    // peer call would flush this function's pre-callback snapshot back over
    // whatever it did. This function assigns no state, so it flushes nothing.
    client function runAction(entry, sourceEvent) {
      var rows = selectedRows()
      output.action({
        id: entry.id,
        selected: selectedKeys,
        rows: rows,
        sourceEvent: sourceEvent
      })
      var handler = entry.raw && entry.raw.onClick
      if (typeof handler === "function") {
        try {
          handler(rows, selectedKeys)
        } catch (error) {
          console.error("[wrnexus] data table action handler failed", error)
        }
      }
    }

    client function emitRowClick(row, sourceEvent) {
      output.rowClick({ row: row, sourceEvent: sourceEvent })
    }
  }

  lifecycle {
    mount {
      // A per-instance id so the async result, which travels on a window
      // event, is only picked up by the table that asked for it.
      instanceId = Math.floor(Math.random() * 1000000) + 1
      reload()
    }
  }

  view {
    <div
      {...attrs}
      data-ui-component="DataTable"
      data-color='{color}'
      data-size='{size}'
      data-density='{density}'
      data-striped='{striped ? "true" : "false"}'
      data-bordered='{bordered ? "true" : "false"}'
      data-gridlines='{gridlines}'
      data-layout='{layout}'
      data-loading='{loading ? "true" : "false"}'
      data-sticky-first='{stickyFirstColumn ? "true" : "false"}'
      class='wire-table {class}'
      @window:wrnexus:datatable:rows='applyRows(event)'
      @window:wrnexus:datatable:error='applyError(event)'
    >
      {#if caption || description || searchable}
        <div class="wire-table__toolbar">
          <div class="wire-table__heading">
            {#if caption}
              <h3 class="wire-table__caption">{caption}</h3>
            {/if}
            {#if description}
              <p class="wire-table__description">{description}</p>
            {/if}
          </div>

          <div class="wire-table__actions" data-show="visibleActions().length">
            <span class="wire-table__selected-count" data-show="selectedCount()">
              {selectedCount()} selected
            </span>

            <button
              type="button"
              class="wire-table__action"
              data-for="entry in visibleActions()"
              data-key="entry.id"
              data-tone='{entry.tone}'
              @click='runAction(entry, event)'
            >
              <span class='{entry.icon}' data-show="entry.icon" aria-hidden="true"></span>
              <span>{entry.label}</span>
            </button>
          </div>

          {#if searchable}
            <div class="wire-table__search">
              <svg
                viewBox="0 0 24 24"
                width="15"
                height="15"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                aria-hidden="true"
              >
                <circle cx="11" cy="11" r="7" />
                <path d="m20 20-3.2-3.2" />
              </svg>
              <input
                type="search"
                value='{query}'
                placeholder='{searchPlaceholder}'
                aria-label='{searchPlaceholder}'
                @input='updateQuery(event)'
              />
            </div>
          {/if}
        </div>
      {/if}

      <div class="wire-table__scroll">
        <table class="wire-table__table" data-show="layout !== 'comparison'">
          <thead>
            <tr>
              {#if selectable}
                <th class="wire-table__select-cell" scope="col">
                  <button
                    type="button"
                    class="wire-table__check"
                    role="checkbox"
                    aria-label="Select all rows on this page"
                    aria-checked='{allVisibleSelected() ? "true" : "false"}'
                    @click='toggleAll()'
                  >
                    <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                      <path d="m5 12 5 5 9-9" />
                    </svg>
                  </button>
                </th>
              {/if}

              <th
                data-for="column in headerColumns()"
                data-key="column.key"
                scope="col"
                data-align='{column.align}'
                style='width: {column.width}'
                aria-sort='{column.ariaSort}'
              >
                <button
                  type="button"
                  class="wire-table__sort"
                  data-show="column.sortable"
                  data-active='{column.active}'
                  @click='sortBy(column.key)'
                >
                  <span>{column.label}</span>
                  <svg
                    class="wire-table__sort-icon"
                    viewBox="0 0 24 24"
                    width="12"
                    height="12"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="2.4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    aria-hidden="true"
                  >
                    <path d="m7 15 5 5 5-5" />
                    <path d="m7 9 5-5 5 5" />
                  </svg>
                </button>
                <span data-show="!column.sortable">{column.label}</span>
              </th>
            </tr>
          </thead>

          <tbody>
            <tr
              data-for="row in rowViews()"
              data-key="row.key"
              data-selected='{row.selected}'
              @click='emitRowClick(row.raw, event)'
            >
              {#if selectable}
                <td class="wire-table__select-cell">
                  <button
                    type="button"
                    class="wire-table__check"
                    role="checkbox"
                    aria-label="Select row"
                    aria-checked='{row.selected}'
                    @click='toggleRowAt(row.key, event)'
                  >
                    <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                      <path d="m5 12 5 5 9-9" />
                    </svg>
                  </button>
                </td>
              {/if}

              <td
                data-for="cell in row.cells"
                data-key="cell.key"
                data-align='{cell.align}'
                data-label='{cell.label}'
              >
                <span data-show="cell.html" data-html="cell.html"></span>
                <span data-show="!cell.html">{cell.value}</span>
              </td>
            </tr>
          </tbody>
        </table>

        <!-- Comparison layout: fields down the left, one column per record. -->
        <table class="wire-table__table wire-table__table--comparison" data-show="layout === 'comparison'">
          <thead>
            <tr>
              <th scope="col">{caption}</th>
              <th
                data-for="heading in comparisonHeadings()"
                data-key="heading.key"
                scope="col"
              >
                {heading.label}
              </th>
            </tr>
          </thead>
          <tbody>
            <tr data-for="field in comparisonRows()" data-key="field.key">
              <th scope="row" class="wire-table__field-label">{field.label}</th>
              <td
                data-for="cell in field.cells"
                data-key="cell.key"
                data-align='{cell.align}'
              >
                <span data-show="cell.html" data-html="cell.html"></span>
                <span data-show="!cell.html">{cell.value}</span>
              </td>
            </tr>
          </tbody>
        </table>

        <div class="wire-table__status" data-show="loading">
          <span class="wire-table__spinner" aria-hidden="true"></span>
          <span>{loadingLabel}</span>
        </div>

        <div class="wire-table__status wire-table__status--error" data-show="loadError">
          <span>{loadError}</span>
          <button type="button" class="wire-table__action" @click='reload()'>
            {retryLabel}
          </button>
        </div>

        <div
          class="wire-table__status"
          data-show="!loading && !loadError && totalCount() === 0"
        >
          <span>{emptyMessage()}</span>
          <button
            type="button"
            class="wire-table__action"
            data-show="isFiltered()"
            @click='clearSearch()'
          >
            {clearSearchLabel}
          </button>
        </div>
      </div>

      <div class="wire-table__footer" data-show="paginated && totalCount() > 0">
        <p class="wire-table__range">{rangeLabel()}</p>

        <div class="wire-table__pager">
          <span class="wire-table__page-size">
            <span>Rows</span>
            <!-- The shared Dropdown rather than a native select: a select
                 renders its list with the operating system styling, which
                 ignores the theme entirely. The trigger label lives in the
                 slot so it stays reactive -- a prop would be fixed at the
                 value the dropdown was first rendered with. -->
            <Dropdown
              class="wire-table__page-dropdown"
              width="sm"
              size="sm"
              placement="top-end"
              label=""
              items='{pageSizeItems()}'
              @select='pickPageSize(payload)'
            >
              <span data-slot="trigger">{perPage}</span>
            </Dropdown>
          </span>

          <button
            type="button"
            class="wire-table__page-button"
            aria-label="Previous page"
            disabled='{currentPage() <= 1}'
            @click='goToPage(currentPage() - 1)'
          >
            <svg
              viewBox="0 0 24 24"
              width="14"
              height="14"
              fill="none"
              stroke="currentColor"
              stroke-width="2.4"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="m15 18-6-6 6-6" />
            </svg>
          </button>

          <span
            class="wire-table__page-indicator"
            data-show="paginationStyle !== 'numbered'"
          >
            {currentPage()} / {pageCount()}
          </span>

          <span
            class="wire-table__page-numbers"
            data-show="paginationStyle === 'numbered'"
          >
            <button
              type="button"
              class="wire-table__page-number"
              data-for="entry in pageNumbers()"
              data-key="entry.number"
              data-active='{entry.active}'
              aria-label='{"Page " + entry.number}'
              @click='goToPage(entry.number)'
            >
              {entry.number}
            </button>
          </span>

          <button
            type="button"
            class="wire-table__page-button"
            aria-label="Next page"
            disabled='{currentPage() >= pageCount()}'
            @click='goToPage(currentPage() + 1)'
          >
            <svg
              viewBox="0 0 24 24"
              width="14"
              height="14"
              fill="none"
              stroke="currentColor"
              stroke-width="2.4"
              stroke-linecap="round"
              stroke-linejoin="round"
              aria-hidden="true"
            >
              <path d="m9 18 6-6-6-6" />
            </svg>
          </button>
        </div>
      </div>

      <slot></slot>
    </div>
    <ComponentBaseStyles hidden />
  }

  style {
    .wire-next--data-map {
      display: grid;
      gap: 0.75rem;
      padding: 1rem;
      border: 1px solid var(--wire-color-border);
      border-radius: var(--wire-radius-md);
      background: var(--wire-color-surface);
      box-shadow: var(--wire-shadow-1);
    }

    .wire-table {
      --table-accent: var(--wire-color-primary);
      display: flex;
      flex-direction: column;
      gap: 0.85rem;
      width: 100%;
      min-width: 0;
      max-width: 100%;
      color: var(--wire-color-text);
    }

    .wire-table[data-color="secondary"] {
      --table-accent: var(--wire-color-secondary);
    }

    .wire-table[data-color="success"] {
      --table-accent: var(--wire-color-success);
    }

    .wire-table[data-color="danger"] {
      --table-accent: var(--wire-color-danger);
    }

    .wire-table[data-color="info"] {
      --table-accent: var(--wire-color-info);
    }

    .wire-table__toolbar {
      display: flex;
      align-items: flex-end;
      justify-content: space-between;
      flex-wrap: wrap;
      gap: 0.75rem;
    }

    .wire-table__heading {
      display: grid;
      gap: 0.2rem;
      min-width: 0;
    }

    .wire-table__caption {
      margin: 0;
      color: var(--wire-color-text);
      font-size: 0.98rem;
      font-weight: 650;
    }

    .wire-table__description {
      margin: 0;
      color: var(--wire-color-text-muted);
      font-size: 0.82rem;
    }

    .wire-table__actions {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: 0.4rem;
    }

    .wire-table__selected-count {
      color: var(--wire-color-text-muted);
      font-size: 0.78rem;
      font-variant-numeric: tabular-nums;
    }

    .wire-table__action {
      appearance: none;
      display: inline-flex;
      align-items: center;
      gap: 0.35rem;
      min-height: 2.1rem;
      padding: 0 0.7rem;
      color: var(--wire-color-text);
      background: var(--wire-color-surface-soft);
      border: 1px solid var(--wire-color-border);
      border-radius: var(--wire-radius-sm);
      font: inherit;
      font-size: 0.79rem;
      cursor: pointer;
      transition: border-color 140ms ease, color 140ms ease, background 140ms ease;
    }

    .wire-table__action:hover {
      color: var(--table-accent);
      border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wire-color-border));
    }

    .wire-table__action[data-tone="danger"] {
      color: var(--wire-color-danger);
      border-color: color-mix(in srgb, var(--wire-color-danger) 35%, var(--wire-color-border));
    }

    .wire-table__action[data-tone="danger"]:hover {
      background: color-mix(in srgb, var(--wire-color-danger) 12%, transparent);
    }

    /* Full gridlines: vertical rules between columns as well as rows. */
    .wire-table[data-gridlines="grid"] .wire-table__table th,
    .wire-table[data-gridlines="grid"] .wire-table__table td {
      border-right: 1px solid color-mix(in srgb, var(--wire-color-border) 60%, transparent);
    }

    .wire-table[data-gridlines="grid"] .wire-table__table th:last-child,
    .wire-table[data-gridlines="grid"] .wire-table__table td:last-child {
      border-right: 0;
    }

    .wire-table__search {
      display: inline-flex;
      align-items: center;
      gap: 0.45rem;
      min-height: 2.35rem;
      padding: 0 0.7rem;
      color: var(--wire-color-text-muted);
      background: var(--wire-color-surface-soft);
      border: 1px solid var(--wire-color-border);
      border-radius: 0.7rem;
    }

    .wire-table__search input {
      width: 12rem;
      max-width: 100%;
      padding: 0;
      color: var(--wire-color-text);
      background: transparent;
      border: 0;
      font: inherit;
      font-size: 0.83rem;
      outline: none;
    }

    .wire-table__search:focus-within {
      border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wire-color-border));
    }

    /*
     * The table scrolls sideways rather than squashing its columns.
     *
     * max-width matters as much as width here: a wide table -- a comparison
     * layout turns every record into a column -- makes the flex item grow to
     * fit its content unless it is told not to, and the whole table then
     * spilled out over the page beside it instead of scrolling inside its own
     * box. min-width: 0 alone is not enough once the parent is a grid.
     */
    .wire-table__scroll {
      position: relative;
      width: 100%;
      min-width: 0;
      max-width: 100%;
      overflow-x: auto;
      border: 1px solid var(--wire-color-border);
      border-radius: 0.9rem;
    }

    .wire-table[data-bordered="false"] .wire-table__scroll {
      border: 0;
      border-radius: 0;
    }

    .wire-table__table {
      width: 100%;
      border-collapse: collapse;
      font-size: 0.85rem;
    }

    .wire-table__table th,
    .wire-table__table td {
      padding: 0.7rem 0.85rem;
      text-align: left;
      vertical-align: middle;
      white-space: nowrap;
    }

    .wire-table[data-density="compact"] .wire-table__table th,
    .wire-table[data-density="compact"] .wire-table__table td {
      padding: 0.42rem 0.6rem;
    }

    .wire-table[data-density="comfortable"] .wire-table__table th,
    .wire-table[data-density="comfortable"] .wire-table__table td {
      padding: 0.95rem 1rem;
    }

    .wire-table__table th[data-align="center"],
    .wire-table__table td[data-align="center"] {
      text-align: center;
    }

    .wire-table__table th[data-align="end"],
    .wire-table__table td[data-align="end"] {
      text-align: right;
    }

    .wire-table__table thead th {
      position: sticky;
      top: 0;
      z-index: 1;
      color: var(--wire-color-text-muted);
      background: var(--wire-color-surface-soft);
      border-bottom: 1px solid var(--wire-color-border);
      font-size: 0.76rem;
      font-weight: 650;
      letter-spacing: 0.02em;
      text-transform: uppercase;
    }

    .wire-table__table tbody tr {
      border-bottom: 1px solid color-mix(in srgb, var(--wire-color-border) 60%, transparent);
    }

    .wire-table__table tbody tr:last-child {
      border-bottom: 0;
    }

    /*
     * Row background: stripe, then hover, then selection, each beating the one
     * before it.
     *
     * These were written at whatever specificity fell out naturally, and the
     * stripe selector was the strongest -- so a selected row on an even line
     * kept its stripe and looked different from a selected row on an odd line,
     * and hovering flipped rows between three shades. Selection is a state
     * about the row, not decoration, so it wins outright; every rule below
     * sits at the same specificity and is ordered deliberately.
     */
    .wire-table[data-striped="true"] .wire-table__table tbody tr:nth-child(even) {
      background: color-mix(in srgb, var(--wire-color-surface-soft) 55%, transparent);
    }

    .wire-table[data-striped] .wire-table__table tbody tr:hover:not([data-selected="true"]) {
      background: color-mix(in srgb, var(--table-accent) 7%, transparent);
    }

    /* Every selected row identical, striped or not. */
    .wire-table[data-striped] .wire-table__table tbody tr[data-selected="true"] {
      background: color-mix(in srgb, var(--table-accent) 15%, transparent);
    }

    .wire-table[data-striped] .wire-table__table tbody tr[data-selected="true"]:hover {
      background: color-mix(in srgb, var(--table-accent) 22%, transparent);
    }

    .wire-table__sort {
      appearance: none;
      display: inline-flex;
      align-items: center;
      gap: 0.32rem;
      padding: 0;
      color: inherit;
      background: transparent;
      border: 0;
      font: inherit;
      font-size: inherit;
      font-weight: inherit;
      letter-spacing: inherit;
      text-transform: inherit;
      cursor: pointer;
    }

    .wire-table__sort:hover {
      color: var(--table-accent);
    }

    .wire-table__sort-icon {
      flex: 0 0 auto;
      opacity: 0.45;
    }

    .wire-table__sort[data-active="true"] {
      color: var(--table-accent);
    }

    .wire-table__sort[data-active="true"] .wire-table__sort-icon {
      opacity: 1;
    }

    .wire-table__select-cell {
      width: 2.6rem;
      text-align: center;
    }

    .wire-table__check {
      appearance: none;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      padding: 0;
      width: 1.05rem;
      height: 1.05rem;
      color: transparent;
      background: var(--wire-color-surface-raised);
      border: 1px solid var(--wire-color-border);
      border-radius: 0.32rem;
      cursor: pointer;
      transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
    }

    .wire-table__check[aria-checked="true"] {
      color: var(--wire-color-primary-contrast);
      background: var(--table-accent);
      border-color: var(--table-accent);
    }

    .wire-table__check:focus-visible {
      outline: none;
      box-shadow: 0 0 0 3px color-mix(in srgb, var(--table-accent) 35%, transparent);
    }

    .wire-table__table--comparison {
      width: auto;
      min-width: 100%;
    }

    .wire-table__table--comparison th,
    .wire-table__table--comparison td {
      min-width: 7rem;
    }

    .wire-table__table--comparison th[scope="row"] {
      color: var(--wire-color-text-muted);
      background: var(--wire-color-surface-soft);
      font-size: 0.76rem;
      font-weight: 650;
      text-transform: uppercase;
      white-space: nowrap;
    }

    .wire-table[data-layout="comparison"] .wire-table__table th:first-child,
    .wire-table[data-layout="comparison"] .wire-table__table td:first-child {
      position: sticky;
      left: 0;
      z-index: 2;
    }

    .wire-table__status {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 0.6rem;
      padding: 1.75rem 1rem;
      color: var(--wire-color-text-muted);
      font-size: 0.85rem;
      text-align: center;
    }

    .wire-table__status--error {
      color: var(--wire-color-danger);
    }

    .wire-table__spinner {
      width: 0.95rem;
      height: 0.95rem;
      border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
      border-top-color: currentColor;
      border-radius: 50%;
      animation: wire-table-spin 700ms linear infinite;
    }

    @keyframes wire-table-spin {
      to {
        transform: rotate(360deg);
      }
    }

    /* Dim the rows while a refresh is in flight rather than tearing them out:
       a table that empties on every keystroke is far harder to read. */
    .wire-table[data-loading="true"] .wire-table__table tbody {
      opacity: 0.45;
    }

    /* Keeps the first column readable while the rest scrolls sideways. */
    .wire-table[data-sticky-first="true"] .wire-table__table th:first-child,
    .wire-table[data-sticky-first="true"] .wire-table__table td:first-child {
      position: sticky;
      left: 0;
      z-index: 2;
      background: var(--wire-color-surface);
    }

    .wire-table[data-sticky-first="true"] .wire-table__table thead th:first-child {
      z-index: 3;
    }

    .wire-table__empty {
      margin: 0;
      padding: 1.75rem 1rem;
      color: var(--wire-color-text-muted);
      font-size: 0.85rem;
      text-align: center;
    }

    .wire-table__footer {
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-wrap: wrap;
      gap: 0.75rem;
    }

    .wire-table__range {
      margin: 0;
      color: var(--wire-color-text-muted);
      font-size: 0.8rem;
    }

    .wire-table__pager {
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }

    .wire-table__page-size {
      display: inline-flex;
      align-items: center;
      gap: 0.4rem;
      color: var(--wire-color-text-muted);
      font-size: 0.78rem;
    }

    .wire-table__page-dropdown {
      display: inline-flex;
    }

    /*
     * padding is reset explicitly: an app-level button { padding: ... } rule
     * outranks the browser default and would crush the icon inside these
     * fixed-size controls.
     */
    .wire-table__page-button {
      appearance: none;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      flex: 0 0 auto;
      padding: 0;
      width: 2rem;
      height: 2rem;
      color: var(--wire-color-text);
      background: var(--wire-color-surface-soft);
      border: 1px solid var(--wire-color-border);
      border-radius: 0.55rem;
      cursor: pointer;
    }

    .wire-table__page-button:hover:not(:disabled) {
      color: var(--table-accent);
      border-color: color-mix(in srgb, var(--table-accent) 40%, var(--wire-color-border));
    }

    .wire-table__page-button:disabled {
      opacity: 0.45;
      cursor: not-allowed;
    }

    .wire-table__page-button svg {
      flex: 0 0 auto;
    }

    .wire-table__page-numbers {
      display: inline-flex;
      align-items: center;
      gap: 0.25rem;
    }

    .wire-table__page-number {
      appearance: none;
      min-width: 2rem;
      height: 2rem;
      padding: 0 0.4rem;
      color: var(--wire-color-text-muted);
      background: transparent;
      border: 1px solid transparent;
      border-radius: 0.55rem;
      font: inherit;
      font-size: 0.78rem;
      font-variant-numeric: tabular-nums;
      cursor: pointer;
    }

    .wire-table__page-number:hover {
      color: var(--wire-color-text);
      background: var(--wire-color-surface-soft);
    }

    .wire-table__page-number[data-active="true"] {
      color: var(--wire-color-primary-contrast);
      background: var(--table-accent);
      border-color: var(--table-accent);
    }

    .wire-table__page-indicator {
      color: var(--wire-color-text-muted);
      font-size: 0.78rem;
      font-variant-numeric: tabular-nums;
    }

    /*
     * Below the breakpoint each row becomes its own card and every cell grows
     * a label from its column, so the data stays readable without a horizontal
     * scrollbar on a phone.
     */
    @media (max-width: 639px) {
      .wire-table__scroll {
        overflow-x: visible;
        border: 0;
      }

      .wire-table__table,
      .wire-table__table tbody,
      .wire-table__table tr,
      .wire-table__table td {
        display: block;
        width: 100%;
      }

      .wire-table__table thead {
        display: none;
      }

      /*
       * Each row becomes a card. These are the exact tokens Card paints its
       * surface with (.wire-next__card-panel in ui.css) rather than a lookalike
       * of my own, so a stacked row and a real Card cannot drift apart.
       *
       * The Card component itself cannot be mounted per row: component tags
       * are resolved once, server side, while these rows are cloned in the
       * browser from a single template -- every row would share one mount.
       */
      .wire-table__table tbody tr {
        margin-bottom: 0.7rem;
        padding: 0.5rem 0.25rem;
        background: var(--wire-color-surface);
        border: 1px solid var(--wire-color-border);
        border-radius: var(--wire-radius-md);
        box-shadow: var(--wire-shadow-1);
      }

      .wire-table[data-striped="true"] .wire-table__table tbody tr:nth-child(even) {
        background: var(--wire-color-surface);
      }

      .wire-table[data-striped] .wire-table__table tbody tr[data-selected="true"] {
        background: color-mix(in srgb, var(--table-accent) 15%, var(--wire-color-surface));
        border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wire-color-border));
      }

      .wire-table__table td {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 1rem;
        white-space: normal;
        text-align: right;
      }

      .wire-table__table td::before {
        content: attr(data-label);
        color: var(--wire-color-text-muted);
        font-size: 0.72rem;
        font-weight: 650;
        text-transform: uppercase;
      }

      .wire-table__select-cell {
        width: auto;
      }

      .wire-table__footer {
        justify-content: center;
      }

      .wire-table__actions {
        width: 100%;
      }

      /* The page-size menu is anchored to a control near the bottom of the
         screen; it must be able to render outside the footer box. */
      .wire-table__footer,
      .wire-table__pager {
        overflow: visible;
      }
    }
  }
}
