@optionfactory/fml 9.0.0-rc1 → 9.0.0-rc3

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/dist/fml.iife.js CHANGED
@@ -6910,12 +6910,16 @@ var fml = (function (exports) {
6910
6910
  //the error region describes the control, or the host where there is no
6911
6911
  //single control to describe (a radio group's legend names its fieldset)
6912
6912
  if (error) {
6913
- (described ?? control).ariaDescribedByElements = [error];
6913
+ //an attribute, not the aria element property: the property reflects to
6914
+ //nothing, so the description lived in the accessibility tree alone and
6915
+ //vanished entirely on a browser without aria element reflection
6916
+ if (!error.id) {
6917
+ error.id = Attributes.uid('ful-field-error');
6918
+ }
6919
+ (described ?? control).setAttribute('aria-describedby', error.id);
6914
6920
  }
6915
6921
  if (label) {
6916
- control.ariaLabelledByElements = [label];
6917
- //a label that does not natively target the control still focuses it
6918
- label.addEventListener('click', () => this.focus());
6922
+ Field.#name(this, label, control);
6919
6923
  }
6920
6924
  //the platform's implicit submission, stood in for where the field's own
6921
6925
  //protocol took it away: the inner controls carry form="", so Enter in one
@@ -7004,6 +7008,49 @@ var fml = (function (exports) {
7004
7008
  }),
7005
7009
  );
7006
7010
  }
7011
+ /** The html elements a label's `for` may point at, `input[type=hidden]` excepted. */
7012
+ static #LABELABLE = new Set(['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA']);
7013
+ /**
7014
+ * Names the control from the field's label, natively wherever the platform
7015
+ * allows it.
7016
+ *
7017
+ * `for` and `id` are the form the dom itself carries, so the association is
7018
+ * there for anything reading the markup rather than the accessibility tree:
7019
+ * an audit tool, the browser's autofill, a translation pass. It also makes
7020
+ * the label's click reach the control the way it does in a plain form, which
7021
+ * is focus for a text control and activation for a checkbox, so the field
7022
+ * needs no handler of its own.
7023
+ *
7024
+ * A control the platform will not let a label target, a composite carrying
7025
+ * `role="radiogroup"` among them, takes `aria-labelledby` instead. That is an
7026
+ * attribute too, so the association is equally visible; what it does not carry
7027
+ * is the label's click, which is why the handler stays on that path only.
7028
+ *
7029
+ * Neither branch uses `ariaLabelledByElements`. The property reflects to no
7030
+ * attribute, so the name lived in the accessibility tree alone: nothing reading
7031
+ * the dom saw it, and on a browser without aria element reflection the
7032
+ * assignment is a silent expando and the field has no name at all.
7033
+ * @param {any} field
7034
+ * @param {HTMLElement} label
7035
+ * @param {any} control
7036
+ */
7037
+ static #name(field, label, control) {
7038
+ const labelable =
7039
+ Field.#LABELABLE.has(control.tagName) && control.getAttribute('type') !== 'hidden';
7040
+ if (!labelable) {
7041
+ if (!label.id) {
7042
+ label.id = Attributes.uid('ful-label');
7043
+ }
7044
+ control.setAttribute('aria-labelledby', label.id);
7045
+ //aria-labelledby carries the name but not the label's click
7046
+ label.addEventListener('click', () => field.focus());
7047
+ return;
7048
+ }
7049
+ if (!control.id) {
7050
+ control.id = Attributes.uid('ful-control');
7051
+ }
7052
+ label.setAttribute('for', control.id);
7053
+ }
7007
7054
  /**
7008
7055
  * Whether the field's chrome should answer a gesture. Badges, dropzones,
7009
7056
  * menus and labels are not form controls, so their handlers must ask the
@@ -7254,6 +7301,7 @@ var fml = (function (exports) {
7254
7301
  'response-mapper',
7255
7302
  'clear-invalid-on-change:presence',
7256
7303
  'scroll-on-error:presence',
7304
+ 'autocomplete',
7257
7305
  ];
7258
7306
  form;
7259
7307
  render() {
@@ -7264,6 +7312,10 @@ var fml = (function (exports) {
7264
7312
  //internals messages custom elements have no default UI for
7265
7313
  form.setAttribute('novalidate', '');
7266
7314
  Attributes.forward('form-', this, form);
7315
+ //the fields read it off whichever of the two they reach first, which depends
7316
+ //on whether they upgraded before or after this render: they cannot read it
7317
+ //off their own control, which carries form="" and so has no form owner
7318
+ Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
7267
7319
  form.replaceChildren(...this.childNodes);
7268
7320
  form.addEventListener('submit', async (e) => {
7269
7321
  e.preventDefault();
@@ -7468,6 +7520,22 @@ var fml = (function (exports) {
7468
7520
  * `reject="[^0-9]"` both leave the digits. Keeping is the one worth reaching for,
7469
7521
  * the rejecting spelling of an allowed set being a double negative.
7470
7522
  */
