{
  "id": "L09",
  "title": "Transfer list",
  "topic": "lists",
  "use": "Assign several entries of a small inventory to one record — the cost centers an employee is booked to. On the left stands what is available, on the right what is assigned, and the user moves entries back and forth while both sides stay in view. The assignment takes effect when it is saved. For a single assignment two lists are an apparatus for one hand movement; a select is enough there.",
  "decisions": [
    "Two selectable ed-lists side by side, available on the left and assigned on the right: the state is there at a glance, and several entries move in one go, without a dialog for each.",
    "Each side moves its own selection with the button in its head — Assign on the left, Remove on the right: taking back is the same gesture as assigning, the other way round, and each button is unavailable while nothing on its side is ticked.",
    "Both sides can be filtered, not only the left: the assigned side grows too. A filter keeps the ticks of the entries it hides, and the line under the list says how many of them are out of view and move along — the list's own rule for a selection beyond the view.",
    "Each side names how many entries it holds in its title, and the count beside it how many of those in view are ticked; both follow every move. A side with nothing in view says why, instead of an empty frame.",
    "Both sides stay sorted by cost center number, so an entry that moves lands where its number puts it and not at the end, where the order would look random.",
    "After a move the focus goes to its side's filter, the next control that is still there: the button just pressed becomes unavailable.",
    "Nothing applies until Save assignment: the line under the toolbar says so and counts the changes not saved yet, and an entry moved back where it came from is no change. A newly assigned entry says the same in its own row until it is saved.",
    "Save assignment stores what the lists showed when it was pressed, and shows that it is saving: a move made meanwhile stays an unsaved change, not one that reads as saved without having been sent.",
    "Save assignment is unavailable while nothing has changed, and its title says what makes it available, so the pointer learns it too where the line under the toolbar is out of sight."
  ],
  "try": [
    {
      "do": "Tick 4210 Fleet North and 4300 Sales on the left and press Assign",
      "then": "both move to the right, in the order of their numbers; the titles count 3 available and 5 assigned, and the line under the toolbar counts 2 unsaved changes"
    },
    {
      "do": "Tick 4300 Sales on the right, then type 42 into the filter on the right",
      "then": "only the cost centers whose number or name holds 42 stay in view, the title still counts all 5 assigned, and the line under the list says 1 ticked cost center is out of view"
    },
    {
      "do": "Tick 4210 Fleet North on the right and press Remove",
      "then": "both ticked cost centers go back to the left, the one out of view included, and the line counts no unsaved change any more, because a move back is no change"
    },
    {
      "do": "Assign 4100 Administration and press Save assignment, then at once assign 4310 Marketing",
      "then": "the toast confirms the assignment for Timo Brandt, 4100 Administration no longer says it is new, and 4310 Marketing stays 1 unsaved change"
    }
  ],
  "revision": 1,
  "changes": [],
  "reviewed": false,
  "source": "<div class=\"ed-stack ed-gap-4\">\n  <!-- Demo only, not part of the pattern. The screen's title, and whom the assignment is for, come from the screen around the pattern; SC01 places them. -->\n\n  <div class=\"ed-stack ed-gap-2\">\n    <ed-toolbar label=\"Cost center assignment\">\n      <ed-button id=\"l09-save\" disabled title=\"Nothing to save — move a cost center first\">\n        <ed-icon slot=\"start\" name=\"check\"></ed-icon>\n        Save assignment\n      </ed-button>\n    </ed-toolbar>\n    <p id=\"l09-state\" class=\"ed-caption-m\" role=\"status\"></p>\n  </div>\n\n  <div class=\"ed-grid\" style=\"--ed-grid-min-column-size: 20rem\">\n    <div class=\"ed-stack ed-gap-2\">\n      <ed-input id=\"l09-available-filter\" type=\"search\" with-clear placeholder=\"Filter available …\" aria-label=\"Filter available cost centers\"></ed-input>\n      <ed-list id=\"l09-available\" selectable>\n        <ed-button id=\"l09-assign\" slot=\"actions\" appearance=\"outlined\" disabled>\n          <ed-icon slot=\"start\" name=\"next\"></ed-icon>\n          Assign\n        </ed-button>\n      </ed-list>\n      <p id=\"l09-available-note\" class=\"ed-caption-m\" role=\"status\"></p>\n    </div>\n\n    <div class=\"ed-stack ed-gap-2\">\n      <ed-input id=\"l09-assigned-filter\" type=\"search\" with-clear placeholder=\"Filter assigned …\" aria-label=\"Filter assigned cost centers\"></ed-input>\n      <ed-list id=\"l09-assigned\" selectable>\n        <ed-button id=\"l09-remove\" slot=\"actions\" appearance=\"plain\" disabled>\n          <ed-icon slot=\"start\" name=\"previous\"></ed-icon>\n          Remove\n        </ed-button>\n      </ed-list>\n      <p id=\"l09-assigned-note\" class=\"ed-caption-m\" role=\"status\"></p>\n    </div>\n  </div>\n\n  <ed-toast-stack id=\"l09-toasts\"></ed-toast-stack>\n</div>\n\n<!-- Demo only, not part of the pattern. The product supplies costCenters, every cost center the employee can be booked to, as { number, name, head, since }, since the date an assigned one has applied from as YYYY-MM-DD and null for one not assigned; employee, whom the assignment is for, as { name }; today, the date a saved assignment applies from, as YYYY-MM-DD; and saveAssignment(numbers), which stores the numbers of the assigned cost centers and resolves once they are saved. What the screen does when saving fails, and the question before leaving with unsaved changes, are not part of this pattern. -->\n\n<script type=\"module\">\n  await Promise.all(['ed-list', 'ed-input', 'ed-toast-stack'].map(tag => customElements.whenDefined(tag)));\n\n  const save = document.getElementById('l09-save');\n  const state = document.getElementById('l09-state');\n  const toasts = document.getElementById('l09-toasts');\n  const sides = {\n    available: {\n      title: 'Available',\n      list: document.getElementById('l09-available'),\n      filter: document.getElementById('l09-available-filter'),\n      move: document.getElementById('l09-assign'),\n      note: document.getElementById('l09-available-note'),\n      empty: 'Every cost center is assigned.',\n      ticked: new Set()\n    },\n    assigned: {\n      title: 'Assigned',\n      list: document.getElementById('l09-assigned'),\n      filter: document.getElementById('l09-assigned-filter'),\n      move: document.getElementById('l09-remove'),\n      note: document.getElementById('l09-assigned-note'),\n      empty: 'No cost center is assigned.',\n      ticked: new Set()\n    }\n  };\n  const other = { available: 'assigned', assigned: 'available' };\n\n  const date = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' });\n  const formatDate = text => date.format(new Date(`${text}T00:00:00Z`));\n\n  // The saved state — which cost centers apply, and since when — and what the screen shows now; the\n  // difference is what Save assignment stores.\n  const since = new Map(costCenters.filter(center => center.since).map(center => [center.number, center.since]));\n  let assigned = new Set(since.keys());\n  let saving = false;\n\n  const sideOf = center => (assigned.has(center.number) ? 'assigned' : 'available');\n  const byNumber = (a, b) => a.number.localeCompare(b.number);\n  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;\n\n  function entryFor(center, side) {\n    const item = document.createElement('ed-list-item');\n    item.value = center.number;\n    const number = document.createElement('span');\n    number.className = 'ed-text-tabular';\n    number.textContent = center.number;\n    const description = document.createElement('span');\n    description.slot = 'description';\n    if (side === 'available') description.textContent = `Head: ${center.head}`;\n    else if (since.has(center.number)) description.textContent = `Since ${formatDate(since.get(center.number))}`;\n    else description.textContent = 'New — applies once saved';\n    item.append(number, ` ${center.name}`, description);\n    return item;\n  }\n\n  function contentOf(name) {\n    const query = sides[name].filter.value.trim().toLowerCase();\n    const centers = costCenters.filter(center => sideOf(center) === name).sort(byNumber);\n    const shown = centers.filter(center => `${center.number} ${center.name}`.toLowerCase().includes(query));\n    return { centers, shown };\n  }\n\n  // A side shows the entries its filter lets through. The ticks are kept here, not in the entries, so\n  // a filter that hides a ticked entry keeps its tick, and the note says how many are out of view.\n  function render(name) {\n    const side = sides[name];\n    for (const item of side.list.querySelectorAll('ed-list-item')) item.remove();\n    side.list.append(\n      ...contentOf(name).shown.map(center => {\n        const item = entryFor(center, name);\n        item.selected = side.ticked.has(center.number);\n        return item;\n      })\n    );\n    refresh(name);\n  }\n\n  // The title, the move button and the note, without rebuilding the entries — a tick keeps its focus.\n  function refresh(name) {\n    const side = sides[name];\n    const { centers, shown } = contentOf(name);\n    side.list.label = `${side.title} · ${centers.length}`;\n    side.move.disabled = side.ticked.size === 0;\n\n    const hidden = [...side.ticked].filter(number => !shown.some(center => center.number === number)).length;\n    const notes = [];\n    if (centers.length === 0) notes.push(side.empty);\n    else if (shown.length === 0) notes.push(`No cost center matches “${side.filter.value.trim()}”.`);\n    if (hidden > 0) notes.push(`${plural(hidden, 'ticked cost center is', 'ticked cost centers are')} out of view and ${hidden === 1 ? 'moves' : 'move'} along.`);\n    side.note.textContent = notes.join(' ');\n  }\n\n  function showState() {\n    const changes =\n      [...assigned].filter(number => !since.has(number)).length +\n      [...since.keys()].filter(number => !assigned.has(number)).length;\n    save.disabled = changes === 0;\n    if (save.disabled) save.title = 'Nothing to save — move a cost center first';\n    else save.removeAttribute('title');\n    state.textContent =\n      changes === 0\n        ? 'The assignment applies once it is saved. Nothing is unsaved.'\n        : `${plural(changes, 'unsaved change', 'unsaved changes')} — they apply once the assignment is saved.`;\n  }\n\n  function moveTicked(from) {\n    const side = sides[from];\n    for (const number of side.ticked) {\n      if (from === 'available') assigned.add(number);\n      else assigned.delete(number);\n    }\n    side.ticked.clear();\n    render(from);\n    render(other[from]);\n    showState();\n    side.filter.focus();\n  }\n\n  for (const [name, side] of Object.entries(sides)) {\n    // The list reports the entries in view; ticks on entries out of view stay as they are.\n    side.list.addEventListener('ed-list-selection-change', () => {\n      for (const item of side.list.querySelectorAll('ed-list-item')) {\n        if (item.selected) side.ticked.add(item.value);\n        else side.ticked.delete(item.value);\n      }\n      refresh(name);\n    });\n    side.move.addEventListener('click', () => moveTicked(name));\n    side.filter.addEventListener('input', () => render(name));\n  }\n\n  save.addEventListener('click', async () => {\n    if (saving) return;\n    saving = true;\n    save.loading = true;\n    // What is sent is what is saved; a move while the request runs stays an unsaved change.\n    const sent = new Set(assigned);\n    try {\n      await saveAssignment([...sent].sort());\n      for (const number of [...since.keys()]) if (!sent.has(number)) since.delete(number);\n      for (const number of sent) if (!since.has(number)) since.set(number, today);\n      toasts.create({ variant: 'success', title: `Cost centers saved for ${employee.name}` });\n    } finally {\n      saving = false;\n      save.loading = false;\n      render('available');\n      render('assigned');\n      showState();\n    }\n  });\n\n  render('available');\n  render('assigned');\n  showState();\n</script>",
  "page": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/lists/l09-transfer-list/",
  "topicPage": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/lists/"
}
