Skip to content

Commit

Permalink
Merge pull request #756 from mafia-rust/meta/popover-element
Browse files Browse the repository at this point in the history
Popover element
  • Loading branch information
Jack-Papel authored Jan 6, 2025
2 parents 4048c42 + 47aaa64 commit 9f2e033
Show file tree
Hide file tree
Showing 4 changed files with 198 additions and 153 deletions.
127 changes: 127 additions & 0 deletions client/src/components/Popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, { ReactElement, useEffect, useMemo, useRef } from "react";
import ReactDOM from "react-dom/client";
import { THEME_CSS_ATTRIBUTES } from "..";

export default function Popover<T extends HTMLElement = HTMLElement>(props: Readonly<{
open: boolean,
children: JSX.Element,
setOpenOrClosed: (open: boolean) => void,
onRender?: (popoverElement: HTMLDivElement, anchorElement?: T | undefined) => void
anchorRef?: React.RefObject<T>,
className?: string
}>): ReactElement {
const thisRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(document.createElement('div'));

const popoverRoot = useMemo(() => {
const popoverElement = popoverRef.current;
popoverElement.style.position = "absolute";

document.body.appendChild(popoverElement);
return ReactDOM.createRoot(popoverElement);
}, [])

//set ref
useEffect(() => {
const initialPopover = popoverRef.current;
return () => {
setTimeout(() => {
popoverRoot.unmount();
})
initialPopover.remove();

popoverRef.current = document.createElement('div');
}
}, [popoverRoot])

//match css styles
useEffect(() => {
const styleBenefactor = props.anchorRef?.current ?? thisRef.current;
const popoverElement = popoverRef.current;

if (styleBenefactor) {
// Match styles
THEME_CSS_ATTRIBUTES.forEach(prop => {
popoverElement.style.setProperty(`--${prop}`, getComputedStyle(styleBenefactor).getPropertyValue(`--${prop}`))
})

popoverElement.className = 'popover ' + (props.className ?? '')
}
}, [props.anchorRef, props.className])

// This is for the popover's anchor, not the element named Anchor
const [anchorLocation, setAnchorLocation] = React.useState(() => {
const bounds = props.anchorRef?.current?.getBoundingClientRect();

if (bounds) {
return { top: bounds.top, left: bounds.left }
} else {
return {top: 0, left: 0}
}
});

//close on scroll
useEffect(() => {
const listener = () => {
const bounds = props.anchorRef?.current?.getBoundingClientRect();
if (
bounds &&
props.open &&
(
anchorLocation.top !== bounds?.top ||
anchorLocation.left !== bounds?.left
)
)
props.setOpenOrClosed(false);
};

window.addEventListener("scroll", listener, true);
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("scroll", listener, true);
window.removeEventListener("resize", listener);
}
})

//open and set position
useEffect(() => {
const popoverElement = popoverRef.current;
const anchorElement = props.anchorRef?.current;

if (props.open) {
popoverRoot.render(props.children);

if (anchorElement) {
const anchorBounds = anchorElement.getBoundingClientRect();

setAnchorLocation({top: anchorBounds.top, left: anchorBounds.left});
}

setTimeout(() => {
popoverElement.hidden = false;

if (props.onRender) {
props.onRender(popoverElement, anchorElement ?? undefined)
}
})
} else {
popoverElement.hidden = true;
}
}, [props, popoverRoot])

//close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!popoverRef.current?.contains(event.target as Node) && props.open) {
props.setOpenOrClosed(false);
}
};

setTimeout(() => {
document.addEventListener("click", handleClickOutside);
})
return () => document.removeEventListener("click", handleClickOutside);
}, [props]);

return <div ref={thisRef} />
}
217 changes: 68 additions & 149 deletions client/src/components/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import { Button, RawButton } from "./Button";
import "./select.css";
import Icon from "./Icon";
import ReactDOM from "react-dom/client";
import { THEME_CSS_ATTRIBUTES } from "..";
import Popover from "./Popover";

