@clayui/tooltip 3.75.2 → 3.78.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.
@@ -8,83 +8,28 @@ import {
8
8
  IPortalBaseProps,
9
9
  Keys,
10
10
  delegate,
11
- doAlign,
12
- useMousePosition,
11
+ useInteractionFocus,
13
12
  } from '@clayui/shared';
14
- import {alignPoint} from 'dom-align';
15
13
  import React, {useCallback, useEffect, useReducer, useRef} from 'react';
16
14
  import warning from 'warning';
17
15
 
18
16
  import ClayTooltip from './Tooltip';
19
-
20
- const ALIGNMENTS = [
21
- 'top',
22
- 'top-right',
23
- 'right',
24
- 'bottom-right',
25
- 'bottom',
26
- 'bottom-left',
27
- 'left',
28
- 'top-left',
29
- ] as const;
30
-
31
- const ALIGNMENTS_MAP = {
32
- bottom: ['tc', 'bc'],
33
- 'bottom-left': ['tl', 'bl'],
34
- 'bottom-right': ['tr', 'br'],
35
- left: ['cr', 'cl'],
36
- right: ['cl', 'cr'],
37
- top: ['bc', 'tc'],
38
- 'top-left': ['bl', 'tl'],
39
- 'top-right': ['br', 'tr'],
40
- } as const;
41
-
42
- const ALIGNMENTS_INVERSE_MAP = {
43
- bctc: 'top',
44
- bltl: 'top-left',
45
- brtr: 'top-right',
46
- clcr: 'right',
47
- crcl: 'left',
48
- tcbc: 'bottom',
49
- tlbl: 'bottom-left',
50
- trbr: 'bottom-right',
51
- } as const;
52
-
53
- const BOTTOM_OFFSET = [0, 7] as const;
54
- const LEFT_OFFSET = [-7, 0] as const;
55
- const RIGHT_OFFSET = [7, 0] as const;
56
- const TOP_OFFSET = [0, -7] as const;
57
-
58
- const OFFSET_MAP = {
59
- bctc: TOP_OFFSET,
60
- bltl: TOP_OFFSET,
61
- brtr: TOP_OFFSET,
62
- clcr: RIGHT_OFFSET,
63
- crcl: LEFT_OFFSET,
64
- tcbc: BOTTOM_OFFSET,
65
- tlbl: BOTTOM_OFFSET,
66
- trbr: BOTTOM_OFFSET,
67
- };
68
-
69
- const ALIGNMENTS_FORCE_MAP = {
70
- ...ALIGNMENTS_INVERSE_MAP,
71
- bctc: 'top-left',
72
- tcbc: 'bottom-left',
73
- } as const;
17
+ import {Align, useAlign} from './useAlign';
18
+ import {useClosestTitle} from './useClosestTitle';
19
+ import {useTooltipState} from './useTooltipState';
74
20
 
75
21
  interface IState {
76
- align?: typeof ALIGNMENTS[number];
77
- floating?: boolean;
22
+ align: Align;
23
+ floating: boolean;
78
24
  message?: string;
79
- show?: boolean;
80
25
  setAsHTML?: boolean;
81
26
  }
82
27
 
83
28
  const initialState: IState = {
84
29
  align: 'top',
30
+ floating: false,
85
31
  message: '',
86
32
  setAsHTML: false,
87
- show: false,
88
33
  };
89
34
 
