major update

This commit is contained in:
2026-03-04 15:56:58 +01:00
parent 16759a2732
commit 8f4dd5840d
478 changed files with 24313 additions and 8201 deletions

View File

@@ -267,6 +267,10 @@
--app-form-element-active-border-color: var(--app-primary-border);
--app-form-element-focus-color: var(--app-primary-border);
--app-form-element-disabled-opacity: 0.5;
--app-form-element-readonly-background-color: #f4f7fb;
--app-form-element-readonly-border-color: #d6deea;
--app-form-element-readonly-color: #2f3440;
--app-form-element-readonly-focus-color: rgba(124, 138, 164, 0.3);
--app-form-element-invalid-border-color: rgb(183.5, 105.5, 106.5);
--app-form-element-invalid-active-border-color: rgb(200.25, 79.25, 72.25);
--app-form-element-invalid-focus-color: var(
@@ -508,6 +512,10 @@
--app-form-element-active-border-color: var(--app-primary-border);
--app-form-element-focus-color: var(--app-primary-border);
--app-form-element-disabled-opacity: 0.5;
--app-form-element-readonly-background-color: #242c3a;
--app-form-element-readonly-border-color: #3a4458;
--app-form-element-readonly-color: #d7dde8;
--app-form-element-readonly-focus-color: rgba(136, 145, 164, 0.35);
--app-form-element-invalid-border-color: rgb(149.5, 74, 80);
--app-form-element-invalid-active-border-color: rgb(183.25, 63.5, 59);
--app-form-element-invalid-focus-color: var(
@@ -726,6 +734,10 @@
--app-form-element-active-border-color: var(--app-primary-border);
--app-form-element-focus-color: var(--app-primary-border);
--app-form-element-disabled-opacity: 0.5;
--app-form-element-readonly-background-color: #242c3a;
--app-form-element-readonly-border-color: #3a4458;
--app-form-element-readonly-color: #d7dde8;
--app-form-element-readonly-focus-color: rgba(136, 145, 164, 0.35);
--app-form-element-invalid-border-color: rgb(149.5, 74, 80);
--app-form-element-invalid-active-border-color: rgb(183.25, 63.5, 59);
--app-form-element-invalid-focus-color: var(

View File

@@ -0,0 +1,86 @@
@layer components {
.app-active-filters {
margin: 0 0 0.875rem;
}
.app-active-filters-inner {
display: flex;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
}
.app-filter-chip-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
}
.app-filter-chip-list li {
margin: 0;
padding: 0;
list-style: none;
}
.app-filter-chip {
-webkit-appearance: none;
appearance: none;
font: inherit;
border: 1px solid var(--app-form-element-border-color);
background: var(--app-form-element-background-color);
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.65rem;
font-size: 0.78rem;
line-height: 1.15;
font-weight: 500;
color: var(--app-form-element-color);
--app-background-color: var(--app-form-element-background-color);
--app-border-color: var(--app-form-element-border-color);
--app-color: var(--app-form-element-color);
cursor: pointer;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease, color 160ms ease;
}
.app-filter-chip:hover {
border-color: var(--app-primary, #1d4ed8);
background: var(--app-card-background-color, #fff);
color: var(--app-form-element-color);
--app-color: var(--app-form-element-color);
transform: translateY(-1px);
}
.app-filter-chip:focus-visible {
outline: 2px solid var(--app-primary, #1d4ed8);
outline-offset: 2px;
color: var(--app-form-element-color);
--app-color: var(--app-form-element-color);
}
.app-filter-chip:active {
color: var(--app-form-element-color);
--app-color: var(--app-form-element-color);
}
.app-filter-chip i {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 999px;
font-size: 0.58rem;
background: var(--app-muted-border-color);
}
.app-filter-chip-clear {
border-style: dashed;
--app-color: var(--app-muted-color);
}
}

View File

@@ -39,6 +39,11 @@
white-space: nowrap;
}
.app-details-titlebar .app-detail-unsaved-indicator {
margin: 0;
white-space: nowrap;
}
.app-details-titlebar h1 i {
background: var(--app-background-color);
width: 35px;

View File

@@ -0,0 +1,57 @@
@layer components {
.app-details-errors {
padding-block-start: 1rem;
}
.app-details-validation-summary {
--app-validation-summary-border-color: var(--app-notice-error-border-color);
--app-validation-summary-background-color: var(--app-notice-error-background-color);
--app-validation-summary-title-color: var(--app-notice-error-color);
--app-validation-summary-link-color: var(--app-contrast);
margin: 0;
border: 1px solid var(--app-validation-summary-border-color);
border-left-width: 4px;
border-radius: var(--app-border-radius);
background: var(--app-validation-summary-background-color);
padding: calc(var(--app-spacing) * 0.75) calc(var(--app-spacing) * 1);
color: var(--app-color);
display: grid;
gap: calc(var(--app-spacing) * 0.55);
}
.app-details-validation-summary:focus-visible {
outline: 2px solid var(--app-validation-summary-border-color);
outline-offset: 2px;
}
.app-details-validation-summary-title {
margin: 0;
font-weight: 600;
color: var(--app-validation-summary-title-color);
}
.app-details-validation-summary-list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: calc(var(--app-spacing) * 0.35);
}
.app-details-validation-summary-list li {
margin: 0;
list-style: disc;
}
.app-details-validation-summary-link {
color: var(--app-validation-summary-link-color);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
transition: color 120ms ease;
}
.app-details-validation-summary-link:hover,
.app-details-validation-summary-link:focus-visible {
color: var(--app-validation-summary-title-color);
}
}

View File

@@ -0,0 +1,184 @@
@layer components {
.app-visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
body.filter-drawer-open {
overflow: hidden;
overscroll-behavior: none;
}
.app-filter-drawer {
position: fixed;
inset: 0;
z-index: 2100;
display: flex;
justify-content: flex-end;
background: rgba(15, 23, 42, 0.35);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.app-filter-drawer[hidden] {
display: none;
}
.app-filter-drawer-panel {
display: flex;
flex-direction: column;
width: min(420px, 100vw);
height: 100%;
background: var(--app-card-background-color, #fff);
border-left: 1px solid
var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
box-shadow: -12px 0 30px rgba(15, 23, 42, 0.18);
}
.app-filter-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid
var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
}
.app-filter-drawer-header h2 {
margin: 0;
font-size: 0.8125rem;
line-height: 1.2;
}
.app-filter-drawer-header .icon-button {
min-height: auto;
padding: 0.2rem 0.4rem;
margin: 0;
}
.app-filter-drawer-body {
--app-drawer-field-gap: 0.35rem;
--app-drawer-row-gap: 1.1rem;
--app-drawer-control-height: calc(
1rem * var(--app-line-height) + var(--app-form-element-spacing-vertical) * 2 +
var(--app-border-width) * 2
);
flex: 1;
overflow: auto;
padding: 1rem 1rem 1.1rem;
display: grid;
align-content: start;
gap: var(--app-drawer-row-gap);
}
.app-filter-drawer-body .app-list-toolbar {
margin-bottom: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--app-drawer-row-gap);
}
.app-filter-drawer-body .app-list-toolbar > * {
width: 100% !important;
}
.app-filter-drawer-body .app-list-toolbar > * > * {
width: 100% !important;
}
.app-filter-drawer-body .app-list-toolbar > * > * > * {
width: 100% !important;
}
.app-filter-drawer-body .app-list-toolbar .app-field,
.app-filter-drawer-body .app-list-toolbar > label {
width: 100%;
display: grid;
gap: var(--app-drawer-field-gap);
align-items: start;
margin: 0 !important;
}
.app-filter-drawer-body .app-list-toolbar .app-field > span,
.app-filter-drawer-body .app-list-toolbar > label > span {
margin: 0 !important;
line-height: 1.2;
}
.app-filter-drawer-body .app-list-toolbar input:not([type="hidden"]),
.app-filter-drawer-body .app-list-toolbar select,
.app-filter-drawer-body .app-list-toolbar textarea {
width: 100% !important;
box-sizing: border-box;
min-height: var(--app-drawer-control-height);
margin: 0 !important;
}
.app-filter-drawer-body .app-list-toolbar .multi-select {
width: 100% !important;
margin: 0 !important;
line-height: var(--app-line-height);
--input-min-height: var(--app-drawer-control-height);
}
.app-filter-drawer-body .app-list-toolbar .multi-select .multi-select-header {
min-height: var(--app-drawer-control-height);
margin: 0 !important;
padding-top: var(--app-form-element-spacing-vertical);
padding-bottom: var(--app-form-element-spacing-vertical);
box-sizing: border-box;
}
.app-filter-drawer-body
.app-list-toolbar
.multi-select
.multi-select-options {
margin-top: 0.25rem;
}
.app-filter-drawer-body
.app-list-toolbar
.multi-select
.multi-select-header-placeholder,
.app-filter-drawer-body
.app-list-toolbar
.multi-select
.multi-select-header-option {
line-height: 1.2;
}
.app-filter-drawer-body #address-book-custom-filters {
margin: 0;
}
.app-filter-drawer-footer {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.9rem 1rem 1.1rem;
border-top: 1px solid
var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
}
.app-filter-drawer-footer > button {
width: 100%;
margin: 0;
}
@media (max-width: 768px) {
.app-filter-drawer-panel {
width: 100vw;
border-left: 0;
}
}
}

View File

@@ -16,6 +16,7 @@
position: relative;
overflow: hidden;
color: var(--app-notice-border-color);
margin-block: calc(var(--app-spacing) * .75);
}
#async-messages .notice {
@@ -32,6 +33,7 @@
position: relative;
overflow: hidden;
color: var(--app-notice-border-color);
margin-block: 0;
}
@@ -111,10 +113,6 @@
margin: 0;
}
.app-details-errors {
padding-block-start: 1rem;
}
/* .flash-stack:has(.notice) {
margin-top: var(--app-spacing);
} */
@@ -126,6 +124,14 @@
margin-bottom: 0;
}
.flash-stack .notice {
margin-block: 0;
}
.flash-stack .notice + .notice {
margin-top: calc(var(--app-spacing) * .5);
}
.notice li {
list-style: none;
}

View File

@@ -1,79 +1,85 @@
@layer components {
.app-list-toolbar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 10px;
margin-bottom: calc(var(--app-spacing) * 1);
}
.app-list-toolbar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 10px;
margin-bottom: calc(var(--app-spacing) * 1);
}
.app-list-toolbar > * {
margin: 0 !important;
width: auto !important;
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
font-size: 13px !important;
height: auto !important;
}
.app-list-toolbar > * > * {
margin: 0 !important;
width: auto !important;
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
font-size: 13px !important;
height: auto !important;
}
.app-list-toolbar > * > * > *{
margin: 0 !important;
width: auto !important;
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
font-size: 13px !important;
height: auto !important;
}
.app-list-toolbar > * {
margin: 0 !important;
width: auto !important;
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
font-size: 13px !important;
height: auto !important;
}
.app-list-toolbar > * > * {
margin: 0 !important;
width: auto !important;
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
font-size: 13px !important;
height: auto !important;
}
.app-list-toolbar > * > * > * {
margin: 0 !important;
width: auto !important;
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
font-size: 13px !important;
height: auto !important;
}
.app-list-toolbar
input:not(
[type="checkbox"],
.app-list-toolbar [type="radio"],
.app-list-toolbar [type="range"],
.app-list-toolbar [type="file"]
):is(
[type="date"],
[type="datetime-local"],
[type="month"],
[type="time"],
[type="week"]
) {
width: auto;
}
.app-list-toolbar > button {
padding: 6px 10px;
}
.app-list-toolbar > label > span {
display: block;
font-size: 11px !important;
}
.app-list-toolbar
input:not(
[type="checkbox"],
.app-list-toolbar [type="radio"],
.app-list-toolbar [type="range"],
.app-list-toolbar [type="file"]
):is(
[type="date"],
[type="datetime-local"],
[type="month"],
[type="time"],
[type="week"]
) {
width: auto;
}
.app-list-toolbar
.multi-select
.multi-select-header
.multi-select-header-placeholder {
font-size: 13px;
}
.app-list-toolbar > label > span {
display: block;
font-size: 11px !important;
}
.app-list-toolbar .multi-select .multi-select-header, .app-list-toolbar .multi-select .multi-select-option, .app-list-toolbar .multi-select .multi-select-all {
.app-list-toolbar
.multi-select
.multi-select-header
.multi-select-header-placeholder {
font-size: 13px;
}
.app-list-toolbar .multi-select .multi-select-header,
.app-list-toolbar .multi-select .multi-select-option,
.app-list-toolbar .multi-select .multi-select-all {
min-height: unset;
padding-left: 5px;
}
}
.app-list-toolbar .multi-select {
.app-list-toolbar .multi-select {
width: 200px !important;
}
}
.app-list-toolbar span.multi-select-header-option {
.app-list-toolbar span.multi-select-header-option {
background: transparent !important;
border: 0 !important;
padding: 0 !important;
padding-left: 3px !important;
font-size: 11px !important;
}
}
}

