GitHub

Entity Search

A scoped entity-search overlay — a sibling to the command palette, but for navigating to records rather than firing actions. Set entities + scopes; it filters client-side, shows per-scope counts, highlights matches, groups by scope, and emits events the consumer handles.

Stable wc import '@esa/ecology/esa-entity-search'; Angular usage

Preview

Data is set as properties in a script. Open with the button or /. Try a query (e.g. “ri”) to see counts + highlighting; ← / → change the scope; ⌘/Ctrl + Enter fires show-all.

<button id="open-es">Open entity search</button>

<esa-entity-search id="es" hotkey="slash" placeholder="Search projects, people, documents…"></esa-entity-search>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-entity-search';

@Component({
  selector: 'app-entity-search-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <button id="open-es">Open entity search</button>
    <esa-entity-search
      id="es"
      hotkey="slash"
      placeholder="Search projects, people, documents…"
    ></esa-entity-search>
  `,
})
export class EntitySearchDemo {}

API

PropTypeDefaultDescription
entities EsaSearchEntity[] [] The searchable records, set as a property. Each: { id, title, subtitle?, scope, icon?, url?, meta? }. Filtered client-side.
scopes EsaSearchScope[] [] Facets to narrow by. Each: { id, label, icon? }. An implicit "All" facet is prepended.
recent EsaSearchEntity[] [] Shown when the query is empty and the "All" facet is active.
row-actions EsaSearchRowAction[] [] Per-row secondary actions (e.g. Impersonate). Each: { id, label, icon?, scopes? }. scopes restricts which rows show it. Property: `rowActions`.
open boolean false Reflected open state.
placeholder string 'Search…' Input placeholder.
all-label string 'All' Label for the implicit all-scopes facet. Property: `allLabel`.
hotkey '' | 'mod+k' | 'slash' '' Built-in open shortcut. 'mod+k' = Cmd/Ctrl+K toggle; 'slash' = '/' opens (ignored while typing in a field).

Events

EventTypeDefaultDescription
select { entity } — A row was chosen (click or Enter). The consumer navigates (e.g. to entity.url).
scope-change { scope } — The active facet changed. scope is '' for All.
show-all { query, scope } — Cmd/Ctrl+Enter — "see all results". The consumer routes to a full results page.
row-action { action, entity } — A per-row action button was clicked. action is the EsaSearchRowAction id.

Methods

PropTypeDefaultDescription
show() method — Opens, clears the query + scope, focuses the input.
close() method — Closes the overlay.
toggle() method — Toggles open state.

Accessibility

  • A native modal <dialog>, opened with showModal() — so the page behind it is genuinely inert, focus is contained, Esc closes, and focus returns to the trigger. None of that is our code.
  • The input is a role="combobox" driving a role="listbox" of role="option" rows via aria-activedescendant. The rows are <div>s, not buttons: an option may not be an interactive widget, and while they were buttons the per-row action buttons were nested inside them.
  • ArrowUp / ArrowDown move the active row (wrapping across groups), Enter selects, ⌘/Ctrl + Enter fires show-all, Esc closes.
  • Facets are a role="radiogroup" navigated with ← / →. Until 2026-08-18 they claimed role="tablist" and bound Tab instead — the exact inversion of what the role promises, and it cost the per-row action buttons any keyboard route at all (SC 2.1.1, Level A). Tab is now plain Tab.

Theming surface

Every public token this component reads, extracted from its source at build time, split by blast radius: the hooks that belong to this component, then the shared and system tokens it consumes alongside everything else. The Resolves to column walks each token's real lineage — component → semantic → primitive → raw value — so you can see exactly where re-pointing it lands. A spoke re-skins by overriding these in its theme-<slug>.css; component internals are never edited. No hook for what you need? File it with /request-lego.

Type roles it names

This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.

RoleRe-pointReach
.typography-body-md
  • --typography-body-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-body-md-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-md-line-height 1.6 via --line-height-normal
+28 more components esa-app-shell, esa-breadcrumbs, esa-card, esa-checkbox, esa-checkbox-group, esa-collapsible, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-container, esa-danger-zone, esa-dialog, esa-dropdown-menu, esa-empty-state, esa-form-field, esa-header-nav, esa-input-tag, esa-nav-dropdown, esa-page-header, esa-popover, esa-progress-bar, esa-radio-group, esa-select, esa-side-dialog, esa-snackbar-item, esa-switch-toggle, esa-tab-layout, esa-textarea
.typography-body-xs
  • --typography-body-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-body-xs-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-xs-line-height 1.6 via --line-height-normal
+14 more components esa-breadcrumbs, esa-checkbox, esa-checkbox-group, esa-combobox, esa-command-palette, esa-empty-state, esa-field-error, esa-header-nav, esa-input-tag, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-textarea
.typography-eyebrow-md
  • --typography-eyebrow-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-eyebrow-md-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-eyebrow-md-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-eyebrow-md-letter-spacing 0.03em via --letter-spacing-wide
  • --typography-eyebrow-md-line-height 1.6 via --line-height-normal
  • --typography-eyebrow-md-text-transform uppercase via --text-transform-uppercase
+3 more components esa-command-palette, esa-search-panel, esa-sidebar-nav
.typography-label-md
  • --typography-label-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-label-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-md-line-height 1.6 via --line-height-normal
+33 more components esa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-card, esa-checkbox-group, esa-color-picker, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-side-dialog, esa-sidebar-nav, esa-stat, esa-text-field, esa-textarea, esa-tooltip
.typography-label-xs
  • --typography-label-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-label-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-xs-line-height 1.6 via --line-height-normal
+14 more components esa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-text-field, esa-textarea
.typography-microcopy-lg-subtle
  • --typography-microcopy-lg-subtle-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-lg-subtle-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-microcopy-lg-subtle-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-lg-subtle-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-lg-subtle-line-height 1 via --line-height-none
+7 more components esa-combobox, esa-command-palette, esa-date-picker, esa-filter-dropdown, esa-input-tag, esa-select, esa-text-field

Wired to this component

No other component reads these — re-point them and esa-entity-search is the only thing that moves.

Token Tier Resolves to
--entity-search-max-height component 78vh
--entity-search-width component 720px

Shared tokens it reads

Owned by a family or by the semantic layer. Still overridable — but scope the override (esa-foo { --form-border-color: … }) unless you mean to move everything that reads it. Reach says what else that is.

Token Reach Resolves to
--focus-ring-color component focus ring group · +34 more #3e9b4f via --color-border-default-focus → --color-grass-10
--focus-ring-offset component focus ring group · +31 more 2px
--focus-ring-width component focus ring group · +35 more 2px via --border-width-focus → --border-width-200
--animation-enter semantic every component 150ms via --duration-150
--border-width-default semantic every component 1px via --border-width-100
--color-background-brand semantic every component #46a758 via --color-grass-9
--color-background-brand-hover semantic every component #3e9b4f via --color-grass-10
--color-background-elevation-floating semantic every component #fcfcfc via --color-gray-1
--color-background-elevation-raised semantic every component #fcfcfc via --color-gray-1
--color-background-elevation-sunken semantic every component #f0f0f0 via --color-gray-3
--color-background-overlay-backdrop semantic every component rgba(0, 0, 0, 0.5) via --color-black-a-7
--color-border-default semantic every component one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half. * * This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus
--color-border-default-strong semantic every component #bbbbbb via --color-gray-8
--color-border-default-subtle semantic every component #d9d9d9 via --color-gray-6
--color-content-brand semantic every component #2a7e3b via --color-grass-11
--color-content-default semantic every component #202020 via --color-gray-12
--color-content-default-knockout semantic every component #fcfcfc via --color-gray-1
--color-content-default-secondary semantic every component #646464 via --color-gray-11
--elevation-6 semantic every component 0 via --shadow-offset-x
--radius-lg semantic every component 0.75rem via --radius-400
--radius-md semantic every component 0.5rem via --radius-200
--radius-pill semantic every component 9999px via --radius-full
--typography-font-family-sans semantic every component 'DM Sans', sans-serif via --font-family-dm-sans
--spacing-100 primitive every component 0.25rem
--spacing-150 primitive every component 0.375rem
--spacing-200 primitive every component 0.5rem
--spacing-250 primitive every component 0.625rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem
--spacing-600 primitive every component 2rem
--spacing-700 primitive every component 3rem