Skip to content

Instantly share code, notes, and snippets.

@janwirth
Last active September 11, 2022 09:01
Show Gist options
  • Save janwirth/b65903d19caf919698ef72a79b521ad8 to your computer and use it in GitHub Desktop.
Save janwirth/b65903d19caf919698ef72a79b521ad8 to your computer and use it in GitHub Desktop.
/* common */
.funk-dropdown-contents {
z-index: 1000;
transition: all 0.05s ease-in-out;
}
/* .funk-dropdown-contents[style] { */
/* transition: .05s all ease-in-out; */
/* } */
funk-dropdown:not(.open) .funk-dropdown-contents {
pointer-events: none !important;
opacity: 0;
transform: translateX(4px);
transition-duration: 0.05s;
}
funk-dropdown:not(.open) .funk-dropdown-contents * {
pointer-events: none;
}
/* base */
funk-dropdown {
user-select: none;
z-index: 200;
}
funk-dropdown.open .funk-dropdown-contents {
opacity: 1;
transition-delay: 0.05s;
pointer-events: all !important;
z-index: 1000;
}
funk-dropdown.open {
z-index: 1000;
}
funk-dropdown.fixed-z-index.fixed-z-index:not(.open) {
z-index: 0;
}
import "./Dropdown.css";
import { usePopout } from "./floating";
import { autoUpdate } from "@floating-ui/dom";
import { runOnIntersect } from "./runOnIntersect.js";
/**
* Dropdown module
* @module customElements/Dropdown
* @description
* Given button and content will produce a dropdown.
* @example
* <funk-dropdown>
* <div class="funk-dropdown-button">Click me</div>
* <div class="funk-dropdown-contents">Some options...</div>
* </funk-dropdown>
*/
class Dropdown extends HTMLElement {
connectedCallback() {
runOnIntersect(this, () => this.init());
}
// set up events
init() {
// autoUpdate(document.body, this, x => {console.log('EV', x)
// })
usePopout(this, "TimezonePicker", (cleanupPopout) => {
cleanupPopout();
});
this.classList.add("closed");
this.handleButtonClick = async (ev) => {
// closed
if (this.classList.contains("open")) {
this.classList.remove("open");
this.classList.add("closed");
this.cleanupPopout && this.cleanupPopout();
// opened
} else {
this.classList.add("open");
await usePopout(this, "TimezonePicker", (cleanupPopout) => {
this.cleanupPopout = cleanupPopout;
});
this.classList.remove("closed");
}
};
this.handleClickOutside = (ev) => {
this.classList.remove("open");
this.classList.add("closed");
this.cleanupPopout && this.cleanupPopout();
};
// when an item in the content was selected, close the dropdown
// exception: if the clicked item is 'insensitive', do not close the dropdown
this.handleContentClick = (ev) => {
// recursive function to detect if it's an insensitive row
const isInsensitiveRow = (el) => {
const isInsensitive = el.classList.contains("funk-dropdown-keep-open");
const isContainer = el.classList.contains("funk-dropdown-contents");
if (isInsensitive) {
return true;
} else if (isContainer) {
// we have checked everywhere but could not find a class that tells that we are in an insensitive row
return false;
} else {
// when we are not at the container yet but have not found the class we go up by one
return isInsensitiveRow(el.parentElement);
}
};
// only close if the row that was clicked is not sensitive to closing on click
if (isInsensitiveRow(ev.target)) {
return;
} else {
this.classList.remove("open");
this.classList.add("closed");
}
};
this.button = this.querySelector(".funk-dropdown-button");
if (!this.button) {
throw new Error(".funk-dropdown-button not found");
}
this.contents = this.querySelector(".funk-dropdown-contents");
if (!this.contents) {
throw new Error(".funk-dropdown-contents not found");
}
this.button = this.querySelector(".funk-dropdown-button");
this.contents = this.querySelector(".funk-dropdown-contents");
this.button.addEventListener("click", this.handleButtonClick);
this.addEventListener("clickoutside", this.handleClickOutside);
this.contents.addEventListener("click", this.handleContentClick);
}
disconnectedCallback() {
try {
this.button.removeEventListener("click", this.handleButtonClick);
this.removeEventListener("clickoutside", this.handleClickOutside);
this.contents.removeEventListener("click", this.handleContentClick);
this.cleanupPopout && this.cleanupPopout();
} catch (e) {}
}
}
customElements.define("funk-dropdown", Dropdown);
import {
computePosition,
offset,
shift,
arrow,
autoUpdate,
} from "@floating-ui/dom";
export const usePopout = (referenceEl, name, callback, count = 0) => {
// make sure element is mounted
if (count > 3) {
return;
}
try {
const el = referenceEl.querySelector(".funk-dropdown-contents");
// apply floating UI to break out of overflow: scroll
const reference =
el.parentElement.querySelector(":scope > .funk-dropdown-button") ||
el.previousSibling;
if (el && reference) {
bind({ reference, el, callback })();
} else {
throw new Error("dropdown contents not found");
}
} catch (e) {
// in case the element did not appear yet
requestAnimationFrame(() =>
usePopout(referenceEl, name, callback, count + 1)
);
}
};
export const bind = (args) => () => {
const { reference, el, callback, arrowEl } = args;
const placement = `bottom-${args.placement || "start"}`;
var middleware = [offset(8), shift({ padding: 8 })];
// if (arrowEl) {
// middleware.push(arrow({element: arrowEl, strategy: "fixed"}))
// }
const update = async () => {
computePosition(reference, el, {
placement,
middleware,
strategy: "fixed",
}).then(({ x, y, middlewareData }) => {
// disable transition to prevent el from zooming across screen
el.style.transition = "none";
Object.assign(el.style, {
left: `${x}px`,
top: `${y}px`,
});
// if (arrowEl) {
// Object.assign(arrowEl.style, {
// left: middlewareData.arrow.x != null ? `${middlewareData.arrow.x}px` : '',
// top: middlewareData.arrow.y != null ? `${middlewareData.arrow.y}px` : '',
// });
// }
// re-enable transition
setTimeout(() => {
el.style.transition = null;
}, 10);
});
};
update();
setTimeout(() => {
callback(
autoUpdate(reference, el, update, {
ancestorResize: false,
elementResize: false,
})
);
}, 10);
};
const options = {
rootMargin: "50px",
threshold: 1.0,
};
const q = [];
var toRun = null;
const advanceQ = () => {
if (!toRun) {
toRun = q.pop();
if (toRun) {
requestAnimationFrame(() => {
toRun();
toRun = null;
advanceQ();
});
}
}
};
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
io.unobserve(entry.target);
q.push(() => {
entry.target.__init__();
entry.target.style.background = null;
});
advanceQ();
}
});
}, options);
export const runOnIntersect = (el, fn) => {
io.observe(el);
el.__init__ = fn;
// el.style.background = "red";
};
classicDropdown label contents_ =
let
dropdownButton =
Html.Styled.button
[ Html.Styled.Attributes.class "funk-dropdown-button"
, Html.Styled.Attributes.css
[ Css.fontSize (Css.px smallFont)
, Css.padding (gridBaseSize |> Css.px)
, Css.backgroundColor Css.transparent
, Css.borderWidth (Css.px 0)
, Css.cursor Css.pointer
, Css.borderRadius (Css.px 2)
, Css.displayFlex
, Css.hover
[ Css.backgroundColor <| Css.transparent
]
]
]
[ Html.Styled.div
[ Html.Styled.Attributes.css [ Css.alignSelf Css.center ]
]
[ Html.Styled.text label
]
, [ Html.Styled.fromUnstyled Ui.BoxIcons.bxCaretDown ]
|> Html.Styled.div
[ Html.Styled.Attributes.class "contain-svg"
, Html.Styled.Attributes.css
[ Css.width (Css.px (gridBaseSize * 2))
, Css.height (Css.px (gridBaseSize * 2))
, Css.marginLeft (Css.px gridBaseSize)
, Css.transform (Css.translateY (Css.px 1))
]
]
]
in
Html.Styled.node "funk-dropdown"
[ Html.Styled.Attributes.class "funk-dropdown-purple-active"
]
[ dropdownButton
, contents_
]
dropdownContents =
dropdownContents_ Css.left []
dropdownContents_ align extraStyles items =
Html.Styled.div
[ Html.Styled.Attributes.css
[ Css.position Css.fixed
, Css.bottom <| Css.px -gridBaseSize
, Css.height <| Css.px 0
, align <| Css.px 0
, Css.backgroundColor lightGreyCss
, Css.fontWeight Css.normal
, Css.zIndex (Css.int 100)
, Css.fontSize (Css.px 14)
-- , Css.minWidth (gridBaseSize * 30 |> Css.px)
]
, Html.Styled.Attributes.class "funk-dropdown-contents"
]
[ Html.Styled.div
[ Html.Styled.Attributes.css
([ Css.borderColor primaryCss
, Css.borderWidth (Css.px 1)
, Css.borderRadius (gridBaseSize / 2 |> Css.px)
, Css.borderStyle Css.solid
, Css.overflow Css.hidden
, shadow 2
, Css.maxWidth (Css.px (gridBaseSize * 45))
, Css.property "width" "min-content"
, Css.maxHeight (Css.px 250)
, Css.overflowY Css.auto
]
++ extraStyles
)
, Html.Styled.Attributes.class "scroll-hint"
]
items
]
dropdownItemStyles_ { canSelect, active, flex } =
let
canSelectStyles =
if canSelect then
[ Css.hover
[ Css.backgroundColor primaryCss
, Css.color whiteCss
, Css.cursor Css.pointer
]
]
else
[ Css.cursor Css.default, Css.color darkGreyCss ]
in
Html.Styled.Attributes.css <|
[ Css.padding (Css.px (gridBaseSize * 1.5))
, Css.textDecoration Css.none
, Css.color primaryCss
, if flex then
Css.displayFlex
else
Css.display Css.block
, Css.width (Css.pct 100)
, Css.boxSizing Css.borderBox
, Css.textAlign Css.left
, Css.alignItems Css.center
]
++ canSelectStyles
++ (if active then
[ Css.backgroundColor lightBlueCss
, Css.color blueCss
]
else
[]
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment