diff --git a/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineComponent.tsx b/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineComponent.tsx index 998e119a4..6a072a06f 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineComponent.tsx +++ b/packages/app-elements/src/ui/forms/RuleEngine/RuleEngineComponent.tsx @@ -2,11 +2,18 @@ import type { OnMount } from "@monaco-editor/react" import classNames from "classnames" import { isEqual } from "lodash-es" import type React from "react" -import { useCallback, useEffect, useRef, useState } from "react" +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react" import type { SetOptional } from "type-fest" import { useTokenProvider } from "#providers/TokenProvider/TokenProvider" import { Button } from "#ui/atoms/Button" import { Icon, type IconProps } from "#ui/atoms/Icon" +import { Tooltip } from "#ui/atoms/Tooltip" import { Dropdown, DropdownDivider, DropdownItem } from "#ui/composite/Dropdown" import { CodeEditor, type CodeEditorProps } from "#ui/forms/CodeEditor" import { @@ -127,6 +134,11 @@ export function RuleEngine(props: RuleEngineProps): React.JSX.Element { ) } +// Same shape as the drag handle of the columns menu: a small rounded rectangle, +// narrower than the circle button so the tabs bar keeps more room for the tabs. +const headerButtonClassName = + "flex items-center justify-center shrink-0 w-6 h-7 rounded text-black outline-hidden hover:bg-gray-100 focus-visible:bg-gray-100" + function RuleEditorComponent(props: RuleEngineProps): React.JSX.Element { const { state: { value, selectedRuleIndex }, @@ -143,6 +155,73 @@ function RuleEditorComponent(props: RuleEngineProps): React.JSX.Element { const selectedRule = value.rules?.[selectedRuleIndex] const codeEditorRef = useRef[0] | null>(null) const [forcedRender, setForcedRender] = useState(0) + const tabRefs = useRef>([]) + const tabsScrollerRef = useRef(null) + + /** + * Store, on each tab, where its menu trigger sits within the menu's containing block, + * which is outside of the tabs scroller. The static position of the menu would not + * account for the scroller being scrolled. + */ + const positionTabMenus = useCallback(() => { + for (const tab of tabRefs.current) { + const trigger = tab?.querySelector("[aria-haspopup]") + const containingBlock = tab?.offsetParent + if (tab == null || trigger == null || containingBlock == null) { + continue + } + + const triggerRect = trigger.getBoundingClientRect() + const origin = + containingBlock.getBoundingClientRect().left + + containingBlock.clientLeft - + containingBlock.scrollLeft + tab.style.setProperty( + "--tab-menu-start", + `${triggerRect.left - origin}px`, + ) + tab.style.setProperty("--tab-menu-end", `${triggerRect.right - origin}px`) + } + }, []) + + const [hasHiddenTabsOnRight, setHasHiddenTabsOnRight] = useState(false) + + const updateTabsFade = useCallback(() => { + const scroller = tabsScrollerRef.current + if (scroller != null) { + setHasHiddenTabsOnRight( + scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + ) + } + }, []) + + useEffect( + function observeTabsOverflow() { + updateTabsFade() + + const scroller = tabsScrollerRef.current + if (scroller == null || typeof ResizeObserver === "undefined") { + return + } + + const observer = new ResizeObserver(updateTabsFade) + observer.observe(scroller) + return () => { + observer.disconnect() + } + }, + [value.rules], + ) + + useEffect( + function scrollSelectedTabIntoView() { + tabRefs.current[selectedRuleIndex]?.scrollIntoView?.({ + block: "nearest", + inline: "nearest", + }) + }, + [selectedRuleIndex, value.rules?.length], + ) useEffect( function updateCodeEditor() { @@ -186,71 +265,109 @@ function RuleEditorComponent(props: RuleEngineProps): React.JSX.Element { className={`shrink-0 basis-3/5 overflow-x-auto relative flex flex-col ${editorVisible ? "" : "grow"}`} >
-
- {value.rules?.map((rule, ruleIndex, rules) => { - const label = `#${(ruleIndex + 1).toString().padStart(2, "0")}` - return ( -
- - - - - - } - dropdownItems={ - <> - { - const ruleIndex = value.rules?.length ?? 0 - setPath(`rules.${ruleIndex}`, { - ...rule, - id: undefined, - name: `${rule.name} (copy)`, - }) - setSelectedRuleIndex(ruleIndex) - }} - label="Duplicate" - /> - - { - setPath(`rules.${ruleIndex}`, null) - if (selectedRuleIndex >= ruleIndex) { - setSelectedRuleIndex(selectedRuleIndex - 1) - } - }} - label="Delete" - /> - - } - /> -
- ) - })} -
- + } + dropdownItems={ + <> + { + const ruleIndex = value.rules?.length ?? 0 + setPath(`rules.${ruleIndex}`, { + ...rule, + id: undefined, + name: `${rule.name} (copy)`, + }) + setSelectedRuleIndex(ruleIndex) + }} + label="Duplicate" + /> + + { + setPath(`rules.${ruleIndex}`, null) + if (selectedRuleIndex >= ruleIndex) { + setSelectedRuleIndex(selectedRuleIndex - 1) + } + }} + label="Delete" + /> + + } + /> +
+ ) + })} +
+ {/* Hints at the tabs hidden on the right. Not positioned: as a later flex item it paints over the tabs, and below tooltips and menus. */} +
+
+ +
-
- +
@@ -365,6 +483,52 @@ function RuleEditorComponent(props: RuleEngineProps): React.JSX.Element { ) } +/** + * The rule name shown on its tab, truncated when too long. + * Only a truncated name gets a tooltip with the full name. + */ +function RuleTabLabel({ + label, + selected, + onClick, +}: { + label: string + selected: boolean + onClick: () => void +}): React.JSX.Element { + const labelRef = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) + + useLayoutEffect( + function detectTruncation() { + const element = labelRef.current + if (element != null) { + setIsTruncated(element.scrollWidth > element.clientWidth) + } + }, + [label], + ) + + const button = ( + + ) + + return isTruncated ? ( + + ) : ( + button + ) +} + function CardConnector({ children }: { children: string }): React.JSX.Element { return (
diff --git a/packages/app-elements/src/ui/forms/RuleEngine/__snapshots__/index.test.tsx.snap b/packages/app-elements/src/ui/forms/RuleEngine/__snapshots__/index.test.tsx.snap index e6c5cb520..971ba686a 100644 --- a/packages/app-elements/src/ui/forms/RuleEngine/__snapshots__/index.test.tsx.snap +++ b/packages/app-elements/src/ui/forms/RuleEngine/__snapshots__/index.test.tsx.snap @@ -18,13 +18,21 @@ exports[`RuleEngine > renders empty without any error 1`] = ` class="w-full bg-white border-b border-gray-200 px-4 flex text-[13px] gap-4 text-gray-400 font-semibold items-center" >
+