forked from fa/breadcrumb-the-shire
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>
806 lines
28 KiB
PHP
806 lines
28 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Grid.js localization map used across list pages.
|
|
*/
|
|
function gridLang(): array
|
|
{
|
|
return [
|
|
'search' => [
|
|
'placeholder' => t('Search...'),
|
|
],
|
|
'sort' => [
|
|
'sortAsc' => t('Sort column ascending'),
|
|
'sortDesc' => t('Sort column descending'),
|
|
],
|
|
'pagination' => [
|
|
'previous' => t('Previous'),
|
|
'next' => t('Next'),
|
|
'navigate' => t('Page %d of %d'),
|
|
'page' => t('Page %d'),
|
|
'rowsPerPage' => t('Rows per page'),
|
|
'rowsPerPageShort' => t('Per page'),
|
|
'showing' => t('Showing'),
|
|
'of' => t('of'),
|
|
'to' => t('to'),
|
|
'results' => t('results'),
|
|
],
|
|
'actions' => [
|
|
'column' => t('Actions'),
|
|
'open' => t('Open'),
|
|
'edit' => t('Edit'),
|
|
'rowEnterHint' => t('Press Enter to open row'),
|
|
],
|
|
'errorRetry' => t('Retry'),
|
|
'loading' => t('Loading...'),
|
|
'noRecordsFound' => t('No records found'),
|
|
'error' => t('An error happened while fetching the data'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Render a multi-select filter field with hidden input sync.
|
|
*
|
|
* @param string $id Base ID for the hidden input (select gets "-ui" suffix)
|
|
* @param string $label Field label (will be translated)
|
|
* @param string $placeholder Placeholder text (will be translated)
|
|
* @param array $items Array of items with 'id' and 'description' keys
|
|
* @param array $selected Array of selected item IDs
|
|
*/
|
|
function multiSelectFilter(
|
|
string $id,
|
|
string $label,
|
|
string $placeholder,
|
|
array $items,
|
|
array $selected
|
|
): void {
|
|
// The hidden input holds a comma-separated value that server-side code already expects.
|
|
?>
|
|
<label class="app-field">
|
|
<span><?php e(t($label)); ?></span>
|
|
<input type="hidden" id="<?php e($id); ?>" value="<?php e(implode(',', $selected)); ?>">
|
|
<select
|
|
id="<?php e($id); ?>-ui"
|
|
multiple
|
|
data-multi-select
|
|
data-sync-target="#<?php e($id); ?>"
|
|
data-select-all="true"
|
|
data-list-all="false"
|
|
data-search="true"
|
|
data-placeholder="<?php e(t($placeholder)); ?>"
|
|
data-search-placeholder="<?php e(t('Search...')); ?>"
|
|
data-select-all-label="<?php e(t('Select all')); ?>"
|
|
data-selected-label="<?php e(t('%d selected')); ?>"
|
|
>
|
|
<?php foreach ($items as $item): ?>
|
|
<?php $itemId = (string) ($item['id'] ?? ''); ?>
|
|
<?php if ($itemId !== ''): ?>
|
|
<option value="<?php e($itemId); ?>"<?php if (in_array($itemId, $selected, true)) { ?> selected<?php } ?>>
|
|
<?php e($item['description'] ?? ''); ?>
|
|
</option>
|
|
<?php endif; ?>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Render a standard select filter field.
|
|
*
|
|
* @param array<int, array{id:mixed,description:mixed,translate?:bool,attributes?:array<string,mixed>}> $items
|
|
* @param array<string,mixed> $labelAttributes
|
|
* @param array<string,mixed> $selectAttributes
|
|
*/
|
|
function selectFilter(
|
|
string $id,
|
|
string $label,
|
|
array $items,
|
|
string $selected = '',
|
|
array $labelAttributes = [],
|
|
array $selectAttributes = []
|
|
): void {
|
|
$labelClass = trim((string) ($labelAttributes['class'] ?? ''));
|
|
if ($labelClass === '') {
|
|
$labelClass = 'app-field';
|
|
} elseif (!str_contains($labelClass, 'app-field')) {
|
|
$labelClass = 'app-field ' . $labelClass;
|
|
}
|
|
$labelAttributes['class'] = $labelClass;
|
|
|
|
$selectAttributes['id'] = $id;
|
|
|
|
?>
|
|
<label<?php uiRenderAttributes($labelAttributes); ?>>
|
|
<span><?php e(t($label)); ?></span>
|
|
<select<?php uiRenderAttributes($selectAttributes); ?>>
|
|
<?php foreach ($items as $item): ?>
|
|
<?php $itemId = (string) ($item['id'] ?? ''); ?>
|
|
<?php $itemLabel = (string) ($item['description'] ?? ''); ?>
|
|
<?php $translateLabel = !array_key_exists('translate', $item) || (bool) $item['translate']; ?>
|
|
<?php $optionAttributes = is_array($item['attributes'] ?? null) ? $item['attributes'] : []; ?>
|
|
<?php $optionAttributes['value'] = $itemId; ?>
|
|
<?php if ($itemId === $selected): ?>
|
|
<?php $optionAttributes['selected'] = true; ?>
|
|
<?php endif; ?>
|
|
<option<?php uiRenderAttributes($optionAttributes); ?>>
|
|
<?php if ($translateLabel): ?>
|
|
<?php e(t($itemLabel)); ?>
|
|
<?php else: ?>
|
|
<?php e($itemLabel); ?>
|
|
<?php endif; ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</label>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Render a full toolbar from a filter schema.
|
|
*
|
|
* @param array<int,array<string,mixed>> $schema
|
|
* @param array<string,mixed> $active
|
|
* @param array<string,array<int,array<string,mixed>>> $optionSets
|
|
*/
|
|
function renderGridFilterToolbar(array $schema, array $active = [], array $optionSets = []): void
|
|
{
|
|
foreach ($schema as $field) {
|
|
if (($field['render'] ?? true) === false) {
|
|
continue;
|
|
}
|
|
|
|
$key = trim((string) ($field['key'] ?? ''));
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
$type = strtolower(trim((string) ($field['type'] ?? 'text')));
|
|
if (!in_array($type, ['text', 'date', 'number', 'hidden', 'select', 'multi_csv'], true)) {
|
|
continue;
|
|
}
|
|
|
|
$inputId = trim((string) ($field['input_id'] ?? ''));
|
|
if ($inputId === '') {
|
|
$inputId = $key . '-filter';
|
|
}
|
|
|
|
$label = (string) ($field['label'] ?? $key);
|
|
$placeholder = (string) ($field['placeholder'] ?? '');
|
|
$defaultValue = $field['default'] ?? '';
|
|
$activeValue = $active[$key] ?? $defaultValue;
|
|
$labelAttributes = is_array($field['label_attributes'] ?? null) ? $field['label_attributes'] : [];
|
|
$inputAttributes = is_array($field['input_attributes'] ?? null) ? $field['input_attributes'] : [];
|
|
|
|
if ($type === 'hidden') {
|
|
$inputAttributes['type'] = 'hidden';
|
|
$inputAttributes['id'] = $inputId;
|
|
$inputAttributes['value'] = is_scalar($activeValue) ? (string) $activeValue : '';
|
|
echo '<input';
|
|
uiRenderAttributes($inputAttributes);
|
|
echo '>';
|
|
continue;
|
|
}
|
|
|
|
if ($type === 'select') {
|
|
$items = uiResolveFilterItems($field, $optionSets);
|
|
$selected = is_scalar($activeValue) ? (string) $activeValue : (string) $defaultValue;
|
|
selectFilter(
|
|
$inputId,
|
|
$label,
|
|
$items,
|
|
$selected,
|
|
$labelAttributes,
|
|
$inputAttributes
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if ($type === 'multi_csv') {
|
|
$items = uiResolveFilterItems($field, $optionSets);
|
|
$selected = uiNormalizeFilterSelection($activeValue);
|
|
multiSelectFilter(
|
|
$inputId,
|
|
$label,
|
|
$placeholder !== '' ? $placeholder : 'Select options',
|
|
$items,
|
|
$selected
|
|
);
|
|
continue;
|
|
}
|
|
|
|
$labelClass = trim((string) ($labelAttributes['class'] ?? ''));
|
|
if ($labelClass === '') {
|
|
$labelClass = 'app-field';
|
|
} elseif (!str_contains($labelClass, 'app-field')) {
|
|
$labelClass = 'app-field ' . $labelClass;
|
|
}
|
|
$labelAttributes['class'] = $labelClass;
|
|
|
|
$inputAttributes['id'] = $inputId;
|
|
$inputAttributes['type'] = $type === 'number' ? 'number' : $type;
|
|
if ($placeholder !== '' && !isset($inputAttributes['placeholder']) && $type === 'text') {
|
|
$inputAttributes['placeholder'] = t($placeholder);
|
|
}
|
|
if (in_array($type, ['text', 'date', 'number'], true)) {
|
|
$inputAttributes['value'] = is_scalar($activeValue) ? (string) $activeValue : '';
|
|
}
|
|
|
|
echo '<label';
|
|
uiRenderAttributes($labelAttributes);
|
|
echo '>';
|
|
echo '<span>';
|
|
e(t($label));
|
|
echo '</span>';
|
|
echo '<input';
|
|
uiRenderAttributes($inputAttributes);
|
|
echo '>';
|
|
echo '</label>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $field
|
|
* @param array<string,array<int,array<string,mixed>>> $optionSets
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
function uiResolveFilterItems(array $field, array $optionSets): array
|
|
{
|
|
$allowed = $field['allowed'] ?? null;
|
|
if (is_array($allowed)) {
|
|
return array_values(array_filter(array_map(static function (mixed $item): array {
|
|
if (is_array($item)) {
|
|
return $item;
|
|
}
|
|
if (is_scalar($item)) {
|
|
$value = (string) $item;
|
|
return ['id' => $value, 'description' => $value, 'translate' => false];
|
|
}
|
|
return [];
|
|
}, $allowed), static fn (array $item): bool => $item !== []));
|
|
}
|
|
|
|
$optionsKey = trim((string) ($field['options_key'] ?? ''));
|
|
if ($optionsKey !== '' && isset($optionSets[$optionsKey])) {
|
|
return array_values(array_filter($optionSets[$optionsKey], static fn (array $item): bool => $item !== []));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
function uiNormalizeFilterSelection(mixed $value): array
|
|
{
|
|
if (is_array($value)) {
|
|
return array_values(array_filter(array_map(
|
|
static fn (mixed $item): string => trim((string) $item),
|
|
$value
|
|
), static fn (string $item): bool => $item !== ''));
|
|
}
|
|
if (!is_scalar($value)) {
|
|
return [];
|
|
}
|
|
$text = trim((string) $value);
|
|
if ($text === '') {
|
|
return [];
|
|
}
|
|
return array_values(array_filter(array_map('trim', explode(',', $text)), static fn (string $item): bool => $item !== ''));
|
|
}
|
|
|
|
/**
|
|
* Render a multi-select form field for data entry.
|
|
*
|
|
* @param string $id Element ID
|
|
* @param string $name Form field name
|
|
* @param string $label Field label (will be translated)
|
|
* @param string $placeholder Placeholder text (will be translated)
|
|
* @param array $items Array of items with 'id' and 'description' keys
|
|
* @param array $selected Array of selected item IDs
|
|
* @param bool $disabled Whether the field is disabled/readonly
|
|
* @param string|array $labelKeys Key(s) to use for option label (tries in order)
|
|
* @param string|null $formId Form ID for selects outside form element
|
|
*/
|
|
function multiSelectForm(
|
|
string $id,
|
|
string $name,
|
|
string $label,
|
|
string $placeholder,
|
|
array $items,
|
|
array $selected,
|
|
bool $disabled = false,
|
|
string|array $labelKeys = 'description',
|
|
?string $formId = null
|
|
): void {
|
|
// Allow a fallback chain for label keys, e.g. ['description', 'label', 'name'].
|
|
$labelKeyList = is_array($labelKeys) ? $labelKeys : [$labelKeys];
|
|
// Vendor MultiSelect appends [] to the field name internally for hidden inputs.
|
|
// Strip a trailing [] here to avoid accidental [][] payloads.
|
|
$normalizedName = preg_replace('/\[\]$/', '', $name) ?: $name;
|
|
?>
|
|
<label>
|
|
<span><?php e(t($label)); ?></span>
|
|
</label>
|
|
<select
|
|
id="<?php e($id); ?>"
|
|
name="<?php e($normalizedName); ?>"
|
|
<?php if ($formId !== null): ?>form="<?php e($formId); ?>"<?php endif; ?>
|
|
multiple
|
|
data-multi-select="true"
|
|
data-select-all="true"
|
|
data-search="true"
|
|
data-placeholder="<?php e(t($placeholder)); ?>"
|
|
data-search-placeholder="<?php e(t('Search...')); ?>"
|
|
data-select-all-label="<?php e(t('Select all')); ?>"
|
|
<?php if ($disabled): ?>data-disabled="true" disabled<?php endif; ?>
|
|
>
|
|
<?php foreach ($items as $item): ?>
|
|
<?php $itemId = (int) ($item['id'] ?? 0); ?>
|
|
<?php if ($itemId > 0): ?>
|
|
<?php
|
|
$isSelected = in_array($itemId, $selected, true);
|
|
$itemLabel = '';
|
|
foreach ($labelKeyList as $key) {
|
|
if (isset($item[$key]) && (string) $item[$key] !== '') {
|
|
$itemLabel = (string) $item[$key];
|
|
break;
|
|
}
|
|
}
|
|
?>
|
|
<option value="<?php e((string) $itemId); ?>"<?php if ($isSelected) { ?> selected<?php } ?>>
|
|
<?php e($itemLabel); ?>
|
|
</option>
|
|
<?php endif; ?>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Render a token-select form field — combobox lookup on top, flat removable list
|
|
* below. Emits a hidden `<select multiple name="<name>[]">` (source of truth for
|
|
* the form POST) plus a progressive-enhancement host that the `token-select`
|
|
* runtime component upgrades into an interactive typeahead.
|
|
*
|
|
* Generic core primitive. Data contract per item:
|
|
* ['id' => int, ...one of $labelKeys, 'key' => string (optional hidden search hint)]
|
|
*
|
|
* Call-sites may override the user-visible default strings via $labelOverrides.
|
|
* Supported keys (all optional):
|
|
* - 'emptyState' default: `token_select.empty_state_title`
|
|
* - 'noMatches' default: `token_select.no_matches`
|
|
* - 'removeTooltip' default: `token_select.remove`
|
|
* - 'clearAll' default: `token_select.clear_all`
|
|
* - 'countSuffix' default: `token_select.selected_count_suffix`
|
|
* - 'errorMessage' default: `token_select.error_invalid_items`
|
|
* Values are treated as already-translated strings (call `t(...)` at the call-site).
|
|
*
|
|
* @param string $id Element ID
|
|
* @param string $name Form field name (e.g. `permission_ids`) — posted as array
|
|
* @param string $label Field label (will be translated)
|
|
* @param string $placeholder Input placeholder (will be translated)
|
|
* @param array|null $items List of items; null/non-array renders an error card
|
|
* @param array $selected Array of selected item IDs
|
|
* @param bool $disabled Whether the field is readonly / disabled
|
|
* @param string|array $labelKeys Key(s) to use for the row label (tries in order, falls back to `key`)
|
|
* @param string|null $formId Form ID for the hidden select when rendered outside the form
|
|
* @param array<string,string> $labelOverrides Per-call-site label overrides (already translated)
|
|
*/
|
|
function tokenSelectForm(
|
|
string $id,
|
|
string $name,
|
|
string $label,
|
|
string $placeholder,
|
|
mixed $items,
|
|
array $selected,
|
|
bool $disabled = false,
|
|
string|array $labelKeys = ['description', 'label', 'name'],
|
|
?string $formId = null,
|
|
array $labelOverrides = []
|
|
): void {
|
|
$normalizedName = preg_replace('/\[\]$/', '', $name) ?: $name;
|
|
$labelKeyList = is_array($labelKeys) ? $labelKeys : [$labelKeys];
|
|
|
|
// Label resolution: override wins over default i18n key.
|
|
$resolve = static function (string $overrideKey, string $i18nKey) use ($labelOverrides): string {
|
|
$override = $labelOverrides[$overrideKey] ?? null;
|
|
if (is_string($override) && $override !== '') {
|
|
return $override;
|
|
}
|
|
return t($i18nKey);
|
|
};
|
|
$lblEmptyState = $resolve('emptyState', 'token_select.empty_state_title');
|
|
$lblNoMatches = $resolve('noMatches', 'token_select.no_matches');
|
|
$lblRemoveTooltip = $resolve('removeTooltip', 'token_select.remove');
|
|
$lblClearAll = $resolve('clearAll', 'token_select.clear_all');
|
|
$lblCountSuffix = $resolve('countSuffix', 'token_select.selected_count_suffix');
|
|
$lblErrorMessage = $resolve('errorMessage', 'token_select.error_invalid_items');
|
|
|
|
// Guard: null or non-array $items renders a user-visible error card (SC-010).
|
|
if (!is_array($items)) {
|
|
?>
|
|
<label>
|
|
<span><?php e(t($label)); ?></span>
|
|
</label>
|
|
<div class="app-token-select app-token-select--error" data-app-component="token-select-error">
|
|
<p class="app-token-select-error-message"><?php e($lblErrorMessage); ?></p>
|
|
</div>
|
|
<?php
|
|
return;
|
|
}
|
|
|
|
$readonlyFlag = $disabled ? '1' : '0';
|
|
$selectedIds = [];
|
|
foreach ($selected as $selId) {
|
|
$selectedIds[(int) $selId] = true;
|
|
}
|
|
|
|
// Build normalized row list (flat, alphabetically sorted). Invalid ids (<= 0) are dropped.
|
|
$rows = [];
|
|
foreach ($items as $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
$itemId = (int) ($item['id'] ?? 0);
|
|
if ($itemId <= 0) {
|
|
continue;
|
|
}
|
|
$key = (string) ($item['key'] ?? '');
|
|
$rowLabel = '';
|
|
foreach ($labelKeyList as $labelKey) {
|
|
if (isset($item[$labelKey]) && (string) $item[$labelKey] !== '') {
|
|
$rowLabel = (string) $item[$labelKey];
|
|
break;
|
|
}
|
|
}
|
|
if ($rowLabel === '') {
|
|
$rowLabel = $key !== '' ? $key : ('#' . $itemId);
|
|
}
|
|
$rows[] = [
|
|
'id' => $itemId,
|
|
'key' => $key,
|
|
'label' => $rowLabel,
|
|
'selected' => isset($selectedIds[$itemId]),
|
|
];
|
|
}
|
|
|
|
$selectedRows = array_values(array_filter($rows, static fn (array $row): bool => $row['selected']));
|
|
usort($selectedRows, static fn (array $a, array $b): int => strcasecmp($a['label'], $b['label']));
|
|
$hasSelection = $selectedRows !== [];
|
|
$selectedTotal = count($selectedRows);
|
|
$listboxId = $id . '-listbox';
|
|
?>
|
|
<label for="<?php e($id); ?>">
|
|
<span><?php e(t($label)); ?></span>
|
|
</label>
|
|
<?php $wrapperClass = 'app-token-select' . ($disabled ? ' app-token-select--readonly' : ''); ?>
|
|
<div
|
|
class="<?php e($wrapperClass); ?>"
|
|
data-app-component="token-select"
|
|
data-token-select-id="<?php e($id); ?>"
|
|
data-token-select-field="<?php e($normalizedName); ?>"
|
|
data-readonly="<?php e($readonlyFlag); ?>"
|
|
data-label-remove="<?php e($lblRemoveTooltip); ?>"
|
|
data-label-empty-state="<?php e($lblEmptyState); ?>"
|
|
data-label-no-matches="<?php e($lblNoMatches); ?>"
|
|
>
|
|
<select
|
|
id="<?php e($id); ?>"
|
|
name="<?php e($normalizedName); ?>[]"
|
|
<?php if ($formId !== null): ?>form="<?php e($formId); ?>"<?php endif; ?>
|
|
multiple
|
|
data-token-select-hidden="1"
|
|
hidden
|
|
aria-hidden="true"
|
|
tabindex="-1"
|
|
<?php if ($disabled): ?>disabled<?php endif; ?>
|
|
>
|
|
<?php foreach ($rows as $row): ?>
|
|
<option
|
|
value="<?php e((string) $row['id']); ?>"
|
|
data-token-key="<?php e($row['key']); ?>"
|
|
data-token-label="<?php e($row['label']); ?>"
|
|
<?php if ($row['selected']): ?>selected<?php endif; ?>
|
|
><?php e($row['label']); ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
|
|
<?php if (!$disabled): ?>
|
|
<div class="app-token-select-combobox">
|
|
<input
|
|
type="text"
|
|
class="app-token-select-input"
|
|
role="combobox"
|
|
aria-autocomplete="list"
|
|
aria-expanded="false"
|
|
aria-controls="<?php e($listboxId); ?>"
|
|
placeholder="<?php e(t($placeholder)); ?>"
|
|
autocomplete="off"
|
|
data-token-select-input
|
|
>
|
|
<ul
|
|
class="app-token-select-listbox"
|
|
role="listbox"
|
|
id="<?php e($listboxId); ?>"
|
|
data-token-select-listbox
|
|
hidden
|
|
></ul>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<div class="app-token-select-card">
|
|
<div class="app-token-select-meta">
|
|
<div class="app-token-select-meta-count">
|
|
<span class="app-token-select-meta-count-value" data-token-select-count><?php e((string) $selectedTotal); ?></span>
|
|
<span class="app-token-select-meta-count-label"><?php e($lblCountSuffix); ?></span>
|
|
</div>
|
|
<?php if (!$disabled): ?>
|
|
<button
|
|
type="button"
|
|
class="secondary outline small app-token-select-clear"
|
|
data-token-select-clear
|
|
<?php if (!$hasSelection): ?>hidden<?php endif; ?>
|
|
><?php e($lblClearAll); ?></button>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<ul class="app-token-select-rows" data-token-select-selected>
|
|
<?php if (!$hasSelection): ?>
|
|
<li
|
|
class="app-empty-state app-token-select-empty"
|
|
data-size="compact"
|
|
data-align="center"
|
|
data-token-select-empty
|
|
>
|
|
<p class="app-empty-state-message"><?php e($lblEmptyState); ?></p>
|
|
</li>
|
|
<?php else: ?>
|
|
<?php foreach ($selectedRows as $row): ?>
|
|
<li class="app-token-select-row" data-token-select-row="<?php e((string) $row['id']); ?>">
|
|
<span class="app-token-select-row-label"><?php e($row['label']); ?></span>
|
|
<?php if (!$disabled): ?>
|
|
<button
|
|
type="button"
|
|
class="app-icon-button app-token-select-row-remove"
|
|
data-token-select-remove="<?php e((string) $row['id']); ?>"
|
|
data-tooltip="<?php e($lblRemoveTooltip); ?>"
|
|
aria-label="<?php e($lblRemoveTooltip); ?>"
|
|
><i class="bi bi-x-lg" aria-hidden="true"></i></button>
|
|
<?php endif; ?>
|
|
</li>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
/**
|
|
* Render arbitrary HTML attributes from a key-value map.
|
|
*
|
|
* @param array<string,mixed> $attributes
|
|
*/
|
|
function uiRenderAttributes(array $attributes): void
|
|
{
|
|
foreach ($attributes as $name => $value) {
|
|
$attrName = trim((string) $name);
|
|
if ($attrName === '') {
|
|
continue;
|
|
}
|
|
if ($value === false || $value === null) {
|
|
continue;
|
|
}
|
|
if ($value === true) {
|
|
echo ' ' . $attrName;
|
|
continue;
|
|
}
|
|
echo ' ' . $attrName . '="';
|
|
e((string) $value);
|
|
echo '"';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve nav active state for one or many paths.
|
|
*
|
|
* @param string|array $path A single path or a list of candidate paths.
|
|
* @param bool $prefix Prefix matching (path starts with candidate).
|
|
* @return array{class:string,aria:string,isActive:bool}
|
|
*/
|
|
function navActive($path, bool $prefix = false): array
|
|
{
|
|
$current = \MintyPHP\Http\Request::path();
|
|
// Normalize to array to keep the matching loop simple.
|
|
$paths = is_array($path) ? $path : [$path];
|
|
$isActive = false;
|
|
foreach ($paths as $p) {
|
|
$p = (string) $p;
|
|
$match = $prefix ? strpos($current, $p) === 0 : $current === $p;
|
|
if (!$match && ($current === '' || $current === '/')) {
|
|
$match = $p === '' || $p === '/';
|
|
}
|
|
if ($match) {
|
|
$isActive = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'class' => $isActive ? 'active' : '',
|
|
'aria' => $isActive ? 'aria-current="page"' : '',
|
|
'isActive' => $isActive,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Load and cache the asset registry config from config/assets.php.
|
|
*/
|
|
function appAssetConfig(): array
|
|
{
|
|
// Static local cache avoids repeated filesystem access during one request.
|
|
static $config = null;
|
|
if ($config !== null) {
|
|
return $config;
|
|
}
|
|
|
|
$file = dirname(__DIR__, 3) . '/config/assets.php';
|
|
if (!is_file($file)) {
|
|
$config = [];
|
|
return $config;
|
|
}
|
|
|
|
$loaded = include $file;
|
|
$config = is_array($loaded) ? $loaded : [];
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* Resolve all CSS paths for one or more asset groups.
|
|
*
|
|
* @param string[] $groupNames
|
|
*
|
|
* @return string[] List of unique web-relative asset paths.
|
|
*/
|
|
function appStylePathsForGroups(array $groupNames): array
|
|
{
|
|
$config = appAssetConfig();
|
|
$styles = $config['styles'] ?? [];
|
|
if (!is_array($styles)) {
|
|
return [];
|
|
}
|
|
|
|
$groups = $styles['groups'] ?? [];
|
|
if (!is_array($groups)) {
|
|
return [];
|
|
}
|
|
|
|
// Merge module-contributed style groups (same shape as config/assets.php groups).
|
|
try {
|
|
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
|
|
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
|
|
foreach ($registry->getAssetGroups() as $groupName => $paths) {
|
|
if ($groupName === '') {
|
|
continue;
|
|
}
|
|
if (isset($groups[$groupName])) {
|
|
continue;
|
|
}
|
|
$groups[$groupName] = $paths;
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// fail-open: no module registry in this runtime context.
|
|
}
|
|
|
|
$resolved = [];
|
|
// Used as a fast set to keep insertion order while removing duplicates.
|
|
$seen = [];
|
|
foreach ($groupNames as $groupName) {
|
|
$groupKey = trim((string) $groupName);
|
|
if ($groupKey === '' || !isset($groups[$groupKey]) || !is_array($groups[$groupKey])) {
|
|
continue;
|
|
}
|
|
foreach ($groups[$groupKey] as $path) {
|
|
$assetPath = ltrim(trim((string) $path), '/');
|
|
if ($assetPath === '' || isset($seen[$assetPath])) {
|
|
continue;
|
|
}
|
|
$seen[$assetPath] = true;
|
|
$resolved[] = $assetPath;
|
|
}
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
/**
|
|
* Resolve all CSS paths for a template based on grouped config entries.
|
|
*
|
|
* @return string[] List of unique web-relative asset paths.
|
|
*/
|
|
function appTemplateStylePaths(string $templateName): array
|
|
{
|
|
$config = appAssetConfig();
|
|
$styles = $config['styles'] ?? [];
|
|
if (!is_array($styles)) {
|
|
return [];
|
|
}
|
|
|
|
$templates = $styles['templates'] ?? [];
|
|
if (!is_array($templates)) {
|
|
return [];
|
|
}
|
|
|
|
$groupNames = $templates[$templateName] ?? [];
|
|
if (!is_array($groupNames)) {
|
|
return [];
|
|
}
|
|
|
|
return appStylePathsForGroups($groupNames);
|
|
}
|
|
|
|
/**
|
|
* Read a raw Buffer value without echoing it to output.
|
|
*/
|
|
function appBufferValue(string $key): string
|
|
{
|
|
ob_start();
|
|
$hasValue = \MintyPHP\Buffer::get($key);
|
|
$raw = (string) ob_get_clean();
|
|
return $hasValue ? trim($raw) : '';
|
|
}
|
|
|
|
/**
|
|
* Resolve page-specific CSS files declared via Buffer::set('style_groups', ...).
|
|
*
|
|
* Accepted formats:
|
|
* - JSON array (e.g. ["address-book"])
|
|
* - comma-separated list (e.g. "address-book,foo")
|
|
*
|
|
* @return string[] List of unique web-relative asset paths.
|
|
*/
|
|
function appPageStylePaths(): array
|
|
{
|
|
$raw = appBufferValue('style_groups');
|
|
if ($raw === '') {
|
|
return [];
|
|
}
|
|
|
|
$groupNames = json_decode($raw, true);
|
|
if (!is_array($groupNames)) {
|
|
$groupNames = array_filter(array_map('trim', explode(',', $raw)));
|
|
}
|
|
return appStylePathsForGroups($groupNames);
|
|
}
|
|
|
|
/**
|
|
* Render stylesheet link tags.
|
|
*
|
|
* @param string[] $stylePaths
|
|
*/
|
|
function renderStylePaths(array $stylePaths): void
|
|
{
|
|
foreach ($stylePaths as $stylePath) {
|
|
echo '<link href="';
|
|
e(assetVersion($stylePath));
|
|
echo "\" rel=\"stylesheet\">\n";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render stylesheet link tags for the given template.
|
|
*/
|
|
function renderTemplateStyles(string $templateName): void
|
|
{
|
|
renderStylePaths(appTemplateStylePaths($templateName));
|
|
}
|
|
|
|
/**
|
|
* Render page-specific stylesheet links from Buffer::set('style_groups', ...).
|
|
*/
|
|
function renderPageStyles(): void
|
|
{
|
|
renderStylePaths(appPageStylePaths());
|
|
}
|