forked from fa/breadcrumb-the-shire
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>
This commit is contained in:
196
web/css/components/app-token-select.css
Normal file
196
web/css/components/app-token-select.css
Normal file
@@ -0,0 +1,196 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user