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
This commit is contained in:
witt
2020-07-21 21:38:17 +08:00
committed by GitHub
parent 6e97f89540
commit 4125e6f0a3
9 changed files with 162 additions and 46 deletions

View File

@@ -94,6 +94,7 @@ exports[`Modal should render correctly 1`] = `
margin: 0 -16pt;
padding: 16pt 16pt 8pt;
overflow-y: auto;
position: relative;
}
.content > :global(*:first-child) {

View File

@@ -26,6 +26,7 @@ const ModalContent: React.FC<ModalContentProps> = ({ className, children, ...pro
margin: 0 -${theme.layout.gap};
padding: ${theme.layout.gap} ${theme.layout.gap} ${theme.layout.gapHalf};
overflow-y: auto;
position: relative;
}
.content > :global(*:first-child) {

View File

@@ -9,6 +9,7 @@ interface Props {
className?: string
dropdownStyle?: object
disableMatchWidth?: boolean
getPopupContainer?: () => HTMLElement | null
}
const defaultProps = {
@@ -25,12 +26,17 @@ const SelectDropdown: React.FC<React.PropsWithChildren<SelectDropdownProps>> = (
className,
dropdownStyle,
disableMatchWidth,
getPopupContainer,
}) => {
const theme = useTheme()
const { ref } = useSelectContext()
return (
<Dropdown parent={ref} visible={visible} disableMatchWidth={disableMatchWidth}>
<Dropdown
parent={ref}
visible={visible}
disableMatchWidth={disableMatchWidth}
getPopupContainer={getPopupContainer}>
<div className={`select-dropdown ${className}`} style={dropdownStyle}>
{children}
<style jsx>{`

View File

