@sveltia/ui 0.68.0 → 0.68.1

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.
@@ -7,7 +7,7 @@ export const SHIKI_VERSION: "4.4.3";
7
7
  /**
8
8
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
9
9
  */
10
- export const UI_VERSION: "0.68.0";
10
+ export const UI_VERSION: "0.68.1";
11
11
  /**
12
12
  * Available syntax highlighting languages, sorted by display name.
13
13
  * @type {{ id: string, name: string, aliases?: string[] }[]}
@@ -10,7 +10,7 @@ export const SHIKI_VERSION = "4.4.3";
10
10
  /**
11
11
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
12
12
  */
13
- export const UI_VERSION = "0.68.0";
13
+ export const UI_VERSION = "0.68.1";
14
14
 
15
15
  /**
16
16
  * Available syntax highlighting languages, sorted by display name.
package/dist/index.d.ts CHANGED
@@ -80,6 +80,7 @@ export { default as EmptyState } from "./components/util/empty-state.svelte";
80
80
  export { default as Group } from "./components/util/group.svelte";
81
81
  export { default as Modal } from "./components/util/modal.svelte";
82
82
  export { default as Placeholder } from "./components/util/placeholder.svelte";
83
+ export { default as Popup } from "./components/util/popup.svelte";
83
84
  export { default as VisibilityObserver } from "./components/util/visibility-observer.svelte";
84
85
  export { loadCodeHighlighter } from "./components/text-editor/core.js";
85
86
  export { setCodeHighlighterCacheEnabled } from "./components/text-editor/shiki/cache.js";
package/dist/index.js CHANGED
@@ -80,6 +80,7 @@ export { default as EmptyState } from './components/util/empty-state.svelte';
80
80
  export { default as Group } from './components/util/group.svelte';
81
81
  export { default as Modal } from './components/util/modal.svelte';
82
82
  export { default as Placeholder } from './components/util/placeholder.svelte';
83
+ export { default as Popup } from './components/util/popup.svelte';
83
84
  export { default as VisibilityObserver } from './components/util/visibility-observer.svelte';
84
85
  export { loadCodeHighlighter } from './components/text-editor/core.js';
85
86
  export { setCodeHighlighterCacheEnabled } from './components/text-editor/shiki/cache.js';
@@ -35,7 +35,6 @@ declare class Popup {
35
35
  maxWidth: string | undefined;
36
36
  height: string | undefined;
37
37
  };
38
- observer: IntersectionObserver;
39
38
  /**
40
39
  * A reference to the `<dialog>` element used for the popup, which also serves as the backdrop.
41
40
  * This is `undefined` while the element is not in the DOM tree, which is the case for a closed
@@ -57,6 +56,7 @@ declare class Popup {
57
56
  intersectionObserver: IntersectionObserver;
58
57
  resizeObserver: ResizeObserver;
59
58
  _rafId: number;
59
+ viewportResizeObserver: ResizeObserver;
60
60
  /**
61
61
  * Attach the `<dialog>` element used for the popup. This is called every time the element is
62
62
  * mounted, which happens on each open.
@@ -83,8 +83,14 @@ declare class Popup {
83
83
  */
84
84
  get isReadOnly(): boolean;
85
85
  /**
86
- * Check the position of the anchor element. This is a no-op while the popup element is not in the
87
- * DOM tree; the caller is expected to call this again once the element is attached.
86
+ * Check the position of the anchor element and, if the popup is mounted, recalculate and apply
87
+ * its inset and size. This is a no-op while the popup element is not in the DOM tree; the caller
88
+ * is expected to call this again once the element is attached.
89
+ *
90
+ * This measures the anchor synchronously with `getBoundingClientRect()` rather than reacting to
91
+ * an `IntersectionObserver`, whose delivery isn’t guaranteed to be timely or frame-aligned the
92
+ * way `ResizeObserver`’s is: a stale computation could arrive after a fresher one and silently
93
+ * overwrite it, occasionally leaving the popup mispositioned after a resize.
88
94
  */
89
95
  checkPosition(): void;
