@lumx/react 3.10.1-alpha.8 → 3.11.1-alpha.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/index.js +177 -5
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/popover/Popover.stories.tsx +60 -44
- package/src/components/popover/Popover.tsx +1 -2
- package/src/components/popover/PositionObserver.ts +155 -0
- package/src/components/popover/usePopoverStyle.tsx +35 -5
package/package.json
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"url": "https://github.com/lumapps/design-system/issues"
|
|
7
7
|
},
|
|
8
8
|
"dependencies": {
|
|
9
|
-
"@lumx/core": "^3.
|
|
10
|
-
"@lumx/icons": "^3.
|
|
9
|
+
"@lumx/core": "^3.11.1-alpha.0",
|
|
10
|
+
"@lumx/icons": "^3.11.1-alpha.0",
|
|
11
11
|
"@popperjs/core": "^2.5.4",
|
|
12
12
|
"body-scroll-lock": "^3.1.5",
|
|
13
13
|
"classnames": "^2.3.2",
|
|
@@ -110,5 +110,5 @@
|
|
|
110
110
|
"build:storybook": "storybook build"
|
|
111
111
|
},
|
|
112
112
|
"sideEffects": false,
|
|
113
|
-
"version": "3.
|
|
113
|
+
"version": "3.11.1-alpha.0"
|
|
114
114
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable react-hooks/rules-of-hooks */
|
|
2
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import React, { CSSProperties, useEffect, useRef, useState } from 'react';
|
|
3
3
|
|
|
4
4
|
import { mdiAccount, mdiBell } from '@lumx/icons';
|
|
5
5
|
import {
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
Popover,
|
|
15
15
|
Size,
|
|
16
16
|
Elevation,
|
|
17
|
+
Message,
|
|
18
|
+
FlexBox, FlexBoxProps, IconButtonProps,
|
|
17
19
|
} from '@lumx/react';
|
|
18
20
|
import range from 'lodash/range';
|
|
19
21
|
import { withCombinations } from '@lumx/react/stories/decorators/withCombinations';
|
|
@@ -110,52 +112,66 @@ export const Placements = {
|
|
|
110
112
|
],
|
|
111
113
|
};
|
|
112
114
|
|
|
113
|
-
export const
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
export const TestUpdatingChildrenAndMovingAnchor = {
|
|
116
|
+
render() {
|
|
117
|
+
const anchorRef = useRef(null);
|
|
118
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
const toggleOpen = () => setIsOpen(!isOpen);
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
const [text, setText] = useState('Long loading text with useless words');
|
|
123
|
+
const [anchorSize, setAnchorSize] = useState<IconButtonProps['size']>('m');
|
|
124
|
+
const [anchorPosition, setAnchorPosition] = useState<FlexBoxProps['vAlign']>('center');
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (isOpen) {
|
|
127
|
+
const timers = [
|
|
128
|
+
setTimeout(() => setText('Text'), 1000),
|
|
129
|
+
setTimeout(() => setAnchorSize('s'), 1500),
|
|
130
|
+
setTimeout(() => setAnchorPosition('left'), 2000),
|
|
131
|
+
];
|
|
132
|
+
return () => timers.forEach(clearTimeout);
|
|
133
|
+
}
|
|
134
|
+
setText('Long loading text with useless words');
|
|
135
|
+
setAnchorSize('m');
|
|
136
|
+
setAnchorPosition('center');
|
|
137
|
+
return undefined;
|
|
138
|
+
}, [isOpen]);
|
|
130
139
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
140
|
+
return (
|
|
141
|
+
<FlexBox orientation="vertical" gap="huge">
|
|
142
|
+
<Message kind="info">
|
|
143
|
+
Test popover text resize (after 1sec), anchor resize (after 1.5sec) and anchor move (after 2sec)
|
|
144
|
+
</Message>
|
|
145
|
+
<FlexBox orientation="horizontal" vAlign={anchorPosition}>
|
|
146
|
+
<IconButton
|
|
147
|
+
label="Notifications"
|
|
148
|
+
className="lumx-spacing-margin-right-huge"
|
|
149
|
+
ref={anchorRef}
|
|
150
|
+
emphasis={Emphasis.low}
|
|
151
|
+
icon={mdiBell}
|
|
152
|
+
size={anchorSize}
|
|
153
|
+
onClick={toggleOpen}
|
|
154
|
+
/>
|
|
155
|
+
<Popover
|
|
156
|
+
closeOnClickAway
|
|
157
|
+
closeOnEscape
|
|
158
|
+
isOpen={isOpen}
|
|
159
|
+
anchorRef={anchorRef}
|
|
160
|
+
placement={Placement.BOTTOM_END}
|
|
161
|
+
onClose={toggleOpen}
|
|
162
|
+
fitWithinViewportHeight
|
|
163
|
+
>
|
|
164
|
+
<List>
|
|
165
|
+
<ListItem before={<Icon icon={mdiAccount} />} className="lumx-spacing-margin-right-huge">
|
|
166
|
+
<span>{text}</span>
|
|
167
|
+
</ListItem>
|
|
168
|
+
</List>
|
|
169
|
+
</Popover>
|
|
170
|
+
</FlexBox>
|
|
171
|
+
</FlexBox>
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
parameters: { chromatic: { disable: true } },
|
|
159
175
|
};
|
|
160
176
|
|
|
161
177
|
export const WithScrollingPopover = () => {
|
|
@@ -137,7 +137,6 @@ const _InnerPopover = forwardRef<PopoverProps, HTMLDivElement>((props, ref) => {
|
|
|
137
137
|
fitWithinViewportHeight,
|
|
138
138
|
boundaryRef,
|
|
139
139
|
anchorRef,
|
|
140
|
-
children,
|
|
141
140
|
placement,
|
|
142
141
|
style,
|
|
143
142
|
zIndex,
|
|
@@ -155,7 +154,7 @@ const _InnerPopover = forwardRef<PopoverProps, HTMLDivElement>((props, ref) => {
|
|
|
155
154
|
const clickAwayRefs = useRef([popoverRef, anchorRef]);
|
|
156
155
|
const mergedRefs = useMergeRefs<HTMLDivElement>(setPopperElement, ref, popoverRef);
|
|
157
156
|
|
|
158
|
-
return isOpen
|
|
157
|
+
return isOpen && styles.popover
|
|
159
158
|
? renderPopover(
|
|
160
159
|
<Component
|
|
161
160
|
{...forwardedProps}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { DOCUMENT, WINDOW } from '@lumx/react/constants';
|
|
2
|
+
|
|
3
|
+
export type PositionObserverCallback = (entries: PositionObserverEntry[], observer: PositionObserver) => void;
|
|
4
|
+
|
|
5
|
+
export type PositionObserverEntry = {
|
|
6
|
+
target: Element;
|
|
7
|
+
boundingClientRect: DOMRect;
|
|
8
|
+
clientHeight: number;
|
|
9
|
+
clientWidth: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const errorString = 'PositionObserver Error';
|
|
13
|
+
|
|
14
|
+
const ROOT = DOCUMENT?.documentElement;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The PositionObserver class is a utility class that observes the position
|
|
18
|
+
* of DOM elements and triggers a callback when their position changes.
|
|
19
|
+
*/
|
|
20
|
+
export default class PositionObserver {
|
|
21
|
+
public entries: Map<Element, PositionObserverEntry>;
|
|
22
|
+
|
|
23
|
+
_tick: number;
|
|
24
|
+
|
|
25
|
+
_callback: PositionObserverCallback;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The constructor takes two arguments, a `callback`, which is called
|
|
29
|
+
* whenever the position of an observed element changes and an `options` object.
|
|
30
|
+
* The callback function should take an array of `PositionObserverEntry` objects
|
|
31
|
+
* as its only argument, but it's not required.
|
|
32
|
+
*
|
|
33
|
+
* @param callback the callback that applies to all targets of this observer
|
|
34
|
+
*/
|
|
35
|
+
constructor(callback: PositionObserverCallback) {
|
|
36
|
+
this.entries = new Map();
|
|
37
|
+
this._callback = callback;
|
|
38
|
+
this._tick = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start observing the position of the specified element.
|
|
43
|
+
* If the element is not currently attached to the DOM,
|
|
44
|
+
* it will NOT be added to the entries.
|
|
45
|
+
*
|
|
46
|
+
* @param target an `Element` target
|
|
47
|
+
*/
|
|
48
|
+
public observe = (target: Element) => {
|
|
49
|
+
if (!(target instanceof Element)) {
|
|
50
|
+
throw new Error(`${errorString}: ${target} is not an instance of Element.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!ROOT?.contains(target)) return;
|
|
54
|
+
|
|
55
|
+
this._new(target).then(({ boundingClientRect }) => {
|
|
56
|
+
if (ROOT && boundingClientRect && !this.entries.has(target)) {
|
|
57
|
+
const { clientWidth, clientHeight } = ROOT;
|
|
58
|
+
|
|
59
|
+
this.entries.set(target, {
|
|
60
|
+
target,
|
|
61
|
+
boundingClientRect,
|
|
62
|
+
clientWidth,
|
|
63
|
+
clientHeight,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!this._tick) this._tick = requestAnimationFrame(this._runCallback);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Private method responsible for all the heavy duty,
|
|
73
|
+
* the observer's runtime.
|
|
74
|
+
*/
|
|
75
|
+
private _runCallback = () => {
|
|
76
|
+
/* istanbul ignore if @preserve - a guard must be set */
|
|
77
|
+
if (!ROOT || !this.entries.size) return;
|
|
78
|
+
const { clientWidth, clientHeight } = ROOT;
|
|
79
|
+
|
|
80
|
+
const queue = new Promise<PositionObserverEntry[]>((resolve) => {
|
|
81
|
+
const updates: PositionObserverEntry[] = [];
|
|
82
|
+
this.entries.forEach(
|
|
83
|
+
({ target, boundingClientRect: oldBoundingBox, clientWidth: oldWidth, clientHeight: oldHeight }) => {
|
|
84
|
+
/* istanbul ignore if @preserve - a guard must be set when target has been removed */
|
|
85
|
+
if (!ROOT.contains(target)) return;
|
|
86
|
+
|
|
87
|
+
this._new(target).then(({ boundingClientRect, isIntersecting }) => {
|
|
88
|
+
/* istanbul ignore if @preserve - make sure to only count visible entries */
|
|
89
|
+
if (!isIntersecting) return;
|
|
90
|
+
const { left, top } = boundingClientRect;
|
|
91
|
+
|
|
92
|
+
/* istanbul ignore else @preserve - only schedule entries that changed position */
|
|
93
|
+
if (
|
|
94
|
+
oldBoundingBox.top !== top ||
|
|
95
|
+
oldBoundingBox.left !== left ||
|
|
96
|
+
oldWidth !== clientWidth ||
|
|
97
|
+
oldHeight !== clientHeight
|
|
98
|
+
) {
|
|
99
|
+
const newEntry = {
|
|
100
|
+
target,
|
|
101
|
+
boundingClientRect,
|
|
102
|
+
clientHeight,
|
|
103
|
+
clientWidth,
|
|
104
|
+
};
|
|
105
|
+
this.entries.set(target, newEntry);
|
|
106
|
+
updates.push(newEntry);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
resolve(updates);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
this._tick = requestAnimationFrame(async () => {
|
|
116
|
+
// execute the queue
|
|
117
|
+
const updates = await queue;
|
|
118
|
+
|
|
119
|
+
// only execute the callback if position actually changed
|
|
120
|
+
/* istanbul ignore else @preserve */
|
|
121
|
+
if (updates.length) this._callback(updates, this);
|
|
122
|
+
|
|
123
|
+
this._runCallback();
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check intersection status and resolve it
|
|
129
|
+
* right away.
|
|
130
|
+
*
|
|
131
|
+
* @param target an `Element` target
|
|
132
|
+
*/
|
|
133
|
+
private _new = (target: Element) => {
|
|
134
|
+
return new Promise<IntersectionObserverEntry>((resolve) => {
|
|
135
|
+
if (!WINDOW?.IntersectionObserver) return;
|
|
136
|
+
|
|
137
|
+
const intersectionObserver = new IntersectionObserver(([entry], ob) => {
|
|
138
|
+
ob.disconnect();
|
|
139
|
+
|
|
140
|
+
resolve(entry);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
intersectionObserver.observe(target);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Immediately stop observing all elements.
|
|
149
|
+
*/
|
|
150
|
+
public disconnect = () => {
|
|
151
|
+
cancelAnimationFrame(this._tick);
|
|
152
|
+
this.entries.clear();
|
|
153
|
+
this._tick = 0;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -6,6 +6,7 @@ import { DOCUMENT, WINDOW } from '@lumx/react/constants';
|
|
|
6
6
|
import { PopoverProps } from '@lumx/react/components/popover/Popover';
|
|
7
7
|
import { usePopper } from '@lumx/react/hooks/usePopper';
|
|
8
8
|
import { ARROW_SIZE, FitAnchorWidth, Placement } from './constants';
|
|
9
|
+
import PositionObserver from './PositionObserver';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Popper js modifier to fit popover min width to the anchor width.
|
|
@@ -74,7 +75,6 @@ type Options = Pick<
|
|
|
74
75
|
| 'fitWithinViewportHeight'
|
|
75
76
|
| 'boundaryRef'
|
|
76
77
|
| 'anchorRef'
|
|
77
|
-
| 'children'
|
|
78
78
|
| 'placement'
|
|
79
79
|
| 'style'
|
|
80
80
|
| 'zIndex'
|
|
@@ -97,13 +97,11 @@ export function usePopoverStyle({
|
|
|
97
97
|
fitWithinViewportHeight,
|
|
98
98
|
boundaryRef,
|
|
99
99
|
anchorRef,
|
|
100
|
-
children,
|
|
101
100
|
placement,
|
|
102
101
|
style,
|
|
103
102
|
zIndex,
|
|
104
103
|
}: Options): Output {
|
|
105
104
|
const [popperElement, setPopperElement] = useState<null | HTMLElement>(null);
|
|
106
|
-
|
|
107
105
|
const [arrowElement, setArrowElement] = useState<null | HTMLElement>(null);
|
|
108
106
|
|
|
109
107
|
const actualOffset: [number, number] = [offset?.along ?? 0, (offset?.away ?? 0) + (hasArrow ? ARROW_SIZE : 0)];
|
|
@@ -137,9 +135,37 @@ export function usePopoverStyle({
|
|
|
137
135
|
}
|
|
138
136
|
|
|
139
137
|
const { styles, attributes, state, update } = usePopper(anchorRef.current, popperElement, { placement, modifiers });
|
|
138
|
+
|
|
139
|
+
// Auto update popover
|
|
140
140
|
useEffect(() => {
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
const { current: anchorElement } = anchorRef;
|
|
142
|
+
if (!update || !popperElement || !anchorElement || !WINDOW?.ResizeObserver) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Only update once per frame
|
|
147
|
+
let frame: number | undefined;
|
|
148
|
+
function limitedUpdate() {
|
|
149
|
+
if (frame) return;
|
|
150
|
+
frame = requestAnimationFrame(() => {
|
|
151
|
+
update?.();
|
|
152
|
+
frame = undefined;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// On anchor move
|
|
157
|
+
const positionObserver = new PositionObserver(limitedUpdate);
|
|
158
|
+
positionObserver.observe(anchorElement);
|
|
159
|
+
|
|
160
|
+
// On anchor or popover resize
|
|
161
|
+
const resizeObserver = new ResizeObserver(limitedUpdate);
|
|
162
|
+
resizeObserver.observe(anchorElement);
|
|
163
|
+
resizeObserver.observe(popperElement);
|
|
164
|
+
return () => {
|
|
165
|
+
resizeObserver.disconnect();
|
|
166
|
+
positionObserver.disconnect();
|
|
167
|
+
};
|
|
168
|
+
}, [anchorRef, popperElement, update]);
|
|
143
169
|
|
|
144
170
|
const position = state?.placement ?? placement;
|
|
145
171
|
|
|
@@ -150,8 +176,12 @@ export function usePopoverStyle({
|
|
|
150
176
|
newStyles.maxHeight = WINDOW?.innerHeight || DOCUMENT?.documentElement.clientHeight;
|
|
151
177
|
}
|
|
152
178
|
|
|
179
|
+
// Do not show the popover while it's not properly placed
|
|
180
|
+
if (!newStyles.transform) newStyles.visibility = 'hidden';
|
|
181
|
+
|
|
153
182
|
return newStyles;
|
|
154
183
|
}, [style, styles.popper, zIndex, fitWithinViewportHeight]);
|
|
184
|
+
|
|
155
185
|
return {
|
|
156
186
|
styles: { arrow: styles.arrow, popover: popoverStyle },
|
|
157
187
|
attributes,
|