export type SelectOptionsNoSearch<K extends { toString(): string}> = Map<K, React.ReactNode>;
export type SelectOptionsSearch<K extends { toString(): string}> = Map<K, [React.ReactNode, string]>;
Expand Down Expand Up @@ -45,7 +44,7 @@ export default function Select<K extends { toString(): string}>(props: Readonly<
}
}, [props]);

const [open, setOpen]= React.useState(false);
const [open, setOpen] = React.useState(false);
const [searchString, setSearchString] = React.useState("");


Expand Down Expand Up @@ -101,169 +100,89 @@ export default function Select<K extends { toString(): string}>(props: Readonly<
}
}

const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(document.createElement('div'));
const ref = useRef<HTMLButtonElement>(null);

const dropdownRoot = useMemo(() => {
const dropdownElement = dropdownRef.current;
dropdownElement.style.position = "absolute";

document.body.appendChild(dropdownElement);
return ReactDOM.createRoot(dropdownElement);
}, [])

//set ref
useEffect(() => {
const initialDropdown = dropdownRef.current;
return () => {
setTimeout(() => {
dropdownRoot.unmount();
})
initialDropdown.remove();

dropdownRef.current = document.createElement('div');
}
}, [dropdownRoot])
const value = optionsSearch.get(props.value);
if(value === undefined) {
console.error(`Value not found in options ${props.value}`);
}

//match css styles
useEffect(() => {
const buttonElement = buttonRef.current;
const dropdownElement = dropdownRef.current;
return <>
<RawButton
ref={ref}
disabled={props.disabled}
onClick={()=>{handleSetOpen(!open)}}
className={"custom-select "+(props.className?props.className:"")}
onKeyDown={(e)=>{
if(props.disabled) return;
if(e.key === "Enter" && !open) {
e.preventDefault();
handleSetOpen(true);
}else if(e.key === "Tab") {
handleSetOpen(false);
}else{
e.preventDefault();
handleKeyInput(e.key);
}
}}
>
{open === true ?
<Icon>keyboard_arrow_up</Icon> :
<Icon>keyboard_arrow_down</Icon>}
{value !== undefined?value[0]:props.value.toString()}
</RawButton>
<Popover className="custom-select-options"
open={open}
setOpenOrClosed={handleSetOpen}
onRender={(dropdownElement, buttonElement) => {
if (!buttonElement) return;

const buttonBounds = buttonElement.getBoundingClientRect();
dropdownElement.style.width = `${buttonBounds.width}px`;
dropdownElement.style.left = `${buttonBounds.left}px`;

if (buttonElement) {
// Match styles
THEME_CSS_ATTRIBUTES.forEach(prop => {
dropdownElement.style.setProperty(`--${prop}`, getComputedStyle(buttonElement).getPropertyValue(`--${prop}`))
})

dropdownElement.className = 'custom-select-options'
}
}, [])

const [buttonLocation, setButtonLocation] = React.useState({top: 0, left: 0});

//close on scroll
useEffect(() => {
const listener = (ev: Event) => {
const bounds = buttonRef.current?.getBoundingClientRect();
if (
open &&
(
buttonLocation.top !== bounds?.top ||
buttonLocation.left !== bounds?.left
)
)
handleSetOpen(false);
};
const spaceAbove = buttonBounds.top;
const spaceBelow = window.innerHeight - buttonBounds.bottom;

window.addEventListener("scroll", listener, true);
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("scroll", listener, true);
window.removeEventListener("resize", listener);
}
})

//open and set position
useEffect(() => {
const buttonElement = buttonRef.current;
const dropdownElement = dropdownRef.current;

if (buttonElement && open) {
dropdownRoot.render(<SelectOptions
searchString={searchString===""?undefined:searchString.substring(0, 20)}
const oneRem = parseFloat(getComputedStyle(buttonElement).fontSize);

const maxHeight = (25 - .25) * oneRem;
const optionsHeight = 1 + .5 * oneRem + (dropdownElement.firstElementChild?.clientHeight ?? Infinity);

if (spaceAbove > spaceBelow) {
const newHeight = Math.min(maxHeight, spaceAbove - .25 * oneRem, optionsHeight);
dropdownElement.style.height = `${newHeight}px`;
dropdownElement.style.top = `unset`;
dropdownElement.style.bottom = `${spaceBelow + buttonBounds.height + .25 * oneRem}px`;
} else {
const newHeight = Math.min(maxHeight, spaceBelow - .25 * oneRem, optionsHeight);
dropdownElement.style.height = `${newHeight}px`;
dropdownElement.style.top = `${spaceAbove + buttonBounds.height + .25 * oneRem}px`;
dropdownElement.style.bottom = `unset`;
}
}}
anchorRef={ref}
>
<SelectOptions
options={optionsNoSearch}
searchString={searchString===""?undefined:searchString.substring(0, 20)}
onChange={(value)=>{
if(props.disabled) return;
handleSetOpen(false);
handleOnChange(value);
}}
/>);


dropdownElement.hidden = false;

const buttonBounds = buttonElement.getBoundingClientRect();
// Position
dropdownElement.style.width = `${buttonBounds.width}px`;
dropdownElement.style.left = `${buttonBounds.left}px`;
setButtonLocation({top: buttonBounds.top, left: buttonBounds.left});

const spaceAbove = buttonBounds.top;
const spaceBelow = window.innerHeight - buttonBounds.bottom;

const oneRem = parseFloat(getComputedStyle(buttonElement).fontSize);

if (spaceAbove > spaceBelow) {
const newHeight = Math.min((25 - .25) * oneRem, spaceAbove - .25 * oneRem);
dropdownElement.style.height = `${newHeight}px`;
dropdownElement.style.top = `unset`;
dropdownElement.style.bottom = `${spaceBelow + buttonBounds.height + .25 * oneRem}px`;
} else {
const newHeight = Math.min((25 - .25) * oneRem, spaceBelow - .25 * oneRem);
dropdownElement.style.height = `${newHeight}px`;
dropdownElement.style.top = `${spaceAbove + buttonBounds.height + .25 * oneRem}px`;
dropdownElement.style.bottom = `unset`;
}
} else {
dropdownElement.hidden = true;
}
}, [handleOnChange, handleSetOpen, open, props.disabled, optionsNoSearch, dropdownRoot, searchString])

