PluginProbe
Code Snippets / 4.0.0-beta.2
Code Snippets v4.0.0-beta.2
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / js / components / common / demo / DemoSpotlight.tsx

DemoSpotlight.tsx in Code Snippets 4.0.0-beta.2, at js/components/common/demo/DemoSpotlight.tsx

105 lines 2.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useCallback, useEffect, useState } from 'react'
2 import type { CSSProperties } from 'react'
3
4 interface Rect {
5 top: number
6 left: number
7 width: number
8 height: number
9 }
10
11 export interface DemoSpotlightProps {
12 /** CSS selector for the element to leave uncovered. */
13 target?: string
14 /** Breathing room left around the target, in pixels. */
15 padding?: number
16 }
17
18 const DEFAULT_PADDING = 8
19
20 /** Padding is applied to both sides of each axis. */
21 const BOTH_SIDES = 2
22
23 const measure = (target: string, padding: number): Rect | undefined => {
24 const element = document.querySelector(target)
25
26 if (!element) {
27 return undefined
28 }
29
30 const { top, left, width, height } = element.getBoundingClientRect()
31
32 return {
33 top: top - padding,
34 left: left - padding,
35 width: width + BOTH_SIDES * padding,
36 height: height + BOTH_SIDES * padding
37 }
38 }
39
40 /**
41 * Dims and blurs the page around one element, so the commentary has something
42 * unambiguous to point at.
43 *
44 * The cutout is made from four panels around the target rather than a masked
45 * overlay: the gap between them is the hole, which keeps the blur honest and
46 * avoids masking support becoming a rendering variable.
47 */
48 export const DemoSpotlight: React.FC<DemoSpotlightProps> = ({ target, padding = DEFAULT_PADDING }) => {
49 const [rect, setRect] = useState<Rect>()
50
51 const remeasure = useCallback(() => {
52 setRect(target ? measure(target, padding) : undefined)
53 }, [padding, target])
54
55 useEffect(() => {
56 remeasure()
57
58 if (!target) {
59 return
60 }
61
62 // The page scrolls and reflows underneath the spotlight, so the cutout
63 // is re-measured rather than positioned once.
64 const observer = new ResizeObserver(remeasure)
65 observer.observe(document.body)
66 window.addEventListener('scroll', remeasure, { passive: true })
67 window.addEventListener('resize', remeasure)
68
69 return () => {
70 observer.disconnect()
71 window.removeEventListener('scroll', remeasure)
72 window.removeEventListener('resize', remeasure)
73 }
74 }, [remeasure, target])
75
76 if (!rect) {
77 return null
78 }
79
80 const { top, left, width, height } = rect
81
82 const panels: CSSProperties[] = [
83 { insetBlockStart: 0, insetInlineStart: 0, inlineSize: '100%', blockSize: `${Math.max(0, top)}px` },
84 { insetBlockStart: `${top + height}px`, insetInlineStart: 0, inlineSize: '100%', insetBlockEnd: 0 },
85 { insetBlockStart: `${top}px`, insetInlineStart: 0, inlineSize: `${Math.max(0, left)}px`, blockSize: `${height}px` },
86 { insetBlockStart: `${top}px`, insetInlineStart: `${left + width}px`, insetInlineEnd: 0, blockSize: `${height}px` }
87 ]
88
89 const ring: CSSProperties = {
90 insetBlockStart: `${top}px`,
91 insetInlineStart: `${left}px`,
92 inlineSize: `${width}px`,
93 blockSize: `${height}px`
94 }
95
96 return (
97 <div className="demo-spotlight" aria-hidden="true">
98 {panels.map((style, index) =>
99 <div key={index} className="demo-spotlight__panel" style={style} />)}
100
101 <div className="demo-spotlight__ring" style={ring} />
102 </div>
103 )
104 }
105