@sanity/ui 1.9.1 → 1.9.3

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "sideEffects": false,
5
5
  "types": "./dist/index.d.ts",
6
6
  "source": "./src/index.ts",
@@ -0,0 +1,99 @@
1
+ import {
2
+ BoundaryElementProvider,
3
+ Button,
4
+ Card,
5
+ Flex,
6
+ Portal,
7
+ PortalProvider,
8
+ Stack,
9
+ Text,
10
+ Tooltip,
11
+ } from '@sanity/ui'
12
+ import {useBoolean, useSelect, useText} from '@sanity/ui-workshop'
13
+ import {useState} from 'react'
14
+ import {WORKSHOP_PLACEMENT_OPTIONS} from '../../../__workshop__/constants'
15
+
16
+ const PORTAL_OPTIONS = {
17
+ '(true)': true,
18
+ '(false)': false,
19
+ portal1: 'portal1',
20
+ }
21
+
22
+ export default function CustomPortalStory() {
23
+ const content = useText(
24
+ 'Content',
25
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut mollis consectetur malesuada. Sed lobortis est dolor, eget imperdiet velit placerat et. Aenean posuere mi non aliquet iaculis. Donec fermentum pulvinar purus at sagittis. Ut tincidunt massa odio, sed finibus justo ullamcorper id. Nam venenatis justo non ligula elementum cursus. Pellentesque laoreet justo in mollis sagittis. In lacinia ornare ultrices. Suspendisse potenti.',
26
+ )
27
+ const placement = useSelect('Placement', WORKSHOP_PLACEMENT_OPTIONS, 'top')
28
+ const portal = useSelect('Portal', PORTAL_OPTIONS, undefined, 'Props')
29
+ const useBoundaryElement = useBoolean('Use boundary element', true)
30
+
31
+ const [portal1Element, setPortal1Element] = useState<HTMLDivElement | null>(null)
32
+ const [boundaryElement, setBoundaryElement] = useState<HTMLDivElement | null>(null)
33
+
34
+ return (
35
+ <PortalProvider
36
+ __unstable_elements={{
37
+ portal1: portal1Element,
38
+ }}
39
+ >
40
+ <Flex align="center" height="fill" justify="center">
41
+ <BoundaryElementProvider element={useBoundaryElement ? boundaryElement : null}>
42
+ <Card
43
+ border
44
+ padding={4}
45
+ ref={setBoundaryElement}
46
+ style={{
47
+ height: 'calc(100vh - 100px)',
48
+ position: 'relative',
49
+ width: '500px',
50
+ }}
51
+ >
52
+ <Text>Boundary element</Text>
53
+ <Flex align="center" height="fill" justify="center">
54
+ <Flex justify="center">
55
+ <Stack space={2}>
56
+ <Tooltip
57
+ boundaryElement={useBoundaryElement ? boundaryElement : null}
58
+ content={<Text size={1}>{content}</Text>}
59
+ padding={2}
60
+ placement={placement}
61
+ portal={portal}
62
+ >
63
+ <Button mode="bleed" text="Tooltip" />
64
+ </Tooltip>
65
+ </Stack>
66
+ </Flex>
67
+ </Flex>
68
+ </Card>
69
+ </BoundaryElementProvider>
70
+ </Flex>
71
+
72
+ <Card
73
+ border
74
+ margin={4}
75
+ padding={4}
76
+ ref={setPortal1Element}
77
+ style={{
78
+ left: 0,
79
+ position: 'absolute',
80
+ top: 0,
81
+ width: '175px',
82
+ }}
83
+ tone="critical"
84
+ />
85
+
86
+ <Portal __unstable_name="portal1">
87
+ <Stack space={4}>
88
+ <Text size={1} weight="medium">
89
+ Portal 1 content
90
+ </Text>
91
+ <Text size={1}>
92
+ The tooltip's max-width should not exceed this container's width (minus padding) if it's
93
+ set to use this portal
94
+ </Text>
95
+ </Stack>
96
+ </Portal>
97
+ </PortalProvider>
98
+ )
99
+ }
@@ -20,5 +20,10 @@ export default defineScope({
20
20
  title: 'Overflowing Boundary',
21
21
  component: lazy(() => import('./overflowingBoundary')),
22
22
  },
23
+ {
24
+ name: 'customPortalStory',
25
+ title: 'Custom Portal',
26
+ component: lazy(() => import('./customPortal')),
27
+ },
23
28
  ],
