Pho Design System

Scope

Chooses what something covers out of a catalog: the events a webhook destination receives, the permissions an app or key holds. A searchable, grouped checklist with a catch-all row at the top. On a resource that already exists, collapsible folds it to a two-line summary that opens in place when clicked.

Import

import { Scope } from "@photon-ai/pho-ui/components/scope";

Choosing

Pass the catalog as items and the selection as value with onValueChange. Each row is one line: the name, then its description in a column of its own; a description too long for the row opens whole on hover. Items file under their group, a thin band with a checkbox for the whole group, mixed while only part of it is chosen.

all adds the catch-all row. Its value is a wildcard the selection holds in place of the items (*): every event now and every event added later. Leaving it spells every item out by name, so the reader prunes from there rather than starting from nothing, and a row unticked under the wildcard does the same in one move.

Which events this destination receives.

const [events, setEvents] = useState(["*"]);

<BasicPage.Fields>
  <Field.Root name="enabledEvents">
    <Field.Label>Events</Field.Label>
    <Field.Description>
      Which events this destination receives.
    </Field.Description>
    <Scope
      items={catalog}
      value={events}
      onValueChange={setEvents}
      all={{
        label: "All events",
        description: "Every current and future event.",
        value: "*",
      }}
      placeholder="Search events"
    />
  </Field.Root>
</BasicPage.Fields>;

On a page, in a form

Where the card stands decides how far in its words sit. It pads by the page’s choice inset, the one RadioCard and CheckboxCard use. On a saved resource’s page it is a section of its own, a card on the Stack under a Header, and its words start 20px in, on the headings’ line like any card’s rows. In a form being filled out it is a field among fields, inside BasicPage.Fields (or a dialog) under a Field.Label, and its words start 12px in, where the inputs’ text does. Don’t nest it in a card’s Row: a card inside a card indents its words twice.

The search is the card’s first row, with no box of its own: its magnifier on the checkbox column, its text on the names’ line, and how many of the catalog are chosen at the row’s end. Like every search that rides on a list or table, it draws no focus ring: the caret and the magnifier, which takes the ink, show where focus is.

It matches names, descriptions, and group names word by word, with the separators ids are written in read as spaces: call ended finds call.ended, delivered finds the event whose description says so. While it holds a query the catch-all steps aside, and a group’s checkbox covers the rows it shows.

The query belongs to the list, not the form around it: typing never marks a form dirty or schedules an auto-save, Enter never submits, and Escape (or the ✕ that shows while it holds text) empties it.

Collapsible

Once the resource exists its scope is read far more often than it changes. collapsible rests the card as two lines: whole groups by name and the rest item by item, over how many of the catalog are chosen. A click opens the list in place with the search focused. The change is the page’s to save: “Save changes” below the card, outside it like every card’s save, shows once the selection differs from what’s saved, and folds the list through open when it lands. The ✕ at the end of the search row (or Escape on an empty search) drops the edit: the selection goes back to what it was when the list opened, and folds.

Events

Which events this destination receives.

const [open, setOpen] = useState(false);

<BasicPage.Form action={save /* then setOpen(false) */}>
  <BasicPage.Header>
    <BasicPage.SectionTitle>Events</BasicPage.SectionTitle>
    <BasicPage.Description>
      Which events this destination receives.
    </BasicPage.Description>
  </BasicPage.Header>
  <Scope
    collapsible
    open={open}
    onOpenChange={setOpen}
    items={catalog}
    value={events}
    onValueChange={setEvents}
    all={allEvents}
    placeholder="Search events"
  />
  <BasicPage.Footer show={changed}>
    <BasicPage.Submit />
  </BasicPage.Footer>
</BasicPage.Form>;

The summary says the catch-all’s own words under the wildcard, and None for an empty selection.

Without a wildcard

Leave value out of all and the catch-all selects every item listed, the way a permission set works: there is no “and whatever comes later”. A value the selection holds that the catalog no longer offers keeps a row under No longer offered: it can be removed, and ticked back while the list is mounted, but never added by the catch-all or a group.

No longer offered
<Scope
  items={scopes}
  value={value}
  onValueChange={setValue}
  all={{ label: "All scopes", description: "Every scope in the catalog." }}
  placeholder="Search scopes"
/>

Presets

presets names selections to start from, the templates a key or an app is usually made with. The picker stands at the search row’s end, a quiet inline select like a table’s toolbar filter. Choosing one sets the list to its values; a value the catalog doesn’t offer stays as it is. The picker shows the name of the preset the selection is exactly, and Custom once it is edited by hand. Folded, such a selection reads as the preset’s name over its description.

<Scope
  items={permissions}
  value={value}
  onValueChange={setValue}
  presets={[
    {
      label: "Full access",
      description: "Everything a key can do.",
      value: every,
    },
    {
      label: "Read only",
      description: "Everything it can see, nothing it can change.",
      value: reads,
    },
  ]}
  placeholder="Search permissions"
/>

Motion

Everything that comes and goes opens and closes on BasicPage.Reveal’s motion: a group or a row a search sets aside, the catch-all stepping out while a query holds. Where one block takes another’s place (the folded face and the open one, the bones and the rows) the one arriving opens from the height the other stood at, while the other fades where it stood. The card moves at its bottom edge only, so the list unfolds from the top down and rolls back up under it.

Loading and disabled

loading shows bones while the catalog is on its way. disabled leaves the list readable and searchable but not changeable; a collapsible one stays folded. Say why beside it, in the row’s description. A page that holds the list open while it saves passes disabled for the request’s length: nothing in the list changes the selection then, the ✕ and Escape included.

message.received1 of 13
<Scope loading items={[]} value={[]} all={allEvents} />
<Scope collapsible disabled items={events} value={["message.received"]} />

Props

Scope (<div role="group">)

ScopeItem

ScopePreset

ScopeAll

Accessibility

Best practices