@rettangoli/ui 1.9.1 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/ui",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "A UI component library for building web interfaces.",
5
5
  "main": "dist/rettangoli-esm.min.js",
6
6
  "type": "module",
@@ -12,6 +12,13 @@ const CLOSE_BUTTON_SIZE_PX = 32;
12
12
  const CLOSE_BUTTON_OFFSET_PX = 8;
13
13
  const ACTIVE_LAYOUT_ATTR = "data-rtgl-active-layout";
14
14
  const FIXED_LAYOUT_SELECTOR = `:host([${ACTIVE_LAYOUT_ATTR}="fixed"])`;
15
+ const NATIVE_TEMPORAL_INPUT_TYPES = new Set([
16
+ "date",
17
+ "datetime-local",
18
+ "month",
19
+ "time",
20
+ "week",
21
+ ]);
15
22
 
16
23
  const mediaQueryCondition = (mediaQuery) => mediaQuery.replace(/^@media\s+/, "");
17
24
  const fixedLayoutStyle = (selector) => css`
@@ -209,6 +216,21 @@ class RettangoliDialogElement extends HTMLElement {
209
216
  max-width: 100vw;
210
217
  padding: 0;
211
218
  }
219
+
220
+ :host([bare]) dialog::backdrop {
221
+ background-color: transparent;
222
+ }
223
+
224
+ :host([bare]) slot[name="content"] {
225
+ animation: none;
226
+ background-color: transparent !important;
227
+ border: none;
228
+ border-radius: 0;
229
+ margin-left: 0;
230
+ margin-right: 0;
231
+ max-width: 100vw;
232
+ padding: 0;
233
+ }
212
234
  `);
213
235
  }
214
236
  }
@@ -254,6 +276,9 @@ class RettangoliDialogElement extends HTMLElement {
254
276
  this._onDialogScroll = () => {
255
277
  this._updateCloseButtonPosition();
256
278
  };
279
+ this._onDialogKeyDown = (event) => {
280
+ this._containTabFocus(event);
281
+ };
257
282
  this._onCloseButtonClick = (e) => {
258
283
  e.preventDefault();
259
284
  e.stopPropagation();
@@ -292,6 +317,7 @@ class RettangoliDialogElement extends HTMLElement {
292
317
  e.preventDefault();
293
318
  this._attemptClose();
294
319
  });
320
+ this._dialogElement.addEventListener('keydown', this._onDialogKeyDown);
295
321
  }
296
322
 
