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.
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.
Search
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.
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.
<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.
<Scope loading items={[]} value={[]} all={allEvents} />
<Scope collapsible disabled items={events} value={["message.received"]} />
Props
Scope (<div role="group">)
- items (required) —
ScopeItem[]— the catalog, in display order - value —
string[]— the selection, controlled - defaultValue —
string[]— the selection at first, uncontrolled - onValueChange —
(value: string[]) => void— the next selection, in catalog order - all —
ScopeAll— the catch-all row; leave out for none - presets —
ScopePreset[]— named selections, picked at the search row’s end - placeholder —
string(default"Search") — the search’s placeholder and name - collapsible —
boolean(defaultfalse) — rest as a two-line summary that opens on click - open / defaultOpen / onOpenChange — the collapsible list’s open state
- loading —
boolean(defaultfalse) — bones in place of rows - disabled —
boolean(defaultfalse) — readable, not changeable - className —
string— merged onto the card
ScopeItem
- value (required) —
string— what the selection holds; the row’s name unlesslabelis set - label —
string— the row’s name, when it isn’t the value - description —
string— one line on what it covers - group —
string— the group it files under; items without one come first
ScopePreset
- label (required) —
string— its name in the picker, and on a folded selection that is exactly it - description —
string— what it covers - value (required) —
string[]— the selection it stands for
ScopeAll
- label (required) —
string— the row’s name - description —
string— what choosing it means - value —
string— a wildcard held in place of the items; without it the row selects every item listed
Accessibility
- Semantics — the card is a
group; name it witharia-labeloraria-labelledby. Each band and its rows form a nestedgroupnamed by the band. - Names — every checkbox is named by its own row and described by its
description, even inside a
Fieldwhose label would otherwise name them all. - Keyboard —
Tabmoves through the search, the preset picker, and the checkboxes,Spacetoggles. In the search,Escapeclears the query, then folds a collapsible list;Enterdoes nothing. - Focus — opening moves focus to the search; the ✕ (named
Cancel) returns it to the summary. - Presets — the picker is a select named
Preset; Custom is listed, not pickable, so the current state is always named.
Best practices
- Name items by their ids when readers act on ids (event types, permission strings); keep descriptions to one sentence that fits a row.
- Use a wildcard only when the catalog grows and the reader should grow with it. A permission set never does.
- Fold the scope once the resource exists, as a section of its own on the page; keep it open, as a field in the form, while creating it.
- A scope that must not be empty says so as a field error on save, not by refusing the last untick.