@sb1/indeks-web 0.18.0 → 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 +8 -0
- package/dist/npm/index.d.ts +8 -0
- package/dist/npm/index.js +490 -2
- package/dist/npm/lib/components/dropdown/IxDropdown.d.ts +91 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.18.1
|
|
10
|
+
|
|
3
11
|
## 0.18.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
package/dist/npm/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|