fokus-styles 2.3.0 → 2.5.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/css/components.css +583 -83
  3. package/dist/css/components.css.map +1 -1
  4. package/dist/css/components.min.css +1 -1
  5. package/dist/css/components.min.css.map +1 -1
  6. package/dist/css/fokus-components.css +583 -83
  7. package/dist/css/fokus-components.css.map +1 -1
  8. package/dist/css/fokus-components.min.css +1 -1
  9. package/dist/css/fokus-components.min.css.map +1 -1
  10. package/dist/css/fokus-core.css +22 -0
  11. package/dist/css/fokus-core.css.map +1 -1
  12. package/dist/css/fokus-core.min.css +1 -1
  13. package/dist/css/fokus-core.min.css.map +1 -1
  14. package/dist/css/fokus-dark.css +10 -0
  15. package/dist/css/fokus-dark.css.map +1 -1
  16. package/dist/css/fokus-dark.min.css +1 -1
  17. package/dist/css/fokus-dark.min.css.map +1 -1
  18. package/dist/css/fokus-rtl.css +9166 -0
  19. package/dist/css/fokus-rtl.css.map +1 -0
  20. package/dist/css/fokus-rtl.min.css +2 -0
  21. package/dist/css/fokus-rtl.min.css.map +1 -0
  22. package/dist/css/fokus-utilities.css +158 -0
  23. package/dist/css/fokus-utilities.css.map +1 -1
  24. package/dist/css/fokus-utilities.min.css +1 -1
  25. package/dist/css/fokus-utilities.min.css.map +1 -1
  26. package/dist/css/fokus.css +1018 -258
  27. package/dist/css/fokus.css.map +1 -1
  28. package/dist/css/fokus.min.css +1 -1
  29. package/dist/css/fokus.min.css.map +1 -1
  30. package/dist/css/forms.css +322 -176
  31. package/dist/css/forms.css.map +1 -1
  32. package/dist/css/forms.min.css +1 -1
  33. package/dist/css/forms.min.css.map +1 -1
  34. package/dist/css/helpers.css +158 -0
  35. package/dist/css/helpers.css.map +1 -1
  36. package/dist/css/helpers.min.css +1 -1
  37. package/dist/css/helpers.min.css.map +1 -1
  38. package/dist/css/layout.css +22 -0
  39. package/dist/css/layout.css.map +1 -1
  40. package/dist/css/layout.min.css +1 -1
  41. package/dist/css/layout.min.css.map +1 -1
  42. package/dist/js/fokus.js +270 -25
  43. package/dist/js/fokus.js.map +2 -2
  44. package/dist/js/fokus.min.js +18 -18
  45. package/dist/js/fokus.min.js.map +3 -3
  46. package/docs/components/alert.md +69 -24
  47. package/docs/components/badge.md +23 -4
  48. package/docs/components/checkbox.md +16 -3
  49. package/docs/components/popover.md +20 -7
  50. package/docs/components/radio.md +11 -1
  51. package/docs/components/responsive.md +19 -0
  52. package/docs/components/switch.md +30 -8
  53. package/docs/components/tag.md +19 -4
  54. package/docs/components/toast.md +72 -59
  55. package/docs/components/tooltip.md +38 -2
  56. package/docs/guides/accessibility.md +7 -0
  57. package/docs/guides/rtl-and-system-preferences.md +28 -0
  58. package/docs/prompts/prompt-plan.md +4 -4
  59. package/docs/reference/accessibility-matrix.md +7 -7
  60. package/docs/reference/contrast-report.md +4 -0
  61. package/docs/reference/definitions.md +5 -1
  62. package/docs/reference/design-tokens.md +14 -0
  63. package/docs/reference/size-baseline.json +13 -13
  64. package/mockup/assets/example-theme.css +11 -0
  65. package/mockup/assets/showcase-contracts.js +6 -6
  66. package/mockup/examples/alerts.html +105 -31
  67. package/mockup/examples/badges-alerts.html +91 -59
  68. package/mockup/examples/check-radio-switch.html +575 -99
  69. package/mockup/examples/popover.html +81 -0
  70. package/mockup/examples/tag.html +97 -87
  71. package/mockup/examples/toast.html +113 -0
  72. package/mockup/examples/tooltip.html +68 -0
  73. package/mockup/forms.html +1 -1
  74. package/mockup/overlays-commands.html +2 -2
  75. package/package.json +2 -1
  76. package/packages/fokus-components/scss/components/_alerts.scss +128 -6
  77. package/packages/fokus-components/scss/components/_badges.scss +26 -10
  78. package/packages/fokus-components/scss/components/_index.scss +1 -0
  79. package/packages/fokus-components/scss/components/_popover.scss +131 -21
  80. package/packages/fokus-components/scss/components/_responsive.scss +23 -0
  81. package/packages/fokus-components/scss/components/_tag.scss +37 -14
  82. package/packages/fokus-components/scss/components/_toasts.scss +156 -11
  83. package/packages/fokus-components/scss/components/_tooltips.scss +70 -18
  84. package/packages/fokus-components/scss/forms/_check-radio-switch.scss +313 -215
  85. package/packages/fokus-components/scss/forms/_forms.scss +16 -0
  86. package/packages/fokus-core/scss/base/_accessibility.scss +14 -0
  87. package/packages/fokus-core/scss/tokens/_root.scss +11 -0
  88. package/packages/fokus-js/js/carousel.d.ts +4 -0
  89. package/packages/fokus-js/js/carousel.js +8 -0
  90. package/packages/fokus-js/js/core/positioning.js +18 -9
  91. package/packages/fokus-js/js/dropdown.d.ts +4 -0
  92. package/packages/fokus-js/js/dropdown.js +7 -0
  93. package/packages/fokus-js/js/popover.js +26 -1
  94. package/packages/fokus-js/js/tabs.d.ts +3 -0
  95. package/packages/fokus-js/js/tabs.js +5 -0
  96. package/packages/fokus-js/js/tag.js +17 -0
  97. package/packages/fokus-js/js/toast.js +77 -3
  98. package/packages/fokus-js/js/tooltip.d.ts +8 -1
  99. package/packages/fokus-js/js/tooltip.js +106 -13
  100. package/packages/fokus-utilities/scss/utilities/_api.scss +48 -0
  101. package/scss/entries/rtl-entry.scss +11 -0
