diff options
| author | qixing-jk <street-anime-olive@duck.com> | 2025-09-07 22:15:12 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-07 15:15:12 +0100 |
| commit | 4cc86240757376a1f5893ad3fa52f45ff8826a88 (patch) | |
| tree | a0a62f5df07d06e5171579d1cf5e9871c1b2c810 /apps | |
| parent | 44bc838f6aeb4ac5b1f7f67e47edb4fd10286733 (diff) | |
| download | karakeep-4cc86240757376a1f5893ad3fa52f45ff8826a88.tar.zst | |
fix(extension): constrain height to prevent viewport overflow (#1580) (#1895)
* fix(extension): constrain height to prevent viewport overflow (#1580)
Fixes #1580
* refactor(extension): move height control to consumer components
Remove default height from base PopoverContent to avoid affecting
reusability. Consumers now explicitly set height via
`--radix-popover-content-available-height` when needed.
* feat(extension): introduce dynamic popover height handling
- add new `DynamicPopoverContent` component with adaptive height logic
- replace `PopoverContent` with `DynamicPopoverContent` in `ListsSelector`
- replace `PopoverContent` with `DynamicPopoverContent` in `TagsSelector`
- remove fixed height constraint for shorter content
- maintain backward compatibility with `dynamicHeight` prop
* feat(extension): improve dynamic popover height handling and styling
- set default max-height using CSS variable for consistent initial state
- remove redundant else branch in height calculation logic
- add overflow-y-auto to enable scrolling when content exceeds available space
* feat(extension): replace useEffect with useLayoutEffect for dynamic height
The change ensures proper measurement of the popover content height before
the browser paints, preventing layout shifts and improving rendering
performance.
* feat(extension): enhance dynamic height adjustment with debounce & resize handling
- add debounce support via custom `useDebounce` hook to optimize performance
- implement window resize handler to recalculate height on viewport changes
- improve height calculation with buffer zone and fallback mechanisms
- refactor code structure with utility functions for better maintainability
- update prop documentation and add new `debounceMs` prop
- enhance ref handling with merged refs callback
- split className into logical groups for better readability
- add proper TypeScript types and error handling for height calculations
* feat(tags-selector): move create tag option above existing tags (#1840)
- add CommandSeparator import
- restructure CommandGroup to display create option first
- remove redundant CommandGroup wrapper for create option
Resolves #1840
Diffstat (limited to 'apps')
3 files changed, 222 insertions, 20 deletions
diff --git a/apps/browser-extension/src/components/ListsSelector.tsx b/apps/browser-extension/src/components/ListsSelector.tsx index 8f74098f..379338b6 100644 --- a/apps/browser-extension/src/components/ListsSelector.tsx +++ b/apps/browser-extension/src/components/ListsSelector.tsx @@ -19,7 +19,8 @@ import { CommandItem, CommandList, } from "./ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { DynamicPopoverContent } from "./ui/dynamic-popover"; +import { Popover, PopoverTrigger } from "./ui/popover"; export function ListsSelector({ bookmarkId }: { bookmarkId: string }) { const currentlyUpdating = useSet<string>(); @@ -67,7 +68,7 @@ export function ListsSelector({ bookmarkId }: { bookmarkId: string }) { <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> - <PopoverContent className="w-[320px] p-0"> + <DynamicPopoverContent className="w-[320px] p-0"> <Command> <CommandInput placeholder="Search Lists ..." /> <CommandList> @@ -101,7 +102,7 @@ export function ListsSelector({ bookmarkId }: { bookmarkId: string }) { </CommandGroup> </CommandList> </Command> - </PopoverContent> + </DynamicPopoverContent> </Popover> ); } diff --git a/apps/browser-extension/src/components/TagsSelector.tsx b/apps/browser-extension/src/components/TagsSelector.tsx index 9df1d582..91864603 100644 --- a/apps/browser-extension/src/components/TagsSelector.tsx +++ b/apps/browser-extension/src/components/TagsSelector.tsx @@ -16,8 +16,10 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, } from "./ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { DynamicPopoverContent } from "./ui/dynamic-popover"; +import { Popover, PopoverTrigger } from "./ui/popover"; export function TagsSelector({ bookmarkId }: { bookmarkId: string }) { const { data: allTags } = api.tags.list.useQuery(); @@ -64,7 +66,7 @@ export function TagsSelector({ bookmarkId }: { bookmarkId: string }) { <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> - <PopoverContent className="w-[320px] p-0"> + <DynamicPopoverContent className="w-[320px] p-0"> <Command> <CommandInput value={input} @@ -73,6 +75,21 @@ export function TagsSelector({ bookmarkId }: { bookmarkId: string }) { /> <CommandList> <CommandGroup> + <CommandItem + onSelect={() => + mutate({ + bookmarkId, + attach: [{ tagName: input }], + detach: [], + }) + } + > + <Plus className="mr-2 size-4" /> + Create "{input}" ... + </CommandItem> + </CommandGroup> + <CommandSeparator /> + <CommandGroup> {allTags?.tags .sort((a, b) => a.name.localeCompare(b.name)) .map((tag) => ( @@ -95,23 +112,9 @@ export function TagsSelector({ bookmarkId }: { bookmarkId: string }) { </CommandItem> ))} </CommandGroup> - <CommandGroup> - <CommandItem - onSelect={() => - mutate({ - bookmarkId, - attach: [{ tagName: input }], - detach: [], - }) - } - > - <Plus className="mr-2 size-4" /> - Create "{input}" ... - </CommandItem> - </CommandGroup> </CommandList> </Command> - </PopoverContent> + </DynamicPopoverContent> </Popover> ); } diff --git a/apps/browser-extension/src/components/ui/dynamic-popover.tsx b/apps/browser-extension/src/components/ui/dynamic-popover.tsx new file mode 100644 index 00000000..a6ced127 --- /dev/null +++ b/apps/browser-extension/src/components/ui/dynamic-popover.tsx @@ -0,0 +1,198 @@ +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "../../utils/css"; + +interface DynamicPopoverContentProps + extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> { + /** + * Whether to enable dynamic height adjustment + * If true, use max-h when content can fit the viewport, otherwise use fixed height + * If false, always use h-[var(--radix-popover-content-available-height)] + */ + dynamicHeight?: boolean; + + /** + * Debounce delay for height adjustment (milliseconds) + * Used to optimize performance and avoid frequent recalculations + */ + debounceMs?: number; +} + +/** + * Custom Hook for debouncing + */ +function useDebounce<T>(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = React.useState<T>(value); + + React.useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * Utility function to get available height + */ +function getAvailableHeight(element: HTMLElement): number { + try { + const cssValue = getComputedStyle(element).getPropertyValue( + "--radix-popover-content-available-height", + ); + + const parsedValue = parseInt(cssValue, 10); + + // If CSS variable value cannot be obtained, fallback to 80% of viewport height + return !isNaN(parsedValue) && parsedValue > 0 + ? parsedValue + : Math.floor(window.innerHeight * 0.8); + } catch (error) { + console.warn("Failed to get available height from CSS variable:", error); + return Math.floor(window.innerHeight * 0.8); + } +} + +/** + * Utility function to calculate content height + */ +function getContentHeight(element: HTMLElement): number { + try { + return element.scrollHeight; + } catch (error) { + console.warn("Failed to get content height:", error); + return 0; + } +} + +const DynamicPopoverContent = React.forwardRef< + React.ElementRef<typeof PopoverPrimitive.Content>, + DynamicPopoverContentProps +>( + ( + { + className, + align = "center", + sideOffset = 4, + dynamicHeight = true, + debounceMs = 100, + children, + ...props + }, + ref, + ) => { + const contentRef = React.useRef<HTMLDivElement>(null); + + // Use state to manage height class name + const [heightClass, setHeightClass] = React.useState<string>( + "max-h-[var(--radix-popover-content-available-height)]", + ); + + // Create a dependency to trigger recalculation + const [childrenKey, setChildrenKey] = React.useState(0); + + // Use debounce to optimize performance + const debouncedChildrenKey = useDebounce(childrenKey, debounceMs); + + // Listen for children changes + React.useEffect(() => { + setChildrenKey((prev) => prev + 1); + }, [children]); + + // Utility function to merge refs + const setRefs = React.useCallback( + (node: HTMLDivElement | null) => { + // Set internal ref + contentRef.current = node; + + // Set external ref + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [ref], + ); + + // Core logic for calculating height + const calculateHeight = React.useCallback(() => { + if (!dynamicHeight || !contentRef.current) { + return; + } + + const element = contentRef.current; + const availableHeight = getAvailableHeight(element); + const contentHeight = getContentHeight(element); + + // Add some buffer to avoid edge cases + const BUFFER = 10; + + if (contentHeight + BUFFER > availableHeight) { + setHeightClass("h-[var(--radix-popover-content-available-height)]"); + } else { + setHeightClass("max-h-[var(--radix-popover-content-available-height)]"); + } + }, [dynamicHeight]); + + // Use useLayoutEffect to avoid layout flickering + React.useLayoutEffect(() => { + calculateHeight(); + }, [calculateHeight, debouncedChildrenKey]); + + // Handle window resize + React.useEffect(() => { + if (!dynamicHeight) return; + + const handleResize = () => { + calculateHeight(); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [calculateHeight, dynamicHeight]); + + // Define all styles as a single constant for better performance and simplicity + const POPOVER_STYLES = + "z-50 w-72 overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"; + + // Determine final height class name + const finalHeightClass = React.useMemo(() => { + return dynamicHeight + ? heightClass + : "h-[var(--radix-popover-content-available-height)]"; + }, [dynamicHeight, heightClass]); + + // Memoize the complete class name for performance + const popoverClassName = React.useMemo( + () => cn(POPOVER_STYLES, finalHeightClass, className), + [finalHeightClass, className], + ); + + return ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={setRefs} + align={align} + sideOffset={sideOffset} + className={popoverClassName} + {...props} + > + {children} + </PopoverPrimitive.Content> + </PopoverPrimitive.Portal> + ); + }, +); + +DynamicPopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { DynamicPopoverContent }; |
