Skip to content

Commit 30f705f

Browse files
committed
feat(ui-position): add available space to position
1 parent 5e7a355 commit 30f705f

4 files changed

Lines changed: 161 additions & 19 deletions

File tree

packages/ui-position/src/Position/__tests__/Position.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,4 +583,25 @@ describe('<Position />', () => {
583583
expect(style.display).toEqual('block')
584584
})
585585
})
586+
587+
it('writes --ui-position-available-{height,width} on the content node', async () => {
588+
render(
589+
<Position
590+
constrain="window"
591+
placement="bottom"
592+
renderTarget={<button data-testid="target">Target</button>}
593+
>
594+
<div data-testid="content">Content</div>
595+
</Position>
596+
)
597+
const content = screen.getByTestId('content')
598+
await waitFor(() => {
599+
expect(
600+
content.style.getPropertyValue('--ui-position-available-height')
601+
).not.toBe('')
602+
expect(
603+
content.style.getPropertyValue('--ui-position-available-width')
604+
).not.toBe('')
605+
})
606+
})
586607
})

packages/ui-position/src/Position/index.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,13 @@ class Position extends Component<PositionProps, PositionState> {
8080
constructor(props: PositionProps) {
8181
super(props)
8282

83+
const initial = this.calculatePosition(props)
84+
this._availableHeight = initial.availableHeight
85+
this._availableWidth = initial.availableWidth
8386
this.state = {
8487
positioned: false,
85-
...this.calculatePosition(props)
88+
placement: initial.placement,
89+
style: initial.style
8690
}
8791
this.position = debounce(this.position, 0, {
8892
leading: false,
@@ -98,6 +102,8 @@ class Position extends Component<PositionProps, PositionState> {
98102
_listener: PositionChangeListenerType | null = null
99103
_content?: PositionElement
100104
_target?: PositionElement
105+
_availableHeight?: number
106+
_availableWidth?: number
101107

102108
handleRef = (el: Element | null) => {
103109
const { elementRef } = this.props
@@ -220,6 +226,22 @@ class Position extends Component<PositionProps, PositionState> {
220226
)
221227
}
222228

229+
// Write `--ui-position-available-{height,width}` directly on the content
230+
// node's inline style
231+
applyAvailableSpaceCustomProperties() {
232+
const node = findDOMNode(this._content) as HTMLElement | null
233+
if (!node?.style) return
234+
const set = (name: string, value: number | undefined) => {
235+
if (typeof value === 'number' && Number.isFinite(value)) {
236+
node.style.setProperty(name, `${value}px`)
237+
} else {
238+
node.style.removeProperty(name)
239+
}
240+
}
241+
set('--ui-position-available-height', this._availableHeight)
242+
set('--ui-position-available-width', this._availableWidth)
243+
}
244+
223245
calculatePosition(props: PositionProps) {
224246
return calculateElementPosition(this._content, this._target, {
225247
placement: props.placement,
@@ -232,10 +254,12 @@ class Position extends Component<PositionProps, PositionState> {
232254
}
233255

234256
position = () => {
235-
this.setState({
236-
positioned: true,
237-
...this.calculatePosition(this.props)
238-
})
257+
const { placement, style, availableHeight, availableWidth } =
258+
this.calculatePosition(this.props)
259+
this._availableHeight = availableHeight
260+
this._availableWidth = availableWidth
261+
this.applyAvailableSpaceCustomProperties()
262+
this.setState({ positioned: true, placement, style })
239263
}
240264

241265
startTracking() {

packages/ui-position/src/PositionPropTypes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ export type ElementPosition = {
135135
}
136136
}
137137

138+
/**
139+
* The full output of `calculateElementPosition`, including the available
140+
* space numbers driving `--ui-position-available-{height,width}`. Kept
141+
* separate from `ElementPosition` so `PositionState` (which extends
142+
* `ElementPosition`) doesn't falsely advertise these fields
143+
*/
144+
export type ElementPositionWithAvailableSpace = ElementPosition & {
145+
availableHeight?: number
146+
availableWidth?: number
147+
}
148+
138149
export type PositionElement = UIElement
139150

140151
export type Offset<Type extends number | string | undefined = number> = {

packages/ui-position/src/calculateElementPosition.ts

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import {
3333
} from '@instructure/ui-dom-utils'
3434
import type { RectType } from '@instructure/ui-dom-utils'
3535
import { mirrorPlacement } from './mirrorPlacement'
36-
// @ts-expect-error will be needed for fix in the `offsetToPx` method
3736
import { px } from '@instructure/ui-utils'
3837

3938
import type {
@@ -42,6 +41,7 @@ import type {
4241
PositionConstraint,
4342
PositionMountNode,
4443
ElementPosition,
44+
ElementPositionWithAvailableSpace,
4545
PositionElement,
4646
Size,
4747
Overflow,
@@ -95,7 +95,7 @@ function calculateElementPosition(
9595
element?: PositionElement,
9696
target?: PositionElement,
9797
options: Options = {}
98-
): ElementPosition {
98+
): ElementPositionWithAvailableSpace {
9999
if (!element || options.placement === 'offscreen') {
100100
// hide offscreen content at the bottom of the DOM from screenreaders
101101
// unless content is contained somewhere else
@@ -113,10 +113,13 @@ function calculateElementPosition(
113113
}
114114

115115
const pos = new PositionData(element, target, options)
116+
const { height: availableHeight, width: availableWidth } = pos.availableSpace
116117

117118
return {
118119
placement: pos.placement,
119-
style: pos.style
120+
style: pos.style,
121+
availableHeight,
122+
availableWidth
120123
}
121124
}
122125

@@ -304,7 +307,7 @@ class PositionData {
304307
) {
305308
this.options = options || {}
306309

307-
const { container, constrain, placement, over } = this.options
310+
const { container, placement, over } = this.options
308311

309312
if (!element || placement === 'offscreen') return
310313

@@ -320,16 +323,9 @@ class PositionData {
320323
over ? this.element.placement : this.element.mirroredPlacement
321324
)
322325

323-
if (constrain === 'window') {
324-
this.constrainTo(ownerWindow(element))
325-
} else if (constrain === 'scroll-parent') {
326-
this.constrainTo(getScrollParents(this.target.node)[0])
327-
} else if (constrain === 'parent') {
328-
this.constrainTo(this.container)
329-
} else if (typeof constrain === 'function') {
330-
this.constrainTo(findDOMNode(constrain.call(null)))
331-
} else if (typeof constrain === 'object') {
332-
this.constrainTo(findDOMNode(constrain))
326+
const constraintNode = this.resolveConstraintNode()
327+
if (constraintNode) {
328+
this.constrainTo(constraintNode)
333329
}
334330
}
335331

@@ -543,6 +539,96 @@ class PositionData {
543539
}
544540
}
545541
}
542+
543+
// Resolves the `constrain` option (`'window'`, `'scroll-parent'`,
544+
// `'parent'`, a function, or an element) to the DOM node it points at.
545+
resolveConstraintNode(): Node | Window | null {
546+
const { constrain } = this.options
547+
const elementNode = this.element?.node
548+
if (!elementNode) return null
549+
550+
if (constrain === 'window') return ownerWindow(elementNode) ?? null
551+
if (constrain === 'scroll-parent') {
552+
return getScrollParents(this.target?.node)[0] ?? null
553+
}
554+
if (constrain === 'parent') return findDOMNode(this.container) ?? null
555+
if (typeof constrain === 'function') {
556+
return findDOMNode(constrain.call(null)) ?? null
557+
}
558+
if (typeof constrain === 'object' && constrain) {
559+
return findDOMNode(constrain) ?? null
560+
}
561+
return null
562+
}
563+
564+
/**
565+
* Maximum height/width (in CSS px) the positioned element can occupy in
566+
* the resolved placement before crossing the constraint. Drives the
567+
* `--ui-position-available-{height,width}` CSS variables
568+
*/
569+
get availableSpace(): { height: number; width: number } {
570+
const targetNode = this.target?.node as Element | undefined
571+
const constraintNode = this.resolveConstraintNode()
572+
if (
573+
!this.element ||
574+
!targetNode?.getBoundingClientRect ||
575+
!constraintNode
576+
) {
577+
return { height: Infinity, width: Infinity }
578+
}
579+
580+
const targetRect = targetNode.getBoundingClientRect()
581+
let constraintRect: RectType
582+
if ('getBoundingClientRect' in constraintNode) {
583+
constraintRect = (constraintNode as Element).getBoundingClientRect()
584+
} else {
585+
const win =
586+
'defaultView' in constraintNode
587+
? (constraintNode as Document).defaultView
588+
: (constraintNode as Window)
589+
if (!win) return { height: Infinity, width: Infinity }
590+
constraintRect = {
591+
top: 0,
592+
left: 0,
593+
right: win.innerWidth,
594+
bottom: win.innerHeight,
595+
width: win.innerWidth,
596+
height: win.innerHeight
597+
}
598+
}
599+
600+
// `offsetX` / `offsetY` always push the popover *away* from the trigger
601+
// so on the primary axis they consume available space.
602+
const elementNode = this.element.node
603+
const offsetY = px(this.element._offset.top, elementNode)
604+
const offsetX = px(this.element._offset.left, elementNode)
605+
const [primary] = this.element.placement
606+
607+
let height: number
608+
if (primary === 'bottom') {
609+
height = constraintRect.bottom - targetRect.bottom - offsetY
610+
} else if (primary === 'top') {
611+
height = targetRect.top - constraintRect.top - offsetY
612+
} else {
613+
height = constraintRect.height
614+
}
615+
616+
let width: number
617+
if (primary === 'end') {
618+
width = constraintRect.right - targetRect.right - offsetX
619+
} else if (primary === 'start') {
620+
width = targetRect.left - constraintRect.left - offsetX
621+
} else {
622+
width = constraintRect.width
623+
}
624+
625+
// Floor at 16px so a consumer's `max-height` doesn't collapse to 0 in the
626+
// frame(s) before placement flips when the trigger sits right at the edge.
627+
return {
628+
height: Math.max(16, height),
629+
width: Math.max(16, width)
630+
}
631+
}
546632
}
547633

548634
function addOffsets(offsets: Offset[]) {

0 commit comments

Comments
 (0)