@y14e/portal 0.0.3 → 0.1.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Portal
2
2
 
3
- Lightweight DOM portal (teleport) utility with fully focus management. Designed for accessible dialogs, menus, overlays, popovers, and etc.
3
+ Lightweight DOM portal (teleport) utility with fully focus management. Designed for accessible dialogs, menus, overlays, popovers.
4
4
 
5
5
  > [!NOTE]
6
6
  > Focus traversal works across portals using invisible sentinels and composed-tree-aware focus detection powered by [Power Focusable](https://github.com/y14e/power-focusable).
@@ -27,18 +27,12 @@ import { createPortal } from 'https://unpkg.com/@y14e/portal/dist/index.js';
27
27
 
28
28
  ### `createPortal`
29
29
 
30
- Creates a portal and preserves keyboard focus flow between the original DOM position and the portal.
30
+ Creates a portal and preserves keyboard focus order between the original DOM and the portal.
31
31
 
32
32
  ```ts
33
- const portal = createPortal(source, container);
34
- // => { element: Element, cleanup: () => void }
33
+ const cleanup = createPortal(source, target);
34
+ // => () => void
35
35
  //
36
36
  // source: Element
37
- // container (optional): Element (default: <body>)
38
-
39
- // Element
40
- console.log(portal.element);
41
-
42
- // Cleanup
43
- portal.cleanup();
37
+ // target (optional): Element (default: <body>)
44
38
  ```
package/dist/index.cjs CHANGED
@@ -7,12 +7,12 @@ function getFocusables(container = document.body, options = {}) {
7
7
  console.warn("Invalid container element. Fallback: <body> element.");
8
8
  container = document.body;
9
9
  }
10
- const { composed = false, filter = () => true } = options;
10
+ const { composed = false, filter = () => true, include } = options;
11
11
  const elements = [];
12
- if (composed) {
12
+ if (composed || typeof include === "function") {
13
13
  let traverse2 = function(node) {
14
14
  if (node instanceof Element) {
15
- if (isFocusable(node)) {
15
+ if (isFocusable(node) || include?.(node)) {
16
16
  elements[elements.length] = node;
17
17
  }
18
18
  }
@@ -85,9 +85,10 @@ function getRelativeFocusable(container, offset, options) {
85
85
  anchor = getActiveElement(),
86
86
  composed = false,
87
87
  filter = () => true,
88
+ include,
88
89
  wrap = false
89
90
  } = options;
90
- const focusables = getFocusables(container, { composed, filter });
91
+ const focusables = getFocusables(container, { composed, filter, include });
91
92
  const { length } = focusables;
92
93
  if (!length) {
93
94
  return null;
@@ -253,39 +254,30 @@ function isUngroupedRadio(element) {
253
254
 
254
255
  // src/index.ts
255
256
  var VISUALLY_HIDDEN_CSS = `border: 0; clip: rect(0, 0, 0, 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; user-select: none; white-space: nowrap; width: 1px;`;
256
- function createPortal(source, container = document.body) {
257
+ function createPortal(source, target = document.body) {
257
258
  if (!(source instanceof Element)) {
258
259
  throw new Error("Invalid source element");
259
260
  }
260
- if (!(container instanceof Element)) {
261
- console.warn("Invalid container element. Fallback: <body> element.");
262
- container = document.body;
261
+ if (!(target instanceof Element)) {
262
+ console.warn("Invalid target element. Fallback: <body> element.");
263
+ target = document.body;
263
264
  }
264
- const portal = new Portal(source, container);
265
- return {
266
- element: portal.getElement(),
267
- cleanup: () => portal.destroy()
268
- };
265
+ const portal = new Portal(source, target);
266
+ return () => portal.destroy();
269
267
  }
270
268
  var Portal = class {
271
269
  #source;
272
- #container;
273
- #portal;
270
+ #target;
274
271
  #entranceSentinel;
275
272
  #exitSentinel;
276
- #focusables = [];
277
273
  #tabIndexes = /* @__PURE__ */ new WeakMap();
278
274
  #controller = null;
279
275
  #isDestroyed = false;
280
- constructor(source, container = document.body) {
276
+ constructor(source, target) {
281
277
  this.#source = source;
282
- this.#container = container;
283
- this.#portal = document.createElement("div");
284
- this.#portal.setAttribute("data-portal", "");
285
- this.#portal.setAttribute("tabindex", "-1");
278
+ this.#target = target;
286
279
  this.#entranceSentinel = this.#createSentinel();
287
280
  this.#exitSentinel = this.#createSentinel();
288
- this.#focusables = getFocusables(this.#source, { composed: true });
289
281
  this.#initialize();
290
282
  }
291
283
  destroy() {
@@ -295,7 +287,10 @@ var Portal = class {
295
287
  this.#isDestroyed = true;
296
288
  this.#controller?.abort();
297
289
  this.#controller = null;
298
- this.#focusables.forEach((focusable) => {
290
+ this.#getFocusables().forEach((focusable) => {
291
+ if (!this.#tabIndexes.has(focusable)) {
292
+ return;
293
+ }
299
294
  const index = this.#tabIndexes.get(focusable);
300
295
  if (index === null) {
301
296
  focusable.removeAttribute("tabindex");
@@ -303,21 +298,16 @@ var Portal = class {
303
298
  focusable.setAttribute("tabindex", String(index));
304
299
  }
305
300
  });
306
- this.#focusables.length = 0;
307
301
  this.#exitSentinel.after(this.#source);
308
- this.#portal.remove();
309
302
  this.#entranceSentinel.remove();
310
303
  this.#exitSentinel.remove();
311
- }
312
- getElement() {
313
- return this.#portal;
304
+ this.#source.removeAttribute("data-portal");
314
305
  }
315
306
  #initialize() {
316
307
  this.#source.before(this.#entranceSentinel);
317
308
  this.#entranceSentinel.after(this.#exitSentinel);
318
- this.#portal.append(this.#source);
319
- this.#container.append(this.#portal);
320
- this.#focusables.forEach((focusable) => {
309
+ this.#target.append(this.#source);
310
+ this.#getFocusables().forEach((focusable) => {
321
311
  const index = focusable.getAttribute("tabindex")?.trim();
322
312
  this.#tabIndexes.set(focusable, index === null ? null : Number(index));
323
313
  focusable.setAttribute("tabindex", "-1");
@@ -332,6 +322,7 @@ var Portal = class {
332
322
  capture: true,
333
323
  signal
334
324
  });
325
+ this.#source.setAttribute("data-portal", "");
335
326
  }
336
327
  #onFocusIn = (event) => {
337
328
  const current = event.target;
@@ -343,14 +334,14 @@ var Portal = class {
343
334
  if (this.#source.contains(before)) {
344
335
  this.#focusOutside("backward");
345
336
  } else {
346
- const first = this.#focusables[0];
337
+ const first = this.#getFocusables()[0];
347
338
  first && focus(first);
348
339
  }
349
340
  } else if (current === this.#exitSentinel) {
350
341
  if (this.#source.contains(before)) {
351
342
  this.#focusOutside("forward");
352
343
  } else {
353
- const last = this.#focusables.at(-1);
344
+ const last = this.#getFocusables().at(-1);
354
345
  last && focus(last);
355
346
  }
356
347
  }
@@ -366,16 +357,16 @@ var Portal = class {
366
357
  if (!this.#source.contains(active)) {
367
358
  return;
368
359
  }
369
- if (!this.#focusables.length) {
360
+ if (!this.#getFocusables().length) {
370
361
  event.preventDefault();
371
362
  (event.shiftKey ? this.#entranceSentinel : this.#exitSentinel).focus();
372
363
  }
373
- const index = this.#focusables.indexOf(active);
364
+ const index = this.#getFocusables().indexOf(active);
374
365
  if (index === -1) {
375
366
  return;
376
367
  }
377
368
  event.preventDefault();
378
- const focusable = this.#focusables[index + (event.shiftKey ? -1 : 1)];
369
+ const focusable = this.#getFocusables()[index + (event.shiftKey ? -1 : 1)];
379
370
  if (focusable) {
380
371
  focus(focusable);
381
372
  } else {
@@ -397,6 +388,12 @@ var Portal = class {
397
388
  const focusable = direction === "backward" ? getPreviousFocusable(document.body, options) : getNextFocusable(document.body, options);
398
389
  focusable && focus(focusable);
399
390
  }
391
+ #getFocusables() {
392
+ return getFocusables(this.#source, {
393
+ composed: true,
394
+ include: (element) => this.#tabIndexes.has(element)
395
+ });
396
+ }
400
397
  };
401
398
  function focus(element) {
402
399
  "focus" in element && typeof element.focus === "function" && element.focus();
@@ -411,9 +408,9 @@ function getActiveElement2() {
411
408
  /**
412
409
  * Portal
413
410
  * Lightweight DOM portal (teleport) utility with fully focus management.
414
- * Designed for accessible dialogs, menus, overlays, popovers, and etc.
411
+ * Designed for accessible dialogs, menus, overlays, popovers.
415
412
  *
416
- * @version 0.0.3
413
+ * @version 0.1.0
417
414
  * @author Yusuke Kamiyamane
418
415
  * @license MIT
419
416
  * @copyright Copyright (c) Yusuke Kamiyamane
@@ -425,10 +422,9 @@ power-focusable/dist/index.js:
425
422
  (**
426
423
  * Power Focusable
427
424
  * High-precision focus management utility with full composed tree support.
428
- * Handles complex focus rules including tabindex ordering, radio groups, inert,
429
- * and shadow DOM.
425
+ * Handles complex focus rules including tabindex ordering, radio groups, inert.
430
426
  *
431
- * @version 4.0.2
427
+ * @version 4.1.0
432
428
  * @author Yusuke Kamiyamane
433
429
  * @license MIT
434
430
  * @copyright Copyright (c) Yusuke Kamiyamane
package/dist/index.d.cts CHANGED
@@ -1,23 +1,19 @@
1
1
  /**
2
2
  * Portal
3
3
  * Lightweight DOM portal (teleport) utility with fully focus management.
4
- * Designed for accessible dialogs, menus, overlays, popovers, and etc.
4
+ * Designed for accessible dialogs, menus, overlays, popovers.
5
5
  *
6
- * @version 0.0.3
6
+ * @version 0.1.0
7
7
  * @author Yusuke Kamiyamane
8
8
  * @license MIT
9
9
  * @copyright Copyright (c) Yusuke Kamiyamane
10
10
  * @see {@link https://github.com/y14e/portal}
11
11
  */
12
- declare function createPortal(source: Element, container?: HTMLElement): {
13
- element: Element;
14
- cleanup: () => void;
15
- };
12
+ declare function createPortal(source: Element, target?: HTMLElement): () => void;
16
13
  declare class Portal {
17
14
  #private;
18
- constructor(source: Element, container?: HTMLElement);
15
+ constructor(source: Element, target: Element);
19
16
  destroy(): void;
20
- getElement(): HTMLElement;
21
17
  }
22
18
 
23
19
  export { Portal, createPortal };
package/dist/index.d.ts CHANGED
@@ -1,23 +1,19 @@
1
1
  /**
2
2
  * Portal
3
3
  * Lightweight DOM portal (teleport) utility with fully focus management.
4
- * Designed for accessible dialogs, menus, overlays, popovers, and etc.
4
+ * Designed for accessible dialogs, menus, overlays, popovers.
5
5
  *
6
- * @version 0.0.3
6
+ * @version 0.1.0
7
7
  * @author Yusuke Kamiyamane
8
8
  * @license MIT
9
9
  * @copyright Copyright (c) Yusuke Kamiyamane
10
10
  * @see {@link https://github.com/y14e/portal}
11
11
  */
12
- declare function createPortal(source: Element, container?: HTMLElement): {
13
- element: Element;
14
- cleanup: () => void;
15
- };
12
+ declare function createPortal(source: Element, target?: HTMLElement): () => void;
16
13
  declare class Portal {
17
14
  #private;
18
- constructor(source: Element, container?: HTMLElement);
15
+ constructor(source: Element, target: Element);
19
16
  destroy(): void;
20
- getElement(): HTMLElement;
21
17
  }
22
18
 
23
19
  export { Portal, createPortal };
package/dist/index.js CHANGED
@@ -5,12 +5,12 @@ function getFocusables(container = document.body, options = {}) {
5
5
  console.warn("Invalid container element. Fallback: <body> element.");
6
6
  container = document.body;
7
7
  }
8
- const { composed = false, filter = () => true } = options;
8
+ const { composed = false, filter = () => true, include } = options;
9
9
  const elements = [];
10
- if (composed) {
10
+ if (composed || typeof include === "function") {
11
11
  let traverse2 = function(node) {
12
12
  if (node instanceof Element) {
13
- if (isFocusable(node)) {
13
+ if (isFocusable(node) || include?.(node)) {
14
14
  elements[elements.length] = node;
15
15
  }
16
16
  }
@@ -83,9 +83,10 @@ function getRelativeFocusable(container, offset, options) {
83
83
  anchor = getActiveElement(),
84
84
  composed = false,
85
85
  filter = () => true,
86
+ include,
86
87
  wrap = false
87
88
  } = options;
88
- const focusables = getFocusables(container, { composed, filter });
89
+ const focusables = getFocusables(container, { composed, filter, include });
89
90
  const { length } = focusables;
90
91
  if (!length) {
91
92
  return null;
@@ -251,39 +252,30 @@ function isUngroupedRadio(element) {
251
252
 
252
253
  // src/index.ts
253
254
  var VISUALLY_HIDDEN_CSS = `border: 0; clip: rect(0, 0, 0, 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; user-select: none; white-space: nowrap; width: 1px;`;
254
- function createPortal(source, container = document.body) {
255
+ function createPortal(source, target = document.body) {
255
256
  if (!(source instanceof Element)) {
256
257
  throw new Error("Invalid source element");
257
258
  }
258
- if (!(container instanceof Element)) {
259
- console.warn("Invalid container element. Fallback: <body> element.");
260
- container = document.body;
259
+ if (!(target instanceof Element)) {
260
+ console.warn("Invalid target element. Fallback: <body> element.");
261
+ target = document.body;
261
262
  }
262
- const portal = new Portal(source, container);
263
- return {
264
- element: portal.getElement(),
265
- cleanup: () => portal.destroy()
266
- };
263
+ const portal = new Portal(source, target);
264
+ return () => portal.destroy();
267
265
  }
268
266
  var Portal = class {
269
267
  #source;
270
- #container;
271
- #portal;
268
+ #target;
272
269
  #entranceSentinel;
273
270
  #exitSentinel;
274
- #focusables = [];
275
271
  #tabIndexes = /* @__PURE__ */ new WeakMap();
276
272
  #controller = null;
277
273
  #isDestroyed = false;
278
- constructor(source, container = document.body) {
274
+ constructor(source, target) {
279
275
  this.#source = source;
280
- this.#container = container;
281
- this.#portal = document.createElement("div");
282
- this.#portal.setAttribute("data-portal", "");
283
- this.#portal.setAttribute("tabindex", "-1");
276
+ this.#target = target;
284
277
  this.#entranceSentinel = this.#createSentinel();
285
278
  this.#exitSentinel = this.#createSentinel();
286
- this.#focusables = getFocusables(this.#source, { composed: true });
287
279
  this.#initialize();
288
280
  }
289
281
  destroy() {
@@ -293,7 +285,10 @@ var Portal = class {
293
285
  this.#isDestroyed = true;
294
286
  this.#controller?.abort();
295
287
  this.#controller = null;
296
- this.#focusables.forEach((focusable) => {
288
+ this.#getFocusables().forEach((focusable) => {
289
+ if (!this.#tabIndexes.has(focusable)) {
290
+ return;
291
+ }
297
292
  const index = this.#tabIndexes.get(focusable);
298
293
  if (index === null) {
299
294
  focusable.removeAttribute("tabindex");
@@ -301,21 +296,16 @@ var Portal = class {
301
296
  focusable.setAttribute("tabindex", String(index));
302
297
  }
303
298
  });
304
- this.#focusables.length = 0;
305
299
  this.#exitSentinel.after(this.#source);
306
- this.#portal.remove();
307
300
  this.#entranceSentinel.remove();
308
301
  this.#exitSentinel.remove();
309
- }
310
- getElement() {
311
- return this.#portal;
302
+ this.#source.removeAttribute("data-portal");
312
303
  }
313
304
  #initialize() {
314
305
  this.#source.before(this.#entranceSentinel);
315
306
  this.#entranceSentinel.after(this.#exitSentinel);
316
- this.#portal.append(this.#source);
317
- this.#container.append(this.#portal);
318
- this.#focusables.forEach((focusable) => {
307
+ this.#target.append(this.#source);
308
+ this.#getFocusables().forEach((focusable) => {
319
309
  const index = focusable.getAttribute("tabindex")?.trim();
320
310
  this.#tabIndexes.set(focusable, index === null ? null : Number(index));
321
311
  focusable.setAttribute("tabindex", "-1");
@@ -330,6 +320,7 @@ var Portal = class {
330
320
  capture: true,
331
321
  signal
332
322
  });
323
+ this.#source.setAttribute("data-portal", "");
333
324
  }
334
325
  #onFocusIn = (event) => {
335
326
  const current = event.target;
@@ -341,14 +332,14 @@ var Portal = class {
341
332
  if (this.#source.contains(before)) {
342
333
  this.#focusOutside("backward");
343
334
  } else {
344
- const first = this.#focusables[0];
335
+ const first = this.#getFocusables()[0];
345
336
  first && focus(first);
346
337
  }
347
338
  } else if (current === this.#exitSentinel) {
348
339
  if (this.#source.contains(before)) {
349
340
  this.#focusOutside("forward");
350
341
  } else {
351
- const last = this.#focusables.at(-1);
342
+ const last = this.#getFocusables().at(-1);
352
343
  last && focus(last);
353
344
  }
354
345
  }
@@ -364,16 +355,16 @@ var Portal = class {
364
355
  if (!this.#source.contains(active)) {
365
356
  return;
366
357
  }
367
- if (!this.#focusables.length) {
358
+ if (!this.#getFocusables().length) {
368
359
  event.preventDefault();
369
360
  (event.shiftKey ? this.#entranceSentinel : this.#exitSentinel).focus();
370
361
  }
371
- const index = this.#focusables.indexOf(active);
362
+ const index = this.#getFocusables().indexOf(active);
372
363
  if (index === -1) {
373
364
  return;
374
365
  }
375
366
  event.preventDefault();
376
- const focusable = this.#focusables[index + (event.shiftKey ? -1 : 1)];
367
+ const focusable = this.#getFocusables()[index + (event.shiftKey ? -1 : 1)];
377
368
  if (focusable) {
378
369
  focus(focusable);
379
370
  } else {
@@ -395,6 +386,12 @@ var Portal = class {
395
386
  const focusable = direction === "backward" ? getPreviousFocusable(document.body, options) : getNextFocusable(document.body, options);
396
387
  focusable && focus(focusable);
397
388
  }
389
+ #getFocusables() {
390
+ return getFocusables(this.#source, {
391
+ composed: true,
392
+ include: (element) => this.#tabIndexes.has(element)
393
+ });
394
+ }
398
395
  };
399
396
  function focus(element) {
400
397
  "focus" in element && typeof element.focus === "function" && element.focus();
@@ -409,9 +406,9 @@ function getActiveElement2() {
409
406
  /**
410
407
  * Portal
411
408
  * Lightweight DOM portal (teleport) utility with fully focus management.
412
- * Designed for accessible dialogs, menus, overlays, popovers, and etc.
409
+ * Designed for accessible dialogs, menus, overlays, popovers.
413
410
  *
414
- * @version 0.0.3
411
+ * @version 0.1.0
415
412
  * @author Yusuke Kamiyamane
416
413
  * @license MIT
417
414
  * @copyright Copyright (c) Yusuke Kamiyamane
@@ -423,10 +420,9 @@ power-focusable/dist/index.js:
423
420
  (**
424
421
  * Power Focusable
425
422
  * High-precision focus management utility with full composed tree support.
426
- * Handles complex focus rules including tabindex ordering, radio groups, inert,
427
- * and shadow DOM.
423
+ * Handles complex focus rules including tabindex ordering, radio groups, inert.
428
424
  *
429
- * @version 4.0.2
425
+ * @version 4.1.0
430
426
  * @author Yusuke Kamiyamane
431
427
  * @license MIT
432
428
  * @copyright Copyright (c) Yusuke Kamiyamane
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@y14e/portal",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "Lightweight DOM portal (teleport) utility with fully focus management",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -48,7 +48,7 @@
48
48
  "homepage": "https://github.com/y14e/portal#readme",
49
49
  "devDependencies": {
50
50
  "bun-types": "latest",
51
- "power-focusable": "^4.0.2",
51
+ "power-focusable": "^4.1.0",
52
52
  "tsup": "^8.0.0",
53
53
  "typescript": "^5.6.0"
54
54
  },