@nectary/components 5.31.0 → 5.31.2

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.
@@ -1,7 +1,7 @@
1
1
  import "../icon/index.js";
2
2
  import "../text/index.js";
3
3
  import "../title/index.js";
4
- import { isAttrEqual, updateAttribute, updateExplicitBooleanAttribute, isAttrTrue, updateBooleanAttribute, getAttribute, getBooleanAttribute, getLiteralAttribute, updateLiteralAttribute } from "../utils/dom.js";
4
+ import { isAttrEqual, updateBooleanAttribute, isAttrTrue, updateExplicitBooleanAttribute, updateAttribute, getAttribute, getBooleanAttribute, getLiteralAttribute, updateLiteralAttribute } from "../utils/dom.js";
5
5
  import { defineCustomElement, NectaryElement } from "../utils/element.js";
6
6
  import { statusValues } from "./utils.js";
7
7
  const templateHTML = '<style>:host{display:block;outline:0;min-height:48px}#wrapper{display:flex;flex-direction:column;position:relative;width:100%;height:100%;box-sizing:border-box;overflow:hidden;border-bottom:1px solid var(--sinch-comp-accordion-color-default-border-initial)}:host(:last-child)>#wrapper{border-bottom:none}#button{all:initial;display:flex;position:relative;align-items:flex-start;gap:8px;box-sizing:border-box;width:100%;min-height:48px;padding:12px 4px 12px 8px;cursor:pointer;--sinch-global-color-icon:var(--sinch-comp-accordion-color-default-icon-initial);--sinch-global-size-icon:var(--sinch-comp-accordion-size-icon)}#button>*{pointer-events:none}#button:disabled{cursor:initial;--sinch-global-color-icon:var(--sinch-comp-accordion-color-disabled-icon-initial)}#button:focus-visible::after{content:"";position:absolute;inset:0;border:2px solid var(--sinch-comp-accordion-color-default-outline-focus);pointer-events:none}#status-wrapper{display:none;width:18px;height:24px;padding:8px 8px 8px 2px;box-sizing:border-box}#status{width:8px;height:8px;border-radius:50%}:host([status]:not([status=""])) #status-wrapper{display:block}:host([status=success]) #status{background-color:var(--sinch-comp-accordion-color-default-status-success)}:host([status=warn]) #status{background-color:var(--sinch-comp-accordion-color-default-status-warning)}:host([status=error]) #status{background-color:var(--sinch-comp-accordion-color-default-status-error)}:host([status=info]) #status{background-color:var(--sinch-comp-accordion-color-default-status-info)}#title{flex:1;min-width:0;--sinch-comp-title-font:var(--sinch-comp-accordion-font-title);--sinch-global-color-text:var(--sinch-comp-accordion-color-default-title-initial)}#button:disabled>#title{--sinch-global-color-text:var(--sinch-comp-accordion-color-disabled-title-initial)}#content{display:none;overflow-y:auto;flex-shrink:1;min-height:0;padding:0 8px 12px}#dropdown-icon{flex-shrink:0;margin-top:2px;transform:rotate(0);will-change:transform;transition:transform .25s ease-in-out}#button[aria-expanded=true]>#dropdown-icon{transform:rotate(180deg)}#button[aria-expanded=true]+#content{display:block}#optional{flex-shrink:0;--sinch-comp-text-font:var(--sinch-comp-accordion-font-optional-text);--sinch-global-color-text:var(--sinch-comp-accordion-color-default-optional-text-initial)}#button:disabled>#optional{--sinch-global-color-text:var(--sinch-comp-accordion-color-disabled-optional-text-initial)}</style><div id="wrapper"><button id="button" aria-controls="content" aria-expanded="false"><div id="status-wrapper"><div id="status"></div></div><slot name="icon"></slot><sinch-title id="title" level="3" type="m" ellipsis></sinch-title><sinch-text id="optional" type="m"></sinch-text><sinch-icon icons-version="2" name="fa-chevron-down" id="dropdown-icon"></sinch-icon></button><div id="content" role="region" aria-labelledby="button"><slot name="content"></slot></div></div>';
@@ -51,7 +51,7 @@ class AccordionItem extends NectaryElement {
51
51
  break;
52
52
  }