@@ -28,6 +28,7 @@ interface Props {
dropdownClassName?: string
dropdownStyle?: object
disableMatchWidth?: boolean
getPopupContainer?: () => HTMLElement | null
}
const defaultProps = {
@@ -60,6 +61,7 @@ const Select: React.FC<React.PropsWithChildren<SelectProps>> = ({
dropdownClassName,
dropdownStyle,
disableMatchWidth,
getPopupContainer,
...props
}) => {
const theme = useTheme()
@@ -148,7 +150,8 @@ const Select: React.FC<React.PropsWithChildren<SelectProps>> = ({
visible={visible}
className={dropdownClassName}
dropdownStyle={dropdownStyle}
disableMatchWidth={disableMatchWidth}>
disableMatchWidth={disableMatchWidth}
getPopupContainer={getPopupContainer}>
{children}
</SelectDropdown>
{!pure && (

View File

@@ -164,4 +164,24 @@ describe('Dropdown', () => {
expect(() => wrapper.unmount()).not.toThrow()
})
it('should render to specified container', () => {
const Mock: React.FC<{}> = () => {
const ref = useRef<HTMLDivElement>(null)
const customContainer = useRef<HTMLDivElement>(null)
return (
<div>
<div ref={customContainer} id="custom" />
<div ref={ref}>
<Dropdown parent={ref} visible getPopupContainer={() => customContainer.current}>
<span>test-value</span>
</Dropdown>
</div>
</div>
)
}
const wrapper = mount(<Mock />)
const customContainer = wrapper.find('#custom')
expect(customContainer.html()).toContain('dropdown')
})
})

View File

@@ -10,6 +10,7 @@ interface Props {
parent?: MutableRefObject<HTMLElement | null> | undefined
visible: boolean
disableMatchWidth?: boolean
getPopupContainer?: () => HTMLElement | null
}
interface ReactiveDomReact {
@@ -26,31 +27,48 @@ const defaultRect: ReactiveDomReact = {
width: 0,
}
const getRect = (ref: MutableRefObject<HTMLElement | null>): ReactiveDomReact => {
const getOffset = (el?: HTMLElement | null | undefined) => {
if (!el)
return {
top: 0,
left: 0,
}
const { top, left } = el.getBoundingClientRect()
return { top, left }
}
const getRect = (
ref: MutableRefObject<HTMLElement | null>,
getContainer?: () => HTMLElement | null,
): ReactiveDomReact => {
if (!ref || !ref.current) return defaultRect
const rect = ref.current.getBoundingClientRect()
const container = getContainer ? getContainer() : null
const scrollElement = container || document.documentElement
const { top: offsetTop, left: offsetLeft } = getOffset(container)
return {
...rect,
width: rect.width || rect.right - rect.left,
top: rect.bottom + document.documentElement.scrollTop,
left: rect.left + document.documentElement.scrollLeft,
top: rect.bottom + scrollElement.scrollTop - offsetTop,
left: rect.left + scrollElement.scrollLeft - offsetLeft,
}
}
const Dropdown: React.FC<React.PropsWithChildren<Props>> = React.memo(
({ children, parent, visible, disableMatchWidth }) => {
const el = usePortal('dropdown')
({ children, parent, visible, disableMatchWidth, getPopupContainer }) => {
const el = usePortal('dropdown', getPopupContainer)
const [rect, setRect] = useState<ReactiveDomReact>(defaultRect)
if (!parent) return null
const updateRect = () => {
const { top, left, right, width: nativeWidth } = getRect(parent)
const { top, left, right, width: nativeWidth } = getRect(parent, getPopupContainer)
setRect({ top, left, right, width: nativeWidth })
}
useResize(updateRect)
useClickAnyWhere(() => {
const { top, left } = getRect(parent)
const { top, left } = getRect(parent, getPopupContainer)
const shouldUpdatePosition = top !== rect.top || left !== rect.left
if (!shouldUpdatePosition) return
updateRect()

View File

@@ -8,7 +8,10 @@ const createElement = (id: string): HTMLElement => {
return el
}
const usePortal = (selectId: string = getId()): HTMLElement | null => {
const usePortal = (
selectId: string = getId(),
getContainer?: () => HTMLElement | null,
): HTMLElement | null => {
const id = `zeit-ui-${selectId}`
const { isBrowser } = useSSR()
const [elSnapshot, setElSnapshot] = useState<HTMLElement | null>(
@@ -16,11 +19,13 @@ const usePortal = (selectId: string = getId()): HTMLElement | null => {
)
useEffect(() => {
const hasElement = document.querySelector<HTMLElement>(`#${id}`)
const customContainer = getContainer ? getContainer() : null
const parentElement = customContainer || document.body
const hasElement = parentElement.querySelector<HTMLElement>(`#${id}`)
const el = hasElement || createElement(id)
if (!hasElement) {
document.body.appendChild(el)
parentElement.appendChild(el)
}
setElSnapshot(el)
}, [])

View File

@@ -1,5 +1,5 @@
import { Layout, Playground, Attributes } from 'lib/components'
import { Select, Spacer, Code } from 'components'
import { Select, Spacer, Code, Modal, useModal, Button } from 'components'
export const meta = {
title: 'select',
@@ -142,25 +142,56 @@ Display a dropdown list of items.
`}
/>
<Playground
title="Set parent element"
desc="you can specify the container for drop-down box rendering."
scope={{ Select, Spacer, useModal, Modal, Button, Code }}
code={`
() => {
const { visible, setVisible, bindings } = useModal()
return (
<>
<Button auto onClick={() => setVisible(true)}>Show Select</Button>
<Modal {...bindings}>
<Modal.Title>Modal</Modal.Title>
<Modal.Content id="customModalSelect">
<Select placeholder="Choose one" initialValue="1"
getPopupContainer={() => document.getElementById('customModalSelect')}>
<Select.Option value="1"><Code>TypeScript</Code></Select.Option>
<Select.Option value="2"><Code>JavaScript</Code></Select.Option>
</Select>
<p>Scroll through the content to see the changes.</p>
<div style={{ height: '1200px' }}></div>
<p>Scroll through the content to see the changes.</p>
</Modal.Content>
<Modal.Action passive onClick={() => setVisible(false)}>Cancel</Modal.Action>
</Modal>
</>
)
}
`}
/>
<Attributes edit="/pages/en-us/components/select.mdx">
<Attributes.Title>Select.Props</Attributes.Title>
| Attribute | Description | Type | Accepted values | Default |
| --------------------- | --------------------------------------- | --------------------------------------------------- | --------------------------------- | --------------- |
| **value** | selected value | `string`, `string[]` | - | - |
| **initialValue** | initial value | `string`, `string[]` | - | - |
| **placeholder** | placeholder string | `string` | - | - |
| **width** | css width value of select | `string` | - | `initial` |
| **size** | select component size | `NormalSizes` | [NormalSizes](#normalsizes) | `medium` |
| **icon** | icon component | `ComponentType` | - | `SVG Component` |
| **pure** | remove icon component | `boolean` | - | `false` |
| **multiple** | support multiple selection | `boolean` | - | `false` |
| **disabled** | disable current radio | `boolean` | - | `false` |
| **onChange** | selected value | <Code>(val: string &#124; string[]) => void </Code> | - | - |
| **dropdownClassName** | className of dropdown menu | `string` | - | - |
| **dropdownStyle** | style of dropdown menu | `object` | - | - |
| **disableMatchWidth** | disable Option from follow Select width | `boolean` | - | `false` |
| ... | native props | `HTMLAttributes` | `'name', 'alt', 'className', ...` | - |
| Attribute | Description | Type | Accepted values | Default |
| --------------------- | ----------------------------------------------------- | --------------------------------------------------- | --------------------------------- | --------------- |
| **value** | selected value | `string`, `string[]` | - | - |
| **initialValue** | initial value | `string`, `string[]` | - | - |
| **placeholder** | placeholder string | `string` | - | - |
| **width** | css width value of select | `string` | - | `initial` |
| **size** | select component size | `NormalSizes` | [NormalSizes](#normalsizes) | `medium` |
| **icon** | icon component | `ComponentType` | - | `SVG Component` |
| **pure** | remove icon component | `boolean` | - | `false` |
| **multiple** | support multiple selection | `boolean` | - | `false` |
| **disabled** | disable current radio | `boolean` | - | `false` |
| **onChange** | selected value | <Code>(val: string &#124; string[]) => void </Code> | - | - |
| **dropdownClassName** | className of dropdown menu | `string` | - | - |
| **dropdownStyle** | style of dropdown menu | `object` | - | - |
| **disableMatchWidth** | disable Option from follow Select width | `boolean` | - | `false` |
| **getPopupContainer** | dropdown render parent element, the default is `body` | `() => HTMLElement` | - | - |
| ... | native props | `HTMLAttributes` | `'name', 'alt', 'className', ...` | - |
<Attributes.Title>Select.Option.Props</Attributes.Title>

View File

@@ -1,5 +1,5 @@
import { Layout, Playground, Attributes } from 'lib/components'
import { Select, Spacer, Code } from 'components'
import { Select, Spacer, Code, Modal, useModal, Button } from 'components'
export const meta = {
title: '选择器 Select',
@@ -142,25 +142,56 @@ export const meta = {
`}
/>
<Playground
title="设置渲染容器"
desc="你可以指定下拉框的元素渲染的容器。"
scope={{ Select, Spacer, useModal, Modal, Button, Code }}
code={`
() => {
const { visible, setVisible, bindings } = useModal()
return (
<>
<Button auto onClick={() => setVisible(true)}>显示选择器</Button>
<Modal {...bindings}>
<Modal.Title>Modal</Modal.Title>
<Modal.Content id="customModalSelect">
<Select placeholder="选择语言" initialValue="1"
getPopupContainer={() => document.getElementById('customModalSelect')}>
<Select.Option value="1"><Code>TypeScript</Code></Select.Option>
<Select.Option value="2"><Code>JavaScript</Code></Select.Option>
</Select>
<p>滚动以查看下拉框的工作方式。(超出弹出层会被隐藏)</p>
<div style={{ height: '1200px' }}></div>
<p>滚动以查看下拉框的工作方式。</p>
</Modal.Content>
<Modal.Action passive onClick={() => setVisible(false)}>取消</Modal.Action>
</Modal>
</>
)
}
`}
/>
<Attributes edit="/pages/zh-cn/components/select.mdx">
<Attributes.Title>Select.Props</Attributes.Title>
| 属性 | 描述 | 类型 | 推荐值 | 默认 |
| --------------------- | ---------------------------- | --------------------------------------------------- | --------------------------------- | --------------- |
| **value** | 手动设置选择器的值 | `string`, `string[]` | - | - |
| **initialValue** | 选择器初始值 | `string`, `string[]` | - | - |
| **placeholder** | 占位文本内容 | `string` | - | - |
| **width** | 组件的 CSS 宽度值 | `string` | - | `initial` |
| **size** | 选择器组件大小 | `NormalSizes` | [NormalSizes](#normalsizes) | `medium` |
| **icon** | 右侧图标组件 | `ComponentType` | - | `SVG Component` |
| **pure** | 隐藏右侧图标组件 | `boolean` | - | `false` |
| **multiple** | 是否支持多选 | `boolean` | - | `false` |
| **disabled** | 禁用所有的交互 | `boolean` | - | `false` |
| **onChange** | 选项被选中所触发的事件 | <Code>(val: string &#124; string[]) => void </Code> | - | - |
| **dropdownClassName** | 下拉框的自定义类名 | `string` | - | - |
| **dropdownStyle** | 下拉框的自定义样式 | `object` | - | - |
| **disableMatchWidth** | 禁止 Option 跟随单选框的宽度 | `boolean` | - | `false` |
| ... | 原生属性 | `HTMLAttributes` | `'name', 'alt', 'className', ...` | - |
| 属性 | 描述 | 类型 | 推荐值 | 默认 |
| --------------------- | --------------------------------- | --------------------------------------------------- | --------------------------------- | --------------- |
| **value** | 手动设置选择器的值 | `string`, `string[]` | - | - |
| **initialValue** | 选择器初始值 | `string`, `string[]` | - | - |
| **placeholder** | 占位文本内容 | `string` | - | - |
| **width** | 组件的 CSS 宽度值 | `string` | - | `initial` |
| **size** | 选择器组件大小 | `NormalSizes` | [NormalSizes](#normalsizes) | `medium` |
| **icon** | 右侧图标组件 | `ComponentType` | - | `SVG Component` |
| **pure** | 隐藏右侧图标组件 | `boolean` | - | `false` |
| **multiple** | 是否支持多选 | `boolean` | - | `false` |
| **disabled** | 禁用所有的交互 | `boolean` | - | `false` |
| **onChange** | 选项被选中所触发的事件 | <Code>(val: string &#124; string[]) => void </Code> | - | - |
| **dropdownClassName** | 下拉框的自定义类名 | `string` | - | - |
| **dropdownStyle** | 下拉框的自定义样式 | `object` | - | - |
| **disableMatchWidth** | 禁止 Option 跟随单选框的宽度 | `boolean` | - | `false` |
| **getPopupContainer** | 下拉框渲染的父元素,默认是 `body` | `() => HTMLElement` | - | - |
| ... | 原生属性 | `HTMLAttributes` | `'name', 'alt', 'className', ...` | - |
<Attributes.Title>Select.Option.Props</Attributes.Title>