@sb1/indeks-web 0.18.1 → 0.19.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,11 @@
1
1
  # @sb1/indeks-web
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ef4f6e2: Ny DropdownMenu-komponent for handlingsmenyer og navigasjon. Komponenten følger APG Menu Button-mønsteret med full tastaturstøtte (piltaster, Home/End, Escape, typeahead), submenyer, og automatisk ARIA-oppsett. CSS-laget definerer `.ix-dropdown__menu`, `.ix-dropdown__item`, `.ix-dropdown__divider` og `.ix-dropdown__group`. Web component `<ix-dropdown>` håndterer åpne/lukke, posisjonering og fokusstyring. React-wrapperen `<DropdownMenu>` eksponerer `<DropdownMenu.Trigger>`, `<DropdownMenu.Menu>`, `<DropdownMenu.Item>`, `<DropdownMenu.Divider>` og `<DropdownMenu.Group>`.
8
+
3
9
  ## 0.18.1
4
10
 
5
11
  ## 0.18.0
@@ -1,6 +1,7 @@
1
1
  import { IxCheckboxGroup } from './lib/components/checkbox-group/IxCheckboxGroup.js';
2
2
  import { IxCombobox } from './lib/components/combobox/IxCombobox.js';
3
3
  import { IxDateField } from './lib/components/date-field/IxDateField.js';
4
+ import { IxDropdown } from './lib/components/dropdown/IxDropdown.js';
4
5
  import { IxField } from './lib/components/field/IxField.js';
5
6
  import { IxIcon, IconName } from './lib/components/icon/IxIcon.js';
6
7
  import { IxPhoneNumberField } from './lib/components/phone-number-field/IxPhoneNumberField.js';
@@ -13,6 +14,7 @@ export { IxRadioGroup };
13
14
  export { IxCheckboxGroup };
14
15
  export { IxCombobox };
15
16
  export { IxDateField };
17
+ export { IxDropdown };
16
18
  export { IxPhoneNumberField };
17
19
  export { IxProgressBar };
18
20
  export type { ProgressBarState } from './lib/components/progress-bar/IxProgressBar.js';
