Files
breadcrumb-the-shire/web/css/components/app-token-select.css
fs b2982bdac7 feat(ui): token-select primitive for large multi-selects
Adds tokenSelectForm() in core/Support/helpers/ui.php: an inline
typeahead combobox + flat alphabetical removable list, designed for
selections that outgrow the chip-header of the vendor MultiSelect
(roles, permissions, and similar admin pickers).

Contract:
- Hidden <select multiple name="<name>[]"> is the form submission source —
  consumers read via $request->body() verbatim, no parsing
- Items are ['id' => int, <labelKey> => string, 'key' => string?]
  where labelKeys default to ['description', 'label', 'name']; 'key' is
  an invisible fuzzy-match hint
- $labelOverrides lets callers swap emptyState / removeTooltip /
  noMatches / clearAll / countSuffix / errorMessage with domain copy
- $disabled renders pure-presentation list (no combobox, no remove)

Runtime:
- initTokenSelect in web/js/components/app-token-select.js is registered
  as 'token-select' in app-init.js; destroy()/cleanupFns contract
- Syncs the hidden <select> on every mutation and dispatches 'change'
  so dependent UI can react

Wired up: pages/admin/permissions/_form.phtml (assigned roles),
pages/admin/roles/_form.phtml (permissions + assignable roles).
Helper contract lives in tests/Support/Helpers/TokenSelectFormHelperTest.php;
runtime contract + entrypoint registration + host usage are enforced by
FrontendRuntime*ContractTest.php.

Coexists with multiSelectForm() — pick tokenSelectForm() when the
selected set can grow beyond ~10 items or typeahead is expected.
Docs in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:52:32 +02:00

197 lines
5.1 KiB
CSS

@layer components {
/*
* Token-select primitive — combobox lookup + flat removable list rendered
* inside a details-card-style bordered wrapper. Reusable core UI component
* for multi-selects with large selectable sets. Rendered via
* `tokenSelectForm()` (core/Support/helpers/ui.php) + initialized by
* `initTokenSelect` (web/js/components/app-token-select.js).
* Coexists with the legacy vendor `app-multiselect-*` primitive.
*/
.app-token-select {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.app-token-select--error .app-token-select-error-message {
color: var(--app-color-danger, #b91c1c);
font-size: var(--text-sm);
margin: 0;
}
/* Combobox ------------------------------------------------------------ */
.app-token-select-combobox {
position: relative;
}
.app-token-select-input {
width: 100%;
margin-bottom: 0;
font-size: var(--text-sm);
}
.app-token-select-listbox {
list-style: none;
margin: 0.25rem 0 0;
padding: 0.25rem;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
max-height: 18rem;
overflow-y: auto;
background: var(--app-card-background-color, #fff);
border: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
border-radius: var(--app-border-radius, 0.375rem);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
.app-token-select-listbox[hidden] {
display: none;
}
.app-token-select-listbox-option {
padding: 0.4rem 0.5rem;
border-radius: calc(var(--app-border-radius, 0.375rem) - 2px);
cursor: pointer;
}
.app-token-select-listbox-option.is-active,
.app-token-select-listbox-option:hover {
background: var(
--app-card-sectioning-background-color,
rgba(148, 163, 184, 0.1)
);
}
.app-token-select-listbox-label {
font-size: var(--text-sm);
font-weight: var(--font-regular);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-token-select-listbox-empty {
/* inherits from .app-empty-state — keep margin tight inside the popover */
margin: 0.25rem 0;
}
/* Selection card — details-card-style bordered wrapper around meta + rows */
.app-token-select-card {
border: 1px solid var(--app-card-border-color, var(--app-muted-border-color, rgba(148, 163, 184, 0.3)));
border-radius: var(--app-border-radius, 0.375rem);
background: var(--app-card-background-color, #fff);
overflow: hidden;
}
.app-token-select-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: calc(var(--app-spacing) * 0.55) calc(var(--app-spacing) * 0.85);
background: var(--app-card-sectioning-background-color, rgba(148, 163, 184, 0.06));
border-bottom: 1px solid var(--app-card-border-color, var(--app-muted-border-color, rgba(148, 163, 184, 0.25)));
}
.app-token-select-meta-count {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
font-size: var(--text-xs);
color: var(--app-color-muted, #6b7280);
letter-spacing: var(--tracking-wide);
}
.app-token-select-meta-count-value {
font-variant-numeric: tabular-nums;
font-weight: var(--font-medium);
color: var(--app-color, inherit);
font-size: var(--text-sm);
}
.app-token-select-meta-count-label {
text-transform: uppercase;
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
}
.app-token-select-clear {
margin-bottom: 0;
}
/* Selected list (flat, alphabetical, inside the card body) ----------- */
.app-token-select-rows {
list-style: none;
margin: 0;
padding: 0 calc(var(--app-spacing) * 0.85);
display: flex;
flex-direction: column;
}
.app-token-select-empty {
/* inherits from .app-empty-state — breathing room inside the card body */
margin: calc(var(--app-spacing) * 0.5) 0;
}
.app-token-select-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
padding: 0;
border-bottom: 1px solid
var(--app-muted-border-color, rgba(148, 163, 184, 0.12));
margin: 0;
}
.app-token-select-row:last-child {
border-bottom: none;
}
.app-token-select-row-label {
font-size: var(--text-sm);
font-weight: var(--font-regular);
line-height: var(--leading-snug);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--app-color, inherit);
}
.app-token-select-row-remove {
margin-bottom: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.app-token-select-row:hover .app-token-select-row-remove,
.app-token-select-row:focus-within .app-token-select-row-remove,
.app-token-select-row-remove:focus-visible {
opacity: 1;
}
.app-token-select--readonly .app-token-select-row-remove {
display: none;
}
.app-token-select--readonly .app-token-select-row {
grid-template-columns: minmax(0, 1fr);
}
@media (max-width: 720px) {
/* Touch devices: keep remove visible since hover doesn't fire reliably */
.app-token-select-row-remove {
opacity: 1;
}
}
}