View File

@@ -1473,6 +1473,38 @@
var(--app-form-element-focus-color);
}
input:not(
[type="submit"],
[type="button"],
[type="reset"],
[type="checkbox"],
[type="radio"],
[type="range"],
[type="file"]
)[readonly]:not([disabled]),
textarea[readonly]:not([disabled]),
select[readonly]:not([disabled]) {
--app-background-color: var(--app-form-element-readonly-background-color);
--app-border-color: var(--app-form-element-readonly-border-color);
--app-color: var(--app-form-element-readonly-color);
--app-box-shadow: inset 0 0 0 0.0625rem var(--app-form-element-readonly-border-color);
}
input:not(
[type="submit"],
[type="button"],
[type="reset"],
[type="checkbox"],
[type="radio"],
[type="range"],
[type="file"]
)[readonly]:not([disabled]):focus,
textarea[readonly]:not([disabled]):focus,
select[readonly]:not([disabled]):focus {
--app-box-shadow: 0 0 0 var(--app-outline-width)
var(--app-form-element-readonly-focus-color);
}
:where(fieldset[disabled])
:is(
input:not([type="submit"], [type="button"], [type="reset"]),

View File

@@ -232,6 +232,25 @@
white-space: nowrap;
}
.app-sidebar #aside-panel-admin > ul {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.app-sidebar #aside-panel-admin .app-sidebar-admin-group {
margin-bottom: 5px;
width: 100%;
}
.app-sidebar #aside-panel-admin .app-sidebar-admin-group details > ul li:first-child {
margin-top: 0;
}
.app-sidebar #aside-panel-admin .app-sidebar-admin-group details > ul li:last-child {
margin-bottom: 0.35rem;
}
.app-sidebar .app-sidebar-group details > ul li:first-child {
margin-top: 10px;
}

View File