@@ -32,6 +34,7 @@ declare global {
32
34
  'ix-checkbox-group': IxCheckboxGroup;
33
35
  'ix-combobox': IxCombobox;
34
36
  'ix-date-field': IxDateField;
37
+ 'ix-dropdown': IxDropdown;
35
38
  'ix-phone-number-field': IxPhoneNumberField;
36
39
  'ix-progress-bar': IxProgressBar;
37
40
  'ix-tabs': IxTabs;
@@ -101,6 +104,11 @@ declare global {
101
104
  class?: string;
102
105
  children?: unknown;
103
106
  };
107
+ 'ix-dropdown': {
108
+ open?: boolean | '';
109
+ placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
110
+ 'data-submenu'?: boolean | '';
111
+ };
104
112
  'ix-icon': {
105
113
  name?: IconName;
106
114
  'data-size'?: 'sm' | 'md' | 'lg' | 'xl';
package/dist/npm/index.js CHANGED
@@ -506,7 +506,8 @@ var IxCombobox = class extends HTMLElement {
506
506
  listbox.style.left = `${Math.max(8, r.left)}px`;
507
507
  const ph = listbox.offsetHeight;
508
508
  const spaceBelow = vh - r.bottom;
509
- if (ph > spaceBelow && r.top > spaceBelow) listbox.style.top = `${Math.max(8, r.top - ph - gap)}px`;
509
+ const spaceAbove = r.top;
510
+ if (ph > spaceBelow && spaceAbove > spaceBelow) listbox.style.top = `${Math.max(8, r.top - ph - gap)}px`;
510
511
  else listbox.style.top = `${r.bottom + gap}px`;
511
512
  }
512
513
  _addRepositionListeners() {
@@ -1321,6 +1322,492 @@ var IxDateField = class extends HTMLElement {
1321
1322
  }
1322
1323
  };
1323
1324
  //#endregion
1325
+ //#region lib/components/dropdown/IxDropdown.ts
1326
+ /**
1327
+ * IxDropdown — ARIA-lim for en nedtrekksmeny med handlinger eller navigasjon.
1328
+ *
1329
+ * ## Designfilosofi
1330
+ *
1331
+ * Komponenten kobler en trigger-knapp med en meny. Forfatteren skriver en
1332
+ * <button> (trigger) og en .ix-dropdown__menu med .ix-dropdown__item-elementer
1333
+ * i light DOM. Komponentens jobb er å sette opp ARIA-attributter, håndtere
1334
+ * tastaturnavigasjon, posisjonere menyen, og styre åpen/lukket-tilstand.
1335
+ *
1336
+ * ## Roving tabindex
1337
+ *
1338
+ * Alle items har tabindex="-1" unntatt det aktive (fokuserte). Piltaster
1339
+ * flytter fokus mellom items. Dette følger APG Menu Button-mønsteret.
1340
+ *
1341
+ * ## Submenyer
1342
+ *
1343
+ * En submenu er en nøstet <ix-dropdown data-submenu> inne i parent-menyen.
1344
+ * ArrowRight på en submenu-trigger åpner submenyen, ArrowLeft lukker den.
1345
+ * Submenyen posisjoneres til høyre for parent-item (flipper ved plassmangel).
1346
+ *
1347
+ * ## Posisjonering
1348
+ *
1349
+ * Menyen posisjoneres manuelt med getBoundingClientRect + viewport-clamp
1350
+ * (samme grep som tooltip og combobox) — position: fixed, JS setter top/left.
1351
+ *
1352
+ * @example
1353
+ * <ix-dropdown>
1354
+ * <button class="ix-button" data-variant="secondary">Handlinger</button>
1355
+ * <div class="ix-dropdown__menu">
1356
+ * <button class="ix-dropdown__item">Rediger</button>
1357
+ * <button class="ix-dropdown__item">Dupliser</button>
1358
+ * <hr class="ix-dropdown__divider" />
1359
+ * <button class="ix-dropdown__item" data-danger>Slett</button>
1360
+ * </div>
1361
+ * </ix-dropdown>
1362
+ */
1363
+ var dropdownCounter = 0;
1364
+ var IxDropdown = class extends HTMLElement {
1365
+ constructor(..._args) {
1366
+ super(..._args);
1367
+ _defineProperty(this, "_instanceId", 0);
1368
+ _defineProperty(this, "_trigger", null);
1369
+ _defineProperty(this, "_menu", null);
1370
+ _defineProperty(this, "_items", []);
1371
+ _defineProperty(this, "_activeIndex", -1);
1372
+ _defineProperty(this, "_onTriggerClick", null);
1373
+ _defineProperty(this, "_onTriggerKeydown", null);
1374
+ _defineProperty(this, "_onMenuKeydown", null);
1375
+ _defineProperty(this, "_onMenuClick", null);
1376
+ _defineProperty(this, "_onMenuPointerenter", null);
1377
+ _defineProperty(this, "_onDocPointer", null);
1378
+ _defineProperty(this, "_onFocusout", null);
1379
+ _defineProperty(this, "_onReposition", null);
1380
+ _defineProperty(this, "_itemObserver", null);
1381
+ _defineProperty(this, "_typeaheadBuffer", "");
1382
+ _defineProperty(this, "_typeaheadTimer", null);
1383
+ _defineProperty(this, "_openedWithKeyboard", false);
1384
+ }
1385
+ static get observedAttributes() {
1386
+ return [
1387
+ "open",
1388
+ "placement",
1389
+ "data-controlled"
1390
+ ];
1391
+ }
1392
+ /**
1393
+ * Kontrollert modus: når `data-controlled` er satt, endrer ikke komponenten
1394
+ * egen åpen-tilstand. Den dispatcher kun `open-request` / `close-request`,
1395
+ * og foreldren må sette `open`-attributtet for å faktisk åpne/lukke.
1396
+ */
1397
+ get _isControlled() {
1398
+ return this.hasAttribute("data-controlled");
1399
+ }
1400
+ connectedCallback() {
1401
+ this._instanceId = ++dropdownCounter;
1402
+ this._wire();
1403
+ }
1404
+ disconnectedCallback() {
1405
+ if (this._trigger) {
1406
+ if (this._onTriggerClick) this._trigger.removeEventListener("click", this._onTriggerClick);
1407
+ if (this._onTriggerKeydown) this._trigger.removeEventListener("keydown", this._onTriggerKeydown);
1408
+ }
1409
+ if (this._menu) {
1410
+ if (this._onMenuKeydown) this._menu.removeEventListener("keydown", this._onMenuKeydown);
1411
+ if (this._onMenuClick) this._menu.removeEventListener("click", this._onMenuClick);
1412
+ if (this._onMenuPointerenter) this._menu.removeEventListener("pointerenter", this._onMenuPointerenter, true);
1413
+ }
1414
+ if (this._onDocPointer) document.removeEventListener("pointerdown", this._onDocPointer, true);
1415
+ if (this._onFocusout) this.removeEventListener("focusout", this._onFocusout);
1416
+ this._removeRepositionListeners();
1417
+ this._itemObserver?.disconnect();
1418
+ if (this._typeaheadTimer) {
1419
+ clearTimeout(this._typeaheadTimer);
1420
+ this._typeaheadTimer = null;
1421
+ }
1422
+ this._onTriggerClick = null;
1423
+ this._onTriggerKeydown = null;
1424
+ this._onMenuKeydown = null;
1425
+ this._onMenuClick = null;
1426
+ this._onMenuPointerenter = null;
1427
+ this._onDocPointer = null;
1428
+ this._onFocusout = null;
1429
+ this._onReposition = null;
1430
+ this._itemObserver = null;
1431
+ this._trigger = null;
1432
+ this._menu = null;
1433
+ this._items = [];
1434
+ }
1435
+ attributeChangedCallback(name, _oldValue, newValue) {
1436
+ if (!this.isConnected) return;
1437
+ if (name === "open") {
1438
+ const shouldBeOpen = newValue !== null && newValue !== "false";
1439
+ if (shouldBeOpen && !this._isOpen) this._doOpen();
1440
+ else if (!shouldBeOpen && this._isOpen) this._doClose();
1441
+ } else if (name === "placement" && this._isOpen) this._position();
1442
+ }
1443
+ get open() {
1444
+ const attr = this.getAttribute("open");
1445
+ return attr !== null && attr !== "false";
1446
+ }
1447
+ set open(value) {
1448
+ this.toggleAttribute("open", value);
1449
+ }
1450
+ get _isOpen() {
1451
+ return this.hasAttribute("data-open");
1452
+ }
1453
+ get _isSubmenu() {
1454
+ return this.hasAttribute("data-submenu");
1455
+ }
1456
+ get _placement() {
1457
+ return this.getAttribute("placement") ?? "bottom-start";
1458
+ }
1459
+ _wire() {
1460
+ this._menu = this.querySelector(":scope > .ix-dropdown__menu");
1461
+ if (this._isSubmenu) this._trigger = this.querySelector(":scope > .ix-dropdown__item");
1462
+ else this._trigger = Array.from(this.children).find((child) => child instanceof HTMLElement && !child.classList.contains("ix-dropdown__menu")) ?? null;
1463
+ if (!this._trigger || !this._menu) return;
1464
+ if (!this._menu.id) this._menu.id = `ix-dropdown-menu-${this._instanceId}`;
1465
+ if (!this._menu.getAttribute("role")) this._menu.setAttribute("role", "menu");
1466
+ this._menu.hidden = true;
1467
+ this._trigger.setAttribute("aria-haspopup", "menu");
1468
+ this._trigger.setAttribute("aria-expanded", "false");
1469
+ this._trigger.setAttribute("aria-controls", this._menu.id);
1470
+ this._wireItems();
1471
+ this._onTriggerClick = (e) => {
1472
+ e.preventDefault();
1473
+ e.stopPropagation();
1474
+ this._openedWithKeyboard = false;
1475
+ this._toggleOpen();
1476
+ };
1477
+ this._trigger.addEventListener("click", this._onTriggerClick);
1478
+ this._onTriggerKeydown = (e) => this._handleTriggerKeydown(e);
1479
+ this._trigger.addEventListener("keydown", this._onTriggerKeydown);
1480
+ this._onMenuKeydown = (e) => this._handleMenuKeydown(e);
1481
+ this._menu.addEventListener("keydown", this._onMenuKeydown);
1482
+ this._onMenuClick = (e) => {
1483
+ const item = e.target.closest(".ix-dropdown__item");
1484
+ if (item && this._menu?.contains(item)) this._handleItemClick(e, item);
1485
+ };
1486
+ this._menu.addEventListener("click", this._onMenuClick);
1487
+ this._onMenuPointerenter = (e) => {
1488
+ const item = e.target.closest(".ix-dropdown__item");
1489
+ if (item && this._menu?.contains(item)) {
1490
+ const idx = this._items.indexOf(item);
1491
+ if (idx !== -1) this._handleItemPointerEnter(idx);
1492
+ }
1493
+ };
1494
+ this._menu.addEventListener("pointerenter", this._onMenuPointerenter, true);
1495
+ this._onDocPointer = (e) => {
1496
+ if (!this._isOpen) return;
1497
+ const target = e.target;
1498
+ if (!this.contains(target)) this._close();
1499
+ };
1500
+ document.addEventListener("pointerdown", this._onDocPointer, true);
1501
+ this._onFocusout = (e) => {
1502
+ if (!this._isOpen) return;
1503
+ const relatedTarget = e.relatedTarget;
1504
+ if (relatedTarget && this.contains(relatedTarget)) return;
1505
+ const rootDropdown = this.closest("ix-dropdown:not([data-submenu])") ?? this;
1506
+ if (relatedTarget && rootDropdown.contains(relatedTarget)) return;
1507
+ this._close();
1508
+ };
1509
+ this.addEventListener("focusout", this._onFocusout);
1510
+ this._itemObserver = new MutationObserver(() => this._rewireItems());
1511
+ this._itemObserver.observe(this._menu, {
1512
+ childList: true,
1513
+ subtree: true
1514
+ });
1515
+ if (this.open) this._doOpen();
1516
+ }
1517
+ _wireItems() {
1518
+ this._rewireItems();
1519
+ }
1520
+ _rewireItems() {
1521
+ if (!this._menu) return;
1522
+ const items = Array.from(this._menu.children).filter((child) => child instanceof HTMLElement && child.classList.contains("ix-dropdown__item"));
1523
+ Array.from(this._menu.querySelectorAll(":scope > ix-dropdown[data-submenu]")).forEach((submenu) => {
1524
+ const submenuTrigger = submenu.querySelector(":scope > .ix-dropdown__item");
1525
+ if (submenuTrigger) {
1526
+ items.push(submenuTrigger);
1527
+ submenuTrigger.setAttribute("aria-haspopup", "menu");
1528
+ submenuTrigger.setAttribute("aria-expanded", "false");
1529
+ }
1530
+ });
1531
+ items.sort((a, b) => {
1532
+ const position = a.compareDocumentPosition(b);
1533
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
1534
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1;
1535
+ return 0;
1536
+ });
1537
+ items.forEach((item) => {
1538
+ if (!item.getAttribute("role")) item.setAttribute("role", "menuitem");
1539
+ item.setAttribute("tabindex", "-1");
1540
+ });
1541
+ this._menu.querySelectorAll(":scope > .ix-dropdown__divider").forEach((divider) => {
1542
+ if (!divider.getAttribute("role")) divider.setAttribute("role", "separator");
1543
+ });
1544
+ this._items = items;
1545
+ }
1546
+ _handleTriggerKeydown(e) {
1547
+ if (this._isSubmenu) return;
1548
+ switch (e.key) {
1549
+ case "Enter":
1550
+ case " ":
1551
+ case "ArrowDown":
1552
+ e.preventDefault();
1553
+ this._openedWithKeyboard = true;
1554
+ this._open();
1555
+ break;
1556
+ case "ArrowUp":
1557
+ e.preventDefault();
1558
+ this._openedWithKeyboard = true;
1559
+ this._open();
1560
+ this._focusItem(this._items.length - 1);
1561
+ break;
1562
+ }
1563
+ }
1564
+ _handleMenuKeydown(e) {
1565
+ const target = e.target;
1566
+ if (!this._menu?.contains(target)) return;
1567
+ const closestSubmenuMenu = target.closest("ix-dropdown[data-submenu] > .ix-dropdown__menu");
1568
+ if (closestSubmenuMenu && closestSubmenuMenu !== this._menu && this._menu.contains(closestSubmenuMenu)) return;
1569
+ switch (e.key) {
1570
+ case "Escape":
1571
+ e.preventDefault();
1572
+ e.stopPropagation();
1573
+ this._close();
1574
+ this._trigger?.focus();
1575
+ break;
1576
+ case "ArrowDown":
1577
+ e.preventDefault();
1578
+ this._focusNextItem(1);
1579
+ break;
1580
+ case "ArrowUp":
1581
+ e.preventDefault();
1582
+ this._focusNextItem(-1);
1583
+ break;
1584
+ case "Home":
1585
+ e.preventDefault();
1586
+ this._focusItem(0);
1587
+ break;
1588
+ case "End":
1589
+ e.preventDefault();
1590
+ this._focusItem(this._items.length - 1);
1591
+ break;
1592
+ case "ArrowRight": {
1593
+ const submenu = target.closest("ix-dropdown[data-submenu]");
1594
+ if (submenu && submenu !== this && target === submenu._trigger) {
1595
+ e.preventDefault();
1596
+ submenu._openedWithKeyboard = true;
1597
+ submenu._open();
1598
+ }
1599
+ break;
1600
+ }
1601
+ case "ArrowLeft":
1602
+ if (this._isSubmenu) {
1603
+ e.preventDefault();
1604
+ e.stopPropagation();
1605
+ const parentDropdown = this.parentElement?.closest("ix-dropdown");
1606
+ if (this._trigger) {
1607
+ this._trigger.focus();
1608
+ if (parentDropdown) {
1609
+ const triggerIndex = parentDropdown._items.indexOf(this._trigger);
1610
+ if (triggerIndex !== -1) parentDropdown._focusItem(triggerIndex);
1611
+ }
1612
+ }
1613
+ this._close();
1614
+ }
1615
+ break;
1616
+ case "Enter":
1617
+ case " ":
1618
+ if (target.classList.contains("ix-dropdown__item")) {
1619
+ e.preventDefault();
1620
+ const submenu = target.parentElement;
1621
+ if (submenu?.hasAttribute("data-submenu") && target === submenu._trigger) {
1622
+ submenu._openedWithKeyboard = true;
1623
+ submenu._open();
1624
+ } else target.click();
1625
+ }
1626
+ break;
1627
+ case "Tab":
1628
+ e.preventDefault();
1629
+ this._closeAll();
1630
+ break;
1631
+ default: if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) this._handleTypeahead(e.key);
1632
+ }
1633
+ }
1634
+ _handleItemPointerEnter(index) {
1635
+ this._activeIndex = index;
1636
+ if (this._menu?.contains(document.activeElement)) this._focusItem(index);
1637
+ }
1638
+ _handleItemClick(e, item) {
1639
+ if (item.hasAttribute("disabled") || item.getAttribute("aria-disabled") === "true") {
1640
+ e.preventDefault();
1641
+ return;
1642
+ }
1643
+ const submenu = item.parentElement;
1644
+ if (submenu?.hasAttribute("data-submenu") && item === submenu._trigger) {
1645
+ e.stopPropagation();
1646
+ return;
1647
+ }
1648
+ this._activeIndex = this._items.indexOf(item);
1649
+ this._closeAll();
1650
+ }
1651
+ _handleTypeahead(char) {
1652
+ this._typeaheadBuffer += char.toLowerCase();
1653
+ if (this._typeaheadTimer) clearTimeout(this._typeaheadTimer);
1654
+ this._typeaheadTimer = setTimeout(() => {
1655
+ this._typeaheadBuffer = "";
1656
+ }, 500);
1657
+ const match = this._items.findIndex((item) => {
1658
+ if (item.hasAttribute("disabled") || item.getAttribute("aria-disabled") === "true") return false;
1659
+ return (item.textContent ?? "").trim().toLowerCase().startsWith(this._typeaheadBuffer);
1660
+ });
1661
+ if (match !== -1) this._focusItem(match);
1662
+ }
1663
+ _focusItem(index) {
1664
+ if (this._items.filter((item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true").length === 0) return;
1665
+ const clampedIndex = Math.max(0, Math.min(index, this._items.length - 1));
1666
+ const item = this._items[clampedIndex];
1667
+ if (item?.hasAttribute("disabled") || item?.getAttribute("aria-disabled") === "true") {
1668
+ this._focusNextItem(1);
1669
+ return;
1670
+ }
1671
+ this._items.forEach((i, idx) => {
1672
+ i.setAttribute("tabindex", idx === clampedIndex ? "0" : "-1");
1673
+ i.removeAttribute("data-active");
1674
+ });
1675
+ if (item) {
1676
+ item.setAttribute("data-active", "");
1677
+ item.focus();
1678
+ this._activeIndex = clampedIndex;
1679
+ }
1680
+ }
1681
+ _focusNextItem(delta) {
1682
+ if (this._items.filter((item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true").length === 0) return;
1683
+ let currentIdx = this._activeIndex;
1684
+ if (currentIdx === -1) currentIdx = delta > 0 ? -1 : this._items.length;
1685
+ let nextIdx = currentIdx;
1686
+ let iterations = 0;
1687
+ do {
1688
+ nextIdx = nextIdx + delta;
1689
+ if (nextIdx < 0) nextIdx = this._items.length - 1;
1690
+ if (nextIdx >= this._items.length) nextIdx = 0;
1691
+ iterations++;
1692
+ } while (iterations < this._items.length && (this._items[nextIdx]?.hasAttribute("disabled") || this._items[nextIdx]?.getAttribute("aria-disabled") === "true"));
1693
+ this._focusItem(nextIdx);
1694
+ }
1695
+ _open() {
1696
+ if (this._isOpen || !this._menu || !this._trigger) return;
1697
+ if (this._isControlled) {
1698
+ this.dispatchEvent(new CustomEvent("open-request", { bubbles: true }));
1699
+ return;
1700
+ }
1701
+ this._doOpen();
1702
+ }
1703
+ _doOpen() {
1704
+ if (this._isOpen || !this._menu || !this._trigger) return;
1705
+ this.setAttribute("data-open", "");
1706
+ this.setAttribute("open", "");
1707
+ this._menu.hidden = false;
1708
+ this._trigger.setAttribute("aria-expanded", "true");
1709
+ this._position();
1710
+ this._addRepositionListeners();
1711
+ if (this._openedWithKeyboard) this._focusItem(0);
1712
+ this.dispatchEvent(new CustomEvent("open", { bubbles: true }));
1713
+ }
1714
+ _close() {
1715
+ if (!this._isOpen || !this._menu || !this._trigger) return;
1716
+ if (this._isControlled) {
1717
+ this.dispatchEvent(new CustomEvent("close-request", { bubbles: true }));
1718
+ return;
1719
+ }
1720
+ this._doClose();
1721
+ }
1722
+ _doClose() {
1723
+ if (!this._isOpen || !this._menu || !this._trigger) return;
1724
+ this._closeAllSubmenus();
1725
+ this.removeAttribute("data-open");
1726
+ this.removeAttribute("open");
1727
+ this._menu.hidden = true;
1728
+ this._trigger.setAttribute("aria-expanded", "false");
1729
+ this._items.forEach((item) => {
1730
+ item.setAttribute("tabindex", "-1");
1731
+ item.removeAttribute("data-active");
1732
+ });
1733
+ this._activeIndex = -1;
1734
+ this._removeRepositionListeners();
1735
+ this.dispatchEvent(new CustomEvent("close", { bubbles: true }));
1736
+ }
1737
+ _toggleOpen() {
1738
+ if (this._isOpen) this._close();
1739
+ else this._open();
1740
+ }
1741
+ _closeAll() {
1742
+ let root = this.parentElement?.closest("ix-dropdown");
1743
+ if (!root) {
1744
+ this._close();
1745
+ this._trigger?.focus();
1746
+ return;
1747
+ }
1748
+ while (root.parentElement?.closest("ix-dropdown")) root = root.parentElement.closest("ix-dropdown");
1749
+ root._close();
1750
+ root._trigger?.focus();
1751
+ }
1752
+ _closeAllSubmenus() {
1753
+ (this._menu?.querySelectorAll("ix-dropdown[data-submenu]"))?.forEach((submenu) => {
1754
+ if (submenu._isOpen) submenu._close();
1755
+ });
1756
+ }
1757
+ _position() {
1758
+ if (!this._menu || !this._trigger) return;
1759
+ const triggerRect = this._trigger.getBoundingClientRect();
1760
+ const menuRect = this._menu.getBoundingClientRect();
1761
+ const vw = window.innerWidth;
1762
+ const vh = window.innerHeight;
1763
+ const gap = 2;
1764
+ let top;
1765
+ let left;
1766
+ if (this._isSubmenu) {
1767
+ const parentMenuRect = this.parentElement?.getBoundingClientRect();
1768
+ const anchorRight = parentMenuRect?.right ?? triggerRect.right;
1769
+ const anchorLeft = parentMenuRect?.left ?? triggerRect.left;
1770
+ const spaceRight = vw - anchorRight;
1771
+ const spaceLeft = anchorLeft;
1772
+ if (spaceRight >= menuRect.width || spaceRight >= spaceLeft) left = anchorRight + gap;
1773
+ else left = anchorLeft - menuRect.width - gap;
1774
+ top = triggerRect.top;
1775
+ if (top + menuRect.height > vh - gap) top = Math.max(gap, vh - menuRect.height - gap);
1776
+ } else {
1777
+ const [vertical, horizontal] = this._placement.split("-");
1778
+ const spaceBelow = vh - triggerRect.bottom;
1779
+ const spaceAbove = triggerRect.top;
1780
+ if (vertical === "bottom" && spaceBelow >= menuRect.height + gap) top = triggerRect.bottom + gap;
1781
+ else if (vertical === "top" && spaceAbove >= menuRect.height + gap) top = triggerRect.top - menuRect.height - gap;
1782
+ else if (spaceBelow >= spaceAbove) top = triggerRect.bottom + gap;
1783
+ else top = triggerRect.top - menuRect.height - gap;
1784
+ if (horizontal === "end") left = triggerRect.right - menuRect.width;
1785
+ else left = triggerRect.left;
1786
+ if (left + menuRect.width > vw - gap) left = vw - menuRect.width - gap;
1787
+ if (left < gap) left = gap;
1788
+ }
1789
+ if (top + menuRect.height > vh - gap) top = Math.max(gap, vh - menuRect.height - gap);
1790
+ if (top < gap) top = gap;
1791
+ this._menu.style.top = `${top}px`;
1792
+ this._menu.style.left = `${left}px`;
1793
+ }
1794
+ _addRepositionListeners() {
1795
+ if (this._onReposition) return;
1796
+ this._onReposition = () => {
1797
+ if (this._isOpen) this._position();
1798
+ };
1799
+ window.addEventListener("scroll", this._onReposition, true);
1800
+ window.addEventListener("resize", this._onReposition);
1801
+ }
1802
+ _removeRepositionListeners() {
1803
+ if (this._onReposition) {
1804
+ window.removeEventListener("scroll", this._onReposition, true);
1805
+ window.removeEventListener("resize", this._onReposition);
1806
+ this._onReposition = null;
1807
+ }
1808
+ }
1809
+ };
1810
+ //#endregion
1324
1811
  //#region lib/components/field/IxField.ts
1325
1812
  /**
1326
1813
  * IxField — tilgjengelighetslim mellom label, input og hjelpetekster.
@@ -3503,6 +3990,7 @@ customElements.define("ix-radio-group", IxRadioGroup);
3503
3990
  customElements.define("ix-checkbox-group", IxCheckboxGroup);
3504
3991
  customElements.define("ix-combobox", IxCombobox);
3505
3992
  customElements.define("ix-date-field", IxDateField);
3993
+ customElements.define("ix-dropdown", IxDropdown);
3506
3994
  customElements.define("ix-phone-number-field", IxPhoneNumberField);
3507
3995
  customElements.define("ix-progress-bar", IxProgressBar);
3508
3996
  customElements.define("ix-tabs", IxTabs);
@@ -3511,4 +3999,4 @@ customElements.define("ix-tab", IxTab);
3511
3999
  customElements.define("ix-tab-panel", IxTabPanel);
3512
4000
  globalThis.cn = cn;
3513
4001
  //#endregion
3514
- export { BUILTIN_FORMAT_NAMES, COMMON_ICON_NAMES, IxCheckboxGroup, IxCombobox, IxDateField, IxField, IxIcon, IxPhoneNumberField, IxProgressBar, IxRadioGroup, IxTab, IxTabList, IxTabPanel, IxTabs, amountFormatterForLocale, cn, createAmountFormatter, createPatternFormatter, registerFormat, resolveFormat };
4002
+ export { BUILTIN_FORMAT_NAMES, COMMON_ICON_NAMES, IxCheckboxGroup, IxCombobox, IxDateField, IxDropdown, IxField, IxIcon, IxPhoneNumberField, IxProgressBar, IxRadioGroup, IxTab, IxTabList, IxTabPanel, IxTabs, amountFormatterForLocale, cn, createAmountFormatter, createPatternFormatter, registerFormat, resolveFormat };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * IxDropdown — ARIA-lim for en nedtrekksmeny med handlinger eller navigasjon.
3
+ *
4
+ * ## Designfilosofi
5
+ *
6
+ * Komponenten kobler en trigger-knapp med en meny. Forfatteren skriver en
7
+ * <button> (trigger) og en .ix-dropdown__menu med .ix-dropdown__item-elementer
8
+ * i light DOM. Komponentens jobb er å sette opp ARIA-attributter, håndtere
9
+ * tastaturnavigasjon, posisjonere menyen, og styre åpen/lukket-tilstand.
10
+ *
11
+ * ## Roving tabindex
12
+ *
13
+ * Alle items har tabindex="-1" unntatt det aktive (fokuserte). Piltaster
14
+ * flytter fokus mellom items. Dette følger APG Menu Button-mønsteret.
15
+ *
16
+ * ## Submenyer
17
+ *
18
+ * En submenu er en nøstet <ix-dropdown data-submenu> inne i parent-menyen.
19
+ * ArrowRight på en submenu-trigger åpner submenyen, ArrowLeft lukker den.
20
+ * Submenyen posisjoneres til høyre for parent-item (flipper ved plassmangel).
21
+ *
22
+ * ## Posisjonering
23
+ *
24
+ * Menyen posisjoneres manuelt med getBoundingClientRect + viewport-clamp
25
+ * (samme grep som tooltip og combobox) — position: fixed, JS setter top/left.
26
+ *
27
+ * @example
28
+ * <ix-dropdown>
29
+ * <button class="ix-button" data-variant="secondary">Handlinger</button>
30
+ * <div class="ix-dropdown__menu">
31
+ * <button class="ix-dropdown__item">Rediger</button>
32
+ * <button class="ix-dropdown__item">Dupliser</button>
33
+ * <hr class="ix-dropdown__divider" />
34
+ * <button class="ix-dropdown__item" data-danger>Slett</button>
35
+ * </div>
36
+ * </ix-dropdown>
37
+ */
38
+ export declare class IxDropdown extends HTMLElement {
39
+ private _instanceId;
40
+ private _trigger;
41
+ private _menu;
42
+ private _items;
43
+ private _activeIndex;
44
+ private _onTriggerClick;
45
+ private _onTriggerKeydown;
46
+ private _onMenuKeydown;
47
+ private _onMenuClick;
48
+ private _onMenuPointerenter;
49
+ private _onDocPointer;
50
+ private _onFocusout;
51
+ private _onReposition;
52
+ private _itemObserver;
53
+ private _typeaheadBuffer;
54
+ private _typeaheadTimer;
55
+ private _openedWithKeyboard;
56
+ static get observedAttributes(): string[];
57
+ /**
58
+ * Kontrollert modus: når `data-controlled` er satt, endrer ikke komponenten
59
+ * egen åpen-tilstand. Den dispatcher kun `open-request` / `close-request`,
60
+ * og foreldren må sette `open`-attributtet for å faktisk åpne/lukke.
61
+ */
62
+ private get _isControlled();
63
+ connectedCallback(): void;
64
+ disconnectedCallback(): void;
65
+ attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void;
66
+ get open(): boolean;
67
+ set open(value: boolean);
68
+ private get _isOpen();
69
+ private get _isSubmenu();
70
+ private get _placement();
71
+ private _wire;
72
+ private _wireItems;
73
+ private _rewireItems;
74
+ private _handleTriggerKeydown;
75
+ private _handleMenuKeydown;
76
+ private _handleItemPointerEnter;
77
+ private _handleItemClick;
78
+ private _handleTypeahead;
79
+ private _focusItem;
80
+ private _focusNextItem;
81
+ private _open;
82
+ private _doOpen;
83
+ private _close;
84
+ private _doClose;
85
+ private _toggleOpen;
86
+ private _closeAll;
87
+ private _closeAllSubmenus;
88
+ private _position;
89
+ private _addRepositionListeners;
90
+ private _removeRepositionListeners;
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sb1/indeks-web",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "description": "Web components for Indeks design system",
6
6
  "homepage": "https://design.sparebank1.no",