-
-
Notifications
You must be signed in to change notification settings - Fork 304
Keep anchored RTE insert popups inside the viewport #6110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,18 @@ | ||
| import { ref, watch } from 'vue'; | ||
| import { nextTick, ref, watch } from 'vue'; | ||
| import throttle from 'lodash/throttle'; | ||
| import { isTouchDevice } from 'shared/utils/browserInfo'; | ||
|
|
||
| export function useModalPositioning() { | ||
| // Gap between the anchor and the modal, and the smallest gap kept to the viewport edges. | ||
| const ANCHOR_GAP = 5; | ||
| const VIEWPORT_MARGIN = 8; | ||
|
|
||
| /** | ||
| * @param {Function} getModalElement returns the element of the modal being positioned, or a | ||
| * nullish value while the modal is not rendered. It is used to measure the modal, so that an | ||
| * anchored modal can be kept inside the viewport, and to tell clicks inside it from clicks | ||
| * outside of it. | ||
| */ | ||
| export function useModalPositioning(getModalElement) { | ||
| const isModalOpen = ref(false); | ||
| const popoverStyle = ref({}); | ||
| const isModalCentered = ref(false); | ||
|
|
@@ -13,9 +23,19 @@ export function useModalPositioning() { | |
| return; | ||
| } | ||
| const rect = anchorElement.value.getBoundingClientRect(); | ||
| // The modal is fixed-positioned, so overflow past the bottom of the viewport cannot be | ||
| // scrolled into view: pull the modal up by the overflowing amount instead. When the modal is | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Height is measured once at open (plus scroll/resize), but At 1280x720 with the anchor at y=400: A
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The natural instinct would be to scroll, and scrolling would fix this. If the container is non-scrollable, that'd be a problem, but right now a non-scrollable container would only happen on a large screen, and a large screen would have enough space to show the new height. We should use KDS components for these floating components once KDS is ready, so I don't want to make this more complex if it will probably be replaced by a more robust approach anyways. |
||
| // taller than the viewport, it is aligned to the top so that its beginning stays visible. | ||
| const modalHeight = getModalElement()?.offsetHeight || 0; | ||
| const highestTop = window.innerHeight - modalHeight - VIEWPORT_MARGIN; | ||
|
|
||
| // Choose the top position that is at least VIEWPORT_MARGIN from the top of the viewport, | ||
| // and at most the bottom of the anchor element plus ANCHOR_GAP, | ||
| // but not overflowing past the bottom of the viewport. | ||
| const top = Math.max(VIEWPORT_MARGIN, Math.min(rect.bottom + ANCHOR_GAP, highestTop)); | ||
| popoverStyle.value = { | ||
| position: 'fixed', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Only the vertical axis is clamped. This bites hardest in RTL, where the toolbar mirrors so buttons that sit far from the left edge in LTR sit near it, and const left = Math.min(Math.max(rect.right, modalWidth + VIEWPORT_MARGIN), window.innerWidth - VIEWPORT_MARGIN);If it is deliberately out of scope, worth a QA check at ~360px/~768px in an RTL locale rather than a code change.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. At that screen size, we don't render a popup but a modal. |
||
| top: `${rect.bottom + 5}px`, | ||
| top: `${top}px`, | ||
| left: `${rect.right}px`, | ||
| transform: 'translateX(-100%)', | ||
| }; | ||
|
|
@@ -61,6 +81,10 @@ export function useModalPositioning() { | |
| setAnchoredPosition(targetElement); | ||
| } | ||
| isModalOpen.value = true; | ||
| if (!isModalCentered.value) { | ||
| // The modal only renders once it is open, so re-position it once its height is known. | ||
| nextTick(updatePosition); | ||
| } | ||
| }; | ||
|
|
||
| const closeModal = () => { | ||
|
|
@@ -69,9 +93,9 @@ export function useModalPositioning() { | |
| anchorElement.value = null; | ||
| }; | ||
|
|
||
| const setupClickOutside = (modalSelector, closeFunction) => { | ||
| const setupClickOutside = closeFunction => { | ||
| const clickOutsideHandler = event => { | ||
| const modalElement = document.querySelector(modalSelector); | ||
| const modalElement = getModalElement(); | ||
| if (isModalOpen.value && modalElement && !modalElement.contains(event.target)) { | ||
| // Allow the consumer to do its own cleanup. | ||
| closeFunction(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { nextTick } from 'vue'; | ||
| import { useModalPositioning } from '../TipTapEditor/composables/useModalPositioning'; | ||
|
|
||
| // jsdom exposes `ontouchstart`, which would otherwise force centered positioning. | ||
| jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); | ||
|
|
||
| const VIEWPORT_HEIGHT = 500; | ||
|
|
||
| /** | ||
| * jsdom does not lay out elements, so anchor and modal geometry has to be faked. | ||
| */ | ||
| function createAnchor({ bottom, right = 300 }) { | ||
| const anchor = document.createElement('button'); | ||
| anchor.getBoundingClientRect = () => ({ bottom, right, top: bottom - 20, left: right - 40 }); | ||
| document.body.appendChild(anchor); | ||
| return anchor; | ||
| } | ||
|
|
||
| function createModal(height) { | ||
| const modal = document.createElement('div'); | ||
| Object.defineProperty(modal, 'offsetHeight', { value: height }); | ||
| document.body.appendChild(modal); | ||
| return modal; | ||
| } | ||
|
|
||
| describe('useModalPositioning', () => { | ||
| beforeEach(() => { | ||
| document.body.innerHTML = ''; | ||
| window.innerHeight = VIEWPORT_HEIGHT; | ||
| }); | ||
|
|
||
| it('anchors the modal below the target when it fits in the viewport', async () => { | ||
| const modal = createModal(100); | ||
| const { openModal, popoverStyle } = useModalPositioning(() => modal); | ||
|
|
||
| openModal({ targetElement: createAnchor({ bottom: 100 }) }); | ||
| await nextTick(); | ||
|
|
||
| expect(popoverStyle.value.top).toBe('105px'); | ||
| expect(popoverStyle.value.left).toBe('300px'); | ||
| }); | ||
|
|
||
| it('pulls the modal up by the overflowing amount when it would overflow the bottom', async () => { | ||
| const modal = createModal(200); | ||
| const { openModal, popoverStyle } = useModalPositioning(() => modal); | ||
|
|
||
| // Below the anchor the modal would end at 400 + 5 + 200 = 605px, past the 500px viewport. | ||
| openModal({ targetElement: createAnchor({ bottom: 400 }) }); | ||
| await nextTick(); | ||
|
|
||
| expect(popoverStyle.value.top).toBe(`${VIEWPORT_HEIGHT - 200 - 8}px`); | ||
| }); | ||
|
|
||
| it('keeps the modal within the top of the viewport when it is taller than the viewport', async () => { | ||
| const modal = createModal(VIEWPORT_HEIGHT + 200); | ||
| const { openModal, popoverStyle } = useModalPositioning(() => modal); | ||
|
|
||
| openModal({ targetElement: createAnchor({ bottom: 400 }) }); | ||
| await nextTick(); | ||
|
|
||
| expect(popoverStyle.value.top).toBe('8px'); | ||
| }); | ||
|
|
||
| it('measures the modal again once it has been rendered', async () => { | ||
| // The modal only renders once it is open, so there is nothing to measure while opening it. | ||
| let modal = null; | ||
| const { openModal, popoverStyle } = useModalPositioning(() => modal); | ||
|
|
||
| openModal({ targetElement: createAnchor({ bottom: 400 }) }); | ||
| expect(popoverStyle.value.top).toBe('405px'); | ||
|
|
||
| modal = createModal(200); | ||
| await nextTick(); | ||
|
|
||
| expect(popoverStyle.value.top).toBe(`${VIEWPORT_HEIGHT - 200 - 8}px`); | ||
| }); | ||
|
|
||
| it('leaves centered positioning untouched', async () => { | ||
| const modal = createModal(200); | ||
| const { openModal, popoverStyle, isModalCentered } = useModalPositioning(() => modal); | ||
|
|
||
| openModal({ centered: true, targetElement: createAnchor({ bottom: 400 }) }); | ||
| await nextTick(); | ||
|
|
||
| expect(isModalCentered.value).toBe(true); | ||
| expect(popoverStyle.value.top).toBe('50%'); | ||
| }); | ||
|
|
||
| describe('setupClickOutside', () => { | ||
| let closeOpenedModal; | ||
|
|
||
| beforeEach(() => { | ||
| jest.useFakeTimers(); | ||
| closeOpenedModal = () => {}; | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| // Closing the modal detaches the listener the composable added to the document. | ||
| closeOpenedModal(); | ||
| await nextTick(); | ||
| jest.useRealTimers(); | ||
| }); | ||
|
|
||
| async function openModalWithClickOutside() { | ||
| const modal = createModal(100); | ||
| const anchor = createAnchor({ bottom: 100 }); | ||
| const closeFunction = jest.fn(); | ||
| const { openModal, closeModal, setupClickOutside } = useModalPositioning(() => modal); | ||
| setupClickOutside(closeFunction); | ||
|
|
||
| openModal({ targetElement: anchor }); | ||
| closeOpenedModal = closeModal; | ||
| // The listener is attached on the tick after the modal opens, on a zero timeout. | ||
| await nextTick(); | ||
| jest.runAllTimers(); | ||
|
|
||
| return { modal, anchor, closeFunction }; | ||
| } | ||
|
|
||
| it('closes the modal when the click lands outside of it', async () => { | ||
| const { anchor, closeFunction } = await openModalWithClickOutside(); | ||
|
|
||
| anchor.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); | ||
|
|
||
| expect(closeFunction).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('leaves the modal open when the click lands inside of it', async () => { | ||
| const { modal, closeFunction } = await openModalWithClickOutside(); | ||
|
|
||
| modal.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); | ||
|
|
||
| expect(closeFunction).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✅ Resolved — addressed in the current code.
nitpick:
8reads as arbitrary next to.has-overlay { z-index: 10 }below andz-index: 1000inFormatDropdown.vue:252/PasteDropdown.vue:321— dropdowns in the same toolbar and stacking context. Which KDS guidance is 8 from, and should those two move to it too? A one-line comment naming the layer would stop the next person nudging it.