Merge pull request #16717 from desktop/floating-ui-autocomplete

Reimplement Popover with floating-ui
This commit is contained in:
Sergio Padrino 2023-05-23 11:09:41 +02:00 committed by GitHub
commit d1016cd03c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 558 additions and 472 deletions

View file

@ -17,6 +17,7 @@
},
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@github/alive-client": "^0.0.2",
"app-path": "^3.3.0",
"byline": "^5.0.0",

View file

@ -8,17 +8,21 @@ import {
import { IAutocompletionProvider } from './index'
import { fatalError } from '../../lib/fatal-error'
import classNames from 'classnames'
import getCaretCoordinates from 'textarea-caret'
import { showContextualMenu } from '../../lib/menu-item'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
interface IRange {
readonly start: number
readonly length: number
}
import getCaretCoordinates from 'textarea-caret'
import { showContextualMenu } from '../../lib/menu-item'
import { AriaLiveContainer } from '../accessibility/aria-live-container'
import { createUniqueId, releaseUniqueId } from '../lib/id-pool'
interface IAutocompletingTextInputProps<ElementType, AutocompleteItemType> {
/**
* An optional className to be applied to the rendered
@ -120,6 +124,9 @@ interface IAutocompletingTextInputState<T> {
*/
readonly autocompletionState: IAutocompletionState<T> | null
/** Coordinates of the caret in the input/textarea element */
readonly caretCoordinates: ReturnType<typeof getCaretCoordinates> | null
/**
* An automatically generated id for the text element used to reference
* it from the label element. This is generated once via the id pool when the
@ -145,6 +152,7 @@ export abstract class AutocompletingTextInput<
IAutocompletingTextInputState<AutocompleteItemType>
> {
private element: ElementType | null = null
private invisibleCaretRef = React.createRef<HTMLDivElement>()
private shouldForceAriaLiveMessage = false
/** The identifier for each autocompletion request. */
@ -163,6 +171,7 @@ export abstract class AutocompletingTextInput<
this.state = {
autocompletionState: null,
caretCoordinates: null,
}
}
@ -227,45 +236,10 @@ export abstract class AutocompletingTextInput<
return null
}
const element = this.element!
let coordinates = getCaretCoordinates(element, state.range.start)
coordinates = {
...coordinates,
top: coordinates.top - element.scrollTop,
left: coordinates.left - element.scrollLeft,
}
const rect = element.getBoundingClientRect()
const popupAbsoluteTop = rect.top + coordinates.top
const popupAbsoluteLeft = rect.left + coordinates.left
const left = popupAbsoluteLeft
const selectedRow = state.selectedItem
? items.indexOf(state.selectedItem)
: -1
// The maximum height we can use for the popup without it extending beyond
// the Window bounds.
let maxHeight: number
let belowElement: boolean = true
if (
element.ownerDocument !== null &&
element.ownerDocument.defaultView !== null
) {
const windowHeight = element.ownerDocument.defaultView.innerHeight
const spaceToBottomOfWindow = windowHeight - popupAbsoluteTop - YOffset
if (
spaceToBottomOfWindow < DefaultPopupHeight &&
popupAbsoluteTop >= DefaultPopupHeight
) {
maxHeight = DefaultPopupHeight
belowElement = false
} else {
maxHeight = Math.min(DefaultPopupHeight, spaceToBottomOfWindow)
}
} else {
maxHeight = DefaultPopupHeight
}
// The height needed to accommodate all the matched items without overflowing
//
// Magic number warning! The autocompletion-popup container adds a border
@ -273,8 +247,7 @@ export abstract class AutocompletingTextInput<
// without overflowing and triggering the scrollbar.
const noOverflowItemHeight = RowHeight * items.length
const height = Math.min(noOverflowItemHeight, maxHeight)
const top = popupAbsoluteTop + (belowElement ? YOffset + 1 : -height)
const minHeight = RowHeight * Math.min(items.length, 3)
// Use the completion text as invalidation props so that highlighting
// will update as you type even though the number of items matched
@ -286,7 +259,15 @@ export abstract class AutocompletingTextInput<
const className = classNames('autocompletion-popup', state.provider.kind)
return (
<div className={className} style={{ top, left, height }}>
<Popover
anchor={this.invisibleCaretRef.current}
anchorPosition={PopoverAnchorPosition.BottomLeft}
decoration={PopoverDecoration.None}
maxHeight={Math.min(DefaultPopupHeight, noOverflowItemHeight)}
minHeight={minHeight}
trapFocus={false}
className={className}
>
<List
accessibleListId={this.state.autocompleteContainerId}
ref={this.onAutocompletionListRef}
@ -301,7 +282,7 @@ export abstract class AutocompletingTextInput<
onSelectedRowChanged={this.onSelectedRowChanged}
invalidationProps={searchText}
/>
</div>
</Popover>
)
}
@ -442,6 +423,53 @@ export abstract class AutocompletingTextInput<
)
}
private updateCaretCoordinates = () => {
const element = this.element
if (!element) {
this.setState({ caretCoordinates: null })
return
}
const selectionEnd = element.selectionEnd
if (selectionEnd === null) {
this.setState({ caretCoordinates: null })
return
}
const caretCoordinates = getCaretCoordinates(element, selectionEnd)
this.setState({
caretCoordinates: {
top: caretCoordinates.top - element.scrollTop,
left: caretCoordinates.left - element.scrollLeft,
height: caretCoordinates.height,
},
})
}
private renderInvisibleCaret = () => {
const { caretCoordinates } = this.state
if (!caretCoordinates) {
return null
}
return (
<div
style={{
backgroundColor: 'transparent',
width: 2,
height: YOffset,
position: 'absolute',
left: caretCoordinates.left,
top: caretCoordinates.top,
}}
ref={this.invisibleCaretRef}
>
&nbsp;
</div>
)
}
private onBlur = (e: React.FocusEvent<ElementType>) => {
this.close()
}
@ -458,6 +486,7 @@ export abstract class AutocompletingTextInput<
private onRef = (ref: ElementType | null) => {
this.element = ref
this.updateCaretCoordinates()
if (this.props.onElementRef) {
this.props.onElementRef(ref)
}
@ -500,6 +529,7 @@ export abstract class AutocompletingTextInput<
</label>
)}
{this.renderTextInput()}
{this.renderInvisibleCaret()}
<AriaLiveContainer shouldForceChange={shouldForceAriaLiveMessage}>
{autoCompleteItems.length > 0 ? suggestionsMessage : ''}
</AriaLiveContainer>
@ -693,6 +723,8 @@ export abstract class AutocompletingTextInput<
this.props.onValueChanged(str)
}
this.updateCaretCoordinates()
return this.open(str)
}

