@@ -33,7 +33,6 @@ import {
3333} from '@instructure/ui-dom-utils'
3434import type { RectType } from '@instructure/ui-dom-utils'
3535import { mirrorPlacement } from './mirrorPlacement'
36- // @ts -expect-error will be needed for fix in the `offsetToPx` method
3736import { px } from '@instructure/ui-utils'
3837
3938import 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
548634function addOffsets ( offsets : Offset [ ] ) {
0 commit comments