@optionfactory/fml 9.0.0-rc1 → 9.0.0-rc10
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 +119 -6
- package/dist/fml.css +8 -8
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +579 -116
- 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 +8 -8
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +179 -7
- package/dist/ful.iife.js +576 -115
- 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 +575 -116
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +63 -3
- package/dist/web-types.json +135 -7
- 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,22 @@ 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
|
+
|
|
7471
7639
|
const warnedBoth = new WeakSet();
|
|
7472
7640
|
const filterOf = (el) => {
|
|
7473
7641
|
const keep = el.declared('keep');
|
|
@@ -7495,7 +7663,15 @@ var fml = (function (exports) {
|
|
|
7495
7663
|
static observed = ['placeholder'];
|
|
7496
7664
|
//configuration: the control is built from them and the value getter reads them,
|
|
7497
7665
|
//but none of them is meant to change once the element is up
|
|
7498
|
-
static attributes = [
|
|
7666
|
+
static attributes = [
|
|
7667
|
+
'type',
|
|
7668
|
+
'v-type',
|
|
7669
|
+
'keep',
|
|
7670
|
+
'reject',
|
|
7671
|
+
'uppercase:presence',
|
|
7672
|
+
'trim:presence',
|
|
7673
|
+
'autocomplete',
|
|
7674
|
+
];
|
|
7499
7675
|
static slots = true;
|
|
7500
7676
|
static template = `
|
|
7501
7677
|
<label>{{{{ slots.default }}}}</label>
|
|
@@ -7519,6 +7695,14 @@ var fml = (function (exports) {
|
|
|
7519
7695
|
const fragment = this.template().withOverlay({ type, slots }).render();
|
|
7520
7696
|
this._input = fragment.querySelector('input,textarea');
|
|
7521
7697
|
|
|
7698
|
+
//the browser reads autocomplete off the control it is classifying, so the
|
|
7699
|
+
//field's own token, or the form's where it declares none, is put there.
|
|
7700
|
+
//Set before the passthrough, which stays the last word
|
|
7701
|
+
Attributes.set(
|
|
7702
|
+
this._input,
|
|
7703
|
+
'autocomplete',
|
|
7704
|
+
this.declared('autocomplete') ?? inheritedAutocomplete(this),
|
|
7705
|
+
);
|
|
7522
7706
|
Attributes.forward('input-', this, this._input);
|
|
7523
7707
|
this._input.addEventListener('input', (evt) => {
|
|
7524
7708
|
const strip = filterOf(this);
|
|
@@ -8156,8 +8340,14 @@ var fml = (function (exports) {
|
|
|
8156
8340
|
const box = invoker.getBoundingClientRect();
|
|
8157
8341
|
const here = popover.getBoundingClientRect();
|
|
8158
8342
|
//against the padding box, which is what a percentage inset resolves against
|
|
8159
|
-
popover.style.setProperty(
|
|
8160
|
-
|
|
8343
|
+
popover.style.setProperty(
|
|
8344
|
+
'--ful-note-callout-inline',
|
|
8345
|
+
`${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
|
|
8346
|
+
);
|
|
8347
|
+
popover.style.setProperty(
|
|
8348
|
+
'--ful-note-callout-block',
|
|
8349
|
+
`${box.top + box.height / 2 - here.top - popover.clientTop}px`,
|
|
8350
|
+
);
|
|
8161
8351
|
};
|
|
8162
8352
|
|
|
8163
8353
|
const place = (popover, anchored) => {
|
|
@@ -8261,67 +8451,93 @@ var fml = (function (exports) {
|
|
|
8261
8451
|
};
|
|
8262
8452
|
|
|
8263
8453
|
/**
|
|
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.
|
|
8454
|
+
* CSS anchor positioning for a popover and the invoker it belongs to, with the
|
|
8455
|
+
* hand-placed fallback for the platforms that do not have it.
|
|
8275
8456
|
*/
|
|
8276
|
-
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
8457
|
+
class Anchors {
|
|
8458
|
+
/**
|
|
8459
|
+
* Anchors a popover to its invoker.
|
|
8460
|
+
*
|
|
8461
|
+
* The invoker is given an `anchor-name` and the popover a `position-anchor`
|
|
8462
|
+
* pointing at it, which is what a stylesheet needs to place the popover
|
|
8463
|
+
* itself: the library's own menus say `top: anchor(bottom); left:
|
|
8464
|
+
* anchor(left)`. **Writing that css is the caller's half of this.** Without
|
|
8465
|
+
* it the popover lands wherever the user agent puts a popover, which is not
|
|
8466
|
+
* beside the invoker.
|
|
8467
|
+
*
|
|
8468
|
+
* Where the platform has no anchor positioning the popover is placed here
|
|
8469
|
+
* instead, beside the invoker whenever it opens, clamped into the viewport,
|
|
8470
|
+
* following it on scroll and resize, and cleaned up on close. That placement
|
|
8471
|
+
* draws the geometry the css above describes, so the two agree.
|
|
8472
|
+
*
|
|
8473
|
+
* @param {HTMLElement} invoker the element the popover belongs to
|
|
8474
|
+
* @param {HTMLElement} popover the `[popover]` element to place
|
|
8475
|
+
* @param {object} [options]
|
|
8476
|
+
* @param {string} [options.prefix] prefixes the generated anchor name and id,
|
|
8477
|
+
* so the dom says which component a name belongs to
|
|
8478
|
+
* @param {boolean} [options.invoke] points the invoker's `popovertarget` at
|
|
8479
|
+
* the popover, giving toggle and light dismiss with no script of your own
|
|
8480
|
+
* @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
|
|
8481
|
+
* step with the popover
|
|
8482
|
+
* @param {boolean} [options.stretch] widens the popover to its invoker, which
|
|
8483
|
+
* is what a combobox dropdown wants
|
|
8484
|
+
* @param {boolean} [options.handPlace] places here on every platform rather
|
|
8485
|
+
* than only as a fallback, which a popover asks for when it needs to know
|
|
8486
|
+
* where its invoker ended up: the tooltip's note points a callout at it, and
|
|
8487
|
+
* a pseudo-element cannot read an anchor outside its own containing block.
|
|
8488
|
+
* Such a popover declares no anchor placement in css, there being none to
|
|
8489
|
+
* agree with
|
|
8490
|
+
*/
|
|
8491
|
+
static wire(
|
|
8492
|
+
invoker,
|
|
8493
|
+
popover,
|
|
8494
|
+
{ prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
|
|
8495
|
+
) {
|
|
8496
|
+
const uid = Attributes.uid(prefix);
|
|
8497
|
+
if (invoke) {
|
|
8498
|
+
//popovertarget needs a target that can be named
|
|
8499
|
+
popover.id = popover.id || uid;
|
|
8500
|
+
invoker.setAttribute('popovertarget', popover.id);
|
|
8501
|
+
}
|
|
8502
|
+
const anchor = `--${uid}`;
|
|
8503
|
+
invoker.style.anchorName = anchor;
|
|
8504
|
+
popover.style.positionAnchor = anchor;
|
|
8505
|
+
if (expanded) {
|
|
8506
|
+
invoker.setAttribute('aria-expanded', 'false');
|
|
8507
|
+
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
8508
|
+
invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
|
|
8509
|
+
});
|
|
8510
|
+
}
|
|
8511
|
+
//the naming above is what the stylesheet reads, so it happens either way:
|
|
8512
|
+
//only the hand placement below is the fallback, and only for a popover that
|
|
8513
|
+
//did not ask to be placed here whatever the platform offers
|
|
8514
|
+
if (!handPlace && platformAnchors()) {
|
|
8515
|
+
return;
|
|
8516
|
+
}
|
|
8517
|
+
const anchored = { invoker, stretch };
|
|
8518
|
+
popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
|
|
8519
|
+
//placed before the showing, refined once laid out: the platform's
|
|
8520
|
+
//centered or corner spot never paints
|
|
8521
|
+
if (evt.newState === 'open') {
|
|
8522
|
+
place(popover, anchored);
|
|
8523
|
+
}
|
|
8524
|
+
});
|
|
8292
8525
|
popover.addEventListener('toggle', (/** @type any */ evt) => {
|
|
8293
|
-
|
|
8526
|
+
if (evt.newState === 'open') {
|
|
8527
|
+
open.set(popover, anchored);
|
|
8528
|
+
place(popover, anchored);
|
|
8529
|
+
} else {
|
|
8530
|
+
open.delete(popover);
|
|
8531
|
+
unplace(popover);
|
|
8532
|
+
}
|
|
8294
8533
|
});
|
|
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);
|
|
8534
|
+
if (!reflowWired) {
|
|
8535
|
+
reflowWired = true;
|
|
8536
|
+
document.addEventListener('scroll', schedule, true);
|
|
8537
|
+
window.addEventListener('resize', schedule);
|
|
8308
8538
|
}
|
|
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);
|
|
8317
|
-
}
|
|
8318
|
-
});
|
|
8319
|
-
if (!reflowWired) {
|
|
8320
|
-
reflowWired = true;
|
|
8321
|
-
document.addEventListener('scroll', schedule, true);
|
|
8322
|
-
window.addEventListener('resize', schedule);
|
|
8323
8539
|
}
|
|
8324
|
-
}
|
|
8540
|
+
}
|
|
8325
8541
|
|
|
8326
8542
|
/**
|
|
8327
8543
|
* Fetches a select's whole vocabulary from a url and serves every later read
|
|
@@ -8850,7 +9066,7 @@ var fml = (function (exports) {
|
|
|
8850
9066
|
});
|
|
8851
9067
|
//each pair carries its own anchor: two selects on a page must not share one
|
|
8852
9068
|
const group = fragment.querySelector('ful-control-group');
|
|
8853
|
-
|
|
9069
|
+
Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
|
|
8854
9070
|
[this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
|
|
8855
9071
|
this.#wireChrome();
|
|
8856
9072
|
this.#wireChips();
|
|
@@ -8872,6 +9088,14 @@ var fml = (function (exports) {
|
|
|
8872
9088
|
if (!this._interactive()) {
|
|
8873
9089
|
return;
|
|
8874
9090
|
}
|
|
9091
|
+
//a click on another control inside the select is that control's, not
|
|
9092
|
+
//the select's: a tooltip marker slotted into `info`, a button a page
|
|
9093
|
+
//put in an affix. Without this, reading the note beside a select
|
|
9094
|
+
//also stole the focus and dropped the dropdown over the note
|
|
9095
|
+
const elsewhere = e.target.closest('button, a[href], input, select, textarea');
|
|
9096
|
+
if (elsewhere && elsewhere !== this.#input) {
|
|
9097
|
+
return;
|
|
9098
|
+
}
|
|
8875
9099
|
if (this.#ddmenu.shown) {
|
|
8876
9100
|
this.#close();
|
|
8877
9101
|
return;
|
|
@@ -9440,16 +9664,10 @@ var fml = (function (exports) {
|
|
|
9440
9664
|
evt.stopPropagation();
|
|
9441
9665
|
this._notifyChange();
|
|
9442
9666
|
});
|
|
9667
|
+
//the base points the label at the input with for/id, so the click toggles
|
|
9668
|
+
//the way it does in a plain form: the input's own change listener above
|
|
9669
|
+
//carries the notification, and readonly is refused by the freeze below
|
|
9443
9670
|
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
9671
|
//a checkbox has no editable text to preserve, so readonly freezes the
|
|
9454
9672
|
//whole choice, label click included: the container is the frozen piece
|
|
9455
9673
|
return {
|
|
@@ -10013,8 +10231,13 @@ var fml = (function (exports) {
|
|
|
10013
10231
|
//started is stale, and neither renders nor updates the request a later
|
|
10014
10232
|
//reload replays, whichever order the responses arrive in
|
|
10015
10233
|
const claim = this.#loads.take();
|
|
10016
|
-
|
|
10017
|
-
|
|
10234
|
+
//the rows stay while the table revalidates. Emptying the body and raising
|
|
10235
|
+
//the spinner row in its place collapsed the table to one tall row and
|
|
10236
|
+
//expanded it again on every sort, page and reload: two layout jumps for
|
|
10237
|
+
//what is the same table with newer rows in it. The spinner is for the load
|
|
10238
|
+
//with nothing to show yet, the first one and the one after a failure; the
|
|
10239
|
+
//rest announce themselves through aria-busy, which the stylesheet reads
|
|
10240
|
+
this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
|
|
10018
10241
|
this.#feedback.setAttribute('hidden', '');
|
|
10019
10242
|
this.#noAutoload.setAttribute('hidden', '');
|
|
10020
10243
|
this.setAttribute('aria-busy', 'true');
|
|
@@ -10032,6 +10255,10 @@ var fml = (function (exports) {
|
|
|
10032
10255
|
return;
|
|
10033
10256
|
}
|
|
10034
10257
|
this.#loading.setAttribute('hidden', '');
|
|
10258
|
+
//the rows the failed load was replacing go with it: what the table
|
|
10259
|
+
//holds is no longer what the request asked for, and leaving them
|
|
10260
|
+
//under the error would say the opposite
|
|
10261
|
+
this.#body.replaceChildren();
|
|
10035
10262
|
this.#feedback.removeAttribute('hidden');
|
|
10036
10263
|
this.#feedback.querySelector('[data-ref=feedback-error]').textContent = Failure.problemsText(
|
|
10037
10264
|
error,
|
|
@@ -10178,6 +10405,20 @@ var fml = (function (exports) {
|
|
|
10178
10405
|
//menu is where the localized words live
|
|
10179
10406
|
this.#button.textContent = this.#display(choice);
|
|
10180
10407
|
Attributes.set(this.#button, 'aria-label', this.#labelFor(choice));
|
|
10408
|
+
this.#mark();
|
|
10409
|
+
}
|
|
10410
|
+
/**
|
|
10411
|
+
* Marks the item the button currently holds, which is what a menu of one
|
|
10412
|
+
* choice among several owes the reader: the glyph on the button says which
|
|
10413
|
+
* one it is only to somebody who already knows the glyphs. The items are
|
|
10414
|
+
* `menuitemradio`, so the state is `aria-checked` rather than the
|
|
10415
|
+
* `aria-selected` a listbox would use, and the stylesheet draws it off that.
|
|
10416
|
+
*/
|
|
10417
|
+
#mark() {
|
|
10418
|
+
const current = this.value;
|
|
10419
|
+
for (const item of this.#items()) {
|
|
10420
|
+
item.setAttribute('aria-checked', String(item.getAttribute('value') === current));
|
|
10421
|
+
}
|
|
10181
10422
|
}
|
|
10182
10423
|
/** The host's disabled claim, composed with the pin: lifting one cannot lift the other. */
|
|
10183
10424
|
set claimed(claimed) {
|
|
@@ -10190,7 +10431,9 @@ var fml = (function (exports) {
|
|
|
10190
10431
|
const li = document.createElement('li');
|
|
10191
10432
|
li.setAttribute('role', 'none');
|
|
10192
10433
|
const a = document.createElement('a');
|
|
10193
|
-
|
|
10434
|
+
//one choice among several, which is what a radio item is: the
|
|
10435
|
+
//state belongs on the item, not on the button alone
|
|
10436
|
+
a.setAttribute('role', 'menuitemradio');
|
|
10194
10437
|
a.setAttribute('tabindex', '-1');
|
|
10195
10438
|
a.setAttribute('value', choice);
|
|
10196
10439
|
const word = this.#labelFor(choice);
|
|
@@ -10208,6 +10451,7 @@ var fml = (function (exports) {
|
|
|
10208
10451
|
return li;
|
|
10209
10452
|
}),
|
|
10210
10453
|
);
|
|
10454
|
+
this.#mark();
|
|
10211
10455
|
}
|
|
10212
10456
|
#sync() {
|
|
10213
10457
|
const pinned = this.pinned;
|
|
@@ -10228,7 +10472,7 @@ var fml = (function (exports) {
|
|
|
10228
10472
|
#wire() {
|
|
10229
10473
|
const button = this.#button;
|
|
10230
10474
|
const menu = this.#menu;
|
|
10231
|
-
|
|
10475
|
+
Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
|
|
10232
10476
|
menu.addEventListener('toggle', (/** @type any */ evt) => {
|
|
10233
10477
|
if (evt.newState !== 'open') {
|
|
10234
10478
|
//give the invoker back the focus the menu had borrowed, without
|
|
@@ -10809,64 +11053,137 @@ var fml = (function (exports) {
|
|
|
10809
11053
|
});
|
|
10810
11054
|
};
|
|
10811
11055
|
|
|
10812
|
-
/**
|
|
11056
|
+
/**
|
|
11057
|
+
* An info icon button toggling a popover with a short explanation.
|
|
11058
|
+
*
|
|
11059
|
+
* The marker is the page's `config.icon`, and the `icon` attribute names a
|
|
11060
|
+
* `ful-icon` for the tooltip that means something other than plain information:
|
|
11061
|
+
* a caveat, a warning, a setting. A name the library does not paint is the
|
|
11062
|
+
* page's own, declared as `ful-icon[name='...'] { mask-image: ... }`.
|
|
11063
|
+
*
|
|
11064
|
+
* `describes` is for the tooltip standing in a field: the note becomes part of
|
|
11065
|
+
* the accessible description of that field's control, so it is announced on
|
|
11066
|
+
* reaching the field rather than only on opening the marker, and the marker
|
|
11067
|
+
* leaves the tab order, so a form of hinted fields costs no extra keystrokes to
|
|
11068
|
+
* walk. The marker stays clickable, and stays a tab stop wherever the note was
|
|
11069
|
+
* not taken, a tooltip claiming `describes` outside a field among them: the
|
|
11070
|
+
* stop only goes where something else delivers the content.
|
|
11071
|
+
*/
|
|
10813
11072
|
class Tooltip extends ParsedElement {
|
|
10814
11073
|
static slots = true;
|
|
10815
|
-
static attributes = ['placement'];
|
|
11074
|
+
static attributes = ['placement', 'icon', 'describes:presence'];
|
|
10816
11075
|
static config = {
|
|
10817
11076
|
icon: 'info-circle-fill',
|
|
10818
11077
|
};
|
|
10819
11078
|
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>
|
|
11079
|
+
<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
11080
|
<ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
|
|
10822
11081
|
`;
|
|
10823
11082
|
render({ slots }) {
|
|
10824
|
-
const fragment = this.template().withOverlay({ slots }).render();
|
|
11083
|
+
const fragment = this.template().withOverlay({ slots, icon: this.declared('icon') }).render();
|
|
10825
11084
|
const trigger = fragment.querySelector('[data-ref=trigger]');
|
|
10826
11085
|
const content = fragment.querySelector('[data-ref=content]');
|
|
10827
11086
|
//placed here rather than by the anchor css: the note draws a callout that
|
|
10828
11087
|
//has to point at the trigger wherever the viewport left room for the note,
|
|
10829
11088
|
//which is a measurement the stylesheet cannot make for a pseudo-element
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
|
|
10833
|
-
|
|
10834
|
-
}
|
|
11089
|
+
Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
|
|
11090
|
+
//above the marker by default: a note opening downwards covers the control
|
|
11091
|
+
//the marker explains, the marker riding the field's label
|
|
11092
|
+
content.setAttribute('placement', this.declared('placement') ?? 'top');
|
|
10835
11093
|
this.replaceChildren(fragment);
|
|
11094
|
+
if (this.declared('describes')) {
|
|
11095
|
+
Tooltip.#describe(this, trigger, content);
|
|
11096
|
+
}
|
|
11097
|
+
}
|
|
11098
|
+
/**
|
|
11099
|
+
* Offers the note to the field the tooltip stands in, and takes the trigger
|
|
11100
|
+
* out of the tab order only where the offer was accepted: a note nothing
|
|
11101
|
+
* carries is reachable by the keyboard through the marker alone, so
|
|
11102
|
+
* dropping the stop there would leave it reachable by nothing at all.
|
|
11103
|
+
*
|
|
11104
|
+
* The offer goes through the description protocol rather than naming a
|
|
11105
|
+
* field, the library's own arrow running from the forms to the disclosures.
|
|
11106
|
+
*/
|
|
11107
|
+
static #describe(tooltip, trigger, content) {
|
|
11108
|
+
if (!describable(tooltip)?.describedBy(content)) {
|
|
11109
|
+
console.warn('a ful-tooltip declares describes but stands in nothing that takes a description', tooltip);
|
|
11110
|
+
return;
|
|
11111
|
+
}
|
|
11112
|
+
trigger.tabIndex = -1;
|
|
10836
11113
|
}
|
|
10837
11114
|
}
|
|
10838
11115
|
|
|
10839
|
-
/**
|
|
11116
|
+
/**
|
|
11117
|
+
* A modal dialog on the native platform, open()/ask() resolving with the
|
|
11118
|
+
* closer's data-result.
|
|
11119
|
+
*
|
|
11120
|
+
* The header carries a close button, as the drawer's does: Escape dismisses a
|
|
11121
|
+
* modal on its own, but nothing says so, and a dialog whose only exit is a key
|
|
11122
|
+
* you have to know about leaves a pointer with nowhere to go. It answers the way
|
|
11123
|
+
* Escape does, with null.
|
|
11124
|
+
*
|
|
11125
|
+
* `requires-answer` is for the dialog that must be answered: the close button is not
|
|
11126
|
+
* rendered and Escape is refused, so the only way out is a button that carries a
|
|
11127
|
+
* result. It has to be both, a close button withheld while Escape still worked
|
|
11128
|
+
* being decoration rather than a rule.
|
|
11129
|
+
*
|
|
11130
|
+
* The chrome is reachable by class as well as by tag, so a plain `<dialog
|
|
11131
|
+
* class="ful-dialog">` written by a page gets the same look whatever its
|
|
11132
|
+
* structure: the tag form matches a direct child, and `ful-dialog-header`,
|
|
11133
|
+
* `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
|
|
11134
|
+
* dialog whose content is wrapped in a form needs.
|
|
11135
|
+
*/
|
|
11136
|
+
/**
|
|
11137
|
+
* How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
|
|
11138
|
+
* the `data-result` of the button that closed it and `response` what a submit
|
|
11139
|
+
* answered with, the one that did not happen being null.
|
|
11140
|
+
* @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
|
|
11141
|
+
*/
|
|
11142
|
+
|
|
10840
11143
|
class Dialog extends ParsedElement {
|
|
10841
|
-
static attributes = ['header'];
|
|
11144
|
+
static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
|
|
10842
11145
|
static slots = true;
|
|
10843
11146
|
static template = `
|
|
10844
11147
|
<dialog data-ref="dialog" class="ful-dialog">
|
|
10845
|
-
<header data-tpl-if="header
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
<button
|
|
11148
|
+
<header data-tpl-if="header || slots.header || !requiresAnswer" class="ful-dialog-header">
|
|
11149
|
+
{{{{ slots.header }}}}
|
|
11150
|
+
<h2 data-tpl-if="header">{{ header }}</h2>
|
|
11151
|
+
<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>
|
|
11152
|
+
</header>
|
|
11153
|
+
<section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
|
|
11154
|
+
<section data-ref="error" role="alert" hidden></section>
|
|
11155
|
+
<div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
|
|
11156
|
+
<footer class="ful-dialog-footer">
|
|
11157
|
+
<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
11158
|
{{{{ slots.buttons }}}}
|
|
10850
11159
|
</footer>
|
|
10851
11160
|
</dialog>
|
|
10852
11161
|
`;
|
|
10853
11162
|
#dialog;
|
|
10854
11163
|
#body;
|
|
11164
|
+
#loading;
|
|
11165
|
+
#error;
|
|
10855
11166
|
#requests = new SectionRequests();
|
|
11167
|
+
#updates = new Claims();
|
|
10856
11168
|
#resolvers = [];
|
|
11169
|
+
//the answer a submit closed the dialog with, which the return value cannot
|
|
11170
|
+
//carry: it is a string, and a response is whatever the server sent
|
|
11171
|
+
/** @type {DialogOutcome|null} */
|
|
11172
|
+
#answer = null;
|
|
10857
11173
|
render({ slots }) {
|
|
11174
|
+
const requiresAnswer = this.declared('requires-answer');
|
|
11175
|
+
const closeOnSubmit = this.declared('close-on-submit');
|
|
10858
11176
|
const fragment = this.template()
|
|
10859
|
-
.withOverlay({ slots, header: this.declared('header') ?? '' })
|
|
11177
|
+
.withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
|
|
10860
11178
|
.render();
|
|
10861
11179
|
this.#dialog = fragment.querySelector('[data-ref=dialog]');
|
|
10862
11180
|
this.#body = fragment.querySelector('[data-ref=body]');
|
|
11181
|
+
this.#loading = fragment.querySelector('[data-ref=loading]');
|
|
11182
|
+
this.#error = fragment.querySelector('[data-ref=error]');
|
|
10863
11183
|
this.#dialog.addEventListener('close', () => {
|
|
10864
|
-
this
|
|
10865
|
-
|
|
10866
|
-
|
|
10867
|
-
}),
|
|
10868
|
-
);
|
|
10869
|
-
this.#settle();
|
|
11184
|
+
const outcome = this.#outcome();
|
|
11185
|
+
this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
|
|
11186
|
+
this.#settle(outcome);
|
|
10870
11187
|
});
|
|
10871
11188
|
this.#dialog.addEventListener('click', (/** @type any */ e) => {
|
|
10872
11189
|
const result = e.target.closest('button[data-result]')?.dataset.result;
|
|
@@ -10874,42 +11191,128 @@ var fml = (function (exports) {
|
|
|
10874
11191
|
this.#dialog.close(result);
|
|
10875
11192
|
}
|
|
10876
11193
|
});
|
|
11194
|
+
//dismissal, not an answer: the waiters are settled with a dismissal, as
|
|
11195
|
+
//Escape does. Optional because a subclass overriding the template owns
|
|
11196
|
+
//what it renders
|
|
11197
|
+
fragment
|
|
11198
|
+
.querySelector('[data-ref=close]')
|
|
11199
|
+
?.addEventListener('click', () => this.#dialog.close(''));
|
|
11200
|
+
if (closeOnSubmit) {
|
|
11201
|
+
//delegated on the body rather than bound to the form, so a body
|
|
11202
|
+
//delivered later by update() is covered by the same listener. The
|
|
11203
|
+
//form must be the body's own: a ful-table wraps its filters in a
|
|
11204
|
+
//ful-form of its own, and a search in a table the dialog holds is
|
|
11205
|
+
//not the dialog being answered
|
|
11206
|
+
this.#body.addEventListener('submit:success', (/** @type any */ e) => {
|
|
11207
|
+
if (e.target !== Nodes.queryChildren(this.#body, 'ful-form')) {
|
|
11208
|
+
return;
|
|
11209
|
+
}
|
|
11210
|
+
this.#answer = { dismissed: false, result: null, response: e.detail.response };
|
|
11211
|
+
this.#dialog.close('submitted');
|
|
11212
|
+
});
|
|
11213
|
+
}
|
|
11214
|
+
if (requiresAnswer) {
|
|
11215
|
+
//the platform's own dismissal, refused where the dialog must be
|
|
11216
|
+
//answered: cancel fires for Escape and for a close request the
|
|
11217
|
+
//browser makes on its own, and preventing it leaves the dialog open
|
|
11218
|
+
this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
|
|
11219
|
+
}
|
|
10877
11220
|
this.replaceChildren(fragment);
|
|
10878
11221
|
wireTargets();
|
|
10879
11222
|
}
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
|
|
11223
|
+
/**
|
|
11224
|
+
* How the dialog ended, in one shape for every way it can end: `dismissed`
|
|
11225
|
+
* alone tells a cancel from an answer, so a submit answering with no body at
|
|
11226
|
+
* all (a 204) is still an answer, where a bare `null` could not say which it
|
|
11227
|
+
* was. `result` carries the `data-result` of the button that closed it and
|
|
11228
|
+
* `response` what a submit answered with; the one that did not happen is null.
|
|
11229
|
+
*/
|
|
11230
|
+
#outcome() {
|
|
11231
|
+
if (this.#answer) {
|
|
11232
|
+
return this.#answer;
|
|
11233
|
+
}
|
|
11234
|
+
//a render that threw adopted no dialog, and a removal still owes its
|
|
11235
|
+
//waiters an answer: reading through it would raise a second, unrelated
|
|
11236
|
+
//failure over the one already reported
|
|
11237
|
+
const result = this.#dialog?.returnValue ?? '';
|
|
11238
|
+
return result === ''
|
|
11239
|
+
? { dismissed: true, result: null, response: null }
|
|
11240
|
+
: { dismissed: false, result, response: null };
|
|
11241
|
+
}
|
|
11242
|
+
//answers every waiter with the dialog's own answer: a dismissal while still
|
|
11243
|
+
//open or closed without a result, which is also the unanswered answer a
|
|
11244
|
+
//dialog leaving the document owes its waiters instead of hanging them
|
|
11245
|
+
#settle(outcome) {
|
|
10884
11246
|
const resolvers = this.#resolvers;
|
|
10885
11247
|
this.#resolvers = [];
|
|
10886
11248
|
for (const resolve of resolvers) {
|
|
10887
|
-
resolve(
|
|
11249
|
+
resolve(outcome);
|
|
10888
11250
|
}
|
|
10889
11251
|
}
|
|
10890
11252
|
disconnectedCallback() {
|
|
10891
|
-
this.#settle();
|
|
11253
|
+
this.#settle(this.#outcome());
|
|
10892
11254
|
}
|
|
10893
11255
|
open() {
|
|
10894
11256
|
return this.ask();
|
|
10895
11257
|
}
|
|
10896
11258
|
ask() {
|
|
10897
|
-
if (
|
|
10898
|
-
this.#
|
|
10899
|
-
this.#dialog.showModal();
|
|
11259
|
+
if (this.#show()) {
|
|
11260
|
+
this.#restChrome();
|
|
10900
11261
|
this.#request();
|
|
10901
11262
|
}
|
|
10902
11263
|
return new Promise((resolve) => {
|
|
10903
11264
|
this.#resolvers.push(resolve);
|
|
10904
11265
|
});
|
|
10905
11266
|
}
|
|
11267
|
+
/**
|
|
11268
|
+
* Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
|
|
11269
|
+
* resolved value paints the body (which is returned), a rejection paints the
|
|
11270
|
+
* problems and travels to the caller, and an update superseded by a newer one
|
|
11271
|
+
* paints nothing. The title is the `header` attribute, configuration like the
|
|
11272
|
+
* rest of the dialog's chrome, so what update() owns is the body alone.
|
|
11273
|
+
*/
|
|
11274
|
+
async update(cb) {
|
|
11275
|
+
//the claim detaches any update still in flight: its outcome belongs to
|
|
11276
|
+
//an abandoned opening and must neither be painted nor own the dialog
|
|
11277
|
+
const claim = this.#updates.take();
|
|
11278
|
+
this.#body.replaceChildren();
|
|
11279
|
+
this.#restChrome();
|
|
11280
|
+
this.#loading?.removeAttribute('hidden');
|
|
11281
|
+
this.#body.setAttribute('hidden', '');
|
|
11282
|
+
//update owns its own open-answer-deliver cycle, so it shows the dialog
|
|
11283
|
+
//without going through ask(): a user reopen during the wait is a real
|
|
11284
|
+
//open and goes through ask()
|
|
11285
|
+
this.#show();
|
|
11286
|
+
try {
|
|
11287
|
+
const delivered = await cb();
|
|
11288
|
+
if (claim.stale) {
|
|
11289
|
+
return this.#body;
|
|
11290
|
+
}
|
|
11291
|
+
this.#body.replaceChildren(delivered);
|
|
11292
|
+
this.#loading?.setAttribute('hidden', '');
|
|
11293
|
+
this.#body.removeAttribute('hidden');
|
|
11294
|
+
return this.#body;
|
|
11295
|
+
} catch (/** @type any */ e) {
|
|
11296
|
+
if (!claim.stale) {
|
|
11297
|
+
//revealed before it is filled, so the live region announces the
|
|
11298
|
+
//change rather than being revealed already holding it
|
|
11299
|
+
this.#error?.removeAttribute('hidden');
|
|
11300
|
+
if (this.#error) {
|
|
11301
|
+
this.#error.textContent = Failure.problemsText(e);
|
|
11302
|
+
}
|
|
11303
|
+
this.#loading?.setAttribute('hidden', '');
|
|
11304
|
+
this.#body.setAttribute('hidden', '');
|
|
11305
|
+
}
|
|
11306
|
+
throw e;
|
|
11307
|
+
}
|
|
11308
|
+
}
|
|
10906
11309
|
#request() {
|
|
10907
11310
|
this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
|
|
10908
11311
|
}
|
|
10909
11312
|
/**
|
|
10910
11313
|
* Re-fires section:requested on the body, open or closed: the explicit
|
|
10911
11314
|
* request for a body that wants refreshing. A failed refresh paints its
|
|
10912
|
-
* problems, nothing rejects:
|
|
11315
|
+
* problems, nothing rejects: update() stays the rejecting call.
|
|
10913
11316
|
*/
|
|
10914
11317
|
refresh() {
|
|
10915
11318
|
return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
|
|
@@ -10917,15 +11320,43 @@ var fml = (function (exports) {
|
|
|
10917
11320
|
close(result) {
|
|
10918
11321
|
this.#dialog.close(result ?? '');
|
|
10919
11322
|
}
|
|
11323
|
+
/** Shows the modal, answering whether this call is the one that opened it. */
|
|
11324
|
+
#show() {
|
|
11325
|
+
if (this.#dialog.open) {
|
|
11326
|
+
return false;
|
|
11327
|
+
}
|
|
11328
|
+
//an opening owes nothing to the one before it: the platform keeps
|
|
11329
|
+
//returnValue across a close with no result, and the answer a submit
|
|
11330
|
+
//left is just as stale
|
|
11331
|
+
this.#dialog.returnValue = '';
|
|
11332
|
+
this.#answer = null;
|
|
11333
|
+
this.#dialog.showModal();
|
|
11334
|
+
return true;
|
|
11335
|
+
}
|
|
11336
|
+
#restChrome() {
|
|
11337
|
+
this.#error?.replaceChildren();
|
|
11338
|
+
this.#error?.setAttribute('hidden', '');
|
|
11339
|
+
this.#loading?.setAttribute('hidden', '');
|
|
11340
|
+
this.#body?.removeAttribute('hidden');
|
|
11341
|
+
}
|
|
10920
11342
|
}
|
|
10921
11343
|
|
|
10922
|
-
/**
|
|
11344
|
+
/**
|
|
11345
|
+
* A side panel drawer on the native dialog platform, update() owning its
|
|
11346
|
+
* open-deliver cycle.
|
|
11347
|
+
*
|
|
11348
|
+
* The `header` slot is content beside the title, before it: an icon, a badge, a
|
|
11349
|
+
* status. It sits outside the heading rather than in it because `update()` sets
|
|
11350
|
+
* the title through `textContent`, which would take anything nested there with
|
|
11351
|
+
* it.
|
|
11352
|
+
*/
|
|
10923
11353
|
class Drawer extends ParsedElement {
|
|
10924
|
-
static attributes = ['title', 'placement'];
|
|
11354
|
+
static attributes = ['title', 'placement', 'close-on-submit:presence'];
|
|
10925
11355
|
static slots = true;
|
|
10926
11356
|
static template = `
|
|
10927
11357
|
<dialog data-ref="dialog" class="ful-drawer">
|
|
10928
11358
|
<header>
|
|
11359
|
+
{{{{ slots.header }}}}
|
|
10929
11360
|
<h2 data-ref="title">{{ title }}</h2>
|
|
10930
11361
|
<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
11362
|
</header>
|
|
@@ -10941,6 +11372,10 @@ var fml = (function (exports) {
|
|
|
10941
11372
|
#content;
|
|
10942
11373
|
#requests = new SectionRequests();
|
|
10943
11374
|
#updates = new Claims();
|
|
11375
|
+
//what a submit closed the drawer with, told from a close of any other kind:
|
|
11376
|
+
//a save answering with no body at all is still a save
|
|
11377
|
+
/** @type {{ dismissed: boolean, response: any }|null} */
|
|
11378
|
+
#answer = null;
|
|
10944
11379
|
render({ slots }) {
|
|
10945
11380
|
const fragment = this.template()
|
|
10946
11381
|
.withOverlay({ slots, title: this.declared('title') ?? '' })
|
|
@@ -10956,8 +11391,25 @@ var fml = (function (exports) {
|
|
|
10956
11391
|
}
|
|
10957
11392
|
fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
|
|
10958
11393
|
this.#dialog.addEventListener('close', () => {
|
|
10959
|
-
this.dispatchEvent(
|
|
11394
|
+
this.dispatchEvent(
|
|
11395
|
+
new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
|
|
11396
|
+
);
|
|
10960
11397
|
});
|
|
11398
|
+
if (this.declared('close-on-submit')) {
|
|
11399
|
+
//delegated on the content section rather than bound to the form: a
|
|
11400
|
+
//drawer's form usually arrives with an update() rather than with the
|
|
11401
|
+
//page, and the section outlives every delivery. The form must be the
|
|
11402
|
+
//content's own, a ful-table wrapping its filters in a ful-form of its
|
|
11403
|
+
//own and a search in a table the drawer holds not being the drawer
|
|
11404
|
+
//finishing
|
|
11405
|
+
this.#content.addEventListener('submit:success', (/** @type any */ e) => {
|
|
11406
|
+
if (e.target !== Nodes.queryChildren(this.#content, 'ful-form')) {
|
|
11407
|
+
return;
|
|
11408
|
+
}
|
|
11409
|
+
this.#answer = { dismissed: false, response: e.detail.response };
|
|
11410
|
+
this.close();
|
|
11411
|
+
});
|
|
11412
|
+
}
|
|
10961
11413
|
this.replaceChildren(fragment);
|
|
10962
11414
|
wireTargets();
|
|
10963
11415
|
}
|
|
@@ -11030,6 +11482,9 @@ var fml = (function (exports) {
|
|
|
11030
11482
|
if (this.#dialog.open) {
|
|
11031
11483
|
return false;
|
|
11032
11484
|
}
|
|
11485
|
+
//an opening owes nothing to the one before it: the answer a submit left
|
|
11486
|
+
//belongs to the drawer that closed on it
|
|
11487
|
+
this.#answer = null;
|
|
11033
11488
|
this.#dialog.showModal();
|
|
11034
11489
|
return true;
|
|
11035
11490
|
}
|
|
@@ -11453,6 +11908,7 @@ var fml = (function (exports) {
|
|
|
11453
11908
|
'filters.boolean.false': 'No',
|
|
11454
11909
|
'info.tooltip': 'More information',
|
|
11455
11910
|
'dialog.acknowledge': 'Got it',
|
|
11911
|
+
'dialog.close': 'Close',
|
|
11456
11912
|
'drawer.close': 'Close',
|
|
11457
11913
|
'spinner.loading': 'Loading…',
|
|
11458
11914
|
'toast.region': 'Notifications',
|
|
@@ -11494,6 +11950,7 @@ var fml = (function (exports) {
|
|
|
11494
11950
|
'filters.boolean.false': 'No',
|
|
11495
11951
|
'info.tooltip': 'Maggiori informazioni',
|
|
11496
11952
|
'dialog.acknowledge': 'Ho capito',
|
|
11953
|
+
'dialog.close': 'Chiudi',
|
|
11497
11954
|
'drawer.close': 'Chiudi',
|
|
11498
11955
|
'spinner.loading': 'Caricamento…',
|
|
11499
11956
|
'toast.region': 'Notifiche',
|
|
@@ -11535,6 +11992,7 @@ var fml = (function (exports) {
|
|
|
11535
11992
|
'filters.boolean.false': 'No',
|
|
11536
11993
|
'info.tooltip': 'Más información',
|
|
11537
11994
|
'dialog.acknowledge': 'Entendido',
|
|
11995
|
+
'dialog.close': 'Cerrar',
|
|
11538
11996
|
'drawer.close': 'Cerrar',
|
|
11539
11997
|
'spinner.loading': 'Cargando…',
|
|
11540
11998
|
'toast.region': 'Notificaciones',
|
|
@@ -11579,6 +12037,7 @@ var fml = (function (exports) {
|
|
|
11579
12037
|
'filters.boolean.false': 'Non',
|
|
11580
12038
|
'info.tooltip': 'Plus d’informations',
|
|
11581
12039
|
'dialog.acknowledge': 'J’ai compris',
|
|
12040
|
+
'dialog.close': 'Fermer',
|
|
11582
12041
|
'drawer.close': 'Fermer',
|
|
11583
12042
|
'spinner.loading': 'Chargement…',
|
|
11584
12043
|
'toast.region': 'Notifications',
|
|
@@ -11668,6 +12127,7 @@ var fml = (function (exports) {
|
|
|
11668
12127
|
var ful = /*#__PURE__*/Object.freeze({
|
|
11669
12128
|
__proto__: null,
|
|
11670
12129
|
Accordion: Accordion,
|
|
12130
|
+
Anchors: Anchors,
|
|
11671
12131
|
AsyncEvents: AsyncEvents,
|
|
11672
12132
|
Bindings: Bindings,
|
|
11673
12133
|
BooleanFilter: BooleanFilter,
|
|
@@ -11708,7 +12168,8 @@ var fml = (function (exports) {
|
|
|
11708
12168
|
Tooltip: Tooltip,
|
|
11709
12169
|
VersionedLocalStorage: VersionedLocalStorage,
|
|
11710
12170
|
VersionedSessionStorage: VersionedSessionStorage,
|
|
11711
|
-
Wizard: Wizard
|
|
12171
|
+
Wizard: Wizard,
|
|
12172
|
+
describable: describable
|
|
11712
12173
|
});
|
|
11713
12174
|
|
|
11714
12175
|
if (typeof window !== 'undefined') {
|
|
@@ -11720,6 +12181,7 @@ var fml = (function (exports) {
|
|
|
11720
12181
|
}
|
|
11721
12182
|
|
|
11722
12183
|
exports.Accordion = Accordion;
|
|
12184
|
+
exports.Anchors = Anchors;
|
|
11723
12185
|
exports.AsyncEvents = AsyncEvents;
|
|
11724
12186
|
exports.Attributes = Attributes;
|
|
11725
12187
|
exports.Base64 = Base64;
|
|
@@ -11785,6 +12247,7 @@ var fml = (function (exports) {
|
|
|
11785
12247
|
exports.VersionedLocalStorage = VersionedLocalStorage;
|
|
11786
12248
|
exports.VersionedSessionStorage = VersionedSessionStorage;
|
|
11787
12249
|
exports.Wizard = Wizard;
|
|
12250
|
+
exports.describable = describable;
|
|
11788
12251
|
exports.registry = registry;
|
|
11789
12252
|
|
|
11790
12253
|
return exports;
|