@@ -2,6 +2,22 @@
2
2
  @use "../../../fokus-core/scss/tools" as *;
3
3
 
4
4
  @layer components {
5
+ .fs-form-fieldset {
6
+ min-inline-size: 0;
7
+ margin: 0;
8
+ padding: var(--fs-space-3);
9
+ border: var(--fs-border-width) solid var(--fs-color-border);
10
+ border-radius: var(--fs-radius-md);
11
+ }
12
+
13
+ .fs-form-fieldset > legend {
14
+ max-inline-size: 100%;
15
+ padding-inline: var(--fs-space-1);
16
+ color: var(--fs-color-text);
17
+ font-size: $font-size-sm;
18
+ font-weight: $font-weight-semibold;
19
+ }
20
+
5
21
  .fs-form-row {
6
22
  display: flex;
7
23
  flex-direction: row;
@@ -10,6 +10,20 @@
10
10
  outline: 2px solid CanvasText;
11
11
  outline-offset: 2px;
12
12
  }
13
+
14
+ button,
15
+ [role="button"],
16
+ input,
17
+ select,
18
+ textarea,
19
+ .fs-btn {
20
+ border-color: ButtonText;
21
+ forced-color-adjust: auto;
22
+ }
23
+
24
+ .fs-u-focus-ring:focus-visible {
25
+ outline: 3px solid Highlight;
26
+ }
13
27
  }
14
28
  }
15
29
 
@@ -75,6 +75,8 @@
75
75
  --fs-component-overlay-backdrop: rgb(0 0 0 / 55%);
76
76
  --fs-component-panel-radius: var(--fs-radius-md);
77
77
  --fs-component-panel-shadow: var(--fs-shadow-lg);
78
+ --fs-contrast-border-width: var(--fs-border-width);
79
+ --fs-contrast-focus-width: var(--fs-focus-width);
78
80
 
79
81
  // Referência dos limiares usados pelos utilitários `@container` (ver
80
82
  // `packages/fokus-utilities/scss/utilities/_container-queries.scss`).
@@ -128,4 +130,13 @@
128
130
  }
129
131
  }
130
132
  }
133
+
134
+ @media (prefers-contrast: more) {
135
+ :root {
136
+ --fs-contrast-border-width: 2px;
137
+ --fs-contrast-focus-width: 3px;
138
+ --fs-focus-width: var(--fs-contrast-focus-width);
139
+ --fs-border-width: var(--fs-contrast-border-width);
140
+ }
141
+ }
131
142
  }
