@optionfactory/fml 9.0.0-rc1 → 9.0.0-rc11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/custom-elements.json +332 -19
- package/dist/fml.css +10 -9
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +763 -152
- package/dist/fml.iife.js.map +1 -1
- package/dist/fml.iife.min.js +1 -1
- package/dist/fml.iife.min.js.map +1 -1
- package/dist/ful.css +10 -9
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +205 -7
- package/dist/ful.iife.js +758 -150
- package/dist/ful.iife.js.map +1 -1
- package/dist/ful.iife.min.js +1 -1
- package/dist/ful.iife.min.js.map +1 -1
- package/dist/ful.min.mjs +1 -1
- package/dist/ful.min.mjs.map +1 -1
- package/dist/ful.mjs +756 -151
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +157 -15
- package/dist/web-types.json +347 -20
- package/package.json +1 -1
package/dist/fml.iife.js
CHANGED
|
@@ -6462,6 +6462,48 @@ var fml = (function (exports) {
|
|
|
6462
6462
|
}
|
|
6463
6463
|
}
|
|
6464
6464
|
|
|
6465
|
+
/**
|
|
6466
|
+
* The protocol by which content standing inside a field becomes part of the
|
|
6467
|
+
* accessible description of that field's control.
|
|
6468
|
+
*
|
|
6469
|
+
* A field owns its control's `aria-describedby`: it is the only thing that
|
|
6470
|
+
* knows which element the description belongs on, and it already writes the
|
|
6471
|
+
* entry for its own error region. Content the author slotted into the field
|
|
6472
|
+
* cannot write that attribute itself without becoming a second owner of it, and
|
|
6473
|
+
* it cannot be wired by the field either, because a slotted custom element
|
|
6474
|
+
* renders after the field has mounted and has nothing to point at when the
|
|
6475
|
+
* field looks.
|
|
6476
|
+
*
|
|
6477
|
+
* So the content asks, once it has something to offer. `describable(el)`
|
|
6478
|
+
* answers the nearest ancestor that accepts a description, and the caller hands
|
|
6479
|
+
* its element to that ancestor's `describedBy`, which answers whether it was
|
|
6480
|
+
* taken. Nothing here names a field or a tooltip: the relation is expressed as
|
|
6481
|
+
* a capability, so the two ends need not import each other, which matters
|
|
6482
|
+
* because the library's own arrow runs from the forms to the disclosures.
|
|
6483
|
+
*
|
|
6484
|
+
* The lookup lives here rather than at its one call site so the protocol has a
|
|
6485
|
+
* name, a place to be documented and a single definition to change.
|
|
6486
|
+
*/
|
|
6487
|
+
|
|
6488
|
+
/**
|
|
6489
|
+
* @typedef {{ describedBy(el: HTMLElement): boolean }} Describable
|
|
6490
|
+
*/
|
|
6491
|
+
|
|
6492
|
+
/**
|
|
6493
|
+
* The nearest ancestor of `el` that accepts elements into the description of
|
|
6494
|
+
* whatever it considers its control, or null when nothing in the ancestry does.
|
|
6495
|
+
* @param {Element} el
|
|
6496
|
+
* @returns {(Element & Describable) | null}
|
|
6497
|
+
*/
|
|
6498
|
+
const describable = (el) => {
|
|
6499
|
+
for (let at = el.parentElement; at; at = at.parentElement) {
|
|
6500
|
+
if (typeof (/** @type {any} */ (at).describedBy) === 'function') {
|
|
6501
|
+
return /** @type {any} */ (at);
|
|
6502
|
+
}
|
|
6503
|
+
}
|
|
6504
|
+
return null;
|
|
6505
|
+
};
|
|
6506
|
+
|
|
6465
6507
|
/**
|
|
6466
6508
|
* Sleeping, debouncing and throttling. Debounce and throttle both return the
|
|
6467
6509
|
* wrapped function together with a cancel function.
|
|
@@ -6856,6 +6898,9 @@ var fml = (function (exports) {
|
|
|
6856
6898
|
/** the role the element internals carry, 'presentation' unless the control is its own */
|
|
6857
6899
|
static ROLE = 'presentation';
|
|
6858
6900
|
#control;
|
|
6901
|
+
#described;
|
|
6902
|
+
#descriptions = [];
|
|
6903
|
+
#errorId = null;
|
|
6859
6904
|
#fieldError;
|
|
6860
6905
|
#claims;
|
|
6861
6906
|
#announces;
|
|
@@ -6907,15 +6952,18 @@ var fml = (function (exports) {
|
|
|
6907
6952
|
true,
|
|
6908
6953
|
);
|
|
6909
6954
|
}
|
|
6910
|
-
//the
|
|
6955
|
+
//the description lands on the control, or on the host where there is no
|
|
6911
6956
|
//single control to describe (a radio group's legend names its fieldset)
|
|
6957
|
+
this.#described = described ?? control;
|
|
6912
6958
|
if (error) {
|
|
6913
|
-
|
|
6959
|
+
//named for what it is, the generic id being for whoever brings no name
|
|
6960
|
+
error.id = error.id || Attributes.uid('ful-field-error');
|
|
6961
|
+
this.#errorId = error.id;
|
|
6914
6962
|
}
|
|
6963
|
+
//anything handed over before the field had a target lands here
|
|
6964
|
+
this.#describe();
|
|
6915
6965
|
if (label) {
|
|
6916
|
-
|
|
6917
|
-
//a label that does not natively target the control still focuses it
|
|
6918
|
-
label.addEventListener('click', () => this.focus());
|
|
6966
|
+
Field.#name(this, label, control);
|
|
6919
6967
|
}
|
|
6920
6968
|
//the platform's implicit submission, stood in for where the field's own
|
|
6921
6969
|
//protocol took it away: the inner controls carry form="", so Enter in one
|
|
@@ -6948,6 +6996,61 @@ var fml = (function (exports) {
|
|
|
6948
6996
|
static #submitsOnEnter(el) {
|
|
6949
6997
|
return el instanceof HTMLInputElement && !['file', 'button', 'submit', 'reset', 'image'].includes(el.type);
|
|
6950
6998
|
}
|
|
6999
|
+
/**
|
|
7000
|
+
* Adds an element to the accessible description of the field's control and
|
|
7001
|
+
* answers whether the field took it.
|
|
7002
|
+
*
|
|
7003
|
+
* A field takes one whenever it is offered, before its own render as
|
|
7004
|
+
* readily as after: content slotted into a field is a custom element of its
|
|
7005
|
+
* own and may upgrade on either side of the field it stands in, which
|
|
7006
|
+
* happens in both directions in practice, a tooltip beating an async select
|
|
7007
|
+
* to its render while losing to a plain input. A description handed over
|
|
7008
|
+
* early waits here and is written the moment the field has somewhere to
|
|
7009
|
+
* write it, so the caller never has to know the order.
|
|
7010
|
+
*
|
|
7011
|
+
* The reference lands on the element handed over rather than on a wrapper
|
|
7012
|
+
* around it: a hidden element is included in a description only where it is
|
|
7013
|
+
* named directly, and content that reaches the description through a
|
|
7014
|
+
* wrapper is skipped while it is hidden. A popover closed until someone
|
|
7015
|
+
* opens it is exactly that, so the caller passes the popover itself.
|
|
7016
|
+
*
|
|
7017
|
+
* An attribute rather than `ariaDescribedByElements`: the property reflects
|
|
7018
|
+
* to nothing, so the description would live in the accessibility tree alone
|
|
7019
|
+
* and vanish entirely on a browser without aria element reflection.
|
|
7020
|
+
*
|
|
7021
|
+
* This is the field's half of the description protocol; `describable` in
|
|
7022
|
+
* `ful/descriptions.mjs` is the half the content uses to find the field.
|
|
7023
|
+
* @param {HTMLElement} el
|
|
7024
|
+
* @returns {boolean}
|
|
7025
|
+
*/
|
|
7026
|
+
describedBy(el) {
|
|
7027
|
+
if (!el) {
|
|
7028
|
+
return false;
|
|
7029
|
+
}
|
|
7030
|
+
if (!el.id) {
|
|
7031
|
+
el.id = Attributes.uid('ful-described');
|
|
7032
|
+
}
|
|
7033
|
+
if (!this.#descriptions.includes(el.id)) {
|
|
7034
|
+
this.#descriptions.push(el.id);
|
|
7035
|
+
}
|
|
7036
|
+
this.#describe();
|
|
7037
|
+
return true;
|
|
7038
|
+
}
|
|
7039
|
+
/**
|
|
7040
|
+
* Writes the description the field has collected, the error region last:
|
|
7041
|
+
* the standing explanations are what the field always says, the problem is
|
|
7042
|
+
* the news. The field owns the attribute outright rather than appending to
|
|
7043
|
+
* whatever is there, so the order does not depend on who arrived when.
|
|
7044
|
+
*/
|
|
7045
|
+
#describe() {
|
|
7046
|
+
if (!this.#described) {
|
|
7047
|
+
return;
|
|
7048
|
+
}
|
|
7049
|
+
const ids = [...this.#descriptions, this.#errorId].filter((id) => id);
|
|
7050
|
+
if (ids.length) {
|
|
7051
|
+
this.#described.setAttribute('aria-describedby', ids.join(' '));
|
|
7052
|
+
}
|
|
7053
|
+
}
|
|
6951
7054
|
focus(options) {
|
|
6952
7055
|
this.#control?.focus(options);
|
|
6953
7056
|
}
|
|
@@ -7004,6 +7107,49 @@ var fml = (function (exports) {
|
|
|
7004
7107
|
}),
|
|
7005
7108
|
);
|
|
7006
7109
|
}
|
|
7110
|
+
/** The html elements a label's `for` may point at, `input[type=hidden]` excepted. */
|
|
7111
|
+
static #LABELABLE = new Set(['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA']);
|
|
7112
|
+
/**
|
|
7113
|
+
* Names the control from the field's label, natively wherever the platform
|
|
7114
|
+
* allows it.
|
|
7115
|
+
*
|
|
7116
|
+
* `for` and `id` are the form the dom itself carries, so the association is
|
|
7117
|
+
* there for anything reading the markup rather than the accessibility tree:
|
|
7118
|
+
* an audit tool, the browser's autofill, a translation pass. It also makes
|
|
7119
|
+
* the label's click reach the control the way it does in a plain form, which
|
|
7120
|
+
* is focus for a text control and activation for a checkbox, so the field
|
|
7121
|
+
* needs no handler of its own.
|
|
7122
|
+
*
|
|
7123
|
+
* A control the platform will not let a label target, a composite carrying
|
|
7124
|
+
* `role="radiogroup"` among them, takes `aria-labelledby` instead. That is an
|
|
7125
|
+
* attribute too, so the association is equally visible; what it does not carry
|
|
7126
|
+
* is the label's click, which is why the handler stays on that path only.
|
|
7127
|
+
*
|
|
7128
|
+
* Neither branch uses `ariaLabelledByElements`. The property reflects to no
|
|
7129
|
+
* attribute, so the name lived in the accessibility tree alone: nothing reading
|
|
7130
|
+
* the dom saw it, and on a browser without aria element reflection the
|
|
7131
|
+
* assignment is a silent expando and the field has no name at all.
|
|
7132
|
+
* @param {any} field
|
|
7133
|
+
* @param {HTMLElement} label
|
|
7134
|
+
* @param {any} control
|
|
7135
|
+
*/
|
|
7136
|
+
static #name(field, label, control) {
|
|
7137
|
+
const labelable =
|
|
7138
|
+
Field.#LABELABLE.has(control.tagName) && control.getAttribute('type') !== 'hidden';
|
|
7139
|
+
if (!labelable) {
|
|
7140
|
+
if (!label.id) {
|
|
7141
|
+
label.id = Attributes.uid('ful-label');
|
|
7142
|
+
}
|
|
7143
|
+
control.setAttribute('aria-labelledby', label.id);
|
|
7144
|
+
//aria-labelledby carries the name but not the label's click
|
|
7145
|
+
label.addEventListener('click', () => field.focus());
|
|
7146
|
+
return;
|
|
7147
|
+
}
|
|
7148
|
+
if (!control.id) {
|
|
7149
|
+
control.id = Attributes.uid('ful-control');
|
|
7150
|
+
}
|
|
7151
|
+
label.setAttribute('for', control.id);
|
|
7152
|
+
}
|
|
7007
7153
|
/**
|
|
7008
7154
|
* Whether the field's chrome should answer a gesture. Badges, dropzones,
|
|
7009
7155
|
* menus and labels are not form controls, so their handlers must ask the
|
|
@@ -7141,8 +7287,9 @@ var fml = (function (exports) {
|
|
|
7141
7287
|
* three claims reach it
|
|
7142
7288
|
* - `error` is the field's live region
|
|
7143
7289
|
* - `label`, when given, names the control and focuses it on click
|
|
7144
|
-
* - `described` moves the
|
|
7145
|
-
*
|
|
7290
|
+
* - `described` moves the description off the control and onto another
|
|
7291
|
+
* element, the host where no single control can carry it: the error
|
|
7292
|
+
* region and anything `describedBy` is later handed both land there
|
|
7146
7293
|
* - `claims` moves the three claims onto a wrapper the field disables as a
|
|
7147
7294
|
* whole, leaving focus and aria on the control
|
|
7148
7295
|
* - `announces` is the element whose role carries `aria-readonly` and
|
|
@@ -7254,6 +7401,7 @@ var fml = (function (exports) {
|
|
|
7254
7401
|
'response-mapper',
|
|
7255
7402
|
'clear-invalid-on-change:presence',
|
|
7256
7403
|
'scroll-on-error:presence',
|
|
7404
|
+
'autocomplete',
|
|
7257
7405
|
];
|
|
7258
7406
|
form;
|
|
7259
7407
|
render() {
|
|
@@ -7264,6 +7412,10 @@ var fml = (function (exports) {
|
|
|
7264
7412
|
//internals messages custom elements have no default UI for
|
|
7265
7413
|
form.setAttribute('novalidate', '');
|
|
7266
7414
|
Attributes.forward('form-', this, form);
|
|
7415
|
+
//the fields read it off whichever of the two they reach first, which depends
|
|
7416
|
+
//on whether they upgraded before or after this render: they cannot read it
|
|
7417
|
+
//off their own control, which carries form="" and so has no form owner
|
|
7418
|
+
Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
|
|
7267
7419
|
form.replaceChildren(...this.childNodes);
|
|
7268
7420
|
form.addEventListener('submit', async (e) => {
|
|
7269
7421
|
e.preventDefault();
|
|
@@ -7468,6 +7620,38 @@ var fml = (function (exports) {
|
|
|
7468
7620
|
* `reject="[^0-9]"` both leave the digits. Keeping is the one worth reaching for,
|
|
7469
7621
|
* the rejecting spelling of an allowed set being a double negative.
|
|
7470
7622
|
*/
|
|
7623
|
+
/**
|
|
7624
|
+
* The autofill token a field inherits from the form around it.
|
|
7625
|
+
*
|
|
7626
|
+
* A control is rendered with `form=""` so that the host is the only thing that
|
|
7627
|
+
* submits, which also leaves it without a form owner, and the platform resolves
|
|
7628
|
+
* `autocomplete` through the form owner. So a form declaring it reaches nothing
|
|
7629
|
+
* on its own and the field reads the setting off the form element instead.
|
|
7630
|
+
*
|
|
7631
|
+
* The `form` a `ful-form` renders answers here, the host copying its token onto
|
|
7632
|
+
* it, and a plain `form` around ful fields answers too: the platform meant the
|
|
7633
|
+
* same thing by it, and its inheritance is broken here for the same reason. An
|
|
7634
|
+
* ancestor always upgrades before its descendants, so the rendered form is in
|
|
7635
|
+
* place by the time a field of its own builds.
|
|
7636
|
+
*/
|
|
7637
|
+
const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
|
|
7638
|
+
|
|
7639
|
+
/** Rendering a text input is what costs these the native min/max/step enforcement. */
|
|
7640
|
+
const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
|
|
7641
|
+
|
|
7642
|
+
const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
|
|
7643
|
+
|
|
7644
|
+
const NUMERIC_FILTERS = {
|
|
7645
|
+
numeric: (v) => signed(v, (t) => t.replace(/\D/g, '')),
|
|
7646
|
+
//one separator, the first: the rest are a slip while typing, not a value
|
|
7647
|
+
decimal: (v) =>
|
|
7648
|
+
signed(v, (t) => {
|
|
7649
|
+
const kept = t.replace(/[^\d.,]/g, '');
|
|
7650
|
+
const at = kept.search(/[.,]/);
|
|
7651
|
+
return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
|
|
7652
|
+
}),
|
|
7653
|
+
};
|
|
7654
|
+
|
|
7471
7655
|
const warnedBoth = new WeakSet();
|
|
7472
7656
|
const filterOf = (el) => {
|
|
7473
7657
|
const keep = el.declared('keep');
|
|
@@ -7487,7 +7671,7 @@ var fml = (function (exports) {
|
|
|
7487
7671
|
const re = compiled('reject', reject);
|
|
7488
7672
|
return re && ((v) => v.replace(re, ''));
|
|
7489
7673
|
}
|
|
7490
|
-
return null;
|
|
7674
|
+
return NUMERIC_FILTERS[el._type()] ?? null;
|
|
7491
7675
|
};
|
|
7492
7676
|
|
|
7493
7677
|
/** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
|
|
@@ -7495,7 +7679,15 @@ var fml = (function (exports) {
|
|
|
7495
7679
|
static observed = ['placeholder'];
|
|
7496
7680
|
//configuration: the control is built from them and the value getter reads them,
|
|
7497
7681
|
//but none of them is meant to change once the element is up
|
|
7498
|
-
static attributes = [
|
|
7682
|
+
static attributes = [
|
|
7683
|
+
'type',
|
|
7684
|
+
'v-type',
|
|
7685
|
+
'keep',
|
|
7686
|
+
'reject',
|
|
7687
|
+
'uppercase:presence',
|
|
7688
|
+
'trim:presence',
|
|
7689
|
+
'autocomplete',
|
|
7690
|
+
];
|
|
7499
7691
|
static slots = true;
|
|
7500
7692
|
static template = `
|
|
7501
7693
|
<label>{{{{ slots.default }}}}</label>
|
|
@@ -7515,10 +7707,26 @@ var fml = (function (exports) {
|
|
|
7515
7707
|
return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
|
|
7516
7708
|
}
|
|
7517
7709
|
_build({ slots }) {
|
|
7518
|
-
const
|
|
7710
|
+
const declared = this._type();
|
|
7711
|
+
const mode = INPUT_MODES[declared];
|
|
7712
|
+
//a numeric widget is a text input: the type names the keyboard, not the
|
|
7713
|
+
//control, so the rendered markup stays valid html
|
|
7714
|
+
const type = mode ? 'text' : declared;
|
|
7519
7715
|
const fragment = this.template().withOverlay({ type, slots }).render();
|
|
7520
7716
|
this._input = fragment.querySelector('input,textarea');
|
|
7521
|
-
|
|
7717
|
+
if (mode) {
|
|
7718
|
+
//before the passthrough, which stays the last word
|
|
7719
|
+
Attributes.set(this._input, 'inputmode', mode);
|
|
7720
|
+
}
|
|
7721
|
+
|
|
7722
|
+
//the browser reads autocomplete off the control it is classifying, so the
|
|
7723
|
+
//field's own token, or the form's where it declares none, is put there.
|
|
7724
|
+
//Set before the passthrough, which stays the last word
|
|
7725
|
+
Attributes.set(
|
|
7726
|
+
this._input,
|
|
7727
|
+
'autocomplete',
|
|
7728
|
+
this.declared('autocomplete') ?? inheritedAutocomplete(this),
|
|
7729
|
+
);
|
|
7522
7730
|
Attributes.forward('input-', this, this._input);
|
|
7523
7731
|
this._input.addEventListener('input', (evt) => {
|
|
7524
7732
|
const strip = filterOf(this);
|
|
@@ -7561,13 +7769,15 @@ var fml = (function (exports) {
|
|
|
7561
7769
|
if (trimmed === '') {
|
|
7562
7770
|
return null;
|
|
7563
7771
|
}
|
|
7772
|
+
//the wire carries a dot wherever the comma is what the keyboard shows
|
|
7773
|
+
const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
|
|
7564
7774
|
if (this.declared('v-type') === 'number') {
|
|
7565
7775
|
//typed values are an explicit opt in, as the select's k-type: blank
|
|
7566
7776
|
//stays null, and a value that does not decode is kept as it is
|
|
7567
|
-
const n = Number(
|
|
7568
|
-
return Number.isNaN(n) ?
|
|
7777
|
+
const n = Number(normalized);
|
|
7778
|
+
return Number.isNaN(n) ? normalized : n;
|
|
7569
7779
|
}
|
|
7570
|
-
return
|
|
7780
|
+
return normalized;
|
|
7571
7781
|
}
|
|
7572
7782
|
set value(value) {
|
|
7573
7783
|
this._input.value = value === '' || value === undefined ? null : value;
|
|
@@ -8156,8 +8366,14 @@ var fml = (function (exports) {
|
|
|
8156
8366
|
const box = invoker.getBoundingClientRect();
|
|
8157
8367
|
const here = popover.getBoundingClientRect();
|
|
8158
8368
|
//against the padding box, which is what a percentage inset resolves against
|
|
8159
|
-
popover.style.setProperty(
|
|
8160
|
-
|
|
8369
|
+
popover.style.setProperty(
|
|
8370
|
+
'--ful-note-callout-inline',
|
|
8371
|
+
`${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
|
|
8372
|
+
);
|
|
8373
|
+
popover.style.setProperty(
|
|
8374
|
+
'--ful-note-callout-block',
|
|
8375
|
+
`${box.top + box.height / 2 - here.top - popover.clientTop}px`,
|
|
8376
|
+
);
|
|
8161
8377
|
};
|
|
8162
8378
|
|
|
8163
8379
|
const place = (popover, anchored) => {
|
|
@@ -8261,67 +8477,93 @@ var fml = (function (exports) {
|
|
|
8261
8477
|
};
|
|
8262
8478
|
|
|
8263
8479
|
/**
|
|
8264
|
-
*
|
|
8265
|
-
*
|
|
8266
|
-
* invoker whenever it opens, stretched to the invoker's width when
|
|
8267
|
-
* asked, and cleaned up when it closes. Where the css works the call
|
|
8268
|
-
* is a no-op.
|
|
8269
|
-
*
|
|
8270
|
-
* `handPlace` takes the placement here on every platform, which a popover
|
|
8271
|
-
* asks for when it needs to know where its invoker ended up: the note's
|
|
8272
|
-
* callout points at the invoker, and a pseudo-element cannot read an anchor
|
|
8273
|
-
* that is not inside its own containing block, so the note is measured rather
|
|
8274
|
-
* than placed by the css. Its stylesheet declares no position-area to match.
|
|
8480
|
+
* CSS anchor positioning for a popover and the invoker it belongs to, with the
|
|
8481
|
+
* hand-placed fallback for the platforms that do not have it.
|
|
8275
8482
|
*/
|
|
8276
|
-
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8483
|
+
class Anchors {
|
|
8484
|
+
/**
|
|
8485
|
+
* Anchors a popover to its invoker.
|
|
8486
|
+
*
|
|
8487
|
+
* The invoker is given an `anchor-name` and the popover a `position-anchor`
|
|
8488
|
+
* pointing at it, which is what a stylesheet needs to place the popover
|
|
8489
|
+
* itself: the library's own menus say `top: anchor(bottom); left:
|
|
8490
|
+
* anchor(left)`. **Writing that css is the caller's half of this.** Without
|
|
8491
|
+
* it the popover lands wherever the user agent puts a popover, which is not
|
|
8492
|
+
* beside the invoker.
|
|
8493
|
+
*
|
|
8494
|
+
* Where the platform has no anchor positioning the popover is placed here
|
|
8495
|
+
* instead, beside the invoker whenever it opens, clamped into the viewport,
|
|
8496
|
+
* following it on scroll and resize, and cleaned up on close. That placement
|
|
8497
|
+
* draws the geometry the css above describes, so the two agree.
|
|
8498
|
+
*
|
|
8499
|
+
* @param {HTMLElement} invoker the element the popover belongs to
|
|
8500
|
+
* @param {HTMLElement} popover the `[popover]` element to place
|
|
8501
|
+
* @param {object} [options]
|
|
8502
|
+
* @param {string} [options.prefix] prefixes the generated anchor name and id,
|
|
8503
|
+
* so the dom says which component a name belongs to
|
|
8504
|
+
* @param {boolean} [options.invoke] points the invoker's `popovertarget` at
|
|
8505
|
+
* the popover, giving toggle and light dismiss with no script of your own
|
|
8506
|
+
* @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
|
|
8507
|
+
* step with the popover
|
|
8508
|
+
* @param {boolean} [options.stretch] widens the popover to its invoker, which
|
|
8509
|
+
* is what a combobox dropdown wants
|
|
8510
|
+
* @param {boolean} [options.handPlace] places here on every platform rather
|
|
8511
|
+
* than only as a fallback, which a popover asks for when it needs to know
|
|
8512
|
+
* where its invoker ended up: the tooltip's note points a callout at it, and
|
|
8513
|
+
* a pseudo-element cannot read an anchor outside its own containing block.
|
|
8514
|
+
* Such a popover declares no anchor placement in css, there being none to
|
|
8515
|
+
* agree with
|
|
8516
|
+
*/
|
|
8517
|
+
static wire(
|
|
8518
|
+
invoker,
|
|
8519
|
+
popover,
|
|
8520
|
+
{ prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
|
|
8521
|
+
) {
|
|
8522
|
+
const uid = Attributes.uid(prefix);
|
|
8523
|
+
if (invoke) {
|
|
8524
|
+
//popovertarget needs a target that can be named
|
|
8525
|
+
popover.id = popover.id || uid;
|
|
8526
|
+
invoker.setAttribute('popovertarget', popover.id);
|
|
8527
|
+
}
|
|
8528
|
+
const anchor = `--${uid}`;
|
|
8529
|
+
invoker.style.anchorName = anchor;
|
|
8530
|
+
popover.style.positionAnchor = anchor;
|
|
8531
|
+
if (expanded) {
|
|
8532
|
+
invoker.setAttribute('aria-expanded', 'false');
|
|
8533
|
+
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
8534
|
+
invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
|
|
8535
|
+
});
|
|
8536
|
+
}
|
|
8537
|
+
//the naming above is what the stylesheet reads, so it happens either way:
|
|
8538
|
+
//only the hand placement below is the fallback, and only for a popover that
|
|
8539
|
+
//did not ask to be placed here whatever the platform offers
|
|
8540
|
+
if (!handPlace && platformAnchors()) {
|
|
8541
|
+
return;
|
|
8542
|
+
}
|
|
8543
|
+
const anchored = { invoker, stretch };
|
|
8544
|
+
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
8545
|
+
//placed before the showing, refined once laid out: the platform's
|
|
8546
|
+
//centered or corner spot never paints
|
|
8547
|
+
if (evt.newState === 'open') {
|
|
8548
|
+
place(popover, anchored);
|
|
8549
|
+
}
|
|
8550
|
+
});
|
|
8292
8551
|
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
8293
|
-
|
|
8552
|
+
if (evt.newState === 'open') {
|
|
8553
|
+
open.set(popover, anchored);
|
|
8554
|
+
place(popover, anchored);
|
|
8555
|
+
} else {
|
|
8556
|
+
open.delete(popover);
|
|
8557
|
+
unplace(popover);
|
|
8558
|
+
}
|
|
8294
8559
|
});
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
if (!handPlace && platformAnchors()) {
|
|
8300
|
-
return;
|
|
8301
|
-
}
|
|
8302
|
-
const anchored = { invoker, stretch };
|
|
8303
|
-
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
8304
|
-
//placed before the showing, refined once laid out: the platform's
|
|
8305
|
-
//centered or corner spot never paints
|
|
8306
|
-
if (evt.newState === 'open') {
|
|
8307
|
-
place(popover, anchored);
|
|
8308
|
-
}
|
|
8309
|
-
});
|
|
8310
|
-
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
8311
|
-
if (evt.newState === 'open') {
|
|
8312
|
-
open.set(popover, anchored);
|
|
8313
|
-
place(popover, anchored);
|
|
8314
|
-
} else {
|
|
8315
|
-
open.delete(popover);
|
|
8316
|
-
unplace(popover);
|
|
8560
|
+
if (!reflowWired) {
|
|
8561
|
+
reflowWired = true;
|
|
8562
|
+
document.addEventListener('scroll', schedule, true);
|
|
8563
|
+
window.addEventListener('resize', schedule);
|
|
8317
8564
|
}
|
|
8318
|
-
});
|
|
8319
|
-
if (!reflowWired) {
|
|
8320
|
-
reflowWired = true;
|
|
8321
|
-
document.addEventListener('scroll', schedule, true);
|
|
8322
|
-
window.addEventListener('resize', schedule);
|
|
8323
8565
|
}
|
|
8324
|
-
}
|
|
8566
|
+
}
|
|
8325
8567
|
|
|
8326
8568
|
/**
|
|
8327
8569
|
* Fetches a select's whole vocabulary from a url and serves every later read
|
|
@@ -8527,10 +8769,27 @@ var fml = (function (exports) {
|
|
|
8527
8769
|
method: el.declared('method') ?? 'POST',
|
|
8528
8770
|
mode: el.declared('mode'),
|
|
8529
8771
|
prefetch: el.declared('preload'),
|
|
8530
|
-
revision: el
|
|
8772
|
+
revision: SelectLoader.#revisionFrom(el),
|
|
8531
8773
|
responseMapper: SelectLoader.#responseMapperFrom(el),
|
|
8532
8774
|
});
|
|
8533
8775
|
}
|
|
8776
|
+
static #revisionFrom(el) {
|
|
8777
|
+
const declared = el.declared('revision');
|
|
8778
|
+
if (declared !== '') {
|
|
8779
|
+
return declared;
|
|
8780
|
+
}
|
|
8781
|
+
const configured = el.component('revision');
|
|
8782
|
+
if (configured === undefined || configured === null) {
|
|
8783
|
+
//not caching while the markup says to cache is the quiet failure
|
|
8784
|
+
//this branch exists to make loud
|
|
8785
|
+
console.warn(
|
|
8786
|
+
"a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
|
|
8787
|
+
el,
|
|
8788
|
+
);
|
|
8789
|
+
return null;
|
|
8790
|
+
}
|
|
8791
|
+
return typeof configured === 'function' ? configured() : configured;
|
|
8792
|
+
}
|
|
8534
8793
|
static #responseMapperFrom(el) {
|
|
8535
8794
|
if (el.declared('k-expr') && el.declared('l-expr')) {
|
|
8536
8795
|
return (response) => {
|
|
@@ -8563,7 +8822,7 @@ var fml = (function (exports) {
|
|
|
8563
8822
|
static slots = true;
|
|
8564
8823
|
static template = `
|
|
8565
8824
|
<ful-spinner class="centered" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
|
|
8566
|
-
<
|
|
8825
|
+
<ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
|
|
8567
8826
|
<menu tabindex="-1" role="listbox" hidden></menu>
|
|
8568
8827
|
`;
|
|
8569
8828
|
static templates = {
|
|
@@ -8585,7 +8844,7 @@ var fml = (function (exports) {
|
|
|
8585
8844
|
? this.template('options')
|
|
8586
8845
|
: Templates.fromFragment(slots.default);
|
|
8587
8846
|
this.#spinner = fragment.querySelector('ful-spinner');
|
|
8588
|
-
this.#empty = fragment.querySelector('
|
|
8847
|
+
this.#empty = fragment.querySelector('[data-ref=empty]');
|
|
8589
8848
|
this.#menu = fragment.querySelector('menu');
|
|
8590
8849
|
//the listbox is named so a combobox can point aria-controls and
|
|
8591
8850
|
//aria-activedescendant at it: a reference to an unnamed element resolves
|
|
@@ -8850,7 +9109,7 @@ var fml = (function (exports) {
|
|
|
8850
9109
|
});
|
|
8851
9110
|
//each pair carries its own anchor: two selects on a page must not share one
|
|
8852
9111
|
const group = fragment.querySelector('ful-control-group');
|
|
8853
|
-
|
|
9112
|
+
Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
|
|
8854
9113
|
[this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
|
|
8855
9114
|
this.#wireChrome();
|
|
8856
9115
|
this.#wireChips();
|
|
@@ -8872,6 +9131,14 @@ var fml = (function (exports) {
|
|
|
8872
9131
|
if (!this._interactive()) {
|
|
8873
9132
|
return;
|
|
8874
9133
|
}
|
|
9134
|
+
//a click on another control inside the select is that control's, not
|
|
9135
|
+
//the select's: a tooltip marker slotted into `info`, a button a page
|
|
9136
|
+
//put in an affix. Without this, reading the note beside a select
|
|
9137
|
+
//also stole the focus and dropped the dropdown over the note
|
|
9138
|
+
const elsewhere = e.target.closest('button, a[href], input, select, textarea');
|
|
9139
|
+
if (elsewhere && elsewhere !== this.#input) {
|
|
9140
|
+
return;
|
|
9141
|
+
}
|
|
8875
9142
|
if (this.#ddmenu.shown) {
|
|
8876
9143
|
this.#close();
|
|
8877
9144
|
return;
|
|
@@ -8937,7 +9204,7 @@ var fml = (function (exports) {
|
|
|
8937
9204
|
return;
|
|
8938
9205
|
}
|
|
8939
9206
|
this.#abortdload();
|
|
8940
|
-
this.#close();
|
|
9207
|
+
this.#close(true);
|
|
8941
9208
|
});
|
|
8942
9209
|
this.#input.addEventListener('keydown', (e) => {
|
|
8943
9210
|
if (!this._interactive()) {
|
|
@@ -8970,7 +9237,6 @@ var fml = (function (exports) {
|
|
|
8970
9237
|
this.#editing = false;
|
|
8971
9238
|
this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
|
|
8972
9239
|
this.#changed();
|
|
8973
|
-
this.#syncBadges();
|
|
8974
9240
|
this.#input.focus();
|
|
8975
9241
|
this.#ddmenu.hide();
|
|
8976
9242
|
if (!this.#multiple) {
|
|
@@ -9027,7 +9293,6 @@ var fml = (function (exports) {
|
|
|
9027
9293
|
}
|
|
9028
9294
|
this.#values.delete(key);
|
|
9029
9295
|
this.#changed();
|
|
9030
|
-
this.#syncBadges();
|
|
9031
9296
|
}
|
|
9032
9297
|
#chipKeydown(e, badge) {
|
|
9033
9298
|
switch (e.code) {
|
|
@@ -9123,7 +9388,7 @@ var fml = (function (exports) {
|
|
|
9123
9388
|
}
|
|
9124
9389
|
case 'Tab': {
|
|
9125
9390
|
this.#abortdload();
|
|
9126
|
-
this.#close();
|
|
9391
|
+
this.#close(true);
|
|
9127
9392
|
break;
|
|
9128
9393
|
}
|
|
9129
9394
|
}
|
|
@@ -9139,12 +9404,25 @@ var fml = (function (exports) {
|
|
|
9139
9404
|
}
|
|
9140
9405
|
return;
|
|
9141
9406
|
}
|
|
9142
|
-
this.#
|
|
9143
|
-
this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
|
|
9407
|
+
this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
|
|
9144
9408
|
}
|
|
9145
|
-
|
|
9409
|
+
/**
|
|
9410
|
+
* @param commit whether the user is leaving the field, which only a blur or
|
|
9411
|
+
* a Tab is: Escape cancels, and closing the list or a claim landing leaves
|
|
9412
|
+
* the edit in progress
|
|
9413
|
+
*/
|
|
9414
|
+
#close(commit = false) {
|
|
9146
9415
|
this.#ddmenu.hide();
|
|
9416
|
+
//emptying the box and leaving is a clear: redisplaying the label would
|
|
9417
|
+
//discard the edit without a word. A multiple select keeps its box empty
|
|
9418
|
+
//by design, so only a single one can mean it
|
|
9419
|
+
const cleared =
|
|
9420
|
+
commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
|
|
9147
9421
|
this.#editing = false;
|
|
9422
|
+
if (cleared) {
|
|
9423
|
+
this.#values.clear();
|
|
9424
|
+
this.#changed();
|
|
9425
|
+
}
|
|
9148
9426
|
this.#display();
|
|
9149
9427
|
}
|
|
9150
9428
|
/**
|
|
@@ -9153,14 +9431,15 @@ var fml = (function (exports) {
|
|
|
9153
9431
|
* highlighted.
|
|
9154
9432
|
*/
|
|
9155
9433
|
#open() {
|
|
9156
|
-
this.#
|
|
9157
|
-
return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
|
|
9434
|
+
return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
|
|
9158
9435
|
}
|
|
9159
|
-
|
|
9160
|
-
|
|
9161
|
-
|
|
9162
|
-
|
|
9163
|
-
|
|
9436
|
+
/**
|
|
9437
|
+
* Browsing is the whole vocabulary, not the label already chosen, and the
|
|
9438
|
+
* label stays on screen while it happens: a select showing nothing reads as
|
|
9439
|
+
* an empty one.
|
|
9440
|
+
*/
|
|
9441
|
+
#query() {
|
|
9442
|
+
return this.#editing ? this.#input.value : '';
|
|
9164
9443
|
}
|
|
9165
9444
|
#display() {
|
|
9166
9445
|
const entry = this.#values.values().next().value;
|
|
@@ -9171,6 +9450,9 @@ var fml = (function (exports) {
|
|
|
9171
9450
|
return [...this.#values.values()];
|
|
9172
9451
|
}
|
|
9173
9452
|
#changed() {
|
|
9453
|
+
//settled before the event: a listener reading the control during it has to
|
|
9454
|
+
//see the selection that caused it
|
|
9455
|
+
this.#syncBadges();
|
|
9174
9456
|
//the detail carries the keys the value property answers with, as every
|
|
9175
9457
|
//other field's does, and the labeled selection beside them
|
|
9176
9458
|
this._notifyChange({ entry: this.entry });
|
|
@@ -9440,16 +9722,10 @@ var fml = (function (exports) {
|
|
|
9440
9722
|
evt.stopPropagation();
|
|
9441
9723
|
this._notifyChange();
|
|
9442
9724
|
});
|
|
9725
|
+
//the base points the label at the input with for/id, so the click toggles
|
|
9726
|
+
//the way it does in a plain form: the input's own change listener above
|
|
9727
|
+
//carries the notification, and readonly is refused by the freeze below
|
|
9443
9728
|
const label = fragment.querySelector('label');
|
|
9444
|
-
//the label neither wraps the input nor targets it, so the toggle is the
|
|
9445
|
-
//field's; the base adds the focus
|
|
9446
|
-
label.addEventListener('click', () => {
|
|
9447
|
-
if (!this._interactive()) {
|
|
9448
|
-
return;
|
|
9449
|
-
}
|
|
9450
|
-
this.value = !this.value;
|
|
9451
|
-
this._notifyChange();
|
|
9452
|
-
});
|
|
9453
9729
|
//a checkbox has no editable text to preserve, so readonly freezes the
|
|
9454
9730
|
//whole choice, label click included: the container is the frozen piece
|
|
9455
9731
|
return {
|
|
@@ -9823,6 +10099,7 @@ var fml = (function (exports) {
|
|
|
9823
10099
|
static slots = true;
|
|
9824
10100
|
static config = {
|
|
9825
10101
|
searchIcon: 'search',
|
|
10102
|
+
emptyIcon: 'inbox',
|
|
9826
10103
|
};
|
|
9827
10104
|
static template = `
|
|
9828
10105
|
<ful-form data-tpl-if="slots.filters">
|
|
@@ -9836,10 +10113,21 @@ var fml = (function (exports) {
|
|
|
9836
10113
|
<tbody data-ref="initial">
|
|
9837
10114
|
<tr>
|
|
9838
10115
|
<td data-tpl-colspan="schema.length">
|
|
9839
|
-
<
|
|
9840
|
-
<
|
|
10116
|
+
<ful-empty>
|
|
10117
|
+
<ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
|
|
9841
10118
|
{{ #l10n:t('table.initial') }}
|
|
9842
|
-
</
|
|
10119
|
+
</ful-empty>
|
|
10120
|
+
</td>
|
|
10121
|
+
</tr>
|
|
10122
|
+
</tbody>
|
|
10123
|
+
<tbody data-ref="empty" hidden>
|
|
10124
|
+
<tr>
|
|
10125
|
+
<td data-tpl-colspan="schema.length">
|
|
10126
|
+
<ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
|
|
10127
|
+
<ful-empty data-tpl-if="!slots.empty">
|
|
10128
|
+
<ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
|
|
10129
|
+
{{ #l10n:t('table.no-data') }}
|
|
10130
|
+
</ful-empty>
|
|
9843
10131
|
</td>
|
|
9844
10132
|
</tr>
|
|
9845
10133
|
</tbody>
|
|
@@ -9869,11 +10157,6 @@ var fml = (function (exports) {
|
|
|
9869
10157
|
`;
|
|
9870
10158
|
static templates = {
|
|
9871
10159
|
row: `
|
|
9872
|
-
<tr data-tpl-if="pageResponse.data.length == 0">
|
|
9873
|
-
<td data-tpl-colspan="schema.length">
|
|
9874
|
-
{{ #l10n:t('table.no-data') }}
|
|
9875
|
-
</td>
|
|
9876
|
-
</tr>
|
|
9877
10160
|
{{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
|
|
9878
10161
|
`,
|
|
9879
10162
|
};
|
|
@@ -9882,6 +10165,7 @@ var fml = (function (exports) {
|
|
|
9882
10165
|
#body;
|
|
9883
10166
|
#loading;
|
|
9884
10167
|
#noAutoload;
|
|
10168
|
+
#empty;
|
|
9885
10169
|
#feedback;
|
|
9886
10170
|
#paginator;
|
|
9887
10171
|
#sorters;
|
|
@@ -9932,6 +10216,7 @@ var fml = (function (exports) {
|
|
|
9932
10216
|
this.#body = table.querySelector(':scope > tbody');
|
|
9933
10217
|
this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
|
|
9934
10218
|
this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
|
|
10219
|
+
this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
|
|
9935
10220
|
this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
|
|
9936
10221
|
this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
|
|
9937
10222
|
this.replaceChildren(fragment);
|
|
@@ -10013,10 +10298,16 @@ var fml = (function (exports) {
|
|
|
10013
10298
|
//started is stale, and neither renders nor updates the request a later
|
|
10014
10299
|
//reload replays, whichever order the responses arrive in
|
|
10015
10300
|
const claim = this.#loads.take();
|
|
10016
|
-
|
|
10017
|
-
|
|
10301
|
+
//the rows stay while the table revalidates. Emptying the body and raising
|
|
10302
|
+
//the spinner row in its place collapsed the table to one tall row and
|
|
10303
|
+
//expanded it again on every sort, page and reload: two layout jumps for
|
|
10304
|
+
//what is the same table with newer rows in it. The spinner is for the load
|
|
10305
|
+
//with nothing to show yet, the first one and the one after a failure; the
|
|
10306
|
+
//rest announce themselves through aria-busy, which the stylesheet reads
|
|
10307
|
+
this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
|
|
10018
10308
|
this.#feedback.setAttribute('hidden', '');
|
|
10019
10309
|
this.#noAutoload.setAttribute('hidden', '');
|
|
10310
|
+
this.#empty.setAttribute('hidden', '');
|
|
10020
10311
|
this.setAttribute('aria-busy', 'true');
|
|
10021
10312
|
try {
|
|
10022
10313
|
const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
|
|
@@ -10025,6 +10316,16 @@ var fml = (function (exports) {
|
|
|
10025
10316
|
}
|
|
10026
10317
|
this.#latestRequest = { pageRequest, sortRequest, filterRequest };
|
|
10027
10318
|
this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
|
|
10319
|
+
//settled before the event: a listener counting what loaded has to
|
|
10320
|
+
//see the table as it now is
|
|
10321
|
+
this.removeAttribute('aria-busy');
|
|
10322
|
+
this.dispatchEvent(
|
|
10323
|
+
new CustomEvent('load:success', {
|
|
10324
|
+
bubbles: true,
|
|
10325
|
+
cancelable: false,
|
|
10326
|
+
detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
|
|
10327
|
+
}),
|
|
10328
|
+
);
|
|
10028
10329
|
} catch (/** @type any */ error) {
|
|
10029
10330
|
if (claim.stale) {
|
|
10030
10331
|
//the newer load owns the table and its outcome: a superseded
|
|
@@ -10032,17 +10333,24 @@ var fml = (function (exports) {
|
|
|
10032
10333
|
return;
|
|
10033
10334
|
}
|
|
10034
10335
|
this.#loading.setAttribute('hidden', '');
|
|
10336
|
+
//the rows the failed load was replacing go with it: what the table
|
|
10337
|
+
//holds is no longer what the request asked for, and leaving them
|
|
10338
|
+
//under the error would say the opposite
|
|
10339
|
+
this.#body.replaceChildren();
|
|
10035
10340
|
this.#feedback.removeAttribute('hidden');
|
|
10036
10341
|
this.#feedback.querySelector('[data-ref=feedback-error]').textContent = Failure.problemsText(
|
|
10037
10342
|
error,
|
|
10038
10343
|
`${error}`,
|
|
10039
10344
|
);
|
|
10345
|
+
this.removeAttribute('aria-busy');
|
|
10346
|
+
this.dispatchEvent(
|
|
10347
|
+
new CustomEvent('load:failure', {
|
|
10348
|
+
bubbles: true,
|
|
10349
|
+
cancelable: false,
|
|
10350
|
+
detail: { pageRequest, sortRequest, filterRequest, exception: error },
|
|
10351
|
+
}),
|
|
10352
|
+
);
|
|
10040
10353
|
throw error;
|
|
10041
|
-
} finally {
|
|
10042
|
-
//a superseded load owns nothing, the newer one's busy state included
|
|
10043
|
-
if (!claim.stale) {
|
|
10044
|
-
this.removeAttribute('aria-busy');
|
|
10045
|
-
}
|
|
10046
10354
|
}
|
|
10047
10355
|
}
|
|
10048
10356
|
/** Hands the loader to the callback, for runtime reconfigurations. */
|
|
@@ -10079,6 +10387,7 @@ var fml = (function (exports) {
|
|
|
10079
10387
|
})
|
|
10080
10388
|
.render(),
|
|
10081
10389
|
);
|
|
10390
|
+
this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
|
|
10082
10391
|
//one move, one repaint: the page and the count are the same state
|
|
10083
10392
|
this.#paginator.update({ current: pageRequest.page, total: pages });
|
|
10084
10393
|
}
|
|
@@ -10178,6 +10487,20 @@ var fml = (function (exports) {
|
|
|
10178
10487
|
//menu is where the localized words live
|
|
10179
10488
|
this.#button.textContent = this.#display(choice);
|
|
10180
10489
|
Attributes.set(this.#button, 'aria-label', this.#labelFor(choice));
|
|
10490
|
+
this.#mark();
|
|
10491
|
+
}
|
|
10492
|
+
/**
|
|
10493
|
+
* Marks the item the button currently holds, which is what a menu of one
|
|
10494
|
+
* choice among several owes the reader: the glyph on the button says which
|
|
10495
|
+
* one it is only to somebody who already knows the glyphs. The items are
|
|
10496
|
+
* `menuitemradio`, so the state is `aria-checked` rather than the
|
|
10497
|
+
* `aria-selected` a listbox would use, and the stylesheet draws it off that.
|
|
10498
|
+
*/
|
|
10499
|
+
#mark() {
|
|
10500
|
+
const current = this.value;
|
|
10501
|
+
for (const item of this.#items()) {
|
|
10502
|
+
item.setAttribute('aria-checked', String(item.getAttribute('value') === current));
|
|
10503
|
+
}
|
|
10181
10504
|
}
|
|
10182
10505
|
/** The host's disabled claim, composed with the pin: lifting one cannot lift the other. */
|
|
10183
10506
|
set claimed(claimed) {
|
|
@@ -10190,7 +10513,9 @@ var fml = (function (exports) {
|
|
|
10190
10513
|
const li = document.createElement('li');
|
|
10191
10514
|
li.setAttribute('role', 'none');
|
|
10192
10515
|
const a = document.createElement('a');
|
|
10193
|
-
|
|
10516
|
+
//one choice among several, which is what a radio item is: the
|
|
10517
|
+
//state belongs on the item, not on the button alone
|
|
10518
|
+
a.setAttribute('role', 'menuitemradio');
|
|
10194
10519
|
a.setAttribute('tabindex', '-1');
|
|
10195
10520
|
a.setAttribute('value', choice);
|
|
10196
10521
|
const word = this.#labelFor(choice);
|
|
@@ -10208,6 +10533,7 @@ var fml = (function (exports) {
|
|
|
10208
10533
|
return li;
|
|
10209
10534
|
}),
|
|
10210
10535
|
);
|
|
10536
|
+
this.#mark();
|
|
10211
10537
|
}
|
|
10212
10538
|
#sync() {
|
|
10213
10539
|
const pinned = this.pinned;
|
|
@@ -10228,7 +10554,7 @@ var fml = (function (exports) {
|
|
|
10228
10554
|
#wire() {
|
|
10229
10555
|
const button = this.#button;
|
|
10230
10556
|
const menu = this.#menu;
|
|
10231
|
-
|
|
10557
|
+
Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
|
|
10232
10558
|
menu.addEventListener('toggle', (/** @type any */ evt) => {
|
|
10233
10559
|
if (evt.newState !== 'open') {
|
|
10234
10560
|
//give the invoker back the focus the menu had borrowed, without
|
|
@@ -10315,6 +10641,19 @@ var fml = (function (exports) {
|
|
|
10315
10641
|
const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
|
|
10316
10642
|
const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
|
|
10317
10643
|
|
|
10644
|
+
/**
|
|
10645
|
+
* The operands are the words the reader chose, not the keys the wire carries.
|
|
10646
|
+
* @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
|
|
10647
|
+
*/
|
|
10648
|
+
|
|
10649
|
+
const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
|
|
10650
|
+
|
|
10651
|
+
/** @returns {FilterCriterion|null} */
|
|
10652
|
+
const asCriterion = (label, operator, operands) => {
|
|
10653
|
+
const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
|
|
10654
|
+
return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
|
|
10655
|
+
};
|
|
10656
|
+
|
|
10318
10657
|
/**
|
|
10319
10658
|
* The shared shape of every operator-and-operands filter: an operator menu, one
|
|
10320
10659
|
* or two operands of the type the subclass declares, and a tuple that mirrors
|
|
@@ -10454,6 +10793,18 @@ var fml = (function (exports) {
|
|
|
10454
10793
|
_syncBetween() {
|
|
10455
10794
|
this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
|
|
10456
10795
|
}
|
|
10796
|
+
/**
|
|
10797
|
+
* The operands as shown rather than as serialized: a date filter describes
|
|
10798
|
+
* what is in its control, not the iso string it sends.
|
|
10799
|
+
* @returns {FilterCriterion|null}
|
|
10800
|
+
*/
|
|
10801
|
+
get criterion() {
|
|
10802
|
+
const operands =
|
|
10803
|
+
this._operator.value === 'BETWEEN'
|
|
10804
|
+
? [this._value1.value, this._value2.value]
|
|
10805
|
+
: [this._value1.value];
|
|
10806
|
+
return asCriterion(labelTextOf(this), this._operator.value, operands);
|
|
10807
|
+
}
|
|
10457
10808
|
get disabled() {
|
|
10458
10809
|
return super.disabled;
|
|
10459
10810
|
}
|
|
@@ -10689,6 +11040,13 @@ var fml = (function (exports) {
|
|
|
10689
11040
|
this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
|
|
10690
11041
|
this._value.value = v[1] ?? '';
|
|
10691
11042
|
}
|
|
11043
|
+
/** @returns {FilterCriterion|null} */
|
|
11044
|
+
get criterion() {
|
|
11045
|
+
const token = this._value.value;
|
|
11046
|
+
return asCriterion(labelTextOf(this), this._operator.value, [
|
|
11047
|
+
token === '' ? '' : booleanValueLabel(token),
|
|
11048
|
+
]);
|
|
11049
|
+
}
|
|
10692
11050
|
get disabled() {
|
|
10693
11051
|
return super.disabled;
|
|
10694
11052
|
}
|
|
@@ -10701,6 +11059,37 @@ var fml = (function (exports) {
|
|
|
10701
11059
|
}
|
|
10702
11060
|
}
|
|
10703
11061
|
|
|
11062
|
+
/**
|
|
11063
|
+
* Set membership over a select's vocabulary, answering for both of data-jpa's
|
|
11064
|
+
* set annotations: InEnum and InList take the same bare array of values and no
|
|
11065
|
+
* operator, differing only in what the server converts them to.
|
|
11066
|
+
*/
|
|
11067
|
+
class InFilter extends Select {
|
|
11068
|
+
async upgrade() {
|
|
11069
|
+
//the snapshot is taken inside super.upgrade(), so an attribute this
|
|
11070
|
+
//element does not let an author turn off has to be written before it
|
|
11071
|
+
this.toggleAttribute('multiple', true);
|
|
11072
|
+
await super.upgrade();
|
|
11073
|
+
}
|
|
11074
|
+
get value() {
|
|
11075
|
+
const keys = super.value;
|
|
11076
|
+
//an empty set is no filter: the table drops a falsy value, where an
|
|
11077
|
+
//empty array would travel and match nothing
|
|
11078
|
+
return keys.length === 0 ? null : keys;
|
|
11079
|
+
}
|
|
11080
|
+
set value(v) {
|
|
11081
|
+
super.value = v ?? [];
|
|
11082
|
+
}
|
|
11083
|
+
/** @returns {FilterCriterion|null} */
|
|
11084
|
+
get criterion() {
|
|
11085
|
+
return asCriterion(
|
|
11086
|
+
labelTextOf(this),
|
|
11087
|
+
null,
|
|
11088
|
+
(super.entry ?? []).map((e) => e.label),
|
|
11089
|
+
);
|
|
11090
|
+
}
|
|
11091
|
+
}
|
|
11092
|
+
|
|
10704
11093
|
/**
|
|
10705
11094
|
* The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
|
|
10706
11095
|
* ful-drawer: every activation of a section fires the section:requested family
|
|
@@ -10809,64 +11198,137 @@ var fml = (function (exports) {
|
|
|
10809
11198
|
});
|
|
10810
11199
|
};
|
|
10811
11200
|
|
|
10812
|
-
/**
|
|
11201
|
+
/**
|
|
11202
|
+
* An info icon button toggling a popover with a short explanation.
|
|
11203
|
+
*
|
|
11204
|
+
* The marker is the page's `config.icon`, and the `icon` attribute names a
|
|
11205
|
+
* `ful-icon` for the tooltip that means something other than plain information:
|
|
11206
|
+
* a caveat, a warning, a setting. A name the library does not paint is the
|
|
11207
|
+
* page's own, declared as `ful-icon[name='...'] { mask-image: ... }`.
|
|
11208
|
+
*
|
|
11209
|
+
* `describes` is for the tooltip standing in a field: the note becomes part of
|
|
11210
|
+
* the accessible description of that field's control, so it is announced on
|
|
11211
|
+
* reaching the field rather than only on opening the marker, and the marker
|
|
11212
|
+
* leaves the tab order, so a form of hinted fields costs no extra keystrokes to
|
|
11213
|
+
* walk. The marker stays clickable, and stays a tab stop wherever the note was
|
|
11214
|
+
* not taken, a tooltip claiming `describes` outside a field among them: the
|
|
11215
|
+
* stop only goes where something else delivers the content.
|
|
11216
|
+
*/
|
|
10813
11217
|
class Tooltip extends ParsedElement {
|
|
10814
11218
|
static slots = true;
|
|
10815
|
-
static attributes = ['placement'];
|
|
11219
|
+
static attributes = ['placement', 'icon', 'describes:presence'];
|
|
10816
11220
|
static config = {
|
|
10817
11221
|
icon: 'info-circle-fill',
|
|
10818
11222
|
};
|
|
10819
11223
|
static template = `
|
|
10820
|
-
<button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="config.icon" aria-hidden="true"></ful-icon></button>
|
|
11224
|
+
<button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="icon ?? config.icon" aria-hidden="true"></ful-icon></button>
|
|
10821
11225
|
<ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
|
|
10822
11226
|
`;
|
|
10823
11227
|
render({ slots }) {
|
|
10824
|
-
const fragment = this.template().withOverlay({ slots }).render();
|
|
11228
|
+
const fragment = this.template().withOverlay({ slots, icon: this.declared('icon') }).render();
|
|
10825
11229
|
const trigger = fragment.querySelector('[data-ref=trigger]');
|
|
10826
11230
|
const content = fragment.querySelector('[data-ref=content]');
|
|
10827
11231
|
//placed here rather than by the anchor css: the note draws a callout that
|
|
10828
11232
|
//has to point at the trigger wherever the viewport left room for the note,
|
|
10829
11233
|
//which is a measurement the stylesheet cannot make for a pseudo-element
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
|
|
10833
|
-
|
|
10834
|
-
}
|
|
11234
|
+
Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
|
|
11235
|
+
//above the marker by default: a note opening downwards covers the control
|
|
11236
|
+
//the marker explains, the marker riding the field's label
|
|
11237
|
+
content.setAttribute('placement', this.declared('placement') ?? 'top');
|
|
10835
11238
|
this.replaceChildren(fragment);
|
|
11239
|
+
if (this.declared('describes')) {
|
|
11240
|
+
Tooltip.#describe(this, trigger, content);
|
|
11241
|
+
}
|
|
11242
|
+
}
|
|
11243
|
+
/**
|
|
11244
|
+
* Offers the note to the field the tooltip stands in, and takes the trigger
|
|
11245
|
+
* out of the tab order only where the offer was accepted: a note nothing
|
|
11246
|
+
* carries is reachable by the keyboard through the marker alone, so
|
|
11247
|
+
* dropping the stop there would leave it reachable by nothing at all.
|
|
11248
|
+
*
|
|
11249
|
+
* The offer goes through the description protocol rather than naming a
|
|
11250
|
+
* field, the library's own arrow running from the forms to the disclosures.
|
|
11251
|
+
*/
|
|
11252
|
+
static #describe(tooltip, trigger, content) {
|
|
11253
|
+
if (!describable(tooltip)?.describedBy(content)) {
|
|
11254
|
+
console.warn('a ful-tooltip declares describes but stands in nothing that takes a description', tooltip);
|
|
11255
|
+
return;
|
|
11256
|
+
}
|
|
11257
|
+
trigger.tabIndex = -1;
|
|
10836
11258
|
}
|
|
10837
11259
|
}
|
|
10838
11260
|
|
|
10839
|
-
/**
|
|
11261
|
+
/**
|
|
11262
|
+
* A modal dialog on the native platform, open()/ask() resolving with the
|
|
11263
|
+
* closer's data-result.
|
|
11264
|
+
*
|
|
11265
|
+
* The header carries a close button, as the drawer's does: Escape dismisses a
|
|
11266
|
+
* modal on its own, but nothing says so, and a dialog whose only exit is a key
|
|
11267
|
+
* you have to know about leaves a pointer with nowhere to go. It answers the way
|
|
11268
|
+
* Escape does, with null.
|
|
11269
|
+
*
|
|
11270
|
+
* `requires-answer` is for the dialog that must be answered: the close button is not
|
|
11271
|
+
* rendered and Escape is refused, so the only way out is a button that carries a
|
|
11272
|
+
* result. It has to be both, a close button withheld while Escape still worked
|
|
11273
|
+
* being decoration rather than a rule.
|
|
11274
|
+
*
|
|
11275
|
+
* The chrome is reachable by class as well as by tag, so a plain `<dialog
|
|
11276
|
+
* class="ful-dialog">` written by a page gets the same look whatever its
|
|
11277
|
+
* structure: the tag form matches a direct child, and `ful-dialog-header`,
|
|
11278
|
+
* `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
|
|
11279
|
+
* dialog whose content is wrapped in a form needs.
|
|
11280
|
+
*/
|
|
11281
|
+
/**
|
|
11282
|
+
* How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
|
|
11283
|
+
* the `data-result` of the button that closed it and `response` what a submit
|
|
11284
|
+
* answered with, the one that did not happen being null.
|
|
11285
|
+
* @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
|
|
11286
|
+
*/
|
|
11287
|
+
|
|
10840
11288
|
class Dialog extends ParsedElement {
|
|
10841
|
-
static attributes = ['header'];
|
|
11289
|
+
static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
|
|
10842
11290
|
static slots = true;
|
|
10843
11291
|
static template = `
|
|
10844
11292
|
<dialog data-ref="dialog" class="ful-dialog">
|
|
10845
|
-
<header data-tpl-if="header
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
<button
|
|
11293
|
+
<header data-tpl-if="header || slots.header || !requiresAnswer" class="ful-dialog-header">
|
|
11294
|
+
{{{{ slots.header }}}}
|
|
11295
|
+
<h2 data-tpl-if="header">{{ header }}</h2>
|
|
11296
|
+
<button data-tpl-if="!requiresAnswer" type="button" data-ref="close" data-tpl-aria-label="#l10n:t('dialog.close')"><ful-icon name="x-lg" aria-hidden="true"></ful-icon></button>
|
|
11297
|
+
</header>
|
|
11298
|
+
<section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
|
|
11299
|
+
<section data-ref="error" role="alert" hidden></section>
|
|
11300
|
+
<div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
|
|
11301
|
+
<footer class="ful-dialog-footer">
|
|
11302
|
+
<button type="button" data-ref="acknowledge" data-result="acknowledged" data-tpl-if="!slots.buttons && !closeOnSubmit" data-tpl-aria-label="#l10n:t('dialog.acknowledge')">{{ #l10n:t('dialog.acknowledge') }}</button>
|
|
10849
11303
|
{{{{ slots.buttons }}}}
|
|
10850
11304
|
</footer>
|
|
10851
11305
|
</dialog>
|
|
10852
11306
|
`;
|
|
10853
11307
|
#dialog;
|
|
10854
11308
|
#body;
|
|
11309
|
+
#loading;
|
|
11310
|
+
#error;
|
|
10855
11311
|
#requests = new SectionRequests();
|
|
11312
|
+
#updates = new Claims();
|
|
10856
11313
|
#resolvers = [];
|
|
11314
|
+
//the answer a submit closed the dialog with, which the return value cannot
|
|
11315
|
+
//carry: it is a string, and a response is whatever the server sent
|
|
11316
|
+
/** @type {DialogOutcome|null} */
|
|
11317
|
+
#answer = null;
|
|
10857
11318
|
render({ slots }) {
|
|
11319
|
+
const requiresAnswer = this.declared('requires-answer');
|
|
11320
|
+
const closeOnSubmit = this.declared('close-on-submit');
|
|
10858
11321
|
const fragment = this.template()
|
|
10859
|
-
.withOverlay({ slots, header: this.declared('header') ?? '' })
|
|
11322
|
+
.withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
|
|
10860
11323
|
.render();
|
|
10861
11324
|
this.#dialog = fragment.querySelector('[data-ref=dialog]');
|
|
10862
11325
|
this.#body = fragment.querySelector('[data-ref=body]');
|
|
11326
|
+
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
11327
|
+
this.#error = fragment.querySelector('[data-ref=error]');
|
|
10863
11328
|
this.#dialog.addEventListener('close', () => {
|
|
10864
|
-
this
|
|
10865
|
-
|
|
10866
|
-
|
|
10867
|
-
}),
|
|
10868
|
-
);
|
|
10869
|
-
this.#settle();
|
|
11329
|
+
const outcome = this.#outcome();
|
|
11330
|
+
this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
|
|
11331
|
+
this.#settle(outcome);
|
|
10870
11332
|
});
|
|
10871
11333
|
this.#dialog.addEventListener('click', (/** @type any */ e) => {
|
|
10872
11334
|
const result = e.target.closest('button[data-result]')?.dataset.result;
|
|
@@ -10874,42 +11336,128 @@ var fml = (function (exports) {
|
|
|
10874
11336
|
this.#dialog.close(result);
|
|
10875
11337
|
}
|
|
10876
11338
|
});
|
|
11339
|
+
//dismissal, not an answer: the waiters are settled with a dismissal, as
|
|
11340
|
+
//Escape does. Optional because a subclass overriding the template owns
|
|
11341
|
+
//what it renders
|
|
11342
|
+
fragment
|
|
11343
|
+
.querySelector('[data-ref=close]')
|
|
11344
|
+
?.addEventListener('click', () => this.#dialog.close(''));
|
|
11345
|
+
if (closeOnSubmit) {
|
|
11346
|
+
//delegated on the body rather than bound to the form, so a body
|
|
11347
|
+
//delivered later by update() is covered by the same listener. The
|
|
11348
|
+
//form must be the body's own: a ful-table wraps its filters in a
|
|
11349
|
+
//ful-form of its own, and a search in a table the dialog holds is
|
|
11350
|
+
//not the dialog being answered
|
|
11351
|
+
this.#body.addEventListener('submit:success', (/** @type any */ e) => {
|
|
11352
|
+
if (e.target !== Nodes.queryChildren(this.#body, 'ful-form')) {
|
|
11353
|
+
return;
|
|
11354
|
+
}
|
|
11355
|
+
this.#answer = { dismissed: false, result: null, response: e.detail.response };
|
|
11356
|
+
this.#dialog.close('submitted');
|
|
11357
|
+
});
|
|
11358
|
+
}
|
|
11359
|
+
if (requiresAnswer) {
|
|
11360
|
+
//the platform's own dismissal, refused where the dialog must be
|
|
11361
|
+
//answered: cancel fires for Escape and for a close request the
|
|
11362
|
+
//browser makes on its own, and preventing it leaves the dialog open
|
|
11363
|
+
this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
|
|
11364
|
+
}
|
|
10877
11365
|
this.replaceChildren(fragment);
|
|
10878
11366
|
wireTargets();
|
|
10879
11367
|
}
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
|
|
11368
|
+
/**
|
|
11369
|
+
* How the dialog ended, in one shape for every way it can end: `dismissed`
|
|
11370
|
+
* alone tells a cancel from an answer, so a submit answering with no body at
|
|
11371
|
+
* all (a 204) is still an answer, where a bare `null` could not say which it
|
|
11372
|
+
* was. `result` carries the `data-result` of the button that closed it and
|
|
11373
|
+
* `response` what a submit answered with; the one that did not happen is null.
|
|
11374
|
+
*/
|
|
11375
|
+
#outcome() {
|
|
11376
|
+
if (this.#answer) {
|
|
11377
|
+
return this.#answer;
|
|
11378
|
+
}
|
|
11379
|
+
//a render that threw adopted no dialog, and a removal still owes its
|
|
11380
|
+
//waiters an answer: reading through it would raise a second, unrelated
|
|
11381
|
+
//failure over the one already reported
|
|
11382
|
+
const result = this.#dialog?.returnValue ?? '';
|
|
11383
|
+
return result === ''
|
|
11384
|
+
? { dismissed: true, result: null, response: null }
|
|
11385
|
+
: { dismissed: false, result, response: null };
|
|
11386
|
+
}
|
|
11387
|
+
//answers every waiter with the dialog's own answer: a dismissal while still
|
|
11388
|
+
//open or closed without a result, which is also the unanswered answer a
|
|
11389
|
+
//dialog leaving the document owes its waiters instead of hanging them
|
|
11390
|
+
#settle(outcome) {
|
|
10884
11391
|
const resolvers = this.#resolvers;
|
|
10885
11392
|
this.#resolvers = [];
|
|
10886
11393
|
for (const resolve of resolvers) {
|
|
10887
|
-
resolve(
|
|
11394
|
+
resolve(outcome);
|
|
10888
11395
|
}
|
|
10889
11396
|
}
|
|
10890
11397
|
disconnectedCallback() {
|
|
10891
|
-
this.#settle();
|
|
11398
|
+
this.#settle(this.#outcome());
|
|
10892
11399
|
}
|
|
10893
11400
|
open() {
|
|
10894
11401
|
return this.ask();
|
|
10895
11402
|
}
|
|
10896
11403
|
ask() {
|
|
10897
|
-
if (
|
|
10898
|
-
this.#
|
|
10899
|
-
this.#dialog.showModal();
|
|
11404
|
+
if (this.#show()) {
|
|
11405
|
+
this.#restChrome();
|
|
10900
11406
|
this.#request();
|
|
10901
11407
|
}
|
|
10902
11408
|
return new Promise((resolve) => {
|
|
10903
11409
|
this.#resolvers.push(resolve);
|
|
10904
11410
|
});
|
|
10905
11411
|
}
|
|
11412
|
+
/**
|
|
11413
|
+
* Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
|
|
11414
|
+
* resolved value paints the body (which is returned), a rejection paints the
|
|
11415
|
+
* problems and travels to the caller, and an update superseded by a newer one
|
|
11416
|
+
* paints nothing. The title is the `header` attribute, configuration like the
|
|
11417
|
+
* rest of the dialog's chrome, so what update() owns is the body alone.
|
|
11418
|
+
*/
|
|
11419
|
+
async update(cb) {
|
|
11420
|
+
//the claim detaches any update still in flight: its outcome belongs to
|
|
11421
|
+
//an abandoned opening and must neither be painted nor own the dialog
|
|
11422
|
+
const claim = this.#updates.take();
|
|
11423
|
+
this.#body.replaceChildren();
|
|
11424
|
+
this.#restChrome();
|
|
11425
|
+
this.#loading?.removeAttribute('hidden');
|
|
11426
|
+
this.#body.setAttribute('hidden', '');
|
|
11427
|
+
//update owns its own open-answer-deliver cycle, so it shows the dialog
|
|
11428
|
+
//without going through ask(): a user reopen during the wait is a real
|
|
11429
|
+
//open and goes through ask()
|
|
11430
|
+
this.#show();
|
|
11431
|
+
try {
|
|
11432
|
+
const delivered = await cb();
|
|
11433
|
+
if (claim.stale) {
|
|
11434
|
+
return this.#body;
|
|
11435
|
+
}
|
|
11436
|
+
this.#body.replaceChildren(delivered);
|
|
11437
|
+
this.#loading?.setAttribute('hidden', '');
|
|
11438
|
+
this.#body.removeAttribute('hidden');
|
|
11439
|
+
return this.#body;
|
|
11440
|
+
} catch (/** @type any */ e) {
|
|
11441
|
+
if (!claim.stale) {
|
|
11442
|
+
//revealed before it is filled, so the live region announces the
|
|
11443
|
+
//change rather than being revealed already holding it
|
|
11444
|
+
this.#error?.removeAttribute('hidden');
|
|
11445
|
+
if (this.#error) {
|
|
11446
|
+
this.#error.textContent = Failure.problemsText(e);
|
|
11447
|
+
}
|
|
11448
|
+
this.#loading?.setAttribute('hidden', '');
|
|
11449
|
+
this.#body.setAttribute('hidden', '');
|
|
11450
|
+
}
|
|
11451
|
+
throw e;
|
|
11452
|
+
}
|
|
11453
|
+
}
|
|
10906
11454
|
#request() {
|
|
10907
11455
|
this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
|
|
10908
11456
|
}
|
|
10909
11457
|
/**
|
|
10910
11458
|
* Re-fires section:requested on the body, open or closed: the explicit
|
|
10911
11459
|
* request for a body that wants refreshing. A failed refresh paints its
|
|
10912
|
-
* problems, nothing rejects:
|
|
11460
|
+
* problems, nothing rejects: update() stays the rejecting call.
|
|
10913
11461
|
*/
|
|
10914
11462
|
refresh() {
|
|
10915
11463
|
return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
|
|
@@ -10917,15 +11465,43 @@ var fml = (function (exports) {
|
|
|
10917
11465
|
close(result) {
|
|
10918
11466
|
this.#dialog.close(result ?? '');
|
|
10919
11467
|
}
|
|
11468
|
+
/** Shows the modal, answering whether this call is the one that opened it. */
|
|
11469
|
+
#show() {
|
|
11470
|
+
if (this.#dialog.open) {
|
|
11471
|
+
return false;
|
|
11472
|
+
}
|
|
11473
|
+
//an opening owes nothing to the one before it: the platform keeps
|
|
11474
|
+
//returnValue across a close with no result, and the answer a submit
|
|
11475
|
+
//left is just as stale
|
|
11476
|
+
this.#dialog.returnValue = '';
|
|
11477
|
+
this.#answer = null;
|
|
11478
|
+
this.#dialog.showModal();
|
|
11479
|
+
return true;
|
|
11480
|
+
}
|
|
11481
|
+
#restChrome() {
|
|
11482
|
+
this.#error?.replaceChildren();
|
|
11483
|
+
this.#error?.setAttribute('hidden', '');
|
|
11484
|
+
this.#loading?.setAttribute('hidden', '');
|
|
11485
|
+
this.#body?.removeAttribute('hidden');
|
|
11486
|
+
}
|
|
10920
11487
|
}
|
|
10921
11488
|
|
|
10922
|
-
/**
|
|
11489
|
+
/**
|
|
11490
|
+
* A side panel drawer on the native dialog platform, update() owning its
|
|
11491
|
+
* open-deliver cycle.
|
|
11492
|
+
*
|
|
11493
|
+
* The `header` slot is content beside the title, before it: an icon, a badge, a
|
|
11494
|
+
* status. It sits outside the heading rather than in it because `update()` sets
|
|
11495
|
+
* the title through `textContent`, which would take anything nested there with
|
|
11496
|
+
* it.
|
|
11497
|
+
*/
|
|
10923
11498
|
class Drawer extends ParsedElement {
|
|
10924
|
-
static attributes = ['title', 'placement'];
|
|
11499
|
+
static attributes = ['title', 'placement', 'close-on-submit:presence'];
|
|
10925
11500
|
static slots = true;
|
|
10926
11501
|
static template = `
|
|
10927
11502
|
<dialog data-ref="dialog" class="ful-drawer">
|
|
10928
11503
|
<header>
|
|
11504
|
+
{{{{ slots.header }}}}
|
|
10929
11505
|
<h2 data-ref="title">{{ title }}</h2>
|
|
10930
11506
|
<button type="button" data-ref="close" data-tpl-aria-label="#l10n:t('drawer.close')"><ful-icon name="x-lg" aria-hidden="true"></ful-icon></button>
|
|
10931
11507
|
</header>
|
|
@@ -10941,6 +11517,10 @@ var fml = (function (exports) {
|
|
|
10941
11517
|
#content;
|
|
10942
11518
|
#requests = new SectionRequests();
|
|
10943
11519
|
#updates = new Claims();
|
|
11520
|
+
//what a submit closed the drawer with, told from a close of any other kind:
|
|
11521
|
+
//a save answering with no body at all is still a save
|
|
11522
|
+
/** @type {{ dismissed: boolean, response: any }|null} */
|
|
11523
|
+
#answer = null;
|
|
10944
11524
|
render({ slots }) {
|
|
10945
11525
|
const fragment = this.template()
|
|
10946
11526
|
.withOverlay({ slots, title: this.declared('title') ?? '' })
|
|
@@ -10956,8 +11536,25 @@ var fml = (function (exports) {
|
|
|
10956
11536
|
}
|
|
10957
11537
|
fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
|
|
10958
11538
|
this.#dialog.addEventListener('close', () => {
|
|
10959
|
-
this.dispatchEvent(
|
|
11539
|
+
this.dispatchEvent(
|
|
11540
|
+
new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
|
|
11541
|
+
);
|
|
10960
11542
|
});
|
|
11543
|
+
if (this.declared('close-on-submit')) {
|
|
11544
|
+
//delegated on the content section rather than bound to the form: a
|
|
11545
|
+
//drawer's form usually arrives with an update() rather than with the
|
|
11546
|
+
//page, and the section outlives every delivery. The form must be the
|
|
11547
|
+
//content's own, a ful-table wrapping its filters in a ful-form of its
|
|
11548
|
+
//own and a search in a table the drawer holds not being the drawer
|
|
11549
|
+
//finishing
|
|
11550
|
+
this.#content.addEventListener('submit:success', (/** @type any */ e) => {
|
|
11551
|
+
if (e.target !== Nodes.queryChildren(this.#content, 'ful-form')) {
|
|
11552
|
+
return;
|
|
11553
|
+
}
|
|
11554
|
+
this.#answer = { dismissed: false, response: e.detail.response };
|
|
11555
|
+
this.close();
|
|
11556
|
+
});
|
|
11557
|
+
}
|
|
10961
11558
|
this.replaceChildren(fragment);
|
|
10962
11559
|
wireTargets();
|
|
10963
11560
|
}
|
|
@@ -11030,6 +11627,9 @@ var fml = (function (exports) {
|
|
|
11030
11627
|
if (this.#dialog.open) {
|
|
11031
11628
|
return false;
|
|
11032
11629
|
}
|
|
11630
|
+
//an opening owes nothing to the one before it: the answer a submit left
|
|
11631
|
+
//belongs to the drawer that closed on it
|
|
11632
|
+
this.#answer = null;
|
|
11033
11633
|
this.#dialog.showModal();
|
|
11034
11634
|
return true;
|
|
11035
11635
|
}
|
|
@@ -11453,6 +12053,7 @@ var fml = (function (exports) {
|
|
|
11453
12053
|
'filters.boolean.false': 'No',
|
|
11454
12054
|
'info.tooltip': 'More information',
|
|
11455
12055
|
'dialog.acknowledge': 'Got it',
|
|
12056
|
+
'dialog.close': 'Close',
|
|
11456
12057
|
'drawer.close': 'Close',
|
|
11457
12058
|
'spinner.loading': 'Loading…',
|
|
11458
12059
|
'toast.region': 'Notifications',
|
|
@@ -11494,6 +12095,7 @@ var fml = (function (exports) {
|
|
|
11494
12095
|
'filters.boolean.false': 'No',
|
|
11495
12096
|
'info.tooltip': 'Maggiori informazioni',
|
|
11496
12097
|
'dialog.acknowledge': 'Ho capito',
|
|
12098
|
+
'dialog.close': 'Chiudi',
|
|
11497
12099
|
'drawer.close': 'Chiudi',
|
|
11498
12100
|
'spinner.loading': 'Caricamento…',
|
|
11499
12101
|
'toast.region': 'Notifiche',
|
|
@@ -11535,6 +12137,7 @@ var fml = (function (exports) {
|
|
|
11535
12137
|
'filters.boolean.false': 'No',
|
|
11536
12138
|
'info.tooltip': 'Más información',
|
|
11537
12139
|
'dialog.acknowledge': 'Entendido',
|
|
12140
|
+
'dialog.close': 'Cerrar',
|
|
11538
12141
|
'drawer.close': 'Cerrar',
|
|
11539
12142
|
'spinner.loading': 'Cargando…',
|
|
11540
12143
|
'toast.region': 'Notificaciones',
|
|
@@ -11579,6 +12182,7 @@ var fml = (function (exports) {
|
|
|
11579
12182
|
'filters.boolean.false': 'Non',
|
|
11580
12183
|
'info.tooltip': 'Plus d’informations',
|
|
11581
12184
|
'dialog.acknowledge': 'J’ai compris',
|
|
12185
|
+
'dialog.close': 'Fermer',
|
|
11582
12186
|
'drawer.close': 'Fermer',
|
|
11583
12187
|
'spinner.loading': 'Chargement…',
|
|
11584
12188
|
'toast.region': 'Notifications',
|
|
@@ -11649,6 +12253,7 @@ var fml = (function (exports) {
|
|
|
11649
12253
|
.defineElement('ful-filter-number', NumberFilter)
|
|
11650
12254
|
.defineElement('ful-filter-boolean', BooleanFilter)
|
|
11651
12255
|
.defineElement('ful-filter-text', TextFilter)
|
|
12256
|
+
.defineElement('ful-filter-in', InFilter)
|
|
11652
12257
|
.defineElement('ful-select', Select)
|
|
11653
12258
|
.defineElement('ful-dropdown', Dropdown)
|
|
11654
12259
|
.defineComponent('loaders:select', SelectLoader)
|
|
@@ -11668,6 +12273,7 @@ var fml = (function (exports) {
|
|
|
11668
12273
|
var ful = /*#__PURE__*/Object.freeze({
|
|
11669
12274
|
__proto__: null,
|
|
11670
12275
|
Accordion: Accordion,
|
|
12276
|
+
Anchors: Anchors,
|
|
11671
12277
|
AsyncEvents: AsyncEvents,
|
|
11672
12278
|
Bindings: Bindings,
|
|
11673
12279
|
BooleanFilter: BooleanFilter,
|
|
@@ -11680,6 +12286,7 @@ var fml = (function (exports) {
|
|
|
11680
12286
|
Field: Field,
|
|
11681
12287
|
Form: Form,
|
|
11682
12288
|
FormLoader: FormLoader,
|
|
12289
|
+
InFilter: InFilter,
|
|
11683
12290
|
Input: Input,
|
|
11684
12291
|
InputFile: InputFile,
|
|
11685
12292
|
InputInstant: InputInstant,
|
|
@@ -11708,7 +12315,8 @@ var fml = (function (exports) {
|
|
|
11708
12315
|
Tooltip: Tooltip,
|
|
11709
12316
|
VersionedLocalStorage: VersionedLocalStorage,
|
|
11710
12317
|
VersionedSessionStorage: VersionedSessionStorage,
|
|
11711
|
-
Wizard: Wizard
|
|
12318
|
+
Wizard: Wizard,
|
|
12319
|
+
describable: describable
|
|
11712
12320
|
});
|
|
11713
12321
|
|
|
11714
12322
|
if (typeof window !== 'undefined') {
|
|
@@ -11720,6 +12328,7 @@ var fml = (function (exports) {
|
|
|
11720
12328
|
}
|
|
11721
12329
|
|
|
11722
12330
|
exports.Accordion = Accordion;
|
|
12331
|
+
exports.Anchors = Anchors;
|
|
11723
12332
|
exports.AsyncEvents = AsyncEvents;
|
|
11724
12333
|
exports.Attributes = Attributes;
|
|
11725
12334
|
exports.Base64 = Base64;
|
|
@@ -11746,6 +12355,7 @@ var fml = (function (exports) {
|
|
|
11746
12355
|
exports.HttpInterceptorChain = HttpInterceptorChain;
|
|
11747
12356
|
exports.HttpMultipartRequestCustomizer = HttpMultipartRequestCustomizer;
|
|
11748
12357
|
exports.HttpRequestBuilder = HttpRequestBuilder;
|
|
12358
|
+
exports.InFilter = InFilter;
|
|
11749
12359
|
exports.Input = Input;
|
|
11750
12360
|
exports.InputFile = InputFile;
|
|
11751
12361
|
exports.InputInstant = InputInstant;
|
|
@@ -11785,6 +12395,7 @@ var fml = (function (exports) {
|
|
|
11785
12395
|
exports.VersionedLocalStorage = VersionedLocalStorage;
|
|
11786
12396
|
exports.VersionedSessionStorage = VersionedSessionStorage;
|
|
11787
12397
|
exports.Wizard = Wizard;
|
|
12398
|
+
exports.describable = describable;
|
|
11788
12399
|
exports.registry = registry;
|
|
11789
12400
|
|
|
11790
12401
|
return exports;
|