Skip to content

Commit e8f9346

Browse files
committed
Refactor DropdownMenu to support anchor-based positioning and update its usage in dependent components
- dropdowns 'invocation count' and 'more' should not be shown through css positioning, but programmatic. Then observe if the dropdown doesn't go out of viewport, if it does, it should be moved up the amount of pixels to fit it back in. Make sure the dropdowns are not full width. - give better name for the component `packages/ui/src/components/editor/panel/prompts/PromptField/components/ProgrammaticDropdown.tsx` - 'anchored dropdown' correctly appears above footer, but 'edit formats' dropdown does not. - the dropdown 'prompt types' should have exactly the same width as the button triggering its appearance - integrate 'anchored menu' within 'dropdown menu' - 'invocation count' dropdown doesnt close on outside click - extract [Fragment] and similar logic to a hook - move `packages/ui/src/components/editor/common/hooks/use-click-outside.ts` to `packages/ui/src/hooks` - move [Fragment] to a top level variable
1 parent 9b9c62c commit e8f9346

6 files changed

Lines changed: 217 additions & 133 deletions

File tree

‎apps/editor/src/views/panel/frontend/Main/MainView/components/Header/Header.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type Props = {
2626
is_setup_complete: boolean
2727
}
2828

29+
const MENU_MAX_HEIGHT = 'calc(100vh - 38px)'
30+
2931
export const Header: React.FC<Props> = (props) => {
3032
const { t } = use_translation()
3133
const header_ref = useRef<HTMLDivElement>(null)
@@ -87,13 +89,14 @@ export const Header: React.FC<Props> = (props) => {
8789
)}
8890
selected_value={props.web_prompt_type}
8991
on_change={props.on_web_prompt_type_change}
90-
menu_max_height="calc(100vh - 80px)"
92+
menu_max_height={MENU_MAX_HEIGHT}
9193
info={t('header.prompt-type')}
9294
title={
9395
is_mac
9496
? 'Change prompt type (⇧⌥)'
9597
: 'Change prompt type (Shift+Alt)'
9698
}
99+
match_button_width
97100
/>
98101
)}
99102
{props.mode == MODE.API && (
@@ -107,13 +110,14 @@ export const Header: React.FC<Props> = (props) => {
107110
)}
108111
selected_value={props.api_prompt_type}
109112
on_change={props.on_api_prompt_type_change}
110-
menu_max_height="calc(100vh - 80px)"
113+
menu_max_height={MENU_MAX_HEIGHT}
111114
info={is_narrow_viewport ? undefined : t('header.prompt-type')}
112115
title={
113116
is_mac
114117
? 'Change prompt type (⇧⌥)'
115118
: 'Change prompt type (Shift+Alt)'
116119
}
120+
match_button_width
117121
/>
118122
)}
119123
</div>

‎packages/ui/src/components/editor/common/DropdownMenu/DropdownMenu.module.scss‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
.menu {
2-
position: absolute;
3-
top: 100%;
42
padding-top: 4px;
5-
right: 0;
63
z-index: 2;
74
width: max-content;
85
text-align: left;
@@ -79,3 +76,12 @@
7976
.underlined {
8077
text-decoration: underline;
8178
}
79+
80+
.anchored {
81+
position: fixed;
82+
z-index: 1;
83+
84+
& > * {
85+
position: static;
86+
}
87+
}

‎packages/ui/src/components/editor/common/DropdownMenu/DropdownMenu.tsx‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from 'react'
1+
import { useState, useLayoutEffect, useRef } from 'react'
22
import { Scrollable } from '../Scrollable'
33
import styles from './DropdownMenu.module.scss'
44

@@ -16,14 +16,59 @@ export namespace DropdownMenu {
1616
underline_non_selected_items?: boolean
1717
max_width?: number | string
1818
max_height?: number | string
19+
width?: number | string
20+
min_width?: number | string
1921
info?: string
22+
anchor_ref?: React.RefObject<HTMLElement>
23+
is_open?: boolean
24+
match_anchor_width?: boolean
2025
}
2126
}
2227