7523
+ /**
7524
+ * The autofill token a field inherits from the form around it.
7525
+ *
7526
+ * A control is rendered with `form=""` so that the host is the only thing that
7527
+ * submits, which also leaves it without a form owner, and the platform resolves
7528
+ * `autocomplete` through the form owner. So a form declaring it reaches nothing
7529
+ * on its own and the field reads the setting off the form element instead.
7530
+ *
7531
+ * The `form` a `ful-form` renders answers here, the host copying its token onto
7532
+ * it, and a plain `form` around ful fields answers too: the platform meant the
7533
+ * same thing by it, and its inheritance is broken here for the same reason. An
7534
+ * ancestor always upgrades before its descendants, so the rendered form is in
7535
+ * place by the time a field of its own builds.
7536
+ */
7537
+ const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
7538
+
7471
7539
  const warnedBoth = new WeakSet();
7472
7540
  const filterOf = (el) => {
7473
7541
  const keep = el.declared('keep');
@@ -7495,7 +7563,15 @@ var fml = (function (exports) {
7495
7563
  static observed = ['placeholder'];
7496
7564
  //configuration: the control is built from them and the value getter reads them,
7497
7565
  //but none of them is meant to change once the element is up
7498
- static attributes = ['type', 'v-type', 'keep', 'reject', 'uppercase:presence', 'trim:presence'];
7566
+ static attributes = [
7567
+ 'type',
7568
+ 'v-type',
7569
+ 'keep',
7570
+ 'reject',
7571
+ 'uppercase:presence',
7572
+ 'trim:presence',
7573
+ 'autocomplete',
7574
+ ];
7499
7575
  static slots = true;
7500
7576
  static template = `
7501
7577
  <label>{{{{ slots.default }}}}</label>
@@ -7519,6 +7595,14 @@ var fml = (function (exports) {
7519
7595
  const fragment = this.template().withOverlay({ type, slots }).render();
7520
7596
  this._input = fragment.querySelector('input,textarea');
7521
7597
 
7598
+ //the browser reads autocomplete off the control it is classifying, so the
7599
+ //field's own token, or the form's where it declares none, is put there.
7600
+ //Set before the passthrough, which stays the last word
7601
+ Attributes.set(
7602
+ this._input,
7603
+ 'autocomplete',
7604
+ this.declared('autocomplete') ?? inheritedAutocomplete(this),
7605
+ );
7522
7606
  Attributes.forward('input-', this, this._input);
7523
7607
  this._input.addEventListener('input', (evt) => {
7524
7608
  const strip = filterOf(this);
@@ -8156,8 +8240,14 @@ var fml = (function (exports) {
8156
8240
  const box = invoker.getBoundingClientRect();
8157
8241
  const here = popover.getBoundingClientRect();
8158
8242
  //against the padding box, which is what a percentage inset resolves against
8159
- popover.style.setProperty('--ful-note-callout-inline', `${box.left + box.width / 2 - here.left - popover.clientLeft}px`);
8160
- popover.style.setProperty('--ful-note-callout-block', `${box.top + box.height / 2 - here.top - popover.clientTop}px`);
8243
+ popover.style.setProperty(
8244
+ '--ful-note-callout-inline',
8245
+ `${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
8246
+ );
8247
+ popover.style.setProperty(
8248
+ '--ful-note-callout-block',
8249
+ `${box.top + box.height / 2 - here.top - popover.clientTop}px`,
8250
+ );
8161
8251
  };
8162
8252
 
8163
8253
  const place = (popover, anchored) => {
@@ -8261,67 +8351,93 @@ var fml = (function (exports) {
8261
8351
  };
8262
8352
 
8263
8353
  /**
8264
- * Wires an invoker/popover pair ful anchors through css: where the
8265
- * platform lacks anchor positioning the popover is placed beside its
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.
8354
+ * CSS anchor positioning for a popover and the invoker it belongs to, with the
8355
+ * hand-placed fallback for the platforms that do not have it.
8275
8356
  */
8276
- const wireAnchoredPopover = (
8277
- invoker,
8278
- popover,
8279
- { prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
8280
- ) => {
8281
- const uid = Attributes.uid(prefix);
8282
- if (invoke) {
8283
- //popovertarget needs a target that can be named
8284
- popover.id = popover.id || uid;
8285
- invoker.setAttribute('popovertarget', popover.id);
8286
- }
8287
- const anchor = `--${uid}`;
8288
- invoker.style.anchorName = anchor;
8289
- popover.style.positionAnchor = anchor;
8290
- if (expanded) {
8291
- invoker.setAttribute('aria-expanded', 'false');
8357
+ class Anchors {
8358
+ /**
8359
+ * Anchors a popover to its invoker.
8360
+ *
8361
+ * The invoker is given an `anchor-name` and the popover a `position-anchor`
8362
+ * pointing at it, which is what a stylesheet needs to place the popover
8363
+ * itself: the library's own menus say `top: anchor(bottom); left:
8364
+ * anchor(left)`. **Writing that css is the caller's half of this.** Without
8365
+ * it the popover lands wherever the user agent puts a popover, which is not
8366
+ * beside the invoker.
8367
+ *
8368
+ * Where the platform has no anchor positioning the popover is placed here
8369
+ * instead, beside the invoker whenever it opens, clamped into the viewport,
8370
+ * following it on scroll and resize, and cleaned up on close. That placement
8371
+ * draws the geometry the css above describes, so the two agree.
8372
+ *
8373
+ * @param {HTMLElement} invoker the element the popover belongs to
8374
+ * @param {HTMLElement} popover the `[popover]` element to place
8375
+ * @param {object} [options]
8376
+ * @param {string} [options.prefix] prefixes the generated anchor name and id,
8377
+ * so the dom says which component a name belongs to
8378
+ * @param {boolean} [options.invoke] points the invoker's `popovertarget` at
8379
+ * the popover, giving toggle and light dismiss with no script of your own
8380
+ * @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
8381
+ * step with the popover
8382
+ * @param {boolean} [options.stretch] widens the popover to its invoker, which
8383
+ * is what a combobox dropdown wants
8384
+ * @param {boolean} [options.handPlace] places here on every platform rather
8385
+ * than only as a fallback, which a popover asks for when it needs to know
8386
+ * where its invoker ended up: the tooltip's note points a callout at it, and
8387
+ * a pseudo-element cannot read an anchor outside its own containing block.
8388
+ * Such a popover declares no anchor placement in css, there being none to
8389
+ * agree with
8390
+ */
8391
+ static wire(
8392
+ invoker,
8393
+ popover,
8394
+ { prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
8395
+ ) {
8396
+ const uid = Attributes.uid(prefix);
8397
+ if (invoke) {
8398
+ //popovertarget needs a target that can be named
8399
+ popover.id = popover.id || uid;
8400
+ invoker.setAttribute('popovertarget', popover.id);
8401
+ }
8402
+ const anchor = `--${uid}`;
8403
+ invoker.style.anchorName = anchor;
8404
+ popover.style.positionAnchor = anchor;
8405
+ if (expanded) {
8406
+ invoker.setAttribute('aria-expanded', 'false');
8407
+ popover.addEventListener('toggle', (/** @type any */ evt) => {
8408
+ invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
8409
+ });
8410
+ }
8411
+ //the naming above is what the stylesheet reads, so it happens either way:
8412
+ //only the hand placement below is the fallback, and only for a popover that
8413
+ //did not ask to be placed here whatever the platform offers
8414
+ if (!handPlace && platformAnchors()) {
8415
+ return;
8416
+ }
8417
+ const anchored = { invoker, stretch };
8418
+ popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
8419
+ //placed before the showing, refined once laid out: the platform's
8420
+ //centered or corner spot never paints
8421
+ if (evt.newState === 'open') {
8422
+ place(popover, anchored);
8423
+ }
8424
+ });
8292
8425
  popover.addEventListener('toggle', (/** @type any */ evt) => {
8293
- invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
8426
+ if (evt.newState === 'open') {
8427
+ open.set(popover, anchored);
8428
+ place(popover, anchored);
8429
+ } else {
8430
+ open.delete(popover);
8431
+ unplace(popover);
8432
+ }
8294
8433
  });
8295
- }
8296
- //the naming above is what the stylesheet reads, so it happens either way:
8297
- //only the hand placement below is the fallback, and only for a popover that
8298
- //did not ask to be placed here whatever the platform offers
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);
8434
+ if (!reflowWired) {
8435
+ reflowWired = true;
8436
+ document.addEventListener('scroll', schedule, true);
8437
+ window.addEventListener('resize', schedule);
8308
8438
  }
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
8439
  }
8324
- };
8440
+ }
8325
8441
 
8326
8442
  /**
8327
8443
  * Fetches a select's whole vocabulary from a url and serves every later read
@@ -8850,7 +8966,7 @@ var fml = (function (exports) {
8850
8966
  });
8851
8967
  //each pair carries its own anchor: two selects on a page must not share one
8852
8968
  const group = fragment.querySelector('ful-control-group');
8853
- wireAnchoredPopover(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
8969
+ Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
8854
8970
  [this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
8855
8971
  this.#wireChrome();
8856
8972
  this.#wireChips();
@@ -9440,16 +9556,10 @@ var fml = (function (exports) {
9440
9556
  evt.stopPropagation();
9441
9557
  this._notifyChange();
9442
9558
  });
9559
+ //the base points the label at the input with for/id, so the click toggles
9560
+ //the way it does in a plain form: the input's own change listener above
9561
+ //carries the notification, and readonly is refused by the freeze below
9443
9562
  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
9563
  //a checkbox has no editable text to preserve, so readonly freezes the
9454
9564
  //whole choice, label click included: the container is the frozen piece
9455
9565
  return {
@@ -10228,7 +10338,7 @@ var fml = (function (exports) {
10228
10338
  #wire() {
10229
10339
  const button = this.#button;
10230
10340
  const menu = this.#menu;
10231
- wireAnchoredPopover(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
10341
+ Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
10232
10342
  menu.addEventListener('toggle', (/** @type any */ evt) => {
10233
10343
  if (evt.newState !== 'open') {
10234
10344
  //give the invoker back the focus the menu had borrowed, without
@@ -10827,7 +10937,7 @@ var fml = (function (exports) {
10827
10937
  //placed here rather than by the anchor css: the note draws a callout that
10828
10938
  //has to point at the trigger wherever the viewport left room for the note,
10829
10939
  //which is a measurement the stylesheet cannot make for a pseudo-element
10830
- wireAnchoredPopover(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
10940
+ Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
10831
10941
  const placement = this.declared('placement');
10832
10942
  if (placement) {
10833
10943
  content.setAttribute('placement', placement);
@@ -10919,13 +11029,22 @@ var fml = (function (exports) {
10919
11029
  }
10920
11030
  }
10921
11031
 
10922
- /** A side panel drawer on the native dialog platform, update() owning its open-deliver cycle. */
11032
+ /**
11033
+ * A side panel drawer on the native dialog platform, update() owning its
11034
+ * open-deliver cycle.
11035
+ *
11036
+ * The `header` slot is content beside the title, before it: an icon, a badge, a
11037
+ * status. It sits outside the heading rather than in it because `update()` sets
11038
+ * the title through `textContent`, which would take anything nested there with
11039
+ * it.
11040
+ */
10923
11041
  class Drawer extends ParsedElement {
10924
11042
  static attributes = ['title', 'placement'];
10925
11043
  static slots = true;
10926
11044
  static template = `
10927
11045
  <dialog data-ref="dialog" class="ful-drawer">
10928
11046
  <header>
11047
+ {{{{ slots.header }}}}
10929
11048
  <h2 data-ref="title">{{ title }}</h2>
10930
11049
  <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
11050
  </header>
@@ -11668,6 +11787,7 @@ var fml = (function (exports) {
11668
11787
  var ful = /*#__PURE__*/Object.freeze({
11669
11788
  __proto__: null,
11670
11789
  Accordion: Accordion,
11790
+ Anchors: Anchors,
11671
11791
  AsyncEvents: AsyncEvents,
11672
11792
  Bindings: Bindings,
11673
11793
  BooleanFilter: BooleanFilter,
@@ -11720,6 +11840,7 @@ var fml = (function (exports) {
11720
11840
  }
11721
11841
 
11722
11842
  exports.Accordion = Accordion;
11843
+ exports.Anchors = Anchors;
11723
11844
  exports.AsyncEvents = AsyncEvents;
11724
11845
  exports.Attributes = Attributes;
11725
11846
  exports.Base64 = Base64;