53
53
  case "ellipsis": {
54
- updateAttribute(this.#$title, "ellipsis", newVal);
54
+ updateBooleanAttribute(this.#$title, name, isAttrTrue(newVal));
55
55
  break;
56
56
  }
57
57
  }
package/bundle.js CHANGED
@@ -452,7 +452,7 @@ class AccordionItem extends NectaryElement {
452
452
  break;
453
453
  }
454
454
  case "ellipsis": {
455
- updateAttribute(this.#$title, "ellipsis", newVal);
455
+ updateBooleanAttribute(this.#$title, name, isAttrTrue(newVal));
456
456
  break;
457
457
  }
458
458
  }
@@ -3466,6 +3466,9 @@ class Pop extends NectaryElement {
3466
3466
  get shouldCloseOnBackdropClick() {
3467
3467
  return !getBooleanAttribute(this, "disable-backdrop-close");
3468
3468
  }
3469
+ get shouldRestoreFocusOnClose() {
3470
+ return !getBooleanAttribute(this, "disable-focus-restore");
3471
+ }
3469
3472
  attributeChangedCallback(name, oldVal, newVal) {
3470
3473
  if (isAttrEqual(oldVal, newVal)) {
3471
3474
  return;
@@ -3651,7 +3654,7 @@ class Pop extends NectaryElement {
3651
3654
  if (isNonModal && !effectiveAllowScroll) {
3652
3655
  this.#restoreTransferredTarget();
3653
3656
  }
3654
- if (this.#targetActiveElement !== null) {
3657
+ if (this.shouldRestoreFocusOnClose && this.#targetActiveElement !== null) {
3655
3658
  if (!isElementFocused(this.#targetActiveElement)) {
3656
3659
  this.#$targetSlot.addEventListener("focus", this.#stopEventPropagation, true);
3657
3660
  this.#targetActiveElement.focus({ preventScroll: true });
@@ -3666,9 +3669,9 @@ class Pop extends NectaryElement {
3666
3669
  }
3667
3670
  });
3668
3671
  }
3669
- this.#targetActiveElement = null;
3670
3672
  }
3671
3673
  }
3674
+ this.#targetActiveElement = null;
3672
3675
  if (!effectiveAllowScroll) {
3673
3676
  enableOverscroll();
3674
3677
  } else {
@@ -3827,6 +3830,9 @@ class TooltipState {
3827
3830
  #timerId = null;
3828
3831
  #state = "hide";
3829
3832
  #options;
3833
+ get isInHideState() {
3834
+ return this.#state === "hide";
3835
+ }
3830
3836
  constructor(options) {
3831
3837
  this.#options = options;
3832
3838
  }
@@ -3983,10 +3989,15 @@ const SHOW_DELAY_SLOW = 1e3;
3983
3989
  const SHOW_DELAY_FAST = 250;
3984
3990
  const HIDE_DELAY = 0;
3985
3991
  const ANIMATION_DURATION = 100;
3992
+ const FOCUSABLE_OUTSIDE_TARGET_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable=""], [contenteditable="true"]';
3986
3993
  const OVERLAP_TOLERANCE = 1;
3987
3994
  const MAX_ZERO_DIMENSION_PLACEMENT_RETRIES = 8;
3988
3995
  const MIN_FIRST_REVEAL_STABILITY_FRAMES = 3;
3989
3996
  const MAX_FIRST_REVEAL_STABILITY_FRAMES = 6;
3997
+ let activeTooltip = null;
3998
+ let focusedTooltip = null;
3999
+ let hoveredTooltip = null;
4000
+ let suspendedFocusedTooltip = null;
3990
4001
  const template$T = document.createElement("template");
3991
4002
  template$T.innerHTML = templateHTML$T;
3992
4003
  class Tooltip extends NectaryElement {
@@ -3996,10 +4007,15 @@ class Tooltip extends NectaryElement {
3996
4007
  #$contentWrapper;
3997
4008
  #$tip;
3998
4009
  #$target;
4010
+ #targetElements = [];
3999
4011
  #resizeObserver = null;
4000
4012
  #tooltipState;
4001
4013
  #animation = null;
4002
4014
  #shouldReduceMotion = false;
4015
+ #hasFocus = false;
4016
+ #suppressFocusIn = false;
4017
+ #suppressedFocusOutArmed = false;
4018
+ #isSuspendedByHover = false;
4003
4019
  #isSubscribed = false;
4004
4020
  #controller;
4005
4021
  #placementScheduled = false;
@@ -4036,6 +4052,7 @@ class Tooltip extends NectaryElement {
4036
4052
  };
4037
4053
  this.setAttribute("role", "tooltip");
4038
4054
  this.#$pop.addEventListener("-close", this.#onPopClose, options);
4055
+ this.#$target.addEventListener("slotchange", this.#onTargetSlotChange, options);
4039
4056
  this.addEventListener("-show", this.#onShowReactHandler, options);
4040
4057
  this.addEventListener("-hide", this.#onHideReactHandler, options);
4041
4058
  this.#resizeObserver = new ResizeObserver(() => {
@@ -4044,10 +4061,23 @@ class Tooltip extends NectaryElement {
4044
4061
  this.#resizeObserver.observe(this.#$content);
4045
4062
  updateAttribute(this.#$pop, "orientation", getPopOrientation$1(this.orientation));
4046
4063
  updateBooleanAttribute(this.#$pop, "hide-outside-viewport", !this.showOutsideViewport);
4064
+ updateBooleanAttribute(this.#$pop, "disable-focus-restore", true);
4047
4065
  this.#updateText();
4048
4066
  }
4049
4067
  disconnectedCallback() {
4050
4068
  super.disconnectedCallback();
4069
+ if (activeTooltip === this) {
4070
+ activeTooltip = null;
4071
+ }
4072
+ if (focusedTooltip === this) {
4073
+ focusedTooltip = null;
4074
+ }
4075
+ if (hoveredTooltip === this) {
4076
+ hoveredTooltip = null;
4077
+ }
4078
+ if (suspendedFocusedTooltip === this) {
4079
+ suspendedFocusedTooltip = null;
4080
+ }
4051
4081
  this.#tooltipState.destroy();
4052
4082
  this.#controller.abort();
4053
4083
  this.#controller = null;
@@ -4165,14 +4195,217 @@ class Tooltip extends NectaryElement {
4165
4195
  this.#tooltipState.destroy();
4166
4196
  };
4167
4197
  #onMouseEnter = () => {
4198
+ this.#claimHoverOwnership();
4199
+ this.#suspendFocusedTooltip();
4200
+ this.#closeActiveTooltip();
4201
+ this.#tooltipState.show();
4202
+ };
4203
+ #onContentMouseEnter = () => {
4204
+ this.#claimHoverOwnership();
4205
+ if (this.#hasFocus) {
4206
+ return;
4207
+ }
4208
+ this.#suspendFocusedTooltip();
4209
+ this.#closeActiveTooltip();
4210
+ this.#tooltipState.show();
4211
+ };
4212
+ #onFocusIn = () => {
4213
+ if (this.#suppressFocusIn) {
4214
+ return;
4215
+ }
4216
+ this.#closeFocusedTooltip();
4217
+ this.#closeActiveTooltip();
4218
+ this.#hasFocus = true;
4219
+ focusedTooltip = this;
4168
4220
  this.#tooltipState.show();
4169
4221
  };
4170
- #onMouseLeave = (e) => {
4171
- if (!this.#isOpen() || e.relatedTarget !== this.#$contentWrapper && e.relatedTarget !== this.#$target) {
4222
+ #closeFocusedTooltip() {
4223
+ if (!this.#isOtherUncontrolledTooltip(focusedTooltip)) {
4224
+ return;
4225
+ }
4226
+ const previousFocusedTooltip = focusedTooltip;
4227
+ previousFocusedTooltip.#clearTrackedFocus();
4228
+ previousFocusedTooltip.#tooltipState.destroy();
4229
+ }
4230
+ #closeActiveTooltip() {
4231
+ if (this.#isOtherUncontrolledTooltip(activeTooltip) && !activeTooltip.#hasFocus) {
4232
+ activeTooltip.#tooltipState.destroy();
4233
+ }
4234
+ }
4235
+ #suspendFocusedTooltip() {
4236
+ if (this.#isOtherUncontrolledTooltip(focusedTooltip)) {
4237
+ suspendedFocusedTooltip = focusedTooltip;
4238
+ focusedTooltip.#suppressFocusIn = true;
4239
+ focusedTooltip.#isSuspendedByHover = true;
4240
+ focusedTooltip.#armSuppressedFocusOut();
4241
+ if (!focusedTooltip.#tooltipState.isInHideState) {
4242
+ focusedTooltip.#tooltipState.destroy();
4243
+ }
4244
+ }
4245
+ }
4246
+ #resumeFocusedTooltip() {
4247
+ const tooltipToResume = suspendedFocusedTooltip;
4248
+ if (tooltipToResume === null || tooltipToResume === this) {
4249
+ return;
4250
+ }
4251
+ if (hoveredTooltip !== null) {
4252
+ if (hoveredTooltip === this && !this.#isPointerWithinHoverSurface()) {
4253
+ this.#releaseHoverOwnership();
4254
+ } else {
4255
+ return;
4256
+ }
4257
+ }
4258
+ if (!tooltipToResume.#isSuspendedByHover) {
4259
+ if (suspendedFocusedTooltip === tooltipToResume) {
4260
+ suspendedFocusedTooltip = null;
4261
+ }
4262
+ return;
4263
+ }
4264
+ const activeEl = document.activeElement;
4265
+ if (activeEl instanceof HTMLElement && tooltipToResume.#isFocusableOutsideTarget(activeEl)) {
4266
+ tooltipToResume.#clearTrackedFocus();
4267
+ return;
4268
+ }
4269
+ tooltipToResume.#clearFocusSuppression();
4270
+ tooltipToResume.#hasFocus = true;
4271
+ focusedTooltip = tooltipToResume;
4272
+ suspendedFocusedTooltip = null;
4273
+ tooltipToResume.#tooltipState.show();
4274
+ }
4275
+ #isOtherUncontrolledTooltip(tooltip) {
4276
+ return tooltip !== null && tooltip !== this && tooltip.isOpenedControlled !== true;
4277
+ }
4278
+ #claimHoverOwnership() {
4279
+ hoveredTooltip = this;
4280
+ }
4281
+ #releaseHoverOwnership() {
4282
+ if (hoveredTooltip === this) {
4283
+ hoveredTooltip = null;
4284
+ }
4285
+ }
4286
+ #isPointerWithinHoverSurface() {
4287
+ return this.#$contentWrapper.matches(":hover") || this.#$target.assignedElements().some((el) => el.matches(":hover"));
4288
+ }
4289
+ #finalizeFocusOut() {
4290
+ if (!this.isDomConnected) {
4291
+ return;
4292
+ }
4293
+ const activeEl = document.activeElement;
4294
+ if (activeEl instanceof Node) {
4295
+ if (this.#targetContains(activeEl) || this.shadowRoot?.contains(activeEl) === true) {
4296
+ return;
4297
+ }
4298
+ }
4299
+ this.#clearTrackedFocus();
4300
+ this.#tooltipState.hide();
4301
+ }
4302
+ #onFocusOut = (e) => {
4303
+ if (this.#isSuspendedByHover) {
4304
+ return;
4305
+ }
4306
+ if (e.relatedTarget instanceof Node) {
4307
+ if (this.#targetContains(e.relatedTarget) || this.shadowRoot?.contains(e.relatedTarget) === true) {
4308
+ return;
4309
+ }
4310
+ }
4311
+ requestAnimationFrame(() => {
4312
+ this.#finalizeFocusOut();
4313
+ });
4314
+ };
4315
+ #onMouseLeave = () => {
4316
+ requestAnimationFrame(() => {
4317
+ if (!this.isDomConnected) {
4318
+ return;
4319
+ }
4320
+ if (this.#isPointerWithinHoverSurface()) {
4321
+ this.#claimHoverOwnership();
4322
+ return;
4323
+ }
4324
+ this.#releaseHoverOwnership();
4325
+ if (this.#hasFocus) {
4326
+ return;
4327
+ }
4172
4328
  this.#tooltipState.hide();
4329
+ });
4330
+ };
4331
+ #targetContains(node) {
4332
+ return this.#$target.assignedElements().some((el) => el.contains(node));
4333
+ }
4334
+ #getTargetElements() {
4335
+ return this.#$target.assignedElements().filter((el) => el instanceof HTMLElement);
4336
+ }
4337
+ #isFocusableOutsideTarget(el) {
4338
+ if (this.#targetContains(el) || this.shadowRoot?.contains(el) === true || this.#isFocusInFloatingLayer(el)) {
4339
+ return false;
4340
+ }
4341
+ return el.matches(FOCUSABLE_OUTSIDE_TARGET_SELECTOR);
4342
+ }
4343
+ #isFocusInFloatingLayer(el) {
4344
+ return el.tagName === "SINCH-POP" || el.tagName === "SINCH-TOOLTIP";
4345
+ }
4346
+ #clearFocusSuppression() {
4347
+ this.#suppressFocusIn = false;
4348
+ this.#isSuspendedByHover = false;
4349
+ if (this.#suppressedFocusOutArmed) {
4350
+ this.#$target.removeEventListener("focusout", this.#onSuppressedFocusOut);
4351
+ this.#suppressedFocusOutArmed = false;
4352
+ }
4353
+ }
4354
+ #clearTrackedFocus() {
4355
+ this.#hasFocus = false;
4356
+ if (focusedTooltip === this) {
4357
+ focusedTooltip = null;
4358
+ }
4359
+ if (suspendedFocusedTooltip === this) {
4360
+ suspendedFocusedTooltip = null;
4361
+ }
4362
+ this.#clearFocusSuppression();
4363
+ }
4364
+ #armSuppressedFocusOut() {
4365
+ if (this.#suppressedFocusOutArmed || this.#controller === null) {
4366
+ return;
4173
4367
  }
