@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/ful.iife.js CHANGED
@@ -681,12 +681,16 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
681
681
  //the error region describes the control, or the host where there is no
682
682
  //single control to describe (a radio group's legend names its fieldset)
683
683
  if (error) {
684
- (described ?? control).ariaDescribedByElements = [error];
684
+ //an attribute, not the aria element property: the property reflects to
685
+ //nothing, so the description lived in the accessibility tree alone and
686
+ //vanished entirely on a browser without aria element reflection
687
+ if (!error.id) {
688
+ error.id = index_mjs.Attributes.uid('ful-field-error');
689
+ }
690
+ (described ?? control).setAttribute('aria-describedby', error.id);
685
691
  }
686
692
  if (label) {
687
- control.ariaLabelledByElements = [label];
688
- //a label that does not natively target the control still focuses it
689
- label.addEventListener('click', () => this.focus());
693
+ Field.#name(this, label, control);
690
694
  }
691
695
  //the platform's implicit submission, stood in for where the field's own
692
696
  //protocol took it away: the inner controls carry form="", so Enter in one
@@ -775,6 +779,49 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
775
779
  }),
776
780
  );
777
781
  }
782
+ /** The html elements a label's `for` may point at, `input[type=hidden]` excepted. */
783
+ static #LABELABLE = new Set(['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA']);
784
+ /**
785
+ * Names the control from the field's label, natively wherever the platform
786
+ * allows it.
787
+ *
788
+ * `for` and `id` are the form the dom itself carries, so the association is
789
+ * there for anything reading the markup rather than the accessibility tree:
790
+ * an audit tool, the browser's autofill, a translation pass. It also makes
791
+ * the label's click reach the control the way it does in a plain form, which
792
+ * is focus for a text control and activation for a checkbox, so the field
793
+ * needs no handler of its own.
794
+ *
795
+ * A control the platform will not let a label target, a composite carrying
796
+ * `role="radiogroup"` among them, takes `aria-labelledby` instead. That is an
797
+ * attribute too, so the association is equally visible; what it does not carry
798
+ * is the label's click, which is why the handler stays on that path only.
799
+ *
800
+ * Neither branch uses `ariaLabelledByElements`. The property reflects to no
801
+ * attribute, so the name lived in the accessibility tree alone: nothing reading
802
+ * the dom saw it, and on a browser without aria element reflection the
803
+ * assignment is a silent expando and the field has no name at all.
804
+ * @param {any} field
805
+ * @param {HTMLElement} label
806
+ * @param {any} control
807
+ */
808
+ static #name(field, label, control) {
809
+ const labelable =
810
+ Field.#LABELABLE.has(control.tagName) && control.getAttribute('type') !== 'hidden';
811
+ if (!labelable) {
812
+ if (!label.id) {
813
+ label.id = index_mjs.Attributes.uid('ful-label');
814
+ }
815
+ control.setAttribute('aria-labelledby', label.id);
816
+ //aria-labelledby carries the name but not the label's click
817
+ label.addEventListener('click', () => field.focus());
818
+ return;
819
+ }
820
+ if (!control.id) {
821
+ control.id = index_mjs.Attributes.uid('ful-control');
822
+ }
823
+ label.setAttribute('for', control.id);
824
+ }
778
825
  /**
779
826
  * Whether the field's chrome should answer a gesture. Badges, dropzones,
780
827
  * menus and labels are not form controls, so their handlers must ask the
@@ -1025,6 +1072,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1025
1072
  'response-mapper',
1026
1073
  'clear-invalid-on-change:presence',
1027
1074
  'scroll-on-error:presence',
1075
+ 'autocomplete',
1028
1076
  ];
1029
1077
  form;
1030
1078
  render() {
@@ -1035,6 +1083,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1035
1083
  //internals messages custom elements have no default UI for
1036
1084
  form.setAttribute('novalidate', '');
1037
1085
  index_mjs.Attributes.forward('form-', this, form);
1086
+ //the fields read it off whichever of the two they reach first, which depends
1087
+ //on whether they upgraded before or after this render: they cannot read it
1088
+ //off their own control, which carries form="" and so has no form owner
1089
+ index_mjs.Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
1038
1090
  form.replaceChildren(...this.childNodes);
1039
1091
  form.addEventListener('submit', async (e) => {
1040
1092
  e.preventDefault();
@@ -1239,6 +1291,22 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1239
1291
  * `reject="[^0-9]"` both leave the digits. Keeping is the one worth reaching for,
1240
1292
  * the rejecting spelling of an allowed set being a double negative.
1241
1293
  */
