@sb1/indeks-web 0.8.0 → 0.9.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @sb1/indeks-web
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f0707b4: Legg til `RadioGroup` og `RadioButton` med full støtte for tilstander, orientering og a11y.
8
+
9
+ - `@sb1/indeks-css`: `radio-group.css` skrevet om — alle tilstander (default, hover, focus, selected, error, readOnly, disabled), vertikal/horisontal orientering, touch-mål 44×44 px, `prefers-reduced-motion`.
10
+ - `@sb1/indeks-web`: ny `<ix-radio-group>` web component (ARIA-lim) som setter `role="radiogroup"`, `aria-labelledby`, `aria-describedby`, `aria-live="polite"` og synkroniserer `aria-invalid`, `name`, `disabled`, `required` og `aria-required` på riktig nivå. Blokkerer tastatur-endringer i `readonly`-tilstand.
11
+ - `@sb1/indeks-react`: `RadioGroup` skrevet om til compound-mønster med `RadioButton`-barn. Støtter kontrollert/ukontrollert bruk, `legend`, `description`, `errorMessage`, `orientation`, `hideLegend`, `disabled`, `readOnly`, `required`. Erstatter den gamle flat-array-API-en (breaking endring i komponentens API, men ikke i typenavn).
12
+
3
13
  ## 0.8.0
4
14
 
5
15
  ## 0.7.0
@@ -1,13 +1,16 @@
1
1
  import { IxField } from './lib/components/field/IxField.js';
2
2
  import { IxIcon, IconName } from './lib/components/icon/IxIcon.js';
3
+ import { IxRadioGroup } from './lib/components/radio-group/IxRadioGroup.js';
3
4
  export { IxField };
4
5
  export { IxIcon };
6
+ export { IxRadioGroup };
5
7
  export { ICON_NAMES } from './lib/components/icon/IxIcon.js';
6
8
  export type { IconName, IconValue } from './lib/components/icon/IxIcon.js';
