major update
This commit is contained in:
@@ -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(
|
||||
|
||||
86
web/css/components/app-active-filter-chips.css
Normal file
86
web/css/components/app-active-filter-chips.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
57
web/css/components/app-details-validation-summary.css
Normal file
57
web/css/components/app-details-validation-summary.css
Normal 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);
|
||||
}
|
||||
}
|
||||
184
web/css/components/app-filter-drawer.css
Normal file
184
web/css/components/app-filter-drawer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
86
web/js/components/app-active-filter-chips.js
Normal file
86
web/js/components/app-active-filter-chips.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[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 };
|
||||
}
|
||||
70
web/js/components/app-confirm-actions.js
Normal file
70
web/js/components/app-confirm-actions.js
Normal 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);
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
284
web/js/components/app-filter-drawer.js
Normal file
284
web/js/components/app-filter-drawer.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
11
web/js/components/app-standard-detail-page.js
Normal file
11
web/js/components/app-standard-detail-page.js
Normal 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();
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
555
web/js/core/app-detail-page-factory.js
Normal file
555
web/js/core/app-detail-page-factory.js
Normal 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);
|
||||
}
|
||||
220
web/js/core/app-details-action-policy.js
Normal file
220
web/js/core/app-details-action-policy.js
Normal 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;
|
||||
}
|
||||
227
web/js/core/app-details-open-state.js
Normal file
227
web/js/core/app-details-open-state.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
301
web/js/pages/app-list-filter-experience.js
Normal file
301
web/js/pages/app-list-filter-experience.js
Normal 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
|
||||
};
|
||||
}
|
||||
258
web/js/pages/app-list-filter-state.js
Normal file
258
web/js/pages/app-list-filter-state.js
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user