1294
+ /**
1295
+ * The autofill token a field inherits from the form around it.
1296
+ *
1297
+ * A control is rendered with `form=""` so that the host is the only thing that
1298
+ * submits, which also leaves it without a form owner, and the platform resolves
1299
+ * `autocomplete` through the form owner. So a form declaring it reaches nothing
1300
+ * on its own and the field reads the setting off the form element instead.
1301
+ *
1302
+ * The `form` a `ful-form` renders answers here, the host copying its token onto
1303
+ * it, and a plain `form` around ful fields answers too: the platform meant the
1304
+ * same thing by it, and its inheritance is broken here for the same reason. An
1305
+ * ancestor always upgrades before its descendants, so the rendered form is in
1306
+ * place by the time a field of its own builds.
1307
+ */
1308
+ const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
1309
+
1242
1310
  const warnedBoth = new WeakSet();
1243
1311
  const filterOf = (el) => {
1244
1312
  const keep = el.declared('keep');
@@ -1266,7 +1334,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1266
1334
  static observed = ['placeholder'];
1267
1335
  //configuration: the control is built from them and the value getter reads them,
1268
1336
  //but none of them is meant to change once the element is up
1269
- static attributes = ['type', 'v-type', 'keep', 'reject', 'uppercase:presence', 'trim:presence'];
1337
+ static attributes = [
1338
+ 'type',
1339
+ 'v-type',
1340
+ 'keep',
1341
+ 'reject',
1342
+ 'uppercase:presence',
1343
+ 'trim:presence',
1344
+ 'autocomplete',
1345
+ ];
1270
1346
  static slots = true;
1271
1347
  static template = `
1272
1348
  <label>{{{{ slots.default }}}}</label>
@@ -1290,6 +1366,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1290
1366
  const fragment = this.template().withOverlay({ type, slots }).render();
1291
1367
  this._input = fragment.querySelector('input,textarea');
1292
1368
 
1369
+ //the browser reads autocomplete off the control it is classifying, so the
1370
+ //field's own token, or the form's where it declares none, is put there.
1371
+ //Set before the passthrough, which stays the last word
1372
+ index_mjs.Attributes.set(
1373
+ this._input,
1374
+ 'autocomplete',
1375
+ this.declared('autocomplete') ?? inheritedAutocomplete(this),
1376
+ );
1293
1377
  index_mjs.Attributes.forward('input-', this, this._input);
1294
1378
  this._input.addEventListener('input', (evt) => {
1295
1379
  const strip = filterOf(this);
@@ -1927,8 +2011,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1927
2011
  const box = invoker.getBoundingClientRect();
1928
2012
  const here = popover.getBoundingClientRect();
1929
2013
  //against the padding box, which is what a percentage inset resolves against
1930
- popover.style.setProperty('--ful-note-callout-inline', `${box.left + box.width / 2 - here.left - popover.clientLeft}px`);
1931
- popover.style.setProperty('--ful-note-callout-block', `${box.top + box.height / 2 - here.top - popover.clientTop}px`);
2014
+ popover.style.setProperty(
2015
+ '--ful-note-callout-inline',
2016
+ `${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
2017
+ );
2018
+ popover.style.setProperty(
2019
+ '--ful-note-callout-block',
2020
+ `${box.top + box.height / 2 - here.top - popover.clientTop}px`,
2021
+ );
1932
2022
  };
1933
2023
 
1934
2024
  const place = (popover, anchored) => {
@@ -2032,67 +2122,93 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2032
2122
  };
2033
2123
 
2034
2124
  /**
2035
- * Wires an invoker/popover pair ful anchors through css: where the
2036
- * platform lacks anchor positioning the popover is placed beside its
2037
- * invoker whenever it opens, stretched to the invoker's width when
2038
- * asked, and cleaned up when it closes. Where the css works the call
2039
- * is a no-op.
2040
- *
2041
- * `handPlace` takes the placement here on every platform, which a popover
2042
- * asks for when it needs to know where its invoker ended up: the note's
2043
- * callout points at the invoker, and a pseudo-element cannot read an anchor
2044
- * that is not inside its own containing block, so the note is measured rather
2045
- * than placed by the css. Its stylesheet declares no position-area to match.
2125
+ * CSS anchor positioning for a popover and the invoker it belongs to, with the
2126
+ * hand-placed fallback for the platforms that do not have it.
2046
2127
  */