90
35
  const TRIGGER_HIDE_EVENTS = [
@@ -102,64 +47,25 @@ const TRIGGER_SHOW_EVENTS = [
102
47
  'touchstart',
103
48
  ] as const;
104
49
 
105
- interface IAction extends IState {
106
- type: 'align' | 'hide' | 'show';
50
+ interface IAction extends Partial<IState> {
51
+ type: 'reset' | 'update';
107
52
  }
108
53
 
109
54
  const reducer = (state: IState, {type, ...payload}: IAction): IState => {
110
55
  switch (type) {
111
- case 'align':
56
+ case 'update':
112
57
  return {...state, ...payload};
113
- case 'show':
114
- return {...state, ...payload, show: true};
115
- case 'hide':
58
+ case 'reset':
116
59
  return {
117
60
  ...state,
118
61
  align: initialState.align,
119
- floating: undefined,
120
- show: false,
62
+ floating: false,
121
63
  };
122
64
  default:
123
65
  throw new TypeError();
124
66
  }
125
67
  };
126
68
 
127
- function matches(
128
- element: HTMLElement & {
129
- msMatchesSelector?: HTMLElement['matches'];
130
- },
131
- selectorString: string
132
- ) {
133
- if (element.matches) {
134
- return element.matches(selectorString);
135
- } else if (element.msMatchesSelector) {
136
- return element.msMatchesSelector(selectorString);
137
- } else if (element.webkitMatchesSelector) {
138
- return element.webkitMatchesSelector(selectorString);
139
- } else {
140
- return false;
141
- }
142
- }
143
-
144
- function closestAncestor(node: HTMLElement, s: string) {
145
- const element = node;
146
- let ancestor: HTMLElement | null = node;
147
-
148
- if (!document.documentElement.contains(element)) {
149
- return null;
150
- }
151
-
152
- do {
153
- if (matches(ancestor, s)) {
154
- return ancestor;
155
- }
156
-
157
- ancestor = ancestor.parentElement;
158
- } while (ancestor !== null);
159
-
160
- return null;
161
- }
162
-
163
69
  type TContentRenderer = (props: {
164
70
  targetNode?: HTMLElement | null;
165
71
  title: string;
@@ -211,238 +117,151 @@ const TooltipProvider = ({
211
117
  delay = 600,
212
118
  scope,
213
119
  }: IPropsWithChildren | IPropsWithScope) => {
214
- const [{align, floating, message = '', setAsHTML, show}, dispatch] =
215
- useReducer(reducer, initialState);
216
-
217
- const mousePosition = useMousePosition(20);
218
-
219
- // Using `any` type since TS incorrectly infers setTimeout to be from NodeJS
220
- const timeoutIdRef = useRef<any>();
221
- const targetRef = useRef<HTMLElement | null>(null);
222
- const titleNodeRef = useRef<HTMLElement | null>(null);
223
- const tooltipRef = useRef<HTMLElement | null>(null);
224
-
225
- const saveTitle = useCallback((element: HTMLElement) => {
226
- titleNodeRef.current = element;
227
-
228
- const title = element.getAttribute('title');
229
-
230
- if (title) {
231
- element.setAttribute('data-restore-title', title);
232
- element.removeAttribute('title');
233
- } else if (element.tagName === 'svg') {
234
- const titleTag = element.querySelector('title');
235
-
236
- if (titleTag) {
237
- element.setAttribute('data-restore-title', titleTag.innerHTML);
238
-
239
- titleTag.remove();
240
- }
241
- }
242
- }, []);
243
-
244
- const restoreTitle = useCallback(() => {
245
- const element = titleNodeRef.current;
120
+ const [{align, floating, message = '', setAsHTML}, dispatch] = useReducer(
121
+ reducer,
122
+ initialState
123
+ );
246
124
 
247
- if (element) {
248
- const title = element.getAttribute('data-restore-title');
125
+ const tooltipRef = useRef<HTMLElement>(null);
249
126
 
250
- if (title) {
251
- if (element.tagName === 'svg') {
252
- const titleTag = document.createElement('title');
127
+ const {getInteraction, isFocusVisible} = useInteractionFocus();
253
128
 
254
- titleTag.innerHTML = title;
129
+ const isHovered = useRef(false);
130
+ const isFocused = useRef(false);
255
131
 
256
- element.appendChild(titleTag);
257
- } else {
258
- element.setAttribute('title', title);
259
- }
132
+ const {close, isOpen, open} = useTooltipState({delay});
260
133
 
261
- element.removeAttribute('data-restore-title');
134
+ const {getProps, onHide, target, titleNode} = useClosestTitle({
135
+ onClick: useCallback(() => {
136
+ isFocused.current = false;
137
+ isHovered.current = false;
138
+ }, []),
139
+ onHide: useCallback(() => {
140
+ if (!isHovered.current && !isFocused.current) {
141
+ dispatch({type: 'reset'});
142
+ close();
262
143
  }
144
+ }, []),
145
+ tooltipRef,
146
+ });
263
147
 
264
- titleNodeRef.current = null;
265
- }
266
- }, []);
267
-
268
- const handleHide = useCallback((event?: any) => {
269
- if (
270
- event &&
271
- (tooltipRef.current?.contains(event.relatedTarget) ||
272
- targetRef.current?.contains(event.relatedTarget))
273
- ) {
274
- return;
275
- }
276
-
277
- dispatch({type: 'hide'});
278
-
279
- clearTimeout(timeoutIdRef.current);
280
-
281
- restoreTitle();
282
-
283
- if (targetRef.current) {
284
- targetRef.current.removeEventListener('click', handleHide);
285
-
286
- targetRef.current = null;
287
- }
288
- }, []);
148
+ useAlign({
149
+ align,
150
+ autoAlign,
151
+ floating,
152
+ isOpen,
153
+ onAlign: useCallback((align) => dispatch({align, type: 'update'}), []),
154
+ sourceElement: tooltipRef,
155
+ targetElement: titleNode,
156
+ });
289
157
 
290
- const handleShow = useCallback(
158
+ const onShow = useCallback(
291
159
  (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
292
- const target = event!.target as HTMLElement;
293
-
294
- const hasTitle =
295
- target &&
296
- (target.hasAttribute('title') ||
297
- target.hasAttribute('data-title'));
298
-
299
- const titleNode = hasTitle
300
- ? target
301
- : closestAncestor(target, '[title], [data-title]');
302
-
303
- if (titleNode) {
304
- targetRef.current = target;
305
-
306
- target.addEventListener('click', handleHide);
307
-
308
- const title =
309
- titleNode.getAttribute('title') ||
310
- titleNode.getAttribute('data-title') ||
311
- '';
312
-
313
- saveTitle(titleNode);
314
-
315
- const customDelay =
316
- titleNode.getAttribute('data-tooltip-delay');
317
- const newAlign = titleNode.getAttribute(
318
- 'data-tooltip-align'
319
- ) as typeof align;
320
- const setAsHTML = !!titleNode.getAttribute(
321
- 'data-title-set-as-html'
322
- );
323
-
324
- const isFloating = titleNode.getAttribute(
325
- 'data-tooltip-floating'
326
- );
327
-
328
- clearTimeout(timeoutIdRef.current);
329
-
330
- timeoutIdRef.current = setTimeout(
331
- () => {
332
- dispatch({
333
- align: newAlign || align,
334
- floating: Boolean(isFloating),
335
- message: title,
336
- setAsHTML,
337
- type: 'show',
338
- });
339
- },
340
- customDelay ? Number(customDelay) : delay
341
- );
160
+ if (isHovered.current || isFocused.current) {
161
+ const props = getProps(event);
162
+
163
+ if (props) {
164
+ dispatch({
165
+ align: (props.align as any) ?? align,
166
+ floating: props.floating,
167
+ message: props.title,
168
+ setAsHTML: props.setAsHTML,
169
+ type: 'update',
170
+ });
171
+ open(
172
+ isFocused.current,
173
+ props.delay ? Number(props.delay) : undefined
174
+ );
175
+ }
342
176
  }
343
177
  },
344
- []
178
+ [align]
345
179
  );
346
180
 
347
181
  useEffect(() => {
348
182
  const handleEsc = (event: KeyboardEvent) => {
349
- if (show && event.key === Keys.Esc) {
183
+ if (isOpen && event.key === Keys.Esc) {
350
184
  event.stopImmediatePropagation();
351
185
 
352
- handleHide();
186
+ onHide();
353
187
  }
354
188
  };
355
189
 
356
190
  document.addEventListener('keyup', handleEsc, true);
357
191
 
358
192
  return () => document.removeEventListener('keyup', handleEsc, true);
359
- }, [show]);
193
+ }, [isOpen]);
194
+
195
+ const onHoverStart = (event: any) => {
196
+ if (getInteraction() === 'pointer') {
197
+ isHovered.current = true;
198
+ } else {
199
+ isHovered.current = false;
200
+ }
201
+
202
+ onShow(event);
203
+ };
204
+
205
+ const onHoverEnd = (event: any) => {
206
+ isFocused.current = false;
207
+ isHovered.current = false;
208
+
209
+ onHide(event);
210
+ };
211
+
212
+ const onFocus = (event: any) => {
213
+ if (isFocusVisible()) {
214
+ isFocused.current = true;
215
+
216
+ onShow(event);
217
+ }
218
+ };
219
+
220
+ const onBlur = (event: any) => {
221
+ isFocused.current = false;
222
+ isHovered.current = false;
223
+
224
+ onHide(event);
225
+ };
360
226
 
361
227
  useEffect(() => {
362
228
  if (scope) {
363
- const disposeShowEvents = TRIGGER_SHOW_EVENTS.map((eventName) => {
364
- return delegate(document.body, eventName, scope, handleShow);
365
- });
366
- const disposeHideEvents = TRIGGER_HIDE_EVENTS.map((eventName) => {
367
- return delegate(
229
+ const disposeShowEvents = TRIGGER_SHOW_EVENTS.map((eventName) =>
230
+ delegate(document.body, eventName, scope, onHoverStart)
231
+ );
232
+ const disposeHideEvents = TRIGGER_HIDE_EVENTS.map((eventName) =>
233
+ delegate(
368
234
  document.body,
369
235
  eventName,
370
236
  `${scope}, .tooltip`,
371
- handleHide
372
- );
373
- });
237
+ onHoverEnd
238
+ )
239
+ );
240
+
241
+ const disposeShowFocus = delegate(
242
+ document.body,
243
+ 'focus',
244
+ `${scope}, .tooltip`,
245
+ onFocus,
246
+ true
247
+ );
248
+ const disposeCloseBlur = delegate(
249
+ document.body,
250
+ 'blur',
251
+ `${scope}, .tooltip`,
252
+ onBlur,
253
+ true
254
+ );
374
255
 
375
256
  return () => {
376
257
  disposeShowEvents.forEach(({dispose}) => dispose());
377
258
  disposeHideEvents.forEach(({dispose}) => dispose());
378
- };
379
- }
380
- }, [handleShow]);
381
259
 
382
- useEffect(() => {
383
- if (
384
- (tooltipRef as React.RefObject<HTMLDivElement>).current &&
385
- show &&
386
- floating
387
- ) {
388
- const points = ALIGNMENTS_MAP[align || 'top'] as [string, string];
389
-
390
- const [clientX, clientY] = mousePosition;
391
-
392
- alignPoint(
393
- (tooltipRef as React.RefObject<HTMLDivElement>).current!,
394
- {
395
- clientX,
396
- clientY,
397
- },
398
- {
399
- offset: OFFSET_MAP[
400
- points.join('') as keyof typeof OFFSET_MAP
401
- ] as [number, number],
402
- points,
403
- }
404
- );
405
- }
406
- }, [show, floating]);
407
-
408
- useEffect(() => {
409
- if (
410
- titleNodeRef.current &&
411
- (tooltipRef as React.RefObject<HTMLDivElement>).current &&
412
- !floating
413
- ) {
414
- const points = ALIGNMENTS_MAP[align || 'top'] as [string, string];
415
-
416
- const alignment = doAlign({
417
- overflow: {
418
- adjustX: autoAlign,
419
- adjustY: autoAlign,
420
- },
421
- points,
422
- sourceElement: (tooltipRef as React.RefObject<HTMLDivElement>)
423
- .current!,
424
- targetElement: titleNodeRef.current,
425
- });
426
-
427
- const alignmentString = alignment.points.join(
428
- ''
429
- ) as keyof typeof ALIGNMENTS_INVERSE_MAP;
430
-
431
- const pointsString = points.join('');
432
-
433
- if (alignment.overflow.adjustX) {
434
- dispatch({
435
- align: ALIGNMENTS_FORCE_MAP[alignmentString],
436
- type: 'align',
437
- });
438
- } else if (pointsString !== alignmentString) {
439
- dispatch({
440
- align: ALIGNMENTS_INVERSE_MAP[alignmentString],
441
- type: 'align',
442
- });
443
- }
260
+ disposeShowFocus.dispose();
261
+ disposeCloseBlur.dispose();
262
+ };
444
263
  }
445
- }, [align, show]);
264
+ }, [onShow]);
446
265
 
447
266
  warning(
448
267
  (typeof children === 'undefined' && typeof scope !== 'undefined') ||
@@ -461,11 +280,11 @@ const TooltipProvider = ({
461
280
  );
462
281
 
463
282
  const titleContent = contentRenderer({
464
- targetNode: targetRef.current,
283
+ targetNode: target.current,
465
284
  title: message,
466
285
  });
467
286
 
468
- const tooltip = show && (
287
+ const tooltip = isOpen && (
469
288
  <ClayPortal {...containerProps}>
470
289
  <ClayTooltip alignPosition={align} ref={tooltipRef} show>
471
290
  {setAsHTML && typeof titleContent === 'string' ? (
@@ -498,8 +317,10 @@ const TooltipProvider = ({
498
317
  {tooltip}
499
318
  </>
500
319
  ),
501
- onMouseOut: handleHide,
502
- onMouseOver: handleShow,
320
+ onBlur,
321
+ onFocus,
322
+ onMouseOut: onHoverEnd,
323
+ onMouseOver: onHoverStart,
503
324
  })
504
325
  )}
505
326
  </>
@@ -33,6 +33,8 @@ describe('ClayTooltip', () => {
33
33
 
34
34
  expect(document.querySelector('.tooltip')).toBeFalsy();
35
35
 
36
+ fireEvent.mouseDown(document.body);
37
+
36
38
  fireEvent.mouseOver(getByTestId('button'));
37
39
 
38
40
  act(() => {
@@ -49,4 +51,30 @@ describe('ClayTooltip', () => {
49
51
 
50
52
  expect(document.querySelector('.tooltip')).toBeFalsy();
51
53
  });
54
+
55
+ it('show tooltip on element focus and hide on blur', () => {
56
+ const {getByTestId} = render(
57
+ <ClayTooltipProvider>
58
+ <button
59
+ data-testid="button"
60
+ data-tooltip-align="bottom"
61
+ title="Bottom"
62
+ >
63
+ tooltip
64
+ </button>
65
+ </ClayTooltipProvider>
66
+ );
67
+
68
+ fireEvent.keyDown(document.body, {key: 'Tab'});
69
+
70
+ expect(document.querySelector('.tooltip')).toBeFalsy();
71
+
72
+ fireEvent.focus(getByTestId('button'));
73
+
74
+ expect(document.querySelector('.tooltip')).toBeTruthy();
75
+
76
+ fireEvent.blur(getByTestId('button'));
77
+
78
+ expect(document.querySelector('.tooltip')).toBeFalsy();
79
+ });
52
80
  });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: © 2019 Liferay, Inc. <https://liferay.com>
3
+ * SPDX-License-Identifier: BSD-3-Clause
4
+ */
5
+
6
+ import {doAlign, useMousePosition} from '@clayui/shared';
7
+ import {alignPoint} from 'dom-align';
8
+ import React, {useEffect} from 'react';
9
+
10
+ const ALIGNMENTS = [
11
+ 'top',
12
+ 'top-right',
13
+ 'right',
14
+ 'bottom-right',
15
+ 'bottom',
16
+ 'bottom-left',
17
+ 'left',
18
+ 'top-left',
19
+ ] as const;
20
+
21
+ const ALIGNMENTS_MAP = {
22
+ bottom: ['tc', 'bc'],
23
+ 'bottom-left': ['tl', 'bl'],
24
+ 'bottom-right': ['tr', 'br'],
25
+ left: ['cr', 'cl'],
26
+ right: ['cl', 'cr'],
27
+ top: ['bc', 'tc'],
28
+ 'top-left': ['bl', 'tl'],
29
+ 'top-right': ['br', 'tr'],
30
+ } as const;
31
+
32
+ const ALIGNMENTS_INVERSE_MAP = {
33
+ bctc: 'top',
34
+ bltl: 'top-left',
35
+ brtr: 'top-right',
36
+ clcr: 'right',
37
+ crcl: 'left',
38
+ tcbc: 'bottom',
39
+ tlbl: 'bottom-left',
40
+ trbr: 'bottom-right',
41
+ } as const;
42
+
43
+ const BOTTOM_OFFSET = [0, 7] as const;
44
+ const LEFT_OFFSET = [-7, 0] as const;
45
+ const RIGHT_OFFSET = [7, 0] as const;
46
+ const TOP_OFFSET = [0, -7] as const;
47
+
48
+ const OFFSET_MAP = {
49
+ bctc: TOP_OFFSET,
50
+ bltl: TOP_OFFSET,
51
+ brtr: TOP_OFFSET,
52
+ clcr: RIGHT_OFFSET,
53
+ crcl: LEFT_OFFSET,
54
+ tcbc: BOTTOM_OFFSET,
55
+ tlbl: BOTTOM_OFFSET,
56
+ trbr: BOTTOM_OFFSET,
57
+ };
58
+
59
+ const ALIGNMENTS_FORCE_MAP = {
60
+ ...ALIGNMENTS_INVERSE_MAP,
61
+ bctc: 'top-left',
62
+ tcbc: 'bottom-left',
63
+ } as const;
64
+
65
+ export type Align = typeof ALIGNMENTS[number];
66
+
67
+ type Props = {
68
+ align: Align;
69
+ autoAlign: boolean;
70
+ floating: boolean;
71
+ isOpen: boolean;
72
+ onAlign: (align: Align) => void;
73
+ sourceElement: React.MutableRefObject<HTMLElement | null>;
74
+ targetElement: React.MutableRefObject<HTMLElement | null>;
75
+ };
76
+
77
+ export function useAlign({
78
+ align,
79
+ autoAlign,
80
+ floating,
81
+ isOpen,
82
+ onAlign,
83
+ sourceElement,
84
+ targetElement,
85
+ }: Props) {
86
+ const mousePosition = useMousePosition(20);
87
+
88
+ useEffect(() => {
89
+ if (sourceElement.current && isOpen && floating) {
90
+ const points = ALIGNMENTS_MAP[align || 'top'] as [string, string];
91
+
92
+ const [clientX, clientY] = mousePosition;
93
+
94
+ alignPoint(
95
+ sourceElement.current,
96
+ {
97
+ clientX,
98
+ clientY,
99
+ },
100
+ {
101
+ offset: OFFSET_MAP[
102
+ points.join('') as keyof typeof OFFSET_MAP
103
+ ] as [number, number],
104
+ points,
105
+ }
106
+ );
107
+ }
108
+ }, [isOpen, floating]);
109
+
110
+ useEffect(() => {
111
+ if (targetElement.current && sourceElement.current && !floating) {
112
+ const points = ALIGNMENTS_MAP[align || 'top'] as [string, string];
113
+
114
+ const alignment = doAlign({
115
+ overflow: {
116
+ adjustX: autoAlign,
117
+ adjustY: autoAlign,
118
+ },
119
+ points,
120
+ sourceElement: sourceElement.current,
121
+ targetElement: targetElement.current,
122
+ });
123
+
124
+ const alignmentString = alignment.points.join(
125
+ ''
126
+ ) as keyof typeof ALIGNMENTS_INVERSE_MAP;
127
+
128
+ const pointsString = points.join('');
129
+
130
+ if (alignment.overflow.adjustX) {
131
+ onAlign(ALIGNMENTS_FORCE_MAP[alignmentString]);
132
+ } else if (pointsString !== alignmentString) {
133
+ onAlign(ALIGNMENTS_INVERSE_MAP[alignmentString]);
134
+ }
135
+ }
136
+ }, [align, isOpen]);
137
+ }