4368
+ this.#suppressedFocusOutArmed = true;
4369
+ const options = { signal: this.#controller.signal };
4370
+ this.#$target.addEventListener("focusout", this.#onSuppressedFocusOut, options);
4371
+ }
4372
+ #onSuppressedFocusOut = (e) => {
4373
+ if (e.relatedTarget instanceof Node) {
4374
+ if (this.#targetContains(e.relatedTarget) || this.shadowRoot?.contains(e.relatedTarget) === true) {
4375
+ return;
4376
+ }
4377
+ }
4378
+ requestAnimationFrame(() => {
4379
+ if (!this.isDomConnected) {
4380
+ return;
4381
+ }
4382
+ if (this.#targetContainsFocus()) {
4383
+ return;
4384
+ }
4385
+ if (this.#isSuspendedByHover) {
4386
+ if (e.relatedTarget instanceof HTMLElement && this.#isFocusInFloatingLayer(e.relatedTarget)) {
4387
+ return;
4388
+ }
4389
+ const activeEl2 = document.activeElement;
4390
+ if (activeEl2 instanceof HTMLElement && this.#isFocusInFloatingLayer(activeEl2)) {
4391
+ return;
4392
+ }
4393
+ if (activeEl2 instanceof HTMLElement && this.#isFocusableOutsideTarget(activeEl2)) {
4394
+ this.#clearTrackedFocus();
4395
+ }
4396
+ return;
4397
+ }
4398
+ const activeEl = document.activeElement;
4399
+ if (activeEl instanceof HTMLElement && this.#isFocusInFloatingLayer(activeEl)) {
4400
+ return;
4401
+ }
4402
+ this.#clearTrackedFocus();
4403
+ });
4174
4404
  };