90
96
  /**
@@ -63,118 +63,6 @@ class Popup {
63
63
  }),
64
64
  );
65
65
 
66
- observer = new IntersectionObserver((entries) => {
67
- entries.forEach(({ intersectionRect, rootBounds }) => {
68
- if (!intersectionRect || !rootBounds) {
69
- return;
70
- }
71
-
72
- // Use the tracked content element rather than searching the popup element, which for a nested
73
- // popup is the shared parent `<dialog>` and would yield the parent’s content
74
- const content = /** @type {HTMLElement | null} */ (
75
- this.contentElement ?? this.popupElement?.querySelector('.content') ?? null
76
- );
77
-
78
- // The content is not in the DOM tree yet; `checkPosition()` will be called again once the
79
- // popup element is attached
80
- if (!content) {
81
- return;
82
- }
83
-
84
- const { scrollHeight: contentHeight, scrollWidth: contentWidth } = content;
85
- const topMargin = intersectionRect.top - 8;
86
- const bottomMargin = rootBounds.height - intersectionRect.bottom - 8;
87
- let { position } = this;
88
- let height;
89
-
90
- // Normalize RTL-friendly positions to LTR for LTR documents
91
- // @todo Rename `PopupPosition` enums to be direction-agnostic
92
- if (document.dir === 'rtl') {
93
- if (position.endsWith('-left')) {
94
- position = /** @type {PopupPosition} */ (position.replace('-left', '-right'));
95
- } else if (position.endsWith('-right')) {
96
- position = /** @type {PopupPosition} */ (position.replace('-right', '-left'));
97
- }
98
-
99
- if (position.startsWith('left-')) {
100
- position = /** @type {PopupPosition} */ (position.replace('left-', 'right-'));
101
- } else if (position.startsWith('right-')) {
102
- position = /** @type {PopupPosition} */ (position.replace('right-', 'left-'));
103
- }
104
- }
105
-
106
- // Alter the position if the space is limited
107
- // @todo Handle more overflow cases
108
- if (position.startsWith('bottom-')) {
109
- if (contentHeight > bottomMargin) {
110
- if (topMargin > bottomMargin) {
111
- position = /** @type {PopupPosition} */ (position.replace('bottom-', 'top-'));
112
- height = topMargin;
113
- } else {
114
- height = bottomMargin;
115
- }
116
- }
117
- }
118
-
119
- // If the popup overflows the viewport, change the position
120
- if (position.endsWith('-left')) {
121
- if (intersectionRect.left + contentWidth > rootBounds.width - 8) {
122
- position = /** @type {PopupPosition} */ (position.replace('-left', '-right'));
123
- }
124
- }
125
-
126
- if (position.endsWith('-right')) {
127
- if (intersectionRect.right - contentWidth < 8) {
128
- position = /** @type {PopupPosition} */ (position.replace('-right', '-left'));
129
- }
130
- }
131
-
132
- const top = position.startsWith('bottom-')
133
- ? `${Math.round(intersectionRect.bottom)}px`
134
- : position.endsWith('-top')
135
- ? `${Math.round(intersectionRect.top)}px`
136
- : 'auto';
137
-
138
- const right = position.startsWith('left-')
139
- ? `${Math.round(rootBounds.width - intersectionRect.left)}px`
140
- : position.endsWith('-right')
141
- ? `${Math.round(rootBounds.width - intersectionRect.right)}px`
142
- : 'auto';
143
-
144
- const bottom = position.startsWith('top-')
145
- ? `${Math.round(rootBounds.height - intersectionRect.top)}px`
146
- : position.endsWith('-bottom')
147
- ? `${Math.round(rootBounds.height - intersectionRect.bottom)}px`
148
- : 'auto';
149
-
150
- const left = position.startsWith('right-')
151
- ? `${Math.round(intersectionRect.right)}px`
152
- : position.endsWith('-left')
153
- ? `${Math.round(intersectionRect.left)}px`
154
- : 'auto';
155
-
156
- const style = {
157
- inset: [top, right, bottom, left].join(' '),
158
- zIndex: 1000,
159
- minWidth: `${Math.round(intersectionRect.width)}px`,
160
- maxWidth: position.endsWith('-left')
161
- ? `${Math.round(rootBounds.width - intersectionRect.left - 8)}px`
162
- : `${Math.round(intersectionRect.right - 8)}px`,
163
- height: height ? `${Math.round(height)}px` : 'auto',
164
- };
165
-
166
- if (
167
- style.inset !== this.style.inset ||
168
- style.zIndex !== this.style.zIndex ||
169
- style.minWidth !== this.style.minWidth ||
170
- style.maxWidth !== this.style.maxWidth ||
171
- style.height !== this.style.height
172
- ) {
173
- this.style = style;
174
- }
175
- });
176
- });
177
-
178
66
  /**
179
67
  * A reference to the `<dialog>` element used for the popup, which also serves as the backdrop.
180
68
  * This is `undefined` while the element is not in the DOM tree, which is the case for a closed
@@ -253,6 +141,21 @@ class Popup {
253
141
  });
254
142
  this.resizeObserver.observe(this.positionBaseElement);
255
143
 
144
+ // Recalculate the position when the viewport is resized. This is needed in addition to the
145
+ // `ResizeObserver` above, which only reacts to the anchor’s own size changing, not to it
146
+ // simply moving — which happens, for example, when a dialog containing the anchor is
147
+ // re-centered as the viewport is resized. This deliberately avoids the `resize` event on
148
+ // `window`, which isn’t guaranteed to fire for every layout-affecting viewport change across
149
+ // browsers; observing this popup’s own `<dialog>` element instead is reliable, because that
150
+ // element is always sized to exactly `100dvw` × `100dvh` (see `Modal.svelte`), so its box
151
+ // changes precisely when the viewport does — independent of the host app’s own CSS, unlike
152
+ // watching `document.body` or `document.documentElement` would be.
153
+ this.viewportResizeObserver = new ResizeObserver(() => {
154
+ if (this.open) {
155
+ this.checkPosition();
156
+ }
157
+ });
158
+
256
159
  if (popupElement) {
257
160
  this.attachPopupElement(popupElement);
258
161
  }
@@ -274,6 +177,7 @@ class Popup {
274
177
  this.detachPopupElement();
275
178
  this.popupElement = popupElement;
276
179
  this.contentElement = contentElement;
180
+ this.viewportResizeObserver.observe(popupElement);
277
181
 
278
182
  // Identify the popup by the element that actually holds its content. Labelling the `<dialog>`
279
183
  // would be wrong for a nested popup, which shares its parent’s: it would overwrite the parent’s
@@ -327,6 +231,10 @@ class Popup {
327
231
  this.#removeEventListeners?.();
328
232
  this.#removeEventListeners = undefined;
329
233
 
234
+ if (this.popupElement) {
235
+ this.viewportResizeObserver.unobserve(this.popupElement);
236
+ }
237
+
330
238
  // The content is leaving the DOM tree, so the anchor must stop referencing it. Only clear a
331
239
  // reference this popup owns; the consumer may have pointed the anchor somewhere else.
332
240
  if (this.anchorElement.getAttribute('aria-controls') === this.id) {
@@ -354,16 +262,140 @@ class Popup {
354
262
  }
355
263
 
356
264
  /**
357
- * Check the position of the anchor element. This is a no-op while the popup element is not in the
358
- * DOM tree; the caller is expected to call this again once the element is attached.
265
+ * Check the position of the anchor element and, if the popup is mounted, recalculate and apply
266
+ * its inset and size. This is a no-op while the popup element is not in the DOM tree; the caller
267
+ * is expected to call this again once the element is attached.
268
+ *
269
+ * This measures the anchor synchronously with `getBoundingClientRect()` rather than reacting to
270
+ * an `IntersectionObserver`, whose delivery isn’t guaranteed to be timely or frame-aligned the
271
+ * way `ResizeObserver`’s is: a stale computation could arrive after a fresher one and silently
272
+ * overwrite it, occasionally leaving the popup mispositioned after a resize.
359
273
  */