//close on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.current?.contains(event.target as Node) && open) {
handleSetOpen(false);
}
};

setTimeout(() => {
document.addEventListener("click", handleClickOutside);
})
return () => document.removeEventListener("click", handleClickOutside);
}, [handleSetOpen, open]);

const value = optionsSearch.get(props.value);
if(value === undefined) {
console.error(`Value not found in options ${props.value}`);
}

return <RawButton
ref={buttonRef}
disabled={props.disabled}
onClick={()=>{handleSetOpen(!open)}}
className={"custom-select "+(props.className?props.className:"")}
onKeyDown={(e)=>{
if(props.disabled) return;
if(e.key === "Enter" && !open) {
e.preventDefault();
handleSetOpen(true);
}else if(e.key === "Tab") {
handleSetOpen(false);
}else{
e.preventDefault();
handleKeyInput(e.key);
}
}}
>
{open === true ?
<Icon>keyboard_arrow_up</Icon> :
<Icon>keyboard_arrow_down</Icon>}
{value !== undefined?value[0]:props.value.toString()}
</RawButton>
/>
</Popover>
</>
}

function SelectOptions<K extends { toString(): string}>(props: Readonly<{
searchString?: string,
options: SelectOptionsNoSearch<K>,
onChange?: (value: K)=>void,
}>) {

return <div>
{props.searchString!==undefined?
props.searchString
:null}
{props.searchString ?? null}
{[...props.options.entries()]
.map(([key, value]) => {
return <Button
Expand Down
5 changes: 2 additions & 3 deletions client/src/components/select.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
.custom-select-options {
position: absolute;
min-width: max-content;
max-height: min-content;
overflow-y: scroll;
overflow-y: auto;

padding: .13rem .25rem;
padding: .13rem .13rem;
background-color: var(--secondary-color);
border-radius: .25rem;
border: .13rem solid var(--primary-border-color);
Expand Down
Loading

0 comments on commit 9f2e033

Please sign in to comment.