@sb1/indeks-web 0.15.0 → 0.17.0

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/npm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/typeof.js
1
+ //#region \0@oxc-project+runtime@0.139.0/helpers/esm/typeof.js
2
2
  function _typeof(o) {
3
3
  "@babel/helpers - typeof";
4
4
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -8,7 +8,7 @@ function _typeof(o) {
8
8
  }, _typeof(o);
9
9
  }
10
10
  //#endregion
11
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/toPrimitive.js
11
+ //#region \0@oxc-project+runtime@0.139.0/helpers/esm/toPrimitive.js
12
12
  function toPrimitive(t, r) {
13
13
  if ("object" != _typeof(t) || !t) return t;
14
14
  var e = t[Symbol.toPrimitive];
@@ -20,13 +20,13 @@ function toPrimitive(t, r) {
20
20
  return ("string" === r ? String : Number)(t);
21
21
  }
22
22
  //#endregion
23
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/toPropertyKey.js
23
+ //#region \0@oxc-project+runtime@0.139.0/helpers/esm/toPropertyKey.js
24
24
  function toPropertyKey(t) {
25
25
  var i = toPrimitive(t, "string");
26
26
  return "symbol" == _typeof(i) ? i : i + "";
27
27
  }
28
28
  //#endregion
29
- //#region \0@oxc-project+runtime@0.133.0/helpers/esm/defineProperty.js
29
+ //#region \0@oxc-project+runtime@0.139.0/helpers/esm/defineProperty.js
30
30
  function _defineProperty(e, r, t) {
31
31
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
32
32
  value: t,
@@ -36,7 +36,1078 @@ function _defineProperty(e, r, t) {
36
36
  }) : e[r] = t, e;
37
37
  }
38
38
  //#endregion
