[
'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 (!is_array($field)) {
continue;
}
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 '';
}
}
/**
* @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;
?>
$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 (!is_string($groupName) || $groupName === '') {
continue;
}
if (!is_array($paths) || 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());
}