@sb1/indeks-web 0.15.0 → 0.16.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/CHANGELOG.md +53 -0
- package/dist/npm/index.d.ts +76 -0
- package/dist/npm/index.js +1807 -6
- package/dist/npm/lib/components/checkbox-group/IxCheckboxGroup.d.ts +83 -0
- package/dist/npm/lib/components/combobox/IxCombobox.d.ts +132 -0
- package/dist/npm/lib/components/field/IxField.d.ts +50 -78
- package/dist/npm/lib/components/field/formats.d.ts +101 -0
- package/dist/npm/lib/components/progress-bar/IxProgressBar.d.ts +56 -0
- package/dist/npm/lib/components/tabs/IxTabs.d.ts +121 -0
- package/dist/npm/lib/modal/modal.d.ts +1 -0
- package/dist/npm/lib/utils/platform.d.ts +15 -0
- package/package.json +5 -5
package/dist/npm/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
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.
|
|
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.
|
|
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.
|
|
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 };
|