2047
- const wireAnchoredPopover = (
2048
- invoker,
2049
- popover,
2050
- { prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
2051
- ) => {
2052
- const uid = index_mjs.Attributes.uid(prefix);
2053
- if (invoke) {
2054
- //popovertarget needs a target that can be named
2055
- popover.id = popover.id || uid;
2056
- invoker.setAttribute('popovertarget', popover.id);
2057
- }
2058
- const anchor = `--${uid}`;
2059
- invoker.style.anchorName = anchor;
2060
- popover.style.positionAnchor = anchor;
2061
- if (expanded) {
2062
- invoker.setAttribute('aria-expanded', 'false');
2128
+ class Anchors {
2129
+ /**
2130
+ * Anchors a popover to its invoker.
2131
+ *
2132
+ * The invoker is given an `anchor-name` and the popover a `position-anchor`
2133
+ * pointing at it, which is what a stylesheet needs to place the popover
2134
+ * itself: the library's own menus say `top: anchor(bottom); left:
2135
+ * anchor(left)`. **Writing that css is the caller's half of this.** Without
2136
+ * it the popover lands wherever the user agent puts a popover, which is not
2137
+ * beside the invoker.
2138
+ *
2139
+ * Where the platform has no anchor positioning the popover is placed here
2140
+ * instead, beside the invoker whenever it opens, clamped into the viewport,
2141
+ * following it on scroll and resize, and cleaned up on close. That placement
2142
+ * draws the geometry the css above describes, so the two agree.
2143
+ *
2144
+ * @param {HTMLElement} invoker the element the popover belongs to
2145
+ * @param {HTMLElement} popover the `[popover]` element to place
2146
+ * @param {object} [options]
2147
+ * @param {string} [options.prefix] prefixes the generated anchor name and id,
2148
+ * so the dom says which component a name belongs to
2149
+ * @param {boolean} [options.invoke] points the invoker's `popovertarget` at
2150
+ * the popover, giving toggle and light dismiss with no script of your own
2151
+ * @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
2152
+ * step with the popover
2153
+ * @param {boolean} [options.stretch] widens the popover to its invoker, which
2154
+ * is what a combobox dropdown wants
2155
+ * @param {boolean} [options.handPlace] places here on every platform rather
2156
+ * than only as a fallback, which a popover asks for when it needs to know
2157
+ * where its invoker ended up: the tooltip's note points a callout at it, and
2158
+ * a pseudo-element cannot read an anchor outside its own containing block.
2159
+ * Such a popover declares no anchor placement in css, there being none to
2160
+ * agree with
2161
+ */
2162
+ static wire(
2163
+ invoker,
2164
+ popover,
2165
+ { prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
2166
+ ) {
2167
+ const uid = index_mjs.Attributes.uid(prefix);
2168
+ if (invoke) {
2169
+ //popovertarget needs a target that can be named
2170
+ popover.id = popover.id || uid;
2171
+ invoker.setAttribute('popovertarget', popover.id);
2172
+ }
2173
+ const anchor = `--${uid}`;
2174
+ invoker.style.anchorName = anchor;
2175
+ popover.style.positionAnchor = anchor;
2176
+ if (expanded) {
2177
+ invoker.setAttribute('aria-expanded', 'false');
2178
+ popover.addEventListener('toggle', (/** @type any */ evt) => {
2179
+ invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
2180
+ });
2181
+ }
2182
+ //the naming above is what the stylesheet reads, so it happens either way:
2183
+ //only the hand placement below is the fallback, and only for a popover that
2184
+ //did not ask to be placed here whatever the platform offers
2185
+ if (!handPlace && platformAnchors()) {
2186
+ return;
2187
+ }
2188
+ const anchored = { invoker, stretch };
2189
+ popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
2190
+ //placed before the showing, refined once laid out: the platform's
2191
+ //centered or corner spot never paints
2192
+ if (evt.newState === 'open') {
2193
+ place(popover, anchored);
2194
+ }
2195
+ });
2063
2196
  popover.addEventListener('toggle', (/** @type any */ evt) => {
2064
- invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
2197
+ if (evt.newState === 'open') {
2198
+ open.set(popover, anchored);
2199
+ place(popover, anchored);
2200
+ } else {
2201
+ open.delete(popover);
2202
+ unplace(popover);
2203
+ }
2065
2204
  });
2066
- }
2067
- //the naming above is what the stylesheet reads, so it happens either way:
2068
- //only the hand placement below is the fallback, and only for a popover that
2069
- //did not ask to be placed here whatever the platform offers
2070
- if (!handPlace && platformAnchors()) {
2071
- return;
2072
- }
2073
- const anchored = { invoker, stretch };
2074
- popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
2075
- //placed before the showing, refined once laid out: the platform's
2076
- //centered or corner spot never paints
2077
- if (evt.newState === 'open') {
2078
- place(popover, anchored);
2205
+ if (!reflowWired) {
2206
+ reflowWired = true;
2207
+ document.addEventListener('scroll', schedule, true);
2208
+ window.addEventListener('resize', schedule);
2079
2209
  }
2080
- });
2081
- popover.addEventListener('toggle', (/** @type any */ evt) => {
2082
- if (evt.newState === 'open') {
2083
- open.set(popover, anchored);
2084
- place(popover, anchored);
2085
- } else {
2086
- open.delete(popover);
2087
- unplace(popover);
2088
- }
2089
- });
2090
- if (!reflowWired) {
2091
- reflowWired = true;
2092
- document.addEventListener('scroll', schedule, true);
2093
- window.addEventListener('resize', schedule);
2094
2210
  }
2095
- };
2211
+ }
2096
2212
 