297
323
  _attemptClose() {
@@ -382,6 +408,330 @@ class RettangoliDialogElement extends HTMLElement {
382
408
  return closeButtonElement;
383
409
  }
384
410
 
411
+ _isTabbableElement(element) {
412
+ if (!this._isTabbableElementIgnoringRadioGroup(element)) {
413
+ return false;
414
+ }
415
+
416
+ return !(
417
+ element instanceof HTMLInputElement &&
418
+ element.type === "radio" &&
419
+ !this._isTabbableRadio(element)
420
+ );
421
+ }
422
+
423
+ _getComposedParent(element) {
424
+ if (element.assignedSlot) {
425
+ return element.assignedSlot;
426
+ }
427
+ if (element.parentElement) {
428
+ return element.parentElement;
429
+ }
430
+
431
+ const root = element.getRootNode?.();
432
+ return root instanceof ShadowRoot ? root.host : null;
433
+ }
434
+
435
+ _hasInertComposedAncestor(element) {
436
+ let currentElement = element;
437
+ while (currentElement) {
438
+ if (currentElement instanceof HTMLElement && currentElement.inert) {
439
+ return true;
440
+ }
441
+ currentElement = this._getComposedParent(currentElement);
442
+ }
443
+ return false;
444
+ }
445
+
446
+ _isEditingHost(element) {
447
+ if (!(element instanceof HTMLElement) || !element.isContentEditable) {
448
+ return false;
449
+ }
450
+
451
+ const parent = this._getComposedParent(element);
452
+ return !(parent instanceof HTMLElement && parent.isContentEditable);
453
+ }
454
+
455
+ _getSequentialTabIndex(element) {
456
+ if (typeof element.tabIndex === "number" && element.tabIndex >= 0) {
457
+ return element.tabIndex;
458
+ }
459
+ if (!element.hasAttribute("tabindex") && this._isEditingHost(element)) {
460
+ return 0;
461
+ }
462
+ return null;
463
+ }
464
+
465
+ _isHiddenByClosedDetails(element) {
466
+ let currentElement = element;
467
+ let parent = this._getComposedParent(currentElement);
468
+
469
+ while (parent) {
470
+ if (parent instanceof HTMLDetailsElement && !parent.open) {
471
+ const summary = [...parent.children]
472
+ .find((child) => child instanceof HTMLElement && child.localName === "summary");
473
+ if (currentElement !== summary) {
474
+ return true;
475
+ }
476
+ }
477
+ currentElement = parent;
478
+ parent = this._getComposedParent(currentElement);
479
+ }
480
+ return false;
481
+ }
482
+
483
+ _isInsideDialog(element) {
484
+ let currentElement = element;
485
+ while (currentElement) {
486
+ if (currentElement === this._dialogElement) {
487
+ return true;
488
+ }
489
+ currentElement = this._getComposedParent(currentElement);
490
+ }
491
+ return false;
492
+ }
493
+
494
+ _isTabbableRadio(radioElement) {
495
+ if (!radioElement.name) {
496
+ return true;
497
+ }
498
+
499
+ const root = radioElement.getRootNode();
500
+ const radioGroup = [...root.querySelectorAll("input[type='radio']")]
501
+ .filter((candidate) => (
502
+ candidate.name === radioElement.name &&
503
+ candidate.form === radioElement.form &&
504
+ this._isInsideDialog(candidate) &&
505
+ this._isTabbableElementIgnoringRadioGroup(candidate)
506
+ ));
507
+ const checkedRadio = radioGroup.find((candidate) => candidate.checked);
508
+ return radioElement === (checkedRadio ?? radioGroup[0]);
509
+ }
510
+
511
+ _isTabbableElementIgnoringRadioGroup(element) {
512
+ if (
513
+ !(element instanceof Element) ||
514
+ this._getSequentialTabIndex(element) === null ||
515
+ element.matches(":disabled") ||
516
+ (element instanceof HTMLInputElement && element.type === "hidden") ||
517
+ this._hasInertComposedAncestor(element) ||
518
+ this._isHiddenByClosedDetails(element)
519
+ ) {
520
+ return false;
521
+ }
522
+
523
+ const style = getComputedStyle(element);
524
+ const isCssVisible = (
525
+ style.display !== "none" &&
526
+ style.visibility !== "hidden" &&
527
+ style.visibility !== "collapse"
528
+ );
529
+ if (element instanceof HTMLAreaElement) {
530
+ return isCssVisible && this._hasVisibleAssociatedImage(element);
531
+ }
532
+
533
+ return (
534
+ element.getClientRects().length > 0 &&
535
+ isCssVisible
536
+ );
537
+ }
538
+
539
+ _hasVisibleAssociatedImage(areaElement) {
540
+ const mapElement = areaElement.closest("map[name]");
541
+ if (!(mapElement instanceof HTMLMapElement) || !mapElement.name) {
542
+ return false;
543
+ }
544
+
545
+ const root = mapElement.getRootNode();
546
+ if (!(root instanceof Document || root instanceof ShadowRoot)) {
547
+ return false;
548
+ }
549
+
550
+ return [...root.querySelectorAll("img[usemap]")].some((imageElement) => {
551
+ const useMap = imageElement.getAttribute("usemap")?.trim();
552
+ if (
553
+ useMap !== `#${mapElement.name}` ||
554
+ this._hasInertComposedAncestor(imageElement) ||
555
+ this._isHiddenByClosedDetails(imageElement)
556
+ ) {
557
+ return false;
558
+ }
559
+
560
+ const style = getComputedStyle(imageElement);
561
+ return (
562
+ imageElement.getClientRects().length > 0 &&
563
+ style.display !== "none" &&
564
+ style.visibility !== "hidden" &&
565
+ style.visibility !== "collapse"
566
+ );
567
+ });
568
+ }
569
+
570
+ _appendTabScope(scopeParent, children, items) {
571
+ const hasTabIndex = scopeParent.hasAttribute("tabindex");
572
+ if (hasTabIndex && scopeParent.tabIndex < 0) {
573
+ return;
574
+ }
575
+
576
+ const childItems = [];
577
+ this._collectTabOrderItems(children, childItems);
578
+ const elements = [];
579
+ if (this._isTabbableElement(scopeParent)) {
580
+ elements.push(scopeParent);
581
+ }
582
+ elements.push(...this._sortTabOrderItems(childItems));
583
+
584
+ if (elements.length > 0) {
585
+ items.push({
586
+ tabIndex: hasTabIndex ? scopeParent.tabIndex : 0,
587
+ elements,
588
+ });
589
+ }
590
+ }
591
+
592
+ _collectTabOrderItems(nodes, items) {
593
+ for (const node of nodes) {
594
+ if (!(node instanceof Element)) {
595
+ continue;
596
+ }
597
+
598
+ if (node instanceof HTMLSlotElement) {
599
+ const assignedElements = node.assignedElements();
600
+ const children = assignedElements.length > 0
601
+ ? assignedElements
602
+ : [...node.children];
603
+ this._appendTabScope(node, children, items);
604
+ continue;
605
+ }
606
+
607
+ if (node.shadowRoot) {
608
+ this._appendTabScope(node, [...node.shadowRoot.children], items);
609
+ continue;
610
+ }
611
+
612
+ if (this._isTabbableElement(node)) {
613
+ items.push({
614
+ tabIndex: this._getSequentialTabIndex(node),
615
+ elements: [node],
616
+ });
617
+ }
618
+ this._collectTabOrderItems(node.children, items);
619
+ }
620
+ }
621
+
622
+ _sortTabOrderItems(items) {
623
+ const positiveItems = items
624
+ .map((item, documentOrder) => ({ ...item, documentOrder }))
625
+ .filter((item) => item.tabIndex > 0)
626
+ .sort((left, right) => (
627
+ left.tabIndex - right.tabIndex ||
628
+ left.documentOrder - right.documentOrder
629
+ ));
630
+ const regularItems = items.filter((item) => item.tabIndex === 0);
631
+ return [...positiveItems, ...regularItems]
632
+ .flatMap((item) => item.elements);
633
+ }
634
+
635
+ _getTabbableElements() {
636
+ const items = [];
637
+ this._collectTabOrderItems(this._dialogElement.children, items);
638
+ return this._sortTabOrderItems(items);
639
+ }
640
+
641
+ _getDeepActiveElement() {
642
+ let activeElement = document.activeElement;
643
+ while (activeElement?.shadowRoot?.activeElement) {
644
+ activeElement = activeElement.shadowRoot.activeElement;
645
+ }
646
+ return activeElement;
647
+ }
648
+
649
+ _hasNativeInternalTabSequence(element) {
650
+ return (
651
+ element instanceof HTMLInputElement &&
652
+ NATIVE_TEMPORAL_INPUT_TYPES.has(element.type)
653
+ );
654
+ }
655
+
656
+ _deferNativeTabContainment(movingBeforeFirst) {
657
+ setTimeout(() => {
658
+ if (!this._dialogElement.matches(":modal")) {
659
+ return;
660
+ }
661
+
662
+ const activeElement = this._getDeepActiveElement();
663
+ if (activeElement && this._isInsideDialog(activeElement)) {
664
+ return;
665
+ }
666
+
667
+ const tabbableElements = this._getTabbableElements();
668
+ const nextElement = movingBeforeFirst
669
+ ? tabbableElements[tabbableElements.length - 1]
670
+ : tabbableElements[0];
671
+ if (nextElement) {
672
+ nextElement.focus({ preventScroll: true });
673
+ } else {
674
+ this._dialogElement.focus({ preventScroll: true });
675
+ }
676
+ }, 0);
677
+ }
678
+
679
+ _containTabFocus(event) {
680
+ if (
681
+ event.key !== "Tab" ||
682
+ event.defaultPrevented ||
683
+ event.altKey ||
684
+ event.ctrlKey ||
685
+ event.metaKey ||
686
+ !this._isTabEventOwnedByDialog(event)
687
+ ) {
688
+ return;
689
+ }
690
+
691
+ const tabbableElements = this._getTabbableElements();
692
+ if (tabbableElements.length === 0) {
693
+ event.preventDefault();
694
+ this._dialogElement.focus({ preventScroll: true });
695
+ return;
696
+ }
697
+
698
+ const activeElement = this._getDeepActiveElement();
699
+ const firstElement = tabbableElements[0];
700
+ const lastElement = tabbableElements[tabbableElements.length - 1];
701
+ const movingBeforeFirst = event.shiftKey && (
702
+ activeElement === firstElement || activeElement === this._dialogElement
703
+ );
704
+ const movingAfterLast = !event.shiftKey && activeElement === lastElement;
705
+
706
+ if (!movingBeforeFirst && !movingAfterLast) {
707
+ return;
708
+ }
709
+
710
+ if (this._hasNativeInternalTabSequence(activeElement)) {
711
+ this._deferNativeTabContainment(movingBeforeFirst);
712
+ return;
713
+ }
714
+
715
+ event.preventDefault();
716
+ const nextElement = movingBeforeFirst ? lastElement : firstElement;
717
+ nextElement.focus({ preventScroll: true });
718
+ }
719
+
720
+ _isTabEventOwnedByDialog(event) {
721
+ for (const node of event.composedPath()) {
722
+ if (!(node instanceof HTMLDialogElement)) {
723
+ continue;
724
+ }
725
+ if (node === this._dialogElement) {
726
+ return true;
727
+ }
728
+ if (node.matches(":modal")) {
729
+ return false;
730
+ }
731
+ }
732
+ return false;
733
+ }
734
+
385
735
  _clearManagedLongTokenWrapping() {
386
736
  for (const textElement of this._managedLongTokenTextElements) {
387
737
  if (textElement?.isConnected) {
@@ -123,9 +123,16 @@ class RettangoliInputNumberElement extends HTMLElement {
123
123
  this._inputElement.focus();
124
124
  }
125
125
 
126
- _emitValueEvent = (eventName) => {
126
+ _emitValueEvent = (eventName, { commit = false } = {}) => {
127
127
  const inputValue = this._inputElement.value;
128
128
  if (inputValue.trim() === "") {
129
+ // Native number inputs expose incomplete values such as `1e` as an
130
+ // empty string with badInput=true. Keep the last valid form value until
131
+ // the editor becomes either a complete number or a genuine empty state.
132
+ if (this._inputElement.validity.badInput) {
133
+ return;
134
+ }
135
+
129
136
  this.dispatchEvent(new CustomEvent(eventName, {
130
137
  detail: {
131
138
  value: null,
@@ -135,16 +142,24 @@ class RettangoliInputNumberElement extends HTMLElement {
135
142
  return;
136
143
  }
137
144
 
138
- let numericValue = parseFloat(inputValue);
145
+ const numericValue = this._inputElement.valueAsNumber;
139
146
 
140
147
  // Only process if the value is a valid number (not NaN)
141
- if (!isNaN(numericValue)) {
142
- numericValue = this._clampValueToBounds(numericValue);
143
- this._inputElement.value = numericValue.toString();
148
+ if (!Number.isNaN(numericValue)) {
149
+ const nextValue = commit
150
+ ? this._clampValueToBounds(numericValue)
151
+ : numericValue;
152
+
153
+ // Preserve native editing states (for example a trailing decimal) while
154
+ // typing. Rewriting the value on every input event resets the caret and
155
+ // makes decimal entry and deletion unreliable.
156
+ if (commit) {
157
+ this._inputElement.value = nextValue.toString();
158
+ }
144
159
 
145
160
  this.dispatchEvent(new CustomEvent(eventName, {
146
161
  detail: {
147
- value: numericValue,
162
+ value: nextValue,
148
163
  },
149
164
  bubbles: true,
150
165
  }));
@@ -156,7 +171,7 @@ class RettangoliInputNumberElement extends HTMLElement {
156
171
  };
157
172
 
158
173
  _onChange = () => {
159
- this._emitValueEvent('value-change');
174
+ this._emitValueEvent('value-change', { commit: true });
160
175
  };
161
176
 
162
177
  attributeChangedCallback(name, oldValue, newValue) {