39
+ //#region lib/components/checkbox-group/IxCheckboxGroup.ts
40
+ /**
41
+ * IxCheckboxGroup — ARIA-lim for checkbox-grupper.
42
+ *
43
+ * ## Designfilosofi
44
+ *
45
+ * Komponenten erstatter ingen native HTML-elementer. Forfatteren skriver
46
+ * native <input type="checkbox">, <label> og hjelpetekster inni
47
+ * <ix-checkbox-group>. Komponenten sin eneste jobb er å sette opp
48
+ * ARIA-koblingene mellom dem. Speiler <ix-radio-group>, med tre bevisste
49
+ * forskjeller (se under).
50
+ *
51
+ * ## Hva komponenten gjør
52
+ *
53
+ * 1. Setter role="group" på host slik at skjermlesere annonserer gruppen og
54
+ * leser legend-teksten som gruppens tilgjengelige navn. (Det finnes ingen
55
+ * "checkboxgroup"-rolle i ARIA — "group" er korrekt for en checkbox-gruppe.)
56
+ *
57
+ * 2. Kobler legend via aria-labelledby — genererer ID på [data-field="legend"]
58
+ * om nødvendig og peker host til den.
59
+ *
60
+ * 3. Setter aria-describedby på host med IDer til description og error
61
+ * (description alltid først). Hopper over elementer som mangler.
62
+ *
63
+ * 4. Genererer stabile IDer på hvert input og setter htmlFor på matchende label
64
+ * om label ikke allerede har det. Respekterer alltid eksisterende IDer.
65
+ *
66
+ * 5. Setter aria-live="polite" på [data-field="error"] og observerer
67
+ * textContent-endringer: ikke-tomt innhold → aria-invalid="true" på host;
68
+ * tomt → aria-invalid fjernes. aria-invalid settes kun på host.
69
+ *
70
+ * 6. Propagerer disabled/readonly til alle inputs via attributeChangedCallback.
71
+ * Per-knapp disabled bevares — settes med en WeakMap ved første _wire().
72
+ *
73
+ * 7. Lytter på childList på host slik at nye <input type="checkbox">-elementer
74
+ * som legges til etter mount automatisk wires (id, name, htmlFor, disabled).
75
+ *
76
+ * ## Forskjeller fra <ix-radio-group>
77
+ *
78
+ * - **role="group"**, ikke "radiogroup".
79
+ * - **Ingen name-mutex.** Checkbox deler ikke `name` for gjensidig utelukkelse.
80
+ * Host-`name` propageres til alle inputs (for form-innsending under felles
81
+ * navn), men når host ikke har name lar vi forfatterens egne name/value stå
82
+ * — vi auto-genererer ikke et felles name.
83
+ * - **Ingen required-triks.** For radio settes `required` kun på første input
84
+ * (Safari-boble-fiks for mutex-gruppen). For checkbox betyr `required` "denne
85
+ * må krysses", så vi rører ikke required automatisk på gruppenivå.
86
+ * - **Tastatur:** checkbox har ingen pil-navigasjon; readonly blokkerer kun
87
+ * Space (toggle) — ikke piltaster.
88
+ *
89
+ * ## Ingen Shadow DOM
90
+ *
91
+ * Komponenten bruker light DOM slik at CSS fra @sb1/indeks-css når alle
92
+ * delelementer direkte.
93
+ *
94
+ * @example
95
+ * <ix-checkbox-group>
96
+ * <span data-field="legend">Hvordan vil du bli kontaktet?</span>
97
+ * <span data-field="description">Velg én eller flere</span>
98
+ * <div data-field="items">
99
+ * <div class="ix-checkbox">
100
+ * <input type="checkbox" value="epost" />
101
+ * <label>E-post</label>
102
+ * </div>
103
+ * </div>
104
+ * <span data-field="error"></span>
105
+ * </ix-checkbox-group>
106
+ */
107
+ var checkboxGroupCounter = 0;
108
+ var checkboxInputCounter = 0;
109
+ var IxCheckboxGroup = class extends HTMLElement {
110
+ constructor(..._args) {
111
+ super(..._args);
112
+ _defineProperty(this, "_errorObserver", null);
113
+ _defineProperty(this, "_childObserver", null);
114
+ _defineProperty(this, "_keydownListener", null);
115
+ _defineProperty(this, "_instanceId", 0);
116
+ _defineProperty(this, "_ownDisabled", /* @__PURE__ */ new WeakMap());
117
+ }
118
+ static get observedAttributes() {
119
+ return [
120
+ "disabled",
121
+ "readonly",
122
+ "name"
123
+ ];
124
+ }
125
+ connectedCallback() {
126
+ this._wire();
127
+ }
128
+ disconnectedCallback() {
129
+ this._errorObserver?.disconnect();
130
+ this._errorObserver = null;
131
+ this._childObserver?.disconnect();
132
+ this._childObserver = null;
133
+ if (this._keydownListener) this.removeEventListener("keydown", this._keydownListener);
134
+ this._keydownListener = null;
135
+ }
136
+ attributeChangedCallback(attr, _oldValue, _newValue) {
137
+ if (!this.isConnected) return;
138
+ if (attr === "disabled" || attr === "readonly") this._syncHostStateToInputs();
139
+ if (attr === "name") this._wireInputs();
140
+ }
141
+ _wire() {
142
+ this.setAttribute("role", "group");
143
+ this._instanceId = ++checkboxGroupCounter;
144
+ const legend = this.querySelector("[data-field=\"legend\"]");
145
+ if (legend) {
146
+ if (!legend.id) legend.id = `ix-checkbox-group-legend-${this._instanceId}`;
147
+ this.setAttribute("aria-labelledby", legend.id);
148
+ }
149
+ const description = this.querySelector("[data-field=\"description\"]");
150
+ const error = this.querySelector("[data-field=\"error\"]");
151
+ const describedBy = [];
152
+ if (description) {
153
+ if (!description.id) description.id = `ix-checkbox-group-desc-${this._instanceId}`;
154
+ describedBy.push(description.id);
155
+ }
156
+ if (error) {
157
+ if (!error.id) error.id = `ix-checkbox-group-error-${this._instanceId}`;
158
+ describedBy.push(error.id);
159
+ error.setAttribute("aria-live", "polite");
160
+ this._errorObserver = new MutationObserver(() => {
161
+ this._syncInvalid(error);
162
+ });
163
+ this._errorObserver.observe(error, {
164
+ childList: true,
165
+ characterData: true,
166
+ subtree: true
167
+ });
168
+ this._syncInvalid(error);
169
+ }
170
+ if (describedBy.length > 0) this.setAttribute("aria-describedby", describedBy.join(" "));
171
+ this._wireInputs();
172
+ this._keydownListener = (e) => {
173
+ if (this.hasAttribute("readonly") && e.key === " ") e.preventDefault();
174
+ };
175
+ this.addEventListener("keydown", this._keydownListener);
176
+ this._childObserver = new MutationObserver((mutations) => {
177
+ const hasInputChange = mutations.some((m) => Array.from(m.addedNodes).some((n) => this._containsCheckboxInput(n)) || Array.from(m.removedNodes).some((n) => this._containsCheckboxInput(n)));
178
+ const hasNameRemoval = mutations.some((m) => m.type === "attributes" && m.attributeName === "name" && m.target instanceof HTMLInputElement && m.target.type === "checkbox" && !m.target.name);
179
+ if (hasInputChange || hasNameRemoval) this._wireInputs();
180
+ });
181
+ this._childObserver.observe(this, {
182
+ childList: true,
183
+ subtree: true,
184
+ attributes: true,
185
+ attributeFilter: ["name"]
186
+ });
187
+ }
188
+ _containsCheckboxInput(node) {
189
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
190
+ const el = node;
191
+ if (el.matches?.("input[type=\"checkbox\"]")) return true;
192
+ return !!el.querySelector?.("input[type=\"checkbox\"]");
193
+ }
194
+ _wireInputs() {
195
+ const inputs = Array.from(this.querySelectorAll("input[type=\"checkbox\"]"));
196
+ if (inputs.length === 0) return;
197
+ for (const input of inputs) {
198
+ if (!input.id) input.id = `ix-checkbox-${++checkboxInputCounter}`;
199
+ const wrapper = input.closest(".ix-checkbox") ?? input.parentElement;
200
+ if (wrapper) {
201
+ const label = wrapper.querySelector("label, .ix-checkbox__label");
202
+ if (label instanceof HTMLLabelElement && !label.htmlFor) label.htmlFor = input.id;
203
+ }
204
+ }
205
+ const hostName = this.getAttribute("name");
206
+ if (hostName) for (const input of inputs) input.name = hostName;
207
+ this._syncHostStateToInputs();
208
+ }
209
+ _syncInvalid(error) {
210
+ if (!!error.textContent?.trim()) this.setAttribute("aria-invalid", "true");
211
+ else this.removeAttribute("aria-invalid");
212
+ }
213
+ _syncHostStateToInputs() {
214
+ const inputs = Array.from(this.querySelectorAll("input[type=\"checkbox\"]"));
215
+ const isDisabled = this.hasAttribute("disabled");
216
+ for (const input of inputs) if (isDisabled) {
217
+ if (!this._ownDisabled.has(input)) this._ownDisabled.set(input, input.disabled);
218
+ input.disabled = true;
219
+ } else if (this._ownDisabled.has(input)) {
220
+ input.disabled = this._ownDisabled.get(input);
221
+ this._ownDisabled.delete(input);
222
+ }
223
+ }
224
+ };
225
+ //#endregion
226
+ //#region lib/components/combobox/IxCombobox.ts
227
+ /**
228
+ * IxCombobox — ARIA-lim for en søkbar select med single- og multiselect.
229
+ *
230
+ * ## Designfilosofi
231
+ *
232
+ * Komponenten erstatter ingen native elementer. Forfatteren skriver <input>,
233
+ * en options-liste og en skjult <select> i light DOM inni <ix-combobox>.
234
+ * Komponentens jobb er å koble dem sammen med korrekt combobox/listbox-ARIA,
235
+ * håndtere tastatur og filtrering, posisjonere lista, og synkronisere valgte
236
+ * verdier til den skjulte <select> for form-innsending.
237
+ *
238
+ * ## Virtual focus
239
+ *
240
+ * DOM-fokus blir ALLTID på <input>. Aktivt listeelement spores med
241
+ * `aria-activedescendant` som peker på option-ens id — fokus flyttes aldri
242
+ * fysisk inn i lista. Dette lar brukeren skrive og pil-navigere samtidig, og
243
+ * er den enkeltbeslutningen hjemmelagde comboboxer oftest bommer på.
244
+ *
245
+ * ## Single vs. multi
246
+ *
247
+ * `multiple` (attributt på host) bytter oppførsel — ikke to komponenter:
248
+ * - Single: valg setter input.value = label, lukker lista, synker én <option>.
249
+ * - Multi: valg legges i et sett, lista holdes åpen, input tømmes, og en
250
+ * <data class="ix-chip" data-removable> rendres i chips-wrapperen.
251
+ * Chips nås med piltast (radiogruppe-modell), ikke egne tab-stopp.
252
+ *
253
+ * ## Posisjonering
254
+ *
255
+ * Lista posisjoneres manuelt med getBoundingClientRect + viewport-clamp (samme
256
+ * grep som tooltip.ts) — position: fixed, flip over feltet ved plassmangel.
257
+ * INGEN popover-API og INGEN avhengigheter: indeks-web er dependency-fritt og
258
+ * bundles til CDN, og manuell posisjonering fungerer på hele browserslist.
259
+ *
260
+ * ## Delelementer (data-field / klasse i light DOM)
261
+ * - .ix-text-field > input → combobox-input
262
+ * - .ix-combobox__toggle → chevron-knapp (åpne/lukke)
263
+ * - .ix-combobox__listbox → listbox-panel
264
+ * - .ix-combobox__option[data-value] → alternativer
265
+ * - .ix-combobox__no-hits → melding ved 0 treff (role="status")
266
+ * - [data-field="chips"] → chips-wrapper (kun multi)
267
+ * - [data-field="native"] (select) → skjult select for form-synk
268
+ *
269
+ * @example Single
270
+ * <ix-combobox data-no-hits-text="Ingen treff">
271
+ * <div class="ix-text-field">
272
+ * <input class="ix-text-field__input" placeholder="Søk …" />
273
+ * <button class="ix-combobox__toggle"></button>
274
+ * </div>
275
+ * <div class="ix-combobox__listbox">
276
+ * <div class="ix-combobox__option" data-value="47">
277
+ * <span class="ix-combobox__option-check" aria-hidden="true"></span>
278
+ * <span class="ix-combobox__option-label">Norge</span>
279
+ * </div>
280
+ * </div>
281
+ * <div class="ix-combobox__no-hits" role="status" hidden>Ingen treff</div>
282
+ * <select data-field="native" hidden></select>
283
+ * </ix-combobox>
284
+ */
285
+ var comboboxCounter = 0;
286
+ var COLLATOR = new Intl.Collator("no", {
287
+ sensitivity: "base",
288
+ usage: "search"
289
+ });
290
+ var IS_TOUCH = typeof navigator !== "undefined" && /iphone|ipad|ipod|android/i.test(navigator.userAgent);
291
+ var IxCombobox = class extends HTMLElement {
292
+ constructor(..._args) {
293
+ super(..._args);
294
+ _defineProperty(this, "_instanceId", 0);
295
+ _defineProperty(this, "_input", null);
296
+ _defineProperty(this, "_listbox", null);
297
+ _defineProperty(this, "_toggle", null);
298
+ _defineProperty(this, "_chips", null);
299
+ _defineProperty(this, "_noHits", null);
300
+ _defineProperty(this, "_noHitsText", "");
301
+ _defineProperty(this, "_select", null);
302
+ _defineProperty(this, "_arrowHint", null);
303
+ _defineProperty(this, "_results", null);
304
+ _defineProperty(this, "_lastAnnouncedCount", -1);
305
+ _defineProperty(this, "_announceTimer", null);
306
+ _defineProperty(this, "_activeOption", null);
307
+ _defineProperty(this, "_onInput", null);
308
+ _defineProperty(this, "_onKeydown", null);
309
+ _defineProperty(this, "_onFocusin", null);
310
+ _defineProperty(this, "_onClick", null);
311
+ _defineProperty(this, "_onDocPointer", null);
312
+ _defineProperty(this, "_onReposition", null);
313
+ _defineProperty(this, "_optionObserver", null);
314
+ }
315
+ static get observedAttributes() {
316
+ return [
317
+ "multiple",
318
+ "disabled",
319
+ "readonly",
320
+ "name",
321
+ "data-no-hits-text",
322
+ "data-arrow-hint-text",
323
+ "data-remove-chip-label",
324
+ "data-chips-label"
325
+ ];
326
+ }
327
+ connectedCallback() {
328
+ this._instanceId = ++comboboxCounter;
329
+ this._wire();
330
+ }
331
+ disconnectedCallback() {
332
+ const input = this._input;
333
+ if (input) {
334
+ if (this._onInput) input.removeEventListener("input", this._onInput);
335
+ if (this._onKeydown) input.removeEventListener("keydown", this._onKeydown);
336
+ }
337
+ if (this._onKeydown) this.removeEventListener("keydown", this._onKeydown);
338
+ if (this._onFocusin) this.removeEventListener("focusin", this._onFocusin);
339
+ if (this._onClick) this.removeEventListener("click", this._onClick);
340
+ if (this._onDocPointer) document.removeEventListener("pointerdown", this._onDocPointer, true);
341
+ this._removeRepositionListeners();
342
+ this._optionObserver?.disconnect();
343
+ if (this._announceTimer) {
344
+ clearTimeout(this._announceTimer);
345
+ this._announceTimer = null;
346
+ }
347
+ this._onInput = null;
348
+ this._onKeydown = null;
349
+ this._onFocusin = null;
350
+ this._onClick = null;
351
+ this._onDocPointer = null;
352
+ this._onReposition = null;
353
+ this._optionObserver = null;
354
+ this._activeOption = null;
355
+ this._arrowHint = null;
356
+ this._results = null;
357
+ }
358
+ attributeChangedCallback(name, _oldValue, newValue) {
359
+ if (!this.isConnected) return;
360
+ if (name === "disabled" || name === "readonly") this._syncControlState();
361
+ else if (name === "multiple") this._syncMultiState();
362
+ else if (name === "name") {
363
+ if (this._select) this._select.name = newValue ?? "";
364
+ } else if (name === "data-remove-chip-label" || name === "data-arrow-hint-text") {
365
+ this._syncChipLabels();
366
+ this._syncArrowHint();
367
+ } else if (name === "data-chips-label") this._syncChipsGroup();
368
+ else if (name === "data-no-hits-text") {
369
+ this._noHitsText = (newValue || "").trim();
370
+ this._updateNoHits();
371
+ }
372
+ }
373
+ get multiple() {
374
+ const attr = this.getAttribute("multiple");
375
+ return attr !== null && attr !== "false";
376
+ }
377
+ set multiple(value) {
378
+ this.toggleAttribute("multiple", value);
379
+ }
380
+ get _options() {
381
+ return this._listbox ? Array.from(this._listbox.querySelectorAll(".ix-combobox__option")) : [];
382
+ }
383
+ get _visibleOptions() {
384
+ return this._options.filter((o) => !o.hidden && o.getAttribute("aria-disabled") !== "true");
385
+ }
386
+ get _isDisabled() {
387
+ return this.hasAttribute("disabled") || this.hasAttribute("readonly");
388
+ }
389
+ _wire() {
390
+ this._input = this.querySelector(".ix-text-field input, input.ix-text-field__input, input");
391
+ this._listbox = this.querySelector(".ix-combobox__listbox");
392
+ this._toggle = this.querySelector(".ix-combobox__toggle");
393
+ this._chips = this.querySelector("[data-field=\"chips\"]");
394
+ this._noHits = this.querySelector(".ix-combobox__no-hits");
395
+ this._select = this.querySelector("select[data-field=\"native\"]");
396
+ const input = this._input;
397
+ const listbox = this._listbox;
398
+ if (!input || !listbox) return;
399
+ if (!this._select) {
400
+ const select = document.createElement("select");
401
+ select.setAttribute("data-field", "native");
402
+ select.hidden = true;
403
+ this.appendChild(select);
404
+ this._select = select;
405
+ }
406
+ const nameAttr = this.getAttribute("name");
407
+ if (nameAttr) this._select.name = nameAttr;
408
+ if (!input.id) input.id = `ix-combobox-input-${this._instanceId}`;
409
+ input.setAttribute("role", "combobox");
410
+ input.setAttribute("aria-autocomplete", "list");
411
+ input.setAttribute("autocomplete", "off");
412
+ input.setAttribute("aria-expanded", "false");
413
+ if (!listbox.id) listbox.id = `ix-combobox-listbox-${this._instanceId}`;
414
+ listbox.setAttribute("role", "listbox");
415
+ listbox.hidden = true;
416
+ input.setAttribute("aria-controls", listbox.id);
417
+ this._options.forEach((option, i) => {
418
+ if (!option.id) option.id = `${listbox.id}-option-${i}`;
419
+ option.setAttribute("role", "option");
420
+ if (this.multiple && !option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
421
+ });
422
+ if (!this.multiple) this._normalizeSelectedAria();
423
+ if (this._toggle) {
424
+ this._toggle.type = "button";
425
+ this._toggle.setAttribute("tabindex", "-1");
426
+ if (IS_TOUCH) this._toggle.setAttribute("aria-hidden", "true");
427
+ }
428
+ if (this._noHits) {
429
+ this._noHitsText = (this.getAttribute("data-no-hits-text") || this._noHits.textContent || "").trim();
430
+ this._noHits.textContent = "";
431
+ this._noHits.removeAttribute("hidden");
432
+ }
433
+ this._ensureResultsEl();
434
+ this._syncMultiState();
435
+ this._syncControlState();
436
+ this._syncFromInitialSelection();
437
+ this._onInput = () => this._handleInput();
438
+ input.addEventListener("input", this._onInput);
439
+ this._onKeydown = (e) => this._handleKeydown(e);
440
+ this.addEventListener("keydown", this._onKeydown);
441
+ this._onClick = (e) => this._handleClick(e);
442
+ this.addEventListener("click", this._onClick);
443
+ this._onFocusin = () => {};
444
+ this._onDocPointer = (e) => {
445
+ if (!this.contains(e.target)) this._close();
446
+ };
447
+ document.addEventListener("pointerdown", this._onDocPointer, true);
448
+ this._optionObserver = new MutationObserver(() => this._rewireOptions());
449
+ this._optionObserver.observe(listbox, {
450
+ childList: true,
451
+ subtree: true
452
+ });
453
+ }
454
+ _rewireOptions() {
455
+ const listbox = this._listbox;
456
+ if (!listbox) return;
457
+ this._options.forEach((option, i) => {
458
+ if (!option.id) option.id = `${listbox.id}-option-${i}`;
459
+ option.setAttribute("role", "option");
460
+ if (this.multiple && !option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
461
+ });
462
+ if (!this.multiple) this._normalizeSelectedAria();
463
+ this._syncFromInitialSelection();
464
+ }
465
+ _normalizeSelectedAria() {
466
+ for (const option of this._options) if (option.getAttribute("aria-selected") !== "true") option.removeAttribute("aria-selected");
467
+ }
468
+ get _isOpen() {
469
+ return !!this._listbox && !this._listbox.hidden;
470
+ }
471
+ _open() {
472
+ if (this._isDisabled || this._isOpen || !this._listbox || !this._input) return;
473
+ this._listbox.hidden = false;
474
+ this._input.setAttribute("aria-expanded", "true");
475
+ this.setAttribute("data-open", "");
476
+ this._position();
477
+ this._addRepositionListeners();
478
+ const selected = this._visibleOptions.find((o) => o.getAttribute("aria-selected") === "true");
479
+ this._setActive(selected ?? this._visibleOptions[0] ?? null);
480
+ }
481
+ _close() {
482
+ if (!this._isOpen || !this._listbox || !this._input) return;
483
+ this._listbox.hidden = true;
484
+ this._input.setAttribute("aria-expanded", "false");
485
+ this.removeAttribute("data-open");
486
+ this._setActive(null);
487
+ this._removeRepositionListeners();
488
+ this._updateNoHits();
489
+ this._resetResults();
490
+ }
491
+ _toggleOpen() {
492
+ if (this._isOpen) this._close();
493
+ else this._open();
494
+ }
495
+ _position() {
496
+ const listbox = this._listbox;
497
+ const field = this.querySelector(".ix-text-field") ?? this._input;
498
+ if (!listbox || !field) return;
499
+ const r = field.getBoundingClientRect();
500
+ const vh = window.innerHeight || 0;
501
+ const gap = 4;
502
+ listbox.style.width = `${r.width}px`;
503
+ listbox.style.left = `${Math.max(8, r.left)}px`;
504
+ const ph = listbox.offsetHeight;
505
+ const spaceBelow = vh - r.bottom;
506
+ if (ph > spaceBelow && r.top > spaceBelow) listbox.style.top = `${Math.max(8, r.top - ph - gap)}px`;
507
+ else listbox.style.top = `${r.bottom + gap}px`;
508
+ }
509
+ _addRepositionListeners() {
510
+ if (this._onReposition) return;
511
+ this._onReposition = () => {
512
+ if (this._isOpen) this._position();
513
+ };
514
+ window.addEventListener("scroll", this._onReposition, true);
515
+ window.addEventListener("resize", this._onReposition);
516
+ }
517
+ _removeRepositionListeners() {
518
+ if (this._onReposition) {
519
+ window.removeEventListener("scroll", this._onReposition, true);
520
+ window.removeEventListener("resize", this._onReposition);
521
+ this._onReposition = null;
522
+ }
523
+ }
524
+ _handleInput() {
525
+ if (this._isDisabled) return;
526
+ const query = (this._input?.value ?? "").trim();
527
+ if (!this._isOpen) this._open();
528
+ else this._position();
529
+ this._filter(query);
530
+ this._setActive(this._visibleOptions[0] ?? null);
531
+ }
532
+ _filter(query) {
533
+ const options = this._options;
534
+ if (!query) options.forEach((o) => o.hidden = false);
535
+ else for (const option of options) option.hidden = !this._searchTexts(option).some((text) => this._match(text, query));
536
+ this._updateNoHits();
537
+ this._announceResults();
538
+ }
539
+ _searchTexts(option) {
540
+ return [
541
+ this._optionLabelText(option),
542
+ option.getAttribute("data-value") ?? "",
543
+ this._optionDescriptionText(option)
544
+ ].filter((text) => text.length > 0);
545
+ }
546
+ _match(text, query) {
547
+ const l = text.length;
548
+ const q = query.length;
549
+ if (q === 0) return true;
550
+ if (q > l) return false;
551
+ for (let i = 0; i + q <= l; i++) if (COLLATOR.compare(text.slice(i, i + q), query) === 0) return true;
552
+ return false;
553
+ }
554
+ _updateNoHits() {
555
+ if (!this._noHits) return;
556
+ const show = this._isOpen && this._visibleOptions.length === 0;
557
+ this._noHits.textContent = show ? this._noHitsText : "";
558
+ }
559
+ _optionLabelText(option) {
560
+ return (option.querySelector(".ix-combobox__option-label")?.textContent ?? option.textContent ?? "").trim();
561
+ }
562
+ _optionDescriptionText(option) {
563
+ return (option.querySelector(".ix-combobox__option-description")?.textContent ?? "").trim();
564
+ }
565
+ _setActive(option) {
566
+ if (this._activeOption && this._activeOption !== option) this._activeOption.removeAttribute("data-active");
567
+ this._activeOption = option;
568
+ if (option) {
569
+ option.setAttribute("data-active", "");
570
+ this._input?.setAttribute("aria-activedescendant", option.id);
571
+ option.scrollIntoView?.({ block: "nearest" });
572
+ } else this._input?.removeAttribute("aria-activedescendant");
573
+ }
574
+ _moveActive(delta) {
575
+ const visible = this._visibleOptions;
576
+ if (visible.length === 0) return;
577
+ let next = (this._activeOption ? visible.indexOf(this._activeOption) : -1) + delta;
578
+ if (next < 0) next = 0;
579
+ if (next >= visible.length) next = visible.length - 1;
580
+ this._setActive(visible[next]);
581
+ }
582
+ _handleKeydown(e) {
583
+ if (this._isDisabled) return;
584
+ const target = e.target;
585
+ if (e.key === "Escape" && this._isOpen) {
586
+ e.preventDefault();
587
+ this._close();
588
+ this._input?.focus();
589
+ return;
590
+ }
591
+ if (target instanceof HTMLDataElement && target.classList.contains("ix-chip")) {
592
+ this._handleChipKeydown(e, target);
593
+ return;
594
+ }
595
+ if (target !== this._input) return;
596
+ this._handleInputKeydown(e);
597
+ }
598
+ _handleInputKeydown(e) {
599
+ const input = this._input;
600
+ if (!input) return;
601
+ switch (e.key) {
602
+ case "ArrowDown":
603
+ e.preventDefault();
604
+ if (!this._isOpen) this._open();
605
+ else this._moveActive(1);
606
+ break;
607
+ case "ArrowUp":
608
+ e.preventDefault();
609
+ if (!this._isOpen) this._open();
610
+ else this._moveActive(-1);
611
+ break;
612
+ case "Enter":
613
+ if (this._isOpen && this._activeOption) {
614
+ e.preventDefault();
615
+ this._selectOption(this._activeOption);
616
+ }
617
+ break;
618
+ case "ArrowLeft":
619
+ case "Backspace":
620
+ if (this.multiple && input.selectionStart === 0 && input.selectionEnd === 0) {
621
+ const chips = this._chipElements();
622
+ const last = chips[chips.length - 1];
623
+ if (last) {
624
+ e.preventDefault();
625
+ last.focus();
626
+ }
627
+ }
628
+ break;
629
+ }
630
+ }
631
+ _handleChipKeydown(e, chip) {
632
+ const chips = this._chipElements();
633
+ const index = chips.indexOf(chip);
634
+ switch (e.key) {
635
+ case "ArrowLeft":
636
+ e.preventDefault();
637
+ chips[index - 1]?.focus();
638
+ break;
639
+ case "ArrowRight":
640
+ e.preventDefault();
641
+ (chips[index + 1] ?? this._input)?.focus();
642
+ break;
643
+ case "Backspace":
644
+ case "Delete":
645
+ case "Enter":
646
+ case " ": {
647
+ e.preventDefault();
648
+ const value = chip.getAttribute("value") ?? "";
649
+ const next = chips[index + 1] ?? chips[index - 1] ?? this._input;
650
+ this._deselectValue(value);
651
+ next?.focus();
652
+ break;
653
+ }
654
+ }
655
+ }
656
+ _handleClick(e) {
657
+ if (this._isDisabled) return;
658
+ const target = e.target;
659
+ if (this._toggle && this._toggle.contains(target)) {
660
+ e.preventDefault();
661
+ this._input?.focus();
662
+ this._toggleOpen();
663
+ return;
664
+ }
665
+ const chip = target.closest("data.ix-chip");
666
+ if (chip && this.contains(chip)) {
667
+ e.preventDefault();
668
+ this._deselectValue(chip.getAttribute("value") ?? "");
669
+ this._input?.focus();
670
+ return;
671
+ }
672
+ const option = target.closest(".ix-combobox__option");
673
+ if (option && this._listbox?.contains(option)) {
674
+ if (option.getAttribute("aria-disabled") === "true") return;
675
+ this._selectOption(option);
676
+ return;
677
+ }
678
+ if (this._input && (target === this._input || this.querySelector(".ix-text-field")?.contains(target))) {
679
+ this._input.focus();
680
+ if (!this._isOpen) this._open();
681
+ }
682
+ }
683
+ _selectOption(option) {
684
+ const value = option.getAttribute("data-value") ?? "";
685
+ const label = this._optionLabelText(option);
686
+ if (this.multiple) {
687
+ if (option.getAttribute("aria-selected") === "true") this._deselectValue(value);
688
+ else {
689
+ option.setAttribute("aria-selected", "true");
690
+ this._addChip(value, label);
691
+ this._syncSelect();
692
+ this._emitChange();
693
+ }
694
+ if (this._input) this._input.value = "";
695
+ this._filter("");
696
+ this._setActive(option);
697
+ } else {
698
+ for (const o of this._options) if (o === option) o.setAttribute("aria-selected", "true");
699
+ else o.removeAttribute("aria-selected");
700
+ if (this._input) this._input.value = label;
701
+ this._syncSelect();
702
+ this._emitChange();
703
+ this._close();
704
+ }
705
+ }
706
+ _deselectValue(value) {
707
+ this._options.find((o) => (o.getAttribute("data-value") ?? "") === value)?.setAttribute("aria-selected", "false");
708
+ this._removeChip(value);
709
+ this._syncSelect();
710
+ this._emitChange();
711
+ }
712
+ _syncFromInitialSelection() {
713
+ const selected = this._options.filter((o) => o.getAttribute("aria-selected") === "true");
714
+ if (this.multiple) for (const option of selected) {
715
+ const value = option.getAttribute("data-value") ?? "";
716
+ if (!this._chipElements().some((c) => c.getAttribute("value") === value)) this._addChip(value, this._optionLabelText(option));
717
+ }
718
+ else if (selected[0] && this._input && !this._input.value) this._input.value = this._optionLabelText(selected[0]);
719
+ this._syncSelect();
720
+ }
721
+ _chipElements() {
722
+ return this._chips ? Array.from(this._chips.querySelectorAll("data.ix-chip")) : [];
723
+ }
724
+ _addChip(value, label) {
725
+ if (!this._chips) return;
726
+ if (this._chipElements().some((c) => c.getAttribute("value") === value)) return;
727
+ const chip = document.createElement("data");
728
+ chip.className = "ix-chip";
729
+ chip.setAttribute("data-removable", "");
730
+ chip.setAttribute("value", value);
731
+ chip.setAttribute("role", "button");
732
+ chip.setAttribute("tabindex", "-1");
733
+ chip.textContent = label;
734
+ chip.setAttribute("aria-label", this._chipLabel(label));
735
+ this._chips.appendChild(chip);
736
+ }
737
+ _removeChip(value) {
738
+ this._chipElements().find((c) => c.getAttribute("value") === value)?.remove();
739
+ }
740
+ _chipLabel(label) {
741
+ const remove = this.getAttribute("data-remove-chip-label");
742
+ return remove ? `${label}, ${remove}` : label;
743
+ }
744
+ _syncChipLabels() {
745
+ for (const chip of this._chipElements()) chip.setAttribute("aria-label", this._chipLabel((chip.textContent ?? "").trim()));
746
+ }
747
+ _syncSelect() {
748
+ const select = this._select;
749
+ if (!select) return;
750
+ select.toggleAttribute("multiple", this.multiple);
751
+ const selectedOptions = this._options.filter((o) => o.getAttribute("aria-selected") === "true");
752
+ select.textContent = "";
753
+ for (const option of selectedOptions) {
754
+ const value = option.getAttribute("data-value") ?? "";
755
+ const opt = new Option(this._optionLabelText(option), value, true, true);
756
+ select.add(opt);
757
+ }
758
+ }
759
+ _emitChange() {
760
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
761
+ }
762
+ _syncControlState() {
763
+ const input = this._input;
764
+ if (!input) return;
765
+ const disabled = this.hasAttribute("disabled");
766
+ const readonly = this.hasAttribute("readonly");
767
+ input.disabled = disabled;
768
+ input.readOnly = readonly;
769
+ this.toggleAttribute("data-disabled", disabled);
770
+ this.toggleAttribute("data-readonly", readonly);
771
+ if (disabled || readonly) this._close();
772
+ }
773
+ _syncMultiState() {
774
+ const multiple = this.multiple;
775
+ if (this._listbox) this._listbox.setAttribute("aria-multiselectable", multiple ? "true" : "false");
776
+ this._syncChipsGroup();
777
+ this._syncArrowHint();
778
+ this._syncSelect();
779
+ }
780
+ _syncChipsGroup() {
781
+ const chips = this._chips;
782
+ if (!chips) return;
783
+ if (this.multiple) {
784
+ chips.setAttribute("role", "group");
785
+ const label = this.getAttribute("data-chips-label");
786
+ if (label) chips.setAttribute("aria-label", label);
787
+ else chips.removeAttribute("aria-label");
788
+ } else {
789
+ chips.removeAttribute("role");
790
+ chips.removeAttribute("aria-label");
791
+ }
792
+ }
793
+ _syncArrowHint() {
794
+ if (!this._input) return;
795
+ const hint = this.getAttribute("data-arrow-hint-text");
796
+ if (this.multiple && hint) {
797
+ const el = this._ensureArrowHintEl();
798
+ el.textContent = hint;
799
+ this._addDescribedBy(el.id);
800
+ } else if (this._arrowHint) {
801
+ this._arrowHint.textContent = "";
802
+ this._removeDescribedBy(this._arrowHint.id);
803
+ }
804
+ }
805
+ _ensureArrowHintEl() {
806
+ if (!this._arrowHint) {
807
+ const el = document.createElement("span");
808
+ el.className = "ix-sr-only";
809
+ el.id = `ix-combobox-arrow-hint-${this._instanceId}`;
810
+ this.appendChild(el);
811
+ this._arrowHint = el;
812
+ }
813
+ return this._arrowHint;
814
+ }
815
+ _addDescribedBy(id) {
816
+ const input = this._input;
817
+ if (!input || !id) return;
818
+ const tokens = (input.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean);
819
+ if (!tokens.includes(id)) {
820
+ tokens.push(id);
821
+ input.setAttribute("aria-describedby", tokens.join(" "));
822
+ }
823
+ }
824
+ _removeDescribedBy(id) {
825
+ const input = this._input;
826
+ if (!input || !id) return;
827
+ const next = (input.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean).filter((t) => t !== id);
828
+ if (next.length) input.setAttribute("aria-describedby", next.join(" "));
829
+ else input.removeAttribute("aria-describedby");
830
+ }
831
+ _ensureResultsEl() {
832
+ if (!this._results) {
833
+ const el = document.createElement("div");
834
+ el.className = "ix-sr-only";
835
+ el.setAttribute("role", "status");
836
+ el.id = `ix-combobox-results-${this._instanceId}`;
837
+ this.appendChild(el);
838
+ this._results = el;
839
+ }
840
+ return this._results;
841
+ }
842
+ _announceResults() {
843
+ const el = this._results;
844
+ if (!el) return;
845
+ const count = this._visibleOptions.length;
846
+ if (!this._isOpen || count === 0) return;
847
+ if (count === this._lastAnnouncedCount) return;
848
+ const template = this.getAttribute("data-results-text");
849
+ if (!template) return;
850
+ const text = template.replace("{n}", String(count));
851
+ if (this._announceTimer) clearTimeout(this._announceTimer);
852
+ this._announceTimer = setTimeout(() => {
853
+ if (!this.isConnected) return;
854
+ el.textContent = text;
855
+ this._lastAnnouncedCount = count;
856
+ }, 250);
857
+ }
858
+ _resetResults() {
859
+ if (this._announceTimer) {
860
+ clearTimeout(this._announceTimer);
861
+ this._announceTimer = null;
862
+ }
863
+ this._lastAnnouncedCount = -1;
864
+ if (this._results) this._results.textContent = "";
865
+ }
866
+ };
867
+ //#endregion
868
+ //#region lib/components/field/formats.ts
869
+ /**
870
+ * Slot-definisjoner for pattern-strenger. Et pattern består av slot-tegn som
871
+ * brukeren fyller, og faste separatorer (alt annet) som settes inn automatisk.
872
+ *
873
+ * `0` → ett siffer
874
+ * `a` → én bokstav (inkl. æ ø å)
875
+ * `*` → hvilket som helst tegn
876
+ *
877
+ * Alt annet i patternet er en fast separator: `"000 00 000"`, `"00.00.0000"`.
878
+ */
879
+ var SLOT_DEFINITIONS = {
880
+ "0": /\d/,
881
+ a: /[a-zA-ZæøåÆØÅ]/,
882
+ "*": /[^\s]/
883
+ };
884
+ function compileTokens(pattern) {
885
+ const tokens = [];
886
+ for (const char of pattern) {
887
+ const slot = SLOT_DEFINITIONS[char];
888
+ tokens.push(slot ? {
889
+ type: "slot",
890
+ match: slot
891
+ } : {
892
+ type: "literal",
893
+ value: char
894
+ });
895
+ }
896
+ return tokens;
897
+ }
898
+ /**
899
+ * Lager en formatter fra en pattern-streng.
900
+ *
901
+ * `format` fyller slots med matchende tegn fra den rå verdien og setter inn
902
+ * separatorer først når neste slot faktisk fylles (ingen dinglende separator på
903
+ * slutten mens man skriver). Tegn som ikke passer neste slot — enten fordi
904
+ * brukeren skrev flere tegn enn patternet har plasser, eller skrev noe som ikke
905
+ * hører hjemme (bokstav i et sifferfelt) — droppes IKKE: den ledende delen som
906
+ * passer formateres, og resten legges uformatert på til slutt i samme
907
+ * rekkefølge brukeren skrev. Alt brukeren skriver forblir synlig.
908
+ *
909
+ * `parse` er tapsfri: den fjerner kun separator-tegnene patternet definerer
910
+ * (literal-tokens) og beholder alt annet i rekkefølge — ingen cap på antall
911
+ * slots, ingen filtrering av «ugyldige» tegn. Slik overlever et feilaktig tegn
912
+ * (bokstav i et sifferfelt) i den rå verdien og kan fanges av validering.
913
+ */
914
+ function createPatternFormatter(pattern) {
915
+ const tokens = compileTokens(pattern);
916
+ const separators = new Set(tokens.filter((t) => t.type === "literal").map((t) => t.value));
917
+ return {
918
+ parse(display) {
919
+ let raw = "";
920
+ for (const char of display) if (!separators.has(char)) raw += char;
921
+ return raw;
922
+ },
923
+ format(raw) {
924
+ const chars = [...raw];
925
+ let out = "";
926
+ let pendingLiterals = "";
927
+ let i = 0;
928
+ for (const token of tokens) {
929
+ if (token.type === "literal") {
930
+ pendingLiterals += token.value;
931
+ continue;
932
+ }
933
+ if (i >= chars.length || !token.match.test(chars[i])) break;
934
+ out += pendingLiterals + chars[i];
935
+ pendingLiterals = "";
936
+ i++;
937
+ }
938
+ return out + chars.slice(i).join("");
939
+ }
940
+ };
941
+ }
942
+ /**
943
+ * Beløp er ikke en fast-lengde pattern-maske: lengden varierer og tusenskillet
944
+ * forskyver seg mens man skriver. Derfor en egen formatter.
945
+ *
946
+ * Rå verdi = siffer med valgfritt desimalskilletegn (`decimalSeparator`), uten
947
+ * tusenskille: `"1234567,89"`. Visning = med tusenskille: `"1 234 567,89"`.
948
+ */
949
+ function createAmountFormatter(options = {}) {
950
+ const group = options.groupSeparator ?? " ";
951
+ const decimal = options.decimalSeparator ?? ",";
952
+ const stripToRaw = (display) => {
953
+ return display.split(group).join("");
954
+ };
955
+ return {
956
+ parse: stripToRaw,
957
+ format(raw) {
958
+ const chars = [...raw];
959
+ let leading = "";
960
+ let seenDecimal = false;
961
+ let i = 0;
962
+ for (; i < chars.length; i++) {
963
+ const char = chars[i];
964
+ if (char >= "0" && char <= "9") leading += char;
965
+ else if (char === decimal && !seenDecimal) {
966
+ leading += decimal;
967
+ seenDecimal = true;
968
+ } else break;
969
+ }
970
+ const rest = chars.slice(i).join("");
971
+ if (leading === "") return rest;
972
+ const [intPart, ...decimals] = leading.split(decimal);
973
+ const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, group);
974
+ return (decimals.length > 0 ? `${grouped}${decimal}${decimals.join("")}` : grouped) + rest;
975
+ }
976
+ };
977
+ }
978
+ /**
979
+ * Utleder tusenskille og desimalskilletegn for en locale via `Intl.NumberFormat`.
980
+ * Brukes for locale-bevisst beløpsformatering uten å hardkode separatorer.
981
+ */
982
+ function amountFormatterForLocale(locale) {
983
+ const parts = new Intl.NumberFormat(locale).formatToParts(1234567.8);
984
+ return createAmountFormatter({
985
+ groupSeparator: parts.find((p) => p.type === "group")?.value ?? " ",
986
+ decimalSeparator: parts.find((p) => p.type === "decimal")?.value ?? ","
987
+ });
988
+ }
989
+ var registry = /* @__PURE__ */ new Map();
990
+ /**
991
+ * Registrerer en navngitt formatter som deretter kan brukes via
992
+ * `data-format="<navn>"` på et input inne i et ix-field, hvor som helst i appen.
993
+ *
994
+ * Team kan registrere sine egne varianter (orgnr, kortnummer, …) uten at
995
+ * designsystemet må endres — «felles, men ikke avhengig av oss».
996
+ */
997
+ function registerFormat(name, formatter) {
998
+ registry.set(name, formatter);
999
+ }
1000
+ /** Slår opp en registrert formatter. Returnerer `undefined` om navnet er ukjent. */
1001
+ function resolveFormat(name) {
1002
+ return registry.get(name);
1003
+ }
1004
+ registerFormat("phone", {
1005
+ ...createPatternFormatter("000 00 000"),
1006
+ live: true
1007
+ });
1008
+ registerFormat("ssn", {
1009
+ ...createPatternFormatter("000000 00000"),
1010
+ live: true
1011
+ });
1012
+ registerFormat("date", {
1013
+ ...createPatternFormatter("00.00.0000"),
1014
+ live: true
1015
+ });
1016
+ registerFormat("account", {
1017
+ ...createPatternFormatter("0000 00 00000"),
1018
+ live: true
1019
+ });
1020
+ registerFormat("orgnr", {
1021
+ ...createPatternFormatter("000 000 000"),
1022
+ live: true
1023
+ });
1024
+ registerFormat("amount", {
1025
+ ...createAmountFormatter({
1026
+ groupSeparator: " ",
1027
+ decimalSeparator: ","
1028
+ }),
1029
+ live: true
1030
+ });
1031
+ //#endregion
39
1032
  //#region lib/components/field/IxField.ts
1033
+ /**
1034
+ * IxField — tilgjengelighetslim mellom label, input og hjelpetekster.
1035
+ *
1036
+ * ## Designfilosofi
1037
+ *
1038
+ * Komponenten erstatter ingen native HTML-elementer. `<label>`, `<input>`,
1039
+ * `<select>`, `<textarea>` og hjelpetekster skrives som vanlig HTML inne i
1040
+ * `<ix-field>`. Komponenten sin eneste jobb er å sette opp ARIA-koblingene
1041
+ * mellom dem — arbeid som er kjedelig å gjøre manuelt og lett å gjøre feil.
1042
+ *
1043
+ * Denne tilnærmingen betyr at:
1044
+ * - Native nettleser-atferd, validering og styling forblir intakt.
1045
+ * - Alle form-attributter (`required`, `disabled`, `name`, `value`, osv.)
1046
+ * settes direkte på native elementet — ikke som props til en wrapper.
1047
+ * - Komponenten kan brukes med alle typer input uten kodeendringer.
1048
+ *
1049
+ * ## Hva komponenten gjør
1050
+ *
1051
+ * 1. Kobler `<label>` til input via `for`/`id`-paret.
1052
+ * Genererer en stabil ID på input om den mangler en.
1053
+ *
1054
+ * 2. Setter `aria-describedby` på input med IDer til description og
1055
+ * error-elementet, slik at skjermlesere leser dem opp etter label og verdi.
1056
+ *
1057
+ * 3. Setter `aria-live="polite"` på error-elementet.
1058
+ * Error-elementet skal alltid ligge i DOM — tomt betyr ingen feil.
1059
+ * Dette er et krav fra ARIA Live Regions: elementet må eksistere i DOM
1060
+ * *før* innholdet settes inn for at skjermlesere skal fange opp endringen.
1061
+ * Hadde vi injisert elementet dynamisk ville mange skjermlesere gått glipp
1062
+ * av meldingen.
1063
+ *
1064
+ * Vi bruker *ikke* `role="alert"` fordi det impliserer `aria-live="assertive"`
1065
+ * som avbryter brukeren. Feilmeldinger i skjema er viktige, men ikke så
1066
+ * kritiske at de bør avbryte — `polite` venter til skjermleseren er ferdig.
1067
+ *
1068
+ * 4. Bruker MutationObserver på error-elementet. Når innholdet endres:
1069
+ * - Ikke-tomt → `aria-invalid="true"` på input (visuell og semantisk feil)
1070
+ * - Tomt → `aria-invalid` fjernes
1071
+ * Applikasjonen styrer altså feilmelding ved å sette/tømme textContent —
1072
+ * komponenten holder input-tilstanden synkronisert automatisk.
1073
+ *
1074
+ * ## Identifisering av delelementer
1075
+ *
1076
+ * Komponenten finner delelementer via:
1077
+ * - `<label>` → første label-element
1078
+ * - `input | select | textarea` → første native form-kontroll
1079
+ * - `[data-field="description"]` → hjelpetekst
1080
+ * - `[data-field="error"]` → feilmelding (alltid til stede, kan være tom)
1081
+ * - `[data-field="prefix"]` → visuell prefix (skjules med aria-hidden)
1082
+ * - `[data-field="suffix"]` → visuell suffix (skjules med aria-hidden)
1083
+ *
1084
+ * `data-field` brukes fremfor `slot`-attributtet selv om Shadow DOM ikke er i
1085
+ * bruk. `slot` er et reservert nøkkelord knytt til Shadow DOM og ville vært
1086
+ * misvisende, samt kunne krasje om `<ix-field>` senere pakkes inn i en
1087
+ * komponent med Shadow DOM.
1088
+ *
1089
+ * ## Ingen Shadow DOM
1090
+ *
1091
+ * Komponenten bruker light DOM. Det betyr at CSS fra `@sb1/indeks-css` når
1092
+ * alle delelementer uten videre, og at ARIA-attributtene er fullt synlige i
1093
+ * den tilgjengelighetstreet nettleseren eksponerer til hjelpemiddelteknologi.
1094
+ *
1095
+ * @example Grunnleggende bruk
1096
+ * <ix-field>
1097
+ * <label>E-postadresse</label>
1098
+ * <span data-field="description">Vi sender kvittering til denne adressen</span>
1099
+ * <input type="email" name="email" />
1100
+ * <span data-field="error"></span>
1101
+ * </ix-field>
1102
+ *
1103
+ * @example Sett feilmelding programmatisk
1104
+ * const errorEl = document.querySelector('ix-field [data-field="error"]');
1105
+ * errorEl.textContent = 'Oppgi en gyldig e-postadresse';
1106
+ * // → input får automatisk aria-invalid="true"
1107
+ *
1108
+ * errorEl.textContent = '';
1109
+ * // → aria-invalid fjernes
1110
+ */
40
1111
  var fieldCounter = 0;
41
1112
  var ICON_URL = "https://cdn.sparebank1.no/icons/info.svg";
42
1113
  var IxField = class extends HTMLElement {
@@ -46,6 +1117,43 @@ var IxField = class extends HTMLElement {
46
1117
  _defineProperty(this, "_stateObserver", null);
47
1118
  _defineProperty(this, "_charCountListener", null);
48
1119
  _defineProperty(this, "_charCountControl", null);
1120
+ _defineProperty(this, "_formatterProp", null);
1121
+ _defineProperty(this, "_formatTeardown", null);
1122
+ _defineProperty(this, "_activeFormatter", null);
1123
+ _defineProperty(this, "_activeControl", null);
1124
+ _defineProperty(this, "_formatMirror", null);
1125
+ }
1126
+ /**
1127
+ * Sett en formatter direkte på instansen (høyest presedens). Lar konsumenten
1128
+ * gi vilkårlig format/parse-logikk som ikke kan uttrykkes som attributt.
1129
+ */
1130
+ set formatter(value) {
1131
+ this._formatterProp = value;
1132
+ if (this.isConnected) this._wireFormatting();
1133
+ }
1134
+ get formatter() {
1135
+ return this._formatterProp;
1136
+ }
1137
+ /**
1138
+ * Den rå (uformaterte) verdien til feltet. Sannheten ligger i den skjulte
1139
+ * mirror-inputen; uten mirror (feltet mangler `name`) utledes den ved å
1140
+ * `parse()` den synlige verdien. Tom streng når ingen formatter er aktiv
1141
+ * eller det ikke finnes noe input.
1142
+ *
1143
+ * Praktisk for ren JS/HTML: `document.querySelector('ix-field').rawValue`.
1144
+ * Merk at `<form>`-innsending og `new FormData(form)` også gir rå verdi
1145
+ * automatisk, siden mirror-inputen bærer det opprinnelige `name`-et.
1146
+ */
1147
+ get rawValue() {
1148
+ if (this._formatMirror) return this._formatMirror.value;
1149
+ const control = this._activeControl;
1150
+ const formatter = this._activeFormatter;
1151
+ if (!control) return "";
1152
+ return formatter ? formatter.parse(control.value) : control.value;
1153
+ }
1154
+ /** True når den aktive formatteren formaterer live (mens man skriver). */
1155
+ get live() {
1156
+ return !!this._activeFormatter?.live;
49
1157
  }
50
1158
  static get observedAttributes() {
51
1159
  return [
@@ -57,6 +1165,7 @@ var IxField = class extends HTMLElement {
57
1165
  connectedCallback() {
58
1166
  this._wire();
59
1167
  this._syncTooltip();
1168
+ this._wireFormatting();
60
1169
  }
61
1170
  disconnectedCallback() {
62
1171
  this._observer?.disconnect();
@@ -64,6 +1173,7 @@ var IxField = class extends HTMLElement {
64
1173
  this._stateObserver?.disconnect();
65
1174
  this._stateObserver = null;
66
1175
  this._teardownCharCount();
1176
+ this._teardownFormatting();
67
1177
  }
68
1178
  attributeChangedCallback(name, _oldValue, _newValue) {
69
1179
  if (name === "tooltip" || name === "tooltip-label" || name === "tooltip-placement") {
@@ -94,6 +1204,7 @@ var IxField = class extends HTMLElement {
94
1204
  }
95
1205
  }
96
1206
  }
1207
+ if (mutations.some((m) => m.attributeName === "data-format" || m.attributeName === "data-format-pattern" || m.attributeName === "data-format-live")) this._wireFormatting();
97
1208
  });
98
1209
  this._stateObserver.observe(control, {
99
1210
  attributes: true,
@@ -101,7 +1212,10 @@ var IxField = class extends HTMLElement {
101
1212
  "disabled",
102
1213
  "readonly",
103
1214
  "maxlength",
104
- "minlength"
1215
+ "minlength",
1216
+ "data-format",
1217
+ "data-format-pattern",
1218
+ "data-format-live"
105
1219
  ]
106
1220
  });
107
1221
  if (!control.id) control.id = `ix-field-${++fieldCounter}`;
@@ -174,6 +1288,124 @@ var IxField = class extends HTMLElement {
174
1288
  if (max) el.textContent = `${current}/${max}`;
175
1289
  else if (min) el.textContent = `${current} tegn (minimum ${min})`;
176
1290
  }
1291
+ _resolveFormatter(control) {
1292
+ if (this._formatterProp) return this._formatterProp;
1293
+ const name = control.getAttribute("data-format");
1294
+ if (name) {
1295
+ const named = resolveFormat(name);
1296
+ if (named) return named;
1297
+ }
1298
+ const pattern = control.getAttribute("data-format-pattern");
1299
+ if (pattern) return createPatternFormatter(pattern);
1300
+ return null;
1301
+ }
1302
+ _applyLiveOverride(formatter, control) {
1303
+ if (!control.hasAttribute("data-format-live")) return formatter;
1304
+ const live = control.getAttribute("data-format-live") !== "false";
1305
+ if (live === !!formatter.live) return formatter;
1306
+ return {
1307
+ ...formatter,
1308
+ live
1309
+ };
1310
+ }
1311
+ _wireFormatting() {
1312
+ this._teardownFormatting();
1313
+ const control = this.querySelector("input");
1314
+ if (!control) return;
1315
+ const base = this._resolveFormatter(control);
1316
+ if (!base) return;
1317
+ const formatter = this._applyLiveOverride(base, control);
1318
+ this._activeFormatter = formatter;
1319
+ this._activeControl = control;
1320
+ const initialRaw = control.value;
1321
+ const originalName = control.getAttribute("name");
1322
+ let mirror = null;
1323
+ if (originalName !== null) {
1324
+ control.setAttribute("name", `${originalName}_formatted`);
1325
+ mirror = document.createElement("input");
1326
+ mirror.type = "hidden";
1327
+ mirror.name = originalName;
1328
+ mirror.value = initialRaw;
1329
+ mirror.disabled = control.disabled;
1330
+ control.after(mirror);
1331
+ this._formatMirror = mirror;
1332
+ }
1333
+ const listeners = [];
1334
+ const on = (type, fn) => {
1335
+ control.addEventListener(type, fn);
1336
+ listeners.push([type, fn]);
1337
+ };
1338
+ if (formatter.live) {
1339
+ on("input", () => this._maskInPlace());
1340
+ control.value = formatter.format(initialRaw);
1341
+ } else {
1342
+ on("focus", () => {
1343
+ control.value = this.rawValue;
1344
+ });
1345
+ on("input", () => {
1346
+ if (this._formatMirror) this._formatMirror.value = control.value;
1347
+ });
1348
+ on("blur", () => {
1349
+ control.value = formatter.format(this.rawValue);
1350
+ });
1351
+ control.value = this.contains(document.activeElement) && document.activeElement === control ? initialRaw : formatter.format(initialRaw);
1352
+ }
1353
+ this._formatTeardown = () => {
1354
+ for (const [type, fn] of listeners) control.removeEventListener(type, fn);
1355
+ control.value = this.rawValue;
1356
+ if (originalName !== null) control.setAttribute("name", originalName);
1357
+ mirror?.remove();
1358
+ this._formatMirror = null;
1359
+ this._activeFormatter = null;
1360
+ this._activeControl = null;
1361
+ };
1362
+ }
1363
+ _maskInPlace() {
1364
+ const formatter = this._activeFormatter;
1365
+ const control = this._activeControl;
1366
+ if (!formatter || !control) return;
1367
+ const display = control.value;
1368
+ const caret = control.selectionStart ?? display.length;
1369
+ const before = formatter.parse(display.slice(0, caret)).length;
1370
+ const raw = formatter.parse(display);
1371
+ const next = formatter.format(raw);
1372
+ let newCaret = next.length;
1373
+ if (before === 0) newCaret = 0;
1374
+ else for (let i = 1; i <= next.length; i++) if (formatter.parse(next.slice(0, i)).length >= before) {
1375
+ newCaret = i;
1376
+ break;
1377
+ }
1378
+ if (next !== display) control.value = next;
1379
+ control.setSelectionRange(newCaret, newCaret);
1380
+ if (this._formatMirror) this._formatMirror.value = raw;
1381
+ }
1382
+ /**
1383
+ * Re-anvender formateringen fra en rå verdi.
1384
+ *
1385
+ * Nyttig for controlled React: når React skriver den rå prop-verdien ved en
1386
+ * re-render fyres ikke `input`-eventet, så visningen må oppdateres eksplisitt.
1387
+ * Kall denne etter commit (f.eks. i `useLayoutEffect`). No-op når ingen
1388
+ * formatter er aktiv.
1389
+ *
1390
+ * @param raw Ny rå verdi. Utelates den, reformateres gjeldende rå verdi.
1391
+ */
1392
+ refreshFormat(raw) {
1393
+ const formatter = this._activeFormatter;
1394
+ const control = this._activeControl;
1395
+ if (!formatter || !control) return;
1396
+ const nextRaw = raw ?? this.rawValue;
1397
+ if (this._formatMirror) this._formatMirror.value = nextRaw;
1398
+ const focused = document.activeElement === control;
1399
+ const nextDisplay = !formatter.live && focused ? nextRaw : formatter.format(nextRaw);
1400
+ if (nextDisplay !== control.value) {
1401
+ control.value = nextDisplay;
1402
+ if (formatter.live) control.setSelectionRange(nextDisplay.length, nextDisplay.length);
1403
+ }
1404
+ }
1405
+ _teardownFormatting() {
1406
+ this._formatTeardown?.();
1407
+ this._formatTeardown = null;
1408
+ }
177
1409
  _syncInvalid(control, error) {
178
1410
  const hasError = !!error.textContent?.trim();
179
1411
  if (hasError) control.setAttribute("aria-invalid", "true");
@@ -184,6 +1416,7 @@ var IxField = class extends HTMLElement {
184
1416
  this.toggleAttribute("data-disabled", control.disabled);
185
1417
  const readOnly = "readOnly" in control ? control.readOnly : false;
186
1418
  this.toggleAttribute("data-readonly", readOnly);
1419
+ if (this._formatMirror) this._formatMirror.disabled = control.disabled;
187
1420
  }
188
1421
  _syncTooltip() {
189
1422
  const content = this.getAttribute("tooltip");
@@ -219,6 +1452,7 @@ var IxField = class extends HTMLElement {
219
1452
  this.querySelector(".ix-field__tooltip-btn")?.remove();
220
1453
  }
221
1454
  };
1455
+ _defineProperty(IxField, "registerFormatter", registerFormat);
222
1456
  //#endregion
223
1457
  //#region lib/components/icon/IxIcon.ts
224
1458
  var ICON_CDN_BASE = "https://cdn.sparebank1.no/icons";
@@ -407,6 +1641,227 @@ var IxIcon = class extends HTMLElement {
407
1641
  }
408
1642
  };
409
1643
  //#endregion
1644
+ //#region lib/utils/platform.ts
1645
+ /**
1646
+ * Plattform-deteksjon for web components.
1647
+ *
1648
+ * Funksjoner (ikke modul-konstanter) fordi userAgent leses ved kall — det gjør
1649
+ * grenene testbare via stub av `navigator.userAgent`, og unngår å cache en verdi
1650
+ * ved import-tid som kan være feil i SSR-/testmiljø.
1651
+ */
1652
+ /**
1653
+ * True når koden kjører på iOS (iPhone/iPad/iPod).
1654
+ *
1655
+ * Brukes til iOS-spesifikke a11y-workarounds, f.eks. at VoiceOver ikke leser
1656
+ * løpende `aria-valuenow`-endringer på `role="progressbar"` — se IxProgressBar.
1657
+ * SSR-guardet: returnerer false når `navigator` ikke finnes.
1658
+ */
1659
+ function isIOS() {
1660
+ if (typeof navigator === "undefined") return false;
1661
+ return /iPad|iPhone|iPod/.test(navigator.userAgent);
1662
+ }
1663
+ //#endregion
1664
+ //#region lib/components/progress-bar/IxProgressBar.ts
1665
+ /**
1666
+ * IxProgressBar — viser fremdrift i én sammenhengende prosess.
1667
+ *
1668
+ * DOM-generator: web componenten eier og genererer all indre HTML (header med
1669
+ * label + verdi/statusikon, track + fill, og en støttetekst-region).
1670
+ * Forfatteren skriver kun host-elementet `<ix-progress-bar>` med attributter —
1671
+ * ingen intern struktur.
1672
+ *
1673
+ * Tilstander (`data-state`):
1674
+ * - `active` (default): role="progressbar" med aria-valuenow/min/max på host.
1675
+ * Nøytral/primær fremdriftsfarge. Ingen statusikon. Kan vise prosent.
1676
+ * - `success` / `error`: rollen `progressbar` FJERNES (prosessen er ferdig/avbrutt).
1677
+ * Status formidles semantisk «tilsvarende Message» — `data-status`
1678
+ * (success/danger) kobler --ix-color-status-* og et dekorativt
1679
+ * `<ix-icon data-badge>` vises alltid. Støtteteksten er en aria-live-region
1680
+ * (role="status") så overgangen annonseres politely.
1681
+ *
1682
+ * Komponenten er rent informativ og aldri fokuserbar (setter aldri tabindex).
1683
+ * Ugyldige verdier klampes: value < 0 → 0, > 100 → 100, ikke-numerisk → 0.
1684
+ *
1685
+ * iOS-workaround: VoiceOver leser ikke løpende `aria-valuenow`-endringer på
1686
+ * `role="progressbar"`. På iOS byttes derfor rollen i `active` til `role="img"`
1687
+ * og verdien bakes inn i `aria-label` (samme mønster som u-elements sin
1688
+ * u-progress). Desktop beholder ekte progressbar-semantikk.
1689
+ */
1690
+ var progressBarCounter = 0;
1691
+ var STATE_ICON = {
1692
+ success: "check",
1693
+ error: "priority_high"
1694
+ };
1695
+ var STATE_STATUS = {
1696
+ success: "success",
1697
+ error: "danger"
1698
+ };
1699
+ var IxProgressBar = class extends HTMLElement {
1700
+ constructor(..._args) {
1701
+ super(..._args);
1702
+ _defineProperty(this, "_instanceId", ++progressBarCounter);
1703
+ _defineProperty(this, "_header", null);
1704
+ _defineProperty(this, "_label", null);
1705
+ _defineProperty(this, "_headerEnd", null);
1706
+ _defineProperty(this, "_track", null);
1707
+ _defineProperty(this, "_fill", null);
1708
+ _defineProperty(this, "_support", null);
1709
+ _defineProperty(this, "_supportText", null);
1710
+ _defineProperty(this, "_icon", null);
1711
+ }
1712
+ static get observedAttributes() {
1713
+ return [
1714
+ "value",
1715
+ "data-state",
1716
+ "label",
1717
+ "data-support-text",
1718
+ "data-show-value",
1719
+ "data-value-text"
1720
+ ];
1721
+ }
1722
+ connectedCallback() {
1723
+ this._ensureStructure();
1724
+ this._update();
1725
+ }
1726
+ attributeChangedCallback(_name, oldValue, newValue) {
1727
+ if (!this.isConnected) return;
1728
+ if (oldValue === newValue) return;
1729
+ this._update();
1730
+ }
1731
+ disconnectedCallback() {}
1732
+ /** Bygger den indre strukturen én gang (idempotent). */
1733
+ _ensureStructure() {
1734
+ if (this._track) return;
1735
+ this._header = document.createElement("div");
1736
+ this._header.className = "ix-progress-bar__header";
1737
+ this._label = document.createElement("span");
1738
+ this._label.className = "ix-progress-bar__label";
1739
+ this._label.id = `ix-progress-bar-label-${this._instanceId}`;
1740
+ this._headerEnd = document.createElement("span");
1741
+ this._headerEnd.className = "ix-progress-bar__value";
1742
+ this._header.append(this._label, this._headerEnd);
1743
+ this._track = document.createElement("div");
1744
+ this._track.className = "ix-progress-bar__track";
1745
+ this._fill = document.createElement("div");
1746
+ this._fill.className = "ix-progress-bar__fill";
1747
+ this._track.appendChild(this._fill);
1748
+ this._support = document.createElement("p");
1749
+ this._support.className = "ix-progress-bar__support";
1750
+ this._support.id = `ix-progress-bar-support-${this._instanceId}`;
1751
+ this._support.setAttribute("role", "status");
1752
+ this._support.setAttribute("aria-live", "polite");
1753
+ this._support.setAttribute("aria-atomic", "false");
1754
+ this._supportText = document.createElement("span");
1755
+ this._support.appendChild(this._supportText);
1756
+ this.append(this._header, this._track, this._support);
1757
+ }
1758
+ _update() {
1759
+ if (!this._track || !this._fill || !this._header || !this._label || !this._headerEnd || !this._support || !this._supportText) return;
1760
+ const state = this._resolveState();
1761
+ const clamped = this._clampValue(this.getAttribute("value"));
1762
+ const label = this.getAttribute("label");
1763
+ const supportText = this.getAttribute("data-support-text");
1764
+ const valueText = this.getAttribute("data-value-text");
1765
+ const showValue = this.hasAttribute("data-show-value");
1766
+ const isActive = state === "active";
1767
+ const fillPercent = state === "success" ? 100 : clamped;
1768
+ this.style.setProperty("--ii-progress-bar-fill", `${fillPercent}%`);
1769
+ this._label.textContent = label ?? "";
1770
+ this._label.hidden = !label;
1771
+ this._updateIconAndValue(state, clamped, showValue, isActive, Boolean(label));
1772
+ this._supportText.textContent = supportText ?? "";
1773
+ Boolean(label) || this.hasAttribute("aria-label") || this.hasAttribute("aria-labelledby");
1774
+ if (isActive) {
1775
+ this.removeAttribute("data-status");
1776
+ if (isIOS()) {
1777
+ this.setAttribute("role", "img");
1778
+ this.removeAttribute("aria-valuemin");
1779
+ this.removeAttribute("aria-valuemax");
1780
+ this.removeAttribute("aria-valuenow");
1781
+ this.removeAttribute("aria-valuetext");
1782
+ this.removeAttribute("aria-labelledby");
1783
+ const iosLabel = [label, valueText ?? `${Math.round(clamped)}\u00A0%`].filter(Boolean).join(", ");
1784
+ if (label || !this.hasAttribute("aria-label")) this.setAttribute("aria-label", iosLabel);
1785
+ this._setAriaRef("aria-describedby", supportText ? this._support.id : null);
1786
+ } else {
1787
+ this.setAttribute("role", "progressbar");
1788
+ this.setAttribute("aria-valuemin", "0");
1789
+ this.setAttribute("aria-valuemax", "100");
1790
+ this.setAttribute("aria-valuenow", String(Math.round(clamped)));
1791
+ if (valueText) this.setAttribute("aria-valuetext", valueText);
1792
+ else this.removeAttribute("aria-valuetext");
1793
+ this._setAriaRef("aria-labelledby", label ? this._label.id : null);
1794
+ this._setAriaRef("aria-describedby", supportText ? this._support.id : null);
1795
+ }
1796
+ } else {
1797
+ this.removeAttribute("role");
1798
+ this.removeAttribute("aria-valuemin");
1799
+ this.removeAttribute("aria-valuemax");
1800
+ this.removeAttribute("aria-valuenow");
1801
+ this.removeAttribute("aria-valuetext");
1802
+ this.removeAttribute("aria-label");
1803
+ this.removeAttribute("aria-labelledby");
1804
+ this.removeAttribute("aria-describedby");
1805
+ this.setAttribute("data-status", STATE_STATUS[state]);
1806
+ }
1807
+ }
1808
+ /**
1809
+ * Plasserer statusikon og verdi. Følger Figma:
1810
+ * - active + data-show-value → «NN %» i header-slutten.
1811
+ * - success/error med label → ikon i header-slutten (til høyre for label).
1812
+ * - success/error uten label → ikon foran støtteteksten.
1813
+ */
1814
+ _updateIconAndValue(state, clamped, showValue, isActive, hasLabel) {
1815
+ if (!this._headerEnd || !this._support || !this._supportText || !this._header) return;
1816
+ if (isActive && showValue) {
1817
+ this._removeIcon();
1818
+ this._headerEnd.textContent = `${Math.round(clamped)}\u00A0%`;
1819
+ this._headerEnd.hidden = false;
1820
+ } else {
1821
+ this._headerEnd.textContent = "";
1822
+ this._headerEnd.hidden = true;
1823
+ }
1824
+ if (!isActive) {
1825
+ const icon = this._ensureIcon();
1826
+ icon.setAttribute("name", STATE_ICON[state]);
1827
+ if (hasLabel) {
1828
+ this._headerEnd.hidden = true;
1829
+ this._header.appendChild(icon);
1830
+ } else this._support.insertBefore(icon, this._supportText);
1831
+ } else this._removeIcon();
1832
+ const headerHasContent = hasLabel || isActive && showValue || !isActive && hasLabel;
1833
+ this._header.hidden = !headerHasContent;
1834
+ }
1835
+ _ensureIcon() {
1836
+ if (!this._icon) {
1837
+ this._icon = document.createElement("ix-icon");
1838
+ this._icon.setAttribute("data-badge", "");
1839
+ this._icon.setAttribute("aria-hidden", "true");
1840
+ }
1841
+ return this._icon;
1842
+ }
1843
+ _removeIcon() {
1844
+ this._icon?.remove();
1845
+ }
1846
+ _resolveState() {
1847
+ const raw = this.getAttribute("data-state");
1848
+ if (raw === "success" || raw === "error") return raw;
1849
+ return "active";
1850
+ }
1851
+ /** Klamper til 0–100. Ikke-numerisk → 0. */
1852
+ _clampValue(raw) {
1853
+ const parsed = Number(raw);
1854
+ if (raw === null || raw.trim() === "" || !Number.isFinite(parsed)) return 0;
1855
+ if (parsed < 0) return 0;
1856
+ if (parsed > 100) return 100;
1857
+ return parsed;
1858
+ }
1859
+ _setAriaRef(attr, id) {
1860
+ if (id) this.setAttribute(attr, id);
1861
+ else this.removeAttribute(attr);
1862
+ }
1863
+ };
1864
+ //#endregion
410
1865
  //#region lib/components/radio-group/IxRadioGroup.ts
411
1866
  /**
412
1867
  * IxRadioGroup — ARIA-lim for radioknapp-grupper.
@@ -612,6 +2067,345 @@ var IxRadioGroup = class extends HTMLElement {
612
2067
  }
613
2068
  };
614
2069
  //#endregion
2070
+ //#region lib/components/tabs/IxTabs.ts
2071
+ /**
2072
+ * IxTabs — ARIA-lim for faner (tabs) i light DOM.
2073
+ *
2074
+ * ## Designfilosofi
2075
+ *
2076
+ * Komponenten erstatter ingen native elementer. Forfatteren skriver semantisk
2077
+ * HTML — en liste med faner og ett panel per fane — og <ix-tabs> kobler dem
2078
+ * sammen med ARIA, roving tabindex, tastaturnavigasjon og panel-visning.
2079
+ * Logikken er portet fra u-elements (u-tabs), men til Indeks' light-DOM-
2080
+ * konvensjon (ingen Shadow DOM, ingen ::slotted).
2081
+ *
2082
+ * Fire custom elements samarbeider:
2083
+ * - <ix-tabs> — rot og koordinator. Eier all kabling og tastatur.
2084
+ * - <ix-tab-list> — role="tablist", horisontal orientering.
2085
+ * - <ix-tab> — role="tab", én per fane.
2086
+ * - <ix-tab-panel> — role="tabpanel", ett innholdspanel per fane.
2087
+ *
2088
+ * Underelementene setter kun sin egen `role` (så de fungerer isolert); <ix-tabs>
2089
+ * er den autoritative koordinatoren og kjører ved connectedCallback.
2090
+ *
2091
+ * ## Hva <ix-tabs> gjør
2092
+ *
2093
+ * 1. Setter role="tablist" + aria-orientation="horizontal" på tab-lista,
2094
+ * role="tab" på fanene og role="tabpanel" på panelene.
2095
+ * 2. Genererer stabile IDer på faner og paneler (der de mangler) og setter opp
2096
+ * kryssreferansene aria-controls (fane → panel) og aria-labelledby
2097
+ * (panel → fane). Eksisterende IDer og aria-controls respekteres. Fane[i]
2098
+ * kobles til panel[i], eller til panelet fanens aria-controls peker på.
2099
+ * 3. Manuell aktivering: piltast (Venstre/Høyre) og Home/End flytter kun FOKUS
2100
+ * via roving tabindex (fokusert fane får tabindex=0, resten -1).
2101
+ * Piltast-navigasjonen looper rundt endene. Enter/Space (og klikk) aktiverer
2102
+ * den fokuserte fanen.
2103
+ * 4. Kun én fane er valgt om gangen. Ved aktivering flyttes aria-selected +
2104
+ * tabindex til ny fane, tilhørende panel vises, øvrige skjules ([hidden] +
2105
+ * aria-hidden). Panelet gjøres ikke fokuserbart — det er ikke en egen
2106
+ * tab-stopp; innholdet i panelet bærer sin egen fokusrekkefølge.
2107
+ * Fane→panel-kobling skjer via delt data-value (ellers aria-controls,
2108
+ * ellers posisjon) — se _getPanel.
2109
+ * 5. Minst én fane er valgt ved mount — enten den som har aria-selected="true"
2110
+ * i markup, ellers den første fanen.
2111
+ * 6. Ved aktivering (klikk/tastatur) sendes et bubbling CustomEvent('change')
2112
+ * på <ix-tabs> slik at en React-wrapper kan lese aria-selected og kalle
2113
+ * onChange. Programmatisk init og ekstern (kontrollert) synk sender IKKE
2114
+ * change — kun ekte brukerinteraksjon.
2115
+ * 7. En MutationObserver re-wirer når faner/paneler legges til eller fjernes
2116
+ * (typisk React conditional rendering), og reagerer på ekstern endring av
2117
+ * aria-selected (kontrollert React-modus) ved å reconcile-e resten.
2118
+ *
2119
+ * ## Ingen Shadow DOM
2120
+ *
2121
+ * Light DOM slik at CSS fra @sb1/indeks-css treffer .ix-tabs__* og tag-
2122
+ * selektorene direkte, og ARIA-relasjonene fungerer på tvers av elementene.
2123
+ *
2124
+ * @example
2125
+ * <ix-tabs class="ix-tabs">
2126
+ * <ix-tab-list class="ix-tabs__list">
2127
+ * <ix-tab class="ix-tabs__tab">Oversikt</ix-tab>
2128
+ * <ix-tab class="ix-tabs__tab">Detaljer</ix-tab>
2129
+ * </ix-tab-list>
2130
+ * <ix-tab-panel class="ix-tabs__panel">Oversikt-innhold</ix-tab-panel>
2131
+ * <ix-tab-panel class="ix-tabs__panel">Detaljer-innhold</ix-tab-panel>
2132
+ * </ix-tabs>
2133
+ */
2134
+ var tabsCounter = 0;
2135
+ var ROLE = "role";
2136
+ var ARIA_SELECTED = "aria-selected";
2137
+ var ARIA_CONTROLS = "aria-controls";
2138
+ var ARIA_LABELLEDBY = "aria-labelledby";
2139
+ var ARIA_HIDDEN = "aria-hidden";
2140
+ var DATA_VALUE = "data-value";
2141
+ function isTab(el) {
2142
+ return el instanceof HTMLElement && (el.tagName === "IX-TAB" || el.getAttribute(ROLE) === "tab");
2143
+ }
2144
+ function isPanel(el) {
2145
+ return el instanceof HTMLElement && (el.tagName === "IX-TAB-PANEL" || el.getAttribute(ROLE) === "tabpanel");
2146
+ }
2147
+ var IxTabs = class extends HTMLElement {
2148
+ constructor(..._args) {
2149
+ super(..._args);
2150
+ _defineProperty(this, "_mutationObserver", null);
2151
+ _defineProperty(this, "_clickListener", null);
2152
+ _defineProperty(this, "_keydownListener", null);
2153
+ _defineProperty(this, "_instanceId", 0);
2154
+ _defineProperty(this, "_internalUpdate", false);
2155
+ }
2156
+ connectedCallback() {
2157
+ this._instanceId = ++tabsCounter;
2158
+ this._clickListener = (e) => this._onClick(e);
2159
+ this._keydownListener = (e) => this._onKeydown(e);
2160
+ this.addEventListener("click", this._clickListener);
2161
+ this.addEventListener("keydown", this._keydownListener);
2162
+ this._mutationObserver = new MutationObserver((records) => this._onMutations(records));
2163
+ this._mutationObserver.observe(this, {
2164
+ childList: true,
2165
+ subtree: true,
2166
+ attributes: true,
2167
+ attributeFilter: [ARIA_SELECTED]
2168
+ });
2169
+ this._wire();
2170
+ }
2171
+ disconnectedCallback() {
2172
+ this._mutationObserver?.disconnect();
2173
+ this._mutationObserver = null;
2174
+ if (this._clickListener) this.removeEventListener("click", this._clickListener);
2175
+ this._clickListener = null;
2176
+ if (this._keydownListener) this.removeEventListener("keydown", this._keydownListener);
2177
+ this._keydownListener = null;
2178
+ }
2179
+ /** Faner i rekkefølge (direkte barn av tab-lista). */
2180
+ _getTabs() {
2181
+ const list = this.querySelector("ix-tab-list, [role=\"tablist\"]");
2182
+ if (!list) return [];
2183
+ return Array.from(list.children).filter(isTab);
2184
+ }
2185
+ /** Paneler i rekkefølge (direkte barn av <ix-tabs>). */
2186
+ _getPanels() {
2187
+ return Array.from(this.children).filter(isPanel);
2188
+ }
2189
+ /**
2190
+ * Finn panelet en fane styrer. Prioritet:
2191
+ * 1. Eksplisitt `aria-controls` (forfatter- eller WC-satt).
2192
+ * 2. Delt `data-value` — kobler fane og panel eksplisitt, uavhengig av
2193
+ * DOM-rekkefølge (React setter dette; ren HTML kan også sette det).
2194
+ * 3. Posisjon (fane[i] → panel[i]) som siste utvei for enkel markup.
2195
+ */
2196
+ _getPanel(tab, index) {
2197
+ const controls = tab.getAttribute(ARIA_CONTROLS);
2198
+ if (controls) {
2199
+ const el = this.ownerDocument.getElementById(controls);
2200
+ if (el) return el;
2201
+ }
2202
+ const value = tab.getAttribute(DATA_VALUE);
2203
+ if (value) {
2204
+ const match = this._getPanels().find((p) => p.getAttribute(DATA_VALUE) === value);
2205
+ if (match) return match;
2206
+ }
2207
+ return this._getPanels()[index] ?? null;
2208
+ }
2209
+ /**
2210
+ * Kobler roller, IDer og kryssreferanser, og setter initial valgt fane.
2211
+ * Idempotent — kan kjøres på nytt når DOM endres.
2212
+ */
2213
+ _wire() {
2214
+ const list = this.querySelector("ix-tab-list, [role=\"tablist\"]");
2215
+ if (!list) return;
2216
+ if (!list.getAttribute(ROLE)) list.setAttribute(ROLE, "tablist");
2217
+ if (!list.getAttribute("aria-orientation")) list.setAttribute("aria-orientation", "horizontal");
2218
+ const tabs = this._getTabs();
2219
+ if (tabs.length === 0) return;
2220
+ tabs.forEach((tab, i) => {
2221
+ if (!tab.getAttribute(ROLE)) tab.setAttribute(ROLE, "tab");
2222
+ if (!tab.id) tab.id = `ix-tab-${this._instanceId}-${i}`;
2223
+ const panel = this._getPanel(tab, i);
2224
+ if (panel) {
2225
+ if (!panel.getAttribute(ROLE)) panel.setAttribute(ROLE, "tabpanel");
2226
+ if (!panel.id) panel.id = `ix-tab-panel-${this._instanceId}-${i}`;
2227
+ if (!tab.getAttribute(ARIA_CONTROLS)) tab.setAttribute(ARIA_CONTROLS, panel.id);
2228
+ if (!panel.getAttribute(ARIA_LABELLEDBY)) panel.setAttribute(ARIA_LABELLEDBY, tab.id);
2229
+ }
2230
+ });
2231
+ const selected = tabs.find((t) => t.getAttribute(ARIA_SELECTED) === "true") ?? tabs[0];
2232
+ this.setSelected(selected, false);
2233
+ }
2234
+ /**
2235
+ * Aktiverer en fane: flytter aria-selected + roving tabindex hit, viser dens
2236
+ * panel og skjuler de øvrige. Sender kun change ved ekte brukerinteraksjon
2237
+ * (dispatch=true) OG når valget faktisk endres.
2238
+ */
2239
+ setSelected(selected, dispatch = false) {
2240
+ if (!selected) return;
2241
+ const tabs = this._getTabs();
2242
+ if (!tabs.includes(selected)) return;
2243
+ const changed = selected.getAttribute(ARIA_SELECTED) !== "true";
2244
+ this._internalUpdate = true;
2245
+ tabs.forEach((tab, i) => {
2246
+ const isSel = tab === selected;
2247
+ tab.setAttribute(ARIA_SELECTED, String(isSel));
2248
+ tab.tabIndex = isSel ? 0 : -1;
2249
+ const panel = this._getPanel(tab, i);
2250
+ if (panel) this._syncPanel(panel, isSel);
2251
+ });
2252
+ this._internalUpdate = false;
2253
+ this._mutationObserver?.takeRecords();
2254
+ if (dispatch && changed) this.dispatchEvent(new CustomEvent("change", { bubbles: true }));
2255
+ }
2256
+ /**
2257
+ * Vis/skjul et panel. Skjult panel fjernes fra a11y-treet og lesefokus.
2258
+ * Panelet gjøres ikke fokuserbart — det inneholder eget innhold (tekst,
2259
+ * lenker, felt) og skal ikke være en egen tab-stopp. Har panelet ikke
2260
+ * fokuserbart innhold, klarer skjermlesere seg med aria-labelledby-koblingen.
2261
+ */
2262
+ _syncPanel(panel, show) {
2263
+ panel.setAttribute(ARIA_HIDDEN, String(!show));
2264
+ if (show) panel.removeAttribute("hidden");
2265
+ else panel.setAttribute("hidden", "");
2266
+ }
2267
+ /** Flytt fokus (roving tabindex) til en fane uten å aktivere den. */
2268
+ _moveFocus(tabs, target) {
2269
+ for (const tab of tabs) tab.tabIndex = tab === target ? 0 : -1;
2270
+ target.focus();
2271
+ }
2272
+ /** Neste fane fra `from`, i retning `step`, med loop rundt endene. */
2273
+ _step(tabs, from, step) {
2274
+ const n = tabs.length;
2275
+ return (from + step + n) % n;
2276
+ }
2277
+ _onClick(e) {
2278
+ const tabs = this._getTabs();
2279
+ const target = e.target;
2280
+ const tab = target ? tabs.find((t) => t === target || t.contains(target)) : void 0;
2281
+ if (tab) this.setSelected(tab, true);
2282
+ }
2283
+ _onKeydown(e) {
2284
+ const tabs = this._getTabs();
2285
+ const target = e.target;
2286
+ const current = target ? tabs.findIndex((t) => t === target || t.contains(target)) : -1;
2287
+ if (current === -1) return;
2288
+ if (e.key === "Enter" || e.key === " ") {
2289
+ e.preventDefault();
2290
+ this.setSelected(tabs[current], true);
2291
+ return;
2292
+ }
2293
+ let next;
2294
+ if (e.key === "ArrowRight") next = this._step(tabs, current, 1);
2295
+ else if (e.key === "ArrowLeft") next = this._step(tabs, current, -1);
2296
+ else if (e.key === "Home") next = 0;
2297
+ else if (e.key === "End") next = tabs.length - 1;
2298
+ else return;
2299
+ e.preventDefault();
2300
+ this._moveFocus(tabs, tabs[next]);
2301
+ }
2302
+ _onMutations(records) {
2303
+ if (this._internalUpdate) return;
2304
+ let structural = false;
2305
+ let externalSelected = null;
2306
+ for (const record of records) if (record.type === "childList") {
2307
+ if (Array.from(record.addedNodes).some(nodeAffectsTabs) || Array.from(record.removedNodes).some(nodeAffectsTabs)) structural = true;
2308
+ } else if (record.type === "attributes" && record.attributeName === ARIA_SELECTED) {
2309
+ const t = record.target;
2310
+ if (t instanceof HTMLElement && isTab(t) && t.getAttribute(ARIA_SELECTED) === "true") externalSelected = t;
2311
+ }
2312
+ if (structural) this._wire();
2313
+ else if (externalSelected) this.setSelected(externalSelected, false);
2314
+ }
2315
+ };
2316
+ function nodeAffectsTabs(node) {
2317
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
2318
+ const el = node;
2319
+ return isTab(el) || isPanel(el) || !!el.querySelector?.("ix-tab, ix-tab-panel, [role=\"tab\"], [role=\"tabpanel\"]");
2320
+ }
2321
+ /** role="tablist" + horisontal orientering. Koordineres av <ix-tabs>. */
2322
+ var IxTabList = class extends HTMLElement {
2323
+ connectedCallback() {
2324
+ if (!this.getAttribute(ROLE)) this.setAttribute(ROLE, "tablist");
2325
+ if (!this.getAttribute("aria-orientation")) this.setAttribute("aria-orientation", "horizontal");
2326
+ }
2327
+ };
2328
+ /** role="tab". Aktiv-tilstand og tabindex styres av <ix-tabs>. */
2329
+ var IxTab = class extends HTMLElement {
2330
+ connectedCallback() {
2331
+ if (!this.getAttribute(ROLE)) this.setAttribute(ROLE, "tab");
2332
+ }
2333
+ };
2334
+ /** role="tabpanel". Vis/skjul styres av <ix-tabs>. */
2335
+ var IxTabPanel = class extends HTMLElement {
2336
+ connectedCallback() {
2337
+ if (!this.getAttribute(ROLE)) this.setAttribute(ROLE, "tabpanel");
2338
+ }
2339
+ };
2340
+ //#endregion
2341
+ //#region lib/modal/modal.ts
2342
+ var ATTR_OPEN = "data-modal-open";
2343
+ var ATTR_CLOSE = "data-modal-close";
2344
+ var ATTR_NO_BACKDROP = "data-no-close-on-backdrop";
2345
+ var openModals = /* @__PURE__ */ new Set();
2346
+ var previousBodyOverflow;
2347
+ function handleOpened(dialog) {
2348
+ if (openModals.has(dialog)) return;
2349
+ if (openModals.size === 0) {
2350
+ previousBodyOverflow = document.body.style.overflow;
2351
+ document.body.style.overflow = "hidden";
2352
+ }
2353
+ openModals.add(dialog);
2354
+ moveOpeningFocus(dialog);
2355
+ }
2356
+ function handleClosed(dialog) {
2357
+ if (!openModals.delete(dialog)) return;
2358
+ if (openModals.size === 0) {
2359
+ document.body.style.overflow = previousBodyOverflow ?? "";
2360
+ previousBodyOverflow = void 0;
2361
+ }
2362
+ }
2363
+ function moveOpeningFocus(dialog) {
2364
+ if (dialog.querySelector("[autofocus]")) return;
2365
+ if (!dialog.hasAttribute("tabindex")) dialog.tabIndex = -1;
2366
+ dialog.focus();
2367
+ }
2368
+ function handleClick(e) {
2369
+ const target = e.target;
2370
+ if (!target?.closest) return;
2371
+ const opener = target.closest(`[${ATTR_OPEN}]`);
2372
+ if (opener) {
2373
+ const id = opener.getAttribute(ATTR_OPEN);
2374
+ const dialog = id ? document.getElementById(id) : null;
2375
+ if (dialog instanceof HTMLDialogElement && !dialog.open) {
2376
+ dialog.showModal();
2377
+ if (dialog.classList.contains("ix-modal")) handleOpened(dialog);
2378
+ }
2379
+ return;
2380
+ }
2381
+ const closer = target.closest(`[${ATTR_CLOSE}]`);
2382
+ if (closer) {
2383
+ closer.closest("dialog")?.close();
2384
+ return;
2385
+ }
2386
+ if (target instanceof HTMLDialogElement && target.classList.contains("ix-modal") && !target.hasAttribute(ATTR_NO_BACKDROP)) target.close();
2387
+ }
2388
+ function handleClose(e) {
2389
+ const target = e.target;
2390
+ if (target instanceof HTMLDialogElement && target.classList.contains("ix-modal")) handleClosed(target);
2391
+ }
2392
+ var observer = new MutationObserver((records) => {
2393
+ for (const record of records) {
2394
+ const dialog = record.target;
2395
+ if (!(dialog instanceof HTMLDialogElement)) continue;
2396
+ if (!dialog.classList.contains("ix-modal")) continue;
2397
+ if (dialog.open) handleOpened(dialog);
2398
+ else handleClosed(dialog);
2399
+ }
2400
+ });
2401
+ document.addEventListener("click", handleClick);
2402
+ document.addEventListener("close", handleClose, true);
2403
+ observer.observe(document.documentElement, {
2404
+ subtree: true,
2405
+ attributes: true,
2406
+ attributeFilter: ["open"]
2407
+ });
2408
+ //#endregion
615
2409
  //#region lib/tooltip/tooltip.ts
616
2410
  var TOOLTIP_ID = "ix-tooltip-singleton";
617
2411
  var ATTR = "data-tooltip";
@@ -734,5 +2528,12 @@ document.addEventListener("keydown", handleKeydown);
734
2528
  customElements.define("ix-field", IxField);
735
2529
  customElements.define("ix-icon", IxIcon);
736
2530
  customElements.define("ix-radio-group", IxRadioGroup);
2531
+ customElements.define("ix-checkbox-group", IxCheckboxGroup);
2532
+ customElements.define("ix-combobox", IxCombobox);
2533
+ customElements.define("ix-progress-bar", IxProgressBar);
2534
+ customElements.define("ix-tabs", IxTabs);
2535
+ customElements.define("ix-tab-list", IxTabList);
2536
+ customElements.define("ix-tab", IxTab);
2537
+ customElements.define("ix-tab-panel", IxTabPanel);
737
2538
  //#endregion
738
- export { COMMON_ICON_NAMES, IxField, IxIcon, IxRadioGroup };
2539
+ export { COMMON_ICON_NAMES, IxCheckboxGroup, IxCombobox, IxField, IxIcon, IxProgressBar, IxRadioGroup, IxTab, IxTabList, IxTabPanel, IxTabs, amountFormatterForLocale, createAmountFormatter, createPatternFormatter, registerFormat, resolveFormat };