{
  "id": "TD01",
  "title": "Editable table",
  "topic": "tables",
  "use": "Change a value where it stands. Whoever adds twelve cost centers to a list of documents needs no dialog for it — the cell is the field. This takes the most frequent case off bulk editing, which stays for setting many records to one value (BE02) or going through each record in turn (BE03).",
  "decisions": [
    "An editable cell shows its control and the other columns are text. A click on a control edits it and does not select the row; a click beside it selects the row.",
    "A change applies to the cell, never to the selection: three selected rows and one changed cell is one changed cell. Whoever means many uses the column action, which names its scope.",
    "The column action asks for its scope in the dialog with up to three answers — this view, the selected rows, all rows — each only where it exists; with neither a view nor a selection there is no question. The view is preselected and spelled out in words, and All rows says it also reaches the rows the filter hides.",
    "The filter exists to build the view the column action acts on, not to search. Search and filter narrow the same set and count the same for the scope.",
    "The filter sits behind a button in a drawer: four filterable columns as four selects would push the search and the actions out of the toolbar. The button carries the number of filters set, the filters set stand as badges above the table, and a reset sits in the drawer's footer and beside the badges.",
    "Only columns with a list of values are filterable, and the values come from the rows: a value without a match is a promise the table does not keep. Free text goes into the search; date and amount stay out, because a range filter is a pattern of its own.",
    "Status has no field in the cell but an entry in the column action: in the row it is information, for many rows it is the most frequent change. Error is not offered because the check sets it; posted rows show their values as text, and setting Posted says first what no longer works.",
    "Every change can be undone once, a column action over many rows included: a table that writes while typing needs a way back."
  ],
  "try": [
    {
      "do": "Click into a booking text, type, and leave the field",
      "then": "the row is not selected by it, and the line under the table says what changed"
    },
    {
      "do": "Move through the table with the arrow keys and press Enter",
      "then": "the focus goes into the cell's field; Escape hands it back to the grid"
    },
    {
      "do": "Select three rows and change one cell",
      "then": "exactly this cell changes, not the three"
    },
    {
      "do": "Press Set column to one value and choose Document type",
      "then": "the dialog “Set the document type for several rows” asks for the value and the scope"
    },
    {
      "do": "Press Filter",
      "then": "a drawer comes in from the right with everything that can be filtered — Document type, Cost center, Processor, Status"
    },
    {
      "do": "Choose two of them and press Done",
      "then": "the button reads Filter (2), both stand as badges above the table, and each can be removed on its own"
    },
    {
      "do": "Select all rows, then filter",
      "then": "the counter still says all 24 are selected"
    },
    {
      "do": "Then open the column action",
      "then": "the scope stands at Only this view, the view is named below it, and All rows of the table adds that it also hits the rows the filter hides"
    },
    {
      "do": "Set a value for Only this view, then reset the filters",
      "then": "the rows outside the view keep their old value"
    },
    {
      "do": "Choose Status in the column action",
      "then": "the status has no field in the cell but can be set for the column — and Posted says beforehand what no longer works afterwards"
    },
    {
      "do": "Choose Close without changes",
      "then": "a question asks before the value is discarded, and Discard value closes the dialog"
    },
    {
      "do": "Open the column action with rows selected",
      "then": "the dialog asks whether all rows or only the selected ones are meant"
    },
    {
      "do": "Look at a posted row",
      "then": "its values stand as text, not as fields"
    },
    {
      "do": "Press Undo change",
      "then": "the last step is undone as a whole, even one that changed 19 rows at once"
    }
  ],
  "revision": 1,
  "changes": [],
  "reviewed": true,
  "source": "<!-- Demo only, not part of the pattern. The table takes the height the screen leaves it; here it is set to fit the frame. -->\n\n<div class=\"ed-stack ed-gap-4\">\n  <ed-data-grid id=\"td01-grid\" label=\"Documents, August 2026\" with-search selectable selection-mode=\"multiple\" data-ed-preload=\"ed-format-date\">\n    <ed-button slot=\"toolbar-actions\" id=\"td01-column-action\">\n      <ed-icon slot=\"start\" name=\"next_entry\"></ed-icon>\n      Set column to one value\n    </ed-button>\n    <ed-button slot=\"toolbar-actions\" id=\"td01-export\" appearance=\"plain\" disabled title=\"Select at least one row.\">\n      <ed-icon slot=\"start\" name=\"export\"></ed-icon>\n      Export\n    </ed-button>\n    <ed-divider slot=\"toolbar-actions\" orientation=\"vertical\"></ed-divider>\n    <ed-button slot=\"toolbar-actions\" id=\"td01-delete\" appearance=\"plain\" disabled title=\"Select at least one row.\">\n      <ed-icon slot=\"start\" name=\"delete\"></ed-icon>\n      Delete documents\n    </ed-button>\n    <ed-button slot=\"toolbar-view\" id=\"td01-filter\" appearance=\"plain\">\n      <ed-icon slot=\"start\" name=\"filter\"></ed-icon>\n      <span id=\"td01-filter-label\">Filter</span>\n    </ed-button>\n  </ed-data-grid>\n\n  <div class=\"ed-cluster ed-gap-3 ed-align-items-center\">\n    <p id=\"td01-status\" role=\"status\" tabindex=\"-1\"></p>\n    <ed-button id=\"td01-undo\" appearance=\"outlined\" density=\"compact\" disabled title=\"There is no change yet.\">\n      <ed-icon slot=\"start\" name=\"undo\"></ed-icon>\n      Undo change\n    </ed-button>\n  </div>\n\n  <ed-drawer id=\"td01-drawer\" light-dismiss label=\"Filter\" placement=\"end\" with-footer style=\"--ed-drawer-size: 22rem\">\n    <div class=\"ed-stack ed-gap-4\">\n      <ed-select data-column=\"type\" label=\"Document type\" placeholder=\"Several\"></ed-select>\n      <ed-select data-column=\"costCenter\" label=\"Cost center\" placeholder=\"Several\"></ed-select>\n      <ed-select data-column=\"processor\" label=\"Processor\" placeholder=\"Several\"></ed-select>\n      <ed-select data-column=\"status\" label=\"Status\" placeholder=\"Several\"></ed-select>\n    </div>\n    <ed-button slot=\"footer\" data-drawer=\"close\">Done</ed-button>\n    <ed-button slot=\"footer\" id=\"td01-reset\" appearance=\"plain\">Reset all filters</ed-button>\n  </ed-drawer>\n\n  <ed-dialog id=\"td01-dialog\" light-dismiss label=\"Set the booking text for several rows\" style=\"--ed-dialog-width: 32rem\">\n    <div class=\"ed-stack ed-gap-4\">\n      <ed-select id=\"td01-column\" label=\"Column\" autofocus>\n        <ed-option value=\"text\">Booking text</ed-option>\n        <ed-option value=\"type\">Document type</ed-option>\n        <ed-option value=\"costCenter\">Cost center</ed-option>\n        <ed-option value=\"processor\">Processor</ed-option>\n        <ed-option value=\"status\">Status</ed-option>\n      </ed-select>\n      <ed-select id=\"td01-value-list\" hidden></ed-select>\n      <ed-input id=\"td01-value-text\"></ed-input>\n      <ed-radio-group id=\"td01-scope\" label=\"Scope\">\n        <ed-radio value=\"view\"></ed-radio>\n        <ed-radio value=\"selection\"></ed-radio>\n        <ed-radio value=\"all\"></ed-radio>\n      </ed-radio-group>\n      <p id=\"td01-effect\" role=\"status\"></p>\n    </div>\n    <ed-button slot=\"footer\" id=\"td01-apply\" disabled>Change rows</ed-button>\n    <ed-button slot=\"footer\" appearance=\"outlined\" data-dialog=\"close\">Close without changes</ed-button>\n  </ed-dialog>\n\n  <ed-alert-dialog id=\"td01-discard\" light-dismiss type=\"warning\" label=\"Discard the new value?\" confirm-text=\"Discard value\" cancel-text=\"Keep editing\">\n    The value you entered is not set in any row.\n  </ed-alert-dialog>\n\n  <ed-toast-stack id=\"td01-toasts\"></ed-toast-stack>\n</div>\n\n<!-- Demo only, not part of the pattern. The product supplies documents, the rows as { id, document, date, text, type, costCenter, processor, amount, status } with status open, reviewed, posted or error, and the value lists DOCUMENT_TYPES and PROCESSORS that the cells and the column action offer. -->\n\n<script type=\"module\">\n  const grid = document.getElementById('td01-grid');\n  const status = document.getElementById('td01-status');\n  const undo = document.getElementById('td01-undo');\n  const drawer = document.getElementById('td01-drawer');\n  const filterLabel = document.getElementById('td01-filter-label');\n  const filterFields = [...drawer.querySelectorAll('ed-select')];\n  const dialog = document.getElementById('td01-dialog');\n  const column = document.getElementById('td01-column');\n  const valueList = document.getElementById('td01-value-list');\n  const valueText = document.getElementById('td01-value-text');\n  const scope = document.getElementById('td01-scope');\n  const effect = document.getElementById('td01-effect');\n  const apply = document.getElementById('td01-apply');\n  const toasts = document.getElementById('td01-toasts');\n\n  const date = value =>\n    Object.assign(document.createElement('ed-format-date'), { date: value, month: 'short', day: 'numeric', year: 'numeric' });\n  const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' });\n\n  const STATUS = {\n    open: { label: 'Open', variant: 'neutral' },\n    reviewed: { label: 'Reviewed', variant: 'info' },\n    posted: { label: 'Posted', variant: 'success' },\n    error: { label: 'Error', variant: 'danger' }\n  };\n\n  // Error is missing from the status options: it comes from the check and is not chosen.\n  const COLUMNS = {\n    text: { label: 'Booking text' },\n    type: { label: 'Document type', options: DOCUMENT_TYPES },\n    costCenter: { label: 'Cost center', digits: 4 },\n    processor: { label: 'Processor', options: PROCESSORS },\n    status: { label: 'Status', options: ['open', 'reviewed', 'posted'], show: value => STATUS[value].label }\n  };\n\n  const show = (field, value) => COLUMNS[field].show?.(value) ?? value;\n  const locked = row => row.status === 'posted';\n  const count = (number, one, many) => `${number} ${number === 1 ? one : many}`;\n\n  function option(field, value, label = show(field, value)) {\n    const item = document.createElement('ed-option');\n    item.value = value;\n    item.textContent = label;\n    return item;\n  }\n\n  const controls = new Map();\n\n  function editable(field) {\n    return (value, row) => {\n      if (locked(row)) return show(field, value);\n      const key = `${row.id}:${field}`;\n      if (!controls.has(key)) controls.set(key, control(row, field));\n      return controls.get(key);\n    };\n  }\n\n  function control(row, field) {\n    const spec = COLUMNS[field];\n    const element = document.createElement(spec.options ? 'ed-select' : 'ed-input');\n    element.append(...(spec.options ?? []).map(value => option(field, value)));\n    if (spec.digits) element.setAttribute('maxlength', String(spec.digits));\n    element.setAttribute('density', 'compact');\n    element.setAttribute('aria-label', `${spec.label}, ${row.document}`);\n    element.style.inlineSize = '100%';\n    element.value = row[field];\n    element.addEventListener('change', () => {\n      if (element.value === row[field]) return;\n      record([{ row, field, from: row[field], to: element.value }], `${spec.label} of ${row.document} changed`);\n    });\n    return element;\n  }\n\n  function badge(value) {\n    const element = document.createElement('ed-badge');\n    element.variant = STATUS[value].variant;\n    element.textContent = STATUS[value].label;\n    return element;\n  }\n\n  grid.columns = [\n    { field: 'document', label: 'Document', rowheader: true, width: 140 },\n    { field: 'date', label: 'Date', width: 120, sortFn: 'datetime', formatter: date },\n    { field: 'text', label: 'Booking text', flex: 2, minWidth: 240, formatter: editable('text') },\n    { field: 'type', label: 'Document type', width: 200, filterable: true, filterType: 'set', formatter: editable('type') },\n    { field: 'costCenter', label: 'Cost center', width: 180, filterable: true, filterType: 'set', formatter: editable('costCenter') },\n    { field: 'processor', label: 'Processor', width: 200, filterable: true, filterType: 'set', formatter: editable('processor') },\n    { field: 'amount', label: 'Amount', align: 'end', width: 130, formatter: value => currency.format(value) },\n    {\n      field: 'status',\n      label: 'Status',\n      width: 130,\n      filterable: true,\n      filterType: 'set',\n      filterOptions: Object.entries(STATUS).map(([value, { label }]) => ({ value, label })),\n      formatter: badge\n    }\n  ];\n  grid.data = documents;\n\n  // One step, taken back once: a cell or a whole column action.\n  let last = null;\n\n  function write(changes) {\n    for (const { row, field, to } of changes) {\n      row[field] = to;\n      const element = controls.get(`${row.id}:${field}`);\n      if (element) element.value = to;\n    }\n    grid.data = documents;\n  }\n\n  function record(changes, done) {\n    write(changes);\n    last = changes;\n    status.textContent = done;\n    undo.disabled = false;\n    undo.setAttribute('title', `Undoes: ${done}`);\n  }\n\n  undo.addEventListener('click', () => {\n    const [{ row, field }] = last;\n    write(last.map(change => ({ ...change, to: change.from })));\n    last = null;\n    status.textContent = 'The last change is undone.';\n    undo.disabled = true;\n    undo.setAttribute('title', 'There is no change yet.');\n    // The button the focus was on is disabled now; the focus goes back to the field it changed, or\n    // to the line that says what happened where that field has no control or is not rendered.\n    requestAnimationFrame(() => {\n      const element = controls.get(`${row.id}:${field}`);\n      (element?.isConnected ? element : status).focus();\n    });\n  });\n\n  grid.addEventListener('ed-data-grid-row-select', () => {\n    for (const button of [document.getElementById('td01-export'), document.getElementById('td01-delete')]) {\n      button.disabled = grid.selectedRows.length === 0;\n      button.setAttribute('title', button.disabled ? 'Select at least one row.' : '');\n    }\n  });\n\n  // The filter: every column with a list of values. The values come from the rows as they are when\n  // the drawer opens — a value without a match is a promise the table does not keep.\n  const filterValues = key => [grid.filters.find(filter => filter.id === key)?.value ?? []].flat();\n\n  function fillFilters() {\n    for (const field of filterFields) {\n      const key = field.dataset.column;\n      const values = [...new Set([...documents.map(row => row[key]), ...filterValues(key)])].map(value => [value, show(key, value)]);\n      values.sort(([, a], [, b]) => a.localeCompare(b));\n      field.replaceChildren(option(key, '', 'All'), ...values.map(([value, label]) => option(key, value, label)));\n    }\n    showFilters();\n  }\n\n  for (const field of filterFields) {\n    field.addEventListener('change', () => {\n      const key = field.dataset.column;\n      const others = grid.filters.filter(filter => filter.id !== key);\n      grid.filters = field.value ? [...others, { id: key, value: [field.value] }] : others;\n      showFilters();\n    });\n  }\n\n  // The column's own panel can set several values; the select has no option for that and shows its\n  // placeholder instead.\n  function showFilters() {\n    for (const field of filterFields) {\n      const value = filterValues(field.dataset.column);\n      field.value = value.length > 1 ? null : (value[0] ?? '');\n    }\n    const set = grid.filters.length;\n    filterLabel.textContent = set ? `Filter (${set})` : drawer.open ? 'Close filter' : 'Filter';\n  }\n\n  grid.addEventListener('ed-data-grid-filter-change', showFilters);\n  drawer.addEventListener('ed-show', fillFilters);\n  drawer.addEventListener('ed-after-hide', showFilters);\n  document.getElementById('td01-filter').addEventListener('click', () => {\n    drawer.open = !drawer.open;\n  });\n  document.getElementById('td01-reset').addEventListener('click', () => {\n    grid.filters = [];\n    showFilters();\n  });\n\n  // The column action. The view is the search and the filters together.\n  const narrowed = () => grid.searchTerm.trim() !== '' || grid.filters.length > 0;\n\n  function view() {\n    const parts = grid.filters.map(({ id, value }) => `${COLUMNS[id].label}: ${[value].flat().map(item => show(id, item)).join(', ')}`);\n    if (grid.searchTerm.trim()) parts.push(`Search: “${grid.searchTerm.trim()}”`);\n    return parts.join(' · ');\n  }\n\n  function plan() {\n    const field = column.value;\n    const value = String((COLUMNS[field].options ? valueList : valueText).value ?? '').trim();\n    const base = scope.value === 'selection' && grid.selectedRows.length ? grid.selectedRows : scope.value === 'all' ? documents : grid.getProcessedRows();\n    const changes = base.filter(row => !locked(row) && row[field] !== value).map(row => ({ row, field, from: row[field], to: value }));\n    return { field, value, changes, skipped: base.filter(locked).length };\n  }\n\n  function describe() {\n    const { field, value, changes, skipped } = plan();\n    apply.disabled = !value || changes.length === 0;\n    if (!value) {\n      effect.textContent = 'No value set.';\n      if (apply.textContent !== 'Change rows') apply.textContent = 'Change rows';\n      return;\n    }\n    const sentences = [`${COLUMNS[field].label} is set to ${show(field, value)} — ${count(changes.length, 'row', 'rows')}.`];\n    if (skipped) sentences.push(skipped === 1 ? '1 row is posted and stays out.' : `${skipped} rows are posted and stay out.`);\n    if (field === 'status' && value === 'posted') {\n      sentences.push('Afterwards these rows take no change; undoing is then possible only through this one step.');\n    }\n    effect.textContent = sentences.join(' ');\n    // A label rewritten between mousedown and mouseup swallows the click on it, and the field's\n    // change fires exactly there, as the press takes the focus away from it.\n    const label = `Change ${count(changes.length, 'row', 'rows')}`;\n    if (apply.textContent !== label) apply.textContent = label;\n  }\n\n  function prepareValue() {\n    const field = column.value;\n    const spec = COLUMNS[field];\n    dialog.label = `Set the ${spec.label.toLowerCase()} for several rows`;\n    valueList.hidden = !spec.options;\n    valueText.hidden = Boolean(spec.options);\n    valueList.label = valueText.label = `New value for ${spec.label.toLowerCase()}`;\n    valueList.replaceChildren(...(spec.options ?? []).map(value => option(field, value)));\n    valueList.value = valueText.value = '';\n    valueText.hint = spec.digits ? `${spec.digits} digits` : '';\n    if (spec.digits) valueText.setAttribute('maxlength', String(spec.digits));\n    else valueText.removeAttribute('maxlength');\n  }\n\n  // The scope is asked only where there is something to decide: without a view and without a\n  // selection the action takes every row, and the question would have one answer.\n  function prepareScope() {\n    const selected = grid.selectedRows.length;\n    const radios = Object.fromEntries([...scope.querySelectorAll('ed-radio')].map(radio => [radio.value, radio]));\n    radios.view.hidden = !narrowed();\n    radios.selection.hidden = selected === 0;\n    scope.hidden = !narrowed() && selected === 0;\n    radios.view.textContent = `Only this view — ${count(grid.getProcessedRows().length, 'row', 'rows')}`;\n    radios.selection.textContent = `Only the ${count(selected, 'selected row', 'selected rows')}`;\n    radios.all.textContent = `All ${documents.length} rows of the table${narrowed() ? ' — including those the filter hides right now' : ''}`;\n    scope.value = narrowed() ? 'view' : selected ? 'selection' : 'all';\n    scope.hint = narrowed() ? `View: ${view()}` : '';\n  }\n\n  const discard = document.getElementById('td01-discard');\n  // Set once closing is meant to lose nothing, or the user has agreed to discard the value.\n  let allowClose = false;\n\n  document.getElementById('td01-column-action').addEventListener('click', () => {\n    allowClose = false;\n    column.value = 'text';\n    prepareValue();\n    prepareScope();\n    describe();\n    dialog.open = true;\n  });\n\n  dialog.addEventListener('change', event => {\n    if (event.target === column) prepareValue();\n    describe();\n  });\n  valueText.addEventListener('input', describe);\n\n  apply.addEventListener('click', () => {\n    const { field, value, changes } = plan();\n    allowClose = true;\n    dialog.open = false;\n    record(changes, `${COLUMNS[field].label} set to ${show(field, value)} in ${count(changes.length, 'row', 'rows')}`);\n    toasts.create({ variant: 'success', title: `${count(changes.length, 'row', 'rows')} changed` });\n  });\n\n  // Every way of closing — Close without changes, Escape, a click beside — asks once a value is\n  // entered.\n  dialog.addEventListener('ed-hide', event => {\n    if (event.target !== dialog || allowClose || !plan().value) return;\n    event.preventDefault();\n    discard.open = true;\n  });\n\n  discard.addEventListener('ed-alert-dialog-confirm', () => {\n    allowClose = true;\n  });\n\n  discard.addEventListener('ed-after-hide', () => {\n    if (allowClose) dialog.open = false;\n  });\n</script>",
  "page": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/tables/td01-editable-table/",
  "topicPage": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/tables/"
}