2328
export const DropdownMenu: React.FC<DropdownMenu.Props> = (props) => {
2429
const [is_preselection_respected, set_is_preselection_respected] =
2530
useState<boolean>(true)
2631

32+
const dropdown_ref = useRef<HTMLDivElement>(null)
33+
const [anchor_style, set_anchor_style] = useState<React.CSSProperties>({})
34+
35+
useLayoutEffect(() => {
36+
if (props.is_open && props.anchor_ref?.current && dropdown_ref.current) {
37+
const anchor_rect = props.anchor_ref.current.getBoundingClientRect()
38+
const dropdown_rect = dropdown_ref.current.getBoundingClientRect()
39+
40+
let top = anchor_rect.bottom
41+
let left = props.match_anchor_width
42+
? anchor_rect.left
43+
: anchor_rect.right - dropdown_rect.width
44+
let width: string | undefined
45+
46+
if (props.match_anchor_width) {
47+
width = `${anchor_rect.width}px`
48+
} else if (left < 0) {
49+
left = 4
50+
} else if (left + dropdown_rect.width > window.innerWidth) {
51+
left = window.innerWidth - dropdown_rect.width - 4
52+
}
53+
54+
const viewport_height = window.innerHeight
55+
if (top + dropdown_rect.height > viewport_height - 4) {
56+
const overflow = top + dropdown_rect.height - (viewport_height - 4)
57+
top -= overflow
58+
}
59+
60+
set_anchor_style({
61+
top: `${top}px`,
62+
left: `${left}px`,
63+
width
64+
})
65+
} else if (!props.is_open) {
66+
set_anchor_style({})
67+
}
68+
}, [props.is_open, props.match_anchor_width, props.items])
69+
70+
if (props.anchor_ref && !props.is_open) return null
71+
2772
const has_any_checked = props.items.some((item) => item.checked)
2873

2974
const content = (
@@ -75,8 +120,15 @@ export const DropdownMenu: React.FC<DropdownMenu.Props> = (props) => {
75120
</div>
76121
)
77122

78-
return (
79-
<div className={styles.menu} style={{ maxWidth: props.max_width }}>
123+
const menu = (
124+
<div
125+
className={styles.menu}
126+
style={{
127+
maxWidth: props.max_width,
128+
width: props.width,
129+
minWidth: props.min_width
130+
}}
131+
>
80132
<div className={styles.menu__inner}>
81133
{props.max_height ? (
82134
<Scrollable
@@ -96,4 +148,14 @@ export const DropdownMenu: React.FC<DropdownMenu.Props> = (props) => {
96148
</div>
97149
</div>
98150
)
151+
152+
if (props.anchor_ref) {
153+
return (
154+
<div ref={dropdown_ref} style={anchor_style} className={styles.anchored}>
155+
{menu}
156+
</div>
157+
)
158+
}
159+
160+
return menu
99161
}

‎packages/ui/src/components/editor/panel/InlineDropdown/InlineDropdown.tsx‎

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useState, useRef, useEffect } from 'react'
1+
import { useState, useRef, useEffect, useCallback } from 'react'
22
import styles from './InlineDropdown.module.scss'
33
import cn from 'classnames'
44
import { DropdownMenu } from '../../common/DropdownMenu'
5+
import { use_click_outside } from '../../../../hooks/use-click-outside'
56

67
export namespace InlineDropdown {
78
export type Option<T extends string> = {
@@ -19,6 +20,7 @@ export namespace InlineDropdown {
1920
menu_max_height?: number | string
2021
info?: string
2122
title?: string
23+
match_button_width?: boolean
2224
}
2325
}
2426

@@ -53,23 +55,14 @@ export const InlineDropdown = <T extends string>(
5355
set_just_opened(false)
5456
}
5557

56-
useEffect(() => {
57-
const handle_click_outside = (event: MouseEvent) => {
58-
if (
59-
container_ref.current &&
60-
!container_ref.current.contains(event.target as Node)
61-
) {
62-
opened_by_shortcut.current = false
63-
set_is_open(false)
64-
set_just_opened(false)
65-
}
66-
}
67-
68-
document.addEventListener('mousedown', handle_click_outside)
69-
return () => {
70-
document.removeEventListener('mousedown', handle_click_outside)
71-
}
72-
}, [])
58+
use_click_outside(
59+
container_ref,
60+
useCallback(() => {
61+
opened_by_shortcut.current = false
62+
set_is_open(false)
63+
set_just_opened(false)
64+
}, [])
65+
)
7366

7467
useEffect(() => {
7568
set_is_open(false)
@@ -137,20 +130,23 @@ export const InlineDropdown = <T extends string>(
137130
)}
138131
</button>
139132

140-
{is_open && (
141-
<DropdownMenu
142-
items={props.options.map((option) => ({
143-
label: option.label,
144-
on_click: () => handle_select(option.value),
145-
is_selected: just_opened && option.value == props.selected_value,
146-
shortcut: option.shortcut
147-
}))}
148-
underline_non_selected_items={opened_by_shortcut.current}
149-
max_width={props.menu_max_width}
150-
max_height={props.menu_max_height}
151-
info={props.info}
152-
/>
153-
)}
133+
<DropdownMenu
134+
anchor_ref={container_ref}
135+
is_open={is_open}
136+
match_anchor_width={props.match_button_width}
137+
items={props.options.map((option) => ({
138+
label: option.label,
139+
on_click: () => handle_select(option.value),
140+
is_selected: just_opened && option.value == props.selected_value,
141+
shortcut: option.shortcut
142+
}))}
143+
underline_non_selected_items={opened_by_shortcut.current}
144+
max_width={props.menu_max_width}
145+
max_height={props.menu_max_height}
146+
width={props.match_button_width ? '100%' : undefined}
147+
min_width={props.match_button_width ? 0 : undefined}
148+
info={props.info}
149+
/>
154150
</div>
155151
)
156152
}

0 commit comments

Comments
 (0)