2097
2213
  /**
2098
2214
  * Fetches a select's whole vocabulary from a url and serves every later read
@@ -2621,7 +2737,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2621
2737
  });
2622
2738
  //each pair carries its own anchor: two selects on a page must not share one
2623
2739
  const group = fragment.querySelector('ful-control-group');
2624
- wireAnchoredPopover(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
2740
+ Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
2625
2741
  [this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
2626
2742
  this.#wireChrome();
2627
2743
  this.#wireChips();
@@ -3211,16 +3327,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3211
3327
  evt.stopPropagation();
3212
3328
  this._notifyChange();
3213
3329
  });
3330
+ //the base points the label at the input with for/id, so the click toggles
3331
+ //the way it does in a plain form: the input's own change listener above
3332
+ //carries the notification, and readonly is refused by the freeze below
3214
3333
  const label = fragment.querySelector('label');
3215
- //the label neither wraps the input nor targets it, so the toggle is the
3216
- //field's; the base adds the focus
3217
- label.addEventListener('click', () => {
3218
- if (!this._interactive()) {
3219
- return;
3220
- }
3221
- this.value = !this.value;
3222
- this._notifyChange();
3223
- });
3224
3334
  //a checkbox has no editable text to preserve, so readonly freezes the
3225
3335
  //whole choice, label click included: the container is the frozen piece
3226
3336
  return {
@@ -3999,7 +4109,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3999
4109
  #wire() {
4000
4110
  const button = this.#button;
4001
4111
  const menu = this.#menu;
4002
- wireAnchoredPopover(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
4112
+ Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
4003
4113
  menu.addEventListener('toggle', (/** @type any */ evt) => {
4004
4114
  if (evt.newState !== 'open') {
4005
4115
  //give the invoker back the focus the menu had borrowed, without
@@ -4598,7 +4708,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4598
4708
  //placed here rather than by the anchor css: the note draws a callout that
4599
4709
  //has to point at the trigger wherever the viewport left room for the note,
4600
4710
  //which is a measurement the stylesheet cannot make for a pseudo-element
4601
- wireAnchoredPopover(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
4711
+ Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
4602
4712
  const placement = this.declared('placement');
4603
4713
  if (placement) {
4604
4714
  content.setAttribute('placement', placement);
@@ -4690,13 +4800,22 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4690
4800
  }
4691
4801
  }
4692
4802
 
4693
- /** A side panel drawer on the native dialog platform, update() owning its open-deliver cycle. */
4803
+ /**
4804
+ * A side panel drawer on the native dialog platform, update() owning its
4805
+ * open-deliver cycle.
4806
+ *
4807
+ * The `header` slot is content beside the title, before it: an icon, a badge, a
4808
+ * status. It sits outside the heading rather than in it because `update()` sets
4809
+ * the title through `textContent`, which would take anything nested there with
4810
+ * it.
4811
+ */
4694
4812
  class Drawer extends index_mjs.ParsedElement {
4695
4813
  static attributes = ['title', 'placement'];
4696
4814
  static slots = true;
4697
4815
  static template = `
4698
4816
  <dialog data-ref="dialog" class="ful-drawer">
4699
4817
  <header>
4818
+ {{{{ slots.header }}}}
4700
4819
  <h2 data-ref="title">{{ title }}</h2>
4701
4820
  <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>
4702
4821
  </header>
@@ -5437,6 +5556,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5437
5556
  }
5438
5557
 
5439
5558
  exports.Accordion = Accordion;
5559
+ exports.Anchors = Anchors;
5440
5560
  exports.AsyncEvents = AsyncEvents;
5441
5561
  exports.Bindings = Bindings;
5442
5562
  exports.BooleanFilter = BooleanFilter;