24
29
  })
@@ -1,6 +1,6 @@
1
- import {BoundaryElementProvider, Button, Card, Code, Flex, Text, Tooltip} from '@sanity/ui'
1
+ import {BoundaryElementProvider, Button, Card, Code, Flex, Stack, Text, Tooltip} from '@sanity/ui'
2
2
  import {useBoolean, useSelect, useText} from '@sanity/ui-workshop'
3
- import {useState} from 'react'
3
+ import {useCallback, useState} from 'react'
4
4
  import {WORKSHOP_PLACEMENT_OPTIONS} from '../../../__workshop__/constants'
5
5
 
6
6
  export default function OverflowingBoundaryStory() {
@@ -13,6 +13,15 @@ export default function OverflowingBoundaryStory() {
13
13
  const useBoundaryElement = useBoolean('Use boundary element', true)
14
14
 
15
15
  const [boundaryElement, setBoundaryElement] = useState<HTMLDivElement | null>(null)
16
+ const [buttonsVisible, setButtonsVisible] = useState(true)
17
+
18
+ const handleHideButtons = useCallback(() => {
19
+ setButtonsVisible(false)
20
+ }, [])
21
+
22
+ const handleShowButtons = useCallback(() => {
23
+ setButtonsVisible(true)
24
+ }, [])
16
25
 
17
26
  return (
18
27
  <Flex align="center" height="fill" justify="center" style={{height: '200vh'}}>
@@ -30,22 +39,33 @@ export default function OverflowingBoundaryStory() {
30
39
  >
31
40
  <Flex align="center" height="fill" justify="center">
32
41
  <Flex justify="center">
33
- <Code size={1}>Placement: {placement}</Code>
42
+ <Stack space={2}>
43
+ <Code size={1}>Placement: {placement}</Code>
44
+ <Button
45
+ disabled={buttonsVisible}
46
+ fontSize={1}
47
+ onClick={handleShowButtons}
48
+ text="Show buttons"
49
+ />
50
+ </Stack>
34
51
  </Flex>
35
52
  </Flex>
36
53
 
37
- <Tooltip
38
- content={<Text size={1}>{content}</Text>}
39
- padding={2}
40
- placement={placement}
41
- portal={portal}
42
- >
43
- <Button
44
- mode="bleed"
45
- style={{position: 'absolute', top: 10, right: 10}}
46
- text="Tooltip"
47
- />
48
- </Tooltip>
54
+ {buttonsVisible && (
55
+ <Tooltip
56
+ content={<Text size={1}>{content}</Text>}
57
+ padding={2}
58
+ placement={placement}
59
+ portal={portal}
60
+ >
61
+ <Button
62
+ mode="bleed"
63
+ onClick={handleHideButtons}
64
+ style={{position: 'absolute', top: 10, right: 10}}
65
+ text="Tooltip"
66
+ />
67
+ </Tooltip>
68
+ )}
49
69
  </Card>
50
70
  </BoundaryElementProvider>
51
71
  </Flex>
@@ -6,6 +6,17 @@ import {render} from '../../../test'
6
6
  import {Text, Button} from '../../primitives'
7
7
  import {Tooltip, TooltipDelayGroupProvider} from '../tooltip'
8
8
 
9
+ // Fake timers using Jest
10
+ beforeEach(() => {
11
+ jest.useFakeTimers()
12
+ })
13
+
14
+ // Running all pending timers and switching to real timers using Jest
15
+ afterEach(() => {
16
+ jest.runOnlyPendingTimers()
17
+ jest.useRealTimers()
18
+ })
19
+
9
20
  describe('Tooltip', () => {
10
21
  describe('Using same delay for open and close', () => {
11
22
  it('should hide and show the tooltip content when hovered, with no delay', () => {
@@ -275,6 +286,7 @@ describe('Tooltip', () => {
275
286
  jest.clearAllMocks()
276
287
  })
277
288
  })
289
+
278
290
  describe('Closing the <Tooltip /> with the Escape key', () => {
279
291
  it('Standalone tooltip closes immediately with Escape key', () => {
280
292
  const delay = 150
@@ -342,4 +354,117 @@ describe('Tooltip', () => {
342
354
  expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
343
355
  })
344
356
  })
357
+
358
+ describe('Clicking the <Tooltip /> child should close the tooltip', () => {
359
+ it('Should close the tooltip when clicked', () => {
360
+ const delay = 150
361
+
362
+ render(
363
+ <Tooltip content={<Text size={1}>{'Tooltip content'}</Text>} delay={delay}>
364
+ <Button mode="bleed" text="Hover me" />
365
+ </Tooltip>,
366
+ )
367
+
368
+ const button = screen.getByText('Hover me')
369
+
370
+ // Assertion: tooltip does not exist in the document
371
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
372
+ fireEvent.focus(button)
373
+
374
+ act(() => jest.advanceTimersByTime(delay))
375
+
376
+ // Assertion: the tooltip is not visible
377
+ expect(screen.queryByText('Tooltip content')).toBeVisible()
378
+
379
+ act(() => fireEvent.click(button))
380
+
381
+ // Assertion: tooltip does not exist in the document
382
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
383
+ })
384
+
385
+ it('Should close the tooltip when the context menu is opened (right click)', () => {
386
+ const delay = 150
387
+
388
+ render(
389
+ <Tooltip content={<Text size={1}>{'Tooltip content'}</Text>} delay={delay}>
390
+ <Button mode="bleed" text="Hover me" />
391
+ </Tooltip>,
392
+ )
393
+
394
+ const button = screen.getByText('Hover me')
395
+
396
+ // Assertion: tooltip does not exist in the document
397
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
398
+ fireEvent.focus(button)
399
+
400
+ act(() => jest.advanceTimersByTime(delay))
401
+
402
+ // Assertion: the tooltip is not visible
403
+ expect(screen.queryByText('Tooltip content')).toBeVisible()
404
+
405
+ act(() => fireEvent.contextMenu(button))
406
+
407
+ // Assertion: tooltip does not exist in the document
408
+ expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument()
409
+ })
410
+ })
411
+
412
+ describe('Used defined events on <Tooltip /> child should fire correctly', () => {
413
+ const handleBlur = jest.fn()
414
+ const handleClick = jest.fn()
415
+ const handleContextMenu = jest.fn()
416
+ const handleFocus = jest.fn()
417
+ const handleMouseEnter = jest.fn()
418
+ const handleMouseLeave = jest.fn()
419
+
420
+ beforeEach(() => {
421
+ render(
422
+ <Tooltip content={<Text size={1}>{'Tooltip content'}</Text>}>
423
+ <Button
424
+ data-testid="btn"
425
+ mode="bleed"
426
+ onBlur={handleBlur}
427
+ onClick={handleClick}
428
+ onContextMenu={handleContextMenu}
429
+ onFocus={handleFocus}
430
+ onMouseEnter={handleMouseEnter}
431
+ onMouseLeave={handleMouseLeave}
432
+ text="Hover me"
433
+ />
434
+ </Tooltip>,
435
+ )
436
+ })
437
+
438
+ afterEach(() => jest.clearAllMocks())
439
+
440
+ it('should fire the onBlur event', () => {
441
+ fireEvent.blur(screen.getByTestId('btn'))
442
+ expect(handleBlur).toHaveBeenCalledTimes(1)
443
+ })
444
+
445
+ it('should fire the onClick event', () => {
446
+ fireEvent.click(screen.getByTestId('btn'))
447
+ expect(handleClick).toHaveBeenCalledTimes(1)
448
+ })
449
+
450
+ it('should fire the onContextMenu event', () => {
451
+ fireEvent.contextMenu(screen.getByTestId('btn'))
452
+ expect(handleContextMenu).toHaveBeenCalledTimes(1)
453
+ })
454
+
455
+ it('should fire the onFocus event', () => {
456
+ fireEvent.focus(screen.getByTestId('btn'))
457
+ expect(handleFocus).toHaveBeenCalledTimes(1)
458
+ })
459
+
460
+ it('should fire the onMouseEnter event', () => {
461
+ fireEvent.mouseEnter(screen.getByTestId('btn'))
462
+ expect(handleMouseEnter).toHaveBeenCalledTimes(1)
463
+ })
464
+
465
+ it('should fire the onMouseLeave event', () => {
466
+ fireEvent.mouseLeave(screen.getByTestId('btn'))
467
+ expect(handleMouseLeave).toHaveBeenCalledTimes(1)
468
+ })
469
+ })
345
470
  })
@@ -27,7 +27,7 @@ import {useArrayProp, useForwardedRef} from '../../hooks'
27
27
  import {useDelayedState} from '../../hooks/useDelayedState'
28
28
  import {ThemeColorSchemeKey, useTheme} from '../../theme'
29
29
  import {Placement} from '../../types'
30
- import {Layer, LayerProps, Portal, useBoundaryElement} from '../../utils'
30
+ import {Layer, LayerProps, Portal, useBoundaryElement, usePortal} from '../../utils'
31
31
  import {Card} from '../card'
32
32
  import {Delay} from '../types'
33
33
  import {DEFAULT_FALLBACK_PLACEMENTS, DEFAULT_TOOLTIP_PADDING} from './constants'
@@ -60,11 +60,9 @@ export interface TooltipProps extends Omit<LayerProps, 'as'> {
60
60
  delay?: Delay
61
61
  }
62
62
 
63
- const Root = styled(Layer)`
63
+ const Root = styled(Layer)<{$maxWidth: number}>`
64
64
  pointer-events: none;
65
- // Note that 100vw doesn't exclude system scrollbars.
66
- // This should be sufficiently small enough to not trigger overflow-x scrollbars when portalled.
67
- max-width: calc(100vw - ${DEFAULT_TOOLTIP_PADDING * 10}px);
65
+ max-width: ${({$maxWidth}) => $maxWidth}px;
68
66
  `
69
67
 
70
68
  /**
@@ -85,7 +83,7 @@ export const Tooltip = forwardRef(function Tooltip(
85
83
  DEFAULT_FALLBACK_PLACEMENTS[props.placement ?? 'bottom'],
86
84
  padding,
87
85
  placement: placementProp = 'bottom',
88
- portal,
86
+ portal: portalProp,
89
87
  scheme,
90
88
  shadow = 2,
91
89
  zOffset = theme.sanity.layer?.tooltip.zOffset,
@@ -98,13 +96,29 @@ export const Tooltip = forwardRef(function Tooltip(
98
96
  const arrowRef = useRef<HTMLDivElement | null>(null)
99
97
  const rootBoundary: RootBoundary = 'viewport'
100
98
 
99
+ const portal = usePortal()
100
+ const portalElement =
101
+ typeof portalProp === 'string' ? portal.elements?.[portalProp] || null : portal.element
102
+
103
+ // Get the maximum tooltip width (sans tooltip padding)
104
+ // Tooltip width should never exceed the width of either any supplied boundary or portal element.
105
+ // If both portal and boundary elements are provided, use the smaller width of the two.
106
+ const tooltipWidth = useMemo(() => {
107
+ const availableWidths = [
108
+ ...(boundaryElement ? [boundaryElement.offsetWidth] : []),
109
+ portalElement?.offsetWidth || document.body.offsetWidth,
110
+ ]
111
+
112
+ return Math.min(...availableWidths) - DEFAULT_TOOLTIP_PADDING * 2
113
+ }, [boundaryElement, portalElement?.offsetWidth])
114
+
101
115
  const middleware = useMemo(() => {
102
116
  const ret: Middleware[] = []
103
117
 
104
118
  // Flip the floating element when leaving the boundary box
105
119
  ret.push(
106
120
  flip({
107
- boundary: portal ? undefined : boundaryElement || undefined,
121
+ boundary: boundaryElement || undefined,
108
122
  fallbackPlacements,
109
123
  padding: DEFAULT_TOOLTIP_PADDING,
110
124
  rootBoundary,
@@ -117,7 +131,7 @@ export const Tooltip = forwardRef(function Tooltip(
117
131
  // Shift the tooltip so its sits with the boundary element
118
132
  ret.push(
119
133
  shift({
120
- boundary: portal ? undefined : boundaryElement || undefined,
134
+ boundary: boundaryElement || undefined,
121
135
  rootBoundary,
122
136
  padding: DEFAULT_TOOLTIP_PADDING,
123
137
  }),
@@ -127,7 +141,7 @@ export const Tooltip = forwardRef(function Tooltip(
127
141
  ret.push(arrow({element: arrowRef, padding: 2}))
128
142
 
129
143
  return ret
130
- }, [boundaryElement, fallbackPlacements, portal])
144
+ }, [boundaryElement, fallbackPlacements])
131
145
 
132
146
  const {floatingStyles, placement, middlewareData, refs, update} = useFloating({
133
147
  middleware,
@@ -192,10 +206,48 @@ export const Tooltip = forwardRef(function Tooltip(
192
206
  [isInsideGroup, delayGroupContext, openDelay, tooltipId, closeDelay, setIsOpen],
193
207
  )
194
208
 
195
- const handleBlur = useCallback(() => handleIsOpenChange(false), [handleIsOpenChange])
196
- const handleFocus = useCallback(() => handleIsOpenChange(true), [handleIsOpenChange])
197
- const handleMouseEnter = useCallback(() => handleIsOpenChange(true), [handleIsOpenChange])
198
- const handleMouseLeave = useCallback(() => handleIsOpenChange(false), [handleIsOpenChange])
209
+ const handleBlur = useCallback(
210
+ (e: FocusEvent) => {
211
+ handleIsOpenChange(false)
212
+ childProp?.props?.onBlur?.(e)
213
+ },
214
+ [childProp?.props, handleIsOpenChange],
215
+ )
216
+ const handleClick = useCallback(
217
+ (e: MouseEvent) => {
218
+ handleIsOpenChange(false, true), [handleIsOpenChange]
219
+ childProp?.props.onClick?.(e)
220
+ },
221
+ [childProp?.props, handleIsOpenChange],
222
+ )
223
+ const handleContextMenu = useCallback(
224
+ (e: MouseEvent) => {
225
+ handleIsOpenChange(false, true), [handleIsOpenChange]
226
+ childProp?.props.onContextMenu?.(e)
227
+ },
228
+ [childProp?.props, handleIsOpenChange],
229
+ )
230
+ const handleFocus = useCallback(
231
+ (e: FocusEvent) => {
232
+ handleIsOpenChange(true)
233
+ childProp?.props?.onFocus?.(e)
234
+ },
235
+ [childProp?.props, handleIsOpenChange],
236
+ )
237
+ const handleMouseEnter = useCallback(
238
+ (e: MouseEvent) => {
239
+ handleIsOpenChange(true)
240
+ childProp?.props?.onMouseEnter?.(e)
241
+ },
242
+ [childProp?.props, handleIsOpenChange],
243
+ )
244
+ const handleMouseLeave = useCallback(
245
+ (e: MouseEvent) => {
246
+ handleIsOpenChange(false)
247
+ childProp?.props?.onMouseLeave?.(e)
248
+ },
249
+ [childProp?.props, handleIsOpenChange],
250
+ )
199
251
 
200
252
  // Detect whether the mouse is moving outside of the reference element. This is sometimes
201
253
  // necessary, because the tooltip might not always close as it should (e.g. when clicking
@@ -292,9 +344,20 @@ export const Tooltip = forwardRef(function Tooltip(
292
344
  onFocus: handleFocus,
293
345
  onMouseEnter: handleMouseEnter,
294
346
  onMouseLeave: handleMouseLeave,
347
+ onClick: handleClick,
348
+ onContextMenu: handleContextMenu,
295
349
  ref: setReference,
296
350
  })
297
- }, [childProp, handleBlur, handleFocus, handleMouseEnter, handleMouseLeave, setReference])
351
+ }, [
352
+ childProp,
353
+ handleBlur,
354
+ handleClick,
355
+ handleContextMenu,
356
+ handleFocus,
357
+ handleMouseEnter,
358
+ handleMouseLeave,
359
+ setReference,
360
+ ])
298
361
 
299
362
  if (!child) return <></>
300
363
 
@@ -307,6 +370,7 @@ export const Tooltip = forwardRef(function Tooltip(
307
370
  ref={setFloating}
308
371
  style={floatingStyles}
309
372
  zOffset={zOffset}
373
+ $maxWidth={tooltipWidth}
310
374
  >
311
375
  <Card
312
376
  data-ui="Tooltip__card"
@@ -328,8 +392,8 @@ export const Tooltip = forwardRef(function Tooltip(
328
392
 
329
393
  {showTooltip && (
330
394
  <>
331
- {portal ? (
332
- <Portal __unstable_name={typeof portal === 'string' ? portal : undefined}>
395
+ {portalProp ? (
396
+ <Portal __unstable_name={typeof portalProp === 'string' ? portalProp : undefined}>
333
397
  {root}
334
398
  </Portal>
335
399
  ) : (