Skip to content

Commit 4125e6f

Browse files
authored
feat(dropdown): allow dropdown to set the specified container (#344)
* feat(dropdown): allow dropdown to set the specified container * test(modal): update snapshots * docs(select): add example for custom popup container * fix(dropdown): fix type of getPopupContainer * test(dropdown): add testcase for specified container rendering
1 parent 6e97f89 commit 4125e6f

File tree

9 files changed

+162
-46
lines changed

9 files changed

+162
-46
lines changed

Diff for: components/modal/__tests__/__snapshots__/index.test.tsx.snap

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ exports[`Modal should render correctly 1`] = `
9494
margin: 0 -16pt;
9595
padding: 16pt 16pt 8pt;
9696
overflow-y: auto;
97+
position: relative;
9798
}
9899
99100
.content > :global(*:first-child) {

Diff for: components/modal/modal-content.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const ModalContent: React.FC<ModalContentProps> = ({ className, children, ...pro
2626
margin: 0 -${theme.layout.gap};
2727
padding: ${theme.layout.gap} ${theme.layout.gap} ${theme.layout.gapHalf};
2828
overflow-y: auto;
29+
position: relative;
2930
}
3031
3132
.content > :global(*:first-child) {

Diff for: components/select/select-dropdown.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface Props {
99
className?: string
1010
dropdownStyle?: object
1111
disableMatchWidth?: boolean
12+
getPopupContainer?: () => HTMLElement | null
1213
}
1314

1415
const defaultProps = {
@@ -25,12 +26,17 @@ const SelectDropdown: React.FC<React.PropsWithChildren<SelectDropdownProps>> = (
2526
className,
2627
dropdownStyle,
2728
disableMatchWidth,
29+
getPopupContainer,
2830
}) => {
2931
const theme = useTheme()
3032
const { ref } = useSelectContext()
3133

3234
return (
33-
<Dropdown parent={ref} visible={visible} disableMatchWidth={disableMatchWidth}>
35+
<Dropdown
36+
parent={ref}
37+
visible={visible}
38+
disableMatchWidth={disableMatchWidth}
39+
getPopupContainer={getPopupContainer}>
3440
<div className={`select-dropdown ${className}`} style={dropdownStyle}>
3541
{children}
3642
<style jsx>{`

Diff for: components/select/select.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface Props {
2828
dropdownClassName?: string
2929
dropdownStyle?: object
3030
disableMatchWidth?: boolean
31+
getPopupContainer?: () => HTMLElement | null
3132
}
3233

3334
const defaultProps = {
@@ -60,6 +61,7 @@ const Select: React.FC<React.PropsWithChildren<SelectProps>> = ({
6061
dropdownClassName,
6162
dropdownStyle,
6263
disableMatchWidth,
64+
getPopupContainer,
6365
...props
6466
}) => {
6567
const theme = useTheme()
@@ -148,7 +150,8 @@ const Select: React.FC<React.PropsWithChildren<SelectProps>> = ({
148150
visible={visible}
149151
className={dropdownClassName}
150152
dropdownStyle={dropdownStyle}
151-
disableMatchWidth={disableMatchWidth}>
153+
disableMatchWidth={disableMatchWidth}
154+
getPopupContainer={getPopupContainer}>
152155
{children}
153156
</SelectDropdown>
154157
{!pure && (

Diff for: components/shared/__tests__/dropdown.test.tsx

+20
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,24 @@ describe('Dropdown', () => {
164164

165165
expect(() => wrapper.unmount()).not.toThrow()
166166
})
167+
168+
it('should render to specified container', () => {
169+
const Mock: React.FC<{}> = () => {
170+
const ref = useRef<HTMLDivElement>(null)
171+
const customContainer = useRef<HTMLDivElement>(null)
172+
return (
173+
<div>
174+
<div ref={customContainer} id="custom" />
175+
<div ref={ref}>
176+
<Dropdown parent={ref} visible getPopupContainer={() => customContainer.current}>
177+
<span>test-value</span>
178+
</Dropdown>
179+
</div>
180+
</div>
181+
)
182+
}
183+
const wrapper = mount(<Mock />)
184+
const customContainer = wrapper.find('#custom')
185+
expect(customContainer.html()).toContain('dropdown')
186+
})
167187
})

Diff for: components/shared/dropdown.tsx

+25-7
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface Props {
1010
parent?: MutableRefObject<HTMLElement | null> | undefined
1111
visible: boolean
1212
disableMatchWidth?: boolean
13+
getPopupContainer?: () => HTMLElement | null
1314
}
1415

1516
interface ReactiveDomReact {
@@ -26,31 +27,48 @@ const defaultRect: ReactiveDomReact = {
2627
width: 0,
2728
}
2829

29-
const getRect = (ref: MutableRefObject<HTMLElement | null>): ReactiveDomReact => {
30+
const getOffset = (el?: HTMLElement | null | undefined) => {
31+
if (!el)
32+
return {
33+
top: 0,
34+
left: 0,
35+
}
36+
const { top, left } = el.getBoundingClientRect()
37+
return { top, left }
38+
}
39+
40+
const getRect = (
41+
ref: MutableRefObject<HTMLElement | null>,
42+
getContainer?: () => HTMLElement | null,
43+
): ReactiveDomReact => {
3044
if (!ref || !ref.current) return defaultRect
3145
const rect = ref.current.getBoundingClientRect()
46+
const container = getContainer ? getContainer() : null
47+
const scrollElement = container || document.documentElement
48+
const { top: offsetTop, left: offsetLeft } = getOffset(container)
49+
3250
return {
3351
...rect,
3452
width: rect.width || rect.right - rect.left,
35-
top: rect.bottom + document.documentElement.scrollTop,
36-
left: rect.left + document.documentElement.scrollLeft,
53+
top: rect.bottom + scrollElement.scrollTop - offsetTop,
54+
left: rect.left + scrollElement.scrollLeft - offsetLeft,
3755
}
3856
}
3957

4058
const Dropdown: React.FC<React.PropsWithChildren<Props>> = React.memo(
41-
({ children, parent, visible, disableMatchWidth }) => {
42-
const el = usePortal('dropdown')
59+
({ children, parent, visible, disableMatchWidth, getPopupContainer }) => {
60+
const el = usePortal('dropdown', getPopupContainer)
4361
const [rect, setRect] = useState<ReactiveDomReact>(defaultRect)
4462
if (!parent) return null
4563

4664
const updateRect = () => {
47-
const { top, left, right, width: nativeWidth } = getRect(parent)
65+
const { top, left, right, width: nativeWidth } = getRect(parent, getPopupContainer)
4866
setRect({ top, left, right, width: nativeWidth })
4967
}
5068

5169
useResize(updateRect)
5270
useClickAnyWhere(() => {
53-
const { top, left } = getRect(parent)
71+
const { top, left } = getRect(parent, getPopupContainer)
5472
const shouldUpdatePosition = top !== rect.top || left !== rect.left
5573
if (!shouldUpdatePosition) return
5674
updateRect()

Diff for: components/utils/use-portal.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,24 @@ const createElement = (id: string): HTMLElement => {
88
return el
99
}
1010

11-
const usePortal = (selectId: string = getId()): HTMLElement | null => {
11+
const usePortal = (
12+
selectId: string = getId(),
13+
getContainer?: () => HTMLElement | null,
14+
): HTMLElement | null => {
1215
const id = `zeit-ui-${selectId}`
1316
const { isBrowser } = useSSR()
1417
const [elSnapshot, setElSnapshot] = useState<HTMLElement | null>(
1518
isBrowser ? createElement(id) : null,
1619
)
1720

1821
useEffect(() => {
19-
const hasElement = document.querySelector<HTMLElement>(`#${id}`)
22+
const customContainer = getContainer ? getContainer() : null
23+
const parentElement = customContainer || document.body
24+
const hasElement = parentElement.querySelector<HTMLElement>(`#${id}`)
2025
const el = hasElement || createElement(id)
2126

2227
if (!hasElement) {
23-
document.body.appendChild(el)
28+
parentElement.appendChild(el)
2429
}
2530
setElSnapshot(el)
2631
}, [])

Diff for: pages/en-us/components/select.mdx

+48-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Layout, Playground, Attributes } from 'lib/components'
2-
import { Select, Spacer, Code } from 'components'
2+
import { Select, Spacer, Code, Modal, useModal, Button } from 'components'
33

44
export const meta = {
55
title: 'select',
@@ -142,25 +142,56 @@ Display a dropdown list of items.
142142
`}
143143
/>
144144

145+
<Playground
146+
title="Set parent element"
147+
desc="you can specify the container for drop-down box rendering."
148+
scope={{ Select, Spacer, useModal, Modal, Button, Code }}
149+
code={`
150+
() => {
151+
const { visible, setVisible, bindings } = useModal()
152+
return (
153+
<>
154+
<Button auto onClick={() => setVisible(true)}>Show Select</Button>
155+
<Modal {...bindings}>
156+
<Modal.Title>Modal</Modal.Title>
157+
<Modal.Content id="customModalSelect">
158+
<Select placeholder="Choose one" initialValue="1"
159+
getPopupContainer={() => document.getElementById('customModalSelect')}>
160+
<Select.Option value="1"><Code>TypeScript</Code></Select.Option>
161+
<Select.Option value="2"><Code>JavaScript</Code></Select.Option>
162+
</Select>
163+
<p>Scroll through the content to see the changes.</p>
164+
<div style={{ height: '1200px' }}></div>
165+
<p>Scroll through the content to see the changes.</p>
166+
</Modal.Content>
167+
<Modal.Action passive onClick={() => setVisible(false)}>Cancel</Modal.Action>
168+
</Modal>
169+
</>
170+
)
171+
}
172+
`}
173+
/>
174+
145175
<Attributes edit="/pages/en-us/components/select.mdx">
146176
<Attributes.Title>Select.Props</Attributes.Title>
147177

148-
| Attribute | Description | Type | Accepted values | Default |
149-
| --------------------- | --------------------------------------- | --------------------------------------------------- | --------------------------------- | --------------- |
150-
| **value** | selected value | `string`, `string[]` | - | - |
151-
| **initialValue** | initial value | `string`, `string[]` | - | - |
152-
| **placeholder** | placeholder string | `string` | - | - |
153-
| **width** | css width value of select | `string` | - | `initial` |
154-
| **size** | select component size | `NormalSizes` | [NormalSizes](#normalsizes) | `medium` |
155-
| **icon** | icon component | `ComponentType` | - | `SVG Component` |
156-
| **pure** | remove icon component | `boolean` | - | `false` |
157-
| **multiple** | support multiple selection | `boolean` | - | `false` |
158-
| **disabled** | disable current radio | `boolean` | - | `false` |
159-
| **onChange** | selected value | <Code>(val: string &#124; string[]) => void </Code> | - | - |
160-
| **dropdownClassName** | className of dropdown menu | `string` | - | - |
161-
| **dropdownStyle** | style of dropdown menu | `object` | - | - |
162-
| **disableMatchWidth** | disable Option from follow Select width | `boolean` | - | `false` |
163-
| ... | native props | `HTMLAttributes` | `'name', 'alt', 'className', ...` | - |
178+
| Attribute | Description | Type | Accepted values | Default |
179+
| --------------------- | ----------------------------------------------------- | --------------------------------------------------- | --------------------------------- | --------------- |
180+
| **value** | selected value | `string`, `string[]` | - | - |
181+
| **initialValue** | initial value | `string`, `string[]` | - | - |
182+
| **placeholder** | placeholder string | `string` | - | - |
183+
| **width** | css width value of select | `string` | - | `initial` |
184+
| **size** | select component size | `NormalSizes` | [NormalSizes](#normalsizes) | `medium` |
185+
| **icon** | icon component | `ComponentType` | - | `SVG Component` |
186+
| **pure** | remove icon component | `boolean` | - | `false` |
187+
| **multiple** | support multiple selection | `boolean` | - | `false` |
188+
| **disabled** | disable current radio | `boolean` | - | `false` |
189+
| **onChange** | selected value | <Code>(val: string &#124; string[]) => void </Code> | - | - |
190+
| **dropdownClassName** | className of dropdown menu | `string` | - | - |
191+
| **dropdownStyle** | style of dropdown menu | `object` | - | - |
192+
| **disableMatchWidth** | disable Option from follow Select width | `boolean` | - | `false` |
193+
| **getPopupContainer** | dropdown render parent element, the default is `body` | `() => HTMLElement` | - | - |
194+
| ... | native props | `HTMLAttributes` | `'name', 'alt', 'className', ...` | - |
164195

165196
<Attributes.Title>Select.Option.Props</Attributes.Title>
166197

0 commit comments

Comments
 (0)