360
274
  checkPosition() {
361
275
  if (!this.popupElement) {
362
276
  return;
363
277
  }
364
278
 
365
- this.observer.unobserve(this.positionBaseElement);
366
- this.observer.observe(this.positionBaseElement);
279
+ // Use the tracked content element rather than searching the popup element, which for a nested
280
+ // popup is the shared parent `<dialog>` and would yield the parent’s content
281
+ const content = /** @type {HTMLElement | null} */ (
282
+ this.contentElement ?? this.popupElement?.querySelector('.content') ?? null
283
+ );
284
+
285
+ // The content is not in the DOM tree yet; `checkPosition()` will be called again once the
286
+ // popup element is attached
287
+ if (!content) {
288
+ return;
289
+ }
290
+
291
+ const anchorRect = this.positionBaseElement.getBoundingClientRect();
292
+ const rootBounds = { width: window.innerWidth, height: window.innerHeight };
293
+
294
+ // Clip the anchor’s rect to the viewport, mirroring what `IntersectionObserver` used to report
295
+ // as `intersectionRect`
296
+ const intersectionRect = {
297
+ top: Math.max(anchorRect.top, 0),
298
+ left: Math.max(anchorRect.left, 0),
299
+ right: Math.min(anchorRect.right, rootBounds.width),
300
+ bottom: Math.min(anchorRect.bottom, rootBounds.height),
301
+ width: 0,
302
+ height: 0,
303
+ };
304
+
305
+ intersectionRect.width = Math.max(0, intersectionRect.right - intersectionRect.left);
306
+ intersectionRect.height = Math.max(0, intersectionRect.bottom - intersectionRect.top);
307
+
308
+ const { scrollHeight: contentHeight, scrollWidth: contentWidth } = content;
309
+ const topMargin = intersectionRect.top - 8;
310
+ const bottomMargin = rootBounds.height - intersectionRect.bottom - 8;
311
+ let { position } = this;
312
+ let height;
313
+
314
+ // Normalize RTL-friendly positions to LTR for LTR documents
315
+ // @todo Rename `PopupPosition` enums to be direction-agnostic
316
+ if (document.dir === 'rtl') {
317
+ if (position.endsWith('-left')) {
318
+ position = /** @type {PopupPosition} */ (position.replace('-left', '-right'));
319
+ } else if (position.endsWith('-right')) {
320
+ position = /** @type {PopupPosition} */ (position.replace('-right', '-left'));
321
+ }
322
+
323
+ if (position.startsWith('left-')) {
324
+ position = /** @type {PopupPosition} */ (position.replace('left-', 'right-'));
325
+ } else if (position.startsWith('right-')) {
326
+ position = /** @type {PopupPosition} */ (position.replace('right-', 'left-'));
327
+ }
328
+ }
329
+
330
+ // Alter the position if the space is limited
331
+ // @todo Handle more overflow cases
332
+ if (position.startsWith('bottom-')) {
333
+ if (contentHeight > bottomMargin) {
334
+ if (topMargin > bottomMargin) {
335
+ position = /** @type {PopupPosition} */ (position.replace('bottom-', 'top-'));
336
+ height = topMargin;
337
+ } else {
338
+ height = bottomMargin;
339
+ }
340
+ }
341
+ }
342
+
343
+ // If the popup overflows the viewport, change the position
344
+ if (position.endsWith('-left')) {
345
+ if (intersectionRect.left + contentWidth > rootBounds.width - 8) {
346
+ position = /** @type {PopupPosition} */ (position.replace('-left', '-right'));
347
+ }
348
+ }
349
+
350
+ if (position.endsWith('-right')) {
351
+ if (intersectionRect.right - contentWidth < 8) {
352
+ position = /** @type {PopupPosition} */ (position.replace('-right', '-left'));
353
+ }
354
+ }
355
+
356
+ const top = position.startsWith('bottom-')
357
+ ? `${Math.round(intersectionRect.bottom)}px`
358
+ : position.endsWith('-top')
359
+ ? `${Math.round(intersectionRect.top)}px`
360
+ : 'auto';
361
+
362
+ const right = position.startsWith('left-')
363
+ ? `${Math.round(rootBounds.width - intersectionRect.left)}px`
364
+ : position.endsWith('-right')
365
+ ? `${Math.round(rootBounds.width - intersectionRect.right)}px`
366
+ : 'auto';
367
+
368
+ const bottom = position.startsWith('top-')
369
+ ? `${Math.round(rootBounds.height - intersectionRect.top)}px`
370
+ : position.endsWith('-bottom')
371
+ ? `${Math.round(rootBounds.height - intersectionRect.bottom)}px`
372
+ : 'auto';
373
+
374
+ const left = position.startsWith('right-')
375
+ ? `${Math.round(intersectionRect.right)}px`
376
+ : position.endsWith('-left')
377
+ ? `${Math.round(intersectionRect.left)}px`
378
+ : 'auto';
379
+
380
+ const style = {
381
+ inset: [top, right, bottom, left].join(' '),
382
+ zIndex: 1000,
383
+ minWidth: `${Math.round(intersectionRect.width)}px`,
384
+ maxWidth: position.endsWith('-left')
385
+ ? `${Math.round(rootBounds.width - intersectionRect.left - 8)}px`
386
+ : `${Math.round(intersectionRect.right - 8)}px`,
387
+ height: height ? `${Math.round(height)}px` : 'auto',
388
+ };
389
+
390
+ if (
391
+ style.inset !== this.style.inset ||
392
+ style.zIndex !== this.style.zIndex ||
393
+ style.minWidth !== this.style.minWidth ||
394
+ style.maxWidth !== this.style.maxWidth ||
395
+ style.height !== this.style.height
396
+ ) {
397
+ this.style = style;
398
+ }
367
399
  }