4175
4405
  #onScroll = () => {
4406
+ if (this.#hasFocus) {
4407
+ return;
4408
+ }
4176
4409
  this.#tooltipState.destroy();
4177
4410
  };
4178
4411
  // Tooltip begins to wait for SHOW_DELAY on mouseenter
@@ -4185,6 +4418,7 @@ class Tooltip extends NectaryElement {
4185
4418
  // SHOW_DELAY ended, tooltip can be shown with animation
4186
4419
  #onStateShowEnd = () => {
4187
4420
  const revealRequestId = ++this.#revealRequestId;
4421
+ activeTooltip = this;
4188
4422
  this.#dispatchShowEvent();
4189
4423
  updateBooleanAttribute(this.#$pop, "open", true);
4190
4424
  this.#schedulePlacement();
@@ -4258,12 +4492,30 @@ class Tooltip extends NectaryElement {
4258
4492
  this.#dispatchHideEvent();
4259
4493
  updateBooleanAttribute(this.#$pop, "open", false);
4260
4494
  }
4495
+ if (activeTooltip === this) {
4496
+ activeTooltip = null;
4497
+ }
4498
+ if (!this.#isSuspendedByHover) {
4499
+ if (!this.#targetContainsFocus()) {
4500
+ this.#clearTrackedFocus();
4501
+ }
4502
+ }
4261
4503
  this.#resetTipOrientation();
4262
4504
  this.#resetContentOffset();
4263
4505
  this.#zeroDimensionPlacementRetries = 0;
4264
4506
  this.#unsubscribeMouseLeaveEvents();
4265
4507
  this.#unsubscribeScroll();
4508
+ if (!this.#isPointerWithinHoverSurface()) {
4509
+ this.#releaseHoverOwnership();
4510
+ }
4511
+ if (!this.#hasFocus) {
4512
+ this.#resumeFocusedTooltip();
4513
+ }
4266
4514
  };
4515
+ #targetContainsFocus() {
4516
+ const activeEl = document.activeElement;
4517
+ return activeEl instanceof Node && this.#targetContains(activeEl);
4518
+ }
4267
4519
  #resetTipOrientation() {
4268
4520
  this.#$tip.style.top = "";
4269
4521
  this.#$tip.style.left = "";
@@ -4410,28 +4662,64 @@ class Tooltip extends NectaryElement {
4410
4662
  if (!this.isDomConnected || this.#isSubscribed) {
4411
4663
  return;
4412
4664
  }
4413
- this.#$target.addEventListener("mouseenter", this.#onMouseEnter, {
4414
- signal: this.#controller.signal
4665
+ const options = { signal: this.#controller.signal };
4666
+ this.#targetElements = this.#getTargetElements();
4667
+ if (this.#targetElements.length === 0) {
4668
+ requestAnimationFrame(() => {
4669
+ if (!this.isDomConnected || this.#isSubscribed || this.text.length === 0) {
4670
+ return;
4671
+ }
4672
+ this.#subscribeMouseEnterEvent();
4673
+ });
4674
+ return;
4675
+ }
4676
+ this.#targetElements.forEach((el) => {
4677
+ el.addEventListener("mouseenter", this.#onMouseEnter, options);
4415
4678
  });
4679
+ this.#$target.addEventListener("focusin", this.#onFocusIn, options);
4416
4680
  this.#isSubscribed = true;
4417
4681
  }
4418
4682
  #unsubscribeMouseEnterEvent() {
4419
- this.#$target.removeEventListener("mouseenter", this.#onMouseEnter);
4683
+ this.#targetElements.forEach((el) => {
4684
+ el.removeEventListener("mouseenter", this.#onMouseEnter);
4685
+ });
4686
+ this.#$target.removeEventListener("focusin", this.#onFocusIn);
4687
+ this.#targetElements = [];
4420
4688
  this.#isSubscribed = false;
4421
4689
  }
4422
4690
  #subscribeMouseLeaveEvents() {
