From e10c29cfd4148b3875dea279aca4b4ae3442c1a3 Mon Sep 17 00:00:00 2001 From: Trey Wallis <40307803+trey-wallis@users.noreply.github.com> Date: Tue, 31 Oct 2023 19:10:31 -0600 Subject: [PATCH 1/2] Advanced multi select (#831) * feat: add advanced multi-select * fix: don't close all when table scrolls from filter change * refactor: rename to Tags * refactor: remove padding on the right * fix: make chevron fill available space * feat: make options focusable * feat: add filtering * feat: add overflow container * fix: add padding to text * fix: resolve menu not found error Former-commit-id: 36d6d24ea4e25c1191e8b5df7eeb49d6b8493354 --- .../loom-app/app/hooks/use-menu-events.ts | 67 ++++++--------- .../number-format-submenu/styles.css | 2 +- .../loom-app/option-bar/filter-menu/index.tsx | 26 +++--- src/react/shared/menu-provider/index.tsx | 19 +++- src/react/shared/multi-select/index.tsx | 82 +++++++++++------- .../shared/multi-select/multi-select-menu.tsx | 86 +++++++++++++++++++ .../multi-select/multi-select-option.tsx | 41 +++++++++ src/react/shared/multi-select/styles.css | 29 ++++++- src/react/shared/multi-select/types.ts | 5 ++ 9 files changed, 270 insertions(+), 87 deletions(-) create mode 100644 src/react/shared/multi-select/multi-select-menu.tsx create mode 100644 src/react/shared/multi-select/multi-select-option.tsx create mode 100644 src/react/shared/multi-select/types.ts diff --git a/src/react/loom-app/app/hooks/use-menu-events.ts b/src/react/loom-app/app/hooks/use-menu-events.ts index 682ca01de..2b2f96638 100644 --- a/src/react/loom-app/app/hooks/use-menu-events.ts +++ b/src/react/loom-app/app/hooks/use-menu-events.ts @@ -9,8 +9,35 @@ import { useMenuOperations } from "src/react/shared/menu-provider/hooks"; export const useMenuEvents = () => { useCloseOnOutsideClick(); useCloseOnObsidianModalOpen(); - useCloseOnTableScroll(); useCloseOnMarkdownViewScroll(); + useStopTableScroll(); +}; + +const useStopTableScroll = () => { + const { reactAppId } = useAppMount(); + const { topMenu } = useMenuOperations(); + + React.useEffect(() => { + const appEl = document.getElementById(reactAppId); + if (!appEl) return; + + const tableContainerEl = appEl.querySelector( + '[data-virtuoso-scroller="true"]' + ) as HTMLElement | null; + if (!tableContainerEl) return; + + const { parentComponentId } = topMenu ?? {}; + //Only stop table scroll when the menu is opened from a cell + if (!parentComponentId?.includes("cell")) return; + + if (topMenu) { + tableContainerEl.style.overflowX = "hidden"; + tableContainerEl.style.overflowY = "hidden"; + } else { + tableContainerEl.style.overflowX = "auto"; + tableContainerEl.style.overflowY = "auto"; + } + }, [topMenu]); }; /** @@ -57,44 +84,6 @@ const useCloseOnMarkdownViewScroll = () => { }, [onCloseAll, isMarkdownView, reactAppId]); }; -const useCloseOnTableScroll = () => { - const { reactAppId } = useAppMount(); - const { onCloseAll } = useMenuOperations(); - - React.useEffect(() => { - const THROTTLE_TIME_MILLIS = 100; - const throttleHandleScroll = _.throttle( - handleScroll, - THROTTLE_TIME_MILLIS - ); - - function handleScroll() { - //Find any open menus - const openMenus = document.querySelectorAll(".dataloom-menu"); - if (openMenus.length === 0) return; - - //Since it takes a noticable amount of time for React to update the DOM, we set - //the display to none and then wait for React to clean up the DOM - for (const menu of openMenus) { - (menu as HTMLElement).style.display = "none"; - } - onCloseAll(); - } - - const appEl = document.getElementById(reactAppId); - if (!appEl) return; - - const tableContainer = appEl.querySelector( - '[data-virtuoso-scroller="true"]' - ) as HTMLElement | null; - if (!tableContainer) return; - - tableContainer.addEventListener("scroll", throttleHandleScroll); - return () => - tableContainer?.removeEventListener("scroll", throttleHandleScroll); - }, [onCloseAll, reactAppId]); -}; - /** * If the user clicks outside the app, close all menus */ diff --git a/src/react/loom-app/header-cell-edit/number-format-submenu/styles.css b/src/react/loom-app/header-cell-edit/number-format-submenu/styles.css index 6e6eb0875..db5f570b2 100644 --- a/src/react/loom-app/header-cell-edit/number-format-submenu/styles.css +++ b/src/react/loom-app/header-cell-edit/number-format-submenu/styles.css @@ -1,6 +1,6 @@ .dataloom-number-format-submenu { width: 100%; height: 240px; - overflow-y: scroll; overflow-x: auto; + overflow-y: scroll; } \ No newline at end of file diff --git a/src/react/loom-app/option-bar/filter-menu/index.tsx b/src/react/loom-app/option-bar/filter-menu/index.tsx index e8d8b050c..8a8ab7a55 100644 --- a/src/react/loom-app/option-bar/filter-menu/index.tsx +++ b/src/react/loom-app/option-bar/filter-menu/index.tsx @@ -62,6 +62,7 @@ import { createTextFilter, } from "src/shared/loom-state/loom-state-factory"; import DateFilterSelect from "./date-filter-select"; +import Tag from "src/react/shared/tag"; interface Props { id: string; @@ -497,20 +498,23 @@ export default function FilterMenu({ const { tagIds } = filter as MultiTagFilter; inputNode = ( ({ + id: tag.id, + name: tag.content, + component: ( + + ), + }))} + selectedOptionIds={tagIds} onChange={(value) => onTagsChange(id, value) } - > - {tags.map((tag) => ( - - {tag.content} - - ))} - + /> ); conditionOptions = [ TextFilterCondition.CONTAINS, diff --git a/src/react/shared/menu-provider/index.tsx b/src/react/shared/menu-provider/index.tsx index 6095aadd7..49d90890f 100644 --- a/src/react/shared/menu-provider/index.tsx +++ b/src/react/shared/menu-provider/index.tsx @@ -80,8 +80,15 @@ export default function MenuProvider({ children }: Props) { const { name, shouldRequestOnClose } = options ?? {}; - if (!triggerRef.current) return; - if (!canOpen(level)) return; + if (!triggerRef.current) { + logger("No trigger ref. Cannot open menu"); + return; + } + if (!canOpen(level)) { + logger("Level is too low. Cannot open menu"); + return; + } + logger("MenuProvider opening menu", { level }); const position = getPositionFromEl(triggerRef.current); const menu = createMenu(parentComponentId, level, position, { @@ -95,7 +102,10 @@ export default function MenuProvider({ children }: Props) { const focusMenuTrigger = React.useCallback( (parentComponentId: string, name?: string) => { - logger("MenuProvider focusMenuTrigger"); + logger("MenuProvider focusMenuTrigger", { + parentComponentId, + name, + }); setFocusedMenuTrigger({ parentComponentId, name, @@ -194,6 +204,7 @@ export default function MenuProvider({ children }: Props) { } const handleCloseAll = React.useCallback(() => { + logger("MenuProvider onCloseAll"); setOpenMenus((prevState) => prevState.filter((menu) => menu.shouldRequestOnClose) ); @@ -202,7 +213,7 @@ export default function MenuProvider({ children }: Props) { createCloseRequest(menu.id, "save-and-close") ); }); - }, [openMenus, setOpenMenus]); + }, [openMenus, setOpenMenus, logger]); const getTopMenu = React.useCallback(() => { return openMenus[openMenus.length - 1] ?? null; diff --git a/src/react/shared/multi-select/index.tsx b/src/react/shared/multi-select/index.tsx index c7e53a1bc..4f964af0a 100644 --- a/src/react/shared/multi-select/index.tsx +++ b/src/react/shared/multi-select/index.tsx @@ -1,43 +1,67 @@ +import Stack from "../stack"; +import Text from "../text"; +import Icon from "../icon"; + +import MultiSelectMenu from "./multi-select-menu"; +import { useMenu } from "../menu-provider/hooks"; +import { LoomMenuLevel } from "../menu-provider/types"; +import MenuTrigger from "../menu-trigger"; +import { MultiSelectOptionType } from "./types"; + import "./styles.css"; interface Props { - className?: string; - value: string[]; - onKeyDown?: (e: React.KeyboardEvent) => void; - onChange: (value: string[]) => void; - children: React.ReactNode; + id: string; + title: string; + selectedOptionIds: string[]; + options: MultiSelectOptionType[]; + onChange: (keys: string[]) => void; } export default function MultiSelect({ - className: customClassName, - value, + id, + title, + selectedOptionIds, + options, onChange, - onKeyDown, - children, }: Props) { - function handleChange(e: React.ChangeEvent) { - const selectedValues = Array.from( - e.target.selectedOptions, - (option) => option.value - ); - onChange(selectedValues); - } + const COMPONENT_ID = `multi-select-${id}`; + const menu = useMenu(COMPONENT_ID); - let className = "dataloom-multi-select dataloom-focusable"; - if (customClassName) { - className += " " + customClassName; + function openMenu() { + menu.onOpen(LoomMenuLevel.TWO); } return ( - - {children} - + <> + openMenu()} + > + + + + + + + + + > ); } diff --git a/src/react/shared/multi-select/multi-select-menu.tsx b/src/react/shared/multi-select/multi-select-menu.tsx new file mode 100644 index 000000000..24eaeacf5 --- /dev/null +++ b/src/react/shared/multi-select/multi-select-menu.tsx @@ -0,0 +1,86 @@ +import Stack from "../stack"; +import Text from "../text"; +import Menu from "../menu"; +import { LoomMenuPosition } from "../menu/types"; +import MultiSelectOption from "./multi-select-option"; +import { MultiSelectOptionType } from "./types"; +import Input from "../input"; +import React from "react"; +import Padding from "../padding"; + +interface Props { + id: string; + isOpen: boolean; + position: LoomMenuPosition; + options: MultiSelectOptionType[]; + selectedOptionIds: string[]; + onChange: (keys: string[]) => void; +} + +export default function MultiSelectMenu({ + id, + isOpen, + position, + options, + selectedOptionIds, + onChange, +}: Props) { + const [inputValue, setInputValue] = React.useState(""); + + React.useEffect(() => { + if (!isOpen) { + setInputValue(""); + } + }, [isOpen]); + + function handleOptionClick(id: string) { + const isSelected = selectedOptionIds.includes(id); + if (isSelected) { + const filteredOptionIds = selectedOptionIds.filter( + (selected) => selected !== id + ); + onChange(filteredOptionIds); + } else { + onChange([...selectedOptionIds, id]); + } + } + + const filteredOptions = options.filter((option) => + option.name.toLowerCase().includes(inputValue.toLocaleLowerCase()) + ); + return ( + + + + + + + + {filteredOptions.map((option) => { + const { id, component } = option; + const isChecked = selectedOptionIds.includes(id); + return ( + + ); + })} + {options.length === 0 && ( + + + + )} + + + + + ); +} diff --git a/src/react/shared/multi-select/multi-select-option.tsx b/src/react/shared/multi-select/multi-select-option.tsx new file mode 100644 index 000000000..264d69c8b --- /dev/null +++ b/src/react/shared/multi-select/multi-select-option.tsx @@ -0,0 +1,41 @@ +import Stack from "../stack"; + +interface Props { + id: string; + isChecked: boolean; + component: React.ReactNode; + handleOptionClick: (id: string) => void; +} + +export default function MultiSelectOption({ + id, + isChecked, + component, + handleOptionClick, +}: Props) { + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.stopPropagation(); + handleOptionClick(id); + } + } + + return ( + handleOptionClick(id)} + > + + {}} + /> + {component} + + + ); +} diff --git a/src/react/shared/multi-select/styles.css b/src/react/shared/multi-select/styles.css index 8a6eb9c3f..506d80566 100644 --- a/src/react/shared/multi-select/styles.css +++ b/src/react/shared/multi-select/styles.css @@ -1,3 +1,26 @@ -.dataloom-multi-select:focus { - box-shadow: none; -} \ No newline at end of file +.dataloom-multi-select { + box-shadow: var(--input-shadow); + border-radius: var(--input-radius); + height: var(--input-height); + font-size: var(--font-ui-small); + background-color: var(--interactive-normal); + color: var(--text-normal); + padding: 0 0.8em; +} + +.dataloom-multi-select:hover { + box-shadow: var(--input-shadow-hover); + background-color: var(--interactive-hover); +} + +.dataloom-multi-select__options { + width: 100%; + cursor: pointer; + max-height: 300px; + overflow-x: auto; + overflow-y: scroll; +} + +.dataloom-multi-select__option { + padding: 2px 6px; +} diff --git a/src/react/shared/multi-select/types.ts b/src/react/shared/multi-select/types.ts new file mode 100644 index 000000000..a75e94eba --- /dev/null +++ b/src/react/shared/multi-select/types.ts @@ -0,0 +1,5 @@ +export type MultiSelectOptionType = { + id: string; + name: string; + component: React.ReactNode; +}; From 54a6023f6b56535373a77e174418b9bab9b0519a Mon Sep 17 00:00:00 2001 From: Trey Wallis <40307803+trey-wallis@users.noreply.github.com> Date: Tue, 31 Oct 2023 19:11:30 -0600 Subject: [PATCH 2/2] chore: bump version Former-commit-id: 34ea1c856c9097359a6e0160f7729fce7cf6ef38 --- manifest.json | 2 +- package.json | 2 +- versions.json | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index cd88d0b4c..dd4c966a3 100644 --- a/manifest.json +++ b/manifest.json @@ -9,5 +9,5 @@ "fundingUrl": { "Buymeacoffee": "https://www.buymeacoffee.com/treywallis" }, - "version": "8.9.3" + "version": "8.10.0" } diff --git a/package.json b/package.json index 4fc378a9d..cb12ff140 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-dataloom", - "version": "8.9.3", + "version": "8.10.0", "description": "Weave together data from diverse sources into a cohesive table view. Inspired by Excel Spreadsheets and Notion.so.", "main": "main.js", "scripts": { diff --git a/versions.json b/versions.json index 4c99c2eea..ca8d1754f 100644 --- a/versions.json +++ b/versions.json @@ -134,5 +134,6 @@ "8.9.0": "1.2.0", "8.9.1": "1.2.0", "8.9.2": "1.2.0", - "8.9.3": "1.2.0" + "8.9.3": "1.2.0", + "8.10.0": "1.2.0" }