@@ -4,6 +4,10 @@ export class Carousel {
4
4
  constructor(carouselEl: HTMLElement);
5
5
 
6
6
  static getInstance(el: Element): Carousel | undefined;
7
+ static getOrCreateInstance(el: Element): Carousel;
8
+ static next(el: Element): void;
9
+ static prev(el: Element): void;
10
+ static goTo(el: Element, index: number): void;
7
11
 
8
12
  next(): void;
9
13
  prev(): void;
@@ -91,6 +91,11 @@ export class Carousel {
91
91
  return instances.get(el);
92
92
  }
93
93
 
94
+ static getOrCreateInstance(el) { return this.getInstance(el) ?? new Carousel(el); }
95
+ static next(el) { return this.getOrCreateInstance(el).next(); }
96
+ static prev(el) { return this.getOrCreateInstance(el).prev(); }
97
+ static goTo(el, index) { return this.getOrCreateInstance(el).goTo(index); }
98
+
94
99
  _isDocumentVisible() {
95
100
  return document.visibilityState !== "hidden";
96
101
  }
@@ -165,6 +170,9 @@ export class Carousel {
165
170
 
166
171
  this.index = to;
167
172
  this._render();
173
+ this.carouselEl.dispatchEvent(
174
+ new CustomEvent("fs:shown", { bubbles: true, detail: { from, to } }),
175
+ );
168
176
  this.carouselEl.dispatchEvent(
169
177
  new CustomEvent("fs:carousel:slid", { bubbles: true, detail: { from, to } }),
170
178
  );
@@ -32,21 +32,18 @@ function alignCrossAxis(align, referenceStart, referenceSize, floatingSize) {
32
32
  }
33
33
 
34
34
  export function computePosition(referenceEl, floatingEl, options = {}) {
35
- const { placement = "bottom", align = "center", offset = 8, padding = 8 } = options;
35
+ const { placement = "bottom", align = "center", offset = 8, padding = 8, fallbackPlacements } = options;
36
36
 
37
37
  const referenceRect = referenceEl.getBoundingClientRect();
38
38
  const floatingRect = floatingEl.getBoundingClientRect();
39
39
  const viewportWidth = document.documentElement.clientWidth;
40
40
  const viewportHeight = document.documentElement.clientHeight;
41
41
 
42
- let finalPlacement = placement;
43
-
44
- if (
45
- !fitsPlacement(placement, referenceRect, floatingRect, offset, viewportWidth, viewportHeight) &&
46
- fitsPlacement(OPPOSITE_PLACEMENT[placement], referenceRect, floatingRect, offset, viewportWidth, viewportHeight)
47
- ) {
48
- finalPlacement = OPPOSITE_PLACEMENT[placement];
49
- }
42
+ const candidates = [...new Set(fallbackPlacements ?? [placement, OPPOSITE_PLACEMENT[placement], "bottom", "top", "right", "left"])]
43
+ .filter(Boolean);
44
+ const primaryCandidates = candidates.slice(0, 2);
45
+ const finalPlacement = primaryCandidates.find((candidate) => fitsPlacement(candidate, referenceRect, floatingRect, offset, viewportWidth, viewportHeight))
46
+ ?? placement;
50
47
 
51
48
  let top;
52
49
  let left;
@@ -70,10 +67,18 @@ export function computePosition(referenceEl, floatingEl, options = {}) {
70
67
  left = Math.min(Math.max(left, padding), maxLeft);
71
68
  top = Math.min(Math.max(top, padding), maxTop);
72
69
 
70
+ const crossSize = finalPlacement === "top" || finalPlacement === "bottom" ? floatingRect.width : floatingRect.height;
71
+ const crossStart = finalPlacement === "top" || finalPlacement === "bottom" ? left : top;
72
+ const referenceCenter = finalPlacement === "top" || finalPlacement === "bottom"
73
+ ? referenceRect.left + referenceRect.width / 2
74
+ : referenceRect.top + referenceRect.height / 2;
75
+ const crossOffset = Math.min(Math.max(referenceCenter - crossStart, 8), Math.max(8, crossSize - 8));
76
+
73
77
  return {
74
78
  top: top + window.scrollY,
75
79
  left: left + window.scrollX,
76
80
  placement: finalPlacement,
81
+ arrowOffset: `${crossOffset}px`,
77
82
  };
78
83
  }
79
84
 
@@ -109,4 +114,8 @@ export function applyPosition(floatingEl, position) {
109
114
  floatingEl.style.position = "absolute";
110
115
  floatingEl.style.top = `${position.top}px`;
111
116
  floatingEl.style.left = `${position.left}px`;
117
+ if (position.arrowOffset) {
118
+ floatingEl.style.setProperty("--fs-popover-arrow-offset", position.arrowOffset);
119
+ floatingEl.style.setProperty("--fs-tooltip-arrow-offset", position.arrowOffset);
120
+ }
112
121
  }
@@ -13,6 +13,10 @@ export class Dropdown {
13
13
  constructor(toggleEl: HTMLElement, options?: DropdownOptions);
14
14
 
15
15
  static getInstance(el: Element): Dropdown | undefined;
16
+ static getOrCreateInstance(el: Element, options?: DropdownOptions): Dropdown;
17
+ static show(el: Element, options?: DropdownOptions): void;
18
+ static hide(el: Element): void;
19
+ static toggle(el: Element, options?: DropdownOptions): void;
16
20
 
17
21
  show(): void;
18
22
  hide(): void;
@@ -57,6 +57,11 @@ export class Dropdown {
57
57
  return instances.get(el);
58
58
  }
59
59
 
60
+ static getOrCreateInstance(el, options = {}) { return this.getInstance(el) ?? new Dropdown(el, options); }
61
+ static show(el, options = {}) { return this.getOrCreateInstance(el, options).show(); }
62
+ static hide(el) { return this.getInstance(el)?.hide(); }
63
+ static toggle(el, options = {}) { return this.getOrCreateInstance(el, options).toggle(); }
64
+
60
65
  _handleToggleClick(event) {
61
66
  event.preventDefault();
62
67
  this.toggle();
@@ -127,6 +132,7 @@ export class Dropdown {
127
132
  this.hide();
128
133
  });
129
134
 
135
+ this.toggleEl.dispatchEvent(new CustomEvent("fs:shown", { bubbles: true }));
130
136
  this.toggleEl.dispatchEvent(new CustomEvent("fs:dropdown:shown", { bubbles: true }));
131
137
  }
132
138
 
@@ -145,6 +151,7 @@ export class Dropdown {
145
151
  this._positionCleanup?.();
146
152
  this._positionCleanup = null;
147
153
 
154
+ this.toggleEl.dispatchEvent(new CustomEvent("fs:hidden", { bubbles: true }));
148
155
  this.toggleEl.dispatchEvent(new CustomEvent("fs:dropdown:hidden", { bubbles: true }));
149
156
  }
150
157
 
@@ -6,6 +6,7 @@ import { autoInit, createInstanceRegistry } from "./core/register.js";
6
6
  const instances = createInstanceRegistry();
7
7
  let idCounter = 0;
8
8
  const HOVER_HIDE_DELAY = 100;
9
+ const openPopovers = new Set();
9
10
 
10
11
  export class Popover {
11
12
  constructor(triggerEl, options = {}) {
@@ -25,6 +26,7 @@ export class Popover {
25
26
  this._outsideClickCleanup = null;
26
27
  this._removeEscapeListener = null;
27
28
  this._hideTimer = null;
29
+ this._suppressNextFocus = false;
28
30
  this._positionCleanup = null;
29
31
  this._originalParent = popoverEl.parentNode;
30
32
  this._originalNextSibling = popoverEl.nextSibling;
@@ -43,6 +45,12 @@ export class Popover {
43
45
  popoverEl.setAttribute("aria-labelledby", headerEl.id);
44
46
  }
45
47
 
48
+ const descriptionEl = popoverEl.querySelector(".fs-popover-text");
49
+ if (descriptionEl) {
50
+ if (!descriptionEl.id) descriptionEl.id = `fokus-popover-text-${idCounter}`;
51
+ popoverEl.setAttribute("aria-describedby", descriptionEl.id);
52
+ }
53
+
46
54
  triggerEl.setAttribute("aria-controls", popoverEl.id);
47
55
  triggerEl.setAttribute("aria-expanded", "false");
48
56
 
@@ -80,7 +88,13 @@ export class Popover {
80
88
  this.popoverEl.addEventListener("mouseenter", this._handleMouseEnter);
81
89
  this.popoverEl.addEventListener("mouseleave", this._handleMouseLeave);
82
90
  } else if (this.trigger === "focus") {
83
- this._handleTriggerFocus = () => this.show();
91
+ this._handleTriggerFocus = () => {
92
+ if (this._suppressNextFocus) {
93
+ this._suppressNextFocus = false;
94
+ return;
95
+ }
96
+ this.show();
97
+ };
84
98
  this._handleTriggerFocusOut = (event) => {
85
99
  if (this.popoverEl.contains(event.relatedTarget)) return;
86
100
  this.hide();
@@ -105,7 +119,12 @@ export class Popover {
105
119
 
106
120
  _handleDismissClick(event) {
107
121
  if (event.target.closest('[data-fs-dismiss="popover"]')) {
122
+ const restoreFocus = this.popoverEl.contains(document.activeElement);
123
+ if (restoreFocus && this.trigger === "focus") this._suppressNextFocus = true;
108
124
  this.hide();
125
+ if (restoreFocus && typeof this.triggerEl.focus === "function") {
126
+ this.triggerEl.focus();
127
+ }
109
128
  }
110
129
  }
111
130
 
@@ -122,6 +141,10 @@ export class Popover {
122
141
 
123
142
  this.popoverEl.classList.add("is-open");
124
143
 
144
+ openPopovers.forEach((popover) => {
145
+ if (popover !== this) popover.hide();
146
+ });
147
+
125
148
  const position = computePosition(this.triggerEl, this.popoverEl, {
126
149
  placement: this.placement,
127
150
  align: this.align,
@@ -134,6 +157,7 @@ export class Popover {
134
157
  offset: 10,
135
158
  });
136
159
  this.popoverEl.setAttribute("data-placement", position.placement);
160
+ openPopovers.add(this);
137
161
 
138
162
  this.triggerEl.setAttribute("aria-expanded", "true");
139
163
 
@@ -150,6 +174,7 @@ export class Popover {
150
174
  hide() {
151
175
  if (!this.isOpen) return;
152
176
  this.isOpen = false;
177
+ openPopovers.delete(this);
153
178
 
154
179
  this.popoverEl.classList.remove("is-open");
155
180
  this._positionCleanup?.();
@@ -4,6 +4,9 @@ export class Tabs {
4
4
  constructor(tablistEl: HTMLElement);
5
5
 
6
6
  static getInstance(el: Element): Tabs | undefined;
7
+ static getOrCreateInstance(el: Element): Tabs;
8
+ static show(el: Element, tab: HTMLElement): void;
9
+ static dispose(el: Element): void;
7
10
 
8
11
  show(tab: HTMLElement): void;
9
12
  dispose(): void;
@@ -45,6 +45,10 @@ export class Tabs {
45
45
  return instances.get(el);
46
46
  }
47
47
 
48
+ static getOrCreateInstance(el) { return this.getInstance(el) ?? new Tabs(el); }
49
+ static show(el, tab) { return this.getOrCreateInstance(el).show(tab); }
50
+ static dispose(el) { return this.getInstance(el)?.dispose(); }
51
+
48
52
  _handleClick(event) {
49
53
  const tab = event.target.closest(".fs-nav-link:not(.is-disabled)");
50
54
  if (!tab || !this.tabs.includes(tab)) return;
@@ -86,6 +90,7 @@ export class Tabs {
86
90
  if (pane) pane.hidden = !isActive;
87
91
  });
88
92
 
93
+ tab.dispatchEvent(new CustomEvent("fs:shown", { bubbles: true }));
89
94
  tab.dispatchEvent(
90
95
  new CustomEvent("fs:tab:changed", { bubbles: true, detail: { target: tab.getAttribute("data-fs-target") } }),
91
96
  );
@@ -33,14 +33,31 @@ export class Tag {
33
33
  dismiss() {
34
34
  if (this._isLoading) return false;
35
35
 
36
+ const dismissButton = this.tagEl.querySelector('[data-fs-dismiss="tag"]');
37
+ const shouldRestoreFocus = dismissButton && document.activeElement === dismissButton;
36
38
  const event = new CustomEvent("fs:tag:dismissed", { bubbles: true, cancelable: true });
37
39
  this.tagEl.dispatchEvent(event);
38
40
  if (event.defaultPrevented) return false;
39
41
 
42
+ const focusTarget = shouldRestoreFocus ? this._getFocusTarget() : null;
40
43
  this.tagEl.remove();
44
+ focusTarget?.focus();
41
45
  return true;
42
46
  }
43
47
 
48
+ _getFocusTarget() {
49
+ const group = this.tagEl.closest(".fs-tag-group");
50
+ if (!group) return null;
51
+
52
+ const buttons = [...group.querySelectorAll('[data-fs-dismiss="tag"]')]
53
+ .filter((button) => !button.disabled && !button.hidden && button.getAttribute("aria-hidden") !== "true");
54
+ const currentButton = this.tagEl.querySelector('[data-fs-dismiss="tag"]');
55
+ const currentIndex = buttons.indexOf(currentButton);
56
+ const remaining = buttons.filter((button) => button !== currentButton);
57
+
58
+ return remaining[currentIndex] || remaining[currentIndex - 1] || null;
59
+ }
60
+
44
61
  // Estado útil enquanto uma remoção ou atualização assíncrona está pendente.
45
62
  // Além da aparência, bloqueia o dismiss e comunica o estado a AT.
46
63
  setLoading(loading) {
@@ -10,13 +10,39 @@ export class Toast {
10
10
  this.autohide = toastEl.getAttribute("data-autohide") !== "false";
11
11
  this.isOpen = false;
12
12
  this._hideTimer = null;
13
+ this._paused = false;
14
+ this._remaining = this.delay;
15
+ this._startedAt = 0;
16
+ this._progressTimer = null;
13
17
 
14
18
  toastEl.setAttribute("role", "status");
15
19
  toastEl.setAttribute("aria-live", "polite");
16
20
  toastEl.style.display = "none";
17
21
 
18
22
  this._handleDismissClick = this._handleDismissClick.bind(this);
23
+ this._pause = () => {
24
+ if (!this.isOpen || this._paused) return;
25
+ this._paused = true;
26
+ clearTimeout(this._hideTimer);
27
+ if (this.autohide && this._startedAt) {
28
+ this._remaining = Math.max(0, this._remaining - (Date.now() - this._startedAt));
29
+ }
30
+ this._updateProgress();
31
+ clearInterval(this._progressTimer);
32
+ this.toastEl.classList.add("is-paused");
33
+ };
34
+ this._resume = () => {
35
+ if (!this.isOpen || !this._paused) return;
36
+ this._paused = false;
37
+ this.toastEl.classList.remove("is-paused");
38
+ this._startProgress();
39
+ if (this.autohide) this._scheduleHide();
40
+ };
19
41
  toastEl.addEventListener("click", this._handleDismissClick);
42
+ toastEl.addEventListener("mouseenter", this._pause);
43
+ toastEl.addEventListener("mouseleave", this._resume);
44
+ toastEl.addEventListener("focusin", this._pause);
45
+ toastEl.addEventListener("focusout", this._resume);
20
46
 
21
47
  instances.set(toastEl, this);
22
48
  }
@@ -35,17 +61,52 @@ export class Toast {
35
61
  if (this.isOpen) return;
36
62
  this.toastEl.dispatchEvent(new CustomEvent("fs:show", { bubbles: true }));
37
63
  this.isOpen = true;
64
+ this._remaining = this.delay;
65
+ this._startedAt = Date.now();
66
+ this.toastEl.style.setProperty("--fs-toast-duration", `${this.delay}ms`);
67
+
68
+ const expansion = expand(this.toastEl);
69
+ this.toastEl.classList.remove("is-progressing");
70
+ void this.toastEl.offsetWidth;
71
+ if (this.toastEl.getAttribute("data-toast-progress") === "true" && this.autohide) {
72
+ this.toastEl.classList.add("is-progressing");
73
+ this._startProgress();
74
+ }
38
75
 
39
- expand(this.toastEl).then(() => {
76
+ expansion.then(() => {
40
77
  this.toastEl.dispatchEvent(new CustomEvent("fs:shown", { bubbles: true }));
41
78
  this.toastEl.dispatchEvent(new CustomEvent("fs:toast:shown", { bubbles: true }));
42
79
  });
43
80
 
44
- if (this.autohide) {
45
- this._hideTimer = setTimeout(() => this.hide(), this.delay);
81
+ this._scheduleHide();
82
+ }
83
+
84
+ _scheduleHide() {
85
+ clearTimeout(this._hideTimer);
86
+ if (this.autohide && !this._paused) {
87
+ this._startedAt = Date.now();
88
+ this._hideTimer = setTimeout(() => this.hide(), this._remaining);
46
89
  }
47
90
  }
48
91
 
92
+ _updateProgress() {
93
+ if (this.toastEl.getAttribute("data-toast-progress") !== "true" || !this.autohide) return;
94
+ const percentage = Math.max(0, Math.min(100, (this._remaining / this.delay) * 100));
95
+ const progressEl = this.toastEl.querySelector(".fs-toast-progress");
96
+ if (progressEl) progressEl.style.inlineSize = `${percentage}%`;
97
+ }
98
+
99
+ _startProgress() {
100
+ if (this.toastEl.getAttribute("data-toast-progress") !== "true" || !this.autohide) return;
101
+ clearInterval(this._progressTimer);
102
+ this._updateProgress();
103
+ this._progressTimer = setInterval(() => {
104
+ if (!this.isOpen || this._paused) return;
105
+ this._remaining = Math.max(0, this.delay - (Date.now() - this._startedAt));
106
+ this._updateProgress();
107
+ }, 50);
108
+ }
109
+
49
110
  hide() {
50
111
  if (!this.isOpen) return;
51
112
  this.toastEl.dispatchEvent(new CustomEvent("fs:hide", { bubbles: true }));
@@ -53,6 +114,14 @@ export class Toast {
53
114
 
54
115
  clearTimeout(this._hideTimer);
55
116
  this._hideTimer = null;
117
+ clearInterval(this._progressTimer);
118
+ this._progressTimer = null;
119
+ this._startedAt = 0;
120
+ this._remaining = this.delay;
121
+ this.toastEl.classList.remove("is-paused");
122
+ this.toastEl.classList.remove("is-progressing");
123
+ const progressEl = this.toastEl.querySelector(".fs-toast-progress");
124
+ if (progressEl) progressEl.style.removeProperty("inline-size");
56
125
 
57
126
  collapse(this.toastEl).then(() => {
58
127
  this.toastEl.dispatchEvent(new CustomEvent("fs:hidden", { bubbles: true }));
@@ -70,7 +139,12 @@ export class Toast {
70
139
 
71
140
  dispose() {
72
141
  clearTimeout(this._hideTimer);
142
+ clearInterval(this._progressTimer);
73
143
  this.toastEl.removeEventListener("click", this._handleDismissClick);
144
+ this.toastEl.removeEventListener("mouseenter", this._pause);
145
+ this.toastEl.removeEventListener("mouseleave", this._resume);
146
+ this.toastEl.removeEventListener("focusin", this._pause);
147
+ this.toastEl.removeEventListener("focusout", this._resume);
74
148
  instances.delete(this.toastEl);
75
149
  }
76
150
  }
@@ -1,8 +1,15 @@
1
1
  import type { Placement } from "./core/positioning.js";
2
2
 
3
+ export type TooltipAlign = "start" | "center" | "end";
4
+ export type TooltipPlacement = Placement | `${Placement}-${Exclude<TooltipAlign, "center">}`;
5
+
3
6
  export interface TooltipOptions {
4
- placement?: Placement;
7
+ placement?: TooltipPlacement;
8
+ align?: TooltipAlign;
9
+ offset?: number;
5
10
  title?: string;
11
+ showDelay?: number;
12
+ hideDelay?: number;
6
13
  }
7
14
 
8
15
  export class Tooltip {
@@ -4,6 +4,19 @@ import { autoInit, createInstanceRegistry } from "./core/register.js";
4
4
 
5
5
  const instances = createInstanceRegistry();
6
6
  let idCounter = 0;
7
+ let openInstance = null;
8
+ const DEFAULT_TOOLTIP_OFFSET = 8;
9
+
10
+ function toDelay(value, fallback = 0) {
11
+ if (value === null || value === undefined || value === "") return fallback;
12
+ const parsed = Number(value);
13
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
14
+ }
15
+
16
+ function parsePlacement(value = "top") {
17
+ const [placement, align = "center"] = value.split("-");
18
+ return { placement, align: ["start", "center", "end"].includes(align) ? align : "center" };
19
+ }
7
20
 
8
21
  export class Tooltip {
9
22
  constructor(referenceEl, options = {}) {
@@ -19,10 +32,18 @@ export class Tooltip {
19
32
 
20
33
  this.referenceEl = referenceEl;
21
34
  this.title = title;
22
- this.placement = options.placement ?? referenceEl.getAttribute("data-placement") ?? "top";
35
+ const parsedPlacement = parsePlacement(options.placement ?? referenceEl.getAttribute("data-placement") ?? "top");
36
+ this.placement = parsedPlacement.placement;
37
+ this.align = options.align ?? referenceEl.getAttribute("data-align") ?? parsedPlacement.align;
38
+ this.offset = toDelay(options.offset ?? referenceEl.getAttribute("data-offset"), DEFAULT_TOOLTIP_OFFSET);
39
+ this.showDelay = toDelay(options.showDelay ?? referenceEl.getAttribute("data-show-delay"), 0);
40
+ this.hideDelay = toDelay(options.hideDelay ?? referenceEl.getAttribute("data-hide-delay"));
23
41
  this.isOpen = false;
24
42
  this.id = `fokus-tooltip-${idCounter}`;
25
43
  this._positionCleanup = null;
44
+ this._showTimer = null;
45
+ this._hideTimer = null;
46
+ this._touchOpened = false;
26
47
  this._originalDescribedBy = referenceEl.getAttribute("aria-describedby");
27
48
 
28
49
  this.tooltipEl = document.createElement("div");
@@ -33,16 +54,36 @@ export class Tooltip {
33
54
  this.tooltipEl.querySelector(".fs-tooltip-inner").textContent = this.title;
34
55
  document.body.appendChild(this.tooltipEl);
35
56
 
36
- referenceEl.setAttribute("aria-describedby", this.id);
37
-
38
- this._handleShow = this.show.bind(this);
39
- this._handleHide = this.hide.bind(this);
40
-
41
- referenceEl.addEventListener("mouseenter", this._handleShow);
42
- referenceEl.addEventListener("mouseleave", this._handleHide);
57
+ this._addDescribedBy();
58
+
59
+ this._handleShow = () => this._scheduleShow(true);
60
+ this._handleHide = () => this._scheduleHide();
61
+
62
+ this._handlePointerEnter = () => this._scheduleShow();
63
+ this._handlePointerLeave = () => this._scheduleHide();
64
+ this._handlePointerDown = (event) => {
65
+ if (event.pointerType === "touch") {
66
+ if (this._touchOpened && this.isOpen) {
67
+ this.hideImmediate();
68
+ return;
69
+ }
70
+ this._touchOpened = true;
71
+ this._scheduleShow(true);
72
+ }
73
+ };
74
+ referenceEl.addEventListener("pointerenter", this._handlePointerEnter);
75
+ referenceEl.addEventListener("pointerleave", this._handlePointerLeave);
76
+ referenceEl.addEventListener("pointerdown", this._handlePointerDown);
77
+ referenceEl.addEventListener("mouseenter", this._handlePointerEnter);
78
+ referenceEl.addEventListener("mouseleave", this._handlePointerLeave);
43
79
  referenceEl.addEventListener("focus", this._handleShow);
44
80
  referenceEl.addEventListener("blur", this._handleHide);
45
81
 
82
+ this._handleDocumentPointerDown = (event) => {
83
+ if (this._touchOpened && !this.referenceEl.contains(event.target) && !this.tooltipEl.contains(event.target)) this.hide();
84
+ };
85
+ document.addEventListener("pointerdown", this._handleDocumentPointerDown, true);
86
+
46
87
  this._removeEscapeListener = onEscapeKey(() => {
47
88
  if (this.isOpen) this.hide();
48
89
  });
@@ -54,8 +95,46 @@ export class Tooltip {
54
95
  return instances.get(el);
55
96
  }
56
97
 
98
+ _clearTimers() {
99
+ clearTimeout(this._showTimer);
100
+ clearTimeout(this._hideTimer);
101
+ this._showTimer = null;
102
+ this._hideTimer = null;
103
+ }
104
+
105
+ _addDescribedBy() {
106
+ const ids = new Set((this.referenceEl.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean));
107
+ ids.add(this.id);
108
+ this.referenceEl.setAttribute("aria-describedby", [...ids].join(" "));
109
+ }
110
+
111
+ _scheduleShow(immediate = false) {
112
+ this._clearTimers();
113
+ if (this.isOpen) return;
114
+ const delay = immediate ? 0 : this.showDelay;
115
+ if (delay === 0) {
116
+ this.show();
117
+ return;
118
+ }
119
+ this._showTimer = setTimeout(() => this.show(), delay);
120
+ }
121
+
122
+ _scheduleHide(immediate = false) {
123
+ this._clearTimers();
124
+ if (!this.isOpen) return;
125
+ const delay = immediate ? 0 : this.hideDelay;
126
+ if (delay === 0) {
127
+ this.hide();
128
+ return;
129
+ }
130
+ this._hideTimer = setTimeout(() => this.hide(), delay);
131
+ }
132
+
57
133
  show() {
134
+ this._clearTimers();
58
135
  if (this.isOpen) return;
136
+ if (openInstance && openInstance !== this) openInstance.hide(true);
137
+ openInstance = this;
59
138
  this.isOpen = true;
60
139
 
61
140
  const theme = this.referenceEl.closest("[data-theme]")?.getAttribute("data-theme");
@@ -67,26 +146,35 @@ export class Tooltip {
67
146
 
68
147
  this.tooltipEl.classList.add("is-open");
69
148
 
70
- const position = computePosition(this.referenceEl, this.tooltipEl, { placement: this.placement, offset: 10 });
149
+ const position = computePosition(this.referenceEl, this.tooltipEl, { placement: this.placement, align: this.align, offset: this.offset });
71
150
  applyPosition(this.tooltipEl, position);
72
151
  this._positionCleanup = watchPosition(this.referenceEl, this.tooltipEl, {
73
152
  placement: this.placement,
74
- offset: 10,
153
+ align: this.align,
154
+ offset: this.offset,
75
155
  });
76
156
  this.tooltipEl.setAttribute("data-placement", position.placement);
157
+ this.tooltipEl.setAttribute("data-align", this.align);
77
158
 
78
159
  this.referenceEl.dispatchEvent(new CustomEvent("fs:tooltip:shown", { bubbles: true }));
79
160
  }
80
161
 
81
162
  hide() {
163
+ this.hideImmediate();
164
+ }
165
+
166
+ hideImmediate() {
167
+ this._clearTimers();
82
168
  if (!this.isOpen) return;
83
169
  this.isOpen = false;
170
+ this._touchOpened = false;
84
171
 
85
172
  this.tooltipEl.classList.remove("is-open");
86
173
  this._positionCleanup?.();
87
174
  this._positionCleanup = null;
88
175
 
89
176
  this.referenceEl.dispatchEvent(new CustomEvent("fs:tooltip:hidden", { bubbles: true }));
177
+ if (openInstance === this) openInstance = null;
90
178
  }
91
179
 
92
180
  toggle() {
@@ -98,11 +186,16 @@ export class Tooltip {
98
186
  }
99
187
 
100
188
  dispose() {
101
- this.hide();
102
- this.referenceEl.removeEventListener("mouseenter", this._handleShow);
103
- this.referenceEl.removeEventListener("mouseleave", this._handleHide);
189
+ this.hideImmediate();
190
+ this._clearTimers();
191
+ this.referenceEl.removeEventListener("pointerenter", this._handlePointerEnter);
192
+ this.referenceEl.removeEventListener("pointerleave", this._handlePointerLeave);
193
+ this.referenceEl.removeEventListener("pointerdown", this._handlePointerDown);
194
+ this.referenceEl.removeEventListener("mouseenter", this._handlePointerEnter);
195
+ this.referenceEl.removeEventListener("mouseleave", this._handlePointerLeave);
104
196
  this.referenceEl.removeEventListener("focus", this._handleShow);
105
197
  this.referenceEl.removeEventListener("blur", this._handleHide);
198
+ document.removeEventListener("pointerdown", this._handleDocumentPointerDown, true);
106
199
  this._removeEscapeListener();
107
200
  if (this._originalDescribedBy === null) this.referenceEl.removeAttribute("aria-describedby");
108
201
  else this.referenceEl.setAttribute("aria-describedby", this._originalDescribedBy);