@deepfuture/dui-core 0.0.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.
- package/apply-theme.d.ts +23 -0
- package/apply-theme.js +38 -0
- package/base.d.ts +6 -0
- package/base.js +75 -0
- package/dom.d.ts +30 -0
- package/dom.js +85 -0
- package/event.d.ts +10 -0
- package/event.js +10 -0
- package/floating-popup-utils.d.ts +40 -0
- package/floating-popup-utils.js +86 -0
- package/floating-portal-controller.d.ts +73 -0
- package/floating-portal-controller.js +221 -0
- package/index.d.ts +12 -0
- package/index.js +7 -0
- package/layout-types.d.ts +1 -0
- package/layout-types.js +1 -0
- package/package.json +72 -0
- package/popup-coordinator.d.ts +8 -0
- package/popup-coordinator.js +18 -0
- package/setup.d.ts +15 -0
- package/setup.js +17 -0
package/apply-theme.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme applicator — creates themed subclasses of unstyled DUI components
|
|
3
|
+
* and registers them as custom elements.
|
|
4
|
+
*
|
|
5
|
+
* Must be called before any DUI component is used in the DOM.
|
|
6
|
+
*/
|
|
7
|
+
import type { CSSResult, LitElement } from "lit";
|
|
8
|
+
export interface DuiTheme {
|
|
9
|
+
/** Token stylesheet to inject into document.adoptedStyleSheets. */
|
|
10
|
+
tokens: CSSStyleSheet;
|
|
11
|
+
/** Themed :host defaults (font-family, color, line-height). */
|
|
12
|
+
base: CSSResult;
|
|
13
|
+
/** Tag name → component aesthetic styles. */
|
|
14
|
+
styles: Map<string, CSSResult>;
|
|
15
|
+
}
|
|
16
|
+
export interface ApplyThemeOptions {
|
|
17
|
+
theme: DuiTheme;
|
|
18
|
+
components: Array<typeof LitElement & {
|
|
19
|
+
tagName: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export declare function getActiveTheme(): DuiTheme | null;
|
|
23
|
+
export declare function applyTheme({ theme, components }: ApplyThemeOptions): void;
|
package/apply-theme.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme applicator — creates themed subclasses of unstyled DUI components
|
|
3
|
+
* and registers them as custom elements.
|
|
4
|
+
*
|
|
5
|
+
* Must be called before any DUI component is used in the DOM.
|
|
6
|
+
*/
|
|
7
|
+
let activeTheme = null;
|
|
8
|
+
export function getActiveTheme() {
|
|
9
|
+
return activeTheme;
|
|
10
|
+
}
|
|
11
|
+
export function applyTheme({ theme, components }) {
|
|
12
|
+
activeTheme = theme;
|
|
13
|
+
// 1. Inject tokens into document (idempotent)
|
|
14
|
+
if (!document.adoptedStyleSheets.includes(theme.tokens)) {
|
|
15
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, theme.tokens];
|
|
16
|
+
}
|
|
17
|
+
// 2. For each component, create a themed subclass and register it
|
|
18
|
+
for (const Base of components) {
|
|
19
|
+
const tagName = Base.tagName;
|
|
20
|
+
if (customElements.get(tagName))
|
|
21
|
+
continue;
|
|
22
|
+
const themeStyles = theme.styles.get(tagName);
|
|
23
|
+
const baseStyles = Array.isArray(Base.styles)
|
|
24
|
+
? Base.styles
|
|
25
|
+
: Base.styles
|
|
26
|
+
? [Base.styles]
|
|
27
|
+
: [];
|
|
28
|
+
const composedStyles = [
|
|
29
|
+
...baseStyles,
|
|
30
|
+
theme.base,
|
|
31
|
+
...(themeStyles ? [themeStyles] : []),
|
|
32
|
+
];
|
|
33
|
+
const ThemedClass = class extends Base {
|
|
34
|
+
static { this.styles = composedStyles; }
|
|
35
|
+
};
|
|
36
|
+
customElements.define(tagName, ThemedClass);
|
|
37
|
+
}
|
|
38
|
+
}
|
package/base.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural reset styles shared by all DUI components.
|
|
3
|
+
* Contains only layout resets and behavioral defaults — no visual opinions.
|
|
4
|
+
* Visual defaults (font-family, color, etc.) come from the theme's base.
|
|
5
|
+
*/
|
|
6
|
+
export declare const base: import("lit").CSSResult;
|
package/base.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural reset styles shared by all DUI components.
|
|
3
|
+
* Contains only layout resets and behavioral defaults — no visual opinions.
|
|
4
|
+
* Visual defaults (font-family, color, etc.) come from the theme's base.
|
|
5
|
+
*/
|
|
6
|
+
import { css } from "lit";
|
|
7
|
+
export const base = css `
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
p,
|
|
13
|
+
ul,
|
|
14
|
+
ol,
|
|
15
|
+
dl,
|
|
16
|
+
dd,
|
|
17
|
+
h1,
|
|
18
|
+
h2,
|
|
19
|
+
h3,
|
|
20
|
+
h4,
|
|
21
|
+
h5,
|
|
22
|
+
h6,
|
|
23
|
+
figure,
|
|
24
|
+
blockquote,
|
|
25
|
+
fieldset {
|
|
26
|
+
margin: 0;
|
|
27
|
+
padding: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ul,
|
|
31
|
+
ol {
|
|
32
|
+
list-style: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
h1,
|
|
36
|
+
h2,
|
|
37
|
+
h3,
|
|
38
|
+
h4,
|
|
39
|
+
h5 {
|
|
40
|
+
font-size: inherit;
|
|
41
|
+
line-height: inherit;
|
|
42
|
+
font-weight: normal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
img,
|
|
46
|
+
svg,
|
|
47
|
+
video,
|
|
48
|
+
canvas {
|
|
49
|
+
display: block;
|
|
50
|
+
max-width: 100%;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
a,
|
|
54
|
+
a:visited,
|
|
55
|
+
a:hover,
|
|
56
|
+
a:active {
|
|
57
|
+
color: inherit;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
img,
|
|
61
|
+
video {
|
|
62
|
+
height: auto;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@media (prefers-reduced-motion: reduce) {
|
|
66
|
+
*,
|
|
67
|
+
*::before,
|
|
68
|
+
*::after {
|
|
69
|
+
animation-duration: 0.01ms !important;
|
|
70
|
+
animation-iteration-count: 1 !important;
|
|
71
|
+
transition-duration: 0.01ms !important;
|
|
72
|
+
scroll-behavior: auto !important;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`;
|
package/dom.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM utilities for composed tree traversal and focus management.
|
|
3
|
+
* Pierces shadow roots and resolves slot assignments.
|
|
4
|
+
*/
|
|
5
|
+
export type GetRootDocumentOptions = {
|
|
6
|
+
composed?: boolean;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Get the root node (Document or DocumentFragment) of a given node.
|
|
10
|
+
*
|
|
11
|
+
* @param node - The node to get the root of.
|
|
12
|
+
* @param options.composed - If true (default), pierce shadow roots to get the host's root.
|
|
13
|
+
* If false, stop at shadow root boundaries.
|
|
14
|
+
*/
|
|
15
|
+
export declare const getRootDocument: (node: Node, { composed }?: GetRootDocumentOptions) => Document | DocumentFragment | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Recursively walk the composed DOM tree (piercing shadow roots, resolving
|
|
18
|
+
* slot assignments) and return all natively-focusable elements in document
|
|
19
|
+
* order.
|
|
20
|
+
*/
|
|
21
|
+
export declare const getComposedFocusableElements: (root: Node) => HTMLElement[];
|
|
22
|
+
/**
|
|
23
|
+
* Find the first element matching `selector` in the composed tree
|
|
24
|
+
* (piercing shadow roots and resolving slot assignments).
|
|
25
|
+
*/
|
|
26
|
+
export declare const queryComposedTree: (root: Node, selector: string) => HTMLElement | null;
|
|
27
|
+
/**
|
|
28
|
+
* Find the first element with the `autofocus` attribute in the composed tree.
|
|
29
|
+
*/
|
|
30
|
+
export declare const queryComposedAutofocus: (root: Node) => HTMLElement | null;
|
package/dom.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM utilities for composed tree traversal and focus management.
|
|
3
|
+
* Pierces shadow roots and resolves slot assignments.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Get the root node (Document or DocumentFragment) of a given node.
|
|
7
|
+
*
|
|
8
|
+
* @param node - The node to get the root of.
|
|
9
|
+
* @param options.composed - If true (default), pierce shadow roots to get the host's root.
|
|
10
|
+
* If false, stop at shadow root boundaries.
|
|
11
|
+
*/
|
|
12
|
+
export const getRootDocument = (node, { composed = true } = {}) => {
|
|
13
|
+
const root = node.getRootNode({ composed });
|
|
14
|
+
if (root instanceof DocumentFragment) {
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
else if (root instanceof Document) {
|
|
18
|
+
return root;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
};
|
|
22
|
+
const FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
23
|
+
/**
|
|
24
|
+
* Get the composed children of a node, piercing shadow roots and
|
|
25
|
+
* resolving `<slot>` elements to their assigned content.
|
|
26
|
+
*/
|
|
27
|
+
const getComposedChildren = (node) => {
|
|
28
|
+
if (node instanceof HTMLSlotElement) {
|
|
29
|
+
return node.assignedNodes({ flatten: true });
|
|
30
|
+
}
|
|
31
|
+
if (node instanceof Element && node.shadowRoot) {
|
|
32
|
+
return Array.from(node.shadowRoot.childNodes);
|
|
33
|
+
}
|
|
34
|
+
return Array.from(node.childNodes);
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Recursively walk the composed DOM tree (piercing shadow roots, resolving
|
|
38
|
+
* slot assignments) and return all natively-focusable elements in document
|
|
39
|
+
* order.
|
|
40
|
+
*/
|
|
41
|
+
export const getComposedFocusableElements = (root) => {
|
|
42
|
+
const results = [];
|
|
43
|
+
const walk = (node) => {
|
|
44
|
+
if (node instanceof HTMLElement &&
|
|
45
|
+
node.matches(FOCUSABLE_SELECTOR) &&
|
|
46
|
+
!node.hasAttribute("disabled")) {
|
|
47
|
+
results.push(node);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const child of getComposedChildren(node)) {
|
|
51
|
+
walk(child);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
for (const child of getComposedChildren(root)) {
|
|
55
|
+
walk(child);
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Find the first element matching `selector` in the composed tree
|
|
61
|
+
* (piercing shadow roots and resolving slot assignments).
|
|
62
|
+
*/
|
|
63
|
+
export const queryComposedTree = (root, selector) => {
|
|
64
|
+
const walk = (node) => {
|
|
65
|
+
if (node instanceof HTMLElement && node.matches(selector)) {
|
|
66
|
+
return node;
|
|
67
|
+
}
|
|
68
|
+
for (const child of getComposedChildren(node)) {
|
|
69
|
+
const found = walk(child);
|
|
70
|
+
if (found)
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
for (const child of getComposedChildren(root)) {
|
|
76
|
+
const found = walk(child);
|
|
77
|
+
if (found)
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Find the first element with the `autofocus` attribute in the composed tree.
|
|
84
|
+
*/
|
|
85
|
+
export const queryComposedAutofocus = (root) => queryComposedTree(root, "[autofocus]");
|
package/event.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Define an event factory for custom events of `type`.
|
|
3
|
+
* Returned event will always be of same type with same event options.
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* const greetEvent = event<string>('greet-event', { bubbles: true });
|
|
7
|
+
* document.dispatchEvent(greetEvent("Hello, world!"));
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export declare const customEvent: <Detail>(type: string, options?: EventInit) => (detail: Detail) => CustomEvent<Detail>;
|
package/event.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Define an event factory for custom events of `type`.
|
|
3
|
+
* Returned event will always be of same type with same event options.
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* const greetEvent = event<string>('greet-event', { bubbles: true });
|
|
7
|
+
* document.dispatchEvent(greetEvent("Hello, world!"));
|
|
8
|
+
* ```
|
|
9
|
+
*/
|
|
10
|
+
export const customEvent = (type, options = {}) => (detail) => new CustomEvent(type, { ...options, detail });
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for floating popup components (popover, tooltip).
|
|
3
|
+
* Provides animation lifecycle helpers, arrow rendering, and a centralized
|
|
4
|
+
* Floating UI positioning wrapper.
|
|
5
|
+
*/
|
|
6
|
+
import { type TemplateResult } from "lit";
|
|
7
|
+
import { type Placement } from "@floating-ui/dom";
|
|
8
|
+
export type FloatingPopupSide = "top" | "bottom";
|
|
9
|
+
/** Double-rAF to ensure CSS starting-style is applied then removed. */
|
|
10
|
+
export declare const waitForAnimationFrame: () => Promise<void>;
|
|
11
|
+
/** Listen for transitionend with a fallback timeout. Guards against double-fire. */
|
|
12
|
+
export declare const onTransitionEnd: (el: Element, callback: () => void, fallbackMs?: number) => void;
|
|
13
|
+
/** Render an arrow SVG pointing at the trigger. */
|
|
14
|
+
export declare const renderArrow: (side: FloatingPopupSide) => TemplateResult;
|
|
15
|
+
export type ComputeFixedPositionOptions = {
|
|
16
|
+
placement?: Placement;
|
|
17
|
+
offsetPx?: number;
|
|
18
|
+
matchWidth?: boolean;
|
|
19
|
+
padding?: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Compute a fixed-strategy position using Floating UI with the viewport
|
|
23
|
+
* override baked in.
|
|
24
|
+
*/
|
|
25
|
+
export declare const computeFixedPosition: (anchor: HTMLElement, floating: HTMLElement, options?: ComputeFixedPositionOptions) => Promise<{
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
placement: Placement;
|
|
29
|
+
}>;
|
|
30
|
+
/**
|
|
31
|
+
* Start Floating UI `autoUpdate` + `computeFixedPosition` in one call.
|
|
32
|
+
* Returns a cleanup function to stop tracking.
|
|
33
|
+
*/
|
|
34
|
+
export declare const startFixedAutoUpdate: (anchor: HTMLElement, floating: HTMLElement, options?: ComputeFixedPositionOptions & {
|
|
35
|
+
onPosition?: (result: {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
placement: Placement;
|
|
39
|
+
}) => void;
|
|
40
|
+
}) => (() => void);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for floating popup components (popover, tooltip).
|
|
3
|
+
* Provides animation lifecycle helpers, arrow rendering, and a centralized
|
|
4
|
+
* Floating UI positioning wrapper.
|
|
5
|
+
*/
|
|
6
|
+
import { html } from "lit";
|
|
7
|
+
import { autoUpdate, computePosition, flip, offset, platform, shift, size, } from "@floating-ui/dom";
|
|
8
|
+
/** Double-rAF to ensure CSS starting-style is applied then removed. */
|
|
9
|
+
export const waitForAnimationFrame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
|
|
10
|
+
/** Listen for transitionend with a fallback timeout. Guards against double-fire. */
|
|
11
|
+
export const onTransitionEnd = (el, callback, fallbackMs = 200) => {
|
|
12
|
+
let called = false;
|
|
13
|
+
const done = () => {
|
|
14
|
+
if (called)
|
|
15
|
+
return;
|
|
16
|
+
called = true;
|
|
17
|
+
el.removeEventListener("transitionend", onEnd);
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
callback();
|
|
20
|
+
};
|
|
21
|
+
const onEnd = () => done();
|
|
22
|
+
el.addEventListener("transitionend", onEnd);
|
|
23
|
+
const timer = setTimeout(done, fallbackMs);
|
|
24
|
+
};
|
|
25
|
+
/** Render an arrow SVG pointing at the trigger. */
|
|
26
|
+
export const renderArrow = (side) => html `
|
|
27
|
+
<svg class="Arrow" part="arrow" viewBox="0 0 10 6" data-side="${side}">
|
|
28
|
+
<polygon class="arrow-fill" points="0,0 5,6 10,0" />
|
|
29
|
+
<path class="arrow-stroke" d="M 0,0 L 5,6 L 10,0" />
|
|
30
|
+
</svg>
|
|
31
|
+
`;
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Centralized Floating UI positioning
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Shared platform override that forces Floating UI to resolve offsets relative
|
|
37
|
+
* to the viewport. Without this, popups inside `container-type: size` ancestors
|
|
38
|
+
* compute incorrect positions because the container becomes the offset parent.
|
|
39
|
+
*/
|
|
40
|
+
const fixedPlatform = {
|
|
41
|
+
...platform,
|
|
42
|
+
getOffsetParent: () => window,
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Compute a fixed-strategy position using Floating UI with the viewport
|
|
46
|
+
* override baked in.
|
|
47
|
+
*/
|
|
48
|
+
export const computeFixedPosition = (anchor, floating, options = {}) => {
|
|
49
|
+
const { placement = "bottom-start", offsetPx = 4, matchWidth = false, padding = 8, } = options;
|
|
50
|
+
const middleware = [
|
|
51
|
+
offset(offsetPx),
|
|
52
|
+
flip(),
|
|
53
|
+
shift({ padding }),
|
|
54
|
+
];
|
|
55
|
+
if (matchWidth) {
|
|
56
|
+
middleware.push(size({
|
|
57
|
+
apply({ rects, elements }) {
|
|
58
|
+
Object.assign(elements.floating.style, {
|
|
59
|
+
width: `${rects.reference.width}px`,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
return computePosition(anchor, floating, {
|
|
65
|
+
placement,
|
|
66
|
+
strategy: "fixed",
|
|
67
|
+
middleware,
|
|
68
|
+
platform: fixedPlatform,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Start Floating UI `autoUpdate` + `computeFixedPosition` in one call.
|
|
73
|
+
* Returns a cleanup function to stop tracking.
|
|
74
|
+
*/
|
|
75
|
+
export const startFixedAutoUpdate = (anchor, floating, options = {}) => {
|
|
76
|
+
const { onPosition, ...positionOptions } = options;
|
|
77
|
+
return autoUpdate(anchor, floating, () => {
|
|
78
|
+
computeFixedPosition(anchor, floating, positionOptions).then((result) => {
|
|
79
|
+
Object.assign(floating.style, {
|
|
80
|
+
left: `${result.x}px`,
|
|
81
|
+
top: `${result.y}px`,
|
|
82
|
+
});
|
|
83
|
+
onPosition?.(result);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reactive controller that combines portal teleportation with Floating UI
|
|
3
|
+
* positioning. Creates a positioner element in the document body (or a
|
|
4
|
+
* provided overlay root) so that popups escape `container-type` ancestors.
|
|
5
|
+
*/
|
|
6
|
+
import { type CSSResultOrNative, type ReactiveController, type ReactiveControllerHost, type TemplateResult } from "lit";
|
|
7
|
+
import type { Placement } from "@floating-ui/dom";
|
|
8
|
+
type PortalHost = ReactiveControllerHost & HTMLElement;
|
|
9
|
+
export type FloatingPortalControllerOptions = {
|
|
10
|
+
/** Returns the anchor element used for positioning. */
|
|
11
|
+
getAnchor: () => HTMLElement | null | undefined;
|
|
12
|
+
/** Whether the popup width should match the anchor width. Default: true. */
|
|
13
|
+
matchWidth?: boolean;
|
|
14
|
+
/** Floating UI placement. Default: "bottom-start". */
|
|
15
|
+
placement?: Placement;
|
|
16
|
+
/** Offset in px between anchor and popup. Default: 4. */
|
|
17
|
+
offset?: number;
|
|
18
|
+
/** Styles to apply inside the positioner's shadow root via `adoptStyles()`. */
|
|
19
|
+
styles?: CSSResultOrNative[];
|
|
20
|
+
/**
|
|
21
|
+
* CSS selector for a container element inside the rendered popup. When set,
|
|
22
|
+
* the controller automatically moves the host's `childNodes` into this
|
|
23
|
+
* container when the popup opens, and moves them back on teardown.
|
|
24
|
+
*/
|
|
25
|
+
contentContainer?: string;
|
|
26
|
+
/**
|
|
27
|
+
* CSS selector to filter which host children get moved. When set, only
|
|
28
|
+
* matching direct children (via `:scope > selector`) are moved. When unset,
|
|
29
|
+
* all `childNodes` are moved. Only used when `contentContainer` is set.
|
|
30
|
+
*/
|
|
31
|
+
contentSelector?: string;
|
|
32
|
+
/** Called after the popup opens (animation started). */
|
|
33
|
+
onOpen?: () => void;
|
|
34
|
+
/** Called after the popup finishes closing (animation done). */
|
|
35
|
+
onClose?: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Called after each Floating UI reposition so the consumer can react
|
|
38
|
+
* to placement changes (e.g. update data-side attributes).
|
|
39
|
+
*/
|
|
40
|
+
onPosition?: (result: {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
placement: Placement;
|
|
44
|
+
}) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Optional render callback invoked after each host update while the popup
|
|
47
|
+
* is open (or closing).
|
|
48
|
+
*/
|
|
49
|
+
renderPopup?: (portal: FloatingPortalController) => TemplateResult;
|
|
50
|
+
/**
|
|
51
|
+
* Returns the overlay root element where the positioner is appended.
|
|
52
|
+
* Defaults to `document.body`.
|
|
53
|
+
*/
|
|
54
|
+
getOverlayRoot?: () => HTMLElement;
|
|
55
|
+
};
|
|
56
|
+
export declare class FloatingPortalController implements ReactiveController {
|
|
57
|
+
#private;
|
|
58
|
+
get isOpen(): boolean;
|
|
59
|
+
get isAnimating(): boolean;
|
|
60
|
+
get isStarting(): boolean;
|
|
61
|
+
get isEnding(): boolean;
|
|
62
|
+
get positionerElement(): HTMLDivElement | null;
|
|
63
|
+
get renderRoot(): ShadowRoot | HTMLDivElement | null;
|
|
64
|
+
set placement(value: Placement);
|
|
65
|
+
set offset(value: number);
|
|
66
|
+
constructor(host: PortalHost, options: FloatingPortalControllerOptions);
|
|
67
|
+
hostConnected(): void;
|
|
68
|
+
hostDisconnected(): void;
|
|
69
|
+
hostUpdated(): void;
|
|
70
|
+
open(): void;
|
|
71
|
+
close(): void;
|
|
72
|
+
}
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reactive controller that combines portal teleportation with Floating UI
|
|
3
|
+
* positioning. Creates a positioner element in the document body (or a
|
|
4
|
+
* provided overlay root) so that popups escape `container-type` ancestors.
|
|
5
|
+
*/
|
|
6
|
+
import { adoptStyles, render, } from "lit";
|
|
7
|
+
import { notifyPopupClosing, notifyPopupOpening } from "./popup-coordinator.js";
|
|
8
|
+
import { onTransitionEnd, startFixedAutoUpdate, waitForAnimationFrame, } from "./floating-popup-utils.js";
|
|
9
|
+
export class FloatingPortalController {
|
|
10
|
+
#host;
|
|
11
|
+
#getAnchor;
|
|
12
|
+
#matchWidth;
|
|
13
|
+
#placement;
|
|
14
|
+
#offset;
|
|
15
|
+
#styles;
|
|
16
|
+
#onOpen;
|
|
17
|
+
#onClose;
|
|
18
|
+
#onPosition;
|
|
19
|
+
#renderPopup;
|
|
20
|
+
#contentContainer;
|
|
21
|
+
#contentSelector;
|
|
22
|
+
#getOverlayRoot;
|
|
23
|
+
#movedNodes = [];
|
|
24
|
+
#positioner = null;
|
|
25
|
+
#cleanupAutoUpdate = null;
|
|
26
|
+
#isOpen = false;
|
|
27
|
+
#isAnimating = false;
|
|
28
|
+
#generation = 0;
|
|
29
|
+
get isOpen() {
|
|
30
|
+
return this.#isOpen;
|
|
31
|
+
}
|
|
32
|
+
get isAnimating() {
|
|
33
|
+
return this.#isAnimating;
|
|
34
|
+
}
|
|
35
|
+
get isStarting() {
|
|
36
|
+
return this.#isOpen && this.#isAnimating;
|
|
37
|
+
}
|
|
38
|
+
get isEnding() {
|
|
39
|
+
return !this.#isOpen && this.#isAnimating;
|
|
40
|
+
}
|
|
41
|
+
get positionerElement() {
|
|
42
|
+
return this.#positioner;
|
|
43
|
+
}
|
|
44
|
+
get renderRoot() {
|
|
45
|
+
return this.#positioner?.shadowRoot ?? this.#positioner;
|
|
46
|
+
}
|
|
47
|
+
set placement(value) {
|
|
48
|
+
this.#placement = value;
|
|
49
|
+
}
|
|
50
|
+
set offset(value) {
|
|
51
|
+
this.#offset = value;
|
|
52
|
+
}
|
|
53
|
+
constructor(host, options) {
|
|
54
|
+
this.#host = host;
|
|
55
|
+
this.#getAnchor = options.getAnchor;
|
|
56
|
+
this.#matchWidth = options.matchWidth ?? true;
|
|
57
|
+
this.#placement = options.placement ?? "bottom-start";
|
|
58
|
+
this.#offset = options.offset ?? 4;
|
|
59
|
+
this.#styles = options.styles;
|
|
60
|
+
this.#onOpen = options.onOpen;
|
|
61
|
+
this.#onClose = options.onClose;
|
|
62
|
+
this.#onPosition = options.onPosition;
|
|
63
|
+
this.#renderPopup = options.renderPopup;
|
|
64
|
+
this.#contentContainer = options.contentContainer;
|
|
65
|
+
this.#contentSelector = options.contentSelector;
|
|
66
|
+
this.#getOverlayRoot = options.getOverlayRoot ?? (() => document.body);
|
|
67
|
+
host.addController(this);
|
|
68
|
+
}
|
|
69
|
+
hostConnected() {
|
|
70
|
+
document.addEventListener("click", this.#onDocumentClick);
|
|
71
|
+
}
|
|
72
|
+
hostDisconnected() {
|
|
73
|
+
document.removeEventListener("click", this.#onDocumentClick);
|
|
74
|
+
this.#teardown();
|
|
75
|
+
notifyPopupClosing(this.#host);
|
|
76
|
+
}
|
|
77
|
+
hostUpdated() {
|
|
78
|
+
if (this.#isOpen) {
|
|
79
|
+
this.#host.setAttribute("open", "");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.#host.removeAttribute("open");
|
|
83
|
+
}
|
|
84
|
+
if (this.#renderPopup && (this.#isOpen || this.isEnding)) {
|
|
85
|
+
const root = this.renderRoot;
|
|
86
|
+
if (root)
|
|
87
|
+
render(this.#renderPopup(this), root);
|
|
88
|
+
}
|
|
89
|
+
if (this.#contentContainer &&
|
|
90
|
+
this.#isOpen &&
|
|
91
|
+
this.#movedNodes.length === 0) {
|
|
92
|
+
this.#moveContentToPortal();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
open() {
|
|
96
|
+
if (this.#isOpen)
|
|
97
|
+
return;
|
|
98
|
+
this.#generation++;
|
|
99
|
+
const gen = this.#generation;
|
|
100
|
+
notifyPopupOpening(this.#host, () => this.close());
|
|
101
|
+
this.#isOpen = true;
|
|
102
|
+
this.#isAnimating = true;
|
|
103
|
+
this.#createPositioner();
|
|
104
|
+
this.#startAutoUpdate();
|
|
105
|
+
this.#onOpen?.();
|
|
106
|
+
this.#host.requestUpdate();
|
|
107
|
+
waitForAnimationFrame().then(() => {
|
|
108
|
+
if (gen !== this.#generation)
|
|
109
|
+
return;
|
|
110
|
+
this.#isAnimating = false;
|
|
111
|
+
this.#host.requestUpdate();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
close() {
|
|
115
|
+
if (!this.#isOpen)
|
|
116
|
+
return;
|
|
117
|
+
this.#generation++;
|
|
118
|
+
this.#isAnimating = true;
|
|
119
|
+
this.#host.requestUpdate();
|
|
120
|
+
const popup = this.renderRoot?.querySelector(".Popup");
|
|
121
|
+
if (!popup) {
|
|
122
|
+
this.#finishClose();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
onTransitionEnd(popup, () => this.#finishClose());
|
|
126
|
+
}
|
|
127
|
+
// ---- Private ----
|
|
128
|
+
#finishClose() {
|
|
129
|
+
if (!this.#isOpen)
|
|
130
|
+
return;
|
|
131
|
+
this.#isOpen = false;
|
|
132
|
+
this.#isAnimating = false;
|
|
133
|
+
this.#stopAutoUpdate();
|
|
134
|
+
notifyPopupClosing(this.#host);
|
|
135
|
+
this.#onClose?.();
|
|
136
|
+
this.#hidePositioner();
|
|
137
|
+
this.#host.requestUpdate();
|
|
138
|
+
}
|
|
139
|
+
#onDocumentClick = (event) => {
|
|
140
|
+
if (!this.#isOpen)
|
|
141
|
+
return;
|
|
142
|
+
const path = event.composedPath();
|
|
143
|
+
if (!path.includes(this.#host) && !this.#positionerInPath(path)) {
|
|
144
|
+
this.close();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
#positionerInPath(path) {
|
|
148
|
+
if (!this.#positioner)
|
|
149
|
+
return false;
|
|
150
|
+
return path.includes(this.#positioner);
|
|
151
|
+
}
|
|
152
|
+
#createPositioner() {
|
|
153
|
+
if (this.#positioner) {
|
|
154
|
+
this.#positioner.style.display = "";
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const overlayRoot = this.#getOverlayRoot();
|
|
158
|
+
const positioner = document.createElement("div");
|
|
159
|
+
positioner.style.position = "fixed";
|
|
160
|
+
positioner.style.zIndex = "1000";
|
|
161
|
+
positioner.style.pointerEvents = "none";
|
|
162
|
+
positioner.setAttribute("data-floating-portal", "");
|
|
163
|
+
positioner.setAttribute("data-dui-portal-for", this.#host.tagName.toLowerCase());
|
|
164
|
+
if (this.#styles) {
|
|
165
|
+
const shadow = positioner.attachShadow({ mode: "open" });
|
|
166
|
+
adoptStyles(shadow, this.#styles);
|
|
167
|
+
}
|
|
168
|
+
overlayRoot.appendChild(positioner);
|
|
169
|
+
this.#positioner = positioner;
|
|
170
|
+
}
|
|
171
|
+
#hidePositioner() {
|
|
172
|
+
if (this.#positioner) {
|
|
173
|
+
this.#positioner.style.display = "none";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
#removePositioner() {
|
|
177
|
+
this.#positioner?.remove();
|
|
178
|
+
this.#positioner = null;
|
|
179
|
+
}
|
|
180
|
+
#startAutoUpdate() {
|
|
181
|
+
if (this.#cleanupAutoUpdate)
|
|
182
|
+
return;
|
|
183
|
+
const anchor = this.#getAnchor();
|
|
184
|
+
const positioner = this.#positioner;
|
|
185
|
+
if (!anchor || !positioner)
|
|
186
|
+
return;
|
|
187
|
+
this.#cleanupAutoUpdate = startFixedAutoUpdate(anchor, positioner, {
|
|
188
|
+
placement: this.#placement,
|
|
189
|
+
offsetPx: this.#offset,
|
|
190
|
+
matchWidth: this.#matchWidth,
|
|
191
|
+
onPosition: this.#onPosition,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
#stopAutoUpdate() {
|
|
195
|
+
this.#cleanupAutoUpdate?.();
|
|
196
|
+
this.#cleanupAutoUpdate = null;
|
|
197
|
+
}
|
|
198
|
+
#moveContentToPortal() {
|
|
199
|
+
const container = this.renderRoot?.querySelector(this.#contentContainer);
|
|
200
|
+
if (!container)
|
|
201
|
+
return;
|
|
202
|
+
const nodes = this.#contentSelector
|
|
203
|
+
? Array.from(this.#host.querySelectorAll(`:scope > ${this.#contentSelector}`))
|
|
204
|
+
: Array.from(this.#host.childNodes);
|
|
205
|
+
for (const node of nodes) {
|
|
206
|
+
container.appendChild(node);
|
|
207
|
+
this.#movedNodes.push(node);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
#moveContentBack() {
|
|
211
|
+
for (const node of this.#movedNodes) {
|
|
212
|
+
this.#host.appendChild(node);
|
|
213
|
+
}
|
|
214
|
+
this.#movedNodes = [];
|
|
215
|
+
}
|
|
216
|
+
#teardown() {
|
|
217
|
+
this.#stopAutoUpdate();
|
|
218
|
+
this.#moveContentBack();
|
|
219
|
+
this.#removePositioner();
|
|
220
|
+
}
|
|
221
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { base } from "./base.js";
|
|
2
|
+
export { getRootDocument, getComposedFocusableElements, queryComposedTree, queryComposedAutofocus, } from "./dom.js";
|
|
3
|
+
export type { GetRootDocumentOptions } from "./dom.js";
|
|
4
|
+
export { customEvent } from "./event.js";
|
|
5
|
+
export { applyTheme, getActiveTheme } from "./apply-theme.js";
|
|
6
|
+
export type { DuiTheme, ApplyThemeOptions } from "./apply-theme.js";
|
|
7
|
+
export { notifyPopupOpening, notifyPopupClosing } from "./popup-coordinator.js";
|
|
8
|
+
export { waitForAnimationFrame, onTransitionEnd, renderArrow, computeFixedPosition, startFixedAutoUpdate, } from "./floating-popup-utils.js";
|
|
9
|
+
export type { FloatingPopupSide, ComputeFixedPositionOptions, } from "./floating-popup-utils.js";
|
|
10
|
+
export { FloatingPortalController } from "./floating-portal-controller.js";
|
|
11
|
+
export type { FloatingPortalControllerOptions } from "./floating-portal-controller.js";
|
|
12
|
+
export type { StackGap } from "./layout-types.js";
|
package/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { base } from "./base.js";
|
|
2
|
+
export { getRootDocument, getComposedFocusableElements, queryComposedTree, queryComposedAutofocus, } from "./dom.js";
|
|
3
|
+
export { customEvent } from "./event.js";
|
|
4
|
+
export { applyTheme, getActiveTheme } from "./apply-theme.js";
|
|
5
|
+
export { notifyPopupOpening, notifyPopupClosing } from "./popup-coordinator.js";
|
|
6
|
+
export { waitForAnimationFrame, onTransitionEnd, renderArrow, computeFixedPosition, startFixedAutoUpdate, } from "./floating-popup-utils.js";
|
|
7
|
+
export { FloatingPortalController } from "./floating-portal-controller.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type StackGap = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "8" | "10" | "12" | "16";
|
package/layout-types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepfuture/dui-core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "DUI core — applyTheme(), setup(), event factory, base styles",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/nicholasgasior/dui.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./index.js",
|
|
15
|
+
"types": "./index.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./event": {
|
|
18
|
+
"import": "./event.js",
|
|
19
|
+
"types": "./event.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./base": {
|
|
22
|
+
"import": "./base.js",
|
|
23
|
+
"types": "./base.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./apply-theme": {
|
|
26
|
+
"import": "./apply-theme.js",
|
|
27
|
+
"types": "./apply-theme.d.ts"
|
|
28
|
+
},
|
|
29
|
+
"./popup-coordinator": {
|
|
30
|
+
"import": "./popup-coordinator.js",
|
|
31
|
+
"types": "./popup-coordinator.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./floating-popup-utils": {
|
|
34
|
+
"import": "./floating-popup-utils.js",
|
|
35
|
+
"types": "./floating-popup-utils.d.ts"
|
|
36
|
+
},
|
|
37
|
+
"./floating-portal-controller": {
|
|
38
|
+
"import": "./floating-portal-controller.js",
|
|
39
|
+
"types": "./floating-portal-controller.d.ts"
|
|
40
|
+
},
|
|
41
|
+
"./layout-types": {
|
|
42
|
+
"import": "./layout-types.js",
|
|
43
|
+
"types": "./layout-types.d.ts"
|
|
44
|
+
},
|
|
45
|
+
"./dom": {
|
|
46
|
+
"import": "./dom.js",
|
|
47
|
+
"types": "./dom.d.ts"
|
|
48
|
+
},
|
|
49
|
+
"./setup": {
|
|
50
|
+
"import": "./setup.js",
|
|
51
|
+
"types": "./setup.d.ts"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"**/*.js",
|
|
56
|
+
"**/*.d.ts",
|
|
57
|
+
"**/*.css"
|
|
58
|
+
],
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"lit": "^3.3.2",
|
|
61
|
+
"@floating-ui/dom": "^1.7.4"
|
|
62
|
+
},
|
|
63
|
+
"sideEffects": false,
|
|
64
|
+
"keywords": [
|
|
65
|
+
"web-components",
|
|
66
|
+
"lit",
|
|
67
|
+
"unstyled",
|
|
68
|
+
"components",
|
|
69
|
+
"dui",
|
|
70
|
+
"theme"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinates popup components so that only one is open at a time.
|
|
3
|
+
* When a popup opens, any previously-open popup is closed first.
|
|
4
|
+
*/
|
|
5
|
+
/** Call when opening — closes any other open popup first. */
|
|
6
|
+
export declare const notifyPopupOpening: (instance: HTMLElement, closeFn: () => void) => void;
|
|
7
|
+
/** Call when closing — removes this instance from tracking. */
|
|
8
|
+
export declare const notifyPopupClosing: (instance: HTMLElement) => void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinates popup components so that only one is open at a time.
|
|
3
|
+
* When a popup opens, any previously-open popup is closed first.
|
|
4
|
+
*/
|
|
5
|
+
const openPopups = new Map();
|
|
6
|
+
/** Call when opening — closes any other open popup first. */
|
|
7
|
+
export const notifyPopupOpening = (instance, closeFn) => {
|
|
8
|
+
for (const [key, close] of openPopups) {
|
|
9
|
+
if (key !== instance)
|
|
10
|
+
close();
|
|
11
|
+
}
|
|
12
|
+
openPopups.clear();
|
|
13
|
+
openPopups.set(instance, closeFn);
|
|
14
|
+
};
|
|
15
|
+
/** Call when closing — removes this instance from tracking. */
|
|
16
|
+
export const notifyPopupClosing = (instance) => {
|
|
17
|
+
openPopups.delete(instance);
|
|
18
|
+
};
|
package/setup.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience setup function — registers all components with the default theme
|
|
3
|
+
* in a single call. For quick starts and CDN usage.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { setup } from "@deepfuture/dui-core/setup";
|
|
8
|
+
* import { defaultTheme } from "@deepfuture/dui-theme-default";
|
|
9
|
+
* import { allComponents } from "@deepfuture/dui-components/all";
|
|
10
|
+
*
|
|
11
|
+
* setup({ theme: defaultTheme, components: allComponents });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { type ApplyThemeOptions } from "./apply-theme.js";
|
|
15
|
+
export declare function setup(options: ApplyThemeOptions): void;
|
package/setup.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convenience setup function — registers all components with the default theme
|
|
3
|
+
* in a single call. For quick starts and CDN usage.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { setup } from "@deepfuture/dui-core/setup";
|
|
8
|
+
* import { defaultTheme } from "@deepfuture/dui-theme-default";
|
|
9
|
+
* import { allComponents } from "@deepfuture/dui-components/all";
|
|
10
|
+
*
|
|
11
|
+
* setup({ theme: defaultTheme, components: allComponents });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { applyTheme } from "./apply-theme.js";
|
|
15
|
+
export function setup(options) {
|
|
16
|
+
applyTheme(options);
|
|
17
|
+
}
|