4423
4691
  const options = { signal: this.#controller.signal };
4424
- this.#$target.addEventListener("mousedown", this.#onMouseDown, options);
4425
- this.#$target.addEventListener("mouseleave", this.#onMouseLeave, options);
4426
- this.#$contentWrapper.addEventListener("mouseenter", this.#onMouseEnter, options);
4692
+ this.#targetElements.forEach((el) => {
4693
+ el.addEventListener("mousedown", this.#onMouseDown, options);
4694
+ el.addEventListener("mouseleave", this.#onMouseLeave, options);
4695
+ });
4696
+ this.#$target.addEventListener("focusout", this.#onFocusOut, options);
4697
+ this.#$contentWrapper.addEventListener("mouseenter", this.#onContentMouseEnter, options);
4427
4698
  this.#$contentWrapper.addEventListener("mouseleave", this.#onMouseLeave, options);
4428
4699
  }
4429
4700
  #unsubscribeMouseLeaveEvents() {
4430
- this.#$target.removeEventListener("mousedown", this.#onMouseDown);
4431
- this.#$target.removeEventListener("mouseleave", this.#onMouseLeave);
4432
- this.#$contentWrapper.removeEventListener("mouseenter", this.#onMouseEnter);
4701
+ this.#targetElements.forEach((el) => {
4702
+ el.removeEventListener("mousedown", this.#onMouseDown);
4703
+ el.removeEventListener("mouseleave", this.#onMouseLeave);
4704
+ });
4705
+ this.#$target.removeEventListener("focusout", this.#onFocusOut);
4706
+ this.#$contentWrapper.removeEventListener("mouseenter", this.#onContentMouseEnter);
4433
4707
  this.#$contentWrapper.removeEventListener("mouseleave", this.#onMouseLeave);
4434
4708
  }
4709
+ #onTargetSlotChange = () => {
4710
+ if (!this.isDomConnected || this.text.length === 0) {
4711
+ return;
4712
+ }
4713
+ const isOpen = this.#isOpen();
4714
+ if (this.#isSubscribed) {
4715
+ this.#unsubscribeMouseLeaveEvents();
4716
+ this.#unsubscribeMouseEnterEvent();
4717
+ }
4718
+ this.#subscribeMouseEnterEvent();
4719
+ if (isOpen) {
4720
+ this.#subscribeMouseLeaveEvents();
4721
+ }
4722
+ };
4435
4723
  #subscribeScroll() {
