@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/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.1-alpha.8",
10
- "@lumx/icons": "^3.10.1-alpha.8",
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.10.1-alpha.8"
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 WithUpdatingChildren = () => {
114
- const anchorRef = useRef(null);
115
- const [isOpen, setIsOpen] = useState(false);
115
+ export const TestUpdatingChildrenAndMovingAnchor = {
116
+ render() {
117
+ const anchorRef = useRef(null);
118
+ const [isOpen, setIsOpen] = useState(false);
116
119
 
117
- const toggleOpen = () => setIsOpen(!isOpen);
120
+ const toggleOpen = () => setIsOpen(!isOpen);
118
121
 
119
- const [text, setText] = useState('Long loading text with useless words');
120
- useEffect(() => {
121
- if (isOpen) {
122
- const timer = setTimeout(() => {
123
- setText('Text');
124
- }, 1000);
125
- return () => clearTimeout(timer);
126
- }
127
- setText('Long loading text with useless words');
128
- return undefined;
129
- }, [isOpen]);
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
- return (
132
- <div style={{ float: 'right' }} className="lumx-spacing-margin-right-huge">
133
- <IconButton
134
- label="Notifications"
135
- className="lumx-spacing-margin-right-huge"
136
- ref={anchorRef}
137
- emphasis={Emphasis.low}
138
- icon={mdiBell}
139
- size={Size.m}
140
- onClick={toggleOpen}
141
- />
142
- <Popover
143
- closeOnClickAway
144
- closeOnEscape
145
- isOpen={isOpen}
146
- anchorRef={anchorRef}
147
- placement={Placement.BOTTOM_END}
148
- onClose={toggleOpen}
149
- fitWithinViewportHeight
150
- >
151
- <List>
152
- <ListItem before={<Icon icon={mdiAccount} />} className="lumx-spacing-margin-right-huge">
153
- <span>{text}</span>
154
- </ListItem>
155
- </List>
156
- </Popover>
157
- </div>
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
- update?.();
142
- }, [children, update]);
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,