7
9
  declare global {
8
10
  interface HTMLElementTagNameMap {
9
11
  'ix-field': IxField;
10
12
  'ix-icon': IxIcon;
13
+ 'ix-radio-group': IxRadioGroup;
11
14
  }
12
15
  namespace JSX {
13
16
  interface IntrinsicElements {
@@ -16,6 +19,16 @@ declare global {
16
19
  class?: string;
17
20
  children?: unknown;
18
21
  };
22
+ 'ix-radio-group': {
23
+ name?: string;
24
+ disabled?: boolean | '';
25
+ readonly?: boolean | '';
26
+ required?: boolean | '';
27
+ 'data-orientation'?: 'vertical' | 'horizontal';
28
+ 'data-state'?: 'error' | 'readonly' | 'disabled';
29
+ class?: string;
30
+ children?: unknown;
31
+ };
19
32
  'ix-icon': {
20
33
  name?: IconName;
21
34
  materialdesignname?: string;
package/dist/npm/index.js CHANGED
@@ -281,6 +281,211 @@ var IxIcon = class extends HTMLElement {
281
281
  }
282
282
  };
283
283
  //#endregion
284
+ //#region lib/components/radio-group/IxRadioGroup.ts
285
+ /**
286
+ * IxRadioGroup — ARIA-lim for radioknapp-grupper.
287
+ *
288
+ * ## Designfilosofi
289
+ *
290
+ * Komponenten erstatter ingen native HTML-elementer. Forfatteren skriver
291
+ * native <input type="radio">, <label> og hjelpetekster inni <ix-radio-group>.
292
+ * Komponenten sin eneste jobb er å sette opp ARIA-koblingene mellom dem.
293
+ *
294
+ * ## Hva komponenten gjør
295
+ *
296
+ * 1. Setter role="radiogroup" på host slik at skjermlesere annonserer gruppen
297
+ * med riktig rolle og leser legend-teksten som gruppens tilgjengelige navn.
298
+ *
299
+ * 2. Kobler legend via aria-labelledby — genererer ID på [data-field="legend"]
300
+ * om nødvendig og peker host til den.
301
+ *
302
+ * 3. Setter aria-describedby på host med IDer til description og error
303
+ * (description alltid først). Hopper over elementer som mangler.
304
+ *
305
+ * 4. Genererer stabile IDer på hvert input og setter htmlFor på matchende label
306
+ * om label ikke allerede har det. Respekterer alltid eksisterende IDer.
307
+ *
308
+ * 5. Synkroniserer name-attributt på alle inputs — name er mekanismen
309
+ * nettleseren bruker for mutual exclusivity (ett valg av gangen) og
310
+ * ArrowKey-navigasjon mellom alternativene. Alle inputs i gruppen må ha
311
+ * samme name. Prioritet: name på host > name på input > auto-generert.
312
+ *
313
+ * 6. Setter aria-live="polite" på [data-field="error"] og observerer
314
+ * textContent-endringer: ikke-tomt innhold → aria-invalid="true" på host;
315
+ * tomt → aria-invalid fjernes. aria-invalid settes kun på host (ikke per
316
+ * input) — det er gruppens validitet som telles, og en duplisert flagg
317
+ * per input gir ingen merverdi for skjermlesere.
318
+ *
319
+ * 7. Propagerer disabled/readonly til alle inputs via attributeChangedCallback.
320
+ * Per-knapp disabled bevares — settes med en WeakMap ved første _wire().
321
+ *
322
+ * 8. Lytter på childList på host slik at nye <input type="radio">-elementer
323
+ * som legges til etter mount automatisk wires (id, name, htmlFor, disabled).
324
+ *
325
+ * ## Ingen Shadow DOM
326
+ *
327
+ * Komponenten bruker light DOM slik at CSS fra @sb1/indeks-css når alle
328
+ * delelementer direkte.
329
+ *
330
+ * @example
331
+ * <ix-radio-group>
332
+ * <span data-field="legend">Velg kundetype</span>
333
+ * <span data-field="description">Beskrivelse</span>
334
+ * <div class="ix-radio-button">
335
+ * <input type="radio" value="privat" />
336
+ * <label>Privat</label>
337
+ * </div>
338
+ * <div class="ix-radio-button">
339
+ * <input type="radio" value="bedrift" />
340
+ * <label>Bedrift</label>
341
+ * </div>
342
+ * <span data-field="error"></span>
343
+ * </ix-radio-group>
344
+ */
345
+ var radioGroupCounter = 0;
346
+ var radioInputCounter = 0;
347
+ var IxRadioGroup = class extends HTMLElement {
348
+ constructor(..._args) {
349
+ super(..._args);
350
+ _defineProperty(this, "_errorObserver", null);
351
+ _defineProperty(this, "_childObserver", null);
352
+ _defineProperty(this, "_keydownListener", null);
353
+ _defineProperty(this, "_instanceId", 0);
354
+ _defineProperty(this, "_ownDisabled", /* @__PURE__ */ new WeakMap());
355
+ }
356
+ static get observedAttributes() {
357
+ return [
358
+ "disabled",
359
+ "readonly",
360
+ "required",
361
+ "name"
362
+ ];
363
+ }
364
+ connectedCallback() {
365
+ this._wire();
366
+ }
367
+ disconnectedCallback() {
368
+ this._errorObserver?.disconnect();
369
+ this._errorObserver = null;
370
+ this._childObserver?.disconnect();
371
+ this._childObserver = null;
372
+ if (this._keydownListener) this.removeEventListener("keydown", this._keydownListener);
373
+ this._keydownListener = null;
374
+ }
375
+ attributeChangedCallback(attr, _oldValue, _newValue) {
376
+ if (!this.isConnected) return;
377
+ if (attr === "disabled" || attr === "readonly") this._syncHostStateToInputs();
378
+ if (attr === "required") this._syncRequired();
379
+ if (attr === "name") this._wireInputs();
380
+ }
381
+ _wire() {
382
+ this.setAttribute("role", "radiogroup");
383
+ this._instanceId = ++radioGroupCounter;
384
+ const legend = this.querySelector("[data-field=\"legend\"]");
385
+ if (legend) {
386
+ if (!legend.id) legend.id = `ix-radio-group-legend-${this._instanceId}`;
387
+ this.setAttribute("aria-labelledby", legend.id);
388
+ }
389
+ const description = this.querySelector("[data-field=\"description\"]");
390
+ const error = this.querySelector("[data-field=\"error\"]");
391
+ const describedBy = [];
392
+ if (description) {
393
+ if (!description.id) description.id = `ix-radio-group-desc-${this._instanceId}`;
394
+ describedBy.push(description.id);
395
+ }
396
+ if (error) {
397
+ if (!error.id) error.id = `ix-radio-group-error-${this._instanceId}`;
398
+ describedBy.push(error.id);
399
+ error.setAttribute("aria-live", "polite");
400
+ this._errorObserver = new MutationObserver(() => {
401
+ this._syncInvalid(error);
402
+ });
403
+ this._errorObserver.observe(error, {
404
+ childList: true,
405
+ characterData: true,
406
+ subtree: true
407
+ });
408
+ this._syncInvalid(error);
409
+ }
410
+ if (describedBy.length > 0) this.setAttribute("aria-describedby", describedBy.join(" "));
411
+ this._wireInputs();
412
+ this._syncRequired();
413
+ this._keydownListener = (e) => {
414
+ if (this.hasAttribute("readonly") && [
415
+ "ArrowUp",
416
+ "ArrowDown",
417
+ "ArrowLeft",
418
+ "ArrowRight",
419
+ " "
420
+ ].includes(e.key)) e.preventDefault();
421
+ };
422
+ this.addEventListener("keydown", this._keydownListener);
423
+ this._childObserver = new MutationObserver((mutations) => {
424
+ const hasInputChange = mutations.some((m) => Array.from(m.addedNodes).some((n) => this._containsRadioInput(n)) || Array.from(m.removedNodes).some((n) => this._containsRadioInput(n)));
425
+ const hasNameRemoval = mutations.some((m) => m.type === "attributes" && m.attributeName === "name" && m.target instanceof HTMLInputElement && m.target.type === "radio" && !m.target.name);
426
+ if (hasInputChange || hasNameRemoval) {
427
+ this._wireInputs();
428
+ this._syncRequired();
429
+ }
430
+ });
431
+ this._childObserver.observe(this, {
432
+ childList: true,
433
+ subtree: true,
434
+ attributes: true,
435
+ attributeFilter: ["name"]
436
+ });
437
+ }
438
+ _containsRadioInput(node) {
439
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
440
+ const el = node;
441
+ if (el.matches?.("input[type=\"radio\"]")) return true;
442
+ return !!el.querySelector?.("input[type=\"radio\"]");
443
+ }
444
+ _wireInputs() {
445
+ const inputs = Array.from(this.querySelectorAll("input[type=\"radio\"]"));
446
+ if (inputs.length === 0) return;
447
+ for (const input of inputs) {
448
+ if (!input.id) input.id = `ix-radio-${++radioInputCounter}`;
449
+ const wrapper = input.closest(".ix-radio-button") ?? input.parentElement;
450
+ if (wrapper) {
451
+ const label = wrapper.querySelector("label");
452
+ if (label && !label.htmlFor) label.htmlFor = input.id;
453
+ }
454
+ }
455
+ const hostName = this.getAttribute("name");
456
+ const existingName = inputs.find((i) => i.name)?.name;
457
+ const groupName = hostName || existingName || `ix-radio-group-name-${this._instanceId}`;
458
+ for (const input of inputs) if (hostName || !input.name) input.name = groupName;
459
+ this._syncHostStateToInputs();
460
+ }
461
+ _syncInvalid(error) {
462
+ if (!!error.textContent?.trim()) this.setAttribute("aria-invalid", "true");
463
+ else this.removeAttribute("aria-invalid");
464
+ }
465
+ _syncHostStateToInputs() {
466
+ const inputs = Array.from(this.querySelectorAll("input[type=\"radio\"]"));
467
+ const isDisabled = this.hasAttribute("disabled");
468
+ for (const input of inputs) if (isDisabled) {
469
+ if (!this._ownDisabled.has(input)) this._ownDisabled.set(input, input.disabled);
470
+ input.disabled = true;
471
+ } else if (this._ownDisabled.has(input)) {
472
+ input.disabled = this._ownDisabled.get(input);
473
+ this._ownDisabled.delete(input);
474
+ }
475
+ }
476
+ _syncRequired() {
477
+ const inputs = Array.from(this.querySelectorAll("input[type=\"radio\"]"));
478
+ if (this.hasAttribute("required")) {
479
+ this.setAttribute("aria-required", "true");
480
+ if (inputs[0]) inputs[0].required = true;
481
+ for (const input of inputs.slice(1)) input.required = false;
482
+ } else {
483
+ this.removeAttribute("aria-required");
484
+ for (const input of inputs) input.required = false;
485
+ }
486
+ }
487
+ };
488
+ //#endregion
284
489
  //#region lib/tooltip/tooltip.ts
285
490
  var TOOLTIP_ID = "ix-tooltip-singleton";
286
491
  var ATTR = "data-tooltip";
@@ -402,5 +607,6 @@ document.addEventListener("keydown", handleKeydown);
402
607
  //#region index.ts
403
608
  customElements.define("ix-field", IxField);
404
609
  customElements.define("ix-icon", IxIcon);
610
+ customElements.define("ix-radio-group", IxRadioGroup);
405
611
  //#endregion
406
- export { ICON_NAMES, IxField, IxIcon };
612
+ export { ICON_NAMES, IxField, IxIcon, IxRadioGroup };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * IxRadioGroup — ARIA-lim for radioknapp-grupper.
3
+ *
4
+ * ## Designfilosofi
5
+ *
6
+ * Komponenten erstatter ingen native HTML-elementer. Forfatteren skriver
7
+ * native <input type="radio">, <label> og hjelpetekster inni <ix-radio-group>.
8
+ * Komponenten sin eneste jobb er å sette opp ARIA-koblingene mellom dem.
9
+ *
10
+ * ## Hva komponenten gjør
11
+ *
12
+ * 1. Setter role="radiogroup" på host slik at skjermlesere annonserer gruppen
13
+ * med riktig rolle og leser legend-teksten som gruppens tilgjengelige navn.
14
+ *
15
+ * 2. Kobler legend via aria-labelledby — genererer ID på [data-field="legend"]
16
+ * om nødvendig og peker host til den.
17
+ *
18
+ * 3. Setter aria-describedby på host med IDer til description og error
19
+ * (description alltid først). Hopper over elementer som mangler.
20
+ *
21
+ * 4. Genererer stabile IDer på hvert input og setter htmlFor på matchende label
22
+ * om label ikke allerede har det. Respekterer alltid eksisterende IDer.
23
+ *
24
+ * 5. Synkroniserer name-attributt på alle inputs — name er mekanismen
25
+ * nettleseren bruker for mutual exclusivity (ett valg av gangen) og
26
+ * ArrowKey-navigasjon mellom alternativene. Alle inputs i gruppen må ha
27
+ * samme name. Prioritet: name på host > name på input > auto-generert.
28
+ *
29
+ * 6. Setter aria-live="polite" på [data-field="error"] og observerer
30
+ * textContent-endringer: ikke-tomt innhold → aria-invalid="true" på host;
31
+ * tomt → aria-invalid fjernes. aria-invalid settes kun på host (ikke per
32
+ * input) — det er gruppens validitet som telles, og en duplisert flagg
33
+ * per input gir ingen merverdi for skjermlesere.
34
+ *
35
+ * 7. Propagerer disabled/readonly til alle inputs via attributeChangedCallback.
36
+ * Per-knapp disabled bevares — settes med en WeakMap ved første _wire().
37
+ *
38
+ * 8. Lytter på childList på host slik at nye <input type="radio">-elementer
39
+ * som legges til etter mount automatisk wires (id, name, htmlFor, disabled).
40
+ *
41
+ * ## Ingen Shadow DOM
42
+ *
43
+ * Komponenten bruker light DOM slik at CSS fra @sb1/indeks-css når alle
44
+ * delelementer direkte.
45
+ *
46
+ * @example
47
+ * <ix-radio-group>
48
+ * <span data-field="legend">Velg kundetype</span>
49
+ * <span data-field="description">Beskrivelse</span>
50
+ * <div class="ix-radio-button">
51
+ * <input type="radio" value="privat" />
52
+ * <label>Privat</label>
53
+ * </div>
54
+ * <div class="ix-radio-button">
55
+ * <input type="radio" value="bedrift" />
56
+ * <label>Bedrift</label>
57
+ * </div>
58
+ * <span data-field="error"></span>
59
+ * </ix-radio-group>
60
+ */
61
+ export declare class IxRadioGroup extends HTMLElement {
62
+ private _errorObserver;
63
+ private _childObserver;
64
+ private _keydownListener;
65
+ private _instanceId;
66
+ private _ownDisabled;
67
+ static get observedAttributes(): string[];
68
+ connectedCallback(): void;
69
+ disconnectedCallback(): void;
70
+ attributeChangedCallback(attr: string, _oldValue: string | null, _newValue: string | null): void;
71
+ private _wire;
72
+ private _containsRadioInput;
73
+ private _wireInputs;
74
+ private _syncInvalid;
75
+ private _syncHostStateToInputs;
76
+ private _syncRequired;
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sb1/indeks-web",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Web components for Indeks design system",
6
6
  "homepage": "https://design.sparebank1.no",