[ '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. ?> }> $items * @param array $labelAttributes * @param array $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; ?> > > > > $schema * @param array $active * @param array>> $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 ''; 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 ''; echo ''; e(t($label)); echo ''; echo ''; echo ''; } } /** * @param array $field * @param array>> $optionSets * @return array> */ 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; ?> []">` (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 $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)) { ?>

$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'; ?>
$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 '\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()); } /** * Render a dashboard tile (icon + count + label, linking to a target URL). * * Options: * - href (string) Link target. Default: '#'. * - label (string) Tile label. Default: ''. * - count (string|null) Numeric or short textual value. Default: null. * - icon (string) Bootstrap-icon class. Default: 'bi bi-grid-1x2'. * - iconBg (string|null) CSS color for icon background. * - iconColor (string|null) CSS color for icon glyph. * - class (string) Extra CSS classes appended to .app-tile. * - tooltip (string) Tooltip text (already translated). * - tooltipPos (string) Tooltip position. Default: 'top'. * * @param array $options */ function appTile(array $options = []): void { $defaults = [ 'href' => '#', 'label' => '', 'count' => null, 'icon' => 'bi bi-grid-1x2', 'iconTone' => null, 'iconBg' => null, 'iconColor' => null, 'class' => '', 'tooltip' => '', 'tooltipPos' => 'top', ]; $data = array_merge($defaults, $options); $template = templatePath('partials/app-tile.phtml'); if (!is_file($template)) { return; } extract($data, EXTR_SKIP); require $template; }