4436
4724
  window.addEventListener("wheel", this.#onScroll, true);
4437
4725
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectary/components",
3
- "version": "5.31.0",
3
+ "version": "5.31.2",
4
4
  "files": [
5
5
  "**/*/*.css",
6
6
  "**/*/*.json",
package/pop/index.d.ts CHANGED
@@ -27,5 +27,6 @@ export declare class Pop extends NectaryElement {
27
27
  get footprintRect(): TRect;
28
28
  get popoverRect(): TRect;
29
29
  get shouldCloseOnBackdropClick(): boolean;
30
+ get shouldRestoreFocusOnClose(): boolean;
30
31
  attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null): void;
31
32
  }
package/pop/index.js CHANGED
@@ -128,6 +128,9 @@ class Pop extends NectaryElement {
128
128
  get shouldCloseOnBackdropClick() {
129
129
  return !getBooleanAttribute(this, "disable-backdrop-close");
130
130
  }
131
+ get shouldRestoreFocusOnClose() {
132
+ return !getBooleanAttribute(this, "disable-focus-restore");
133
+ }
131
134
  attributeChangedCallback(name, oldVal, newVal) {
132
135
  if (isAttrEqual(oldVal, newVal)) {
133
136
  return;
@@ -313,7 +316,7 @@ class Pop extends NectaryElement {
313
316
  if (isNonModal && !effectiveAllowScroll) {
314
317
  this.#restoreTransferredTarget();
315
318
  }
316
- if (this.#targetActiveElement !== null) {
319
+ if (this.shouldRestoreFocusOnClose && this.#targetActiveElement !== null) {
317
320
  if (!isElementFocused(this.#targetActiveElement)) {
318
321
  this.#$targetSlot.addEventListener("focus", this.#stopEventPropagation, true);
319
322
  this.#targetActiveElement.focus({ preventScroll: true });
@@ -328,9 +331,9 @@ class Pop extends NectaryElement {
328
331
  }
329
332
  });
330
333
  }
331
- this.#targetActiveElement = null;
332
334
  }
333
335
  }
336
+ this.#targetActiveElement = null;
334
337
  if (!effectiveAllowScroll) {
335
338
  enableOverscroll();
336
339
  } else {
package/pop/types.d.ts CHANGED
@@ -3,6 +3,8 @@ export type TSinchPopOrientation = 'top-left' | 'top-right' | 'bottom-left' | 'b
3
3
  export type TSinchPopProps = {
4
4
  /** Allow scrolling of the page when pop is open */
5
5
  'allow-scroll'?: boolean;
6
+ /** Skip restoring the previously focused target when the pop closes */
7
+ 'disable-focus-restore'?: boolean;
6
8
  /** Open/close state */
7
9
  open: boolean;
8
10
  /** Orientation, where it *points to* from origin */
package/tooltip/index.js CHANGED
@@ -13,10 +13,15 @@ const SHOW_DELAY_SLOW = 1e3;
13
13
  const SHOW_DELAY_FAST = 250;
14
14
  const HIDE_DELAY = 0;
15
15
  const ANIMATION_DURATION = 100;
16
+ const FOCUSABLE_OUTSIDE_TARGET_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable=""], [contenteditable="true"]';
16
17
  const OVERLAP_TOLERANCE = 1;
17
18
  const MAX_ZERO_DIMENSION_PLACEMENT_RETRIES = 8;
18
19
  const MIN_FIRST_REVEAL_STABILITY_FRAMES = 3;
19
20
  const MAX_FIRST_REVEAL_STABILITY_FRAMES = 6;
21
+ let activeTooltip = null;
22
+ let focusedTooltip = null;
23
+ let hoveredTooltip = null;
24
+ let suspendedFocusedTooltip = null;
20
25
  const template = document.createElement("template");
21
26
  template.innerHTML = templateHTML;
22
27
  class Tooltip extends NectaryElement {
@@ -26,10 +31,15 @@ class Tooltip extends NectaryElement {
26
31
  #$contentWrapper;
27
32
  #$tip;
28
33
  #$target;
34
+ #targetElements = [];
29
35
  #resizeObserver = null;
30
36
  #tooltipState;
31
37
  #animation = null;
32
38
  #shouldReduceMotion = false;
39
+ #hasFocus = false;
40
+ #suppressFocusIn = false;
41
+ #suppressedFocusOutArmed = false;
42
+ #isSuspendedByHover = false;
33
43
  #isSubscribed = false;
34
44
  #controller;
35
45
  #placementScheduled = false;
@@ -66,6 +76,7 @@ class Tooltip extends NectaryElement {
66
76
  };
67
77
  this.setAttribute("role", "tooltip");
68
78
  this.#$pop.addEventListener("-close", this.#onPopClose, options);
79
+ this.#$target.addEventListener("slotchange", this.#onTargetSlotChange, options);
69
80
  this.addEventListener("-show", this.#onShowReactHandler, options);
70
81
  this.addEventListener("-hide", this.#onHideReactHandler, options);
71
82
  this.#resizeObserver = new ResizeObserver(() => {
@@ -74,10 +85,23 @@ class Tooltip extends NectaryElement {
74
85
  this.#resizeObserver.observe(this.#$content);
75
86
  updateAttribute(this.#$pop, "orientation", getPopOrientation(this.orientation));
76
87
  updateBooleanAttribute(this.#$pop, "hide-outside-viewport", !this.showOutsideViewport);
88
+ updateBooleanAttribute(this.#$pop, "disable-focus-restore", true);
77
89
  this.#updateText();
78
90
  }
79
91
  disconnectedCallback() {
80
92
  super.disconnectedCallback();
93
+ if (activeTooltip === this) {
94
+ activeTooltip = null;
95
+ }
96
+ if (focusedTooltip === this) {
97
+ focusedTooltip = null;
98
+ }
99
+ if (hoveredTooltip === this) {
100
+ hoveredTooltip = null;
101
+ }
102
+ if (suspendedFocusedTooltip === this) {
103
+ suspendedFocusedTooltip = null;
104
+ }
81
105
  this.#tooltipState.destroy();
82
106
  this.#controller.abort();
83
107
  this.#controller = null;
@@ -195,14 +219,217 @@ class Tooltip extends NectaryElement {
195
219
  this.#tooltipState.destroy();
196
220
  };
197
221
  #onMouseEnter = () => {
222
+ this.#claimHoverOwnership();
223
+ this.#suspendFocusedTooltip();
224
+ this.#closeActiveTooltip();
225
+ this.#tooltipState.show();
226
+ };
227
+ #onContentMouseEnter = () => {
228
+ this.#claimHoverOwnership();
229
+ if (this.#hasFocus) {
230
+ return;
231
+ }
232
+ this.#suspendFocusedTooltip();
233
+ this.#closeActiveTooltip();
198
234
  this.#tooltipState.show();
199
235
  };
200
- #onMouseLeave = (e) => {
201
- if (!this.#isOpen() || e.relatedTarget !== this.#$contentWrapper && e.relatedTarget !== this.#$target) {
236
+ #onFocusIn = () => {
237
+ if (this.#suppressFocusIn) {
238
+ return;
239
+ }
240
+ this.#closeFocusedTooltip();
241
+ this.#closeActiveTooltip();
242
+ this.#hasFocus = true;
243
+ focusedTooltip = this;
244
+ this.#tooltipState.show();
245
+ };
246
+ #closeFocusedTooltip() {
247
+ if (!this.#isOtherUncontrolledTooltip(focusedTooltip)) {
248
+ return;
249
+ }
250
+ const previousFocusedTooltip = focusedTooltip;
251
+ previousFocusedTooltip.#clearTrackedFocus();
252
+ previousFocusedTooltip.#tooltipState.destroy();
253
+ }
254
+ #closeActiveTooltip() {
255
+ if (this.#isOtherUncontrolledTooltip(activeTooltip) && !activeTooltip.#hasFocus) {
256
+ activeTooltip.#tooltipState.destroy();
257
+ }
258
+ }
259
+ #suspendFocusedTooltip() {
260
+ if (this.#isOtherUncontrolledTooltip(focusedTooltip)) {
261
+ suspendedFocusedTooltip = focusedTooltip;
262
+ focusedTooltip.#suppressFocusIn = true;
263
+ focusedTooltip.#isSuspendedByHover = true;
264
+ focusedTooltip.#armSuppressedFocusOut();
265
+ if (!focusedTooltip.#tooltipState.isInHideState) {
266
+ focusedTooltip.#tooltipState.destroy();
267
+ }
268
+ }
269
+ }
270
+ #resumeFocusedTooltip() {
271
+ const tooltipToResume = suspendedFocusedTooltip;
272
+ if (tooltipToResume === null || tooltipToResume === this) {
273
+ return;
274
+ }
275
+ if (hoveredTooltip !== null) {
276
+ if (hoveredTooltip === this && !this.#isPointerWithinHoverSurface()) {
277
+ this.#releaseHoverOwnership();
278
+ } else {
279
+ return;
280
+ }
281
+ }
282
+ if (!tooltipToResume.#isSuspendedByHover) {
283
+ if (suspendedFocusedTooltip === tooltipToResume) {
284
+ suspendedFocusedTooltip = null;
285
+ }
286
+ return;
287
+ }
288
+ const activeEl = document.activeElement;
289
+ if (activeEl instanceof HTMLElement && tooltipToResume.#isFocusableOutsideTarget(activeEl)) {
290
+ tooltipToResume.#clearTrackedFocus();
291
+ return;
292
+ }
293
+ tooltipToResume.#clearFocusSuppression();
294
+ tooltipToResume.#hasFocus = true;
295
+ focusedTooltip = tooltipToResume;
296
+ suspendedFocusedTooltip = null;
297
+ tooltipToResume.#tooltipState.show();
298
+ }
299
+ #isOtherUncontrolledTooltip(tooltip) {
300
+ return tooltip !== null && tooltip !== this && tooltip.isOpenedControlled !== true;
301
+ }
302
+ #claimHoverOwnership() {
303
+ hoveredTooltip = this;
304
+ }
305
+ #releaseHoverOwnership() {
306
+ if (hoveredTooltip === this) {
307
+ hoveredTooltip = null;
308
+ }
309
+ }
310
+ #isPointerWithinHoverSurface() {
311
+ return this.#$contentWrapper.matches(":hover") || this.#$target.assignedElements().some((el) => el.matches(":hover"));
312
+ }
313
+ #finalizeFocusOut() {
314
+ if (!this.isDomConnected) {
315
+ return;
316
+ }
317
+ const activeEl = document.activeElement;
318
+ if (activeEl instanceof Node) {
319
+ if (this.#targetContains(activeEl) || this.shadowRoot?.contains(activeEl) === true) {
320
+ return;
321
+ }
322
+ }
323
+ this.#clearTrackedFocus();
324
+ this.#tooltipState.hide();
325
+ }
326
+ #onFocusOut = (e) => {
327
+ if (this.#isSuspendedByHover) {
328
+ return;
329
+ }
330
+ if (e.relatedTarget instanceof Node) {
331
+ if (this.#targetContains(e.relatedTarget) || this.shadowRoot?.contains(e.relatedTarget) === true) {
332
+ return;
333
+ }
334
+ }
335
+ requestAnimationFrame(() => {
336
+ this.#finalizeFocusOut();
337
+ });
338
+ };
339
+ #onMouseLeave = () => {
340
+ requestAnimationFrame(() => {
341
+ if (!this.isDomConnected) {
342
+ return;
343
+ }
344
+ if (this.#isPointerWithinHoverSurface()) {
345
+ this.#claimHoverOwnership();
346
+ return;
347
+ }
348
+ this.#releaseHoverOwnership();
349
+ if (this.#hasFocus) {
350
+ return;
351
+ }
202
352
  this.#tooltipState.hide();
353
+ });
354
+ };
355
+ #targetContains(node) {
356
+ return this.#$target.assignedElements().some((el) => el.contains(node));
357
+ }
358
+ #getTargetElements() {
359
+ return this.#$target.assignedElements().filter((el) => el instanceof HTMLElement);
360
+ }
361
+ #isFocusableOutsideTarget(el) {
362
+ if (this.#targetContains(el) || this.shadowRoot?.contains(el) === true || this.#isFocusInFloatingLayer(el)) {
363
+ return false;
364
+ }
365
+ return el.matches(FOCUSABLE_OUTSIDE_TARGET_SELECTOR);
366
+ }
367
+ #isFocusInFloatingLayer(el) {
368
+ return el.tagName === "SINCH-POP" || el.tagName === "SINCH-TOOLTIP";
369
+ }
370
+ #clearFocusSuppression() {
371
+ this.#suppressFocusIn = false;
372
+ this.#isSuspendedByHover = false;
373
+ if (this.#suppressedFocusOutArmed) {
374
+ this.#$target.removeEventListener("focusout", this.#onSuppressedFocusOut);
375
+ this.#suppressedFocusOutArmed = false;
376
+ }
377
+ }
378
+ #clearTrackedFocus() {
379
+ this.#hasFocus = false;
380
+ if (focusedTooltip === this) {
381
+ focusedTooltip = null;
382
+ }
383
+ if (suspendedFocusedTooltip === this) {
384
+ suspendedFocusedTooltip = null;
385
+ }
386
+ this.#clearFocusSuppression();
387
+ }
388
+ #armSuppressedFocusOut() {
389
+ if (this.#suppressedFocusOutArmed || this.#controller === null) {
390
+ return;
391
+ }
392
+ this.#suppressedFocusOutArmed = true;
393
+ const options = { signal: this.#controller.signal };
394
+ this.#$target.addEventListener("focusout", this.#onSuppressedFocusOut, options);
395
+ }
396
+ #onSuppressedFocusOut = (e) => {
397
+ if (e.relatedTarget instanceof Node) {
398
+ if (this.#targetContains(e.relatedTarget) || this.shadowRoot?.contains(e.relatedTarget) === true) {
399
+ return;
400
+ }
203
401
  }
