From aa50be0b8b12107462df7a039423972eafb90136 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Fri, 29 Sep 2023 06:10:24 -0700 Subject: [PATCH] Handle large elements with new "unaligned" alignment fallback options that nudge the element onto the screen. Solves issue #17 --- README.md | 16 ++-- src/index.ts | 144 ++++++++++++++++++++++++++++++++---- test/index.test.ts | 181 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 317 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a515eae..873e8af 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ be the button that triggered a dropdown menu to appear. relative to its anchor. The horizontal alignment mode is used if the element is positioned in the top, bottom, or cover positions relative to the anchor, and causes the element to be moved horizontally in order to make a specific - edge align. It may be set to null, "center", "left", "right", or an array of - some of those string values. The element will attempt to use this alignment - (or each value in the array in order) unless it is not possible to do so while - fitting the element on-screen. + edge align. It may be set to null, "center", "left", "right", "unaligned", or + an array of some of those string values. The element will attempt to use this + alignment (or each value in the array in order) unless it is not possible to + do so while fitting the element on-screen. - `forceHAlign` is a boolean which controls whether the configured hAlign value will be used even if it results in the element going off of the screen. @@ -57,10 +57,10 @@ be the button that triggered a dropdown menu to appear. to its anchor. The vertical alignment mode is used if the element is positioned in the left, right, or cover positions relative to the anchor, and causes the element to be moved vertically in order to make a specific edge - align. It may be set to null, "center", "top", "bottom", or an array of some - of those string values. The element will attempt to use this alignment (or - each value in the array in order) unless it is not possible to do so while - fitting the element on-screen. + align. It may be set to null, "center", "top", "bottom", "unaligned", or an + array of some of those string values. The element will attempt to use this + alignment (or each value in the array in order) unless it is not possible to + do so while fitting the element on-screen. - `forceVAlign` is a boolean which controls whether the configured vAlign value will be used even if it results in the element going off of the screen. diff --git a/src/index.ts b/src/index.ts index 91f55df..6e8b917 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,20 @@ import uniq from "lodash/uniq"; import { isNotNil } from "./isNotNil"; export type PositionOption = "top" | "bottom" | "left" | "right" | "cover"; -export type HAlignOption = "center" | "left" | "right"; -export type VAlignOption = "center" | "top" | "bottom"; +/** + * This option is used when position is "cover", "top", or "bottom". + * It controls the horizontal alignment of the element relative to the anchor. + * "center" means the element's center is aligned with the anchor's center. + * "left" and "right" means that edge of the element is aligned with the same edge of the anchor. + * "unaligned" means the element may not be aligned with the anchor at all. Currently + * this works by doing the same thing as "center" and then adjusting the result to fit on screen. + */ +export type HAlignOption = "center" | "left" | "right" | "unaligned"; +/** + * Similar to {@link HAlignOption}, except this controls the vertical alignment of the element + * relative to the anchor when the position is "cover", "left", or "right". + */ +export type VAlignOption = "center" | "top" | "bottom" | "unaligned"; export type Position = PositionOption | PositionOption[]; export type HAlign = HAlignOption | HAlignOption[]; @@ -129,29 +141,73 @@ export function getContainByScreenResults( ), ); + // Try unaligned versions at the end + if (!hAligns.includes("unaligned")) { + allPossibleChoices.push( + ...flatten( + positions.map((position) => + !["cover", "top", "bottom"].includes(position) + ? [] + : vAligns.map((vAlign) => ({ + position, + hAlign: "unaligned" as const, + vAlign, + })), + ), + ), + ); + } + if (!vAligns.includes("unaligned")) { + allPossibleChoices.push( + ...flatten( + positions.map((position) => + !["cover", "left", "right"].includes(position) + ? [] + : hAligns.map((hAlign) => ({ + position, + hAlign, + vAlign: "unaligned" as const, + })), + ), + ), + ); + } + let choiceAndCoord: ChoiceAndCoordinates | null = null; for (let i = 0; i < allPossibleChoices.length; i++) { const choice = allPossibleChoices[i]; const coordinates = positionAndAlign(elRect, anchorRect, choice, buffers); const { top, left } = coordinates; - if ( - top - buffers.all - buffers.top >= 0 && - left - buffers.all - buffers.left >= 0 && - top + elRect.height + buffers.all + buffers.bottom <= - window.innerHeight && - left + elRect.width + buffers.all + buffers.right <= window.innerWidth - ) { + + const ignoreHorizontalConstraints = + choice.hAlign === "unaligned" && + ["cover", "top", "bottom"].includes(choice.position); + const ignoreVerticalConstraints = + choice.vAlign === "unaligned" && + ["cover", "left", "right"].includes(choice.position); + + const hasHorizontalFit = + ignoreHorizontalConstraints || + (left - buffers.all - buffers.left >= 0 && + left + elRect.width + buffers.all + buffers.right <= window.innerWidth); + const hasVerticalFit = + ignoreVerticalConstraints || + (top - buffers.all - buffers.top >= 0 && + top + elRect.height + buffers.all + buffers.bottom <= + window.innerHeight); + + if (hasHorizontalFit && hasVerticalFit) { choiceAndCoord = { choice, coordinates }; break; } } - // Fallback if we failed to find a position that fit on the screen. + // Fallback if we failed to find a choice that fit on the screen. if (!choiceAndCoord) { - const choice = { - position: optionPositions[0] || "top", - hAlign: optionHAligns[0] || "center", - vAlign: optionVAligns[0] || "center", + const choice: Choice = { + position: "cover", + hAlign: "unaligned", + vAlign: "unaligned", }; choiceAndCoord = { choice, @@ -204,6 +260,19 @@ function positionAndAlign( case "right": left = Math.ceil(anchorRect.right - elRect.width); break; + case "unaligned": { + left = Math.max( + buffers.all + buffers.left, + Math.round((anchorRect.left + anchorRect.right - elRect.width) / 2), + ); + const overhang = Math.ceil( + left + elRect.width + buffers.all + buffers.right - window.innerWidth, + ); + if (overhang > 0) { + left -= overhang; + } + break; + } default: hAlign satisfies never; throw new Error("Should not happen"); @@ -220,6 +289,23 @@ function positionAndAlign( case "bottom": top = Math.ceil(anchorRect.bottom - elRect.height); break; + case "unaligned": { + top = Math.max( + buffers.all + buffers.top, + Math.round((anchorRect.top + anchorRect.bottom - elRect.height) / 2), + ); + const overhang = Math.ceil( + top + + elRect.height + + buffers.all + + buffers.bottom - + window.innerHeight, + ); + if (overhang > 0) { + top -= overhang; + } + break; + } default: vAlign satisfies never; throw new Error("Should not happen"); @@ -250,6 +336,19 @@ function positionAndAlign( case "right": left = Math.round(anchorRect.right - elRect.width); break; + case "unaligned": { + left = Math.max( + buffers.all + buffers.left, + Math.round((anchorRect.left + anchorRect.right - elRect.width) / 2), + ); + const overhang = Math.ceil( + left + elRect.width + buffers.all + buffers.right - window.innerWidth, + ); + if (overhang > 0) { + left -= overhang; + } + break; + } default: hAlign satisfies never; throw new Error("Should not happen"); @@ -280,6 +379,23 @@ function positionAndAlign( case "bottom": top = Math.round(anchorRect.bottom - elRect.height); break; + case "unaligned": { + top = Math.max( + buffers.all + buffers.top, + Math.round((anchorRect.top + anchorRect.bottom - elRect.height) / 2), + ); + const overhang = Math.ceil( + top + + elRect.height + + buffers.all + + buffers.bottom - + window.innerHeight, + ); + if (overhang > 0) { + top -= overhang; + } + break; + } default: vAlign satisfies never; throw new Error("Should not happen"); diff --git a/test/index.test.ts b/test/index.test.ts index 2c98248..b390224 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -32,7 +32,7 @@ describe("containByScreen", () => { delete (globalThis as any).window; }); - it("fallback", () => { + it("reposition to bottom", () => { const button = new MockElement({ top: 10, bottom: 20, @@ -46,11 +46,16 @@ describe("containByScreen", () => { right: 100, }); - containByScreen(dropdown as any, button as any, { + const choice = containByScreen(dropdown as any, button as any, { position: "left", hAlign: "center", vAlign: "center", }); + assert.deepEqual(choice, { + position: "bottom", + hAlign: "left", + vAlign: "center", + }); // button is unmoved assert.deepEqual(button.style, {}); @@ -59,6 +64,178 @@ describe("containByScreen", () => { assert.deepEqual(dropdown.style, { top: "20px", left: "30px" }); }); + describe("fallback to unaligned", () => { + it("fallback to vAlign:unaligned clipped at top", () => { + const button = new MockElement({ + top: 200, + bottom: 220, + left: 30, + right: 50, + }); + const dropdown = new MockElement({ + top: 0, + bottom: 550, + left: 0, + right: 100, + }); + + const choice = containByScreen(dropdown as any, button as any, { + position: "left", + hAlign: "center", + vAlign: "center", + buffer: 1, + topBuffer: 2, + bottomBuffer: 3, + leftBuffer: 4, + rightBuffer: 5, + }); + assert.deepEqual(choice, { + position: "right", + hAlign: "center", + vAlign: "unaligned", + }); + + // dropdown is moved + assert.deepEqual(dropdown.style, { top: "3px", left: "55px" }); + }); + + it("fallback to vAlign:unaligned clipped at bottom", () => { + const button = new MockElement({ + top: 400, + bottom: 420, + left: 30, + right: 50, + }); + const dropdown = new MockElement({ + top: 0, + bottom: 550, + left: 0, + right: 100, + }); + + const choice = containByScreen(dropdown as any, button as any, { + position: "left", + hAlign: "center", + vAlign: "center", + buffer: 1, + topBuffer: 2, + bottomBuffer: 3, + leftBuffer: 4, + rightBuffer: 5, + }); + assert.deepEqual(choice, { + position: "right", + hAlign: "center", + vAlign: "unaligned", + }); + + // dropdown is moved + assert.deepEqual(dropdown.style, { top: "46px", left: "55px" }); + }); + + it("fallback to hAlign:unaligned clipped at left", () => { + const button = new MockElement({ + top: 200, + bottom: 220, + left: 230, + right: 250, + }); + const dropdown = new MockElement({ + top: 0, + bottom: 50, + left: 0, + right: 760, + }); + + const choice = containByScreen(dropdown as any, button as any, { + position: "left", + hAlign: "center", + vAlign: "center", + buffer: 1, + topBuffer: 2, + bottomBuffer: 3, + leftBuffer: 4, + rightBuffer: 5, + }); + assert.deepEqual(choice, { + position: "top", + hAlign: "unaligned", + vAlign: "center", + }); + + // dropdown is moved + assert.deepEqual(dropdown.style, { top: "146px", left: "5px" }); + }); + + it("fallback to hAlign:unaligned clipped at right", () => { + const button = new MockElement({ + top: 200, + bottom: 220, + left: 630, + right: 650, + }); + const dropdown = new MockElement({ + top: 0, + bottom: 50, + left: 0, + right: 760, + }); + + const choice = containByScreen(dropdown as any, button as any, { + position: "left", + hAlign: "center", + vAlign: "center", + buffer: 1, + topBuffer: 2, + bottomBuffer: 3, + leftBuffer: 4, + rightBuffer: 5, + }); + assert.deepEqual(choice, { + position: "top", + hAlign: "unaligned", + vAlign: "center", + }); + + // dropdown is moved + assert.deepEqual(dropdown.style, { top: "146px", left: "34px" }); + }); + + it("fallback after all positioning attempts fail", () => { + const button = new MockElement({ + top: 200, + bottom: 220, + left: 630, + right: 650, + }); + const dropdown = new MockElement({ + top: 0, + bottom: 560, + left: 0, + right: 760, + }); + + const choice = containByScreen(dropdown as any, button as any, { + position: "left", + hAlign: "center", + vAlign: "center", + buffer: 1, + topBuffer: 2, + bottomBuffer: 3, + leftBuffer: 4, + rightBuffer: 5, + }); + assert.deepEqual(choice, { + position: "cover", + hAlign: "unaligned", + vAlign: "unaligned", + }); + + // dropdown is moved + assert.deepEqual(dropdown.style, { top: "3px", left: "34px" }); + }); + }); + describe("buffers", () => { describe("target is placed buffer distance away", () => { it("right, vAlign=top with buffers", () => {