@@ -8,9 +8,11 @@ use MintyPHP\Firewall;
use MintyPHP\Http\AccessControl;
use MintyPHP\Http\AssetDetector;
use MintyPHP\Http\LocaleResolver;
use MintyPHP\Http\RequestContext;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Session;
use MintyPHP\Support\Flash;
@@ -25,13 +27,17 @@ require 'config/config.php';
require 'config/router.php';
// Register shortcut functions
require 'lib/Support/helpers.php';
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
RequestContext::start();
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
// Start the firewall
Firewall::start();
// Start the session
Session::start();
$authServicesFactory = new AuthServicesFactory();
$authServicesFactory = app(AuthServicesFactory::class);
$authService = $authServicesFactory->createAuthService();
$rememberMeService = $authServicesFactory->createRememberMeService();
@@ -58,6 +64,7 @@ $parsedUri = $localeResolver->parseUri($originalUri);
$pathWithoutLocale = $parsedUri['pathWithoutLocale'];
$requestedLocale = $parsedUri['locale'];
$_SERVER['MINTY_LOCALE'] = $requestedLocale;
RequestContext::setPath($pathWithoutLocale);
// Rewrite REQUEST_URI without locale segment for router
if ($parsedUri['hadLocaleInUrl']) {
@@ -76,6 +83,7 @@ I18n::$locale = $localeResolver->resolveLocale(
$isAssetRequest = AssetDetector::isAssetRequest($pathWithoutLocale);
$isApiRequest = str_starts_with($pathWithoutLocale, 'api/');
RequestContext::setChannel($isApiRequest ? 'api' : 'web');
if ($isApiRequest) {
if (!defined('MINTY_API_REQUEST')) {
@@ -177,6 +185,17 @@ if (defined('MINTY_ALLOW_OUTPUT') && MINTY_ALLOW_OUTPUT) {
exit;
}
// Resolve layout capabilities while DB/session are still open.
// Templates render after DB::close(), so authz lookup must be prepared here.
if (Router::getTemplateView()) {
$viewAuth = is_array($viewAuth ?? null) ? $viewAuth : [];
$layoutAuth = $viewAuth['layout'] ?? null;
if (!is_array($layoutAuth)) {
$actorUserId = (int) (($_SESSION['user']['id'] ?? 0));
$viewAuth['layout'] = app(UiAccessService::class)->layoutCapabilities($actorUserId);
}
}
// End the session
Session::end();

View File

@@ -8,12 +8,14 @@ import './components/app-nav-history.js';
import './components/app-toggle-sidebar.js';
import './components/app-toggle-aside-panels.js';
import './components/app-toggle-details-aside.js';
import './components/app-toggle-toolbar.js';
import './components/app-filter-overflow.js';
import './components/app-filter-drawer.js';
import './components/app-active-filter-chips.js';
import './components/app-global-search.js';
import './components/app-theme-toggle.js';
import './components/app-contrast-toggle.js';
import './components/app-confirm-actions.js';
import './components/app-details-state.js';
import './components/app-standard-detail-page.js';
import './components/app-tenant-switcher.js';
import './components/app-color-default-toggle.js';
import './components/app-custom-field-options-toggle.js';

View File

@@ -0,0 +1,86 @@
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
export function initActiveFilterChips(options = {}) {
const {
container,
removeButtonLabel = 'Remove filter',
clearAllLabel = 'Clear all filters',
onRemove = null,
onClearAll = null
} = options;
const root = typeof container === 'string' ? document.querySelector(container) : container;
if (!root) {
return null;
}
const bindEvents = () => {
const removeButtons = Array.from(root.querySelectorAll('[data-active-filter-remove]'));
removeButtons.forEach((button) => {
if (button.dataset.bound === '1') {return;}
button.dataset.bound = '1';
button.addEventListener('click', () => {
if (typeof onRemove !== 'function') {return;}
onRemove({
id: button.dataset.activeFilterId || '',
param: button.dataset.activeFilterParam || '',
value: button.dataset.activeFilterValue || '',
valueType: button.dataset.activeFilterValueType || 'scalar',
meta: button.dataset.activeFilterMeta || ''
});
});
});
const clearButton = root.querySelector('[data-active-filter-clear]');
if (clearButton && clearButton.dataset.bound !== '1') {
clearButton.dataset.bound = '1';
clearButton.addEventListener('click', () => {
if (typeof onClearAll === 'function') {
onClearAll();
}
});
}
};
const render = (chips = []) => {
if (!Array.isArray(chips) || chips.length === 0) {
root.innerHTML = '';
root.hidden = true;
return;
}
root.hidden = false;
const list = chips.map((chip) => {
const id = String(chip?.id ?? '');
const param = String(chip?.param ?? '');
const value = String(chip?.value ?? '');
const valueType = String(chip?.valueType ?? 'scalar');
const label = String(chip?.label ?? '');
const meta = String(chip?.meta ?? '');
return `<li>
<button type="button" class="app-filter-chip" data-active-filter-remove data-active-filter-id="${escapeHtml(id)}" data-active-filter-param="${escapeHtml(param)}" data-active-filter-value="${escapeHtml(value)}" data-active-filter-value-type="${escapeHtml(valueType)}" data-active-filter-meta="${escapeHtml(meta)}" aria-label="${escapeHtml(removeButtonLabel)}">
<span>${escapeHtml(label)}</span>
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
</li>`;
}).join('');
const clearChip = `<li>
<button type="button" class="app-filter-chip app-filter-chip-clear" data-active-filter-clear aria-label="${escapeHtml(clearAllLabel)}">
<span>${escapeHtml(clearAllLabel)}</span>
<i class="bi bi-x-lg" aria-hidden="true"></i>
</button>
</li>`;
root.innerHTML = `<div class="app-active-filters-inner"><ul class="app-filter-chip-list">${list}${clearChip}</ul></div>`;
bindEvents();
};
return { render };
}

View File

@@ -0,0 +1,70 @@
const CONFIRM_ATTR = 'data-confirm-message';
const isSubmitControl = (element) => {
if (!(element instanceof HTMLElement)) {
return false;
}
if (element instanceof HTMLButtonElement) {
const type = String(element.getAttribute('type') || 'submit').trim().toLowerCase();
return type === '' || type === 'submit';
}
if (element instanceof HTMLInputElement) {
const type = String(element.type || '').trim().toLowerCase();
return type === 'submit' || type === 'image';
}
return false;
};
const submitterByForm = new Map();
const onClickCapture = (event) => {
const trigger = event.target instanceof HTMLElement ? event.target.closest('a, button, input') : null;
if (!(trigger instanceof HTMLElement)) {
return;
}
if (isSubmitControl(trigger)) {
const form = trigger.form instanceof HTMLFormElement ? trigger.form : null;
if (form) {
submitterByForm.set(form, trigger);
}
return;
}
const confirmMessage = String(trigger.getAttribute(CONFIRM_ATTR) || '').trim();
if (confirmMessage === '') {
return;
}
if (!window.confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
};
const onSubmitCapture = (event) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!(form instanceof HTMLFormElement)) {
return;
}
const submitter = event.submitter instanceof HTMLElement
? event.submitter
: (submitterByForm.get(form) || null);
submitterByForm.delete(form);
const submitterMessage = String(submitter?.getAttribute(CONFIRM_ATTR) || '').trim();
const formMessage = String(form.getAttribute(CONFIRM_ATTR) || '').trim();
const confirmMessage = submitterMessage !== '' ? submitterMessage : formMessage;
if (confirmMessage === '') {
return;
}
if (!window.confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
};
document.addEventListener('click', onClickCapture, true);
document.addEventListener('submit', onSubmitCapture, true);

View File

@@ -1,23 +1,6 @@
const readStored = (storageKey) => {
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
const writeStored = (storageKey, openKeys) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(openKeys));
} catch (error) {
// ignore storage errors
}
};
const controllerRegistry = new WeakMap();
const initDetailsGroup = (group) => {
if (!group || group.dataset.detailsStorageBound === '1') {
@@ -29,42 +12,15 @@ const initDetailsGroup = (group) => {
return;
}
const managedDetails = Array.from(group.querySelectorAll('details[data-details-key]'));
const alwaysOpenDetails = Array.from(group.querySelectorAll('details[data-details-always-open]'));
if (!managedDetails.length && !alwaysOpenDetails.length) {
const controller = initPersistedDetailsGroup({
root: group,
storageKey,
});
if (!controller) {
return;
}
const storedOpenKeys = readStored(storageKey);
if (storedOpenKeys.length > 0) {
managedDetails.forEach((details) => {
const key = (details.dataset.detailsKey || '').trim();
if (key !== '') {
details.open = storedOpenKeys.includes(key);
}
});
}
alwaysOpenDetails.forEach((details) => {
details.open = true;
details.addEventListener('toggle', () => {
if (!details.open) {
details.open = true;
}
});
});
managedDetails.forEach((details) => {
details.addEventListener('toggle', () => {
const openKeys = managedDetails
.filter((item) => item.open)
.map((item) => (item.dataset.detailsKey || '').trim())
.filter(Boolean);
writeStored(storageKey, openKeys);
});
});
controllerRegistry.set(group, controller);
group.dataset.detailsStorageBound = '1';
};

View File

@@ -0,0 +1,284 @@
import { warnOnce } from '../core/app-dom.js';
export function initFilterDrawer(options = {}) {
const {
root = document,
drawerSelector = '[data-filter-drawer]',
openSelector = '[data-filter-drawer-open]',
closeSelector = '[data-filter-drawer-close]',
applySelector = '[data-filter-drawer-apply]',
resetSelector = '[data-filter-drawer-reset]',
onOpen = null,
onClose = null,
onApply = null,
onReset = null,
onDiscard = null
} = options;
const host = root && typeof root.querySelector === 'function' ? root : document;
const drawer = host.querySelector(drawerSelector);
if (!drawer) {
warnOnce('UI_EL_MISSING', `Missing filter drawer: ${drawerSelector}`, { module: 'filter-drawer' });
return null;
}
const openButtons = Array.from(host.querySelectorAll(openSelector));
const closeButtons = Array.from(drawer.querySelectorAll(closeSelector));
const applyButton = drawer.querySelector(applySelector);
const resetButton = drawer.querySelector(resetSelector);
const applyBaseLabel = applyButton ? String(applyButton.textContent || '').trim() : '';
const openButtonBaseLabels = new WeakMap();
const panel = drawer.querySelector('[data-filter-drawer-panel]') || drawer;
let isOpen = false;
let lockedScrollY = 0;
let lastTrigger = null;
if (!drawer.hasAttribute('role')) {
drawer.setAttribute('role', 'dialog');
}
if (!drawer.hasAttribute('aria-modal')) {
drawer.setAttribute('aria-modal', 'true');
}
drawer.setAttribute('aria-hidden', drawer.hidden ? 'true' : 'false');
if (!panel.hasAttribute('tabindex')) {
panel.setAttribute('tabindex', '-1');
}
const lockPageScroll = () => {
const body = document.body;
if (body.dataset.filterDrawerLock === '1') {return;}
lockedScrollY = window.scrollY || window.pageYOffset || 0;
body.dataset.filterDrawerLock = '1';
body.style.position = 'fixed';
body.style.top = `-${lockedScrollY}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
};
const unlockPageScroll = () => {
const body = document.body;
if (body.dataset.filterDrawerLock !== '1') {return;}
body.style.position = '';
body.style.top = '';
body.style.left = '';
body.style.right = '';
body.style.width = '';
delete body.dataset.filterDrawerLock;
window.scrollTo(0, lockedScrollY);
};
const getFocusableElements = () => {
const selectors = [
'a[href]',
'area[href]',
'input:not([type="hidden"]):not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
];
return Array.from(panel.querySelectorAll(selectors.join(','))).filter((element) => {
if (!element) {return false;}
if (element.getAttribute('aria-hidden') === 'true') {return false;}
if (element instanceof HTMLElement && element.hidden) {return false;}
return true;
});
};
const focusFirstField = () => {
const focusable = getFocusableElements()[0] || panel;
if (focusable && typeof focusable.focus === 'function') {
focusable.focus();
}
};
const setOpen = (nextOpen) => {
isOpen = nextOpen;
drawer.hidden = !nextOpen;
drawer.setAttribute('aria-hidden', nextOpen ? 'false' : 'true');
drawer.classList.toggle('is-open', nextOpen);
document.body.classList.toggle('filter-drawer-open', nextOpen);
if (nextOpen) {
lockPageScroll();
} else {
unlockPageScroll();
}
openButtons.forEach((button) => {
button.setAttribute('aria-expanded', nextOpen ? 'true' : 'false');
});
if (!nextOpen) {
if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) {
lastTrigger.focus();
}
lastTrigger = null;
if (typeof onClose === 'function') {
onClose();
}
return;
}
if (typeof onOpen === 'function') {
onOpen();
}
focusFirstField();
};
const open = (trigger = null) => {
if (isOpen) {return;}
lastTrigger = trigger && typeof trigger.focus === 'function'
? trigger
: (document.activeElement instanceof HTMLElement ? document.activeElement : null);
setOpen(true);
};
const close = (mode = 'discard') => {
if (!isOpen) {return;}
if (mode === 'discard' && typeof onDiscard === 'function') {
onDiscard();
}
setOpen(false);
};
openButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
open(button);
});
});
closeButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
close('discard');
});
});
if (applyButton) {
applyButton.addEventListener('click', async (event) => {
event.preventDefault();
if (applyButton.disabled) {
return;
}
let shouldClose = true;
if (typeof onApply === 'function') {
const result = await onApply();
shouldClose = result !== false;
}
if (shouldClose) {
close('apply');
}
});
}
if (resetButton) {
resetButton.addEventListener('click', async (event) => {
event.preventDefault();
if (typeof onReset === 'function') {
await onReset();
}
});
}
drawer.addEventListener('click', (event) => {
if (!isOpen) {return;}
if (!panel.contains(event.target)) {
close('discard');
}
});
document.addEventListener('keydown', (event) => {
if (!isOpen) {return;}
if (event.key === 'Escape') {
event.preventDefault();
close('discard');
return;
}
if (event.key !== 'Tab') {return;}
const focusable = getFocusableElements();
if (!focusable.length) {
event.preventDefault();
panel.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !panel.contains(active)) {
event.preventDefault();
last.focus();
}
return;
}
if (active === last) {
event.preventDefault();
first.focus();
}
});
const setApplyEnabled = (enabled) => {
if (!applyButton) {return;}
const nextEnabled = enabled === true;
applyButton.disabled = !nextEnabled;
applyButton.setAttribute('aria-disabled', nextEnabled ? 'false' : 'true');
};
const setApplyCount = (count) => {
if (!applyButton) {return;}
const parsed = Number.parseInt(String(count ?? '0'), 10);
const normalized = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
applyButton.textContent = normalized > 0
? `${applyBaseLabel} (${normalized})`
: applyBaseLabel;
};
const setTriggerCount = (count) => {
const parsed = Number.parseInt(String(count ?? '0'), 10);
const normalized = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
openButtons.forEach((button) => {
const baseLabel = openButtonBaseLabels.get(button)
|| String(button.getAttribute('aria-label') || button.textContent || '').trim();
if (!baseLabel) {return;}
if (!openButtonBaseLabels.has(button)) {
openButtonBaseLabels.set(button, baseLabel);
}
const nextLabel = normalized > 0 ? `${baseLabel} (${normalized})` : baseLabel;
const icon = button.querySelector('i');
if (icon) {
while (icon.nextSibling) {
icon.parentNode?.removeChild(icon.nextSibling);
}
icon.insertAdjacentText('afterend', ` ${nextLabel}`);
} else {
button.textContent = nextLabel;
}
button.setAttribute('aria-label', nextLabel);
button.setAttribute('data-tooltip', nextLabel);
});
};
setApplyEnabled(false);
setApplyCount(0);
setTriggerCount(0);
return {
open,
close,
isOpen: () => isOpen,
drawer,
setApplyEnabled,
setApplyCount,
setTriggerCount
};
}

View File

@@ -86,7 +86,24 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
syncInput.dispatchEvent(new Event('change', { bubbles: true }));
isSyncing = false;
};
instance.element.addEventListener('multi-select:change', syncValues);
let boundElement = null;
const bindSyncListener = () => {
if (!instance?.element || instance.element === boundElement) {
return;
}
instance.element.addEventListener('multi-select:change', syncValues);
boundElement = instance.element;
};
bindSyncListener();
if (typeof instance.refresh === 'function' && instance._syncRefreshWrapped !== true) {
const originalRefresh = instance.refresh.bind(instance);
instance.refresh = (...args) => {
const result = originalRefresh(...args);
bindSyncListener();
return result;
};
instance._syncRefreshWrapped = true;
}
const values = instance.selectedValues || [];
syncInput.value = Array.isArray(values) ? values.join(',') : '';
syncInput.addEventListener('change', () => {

View File

@@ -0,0 +1,11 @@
import { autoInitStandardDetailPages } from '../core/app-detail-page-factory.js';
export function initStandardDetailPages(root = document) {
return autoInitStandardDetailPages(root);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initStandardDetailPages());
} else {
initStandardDetailPages();
}

View File

@@ -1,4 +1,5 @@
import { requireEl, warnOnce } from '../core/app-dom.js';
import { initPersistedDetailsGroup } from '../core/app-details-open-state.js';
export function initAsidePanels(options = {}) {
const {
@@ -43,51 +44,34 @@ export function initAsidePanels(options = {}) {
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
const panelDetailsRegistry = new WeakMap();
const findActiveDetailsKeys = (panel) => Array.from(panel.querySelectorAll('details[data-details-key]'))
.filter((details) => details.querySelector('a.active, a[aria-current="page"]'))
.map((details) => details.dataset.detailsKey || '')
.filter(Boolean);
const initPanelDetails = (panel) => {
if (!panel || panel.dataset.detailsBound === '1') {return;}
if (!panel) {return;}
const existing = panelDetailsRegistry.get(panel);
if (existing) {
existing.apply();
return;
}
const storageKey = panel.dataset.asideDetailsStorage || '';
if (!storageKey) {return;}
const detailsEls = Array.from(panel.querySelectorAll('details[data-details-key]'));
if (!detailsEls.length) {return;}
const readStored = () => {
try {
const raw = window.localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : [];
} catch (error) {
return [];
}
};
const writeStored = (value) => {
try {
const list = Array.isArray(value) ? value : [];
window.localStorage.setItem(storageKey, JSON.stringify(list));
} catch (error) {
// ignore storage errors
}
};
const applyStored = () => {
const stored = readStored();
if (!stored.length) {return;}
detailsEls.forEach((details) => {
details.open = stored.includes(details.dataset.detailsKey || '');
});
};
detailsEls.forEach((details) => {
details.addEventListener('toggle', () => {
const openKeys = detailsEls
.filter((item) => item.open)
.map((item) => item.dataset.detailsKey || '')
.filter(Boolean);
writeStored(openKeys);
});
const openActive = panel.dataset.asideDetailsOpenActive === '1';
const detailsState = initPersistedDetailsGroup({
root: panel,
storageKey,
ensureOpenKeys: openActive ? () => findActiveDetailsKeys(panel) : []
});
applyStored();
panel.dataset.detailsBound = '1';
if (!detailsState) {
return;
}
panelDetailsRegistry.set(panel, detailsState);
};
const readStored = () => {

View File

@@ -0,0 +1,555 @@
import { initDetailActionPolicy } from './app-details-action-policy.js';
const DEFAULT_UNSAVED_MESSAGE = 'You have unsaved changes. Leave without saving?';
const toFormElement = (value) => {
if (!value) {return null;}
if (typeof value === 'string') {
return document.querySelector(value);
}
if (value instanceof HTMLFormElement) {
return value;
}
return null;
};
const escapeAttr = (value) => {
const raw = String(value || '');
if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') {
return CSS.escape(raw);
}
return raw.replace(/["\\]/g, '\\$&');
};
const normalizeFieldKey = (key) => String(key || '').replace(/\[\]$/, '');
const normalizeFieldValue = (value) => {
if (value instanceof File) {
return `[file:${value.name || ''}:${Number(value.size || 0)}]`;
}
return String(value ?? '');
};
const createFormStateMap = (form) => {
const data = new FormData(form);
const state = new Map();
data.forEach((value, key) => {
const normalizedKey = normalizeFieldKey(key);
const normalizedValue = normalizeFieldValue(value);
if (!state.has(normalizedKey)) {
state.set(normalizedKey, []);
}
state.get(normalizedKey).push(normalizedValue);
});
state.forEach((values) => values.sort());
return state;
};
const countChangedFields = (initialState, currentState) => {
const keys = new Set([...initialState.keys(), ...currentState.keys()]);
let changed = 0;
keys.forEach((key) => {
const initialValues = initialState.get(key) || [];
const currentValues = currentState.get(key) || [];
if (initialValues.length !== currentValues.length) {
changed += 1;
return;
}
for (let index = 0; index < initialValues.length; index += 1) {
if (initialValues[index] !== currentValues[index]) {
changed += 1;
return;
}
}
});
return changed;
};
const formatCountLabel = (template, count) => {
const normalizedTemplate = String(template || '').trim();
if (normalizedTemplate === '') {
return String(count);
}
if (normalizedTemplate.includes('{count}')) {
return normalizedTemplate.replace('{count}', String(count));
}
if (normalizedTemplate.includes('%d')) {
return normalizedTemplate.replace('%d', String(count));
}
return `${normalizedTemplate} (${count})`;
};
const belongsToForm = (el, form) => {
if (!(el instanceof HTMLElement)) {
return false;
}
const formAttr = (el.getAttribute('form') || '').trim();
if (formAttr !== '') {
const formId = (form.id || '').trim();
return formId !== '' && formAttr === formId;
}
return form.contains(el);
};
const findPrimarySubmitter = (form, titlebar, savePrimarySelector = '[data-detail-save-primary="1"]') => {
const preferred = [];
if (titlebar) {
preferred.push(...Array.from(titlebar.querySelectorAll(savePrimarySelector)));
}
preferred.push(...Array.from(form.querySelectorAll(savePrimarySelector)));
const formId = (form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
preferred.push(...Array.from(document.querySelectorAll(`${savePrimarySelector}[form="${escapedId}"]`)));
}
const unique = Array.from(new Set(preferred));
const firstPreferred = unique.find((el) => belongsToForm(el, form) && !el.disabled);
if (firstPreferred) {
return firstPreferred;
}
const fallback = [];
fallback.push(...Array.from(form.querySelectorAll('button[type="submit"], input[type="submit"]')));
if (formId !== '') {
const escapedId = escapeAttr(formId);
fallback.push(...Array.from(document.querySelectorAll(`button[type="submit"][form="${escapedId}"], input[type="submit"][form="${escapedId}"]`)));
}
return fallback.find((el) => belongsToForm(el, form) && !el.disabled) || null;
};
const resolveNamedControl = (form, fieldName) => {
const normalizedName = normalizeFieldKey(fieldName).trim();
if (normalizedName === '') {
return null;
}
const escapedName = escapeAttr(normalizedName);
const controls = [
...Array.from(form.querySelectorAll(`[name="${escapedName}"]`))
];
const formId = (form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
controls.push(...Array.from(document.querySelectorAll(`[name="${escapedName}"][form="${escapedId}"]`)));
}
return controls.find((el) => belongsToForm(el, form) && !el.disabled) || controls[0] || null;
};
const resolveNamedControls = (form, fieldName) => {
const normalizedName = normalizeFieldKey(fieldName).trim();
if (normalizedName === '') {
return [];
}
const escapedName = escapeAttr(normalizedName);
const controls = [
...Array.from(form.querySelectorAll(`[name="${escapedName}"]`))
];
const formId = (form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
controls.push(...Array.from(document.querySelectorAll(`[name="${escapedName}"][form="${escapedId}"]`)));
}
return Array.from(new Set(controls))
.filter((el) => el instanceof HTMLElement)
.filter((el) => belongsToForm(el, form));
};
const revealInDetailsTree = (target) => {
if (!(target instanceof HTMLElement)) {
return;
}
let node = target.parentElement;
while (node) {
if (node instanceof HTMLDetailsElement) {
node.open = true;
}
node = node.parentElement;
}
};
const resolveValidationTarget = (form, target = {}) => {
const targetId = String(target.fieldId || '').trim();
if (targetId !== '') {
const element = document.getElementById(targetId);
if (element && belongsToForm(element, form)) {
return element;
}
}
const targetName = String(target.fieldName || '').trim();
if (targetName !== '') {
const byName = resolveNamedControl(form, targetName);
if (byName) {
return byName;
}
}
const fallbackSelectors = [
'[aria-invalid="true"]',
'.is-invalid',
'input:invalid',
'select:invalid',
'textarea:invalid'
];
for (const selector of fallbackSelectors) {
const candidate = form.querySelector(selector);
if (candidate && !candidate.disabled) {
return candidate;
}
}
return null;
};
const focusValidationTarget = (target, summary = null) => {
if (!(target instanceof HTMLElement)) {
if (summary instanceof HTMLElement) {
summary.focus();
}
return false;
}
revealInDetailsTree(target);
const focusTarget = target.matches('input, select, textarea, button, [tabindex], a[href]')
? target
: target.querySelector('input, select, textarea, button, [tabindex], a[href]');
if (!(focusTarget instanceof HTMLElement)) {
if (summary instanceof HTMLElement) {
summary.focus();
}
return false;
}
try {
focusTarget.focus({ preventScroll: true });
} catch (error) {
focusTarget.focus();
}
if (typeof focusTarget.scrollIntoView === 'function') {
focusTarget.scrollIntoView({ block: 'center', inline: 'nearest' });
}
return true;
};
const initValidationSummaryExperience = (form, container, options = {}) => {
const summary = options.summary
|| container?.querySelector('[data-validation-summary]')
|| null;
if (!(summary instanceof HTMLElement)) {
return { summary: null, destroy: () => {} };
}
const autoFocusEnabled = options.autoFocus !== false && summary.dataset.validationSummaryAutofocus !== '0';
if (summary.id === '') {
const randomSuffix = Math.random().toString(36).slice(2, 10);
summary.id = `detail-validation-summary-${randomSuffix}`;
}
const applyValidationMarker = (target) => {
if (!(target instanceof HTMLElement)) {
return;
}
target.setAttribute('aria-invalid', 'true');
const summaryId = summary.id;
if (summaryId === '') {
return;
}
const describedBy = new Set(
String(target.getAttribute('aria-describedby') || '')
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean)
);
describedBy.add(summaryId);
target.setAttribute('aria-describedby', Array.from(describedBy).join(' '));
};
const resolveTargetsFromLink = (link) => {
const targetId = String(link?.dataset?.validationTargetId || '').trim();
if (targetId !== '') {
const target = resolveValidationTarget(form, { fieldId: targetId });
return target ? [target] : [];
}
const targetName = String(link?.dataset?.validationTargetName || '').trim();
if (targetName !== '') {
return resolveNamedControls(form, targetName);
}
return [];
};
const resolveTargetFromLink = (link) => resolveValidationTarget(form, {
fieldId: link?.dataset?.validationTargetId || '',
fieldName: link?.dataset?.validationTargetName || ''
});
const onSummaryClick = (event) => {
const trigger = event.target instanceof HTMLElement
? event.target.closest('[data-validation-error-link]')
: null;
if (!(trigger instanceof HTMLElement) || !summary.contains(trigger)) {
return;
}
event.preventDefault();
const target = resolveTargetFromLink(trigger);
if (!target) {
summary.focus();
return;
}
focusValidationTarget(target, summary);
};
summary.addEventListener('click', onSummaryClick);
const links = Array.from(summary.querySelectorAll('[data-validation-error-link]'));
links.forEach((link) => {
resolveTargetsFromLink(link).forEach(applyValidationMarker);
});
if (autoFocusEnabled) {
window.requestAnimationFrame(() => {
const firstLink = summary.querySelector('[data-validation-error-link]');
const target = firstLink
? resolveTargetFromLink(firstLink)
: resolveValidationTarget(form);
if (!focusValidationTarget(target, summary)) {
if (typeof summary.scrollIntoView === 'function') {
summary.scrollIntoView({ block: 'start', inline: 'nearest' });
}
}
});
}
return {
summary,
destroy: () => {
summary.removeEventListener('click', onSummaryClick);
}
};
};
export function initStandardDetailPage(options = {}) {
const form = toFormElement(options.form || options.formSelector || '[data-standard-detail-form]');
if (!form) {
return null;
}
if (form.dataset.standardDetailBound === '1' && form._standardDetailApi) {
return form._standardDetailApi;
}
const container = options.container
|| form.closest('.app-details-container')
|| document;
const titlebar = options.titlebar
|| container.querySelector('.app-details-titlebar');
const backLinkSelector = options.backLinkSelector || '.app-details-titlebar h1 a[href]';
const backLink = options.backLink
|| (titlebar ? titlebar.querySelector('h1 a[href]') : document.querySelector(backLinkSelector));
const savePrimarySelector = options.savePrimarySelector || '[data-detail-save-primary="1"]';
const actionPolicyEnabled = options.actionPolicy === false
? false
: (options.actionPolicy === true
? true
: titlebar?.getAttribute('data-detail-action-policy') === '1');
const actionPolicy = initDetailActionPolicy({
container,
enabled: actionPolicyEnabled
});
const unsavedMessage = String(
options.unsavedMessage
|| titlebar?.getAttribute('data-detail-unsaved-message')
|| DEFAULT_UNSAVED_MESSAGE
);
const unsavedIndicatorEnabled = options.unsavedIndicator !== false;
const unsavedIndicatorLabel = String(
options.unsavedIndicatorLabel
|| titlebar?.getAttribute('data-detail-unsaved-indicator-label')
|| 'Unsaved changes'
);
const unsavedIndicatorLabelOne = String(
options.unsavedIndicatorLabelOne
|| titlebar?.getAttribute('data-detail-unsaved-indicator-label-one')
|| ''
);
const unsavedIndicatorLabelMany = String(
options.unsavedIndicatorLabelMany
|| titlebar?.getAttribute('data-detail-unsaved-indicator-label-many')
|| ''
);
const titlebarActions = titlebar?.querySelector('.app-details-titlebar-actions') || null;
const primarySubmitter = findPrimarySubmitter(form, titlebar, savePrimarySelector);
const validationSummaryExperience = initValidationSummaryExperience(form, container, {
summary: options.validationSummary || null,
autoFocus: options.validationSummaryAutoFocus
});
let unsavedIndicator = titlebar?.querySelector('[data-detail-unsaved-indicator]') || null;
if (unsavedIndicatorEnabled && titlebarActions) {
if (!unsavedIndicator) {
unsavedIndicator = document.createElement('small');
unsavedIndicator.className = 'muted app-detail-unsaved-indicator';
unsavedIndicator.setAttribute('data-detail-unsaved-indicator', '1');
}
if (primarySubmitter && titlebarActions.contains(primarySubmitter)) {
titlebarActions.insertBefore(unsavedIndicator, primarySubmitter);
} else {
titlebarActions.prepend(unsavedIndicator);
}
}
let initialState = createFormStateMap(form);
let allowNextUnload = false;
const changedFieldCount = () => countChangedFields(initialState, createFormStateMap(form));
const isDirty = () => changedFieldCount() > 0;
const syncUnsavedIndicator = () => {
if (!unsavedIndicatorEnabled || !unsavedIndicator) {
return;
}
const count = changedFieldCount();
if (count <= 0) {
unsavedIndicator.hidden = true;
unsavedIndicator.textContent = '';
return;
}
const labelTemplate = count === 1
? (unsavedIndicatorLabelOne || unsavedIndicatorLabel)
: (unsavedIndicatorLabelMany || unsavedIndicatorLabel);
unsavedIndicator.hidden = false;
unsavedIndicator.textContent = formatCountLabel(labelTemplate, count);
};
const markClean = () => {
initialState = createFormStateMap(form);
syncUnsavedIndicator();
};
const shouldWarn = () => !actionPolicy.isSubmitting() && isDirty();
const onBeforeUnload = (event) => {
if (allowNextUnload) {
allowNextUnload = false;
return undefined;
}
if (!shouldWarn()) {
return undefined;
}
event.preventDefault();
event.returnValue = '';
return '';
};
const onBackLinkClick = (event) => {
if (!shouldWarn()) {
return;
}
const leaveWithoutSaving = window.confirm(unsavedMessage);
if (!leaveWithoutSaving) {
event.preventDefault();
return;
}
// Avoid a second native beforeunload prompt after explicit confirmation.
allowNextUnload = true;
};
const onFormStateChange = () => {
if (actionPolicy.isSubmitting()) {
return;
}
syncUnsavedIndicator();
};
const onFormReset = () => {
window.setTimeout(() => {
if (!actionPolicy.isSubmitting()) {
syncUnsavedIndicator();
}
}, 0);
};
let formObserver = null;
if (unsavedIndicatorEnabled && typeof MutationObserver !== 'undefined') {
// Observe dynamic form controls (for example MultiSelect hidden inputs).
formObserver = new MutationObserver(() => {
if (!actionPolicy.isSubmitting()) {
syncUnsavedIndicator();
}
});
formObserver.observe(form, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['name', 'value', 'checked', 'selected', 'disabled']
});
}
const onKeyDown = (event) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
return;
}
if (String(event.key || '').toLowerCase() !== 's') {
return;
}
event.preventDefault();
const submitter = findPrimarySubmitter(form, titlebar, savePrimarySelector);
if (submitter && typeof form.requestSubmit === 'function') {
form.requestSubmit(submitter);
return;
}
if (submitter) {
submitter.click();
return;
}
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
return;
}
form.submit();
};
window.addEventListener('beforeunload', onBeforeUnload);
form.addEventListener('input', onFormStateChange);
form.addEventListener('change', onFormStateChange);
form.addEventListener('reset', onFormReset);
document.addEventListener('keydown', onKeyDown);
if (backLink) {
backLink.addEventListener('click', onBackLinkClick);
}
syncUnsavedIndicator();
const destroy = () => {
window.removeEventListener('beforeunload', onBeforeUnload);
form.removeEventListener('input', onFormStateChange);
form.removeEventListener('change', onFormStateChange);
form.removeEventListener('reset', onFormReset);
document.removeEventListener('keydown', onKeyDown);
if (backLink) {
backLink.removeEventListener('click', onBackLinkClick);
}
if (formObserver) {
formObserver.disconnect();
}
actionPolicy.destroy();
validationSummaryExperience.destroy();
delete form._standardDetailApi;
delete form.dataset.standardDetailBound;
};
const api = {
form,
validationSummary: validationSummaryExperience.summary,
isDirty,
markClean,
destroy
};
form.dataset.standardDetailBound = '1';
form._standardDetailApi = api;
return api;
}
export function autoInitStandardDetailPages(root = document) {
if (!root || typeof root.querySelectorAll !== 'function') {
return [];
}
const forms = Array.from(root.querySelectorAll('[data-standard-detail-form]'));
return forms
.map((form) => initStandardDetailPage({ form }))
.filter(Boolean);
}

View File

@@ -0,0 +1,220 @@
const escapeAttr = (value) => {
const raw = String(value || '');
if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') {
return CSS.escape(raw);
}
return raw.replace(/["\\]/g, '\\$&');
};
const toContainerElement = (value) => {
if (!value) {
return document.querySelector('.app-details-container') || document.body || document.documentElement;
}
if (typeof value === 'string') {
return document.querySelector(value);
}
if (value instanceof HTMLElement) {
return value;
}
if (value === document) {
return document.documentElement;
}
return null;
};
const toFormElement = (value) => {
if (!value) {
return null;
}
if (value instanceof HTMLFormElement) {
return value;
}
if (typeof value === 'string') {
const element = document.querySelector(value);
return element instanceof HTMLFormElement ? element : null;
}
return null;
};
const normalizeSubmitLock = (value) => {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'none' ? 'none' : 'form';
};
const belongsToForm = (control, form) => {
if (!(control instanceof HTMLElement) || !(form instanceof HTMLFormElement)) {
return false;
}
const owner = control.form;
if (owner instanceof HTMLFormElement) {
return owner === form;
}
const controlFormAttr = String(control.getAttribute('form') || '').trim();
const formId = String(form.id || '').trim();
if (controlFormAttr !== '' && formId !== '') {
return controlFormAttr === formId;
}
return form.contains(control);
};
const isSubmitControl = (control) => {
if (!(control instanceof HTMLElement)) {
return false;
}
if (control instanceof HTMLButtonElement) {
const type = String(control.getAttribute('type') || 'submit').trim().toLowerCase();
return type === '' || type === 'submit';
}
if (control instanceof HTMLInputElement) {
const type = String(control.type || '').trim().toLowerCase();
return type === 'submit' || type === 'image';
}
return false;
};
const findSubmitControlsForForm = (form) => {
if (!(form instanceof HTMLFormElement)) {
return [];
}
const controls = [];
controls.push(
...Array.from(form.querySelectorAll('button, input')).filter(isSubmitControl)
);
const formId = String(form.id || '').trim();
if (formId !== '') {
const escapedId = escapeAttr(formId);
controls.push(
...Array.from(document.querySelectorAll(`button[form="${escapedId}"], input[form="${escapedId}"]`))
.filter(isSubmitControl)
);
}
return Array.from(new Set(controls)).filter((control) => belongsToForm(control, form));
};
const pickSubmitter = (event, form, submitterByForm) => {
const eventSubmitter = event.submitter instanceof HTMLElement ? event.submitter : null;
if (eventSubmitter && belongsToForm(eventSubmitter, form)) {
return eventSubmitter;
}
const fallback = submitterByForm.get(form);
if (fallback && belongsToForm(fallback, form)) {
return fallback;
}
return null;
};
const readConfirmMessage = (submitter, form) => {
const fromSubmitter = String(submitter?.getAttribute('data-detail-confirm-message') || '').trim();
if (fromSubmitter !== '') {
return fromSubmitter;
}
return String(form.getAttribute('data-detail-confirm-message') || '').trim();
};
const readSubmitLockMode = (submitter, form) => normalizeSubmitLock(
submitter?.getAttribute('data-detail-submit-lock')
|| form.getAttribute('data-detail-submit-lock')
|| 'form'
);
const disableSubmitControlsForForm = (form, submitter, options = {}) => {
const controls = findSubmitControlsForForm(form);
controls.forEach((control) => {
control.disabled = true;
});
if (submitter instanceof HTMLElement) {
submitter.setAttribute('aria-busy', 'true');
}
if (options.markFormBusy === true) {
form.setAttribute('aria-busy', 'true');
}
};
export function initDetailActionPolicy(options = {}) {
const enabled = options.enabled !== false;
if (!enabled) {
return {
isSubmitting: () => false,
destroy: () => {}
};
}
const container = toContainerElement(options.container);
if (!(container instanceof HTMLElement)) {
return {
isSubmitting: () => false,
destroy: () => {}
};
}
if (container._detailActionPolicyApi) {
return container._detailActionPolicyApi;
}
const submittingForms = new Set();
const submitterByForm = new Map();
const onClickCapture = (event) => {
const target = event.target instanceof HTMLElement
? event.target.closest('button, input')
: null;
if (!(target instanceof HTMLElement) || !isSubmitControl(target) || !container.contains(target)) {
return;
}
const form = target.form instanceof HTMLFormElement ? target.form : null;
if (!(form instanceof HTMLFormElement)) {
return;
}
submitterByForm.set(form, target);
};
const onSubmitCapture = (event) => {
const form = event.target instanceof HTMLFormElement ? event.target : null;
if (!(form instanceof HTMLFormElement) || !container.contains(form)) {
return;
}
const submitter = pickSubmitter(event, form, submitterByForm);
const lockMode = readSubmitLockMode(submitter, form);
if (lockMode !== 'none' && submittingForms.has(form)) {
event.preventDefault();
return;
}
const confirmMessage = readConfirmMessage(submitter, form);
if (confirmMessage !== '' && !window.confirm(confirmMessage)) {
event.preventDefault();
return;
}
if (lockMode === 'none') {
return;
}
submittingForms.add(form);
disableSubmitControlsForForm(form, submitter, { markFormBusy: options.markFormBusy === true });
};
container.addEventListener('click', onClickCapture, true);
container.addEventListener('submit', onSubmitCapture, true);
const api = {
isSubmitting: (formRef = null) => {
const form = toFormElement(formRef);
if (form) {
return submittingForms.has(form);
}
return submittingForms.size > 0;
},
destroy: () => {
container.removeEventListener('click', onClickCapture, true);
container.removeEventListener('submit', onSubmitCapture, true);
submittingForms.clear();
submitterByForm.clear();
delete container._detailActionPolicyApi;
}
};
container._detailActionPolicyApi = api;
return api;
}

View File

@@ -0,0 +1,227 @@
const toElement = (value) => {
if (value instanceof HTMLElement) {
return value;
}
if (typeof value === 'string') {
return document.querySelector(value);
}
return null;
};
const normalizeKeys = (value) => Array.from(new Set(
(Array.isArray(value) ? value : [])
.map((key) => String(key || '').trim())
.filter(Boolean)
));
const readStoredKeys = (storageKey) => {
if (!storageKey) {
return [];
}
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return normalizeKeys(parsed);
} catch (error) {
return [];
}
};
const writeStoredKeys = (storageKey, keys) => {
if (!storageKey) {
return;
}
try {
window.localStorage.setItem(storageKey, JSON.stringify(normalizeKeys(keys)));
} catch (error) {
// ignore storage errors
}
};
const resolveEnsureKeys = (value) => {
if (typeof value === 'function') {
return normalizeKeys(value());
}
return normalizeKeys(value);
};
export function initPersistedDetailsGroup(options = {}) {
const root = toElement(options.root || options.group || null);
if (!root) {
return null;
}
const storageKey = String(options.storageKey || '').trim();
if (storageKey === '') {
return null;
}
const detailsSelector = String(options.detailsSelector || 'details[data-details-key]').trim();
const alwaysOpenSelector = String(options.alwaysOpenSelector || 'details[data-details-always-open]').trim();
const ensureOpenKeysSource = options.ensureOpenKeys ?? [];
const managedDetails = Array.from(root.querySelectorAll(detailsSelector))
.filter((details) => details instanceof HTMLDetailsElement);
const alwaysOpenDetails = Array.from(root.querySelectorAll(alwaysOpenSelector))
.filter((details) => details instanceof HTMLDetailsElement);
if (!managedDetails.length && !alwaysOpenDetails.length) {
return null;
}
const detailsByKey = new Map();
managedDetails.forEach((details) => {
const key = String(details.dataset.detailsKey || '').trim();
if (key !== '' && !detailsByKey.has(key)) {
detailsByKey.set(key, details);
}
});
let isInternalSync = false;
const withInternalSync = (callback) => {
isInternalSync = true;
try {
callback();
} finally {
isInternalSync = false;
}
};
const applyAlwaysOpen = () => {
withInternalSync(() => {
alwaysOpenDetails.forEach((details) => {
details.open = true;
});
});
};
const collectOpenKeys = () => managedDetails
.filter((details) => details.open)
.map((details) => String(details.dataset.detailsKey || '').trim())
.filter(Boolean);
const mergeOpenKeys = (keys) => {
const merged = new Set(normalizeKeys(keys));
resolveEnsureKeys(ensureOpenKeysSource).forEach((key) => merged.add(key));
alwaysOpenDetails.forEach((details) => {
const key = String(details.dataset.detailsKey || '').trim();
if (key !== '') {
merged.add(key);
}
});
return Array.from(merged);
};
const writeOpenKeys = (keys) => {
writeStoredKeys(storageKey, mergeOpenKeys(keys));
};
const persistCurrentState = () => {
writeOpenKeys(collectOpenKeys());
};
const ensureOpen = (keys) => {
const normalizedKeys = normalizeKeys(keys);
if (!normalizedKeys.length) {
return;
}
withInternalSync(() => {
normalizedKeys.forEach((key) => {
const details = detailsByKey.get(key);
if (details) {
details.open = true;
}
});
});
};
const apply = () => {
const storedKeys = readStoredKeys(storageKey);
const ensureKeys = resolveEnsureKeys(ensureOpenKeysSource);
if (storedKeys.length > 0) {
const nextOpenSet = new Set(mergeOpenKeys(storedKeys));
withInternalSync(() => {
managedDetails.forEach((details) => {
const key = String(details.dataset.detailsKey || '').trim();
if (key !== '') {
details.open = nextOpenSet.has(key);
}
});
});
} else if (ensureKeys.length > 0) {
ensureOpen(ensureKeys);
}
applyAlwaysOpen();
persistCurrentState();
};
const onManagedToggle = (event) => {
if (isInternalSync) {
return;
}
const target = event.currentTarget instanceof HTMLDetailsElement ? event.currentTarget : null;
if (!target) {
return;
}
const key = String(target.dataset.detailsKey || '').trim();
if (key !== '' && resolveEnsureKeys(ensureOpenKeysSource).includes(key) && !target.open) {
withInternalSync(() => {
target.open = true;
});
}
applyAlwaysOpen();
persistCurrentState();
};
const onAlwaysOpenToggle = (event) => {
if (isInternalSync) {
return;
}
const target = event.currentTarget instanceof HTMLDetailsElement ? event.currentTarget : null;
if (!target) {
return;
}
if (!target.open) {
withInternalSync(() => {
target.open = true;
});
}
persistCurrentState();
};
managedDetails.forEach((details) => {
details.addEventListener('toggle', onManagedToggle);
});
alwaysOpenDetails
.filter((details, index, list) => list.indexOf(details) === index)
.forEach((details) => {
details.addEventListener('toggle', onAlwaysOpenToggle);
});
apply();
return {
apply,
destroy: () => {
managedDetails.forEach((details) => {
details.removeEventListener('toggle', onManagedToggle);
});
alwaysOpenDetails
.filter((details, index, list) => list.indexOf(details) === index)
.forEach((details) => {
details.removeEventListener('toggle', onAlwaysOpenToggle);
});
},
readOpenKeys: () => readStoredKeys(storageKey),
writeOpenKeys,
};
}

View File

@@ -1,4 +1,7 @@
import { warnOnce } from './app-dom.js';
import { gridFiltersFromSchema } from '../pages/app-list-utils.js';
import { initListFilterExperience } from '../pages/app-list-filter-experience.js';
import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js';
export function createServerGrid(options) {
const {
@@ -13,6 +16,7 @@ export function createServerGrid(options) {
language = {},
search = null,
filters = [],
filterBindingMode = 'live',
urlSync = true,
urlState = null,
gridjs,
@@ -22,6 +26,10 @@ export function createServerGrid(options) {
selection = null
} = options || {};
const normalizedFilterBindingMode = String(filterBindingMode || 'live').trim().toLowerCase() === 'manual'
? 'manual'
: 'live';
const gridjsLib = gridjs || window.gridjs;
const containerEl = typeof container === 'string' ? document.querySelector(container) : container;
if (!containerEl || !gridjsLib) {
@@ -161,13 +169,26 @@ export function createServerGrid(options) {
return value;
};
const resolveFilterRawValue = (filter) => {
if (!filter) {
return '';
}
if (normalizedFilterBindingMode === 'manual') {
return filter.value ?? '';
}
if (!filter.input) {
return filter.value ?? '';
}
return getInputValue(filter.input);
};
const buildDataParams = () => {
const query = new URLSearchParams();
if (searchValue) {
query.set(searchParam, searchValue);
}
filterState.forEach((filter) => {
const raw = getInputValue(filter.input) || filter.value || '';
const raw = resolveFilterRawValue(filter);
const normalized = normalizeValue(filter, raw);
if (Array.isArray(normalized)) {
const list = normalized.map((item) => item.toString()).filter(Boolean);
@@ -212,8 +233,11 @@ export function createServerGrid(options) {
return url.toString();
};
const updateUrl = () => {
const updateUrl = (options = {}) => {
if (!urlSync) {return;}
const historyMode = String(options?.historyMode || 'replace').trim().toLowerCase() === 'push'
? 'push'
: 'replace';
const url = new URL(window.location.href);
if (searchParam) { url.searchParams.delete(searchParam); }
filterState.forEach((filter) => url.searchParams.delete(filter.param));
@@ -222,6 +246,10 @@ export function createServerGrid(options) {
url.searchParams.delete(pageParam);
const query = buildUrlParams();
query.forEach((value, key) => url.searchParams.set(key, value));
if (historyMode === 'push') {
window.history.pushState({}, '', url.toString());
return;
}
window.history.replaceState({}, '', url.toString());
};
@@ -588,17 +616,31 @@ export function createServerGrid(options) {
const updateGrid = (options = {}) => {
const resetPage = options && options.resetPage === true;
const urlHistoryMode = options?.urlHistoryMode || 'replace';
if (resetPage) {
currentPage = 1;
}
updateUrl();
updateUrl({ historyMode: urlHistoryMode });
const nextConfig = {
server: { ...serverConfig, url: baseUrl() }
};
if (resetPage) {
nextConfig.pagination = { ...paginationConfig, page: 0 };
if (resetPage && grid?.config?.pagination && typeof grid.config.pagination === 'object') {
nextConfig.pagination = { ...grid.config.pagination, page: 0 };
}
grid.updateConfig(nextConfig).forceRender();
// Use Grid.js reconfiguration API so server pipeline is refreshed reliably on filter/search changes.
if (typeof grid?.updateConfig === 'function') {
grid.updateConfig(nextConfig).forceRender();
return;
}
if (grid?.config) {
grid.config.server = nextConfig.server;
if (nextConfig.pagination) {
grid.config.pagination = nextConfig.pagination;
}
}
grid.forceRender();
};
const debounce = (fn, delay = 250) => {
@@ -616,17 +658,41 @@ export function createServerGrid(options) {
}, searchDebounce));
}
filterState.forEach((filter) => {
if (!filter.input) {return;}
const eventName = filter.event || 'change';
filter.input.addEventListener(eventName, () => {
filter.value = getInputValue(filter.input);
updateGrid({ resetPage: true });
if (normalizedFilterBindingMode === 'live') {
filterState.forEach((filter) => {
if (!filter.input) {return;}
const eventName = filter.event || 'change';
filter.input.addEventListener(eventName, () => {
filter.value = getInputValue(filter.input);
updateGrid({ resetPage: true });
});
});
});
}
const resetFilters = () => {
if (search) {
const commitFilterInputValues = () => {
filterState.forEach((filter) => {
if (!filter.input) {return;}
filter.value = getInputValue(filter.input);
});
};
const applyFilters = (options = {}) => {
const resetPage = options?.resetPage !== false;
const urlHistoryMode = options?.urlHistoryMode || 'replace';
commitFilterInputValues();
updateGrid({ resetPage, urlHistoryMode });
};
const resetFilters = (options = {}) => {
const preserveFilterParams = Array.isArray(options?.preserveFilterParams)
? options.preserveFilterParams.map((item) => String(item ?? '').trim()).filter(Boolean)
: [];
const preserveFilterParamSet = new Set(preserveFilterParams);
const preserveSearch = options?.preserveSearch === true;
const resetPage = options?.resetPage !== false;
const urlHistoryMode = options?.urlHistoryMode || 'replace';
if (search && !preserveSearch) {
const defaultValue = search.default ?? '';
searchValue = defaultValue;
if (searchInput) {
@@ -634,13 +700,89 @@ export function createServerGrid(options) {
}
}
filterState.forEach((filter) => {
if (preserveFilterParamSet.has(filter.param)) {
if (filter.input) {
filter.value = getInputValue(filter.input);
}
return;
}
const defaultValue = filter.default ?? '';
filter.value = defaultValue;
if (filter.input) {
setInputValue(filter.input, defaultValue);
}
});
updateGrid({ resetPage: true });
updateGrid({ resetPage, urlHistoryMode });
};
const captureAppliedState = () => {
const filterValues = {};
filterState.forEach((filter) => {
filterValues[filter.param] = filter.value ?? filter.default ?? '';
});
return {
search: searchValue,
filters: filterValues,
sort: currentSort ? { ...currentSort } : null,
page: currentPage
};
};
const captureDraftState = () => {
const filterValues = {};
filterState.forEach((filter) => {
const raw = filter.input ? getInputValue(filter.input) : (filter.value ?? filter.default ?? '');
filterValues[filter.param] = raw;
});
const draftSearch = searchInput
? String(getInputValue(searchInput) ?? '').trim()
: searchValue;
return {
search: draftSearch,
filters: filterValues,
sort: currentSort ? { ...currentSort } : null,
page: currentPage
};
};
const restoreAppliedState = (state = {}) => {
if (search) {
const nextSearch = typeof state?.search === 'string' ? state.search : (search.default ?? '');
searchValue = nextSearch;
if (searchInput) {
setInputValue(searchInput, nextSearch);
}
}
const nextFilters = state?.filters && typeof state.filters === 'object'
? state.filters
: {};
filterState.forEach((filter) => {
const nextValue = Object.prototype.hasOwnProperty.call(nextFilters, filter.param)
? nextFilters[filter.param]
: (filter.default ?? '');
filter.value = nextValue;
if (filter.input) {
setInputValue(filter.input, nextValue);
}
});
if (state?.sort && typeof state.sort === 'object') {
const order = String(state.sort.order ?? '').trim();
const dir = normalizeDir(state.sort.dir ?? '');
if (order !== '' && dir !== '' && validSortKeys.has(order)) {
currentSort = { order, dir };
} else {
currentSort = null;
}
} else if (state?.sort === null) {
currentSort = null;
}
if (Number.isFinite(Number(state?.page))) {
const parsedPage = parsePage(state.page);
currentPage = parsedPage;
}
};
const getSortParams = () => (currentSort ? { ...currentSort } : null);
@@ -704,5 +846,193 @@ export function createServerGrid(options) {
}, 0);
});
return { grid, container: containerEl, updateGrid, updateUrl, baseUrl, resolveUrl, getSortParams, resetFilters, selection: selectionApi };
return {
grid,
container: containerEl,
updateGrid,
updateUrl,
baseUrl,
resolveUrl,
getSortParams,
resetFilters,
applyFilters,
captureAppliedState,
captureDraftState,
restoreAppliedState,
selection: selectionApi
};
}
const DEFAULT_LIST_FILTER_LABELS = {
chipsClearAll: 'Clear all filters',
chipsRemove: 'Remove filter',
filtersApplied: 'Filters applied',
filterRemoved: 'Filter removed',
filtersCleared: 'All filters cleared'
};
const resolveDomQueryElement = (target, root = document) => {
if (!target) {
return null;
}
if (typeof target === 'string') {
if (root && typeof root.querySelector === 'function') {
return root.querySelector(target) || document.querySelector(target);
}
return document.querySelector(target);
}
return target;
};
const readDomLabelDefaults = (options = {}) => {
const drawerRoot = options.drawerRoot && typeof options.drawerRoot.querySelector === 'function'
? options.drawerRoot
: document;
const chipsSelector = options.chipsSelector || '[data-active-filter-chips]';
const liveSelector = options.liveSelector || '[data-filter-live-region]';
const chipsEl = resolveDomQueryElement(chipsSelector, drawerRoot);
const liveEl = resolveDomQueryElement(liveSelector, drawerRoot);
const chipsClearAll = String(chipsEl?.dataset?.filterChipsClearAllLabel || '').trim();
const chipsRemove = String(chipsEl?.dataset?.filterChipsRemoveLabel || '').trim();
const filtersApplied = String(liveEl?.dataset?.filterLiveAppliedMessage || '').trim();
const filterRemoved = String(liveEl?.dataset?.filterLiveRemovedMessage || '').trim();
const filtersCleared = String(liveEl?.dataset?.filterLiveClearedMessage || '').trim();
return {
...(chipsClearAll !== '' ? { chipsClearAll } : {}),
...(chipsRemove !== '' ? { chipsRemove } : {}),
...(filtersApplied !== '' ? { filtersApplied } : {}),
...(filterRemoved !== '' ? { filterRemoved } : {}),
...(filtersCleared !== '' ? { filtersCleared } : {})
};
};
const normalizeListMode = (value) => {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'drawer' || mode === 'search-only' || mode === 'none') {
return mode;
}
return 'drawer';
};
/**
* Standardized list-page bootstrap:
* - builds grid filters from schema (or uses explicit filters)
* - initializes server grid
* - optionally wires drawer/chip filter experience
*/
export function initStandardListPage(options = {}) {
const gridOptions = options?.grid && typeof options.grid === 'object'
? options.grid
: {};
const filterOptions = options?.filters && typeof options.filters === 'object'
? options.filters
: {};
const hooks = options?.hooks && typeof options.hooks === 'object'
? options.hooks
: {};
const mode = normalizeListMode(filterOptions.mode);
const filterSchema = Array.isArray(gridOptions.filterSchema) ? gridOptions.filterSchema : [];
const extraFilters = Array.isArray(gridOptions.extraFilters) ? gridOptions.extraFilters : [];
const resolvedFilters = Array.isArray(gridOptions.filters)
? gridOptions.filters
: [...gridFiltersFromSchema(filterSchema), ...extraFilters];
const hasExplicitBindingMode = Object.prototype.hasOwnProperty.call(gridOptions, 'filterBindingMode');
const defaultBindingMode = mode === 'drawer' ? 'manual' : 'live';
const createOptions = {
...gridOptions,
filters: resolvedFilters,
filterBindingMode: hasExplicitBindingMode ? gridOptions.filterBindingMode : defaultBindingMode
};
delete createOptions.filterSchema;
delete createOptions.extraFilters;
const gridConfig = createServerGrid(createOptions);
if (typeof hooks.onGridReady === 'function') {
hooks.onGridReady({ gridConfig, mode });
}
let filterExperience = null;
if (mode === 'drawer' && gridConfig) {
const chipMeta = filterOptions.chipMeta && typeof filterOptions.chipMeta === 'object'
? filterOptions.chipMeta
: {};
const drawerConfig = filterOptions.drawer && typeof filterOptions.drawer === 'object'
? filterOptions.drawer
: {};
const liveConfig = filterOptions.live && typeof filterOptions.live === 'object'
? filterOptions.live
: {};
const chipUiConfig = filterOptions.chips && typeof filterOptions.chips === 'object'
? filterOptions.chips
: {};
const drawerRoot = filterOptions.drawerRoot || document;
const domLabels = readDomLabelDefaults({
drawerRoot,
chipsSelector: chipUiConfig.container || '[data-active-filter-chips]',
liveSelector: liveConfig.regionSelector || '[data-filter-live-region]'
});
const labels = {
...DEFAULT_LIST_FILTER_LABELS,
...domLabels,
...(filterOptions.labels && typeof filterOptions.labels === 'object' ? filterOptions.labels : {})
};
const preserveFilterParamsOnReset = Array.isArray(filterOptions.preserveFilterParamsOnReset)
? filterOptions.preserveFilterParamsOnReset
: [];
const watchInputs = Array.isArray(filterOptions.watchInputs)
? filterOptions.watchInputs
: [];
const clearMetaOptions = filterOptions.clearMetaOptions && typeof filterOptions.clearMetaOptions === 'object'
? filterOptions.clearMetaOptions
: {};
const buildChips = typeof filterOptions.buildChips === 'function'
? filterOptions.buildChips
: (state = {}) => buildChipsFromMeta(state, chipMeta);
const removeChip = typeof filterOptions.removeChip === 'function'
? filterOptions.removeChip
: (chip, state = {}) => removeChipFromMetaState(chip, state, chipMeta);
const clearAllState = typeof filterOptions.clearAllState === 'function'
? filterOptions.clearAllState
: (state = {}) => clearMetaState(state, chipMeta, clearMetaOptions);
filterExperience = initListFilterExperience({
gridConfig,
drawerRoot,
drawer: drawerConfig,
live: {
regionSelector: '[data-filter-live-region]',
appliedMessage: labels.filtersApplied,
removedMessage: labels.filterRemoved,
clearedMessage: labels.filtersCleared,
...liveConfig
},
chips: {
container: '[data-active-filter-chips]',
clearAllLabel: labels.chipsClearAll,
removeButtonLabel: labels.chipsRemove,
...chipUiConfig
},
preserveFilterParamsOnReset,
buildChips,
removeChip,
clearAllState,
watchInputs
});
}
if (typeof hooks.onFilterExperienceReady === 'function') {
hooks.onFilterExperienceReady({ gridConfig, filterExperience, mode });
}
return {
gridConfig,
filterExperience
};
}

View File

@@ -0,0 +1,301 @@
import { initFilterDrawer } from '../components/app-filter-drawer.js';
import { initActiveFilterChips } from '../components/app-active-filter-chips.js';
const debounce = (fn, delay = 250) => {
let timer = null;
return (...args) => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => fn(...args), delay);
};
};
export function initListFilterExperience(options = {}) {
const {
gridConfig,
drawerRoot = document,
drawer = {},
live = {},
chips = {},
preserveFilterParamsOnReset = [],
buildChips = () => [],
removeChip = null,
clearAllState = null,
watchInputs = []
} = options;
if (!gridConfig || typeof gridConfig.captureAppliedState !== 'function') {
return null;
}
let openSnapshot = null;
let drawerDirtyDebounce = null;
const announceUpdated = () => {
document.dispatchEvent(new CustomEvent('app:filters-updated'));
};
const liveRegion = typeof live.regionSelector === 'string'
? document.querySelector(live.regionSelector)
: null;
const announceLive = (message) => {
if (!liveRegion) {return;}
const text = String(message || '').trim();
if (text === '') {return;}
liveRegion.textContent = '';
requestAnimationFrame(() => {
liveRegion.textContent = text;
});
};
const normalizeComparable = (value) => {
if (Array.isArray(value)) {
return value
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.sort()
.join('|');
}
return String(value ?? '').trim();
};
const countDraftChanges = (appliedState, draftState) => {
const appliedFilters = appliedState?.filters && typeof appliedState.filters === 'object'
? appliedState.filters
: {};
const draftFilters = draftState?.filters && typeof draftState.filters === 'object'
? draftState.filters
: {};
let changed = 0;
if (normalizeComparable(appliedState?.search || '') !== normalizeComparable(draftState?.search || '')) {
changed += 1;
}
const keys = new Set([...Object.keys(appliedFilters), ...Object.keys(draftFilters)]);
keys.forEach((key) => {
if (normalizeComparable(appliedFilters[key] ?? '') !== normalizeComparable(draftFilters[key] ?? '')) {
changed += 1;
}
});
return changed;
};
const countActiveDraftFilters = (draftState) => {
const filters = draftState?.filters && typeof draftState.filters === 'object'
? draftState.filters
: {};
const ignoredParams = new Set(
Array.isArray(drawer?.countExcludeParams)
? drawer.countExcludeParams.map((item) => String(item ?? '').trim()).filter(Boolean)
: []
);
let count = 0;
Object.entries(filters).forEach(([param, rawValue]) => {
if (ignoredParams.has(param)) {return;}
if (Array.isArray(rawValue)) {
count += rawValue
.map((value) => String(value ?? '').trim())
.filter(Boolean)
.length;
return;
}
const value = String(rawValue ?? '').trim();
if (value === '' || value === 'all') {return;}
if (value.includes(',')) {
count += value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.length;
return;
}
count += 1;
});
return count;
};
const chipsController = initActiveFilterChips({
container: chips.container,
removeButtonLabel: chips.removeButtonLabel || 'Remove filter',
clearAllLabel: chips.clearAllLabel || 'Clear all filters',
onRemove: async (chip) => {
if (typeof removeChip !== 'function') {return;}
const current = gridConfig.captureAppliedState();
const next = removeChip(chip, current);
if (!next || typeof next !== 'object') {return;}
gridConfig.restoreAppliedState(next);
if (typeof gridConfig.applyFilters === 'function') {
gridConfig.applyFilters({ resetPage: true, urlHistoryMode: 'push' });
} else {
gridConfig.updateGrid({ resetPage: true, urlHistoryMode: 'push' });
}
announceUpdated();
announceLive(live.removeMessage || '');
renderChips();
},
onClearAll: async () => {
if (typeof clearAllState === 'function') {
const current = gridConfig.captureAppliedState();
const next = clearAllState(current);
if (next && typeof next === 'object') {
gridConfig.restoreAppliedState(next);
if (typeof gridConfig.applyFilters === 'function') {
gridConfig.applyFilters({ resetPage: true, urlHistoryMode: 'push' });
} else {
gridConfig.updateGrid({ resetPage: true, urlHistoryMode: 'push' });
}
announceUpdated();
announceLive(live.clearedMessage || '');
renderChips();
}
return;
}
if (typeof gridConfig.resetFilters === 'function') {
gridConfig.resetFilters({
resetPage: true,
urlHistoryMode: 'push',
preserveFilterParams: preserveFilterParamsOnReset
});
announceUpdated();
announceLive(live.clearedMessage || '');
renderChips();
}
}
});
const renderChips = () => {
const state = gridConfig.captureAppliedState();
if (chipsController) {
chipsController.render(buildChips(state));
}
if (drawerController && typeof drawerController.setTriggerCount === 'function') {
drawerController.setTriggerCount(countActiveDraftFilters(state));
}
};
const getDrawerDraftState = () => {
if (typeof gridConfig.captureDraftState === 'function') {
return gridConfig.captureDraftState();
}
return gridConfig.captureAppliedState();
};
const applyDirtyUi = (immediate = false) => {
if (!drawerController || typeof drawerController.setApplyEnabled !== 'function') {
return;
}
const run = () => {
const baseState = openSnapshot || gridConfig.captureAppliedState();
const draftState = getDrawerDraftState();
const changedCount = countDraftChanges(baseState, draftState);
const activeCount = countActiveDraftFilters(draftState);
drawerController.setApplyEnabled(changedCount > 0);
if (typeof drawerController.setApplyCount === 'function') {
drawerController.setApplyCount(activeCount);
}
};
if (immediate) {
run();
return;
}
if (drawerDirtyDebounce) {
clearTimeout(drawerDirtyDebounce);
}
drawerDirtyDebounce = setTimeout(run, 120);
};
const drawerController = initFilterDrawer({
root: drawerRoot,
drawerSelector: drawer.drawerSelector || '[data-filter-drawer]',
openSelector: drawer.openSelector || '[data-filter-drawer-open]',
closeSelector: drawer.closeSelector || '[data-filter-drawer-close]',
applySelector: drawer.applySelector || '[data-filter-drawer-apply]',
resetSelector: drawer.resetSelector || '[data-filter-drawer-reset]',
onOpen: () => {
openSnapshot = gridConfig.captureAppliedState();
gridConfig.restoreAppliedState(openSnapshot);
applyDirtyUi(true);
},
onDiscard: () => {
if (!openSnapshot) {return;}
gridConfig.restoreAppliedState(openSnapshot);
if (typeof drawerController?.setApplyEnabled === 'function') {
drawerController.setApplyEnabled(false);
}
if (typeof drawerController?.setApplyCount === 'function') {
drawerController.setApplyCount(0);
}
openSnapshot = null;
},
onApply: async () => {
const baseState = openSnapshot || gridConfig.captureAppliedState();
const draftState = getDrawerDraftState();
const changedCount = countDraftChanges(baseState, draftState);
if (changedCount <= 0) {
applyDirtyUi(true);
return false;
}
if (typeof gridConfig.applyFilters === 'function') {
gridConfig.applyFilters({ resetPage: true, urlHistoryMode: 'push' });
} else {
gridConfig.updateGrid({ resetPage: true, urlHistoryMode: 'push' });
}
openSnapshot = null;
announceUpdated();
announceLive(live.appliedMessage || '');
renderChips();
return true;
},
onReset: async () => {
if (typeof gridConfig.resetFilters !== 'function') {return;}
gridConfig.resetFilters({
resetPage: true,
urlHistoryMode: 'push',
preserveFilterParams: preserveFilterParamsOnReset
});
openSnapshot = gridConfig.captureAppliedState();
announceUpdated();
renderChips();
}
});
if (drawerController?.drawer) {
const drawerFields = Array.from(
drawerController.drawer.querySelectorAll('input, select, textarea')
);
drawerFields.forEach((field) => {
if (field.dataset.drawerDirtyBound === '1') {return;}
field.dataset.drawerDirtyBound = '1';
field.addEventListener('input', () => applyDirtyUi(false));
field.addEventListener('change', () => applyDirtyUi(false));
});
}
watchInputs
.map((input) => (typeof input === 'string' ? document.querySelector(input) : input))
.filter(Boolean)
.forEach((input) => {
input.addEventListener('input', debounce(() => {
renderChips();
}, 300));
input.addEventListener('change', () => {
renderChips();
});
});
document.addEventListener('app:filters-updated', () => {
renderChips();
});
renderChips();
return {
drawer: drawerController,
renderChips
};
}

View File

@@ -0,0 +1,258 @@
const normalizeCsv = (value) => {
if (Array.isArray(value)) {
return value
.map((item) => String(item ?? '').trim())
.filter(Boolean);
}
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
};
const normalizeDefault = (value) => {
if (Array.isArray(value)) {
return value
.map((item) => String(item ?? '').trim())
.filter(Boolean)
.join(',');
}
return String(value ?? '').trim();
};
const normalizeEntries = (meta = {}) => {
const entries = [];
if (!meta || typeof meta !== 'object') {
return entries;
}
Object.entries(meta).forEach(([key, value]) => {
if (key === 'custom_fields' && Array.isArray(value)) {
value.forEach((custom, index) => {
if (!custom || typeof custom !== 'object') {return;}
entries.push({
key: String(custom.key || custom.param || `custom_${index}`),
...custom
});
});
return;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return;
}
entries.push({
key,
...value
});
});
return entries;
};
const findMetaEntry = (meta = {}, chip = {}) => {
const entries = normalizeEntries(meta);
const chipParam = String(chip?.param || '').trim();
const chipMeta = String(chip?.meta || '').trim();
if (chipMeta.startsWith('date_range:')) {
const [, fromParam, toParam] = chipMeta.split(':');
return entries.find((entry) => (
String(entry?.type || '').toLowerCase() === 'date_range'
&& String(entry?.from_param || '') === String(fromParam || '')
&& String(entry?.to_param || '') === String(toParam || '')
)) || null;
}
return entries.find((entry) => {
const type = String(entry?.type || '').toLowerCase();
const entryParam = String(entry?.param || entry?.key || '').trim();
if (chipParam !== '' && entryParam === chipParam) {
return true;
}
if (type !== 'date_range') {
return false;
}
return (
String(entry?.from_param || '').trim() === chipParam
|| String(entry?.to_param || '').trim() === chipParam
);
}) || null;
};
const resolveLabel = (entry = {}, rawValue = '') => {
const value = String(rawValue ?? '').trim();
if (value === '') {
return value;
}
const options = entry?.options && typeof entry.options === 'object'
? entry.options
: {};
return String(options[value] ?? value);
};
export const buildChipsFromMeta = (state = {}, meta = {}) => {
const chips = [];
const filters = state?.filters && typeof state.filters === 'object'
? state.filters
: {};
normalizeEntries(meta).forEach((entry) => {
const type = String(entry?.type || 'text').toLowerCase();
const label = String(entry?.label || entry?.key || '').trim();
if (label === '') {
return;
}
if (type === 'date_range') {
const fromParam = String(entry?.from_param || '').trim();
const toParam = String(entry?.to_param || '').trim();
if (fromParam === '' || toParam === '') {
return;
}
const fromValue = String(filters[fromParam] ?? '').trim();
const toValue = String(filters[toParam] ?? '').trim();
if (fromValue === '' && toValue === '') {
return;
}
const rangeLabel = [fromValue, toValue].filter(Boolean).join(' -> ');
chips.push({
id: `${fromParam}:${toParam}`,
param: fromParam,
value: rangeLabel,
valueType: 'range',
meta: `date_range:${fromParam}:${toParam}`,
label: `${label}: ${rangeLabel}`
});
return;
}
const param = String(entry?.param || entry?.key || '').trim();
if (param === '') {
return;
}
if (param === 'search') {
const searchValue = String(state?.search ?? '').trim();
if (searchValue === '') {
return;
}
chips.push({
id: 'search',
param: 'search',
value: searchValue,
valueType: 'scalar',
label: `${label}: ${searchValue}`
});
return;
}
if (type === 'multi_csv') {
normalizeCsv(filters[param]).forEach((value) => {
const mapped = resolveLabel(entry, value);
chips.push({
id: `${param}:${value}`,
param,
value,
valueType: 'multi',
label: `${label}: ${mapped}`
});
});
return;
}
const value = String(filters[param] ?? '').trim();
if (value === '') {
return;
}
const defaultValue = normalizeDefault(entry?.default ?? '');
if (defaultValue !== '' && value === defaultValue) {
return;
}
const mapped = resolveLabel(entry, value);
chips.push({
id: `${param}:${value}`,
param,
value,
valueType: 'scalar',
label: `${label}: ${mapped}`
});
});
return chips;
};
export const removeChipFromMetaState = (chip = {}, state = {}, meta = {}) => {
const next = {
...state,
filters: { ...(state?.filters || {}) }
};
const param = String(chip?.param || '').trim();
if (param === 'search') {
next.search = '';
return next;
}
if (String(chip?.meta || '').startsWith('date_range:')) {
const [, fromParam, toParam] = String(chip.meta).split(':');
if (fromParam) {
next.filters[fromParam] = '';
}
if (toParam) {
next.filters[toParam] = '';
}
return next;
}
if (chip?.valueType === 'multi') {
const current = normalizeCsv(next.filters[param]);
const filtered = current.filter((item) => item !== String(chip?.value || ''));
next.filters[param] = filtered.join(',');
return next;
}
const entry = findMetaEntry(meta, chip);
next.filters[param] = normalizeDefault(entry?.default ?? '');
return next;
};
export const clearMetaState = (state = {}, meta = {}, options = {}) => {
const preserveSearch = options?.preserveSearch === true;
const preserveFilterParams = new Set(
Array.isArray(options?.preserveFilterParams)
? options.preserveFilterParams.map((item) => String(item ?? '').trim()).filter(Boolean)
: []
);
const next = {
...state,
search: preserveSearch ? String(state?.search ?? '') : '',
filters: { ...(state?.filters || {}) }
};
normalizeEntries(meta).forEach((entry) => {
const type = String(entry?.type || '').toLowerCase();
if (type === 'date_range') {
const fromParam = String(entry?.from_param || '').trim();
const toParam = String(entry?.to_param || '').trim();
if (fromParam !== '' && !preserveFilterParams.has(fromParam)) {
next.filters[fromParam] = '';
}
if (toParam !== '' && !preserveFilterParams.has(toParam)) {
next.filters[toParam] = '';
}
return;
}
const param = String(entry?.param || entry?.key || '').trim();
if (param === '' || param === 'search' || preserveFilterParams.has(param)) {
return;
}
next.filters[param] = normalizeDefault(entry?.default ?? '');
});
return next;
};

View File

@@ -63,3 +63,130 @@ export const badgeHtml = (gridjs, value = '') => {
const safe = value ?? '';
return gridjs.html(`<span class="badge" data-variant="neutral">${safe}</span>`);
};
export const normalizeMultiSelectValue = (value) => {
if (Array.isArray(value)) {
return value
.map((item) => String(item ?? '').trim())
.filter(Boolean);
}
if (typeof value === 'string') {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return [];
};
export const gridFilterSelect = (input, param, options = {}) => ({
input,
param,
...options
});
export const gridFilterDate = (input, param, options = {}) => ({
input,
param,
normalize: (value) => {
const date = String(value ?? '').trim();
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : '';
},
...options
});
export const gridFilterText = (input, param, options = {}) => ({
input,
param,
normalize: (value) => String(value ?? '').trim(),
...options
});
export const gridFilterMultiCsv = (input, param, options = {}) => ({
input,
param,
normalize: normalizeMultiSelectValue,
...options
});
export const gridFilterNumber = (input, param, options = {}) => ({
input,
param,
normalize: (value) => {
const raw = String(value ?? '').trim();
if (raw === '') {
return '';
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? String(parsed) : '';
},
...options
});
export const gridFilterHidden = (input, param, options = {}) => ({
input,
param,
normalize: (value) => String(value ?? '').trim(),
...options
});
const NORMALIZERS = {
trim: (value) => String(value ?? '').trim(),
trim_lower: (value) => String(value ?? '').trim().toLowerCase(),
all_to_empty: (value) => {
const normalized = String(value ?? '').trim();
return normalized === '' || normalized === 'all' ? '' : normalized;
},
csv: normalizeMultiSelectValue
};
export const gridFiltersFromSchema = (schema) => {
if (!Array.isArray(schema)) {
return [];
}
return schema.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const type = String(entry.type ?? '').trim().toLowerCase();
const input = entry.input ?? '';
const param = String(entry.param ?? '').trim();
if (!input || !param) {
return null;
}
const options = {};
if ('default' in entry) {
options.default = entry.default;
}
if (typeof entry.event === 'string' && entry.event.trim() !== '') {
options.event = entry.event.trim();
}
if (typeof entry.normalize === 'string' && NORMALIZERS[entry.normalize]) {
options.normalize = NORMALIZERS[entry.normalize];
}
if (type === 'date') {
return gridFilterDate(input, param, options);
}
if (type === 'select') {
return gridFilterSelect(input, param, options);
}
if (type === 'multi_csv') {
return gridFilterMultiCsv(input, param, options);
}
if (type === 'number') {
return gridFilterNumber(input, param, options);
}
if (type === 'hidden') {
return gridFilterHidden(input, param, options);
}
if (type === 'text') {
return gridFilterText(input, param, options);
}
return null;
}).filter(Boolean);
};

View File

@@ -8,6 +8,7 @@ export function initUsersListPage(options = {}) {
bulkDownloadForms = {},
exportPath = 'admin/users/export',
listPath = 'admin/users',
resetOptions = {},
buildBulkUrl,
showFlash,
withLoading
@@ -55,10 +56,11 @@ export function initUsersListPage(options = {}) {
if (resetButton) {
resetButton.addEventListener('click', () => {
if (typeof gridConfig.resetFilters === 'function') {
gridConfig.resetFilters();
gridConfig.resetFilters(resetOptions);
} else {
gridConfig.updateGrid();
gridConfig.updateGrid(resetOptions);
}
document.dispatchEvent(new CustomEvent('app:filters-updated'));
});
}