368
400
 
369
401
  /**
@@ -390,7 +422,7 @@ class Popup {
390
422
  this.detachPopupElement();
391
423
  this.intersectionObserver?.disconnect();
392
424
  this.resizeObserver?.disconnect();
393
- this.observer?.disconnect();
425
+ this.viewportResizeObserver?.disconnect();
394
426
 
395
427
  if (this._rafId) {
396
428
  cancelAnimationFrame(this._rafId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltia/ui",
3
- "version": "0.68.0",
3
+ "version": "0.68.1",
4
4
  "description": "A collection of Svelte components and utilities for building user interfaces.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "@lexical/selection": "^0.49.0",
47
47
  "@lexical/table": "^0.49.0",
48
48
  "@lexical/utils": "^0.49.0",
49
- "@sveltia/i18n": "^1.2.3",
49
+ "@sveltia/i18n": "^1.3.0",
50
50
  "@sveltia/utils": "^0.12.1",
51
51
  "lexical": "^0.49.0"
52
52
  },
@@ -57,7 +57,7 @@
57
57
  "@sveltejs/package": "^2.5.8",
58
58
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
59
59
  "@vitest/coverage-v8": "^4.1.11",
60
- "cspell": "^10.1.0",
60
+ "cspell": "^10.1.1",
61
61
  "eslint": "^9.39.5",
62
62
  "eslint-config-airbnb-extended": "^3.2.0",
63
63
  "eslint-config-prettier": "^10.1.8",
@@ -66,13 +66,13 @@
66
66
  "eslint-plugin-package-json": "^1.7.1",
67
67
  "eslint-plugin-svelte": "^3.23.0",
68
68
  "globals": "^17.11.0",
69
- "happy-dom": "^20.11.6",
70
- "oxlint": "^1.79.0",
69
+ "happy-dom": "^20.11.8",
70
+ "oxlint": "^1.80.0",
71
71
  "postcss": "^8.5.26",
72
72
  "postcss-html": "^2.0.0",
73
73
  "prettier": "^3.9.6",
74
74
  "prettier-plugin-svelte": "^4.1.1",
75
- "rolldown": "^1.2.5",
75
+ "rolldown": "^1.2.6",
76
76
  "sass": "^1.103.1",
77
77
  "shiki": "^4.4.3",
78
78
  "stylelint": "^17.14.1",