402
+ requestAnimationFrame(() => {
403
+ if (!this.isDomConnected) {
404
+ return;
405
+ }
406
+ if (this.#targetContainsFocus()) {
407
+ return;
408
+ }
409
+ if (this.#isSuspendedByHover) {
410
+ if (e.relatedTarget instanceof HTMLElement && this.#isFocusInFloatingLayer(e.relatedTarget)) {
411
+ return;
412
+ }
413
+ const activeEl2 = document.activeElement;
414
+ if (activeEl2 instanceof HTMLElement && this.#isFocusInFloatingLayer(activeEl2)) {
415
+ return;
416
+ }
417
+ if (activeEl2 instanceof HTMLElement && this.#isFocusableOutsideTarget(activeEl2)) {
418
+ this.#clearTrackedFocus();
419
+ }
420
+ return;
421
+ }
422
+ const activeEl = document.activeElement;
423
+ if (activeEl instanceof HTMLElement && this.#isFocusInFloatingLayer(activeEl)) {
424
+ return;
425
+ }
426
+ this.#clearTrackedFocus();
427
+ });
204
428
  };
205
429
  #onScroll = () => {
430
+ if (this.#hasFocus) {
431
+ return;
432
+ }
206
433
  this.#tooltipState.destroy();
207
434
  };
208
435
  // Tooltip begins to wait for SHOW_DELAY on mouseenter