View file

@ -14,6 +14,8 @@ interface IPullRequestBadgeProps {
/** The GitHub repository to use when looking up commit status. */
readonly repository: GitHubRepository
readonly onBadgeRef?: (ref: HTMLDivElement | null) => void
/** The GitHub repository to use when looking up commit status. */
readonly onBadgeClick?: () => void
@ -57,6 +59,7 @@ export class PullRequestBadge extends React.Component<
private onRef = (badgeRef: HTMLDivElement) => {
this.badgeRef = badgeRef
this.props.onBadgeRef?.(badgeRef)
}
private onBadgeClick = (

View file

@ -2,7 +2,11 @@ import React from 'react'
import { Select } from '../lib/select'
import { Button } from '../lib/button'
import { Row } from '../lib/row'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
import { IAvatarUser } from '../../models/avatar'
import { Avatar } from '../lib/avatar'
import { Octicon } from '../octicons'
@ -71,7 +75,7 @@ export class CommitMessageAvatar extends React.Component<
ICommitMessageAvatarState
> {
private avatarButtonRef: HTMLButtonElement | null = null
private popoverRef = React.createRef<Popover>()
private warningBadgeRef = React.createRef<HTMLDivElement>()
public constructor(props: ICommitMessageAvatarProps) {
super(props)
@ -138,7 +142,7 @@ export class CommitMessageAvatar extends React.Component<
private renderWarningBadge() {
return (
<div className="warning-badge">
<div className="warning-badge" ref={this.warningBadgeRef}>
<Octicon symbol={OcticonSymbol.alert} />
</div>
)
@ -212,28 +216,6 @@ export class CommitMessageAvatar extends React.Component<
)
}
private getPopoverPosition(): React.CSSProperties | undefined {
if (!this.avatarButtonRef) {
return
}
const defaultPopoverHeight = this.props.warningBadgeVisible
? 278
: this.state.isGitConfigLocal
? 208
: 238
const popoverHeight =
this.popoverRef.current?.containerDivRef.current?.clientHeight ??
defaultPopoverHeight
const buttonHeight = this.avatarButtonRef.clientHeight
const buttonWidth = this.avatarButtonRef.clientWidth
const rect = this.avatarButtonRef.getBoundingClientRect()
const top = rect.top - popoverHeight + buttonHeight / 2
const left = rect.left + buttonWidth / 2
return { top, left }
}
private renderMisattributedCommitPopover() {
const accountTypeSuffix = this.props.isEnterpriseAccount
? ' Enterprise'
@ -320,11 +302,15 @@ export class CommitMessageAvatar extends React.Component<
return (
<Popover
caretPosition={PopoverCaretPosition.LeftBottom}
anchor={
warningBadgeVisible
? this.warningBadgeRef.current
: this.avatarButtonRef
}
anchorPosition={PopoverAnchorPosition.RightBottom}
decoration={PopoverDecoration.Balloon}
onClickOutside={this.closePopover}
ariaLabelledby="misattributed-commit-popover-header"
style={this.getPopoverPosition()}
ref={this.popoverRef}
>
<h3 id="commit-avatar-popover-header">
{warningBadgeVisible

View file

@ -12,7 +12,11 @@ import {
} from '../../lib/ci-checks/ci-checks'
import { Octicon, syncClockwise } from '../octicons'
import { APICheckConclusion, IAPIWorkflowJobStep } from '../../lib/api'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
import { CICheckRunList } from './ci-check-run-list'
import { encodePathAsUrl } from '../../lib/path'
import { PopupType } from '../../models/popup'
@ -42,9 +46,7 @@ interface ICICheckRunPopoverProps {
/** The pull request's number. */
readonly prNumber: number
/** The bottom of the pull request badge so we can position popover relative
* to it. */
readonly badgeBottom: number
readonly anchor: HTMLElement | null
/** Callback for when popover closes */
readonly closePopover: (event?: MouseEvent) => void
@ -207,20 +209,6 @@ export class CICheckRunPopover extends React.PureComponent<
})
}
private getPopoverPositioningStyles = (): React.CSSProperties => {
const top = this.props.badgeBottom + 10
return { top }
}
private getListHeightStyles = (): React.CSSProperties => {
const headerHeight = 55
return {
maxHeight: `${
window.innerHeight - (this.props.badgeBottom + headerHeight + 20)
}px`,
}
}
private renderRerunButton = () => {
const { checkRuns } = this.state
if (!supportsRerunningChecks(this.props.repository.endpoint)) {
@ -380,10 +368,7 @@ export class CICheckRunPopover extends React.PureComponent<
}
return (
<div
className="ci-check-run-list-container"
style={this.getListHeightStyles()}
>
<div className="ci-check-run-list-container">
<CICheckRunList
checkRuns={checkRuns}
loadingActionLogs={loadingActionLogs}
@ -406,13 +391,16 @@ export class CICheckRunPopover extends React.PureComponent<
return (
<div className="ci-check-list-popover">
<Popover
anchor={this.props.anchor}
anchorPosition={PopoverAnchorPosition.Bottom}
decoration={PopoverDecoration.Balloon}
ariaLabelledby="ci-check-run-header"
caretPosition={PopoverCaretPosition.Top}
onClickOutside={this.props.closePopover}
style={this.getPopoverPositioningStyles()}
>
{this.renderHeader()}
{this.renderList()}
<div className="ci-check-run-list-wrapper">
{this.renderHeader()}
{this.renderList()}
</div>
</Popover>
</div>
)

View file

@ -3,7 +3,11 @@ import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { RadioButton } from '../lib/radio-button'
import { Popover, PopoverCaretPosition } from '../lib/popover'
import {
Popover,
PopoverAnchorPosition,
PopoverDecoration,
} from '../lib/popover'
interface IDiffOptionsProps {
readonly isInteractiveDiff: boolean
@ -28,6 +32,7 @@ export class DiffOptions extends React.Component<
IDiffOptionsState
> {
private diffOptionsRef = React.createRef<HTMLDivElement>()
private gearIconRef = React.createRef<HTMLSpanElement>()
public constructor(props: IDiffOptionsProps) {
super(props)
@ -77,7 +82,9 @@ export class DiffOptions extends React.Component<
return (
<div className="diff-options-component" ref={this.diffOptionsRef}>
<button onClick={this.onButtonClick}>
<Octicon symbol={OcticonSymbol.gear} />
<span ref={this.gearIconRef}>
<Octicon symbol={OcticonSymbol.gear} />
</span>
<Octicon symbol={OcticonSymbol.triangleDown} />
</button>
{this.state.isPopoverOpen && this.renderPopover()}
@ -89,7 +96,9 @@ export class DiffOptions extends React.Component<
return (
<Popover
ariaLabelledby="diff-options-popover-header"
caretPosition={PopoverCaretPosition.TopRight}
anchor={this.gearIconRef.current}
anchorPosition={PopoverAnchorPosition.BottomRight}
decoration={PopoverDecoration.Balloon}
onClickOutside={this.closePopover}
>
<h3 id="diff-options-popover-header">

View file

@ -15,7 +15,7 @@ import { narrowNoNewlineSymbol } from './text-diff'
import { shallowEquals, structuralEquals } from '../../lib/equality'
import { DiffHunkExpansionType } from '../../models/diff'
import { DiffExpansionKind } from './text-diff-expansion'
import { PopoverCaretPosition } from '../lib/popover'
import { PopoverAnchorPosition } from '../lib/popover'
import { WhitespaceHintPopover } from './whitespace-hint-popover'
import { TooltippedContent } from '../lib/tooltipped-content'
import { TooltipDirection } from '../lib/tooltip'
@ -173,7 +173,10 @@ export class SideBySideDiffRow extends React.Component<
return (
<div className="row context">
<div className="before">
{this.renderLineNumbers([beforeLineNumber, afterLineNumber])}
{this.renderLineNumbers(
[beforeLineNumber, afterLineNumber],
undefined
)}
{this.renderContentFromString(row.content, row.beforeTokens)}
</div>
</div>
@ -183,11 +186,11 @@ export class SideBySideDiffRow extends React.Component<
return (
<div className="row context">
<div className="before">
{this.renderLineNumber(beforeLineNumber)}
{this.renderLineNumber(beforeLineNumber, DiffColumn.Before)}
{this.renderContentFromString(row.content, row.beforeTokens)}
</div>
<div className="after">
{this.renderLineNumber(afterLineNumber)}
{this.renderLineNumber(afterLineNumber, DiffColumn.After)}
{this.renderContentFromString(row.content, row.afterTokens)}
</div>
</div>
@ -202,7 +205,11 @@ export class SideBySideDiffRow extends React.Component<
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={afterClasses}>
{this.renderLineNumbers([undefined, lineNumber], isSelected)}
{this.renderLineNumbers(
[undefined, lineNumber],
DiffColumn.After,
isSelected
)}
{this.renderHunkHandle()}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
@ -214,12 +221,12 @@ export class SideBySideDiffRow extends React.Component<
return (
<div className="row added" onMouseEnter={this.onMouseEnterLineNumber}>
<div className={beforeClasses}>
{this.renderLineNumber()}
{this.renderLineNumber(undefined, DiffColumn.Before)}
{this.renderContentFromString('')}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
<div className={afterClasses}>
{this.renderLineNumber(lineNumber, isSelected)}
{this.renderLineNumber(lineNumber, DiffColumn.After, isSelected)}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
</div>
@ -236,7 +243,11 @@ export class SideBySideDiffRow extends React.Component<
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={beforeClasses}>
{this.renderLineNumbers([lineNumber, undefined], isSelected)}
{this.renderLineNumbers(
[lineNumber, undefined],
DiffColumn.Before,
isSelected
)}
{this.renderHunkHandle()}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
@ -251,12 +262,12 @@ export class SideBySideDiffRow extends React.Component<
onMouseEnter={this.onMouseEnterLineNumber}
>
<div className={beforeClasses}>
{this.renderLineNumber(lineNumber, isSelected)}
{this.renderLineNumber(lineNumber, DiffColumn.Before, isSelected)}
{this.renderContent(row.data)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
<div className={afterClasses}>
{this.renderLineNumber()}
{this.renderLineNumber(undefined, DiffColumn.After)}
{this.renderContentFromString('')}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
</div>
@ -272,7 +283,11 @@ export class SideBySideDiffRow extends React.Component<
className={beforeClasses}
onMouseEnter={this.onMouseEnterLineNumber}
>
{this.renderLineNumber(before.lineNumber, before.isSelected)}
{this.renderLineNumber(
before.lineNumber,
DiffColumn.Before,
before.isSelected
)}
{this.renderContent(before)}
{this.renderWhitespaceHintPopover(DiffColumn.Before)}
</div>
@ -280,7 +295,11 @@ export class SideBySideDiffRow extends React.Component<
className={afterClasses}
onMouseEnter={this.onMouseEnterLineNumber}
>
{this.renderLineNumber(after.lineNumber, after.isSelected)}
{this.renderLineNumber(
after.lineNumber,
DiffColumn.After,
after.isSelected
)}
{this.renderContent(after)}
{this.renderWhitespaceHintPopover(DiffColumn.After)}
</div>
@ -455,21 +474,33 @@ export class SideBySideDiffRow extends React.Component<
)
}
private getLineNumbersContainerID(column: DiffColumn) {
return `line-numbers-${this.props.numRow}-${column}`
}
/**
* Renders the line number box.
*
* @param lineNumbers Array with line numbers to display.
* @param column Column to which the line number belongs.
* @param isSelected Whether the line has been selected.
* If undefined is passed, the line is treated
* as non-selectable.
*/
private renderLineNumbers(
lineNumbers: Array<number | undefined>,
column: DiffColumn | undefined,
isSelected?: boolean
) {
const wrapperID =
column === undefined ? undefined : this.getLineNumbersContainerID(column)
if (!this.props.isDiffSelectable || isSelected === undefined) {
return (
<div className="line-number" style={{ width: this.lineGutterWidth }}>
<div
id={wrapperID}
className="line-number"
style={{ width: this.lineGutterWidth }}
>
{lineNumbers.map((lineNumber, index) => (
<span key={index}>{lineNumber}</span>
))}
@ -480,6 +511,7 @@ export class SideBySideDiffRow extends React.Component<
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
id={wrapperID}
className={classNames('line-number', 'selectable', 'hoverable', {
'line-selected': isSelected,
hover: this.props.isHunkHovered,
@ -499,22 +531,21 @@ export class SideBySideDiffRow extends React.Component<
if (this.state.showWhitespaceHint !== column) {
return
}
const caretPosition =
column === DiffColumn.Before
? PopoverCaretPosition.RightTop
: PopoverCaretPosition.LeftTop
const style: React.CSSProperties = {
[column === DiffColumn.Before ? 'marginRight' : 'marginLeft']:
this.lineGutterWidth + 10,
marginTop: -10,
const elementID = `line-numbers-${this.props.numRow}-${column}`
const anchor = document.getElementById(elementID)
if (anchor === null) {
return
}
const anchorPosition =
column === DiffColumn.Before
? PopoverAnchorPosition.LeftTop
: PopoverAnchorPosition.RightTop
return (
<WhitespaceHintPopover
caretPosition={caretPosition}
style={style}
anchor={anchor}
anchorPosition={anchorPosition}
onHideWhitespaceInDiffChanged={this.props.onHideWhitespaceInDiffChanged}
onDismissed={this.onWhitespaceHintClose}
/>
@ -529,22 +560,21 @@ export class SideBySideDiffRow extends React.Component<
* Renders the line number box.
*
* @param lineNumber Line number to display.
* @param column Column to which the line number belongs.
* @param isSelected Whether the line has been selected.
* If undefined is passed, the line is treated
* as non-selectable.
*/
private renderLineNumber(lineNumber?: number, isSelected?: boolean) {
return this.renderLineNumbers([lineNumber], isSelected)
private renderLineNumber(
lineNumber: number | undefined,
column: DiffColumn,
isSelected?: boolean
) {
return this.renderLineNumbers([lineNumber], column, isSelected)
}
private getDiffColumn(targetElement?: Element): DiffColumn | null {
const { row, showSideBySideDiff } = this.props
// On unified diffs we don't have columns so we always use "before" to not
// mess up with line selections.
if (!showSideBySideDiff) {
return DiffColumn.Before
}
const { row } = this.props
switch (row.type) {
case DiffRowType.Added:

View file

@ -56,7 +56,7 @@ import {
import { createOcticonElement } from '../octicons/octicon'
import * as OcticonSymbol from '../octicons/octicons.generated'
import { WhitespaceHintPopover } from './whitespace-hint-popover'
import { PopoverCaretPosition } from '../lib/popover'
import { PopoverAnchorPosition } from '../lib/popover'
import { HiddenBidiCharsWarning } from './hidden-bidi-chars-warning'
// This is a custom version of the no-newline octicon that's exactly as
@ -982,7 +982,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
) {
const marker = lineInfo.gutterMarkers[diffGutterName]
if (marker instanceof HTMLElement) {
this.updateGutterMarker(marker, hunk, diffLine)
this.updateGutterMarker(lineInfo.line, marker, hunk, diffLine)
}
} else {
batchedOps.push(() => {
@ -1037,6 +1037,10 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
: canSelect(file) && file.selection.isSelected(index)
}
private getGutterLineID(index: number) {
return `diff-line-gutter-${index}`
}
private getGutterLineClassNameInfo(
hunk: DiffHunk,
diffLine: DiffLine
@ -1175,7 +1179,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
)
}
this.updateGutterMarker(marker, hunk, diffLine)
this.updateGutterMarker(index, marker, hunk, diffLine)
return marker
}
@ -1257,6 +1261,7 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
}
private updateGutterMarker(
index: number,
marker: HTMLElement,
hunk: DiffHunk,
diffLine: DiffLine
@ -1270,6 +1275,8 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
}
}
marker.id = this.getGutterLineID(index)
const hunkExpandWholeHandle = marker.getElementsByClassName(
'hunk-expand-whole-handle'
)[0]
@ -1419,39 +1426,22 @@ export class TextDiff extends React.Component<ITextDiffProps, ITextDiffState> {
container.style.position = 'absolute'
const scroller = cm.getScrollerElement()
const diffSize = getLineWidthFromDigitCount(
getNumberOfDigits(this.state.diff.maxLineNumber)
)
const lineY = cm.heightAtLine(index, 'local')
// We're positioning relative to the scroll container, not the
// sizer or lines so we'll have to account for the gutter width and
// the hunk handle.
const style: React.CSSProperties = { left: diffSize * 2 + 10 }
let caretPosition = PopoverCaretPosition.LeftTop
// Offset down by 10px to align the popover arrow.
container.style.top = `${lineY - 10}px`
// If the line is further than 50% down the viewport we'll flip the
// popover to point upwards so as to not get hidden beneath (or above)
// the scroll boundary.
if (lineY - scroller.scrollTop > scroller.clientHeight / 2) {
caretPosition = PopoverCaretPosition.LeftBottom
style.bottom = -35
}
scroller.appendChild(container)
this.whitespaceHintContainer = container
ReactDOM.render(
<WhitespaceHintPopover
caretPosition={caretPosition}
anchor={document.getElementById(this.getGutterLineID(index))}
anchorPosition={PopoverAnchorPosition.RightTop}
onDismissed={this.unmountWhitespaceHint}
onHideWhitespaceInDiffChanged={
this.props.onHideWhitespaceInDiffChanged
}
style={style}
/>,
container
)

View file

@ -1,27 +1,29 @@
import * as React from 'react'
import {
Popover,
PopoverCaretPosition,
PopoverAnchorPosition,
PopoverAppearEffect,
PopoverDecoration,
} from '../lib/popover'
import { OkCancelButtonGroup } from '../dialog'
interface IWhitespaceHintPopoverProps {
readonly caretPosition: PopoverCaretPosition
readonly anchor: HTMLElement | null
readonly anchorPosition: PopoverAnchorPosition
/** Called when the user changes the hide whitespace in diffs setting. */
readonly onHideWhitespaceInDiffChanged: (checked: boolean) => void
readonly onDismissed: () => void
readonly style: React.CSSProperties
}
export class WhitespaceHintPopover extends React.Component<IWhitespaceHintPopoverProps> {
public render() {
return (
<Popover
caretPosition={this.props.caretPosition}
anchor={this.props.anchor}
anchorPosition={this.props.anchorPosition}
decoration={PopoverDecoration.Balloon}
onMousedownOutside={this.onDismissed}
className={'whitespace-hint'}
style={this.props.style}
appearEffect={PopoverAppearEffect.Shake}
ariaLabelledby="whitespace-hint-header"
>

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import { Button } from './button'
import { Popover, PopoverCaretPosition } from './popover'
import { Popover, PopoverAnchorPosition, PopoverDecoration } from './popover'
import { Octicon } from '../octicons'
import * as OcticonSymbol from '../octicons/octicons.generated'
import classNames from 'classnames'
@ -92,7 +92,9 @@ export class PopoverDropdown extends React.Component<
return (
<Popover
className="popover-dropdown-popover"
caretPosition={PopoverCaretPosition.TopLeft}
anchor={this.invokeButtonRef}
anchorPosition={PopoverAnchorPosition.BottomLeft}
decoration={PopoverDecoration.Balloon}
onClickOutside={this.closePopover}
aria-labelledby="popover-dropdown-header"
>

View file

@ -2,49 +2,94 @@ import * as React from 'react'
import FocusTrap from 'focus-trap-react'
import { Options as FocusTrapOptions } from 'focus-trap'
import classNames from 'classnames'
import {
ComputePositionReturn,
autoUpdate,
computePosition,
} from '@floating-ui/react-dom'
import {
arrow,
flip,
offset,
Placement,
shift,
Side,
size,
} from '@floating-ui/core'
import { assertNever } from '../../lib/fatal-error'
/**
* Position of the caret relative to the pop up. It's composed by 2 dimensions:
* - The first one is the edge on which the caret will rest.
* - The second one is the alignment of the caret within that edge.
* Position of the popover relative to its anchor element. It's composed by 2
* dimensions:
* - The first one is the edge of the anchor element from which the popover will
* be displayed.
* - The second one is the alignment of the popover within that edge.
*
* Example: TopRight means the caret will be in the top edge, on its right side.
*
* **Note:** If new positions are added to this enum, the value given to them
* is prepended with `popover-caret-` to create a class name which defines, in
* `app/styles/ui/_popover.scss`, where the caret is located for that specific
* position.
* Example: BottomRight means the popover will be in the bottom edge of the
* anchor element, on its right side.
**/
export enum PopoverCaretPosition {
export enum PopoverAnchorPosition {
Top = 'top',
TopRight = 'top-right',
TopLeft = 'top-left',
Left = 'left',
LeftTop = 'left-top',
LeftBottom = 'left-bottom',
Bottom = 'bottom',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Right = 'right',
RightTop = 'right-top',
None = 'none',
RightBottom = 'right-bottom',
}
export enum PopoverAppearEffect {
Shake = 'shake',
}
export enum PopoverDecoration {
None = 'none',
Balloon = 'balloon',
}
const TipSize = 8
const TipCornerPadding = TipSize
const ScreenBorderPadding = 10
interface IPopoverProps {
readonly onClickOutside?: (event?: MouseEvent) => void
readonly onMousedownOutside?: (event?: MouseEvent) => void
/** The position of the caret or pointer towards the content to which the the
* popover refers. If the caret position is not provided, the popup will have
* no caret. */
readonly caretPosition?: PopoverCaretPosition
/** Element to anchor the popover to */
readonly anchor: HTMLElement | null
/** The position of the popover relative to the anchor. */
readonly anchorPosition: PopoverAnchorPosition
/**
* The position of the tip or pointer of the popover relative to the side at
* which the tip is presented. Optional. Default: Center
*/
readonly className?: string
readonly style?: React.CSSProperties
readonly appearEffect?: PopoverAppearEffect
readonly ariaLabelledby?: string
readonly trapFocus?: boolean // Default: true
readonly decoration?: PopoverDecoration // Default: none
/** Maximum height decided by clients of Popover */
readonly maxHeight?: number
/** Minimum height decided by clients of Popover */
readonly minHeight?: number
}
export class Popover extends React.Component<IPopoverProps> {
interface IPopoverState {
readonly position: ComputePositionReturn | null
}
export class Popover extends React.Component<IPopoverProps, IPopoverState> {
private focusTrapOptions: FocusTrapOptions
public containerDivRef = React.createRef<HTMLDivElement>()
private containerDivRef = React.createRef<HTMLDivElement>()
private contentDivRef = React.createRef<HTMLDivElement>()
private tipDivRef = React.createRef<HTMLDivElement>()
private floatingCleanUp: (() => void) | null = null
public constructor(props: IPopoverProps) {
super(props)
@ -54,11 +99,88 @@ export class Popover extends React.Component<IPopoverProps> {
escapeDeactivates: true,
onDeactivate: this.props.onClickOutside,
}
this.state = { position: null }
}
private async setupPosition() {
this.floatingCleanUp?.()
this.floatingCleanUp = null
const { anchor } = this.props
if (
anchor === null ||
anchor === undefined ||
this.containerDivRef.current === null
) {
return
}
this.floatingCleanUp = autoUpdate(
anchor,
this.containerDivRef.current,
this.updatePosition
)
}
private updatePosition = async () => {
const { anchor, decoration, maxHeight } = this.props
const containerDiv = this.containerDivRef.current
const contentDiv = this.contentDivRef.current
if (
anchor === null ||
anchor === undefined ||
containerDiv === null ||
contentDiv === null
) {
return
}
const tipDiv = this.tipDivRef.current
const middleware = [
offset(decoration === PopoverDecoration.Balloon ? TipSize : 0),
shift({ padding: ScreenBorderPadding }),
flip({ padding: ScreenBorderPadding }),
size({
apply({ availableHeight, availableWidth }) {
Object.assign(contentDiv.style, {
maxHeight:
maxHeight === undefined
? `${availableHeight}px`
: `${Math.min(availableHeight, maxHeight)}px`,
maxWidth: `${availableWidth}px`,
})
},
padding: ScreenBorderPadding,
}),
]
if (decoration === PopoverDecoration.Balloon && tipDiv) {
middleware.push(arrow({ element: tipDiv, padding: TipCornerPadding }))
}
const position = await computePosition(anchor, containerDiv, {
strategy: 'fixed',
placement: this.getFloatingPlacementForAnchorPosition(),
middleware,
})
this.setState({ position })
}
public componentDidMount() {
document.addEventListener('click', this.onDocumentClick)
document.addEventListener('mousedown', this.onDocumentMouseDown)
this.setupPosition()
}
public componentDidUpdate(prevProps: IPopoverProps) {
if (prevProps.anchor !== this.props.anchor) {
this.setupPosition()
}
}
public componentWillUnmount() {
@ -67,7 +189,7 @@ export class Popover extends React.Component<IPopoverProps> {
}
private onDocumentClick = (event: MouseEvent) => {
const { current: ref } = this.containerDivRef
const ref = this.containerDivRef.current
const { target } = event
if (
@ -82,7 +204,7 @@ export class Popover extends React.Component<IPopoverProps> {
}
private onDocumentMouseDown = (event: MouseEvent) => {
const { current: ref } = this.containerDivRef
const ref = this.containerDivRef.current
const { target } = event
if (
@ -97,31 +219,163 @@ export class Popover extends React.Component<IPopoverProps> {
}
public render() {
const {
trapFocus,
className,
appearEffect,
ariaLabelledby,
children,
decoration,
maxHeight,
minHeight,
} = this.props
const cn = classNames(
'popover-component',
this.getClassNameForCaret(),
this.props.className,
this.props.appearEffect && `appear-${this.props.appearEffect}`
decoration === PopoverDecoration.Balloon && 'popover-component',
className,
appearEffect && `appear-${appearEffect}`
)
return (
<FocusTrap active={true} focusTrapOptions={this.focusTrapOptions}>
const { position } = this.state
// Make sure the popover *always* has at least `position: fixed` set, otherwise
// it can cause weird layout glitches.
const style: React.CSSProperties = {
position: 'fixed',
zIndex: 17, // same as --foldout-z-index
height: 'auto',
}
const contentStyle: React.CSSProperties = {
overflow: 'hidden',
width: '100%',
}
let tipStyle: React.CSSProperties = {}
if (position) {
style.top = position.y === undefined ? undefined : `${position.y}px`
style.left = position.x === undefined ? undefined : `${position.x}px`
contentStyle.minHeight =
minHeight === undefined ? undefined : `${minHeight}px`
contentStyle.height =
maxHeight === undefined ? undefined : `${maxHeight}px`
const arrow = position.middlewareData.arrow
if (arrow) {
const side: Side = position.placement.split('-')[0] as Side
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[side]
const angle = {
top: '270deg',
right: '0deg',
bottom: '90deg',
left: '180deg',
}[side]
tipStyle = {
top: arrow.y,
left: arrow.x,
transform: `rotate(${angle})`,
[staticSide]: this.tipDivRef.current
? `${-this.tipDivRef.current.offsetWidth}px`
: undefined,
}
}
}
const content = (
<div
className={cn}
style={style}
ref={this.containerDivRef}
aria-labelledby={ariaLabelledby}
role="dialog"
>
<div
className={cn}
ref={this.containerDivRef}
style={this.props.style}
aria-labelledby={this.props.ariaLabelledby}
role="dialog"
className="popover-content"
style={contentStyle}
ref={this.contentDivRef}
>
{this.props.children}
{children}
</div>
</FocusTrap>
{decoration === PopoverDecoration.Balloon && (
<div
className="popover-tip"
style={{
position: 'absolute',
width: TipSize * 2,
height: TipSize * 2,
...tipStyle,
}}
ref={this.tipDivRef}
>
<div
className="popover-tip-border"
style={{
position: 'absolute',
right: 1,
width: 0,
height: 0,
borderWidth: `${TipSize}px`,
borderRightWidth: `${TipSize - 1}px`,
}}
/>
<div
className="popover-tip-background"
style={{
position: 'absolute',
right: 0,
width: 0,
height: 0,
borderWidth: `${TipSize}px`,
borderRightWidth: `${TipSize - 1}px`,
}}
/>
</div>
)}
</div>
)
return trapFocus !== false ? (
<FocusTrap focusTrapOptions={this.focusTrapOptions}>{content}</FocusTrap>
) : (
content
)
}
private getClassNameForCaret() {
return `popover-caret-${
this.props.caretPosition ?? PopoverCaretPosition.None
}`
private getFloatingPlacementForAnchorPosition(): Placement {
const { anchorPosition } = this.props
switch (anchorPosition) {
case PopoverAnchorPosition.Top:
return 'top'
case PopoverAnchorPosition.TopLeft:
return 'top-start'
case PopoverAnchorPosition.TopRight:
return 'top-end'
case PopoverAnchorPosition.Left:
return 'left'
case PopoverAnchorPosition.LeftTop:
return 'left-start'
case PopoverAnchorPosition.LeftBottom:
return 'left-end'
case PopoverAnchorPosition.Right:
return 'right'
case PopoverAnchorPosition.RightTop:
return 'right-start'
case PopoverAnchorPosition.RightBottom:
return 'right-end'
case PopoverAnchorPosition.Bottom:
return 'bottom'
case PopoverAnchorPosition.BottomLeft:
return 'bottom-start'
case PopoverAnchorPosition.BottomRight:
return 'bottom-end'
default:
assertNever(anchorPosition, 'Unknown anchor position')
}
}
}

View file

@ -64,23 +64,12 @@ interface IBranchDropdownProps {
/** Map from the emoji shortcut (e.g., :+1:) to the image's local path. */
readonly emoji: Map<string, string>
}
interface IBranchDropdownState {
readonly badgeBottom: number
}
/**
* A drop down for selecting the currently checked out branch.
*/
export class BranchDropdown extends React.Component<
IBranchDropdownProps,
IBranchDropdownState
> {
public constructor(props: IBranchDropdownProps) {
super(props)
this.state = {
badgeBottom: 0,
}
}
export class BranchDropdown extends React.Component<IBranchDropdownProps> {
private badgeRef: HTMLElement | null = null
private renderBranchFoldout = (): JSX.Element | null => {
const repositoryState = this.props.repositoryState
@ -306,10 +295,6 @@ export class BranchDropdown extends React.Component<
this.openPopover()
}
private updateBadgeBottomPosition = (badgeBottom: number) => {
this.setState({ badgeBottom })
}
private openPopover = () => {
this.props.dispatcher.setShowCIStatusPopover(true)
}
@ -361,12 +346,16 @@ export class BranchDropdown extends React.Component<
dispatcher={this.props.dispatcher}
repository={pr.base.gitHubRepository}
branchName={currentBranchName}
badgeBottom={this.state.badgeBottom}
anchor={this.badgeRef}
closePopover={this.closePopover}
/>
)
}
private onBadgeRef = (ref: HTMLSpanElement | null) => {
this.badgeRef = ref
}
private renderPullRequestInfo() {
const pr = this.props.currentPullRequest
@ -379,8 +368,8 @@ export class BranchDropdown extends React.Component<
number={pr.pullRequestNumber}
dispatcher={this.props.dispatcher}
repository={pr.base.gitHubRepository}
onBadgeRef={this.onBadgeRef}
onBadgeClick={this.onBadgeClick}
onBadgeBottomPositionUpdate={this.updateBadgeBottomPosition}
/>
)
}

View file

@ -12,8 +12,6 @@
.autocompletion-popup {
display: flex;
position: fixed;
z-index: var(--popup-z-index);
width: 250px;
border-radius: var(--border-radius);

View file

@ -1,6 +1,8 @@
.commit-message-avatar-component {
// With this, the popover's absolute position will be relative to its parent
position: relative;
width: var(--text-field-height);
height: var(--text-field-height);
.avatar-button {
// override default button styles
@ -16,7 +18,12 @@
background-color: none;
border-radius: 50%;
margin-right: var(--spacing-half);
.avatar {
flex-shrink: 0;
width: 100%;
height: 100%;
}
}
.toggletip {
@ -82,77 +89,7 @@
margin-bottom: var(--spacing);
}
position: fixed;
width: 300px;
margin-left: 19px;
margin-top: 18px;
@media (max-height: 500px) {
bottom: 5px;
top: unset !important;
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 172px;
}
}
}
@media (max-height: 400px) {
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 154px;
}
}
}
@media (max-height: 330px) {
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 109px;
}
}
}
}
&.misattributed {
.popover-component {
margin-left: 5px;
margin-top: 5px;
@media (max-height: 500px) {
bottom: 5px;
top: unset !important;
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 183px;
}
}
}
@media (max-height: 400px) {
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 165px;
}
}
}
@media (max-height: 330px) {
&.popover-caret-left-bottom {
&::before,
&::after {
bottom: 120px;
}
}
}
}
}
.link-button-component {

View file

@ -66,11 +66,6 @@
}
.popover-component {
position: absolute;
left: 28px;
margin-left: -250px;
top: 27px;
width: 250px;
}
}

View file

@ -104,7 +104,6 @@
}
.popover-component.whitespace-hint {
position: absolute;
width: 225px;
}
}

View file

@ -12,11 +12,12 @@
}
.popover-dropdown-popover {
position: absolute;
min-height: 200px;
width: 365px;
padding: 0;
margin-top: 25px;
.popover-content {
padding: 0;
}
.popover-dropdown-header {
padding: var(--spacing);

View file

@ -2,31 +2,16 @@
font-size: var(--font-size);
font-family: var(--font-family-sans-serif);
z-index: var(--foldout-z-index);
background: var(--background-color);
color: var(--text-color);
border-radius: var(--border-radius);
border: var(--base-border);
padding: var(--spacing-double);
box-shadow: var(--base-box-shadow);
&::before,
&::after {
position: absolute;
display: inline-block;
content: '';
pointer-events: none;
}
&::before {
border: 8px solid transparent;
}
&::after {
border: 7px solid transparent;
.popover-content {
padding: var(--spacing-double);
border-radius: var(--border-radius);
}
& > p:first-of-type,
@ -87,130 +72,17 @@
}
}
// Carets
.popover-component.popover-caret-top-right {
&::before,
&::after {
right: 20px;
.popover-tip {
* {
border-style: solid;
border-color: transparent;
}
&::before {
top: -16px;
margin-right: -9px;
border-bottom-color: var(--box-border-color);
}
&::after {
top: -14px;
margin-right: -8px;
border-bottom-color: var(--background-color);
}
}
.popover-component.popover-caret-top-left {
&::before,
&::after {
left: 20px;
}
&::before {
top: -16px;
margin-right: -9px;
border-bottom-color: var(--box-border-color);
}
&::after {
top: -14px;
margin-right: -8px;
border-bottom-color: var(--background-color);
}
}
.popover-component.popover-caret-top {
&::before,
&::after {
position: absolute;
left: 50%;
display: inline-block;
content: '';
pointer-events: none;
}
&::before {
top: -16px;
margin-right: -9px;
border: 8px solid transparent;
border-bottom-color: var(--box-border-color);
}
&::after {
top: -14px;
margin-right: -8px;
border: 7px solid transparent;
border-bottom-color: var(--background-color);
}
}
.popover-component.popover-caret-left-top {
&::before,
&::after {
top: 20px;
}
&::before {
left: -16px;
margin-top: -9px;
border-right-color: var(--box-border-color);
}
&::after {
left: -14px;
margin-top: -8px;
.popover-tip-background {
border-right-color: var(--background-color);
}
}
.popover-component.popover-caret-left-bottom {
&::before,
&::after {
bottom: 20px;
}
&::before {
left: -16px;
margin-bottom: -9px;
.popover-tip-border {
border-right-color: var(--box-border-color);
}
&::after {
left: -14px;
margin-bottom: -8px;
border-right-color: var(--background-color);
}
}
.popover-component.popover-caret-right-top {
&::before,
&::after {
top: 20px;
}
&::before {
right: -16px;
margin-top: -9px;
border-left-color: var(--box-border-color);
}
&::after {
right: -14px;
margin-top: -8px;
border-left-color: var(--background-color);
}
}
.popover-component.popover-caret-none {
&::before,
&::after {
display: none;
}
}

View file

@ -63,7 +63,6 @@
.row {
.popover-component.whitespace-hint {
position: fixed;
width: 275px;
}
}

View file

@ -16,6 +16,7 @@
position: relative;
display: flex;
flex-direction: row;
column-gap: var(--spacing-half);
.summary-field {
flex: 1;
@ -26,12 +27,6 @@
}
}
.avatar {
flex-shrink: 0;
width: var(--text-field-height);
height: var(--text-field-height);
}
&.with-length-hint input {
padding-right: 20px;
}

View file

@ -1,14 +1,20 @@
.ci-check-list-popover {
.popover-component {
position: absolute;
padding: 0px;
width: 440px;
top: 60;
margin-left: -302px;
&::after {
border-bottom-color: var(--box-alt-background-color);
.popover-content {
padding: 0px;
}
.popover-tip-background {
border-right-color: var(--box-alt-background-color);
}
}
.ci-check-run-list-wrapper {
display: flex;
flex-direction: column;
max-height: inherit;
}
.ci-check-run-list-header {

View file

@ -6,27 +6,6 @@
flex: 1;
min-height: 0;
.popover-component {
position: absolute;
left: 100%;
top: 73px;
width: 284px;
.call-to-action-bubble {
font-weight: var(--font-weight-semibold);
display: inline-block;
font-size: var(--font-size-xs);
border: 1px solid var(--call-to-action-bubble-border-color);
color: var(--call-to-action-bubble-color);
padding: 1px 5px;
border-radius: var(--border-radius);
margin-left: var(--spacing-third);
}
img {
width: 100%;
}
}
.draggable,
.commit {
width: 100%;

View file

@ -30,6 +30,25 @@
enabled "2.0.x"
kuler "^2.0.0"
"@floating-ui/core@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0"
integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==
"@floating-ui/dom@^1.2.7":
version "1.2.7"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.7.tgz#c123e4db014b07b97e996cd459245fa217049c6b"
integrity sha512-DyqylONj1ZaBnzj+uBnVfzdjjCkFCL2aA9ESHLyUOGSqb03RpbLMImP1ekIQXYs4KLk9jAjJfZAU8hXfWSahEg==
dependencies:
"@floating-ui/core" "^1.2.6"
"@floating-ui/react-dom@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.0.tgz#7514baac526c818892bbcc84e1c3115008c029f9"
integrity sha512-Ke0oU3SeuABC2C4OFu2mSAwHIP5WUiV98O9YWoHV4Q5aT6E9k06DV0Khi5uYspR8xmmBk08t8ZDcz3TR3ARkEg==
dependencies:
"@floating-ui/dom" "^1.2.7"
"@github/alive-client@^0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@github/alive-client/-/alive-client-0.0.2.tgz#77ccf103483f87930ca25ee8d69c4ab7b86be53f"