Skip to content

Commit 3d8fc89

Browse files
authored
feat: Expand selection to parent elements when adding text assertions (#875)
1 parent 56893f4 commit 3d8fc89

File tree

11 files changed

+205
-266
lines changed

11 files changed

+205
-266
lines changed

extension/src/frontend/view/ElementInspector/ElementInspector.hooks.ts

Lines changed: 15 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,12 @@
1-
import { last } from 'lodash-es'
2-
import { useCallback, useEffect, useMemo, useState } from 'react'
1+
import { useCallback, useEffect, useState } from 'react'
32

4-
import { ElementSelector } from '@/schemas/recording'
5-
import { uuid } from '@/utils/uuid'
6-
import { ElementRole, getElementRoles } from 'extension/src/utils/aria'
7-
8-
import { generateSelectors } from '../../../selectors'
93
import { useGlobalClass } from '../GlobalStyles'
104
import { useHighlightDebounce } from '../hooks/useHighlightDebounce'
115
import { usePreventClick } from '../hooks/usePreventClick'
126
import { Bounds, Position } from '../types'
13-
import { getElementBounds } from '../utils'
14-
15-
function* getAncestors(element: Element) {
16-
let current: Element | null = element.parentElement
17-
18-
while (current !== null) {
19-
yield current
20-
21-
if (current === document.documentElement) {
22-
break
23-
}
24-
25-
current = current.parentElement
26-
}
27-
}
28-
29-
function findLabelFor(label: HTMLLabelElement): Element | null {
30-
const id = label.getAttribute('for')
31-
32-
if (id === null) {
33-
return null
34-
}
35-
36-
return document.getElementById(id)
37-
}
387

39-
function findInChildren(label: HTMLLabelElement): Element | null {
40-
return label.querySelector('input, select, textarea')
41-
}
42-
43-
function findLabelledBy(label: HTMLLabelElement): Element | null {
44-
if (label.id === '') {
45-
return null
46-
}
47-
48-
return document.querySelector(`[aria-labelledby="${label.id}"]`)
49-
}
50-
51-
export function findRelatedInput(element: Element): TrackedElement | null {
52-
const label = [...getAncestors(element)].find(
53-
(ancestor) => ancestor instanceof HTMLLabelElement
54-
)
55-
56-
if (label === undefined) {
57-
return null
58-
}
59-
60-
const input =
61-
findLabelFor(label) ?? findInChildren(label) ?? findLabelledBy(label)
62-
63-
if (input === null) {
64-
return null
65-
}
66-
67-
return toTrackedElement(input)
68-
}
69-
70-
export interface TrackedElement {
71-
id: string
72-
roles: ElementRole[]
73-
selector: ElementSelector
74-
target: Element
75-
bounds: Bounds
76-
}
77-
78-
function toTrackedElement(element: Element): TrackedElement {
79-
const roles = getElementRoles(element)
80-
81-
return {
82-
id: uuid(),
83-
roles: [...roles],
84-
selector: generateSelectors(element),
85-
target: element,
86-
bounds: getElementBounds(element),
87-
}
88-
}
8+
import { usePinnedElement } from './hooks'
9+
import { toTrackedElement, TrackedElement } from './utils'
8910

9011
function isInsideBounds(
9112
position: Position,
@@ -118,9 +39,10 @@ export function useInspectedElement() {
11839
* the selection and removed when the user contracts the selection. If the
11940
* stack is empty, then no element is pinned.
12041
*/
121-
const [pinnedEl, setPinnedElement] = useState<TrackedElement[]>([])
12242
const [hoveredEl, setHoveredEl] = useState<TrackedElement | null>(null)
12343

44+
const { selected, pinned, pin, unpin, expand, contract } = usePinnedElement()
45+
12446
useGlobalClass('inspecting')
12547

12648
useEffect(() => {
@@ -155,23 +77,23 @@ export function useInspectedElement() {
15577
}
15678
}, [])
15779

158-
const unpin = useCallback(() => {
80+
const reset = useCallback(() => {
15981
setMousePosition({
16082
top: 0,
16183
left: 0,
16284
})
16385

164-
setPinnedElement([])
165-
}, [])
86+
unpin()
87+
}, [unpin])
16688

16789
usePreventClick({
16890
callback: (ev) => {
16991
if (hoveredEl === null) {
17092
return
17193
}
17294

173-
if (pinnedEl.length > 0) {
174-
unpin()
95+
if (pinned !== null) {
96+
reset()
17597

17698
return
17799
}
@@ -191,53 +113,18 @@ export function useInspectedElement() {
191113
}
192114

193115
setMousePosition(position)
194-
setPinnedElement([hoveredEl])
116+
pin(hoveredEl)
195117
},
196-
dependencies: [pinnedEl, hoveredEl, unpin],
118+
dependencies: [pinned, hoveredEl, unpin],
197119
})
198120

199-
const expand = useMemo(() => {
200-
const [head] = pinnedEl
201-
202-
if (head === undefined) {
203-
return undefined
204-
}
205-
206-
const parent = head.target.parentElement
207-
208-
if (parent === null || parent === document.documentElement) {
209-
return undefined
210-
}
211-
212-
return () => {
213-
setPinnedElement((pinned) => {
214-
return [toTrackedElement(parent), ...pinned]
215-
})
216-
}
217-
}, [pinnedEl])
218-
219-
const contract = useMemo(() => {
220-
const [head, ...tail] = pinnedEl
221-
222-
// If head is undefined, that means no element is pinned. If tail is
223-
// empty, that means we're back at the intial element. In either case
224-
// we can't decrease the selection any further.
225-
if (head === undefined || tail.length === 0) {
226-
return undefined
227-
}
228-
229-
return () => {
230-
setPinnedElement(tail)
231-
}
232-
}, [pinnedEl])
233-
234121
const highlightedEl = useHighlightDebounce(hoveredEl)
235122

236123
return {
237-
pinned: last(pinnedEl) ?? null,
238-
element: pinnedEl[0] ?? highlightedEl,
124+
pinned,
125+
element: selected ?? highlightedEl,
239126
mousePosition,
240-
unpin,
127+
reset,
241128
expand,
242129
contract,
243130
}

extension/src/frontend/view/ElementInspector/ElementInspector.tsx

Lines changed: 8 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { css } from '@emotion/react'
22
import { upperFirst } from 'lodash-es'
3-
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
4-
import { useEffect, useState } from 'react'
3+
import { useState } from 'react'
54

6-
import { Flex } from '@/components/primitives/Flex'
7-
import { IconButton } from '@/components/primitives/IconButton'
85
import { Tooltip } from '@/components/primitives/Tooltip'
96
import { uuid } from '@/utils/uuid'
107
import { ElementRole } from 'extension/src/utils/aria'
@@ -20,6 +17,7 @@ import { ElementMenu } from './ElementMenu'
2017
import { ElementPopover } from './ElementPopover'
2118
import { AssertionEditor } from './assertions/AssertionEditor'
2219
import { AssertionData } from './assertions/types'
20+
import { useElementHighlight } from './hooks'
2321

2422
function getHeader(assertion: AssertionData | null) {
2523
switch (assertion?.type) {
@@ -50,40 +48,6 @@ function getHeader(assertion: AssertionData | null) {
5048
}
5149
}
5250

53-
interface ElementSelectorProps {
54-
selector: string
55-
onExpand?: () => void
56-
onContract?: () => void
57-
}
58-
59-
function ElementSelector({
60-
selector,
61-
onExpand,
62-
onContract,
63-
}: ElementSelectorProps) {
64-
return (
65-
<Flex align="center" gap="1">
66-
<Tooltip asChild content="Select parent element">
67-
<IconButton disabled={onExpand === undefined} onClick={onExpand}>
68-
<ChevronLeftIcon />
69-
</IconButton>
70-
</Tooltip>
71-
<ElementPopover.Heading
72-
css={css`
73-
flex: 1 1 0;
74-
`}
75-
>
76-
{selector}
77-
</ElementPopover.Heading>
78-
<Tooltip asChild content="Select child element">
79-
<IconButton disabled={onContract === undefined} onClick={onContract}>
80-
<ChevronRightIcon />
81-
</IconButton>
82-
</Tooltip>
83-
</Flex>
84-
)
85-
}
86-
8751
function formatRoles(roles: ElementRole[]) {
8852
if (roles.length === 0) {
8953
return 'None'
@@ -97,40 +61,23 @@ interface ElementInspectorProps {
9761
}
9862

9963
export function ElementInspector({ onClose }: ElementInspectorProps) {
100-
const { pinned, element, mousePosition, unpin, expand, contract } =
64+
const { pinned, element, mousePosition, reset, expand, contract } =
10165
useInspectedElement()
10266

10367
const [assertion, setAssertion] = useState<AssertionData | null>(null)
10468

69+
useElementHighlight(element)
70+
10571
useEscape(() => {
10672
if (pinned) {
107-
unpin()
73+
reset()
10874

10975
return
11076
}
11177

11278
onClose()
11379
}, [pinned, onClose])
11480

115-
useEffect(() => {
116-
client.send({
117-
type: 'highlight-elements',
118-
selector: element && {
119-
type: 'css',
120-
selector: element.selector.css,
121-
},
122-
})
123-
}, [element])
124-
125-
useEffect(() => {
126-
return () => {
127-
client.send({
128-
type: 'highlight-elements',
129-
selector: null,
130-
})
131-
}
132-
}, [])
133-
13481
const handleOpenChange = () => {
13582
setAssertion(null)
13683
}
@@ -201,8 +148,8 @@ export function ElementInspector({ onClose }: ElementInspectorProps) {
201148
anchor={<Anchor position={mousePosition} />}
202149
header={
203150
getHeader(assertion) ?? (
204-
<ElementSelector
205-
selector={element.selector.css}
151+
<ElementPopover.Selector
152+
element={element}
206153
onContract={contract}
207154
onExpand={expand}
208155
/>

extension/src/frontend/view/ElementInspector/ElementMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { ComponentProps, ReactNode } from 'react'
1111
import { Toolbar } from '@/components/primitives/Toolbar'
1212
import { ElementRole } from 'extension/src/utils/aria'
1313

14-
import { TrackedElement } from './ElementInspector.hooks'
1514
import {
1615
findAssociatedControl,
1716
getCheckedState,
@@ -20,6 +19,7 @@ import {
2019
getTextBoxValue,
2120
} from './ElementMenu.utils'
2221
import { AssertionData, CheckAssertionData } from './assertions/types'
22+
import { TrackedElement } from './utils'
2323

2424
function ToolbarRoot(props: ComponentProps<typeof Toolbar.Root>) {
2525
return (

extension/src/frontend/view/ElementInspector/ElementMenu.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { generateSelectors } from 'extension/src/selectors'
33
import { ElementRole, getElementRoles } from 'extension/src/utils/aria'
44
import { findAssociatedElement } from 'extension/src/utils/dom'
55

6-
import { TrackedElement } from './ElementInspector.hooks'
76
import { CheckAssertionData } from './assertions/types'
7+
import { TrackedElement } from './utils'
88

99
function* getAncestors(element: Element) {
1010
let currentElement: Element | null = element

0 commit comments

Comments
 (0)