@@ -215,6 +442,7 @@ class Tooltip extends NectaryElement {
215
442
  // SHOW_DELAY ended, tooltip can be shown with animation
216
443
  #onStateShowEnd = () => {
217
444
  const revealRequestId = ++this.#revealRequestId;
445
+ activeTooltip = this;
218
446
  this.#dispatchShowEvent();
219
447
  updateBooleanAttribute(this.#$pop, "open", true);
220
448
  this.#schedulePlacement();
@@ -288,12 +516,30 @@ class Tooltip extends NectaryElement {
288
516
  this.#dispatchHideEvent();
289
517
  updateBooleanAttribute(this.#$pop, "open", false);
290
518
  }
519
+ if (activeTooltip === this) {
520
+ activeTooltip = null;
521
+ }
522
+ if (!this.#isSuspendedByHover) {
523
+ if (!this.#targetContainsFocus()) {
524
+ this.#clearTrackedFocus();
525
+ }
526
+ }
291
527
  this.#resetTipOrientation();
292
528
  this.#resetContentOffset();
293
529
  this.#zeroDimensionPlacementRetries = 0;
294
530
  this.#unsubscribeMouseLeaveEvents();
295
531
  this.#unsubscribeScroll();
532
+ if (!this.#isPointerWithinHoverSurface()) {
533
+ this.#releaseHoverOwnership();
534
+ }
535
+ if (!this.#hasFocus) {
536
+ this.#resumeFocusedTooltip();
537
+ }
296
538
  };
539
+ #targetContainsFocus() {
540
+ const activeEl = document.activeElement;
541
+ return activeEl instanceof Node && this.#targetContains(activeEl);
542
+ }
297
543
  #resetTipOrientation() {
298
544
  this.#$tip.style.top = "";
299
545
  this.#$tip.style.left = "";
@@ -440,28 +686,64 @@ class Tooltip extends NectaryElement {
440
686
  if (!this.isDomConnected || this.#isSubscribed) {
441
687
  return;
442
688
  }
443
- this.#$target.addEventListener("mouseenter", this.#onMouseEnter, {
444
- signal: this.#controller.signal
689
+ const options = { signal: this.#controller.signal };
690
+ this.#targetElements = this.#getTargetElements();
691
+ if (this.#targetElements.length === 0) {
692
+ requestAnimationFrame(() => {
693
+ if (!this.isDomConnected || this.#isSubscribed || this.text.length === 0) {
694
+ return;
695
+ }
696
+ this.#subscribeMouseEnterEvent();
697
+ });
698
+ return;
699
+ }
700
+ this.#targetElements.forEach((el) => {
701
+ el.addEventListener("mouseenter", this.#onMouseEnter, options);
445
702
  });
703
+ this.#$target.addEventListener("focusin", this.#onFocusIn, options);
446
704
  this.#isSubscribed = true;
447
705
  }
448
706
  #unsubscribeMouseEnterEvent() {
449
- this.#$target.removeEventListener("mouseenter", this.#onMouseEnter);
707
+ this.#targetElements.forEach((el) => {
708
+ el.removeEventListener("mouseenter", this.#onMouseEnter);
709
+ });
710
+ this.#$target.removeEventListener("focusin", this.#onFocusIn);
711
+ this.#targetElements = [];
450
712
  this.#isSubscribed = false;
451
713
  }
452
714
  #subscribeMouseLeaveEvents() {
453
715
  const options = { signal: this.#controller.signal };
454
- this.#$target.addEventListener("mousedown", this.#onMouseDown, options);
455
- this.#$target.addEventListener("mouseleave", this.#onMouseLeave, options);
456
- this.#$contentWrapper.addEventListener("mouseenter", this.#onMouseEnter, options);
716
+ this.#targetElements.forEach((el) => {
717
+ el.addEventListener("mousedown", this.#onMouseDown, options);
718
+ el.addEventListener("mouseleave", this.#onMouseLeave, options);
719
+ });
720
+ this.#$target.addEventListener("focusout", this.#onFocusOut, options);
721
+ this.#$contentWrapper.addEventListener("mouseenter", this.#onContentMouseEnter, options);
457
722
  this.#$contentWrapper.addEventListener("mouseleave", this.#onMouseLeave, options);
458
723
  }
459
724
  #unsubscribeMouseLeaveEvents() {
460
- this.#$target.removeEventListener("mousedown", this.#onMouseDown);
461
- this.#$target.removeEventListener("mouseleave", this.#onMouseLeave);
462
- this.#$contentWrapper.removeEventListener("mouseenter", this.#onMouseEnter);
725
+ this.#targetElements.forEach((el) => {
726
+ el.removeEventListener("mousedown", this.#onMouseDown);
727
+ el.removeEventListener("mouseleave", this.#onMouseLeave);
728
+ });
729
+ this.#$target.removeEventListener("focusout", this.#onFocusOut);
730
+ this.#$contentWrapper.removeEventListener("mouseenter", this.#onContentMouseEnter);
463
731
  this.#$contentWrapper.removeEventListener("mouseleave", this.#onMouseLeave);
464
732
  }
733
+ #onTargetSlotChange = () => {
734
+ if (!this.isDomConnected || this.text.length === 0) {
735
+ return;
736
+ }
737
+ const isOpen = this.#isOpen();
738
+ if (this.#isSubscribed) {
739
+ this.#unsubscribeMouseLeaveEvents();
740
+ this.#unsubscribeMouseEnterEvent();
741
+ }
742
+ this.#subscribeMouseEnterEvent();
743
+ if (isOpen) {
744
+ this.#subscribeMouseLeaveEvents();
745
+ }
746
+ };
465
747
  #subscribeScroll() {
466
748
  window.addEventListener("wheel", this.#onScroll, true);
467
749
  }
@@ -10,6 +10,7 @@ type TTooltipStateOptions = {
10
10
  };
11
11
  export declare class TooltipState {
12
12
  #private;
13
+ get isInHideState(): boolean;
13
14
  constructor(options: TTooltipStateOptions);
14
15
  updateOptions(options: Partial<TTooltipStateOptions>): void;
15
16
  show(): void;
@@ -2,6 +2,9 @@ class TooltipState {
2
2
  #timerId = null;
3
3
  #state = "hide";
4
4
  #options;
5
+ get isInHideState() {
6
+ return this.#state === "hide";
7
+ }
5
8
  constructor(options) {
6
9
  this.#options = options;
7
10
  }