{
  "id": "SU01",
  "title": "Global search",
  "topic": "search",
  "use": "The search in the header searches across every kind of data, in the example across staff, employees and establishments. Before anything is typed it shows what was opened last. After the first letters the results stand below, grouped by kind: per group the total count, three results and the way into the full list. It is meant for the case where the user knows the name but not the area.",
  "decisions": [
    "Grouped by kind, not sorted by quality: the user knows whether they look for a person or a place, and finds it in its group without reading what stands between.",
    "Three per group with the total count, and where there are more, a way to all of them with the count on it: three show whether the search understood what was meant, all of them would make the surface a results page.",
    "The order of the groups is fixed: sorted by count it would jump with every key, and a fixed order can be used blind after a while.",
    "The find is marked in the text, not the row: in a number such as 02998/23700 the difference to its neighbors is one digit, and a marked row does not say which.",
    "Every result is a link to its record, so a new tab, the context menu and a middle click work as they do on any link.",
    "The surface opens with a click into the field, with typing, with Arrow Down and with the shortcut, not when Tab passes the field; it is never empty: before the first letter it shows what was opened last and what the search looks in.",
    "“Everything” is the promise the pattern hangs on, so the surface says it: a global search that knows only some kinds of records returns nothing once, and nobody looks at it again.",
    "The surface is as tall as its content, up to 42rem or the screen: whoever has to scroll after the third result does not read the fourth.",
    "Arrow Down moves the focus from the field into the surface and on across every link in it, the groups and their ways to all included; Arrow Up above the first goes back to the field, and from the field to the last. A key typed on a link goes on into the field.",
    "Escape on a link goes back to the field and closes the surface; in the field it empties it first, as any field with a clear button does, and closes the surface once the field is empty.",
    "Ctrl+K (⌘K on a Mac) goes to the search from anywhere, and the field shows the key. Ctrl+F stays the browser's find in the page, which a page with SU02 needs.",
    "Where nothing matches, the empty state names the term, so a typing error can be seen in it, says what was searched in, and offers to clear the search."
  ],
  "try": [
    {
      "do": "Click into the search field",
      "then": "the surface shows what was opened last, and what the search looks in, with the kinds and their counts"
    },
    {
      "do": "Type “mai”, “02” or “wit”",
      "then": "the results stand grouped by kind, three per group, with the way to all of them where there are more, and the find marked in the text"
    },
    {
      "do": "Press Arrow Down and Arrow Up",
      "then": "the focus moves across all groups as through one list; above the first result it goes back to the field"
    },
    {
      "do": "Press Enter on a result",
      "then": "the surface closes, and the content area shows the record the link leads to"
    },
    {
      "do": "Press Ctrl+K (⌘K on a Mac)",
      "then": "the search takes the focus from anywhere on the page and opens"
    },
    {
      "do": "Type something nothing contains, such as “annw”",
      "then": "the empty state names the term and what was searched in, and offers to clear the search"
    },
    {
      "do": "Press Escape",
      "then": "on a result it goes back to the field and closes the surface; in the field it empties it, and once it is empty it closes the surface"
    }
  ],
  "revision": 2,
  "changes": [
    {
      "revision": 2,
      "change": "The search field drops navbar, which ed-input no longer has: a field in ed-header takes the header's colors by itself. Markup only, the behavior is unchanged."
    }
  ],
  "reviewed": false,
  "source": "<style>\n  .su01-search {\n    flex: 1 1 auto;\n    max-inline-size: 36rem;\n  }\n\n  .su01-surface::part(body) {\n    display: flex;\n    flex-direction: column;\n    gap: var(--ed-space-4);\n    max-block-size: min(42rem, calc(100dvh - 6rem));\n    overflow-y: auto;\n  }\n\n  .su01-group {\n    display: flex;\n    flex-direction: column;\n    gap: var(--ed-space-2);\n  }\n\n  /* A link laid out as a row of three lines. */\n  .su01-result {\n    display: flex;\n    align-items: start;\n    gap: var(--ed-space-2);\n    border-radius: var(--ed-border-radius-2x);\n    padding: var(--ed-space-2) var(--ed-space-3);\n    color: inherit;\n    text-decoration: none;\n  }\n\n  .su01-result:hover,\n  .su01-result:focus-visible {\n    background: var(--ed-color-surface-default);\n  }\n\n  .su01-result:focus-visible {\n    outline: var(--ed-focus-ring);\n    outline-offset: var(--ed-focus-ring-offset-inset);\n  }\n\n  .su01-result > ed-icon {\n    flex: 0 0 auto;\n    color: var(--ed-color-icon);\n  }\n\n  .su01-result > span {\n    display: flex;\n    flex-direction: column;\n    min-inline-size: 0;\n  }\n\n  .su01-more {\n    align-self: start;\n  }\n</style>\n\n<!-- Demo only, not part of the pattern. The app frame takes the screen; here it is set to fit the frame. -->\n\n<ed-app id=\"su01-app\" mobile-breakpoint=\"900px\">\n  <ed-header slot=\"header\">\n    <div class=\"su01-search\">\n      <ed-input id=\"su01-field\" type=\"search\" with-clear autocomplete=\"off\" placeholder=\"Name, number or place\" aria-label=\"Search everything\">\n        <ed-icon slot=\"start\" name=\"search\"></ed-icon>\n        <kbd id=\"su01-key\" slot=\"end\" aria-hidden=\"true\">Ctrl K</kbd>\n      </ed-input>\n      <ed-popup anchor=\"su01-field\" placement=\"bottom-start\" distance=\"4\" sync=\"width\">\n        <ed-card id=\"su01-surface\" class=\"su01-surface\" hidden></ed-card>\n      </ed-popup>\n      <p id=\"su01-status\" class=\"ed-visually-hidden\" role=\"status\"></p>\n    </div>\n    <ed-button navbar class=\"ed-ml-auto\"><ed-icon slot=\"start\" name=\"notification\"></ed-icon>Notifications</ed-button>\n    <a slot=\"logo\" href=\"#\"><img src=\"/a/eurodata-ui/assets/images/logo_white.png\" alt=\"Home\"></a>\n  </ed-header>\n\n  <main id=\"su01-content\" class=\"ed-stack ed-gap-4 ed-p-6\">\n    <hgroup class=\"ed-stack ed-gap-0\">\n      <h1>Payroll</h1>\n      <p class=\"ed-caption-m\">Bergmann &amp; Söhne Fahrzeugtechnik · August 2026</p>\n    </hgroup>\n    <!-- Demo only, not part of the pattern. The record a result links to opens in its own screen. -->\n  </main>\n</ed-app>\n\n<!-- Demo only, not part of the pattern. The product supplies groups: every kind of record the application holds, in a fixed order, as { title, noun, icon, href, records }, with href(term) the address of the full list for a term and each record as { number, name, line2, line3, href }; and recent: what the user opened last, as { group, record }. A product with more records than it can hold asks its server for each group's hits and total and keeps the answer in this shape. -->\n\n<!-- Demo only, not part of the pattern. Following a link opens its record or list in the product. -->\n\n<script type=\"module\">\n  const field = document.getElementById('su01-field');\n  const popup = document.querySelector('.su01-search ed-popup');\n  const surface = document.getElementById('su01-surface');\n  const status = document.getElementById('su01-status');\n  const key = document.getElementById('su01-key');\n  const search = document.querySelector('.su01-search');\n\n  const perGroup = 3;\n  const mac = /Mac|iPhone|iPad/.test(navigator.userAgentData?.platform ?? navigator.platform);\n  key.textContent = mac ? '⌘ K' : 'Ctrl K';\n\n  // Built from elements, not from an HTML string: a record's text is data and never becomes markup.\n  const element = (tag, properties = {}, ...children) => {\n    const node = Object.assign(document.createElement(tag), properties);\n    node.append(...children);\n    return node;\n  };\n  const count = n => n.toLocaleString('en-US');\n  const term = () => field.value ?? '';\n  let titles = 0;\n\n  // Searched regardless of case, marked in the original: the result shows the spelling of the data.\n  const marked = (text, find) => {\n    const at = find ? text.toLowerCase().indexOf(find) : -1;\n    if (at < 0) return [text];\n    return [text.slice(0, at), element('mark', { textContent: text.slice(at, at + find.length) }), text.slice(at + find.length)];\n  };\n  const matches = (record, find) =>\n    [record.number, record.name, record.line2, record.line3].some(text => text.toLowerCase().includes(find));\n\n  // A group's title names its list. It is no heading: the surface stands in the header, ahead of\n  // the page's own h1.\n  function group(title, rows, ...after) {\n    const id = `su01-title-${++titles}`;\n    const list = element('ul', { className: 'ed-list-plain ed-stack ed-gap-0 ed-m-0' }, ...rows);\n    list.setAttribute('aria-labelledby', id);\n    return element('section', { className: 'su01-group' }, element('p', { id, className: 'ed-heading-s', textContent: title }), list, ...after);\n  }\n\n  function row(record, kind, find) {\n    const link = element(\n      'a',\n      { href: record.href, className: 'su01-result' },\n      element('ed-icon', { name: kind.icon }),\n      element(\n        'span',\n        {},\n        element('span', { className: 'ed-font-weight-semibold' }, ...marked(record.number, find), ' ', ...marked(record.name, find)),\n        element('span', {}, ...marked(record.line2, find)),\n        element('span', {}, ...marked(record.line3, find))\n      )\n    );\n    return element('li', {}, link);\n  }\n\n  // Said once the typing rests, so a reader hears the count for the term and not one per key.\n  let announcement;\n  function announce(text) {\n    clearTimeout(announcement);\n    status.textContent = '';\n    if (text) announcement = setTimeout(() => (status.textContent = text), 400);\n  }\n\n  function show(...content) {\n    surface.replaceChildren(...content);\n    surface.hidden = false;\n    popup.active = true;\n  }\n\n  function close() {\n    surface.hidden = true;\n    popup.active = false;\n    surface.replaceChildren();\n    announce('');\n  }\n\n  // Before the first letter: the way back to what was open, and what the search looks in — the\n  // two reasons for clicking into the field.\n  function rest() {\n    const kinds = groups.map(kind => {\n      const badge = element('ed-badge', {}, element('ed-icon', { slot: 'start', name: kind.icon }), `${kind.title} · ${count(kind.records.length)}`);\n      badge.setAttribute('variant', 'neutral');\n      return badge;\n    });\n    show(\n      group('Recently opened', recent.map(entry => row(entry.record, entry.group, ''))),\n      element(\n        'section',\n        { className: 'su01-group' },\n        element('p', { className: 'ed-heading-s', textContent: 'Searched in everything' }),\n        element('p', { textContent: 'Every kind of record the application holds — not only the one you are in.' }),\n        element('p', { className: 'ed-cluster ed-gap-2' }, ...kinds),\n        element('p', { className: 'ed-caption-s', textContent: 'Found by number, name, address and establishment — in the middle of a word, too.' })\n      )\n    );\n    announce('');\n  }\n\n  function render() {\n    const find = term().trim().toLowerCase();\n    if (!find) return rest();\n\n    const found = groups\n      .map(kind => ({ ...kind, hits: kind.records.filter(record => matches(record, find)) }))\n      .filter(kind => kind.hits.length);\n\n    if (!found.length) {\n      const clear = element('ed-button', { id: 'su01-clear', appearance: 'plain' }, element('ed-icon', { slot: 'start', name: 'close' }), 'Clear search');\n      clear.slot = 'button';\n      const kinds = groups.map(kind => kind.noun);\n      const empty = element(\n        'ed-empty-state',\n        {},\n        element('span', { slot: 'label', textContent: `No match for “${term()}”` }),\n        element('span', { textContent: `Searched in ${kinds.slice(0, -1).join(', ')} and ${kinds.at(-1)}. Check the spelling, or try another term.` }),\n        clear\n      );\n      empty.setAttribute('icon', 'search');\n      empty.setAttribute('size', 'medium');\n      empty.setAttribute('alignment', 'horizontal');\n      show(empty);\n      announce('No results');\n      return;\n    }\n\n    show(\n      ...found.map(kind => {\n        // The way into the full list, where it holds more than the surface shows; the number stands\n        // on it, so its size is known before the click.\n        const more = kind.hits.length > perGroup\n          ? [element('ed-button', { className: 'su01-more', appearance: 'outlined', href: kind.href(term().trim()) }, `Show all ${count(kind.hits.length)} ${kind.noun} with “${term().trim()}”`)]\n          : [];\n        return group(`${kind.title} (${count(kind.hits.length)})`, kind.hits.slice(0, perGroup).map(record => row(record, kind, find)), ...more);\n      })\n    );\n    const total = found.reduce((sum, kind) => sum + kind.hits.length, 0);\n    announce(`${count(total)} ${total === 1 ? 'result' : 'results'} in ${found.length} ${found.length === 1 ? 'group' : 'groups'}`);\n  }\n\n  // Every link in the surface, in the order the arrows walk.\n  const stops = () => [...surface.querySelectorAll('.su01-result, .su01-more')];\n\n  // ed-input empties itself on Escape; its listener has to come first, so the press it takes is not\n  // also read here as closing the surface.\n  await customElements.whenDefined('ed-input');\n  await field.updateComplete;\n\n  field.addEventListener('input', render);\n  field.addEventListener('click', () => surface.hidden && render());\n  // The clear button and Escape lead back to the resting surface, not to a closed one: the user\n  // wanted the term gone, not the search.\n  field.addEventListener('ed-clear', () => !surface.hidden && rest());\n\n  field.addEventListener('keydown', async event => {\n    if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;\n    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n      event.preventDefault();\n      // A closed surface opens first; its links take the focus once the popup shows them.\n      if (surface.hidden) {\n        render();\n        await popup.updateComplete;\n      }\n      const all = stops();\n      if (all.length) (event.key === 'ArrowDown' ? all[0] : all.at(-1)).focus();\n    } else if (event.key === 'Escape' && !surface.hidden) {\n      event.preventDefault();\n      close();\n    }\n  });\n\n  surface.addEventListener('keydown', event => {\n    const all = stops();\n    const at = all.indexOf(event.target.closest('.su01-result, .su01-more'));\n    if (event.key === 'ArrowDown' && at >= 0) {\n      event.preventDefault();\n      all[Math.min(at + 1, all.length - 1)].focus();\n    } else if (event.key === 'ArrowUp' && at >= 0) {\n      event.preventDefault();\n      (at === 0 ? field : all[at - 1]).focus();\n    } else if (event.key === 'Escape') {\n      event.preventDefault();\n      close();\n      field.focus();\n    } else if (event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      // A letter typed on a link goes on into the field: the focus moves before the key's default\n      // action, which then types into the field.\n      field.focus();\n    }\n  });\n\n  surface.addEventListener('click', event => {\n    if (event.target.closest('#su01-clear')) {\n      field.value = '';\n      field.focus();\n      rest();\n      return;\n    }\n    // Following a link leaves the search: the surface closes and the field empties behind it. After\n    // the click, not in it: a link taken out of the document while it is clicked goes nowhere.\n    if (event.target.closest('a, .su01-more')) {\n      setTimeout(() => {\n        field.value = '';\n        close();\n      });\n    }\n  });\n\n  // Ctrl+K, ⌘K on a Mac, goes to the search from anywhere. Ctrl+F stays the browser's. The code and\n  // not the key: on a Cyrillic layout the key reads \"л\".\n  document.addEventListener('keydown', event => {\n    if (event.defaultPrevented || event.shiftKey || event.altKey) return;\n    if ((mac ? event.metaKey : event.ctrlKey) && (event.code === 'KeyK' || event.key.toLowerCase() === 'k')) {\n      event.preventDefault();\n      field.focus();\n      if (surface.hidden) render();\n    }\n  });\n\n  // Leaving the search, by pointer or by Tab, closes the surface. The surface stands in the top\n  // layer but in the wrapper's tree, so the wrapper holds both.\n  document.addEventListener('pointerdown', event => {\n    if (!surface.hidden && !event.composedPath().includes(search)) close();\n  });\n  search.addEventListener('focusout', event => {\n    if (event.relatedTarget && !search.contains(event.relatedTarget)) close();\n  });\n</script>",
  "page": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/search/su01-global-search/",
  "topicPage": "https://test-apps.eurodata.de/a/eurodata-ui/docs/patterns/search/"
}
