Skip to content

Commit

Permalink
feat(comp:*): add overlayTabindex prop for all overlayed controls (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
sallerli1 authored Jul 30, 2024
1 parent 32ac59c commit 0778970
Show file tree
Hide file tree
Showing 37 changed files with 216 additions and 106 deletions.
23 changes: 23 additions & 0 deletions packages/cdk/utils/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,29 @@ export function isTouchEvent(evt: MouseEvent | TouchEvent): evt is TouchEvent {
return evt.type.startsWith('touch')
}

export function isFocusable(element: unknown): boolean {
if (!element || (!(element instanceof HTMLElement) && !(element instanceof SVGElement))) {
return false
}

if (element.getAttribute('tabIndex') !== null) {
return true
}

switch (element.nodeName) {
case 'A':
return !!(element as HTMLAnchorElement).href && (element as HTMLAnchorElement).rel != 'ignore'
case 'INPUT':
return (element as HTMLInputElement).type != 'hidden' && (element as HTMLInputElement).type != 'file'
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
return true
default:
return element instanceof HTMLElement ? element.isContentEditable : false
}
}

export function getMouseEvent(evt: MouseEvent | TouchEvent): MouseEvent | Touch {
return isTouchEvent(evt) ? evt.touches[0] || evt.changedTouches[0] : evt
}
Expand Down
4 changes: 2 additions & 2 deletions packages/components/_private/trigger/src/Trigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { computed, defineComponent, normalizeClass, onBeforeUnmount, onMounted,
import { isArray, isNil, toString } from 'lodash-es'

import { useSharedFocusMonitor } from '@idux/cdk/a11y'
import { callEmit, isEmptyNode, useState } from '@idux/cdk/utils'
import { callEmit, isEmptyNode, isFocusable, useState } from '@idux/cdk/utils'
import { useGlobalConfig } from '@idux/components/config'
import { IxIcon } from '@idux/components/icon'

Expand Down Expand Up @@ -97,7 +97,7 @@ export default defineComponent({
})

const handleMouseDown = (evt: MouseEvent) => {
if (evt.target instanceof HTMLInputElement) {
if (isFocusable(evt.target)) {
return
}

Expand Down
13 changes: 2 additions & 11 deletions packages/components/cascader/src/Cascader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,6 @@ export default defineComponent({
clearInput()
})

const handleOverlayMousedown = () => {
if (props.searchable !== 'overlay') {
setTimeout(focus)
}
}

const onFocus = (evt: FocusEvent) => {
callEmit(props.onFocus, evt)
}
Expand Down Expand Up @@ -212,11 +206,7 @@ export default defineComponent({
)
}

return (
<div tabindex={-1} onMousedown={handleOverlayMousedown}>
{overlayRender ? overlayRender(children) : children}
</div>
)
return <div>{overlayRender ? overlayRender(children) : children}</div>
}

return () => {
Expand All @@ -225,6 +215,7 @@ export default defineComponent({
autofocus: props.autofocus,
overlayClassName: overlayClasses.value,
overlayContainer: props.overlayContainer ?? config.overlayContainer,
overlayTabindex: props.overlayTabindex ?? config.overlayTabindex,
overlayContainerFallback: `.${mergedPrefixCls.value}-overlay-container`,
overlayMatchWidth: props.overlayMatchWidth ?? config.overlayMatchWidth,
class: mergedPrefixCls.value,
Expand Down
1 change: 1 addition & 0 deletions packages/components/cascader/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const cascaderProps = {
type: [String, HTMLElement, Function] as PropType<OverlayContainerType>,
default: undefined,
},
overlayTabindex: { type: Number, default: undefined },
overlayMatchWidth: { type: [Boolean, String] as PropType<boolean | 'minWidth'>, default: undefined },
overlayRender: { type: Function as PropType<(children: VNode[]) => VNodeChild>, default: undefined },
placeholder: { type: String, default: undefined },
Expand Down
5 changes: 5 additions & 0 deletions packages/components/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export interface CascaderConfig {
getKey: string | ((data: CascaderData<any>) => any)
labelKey: string
overlayContainer?: OverlayContainerType
overlayTabindex?: number
overlayMatchWidth: boolean
size: FormSize
suffix: string
Expand Down Expand Up @@ -216,6 +217,7 @@ export interface DatePickerConfig {
size: FormSize
suffix: string
overlayContainer?: OverlayContainerType
overlayTabindex?: number
}

export interface DescConfig {
Expand Down Expand Up @@ -429,6 +431,7 @@ export interface SelectConfig {
labelKey: string
offset: [number, number]
overlayContainer?: OverlayContainerType
overlayTabindex?: number
overlayMatchWidth: boolean
size: FormSize
suffix: string
Expand Down Expand Up @@ -548,6 +551,7 @@ export interface TimePickerConfig {
size: FormSize
suffix: string
overlayContainer?: OverlayContainerType
overlayTabindex?: number
allowInput: boolean | 'overlay'
format: string
}
Expand Down Expand Up @@ -604,6 +608,7 @@ export interface TreeSelectConfig {
labelKey: string
offset: [number, number]
overlayContainer?: OverlayContainerType
overlayTabindex?: number
overlayMatchWidth: boolean
size: FormSize
suffix: string
Expand Down
1 change: 1 addition & 0 deletions packages/components/control-trigger/docs/Api.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| `overlayClassName` | 下拉菜单的 `class` | `string` | - | - | - |
| `overlayContainer` | 自定义浮层容器节点 | `string \| HTMLElement \| (trigger?: Element) => string \| HTMLElement` | - | - | - |
| `overlayContainerFallback` | 默认的浮层容器节点class | `string` | - | - | - |
| `overlayTabindex` | 自定义浮层tabindex | `number` | - | - | - |
| `overlayLazy` | 浮层是否懒渲染 | `boolean` | `true` | - | - |
| `overlayMatchWidth` | 下拉菜单和选择器同宽 | `boolean` | `false` | - | - |
| `paddingless` | 是否去掉内边距 | `boolean` | `false` | - | - |
Expand Down
30 changes: 18 additions & 12 deletions packages/components/control-trigger/src/ControlTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useMergedCommonControlProps, useOverlayFocusMonitor } from '@idux/compo

import ControlTriggerOverlay from './ControlTriggerOverlay'
import { useOverlayState } from './composables/useOverlayState'
import { useTriggerFocusState } from './composables/useTriggerFocusState'
import { controlTriggerToken } from './token'
import { type ControlTriggerSlots, controlTriggerProps } from './types'

Expand All @@ -37,27 +38,21 @@ export default defineComponent({
const mergedPrefixCls = computed(() => `${common.prefixCls}-control-trigger`)
const triggerRef = ref<ɵTriggerInstance>()

const focus = () => triggerRef.value?.focus()
const blur = () => triggerRef.value?.blur()

expose({
focus,
blur,
})

const mergedControlProps = useMergedCommonControlProps(props, defaultTriggerProps)

const onFocus = (evt: FocusEvent) => {
callEmit(props.onFocus, evt)
handleTriggerFocus(evt)
}
const onBlur = (evt: FocusEvent) => {
setOverlayOpened(false)
callEmit(props.onBlur, evt)
}
const { focused, triggerFocused, bindOverlayMonitor, handleFocus, handleBlur } = useOverlayFocusMonitor(
onFocus,
onBlur,
)
const { focused, triggerFocused, overlayFocused, focusVia, blurVia, bindOverlayMonitor, handleFocus, handleBlur } =
useOverlayFocusMonitor(onFocus, onBlur)

const { resetTriggerFocus, handleTriggerFocus } = useTriggerFocusState(triggerRef, triggerFocused)

const { overlayOpened, overlayRef, overlayMatchWidth, overlayStyle, setOverlayOpened } = useOverlayState(
props,
defaultTriggerProps,
Expand All @@ -66,9 +61,19 @@ export default defineComponent({
triggerFocused,
)

const focus = () => focusVia(triggerRef.value)
const blur = () => blurVia(triggerRef.value)

expose({
focus,
blur,
})

provide(controlTriggerToken, {
props,
mergedPrefixCls,
overlayFocused,
resetTriggerFocus,
bindOverlayMonitor,
})

Expand Down Expand Up @@ -175,6 +180,7 @@ export default defineComponent({
style={overlayStyle.value}
visible={overlayOpened.value}
lazy={props.overlayLazy}
tabindex={props.overlayTabindex}
trigger="manual"
v-slots={{ default: renderTrigger, content: renderContent }}
/>
Expand Down
31 changes: 27 additions & 4 deletions packages/components/control-trigger/src/ControlTriggerOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/

import { computed, defineComponent, inject, onMounted, ref } from 'vue'
import { computed, defineComponent, inject, onMounted, shallowRef, watch } from 'vue'

import { isFocusable, useEventListener } from '@idux/cdk/utils'
import { ɵOverlay, type ɵOverlayInstance } from '@idux/components/_private/overlay'
import { useGlobalConfig } from '@idux/components/config'

Expand All @@ -21,9 +22,16 @@ export default defineComponent({
props: controlTrigglerOverlayProps,
setup(props, { expose, slots, attrs }) {
const common = useGlobalConfig('common')
const { props: controlTriggerProps, mergedPrefixCls, bindOverlayMonitor } = inject(controlTriggerToken)!
const {
props: controlTriggerProps,
mergedPrefixCls,
overlayFocused,
bindOverlayMonitor,
resetTriggerFocus,
} = inject(controlTriggerToken)!

const overlayRef = ref<ɵOverlayInstance>()
const overlayRef = shallowRef<ɵOverlayInstance>()
const popperElRef = computed(() => overlayRef.value?.getPopperElement())
const updatePopper = () => {
overlayRef.value?.updatePopper()
}
Expand Down Expand Up @@ -52,10 +60,25 @@ export default defineComponent({

const overlayOpened = computed(() => props.visible ?? false)

watch(overlayOpened, opened => {
if (!opened && overlayFocused.value) {
resetTriggerFocus()
}
})
useEventListener(popperElRef, 'mousedown', evt => {
if (isFocusable(evt.target) || isFocusable(evt.currentTarget)) {
return
}

resetTriggerFocus()
})

onMounted(() => {
bindOverlayMonitor(overlayRef, overlayOpened)
})

return () => <ɵOverlay ref={overlayRef} tabindex={-1} {...overlayProps.value} {...attrs} v-slots={slots} />
return () => (
<ɵOverlay ref={overlayRef} tabindex={props.tabindex} {...overlayProps.value} {...attrs} v-slots={slots} />
)
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @license
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/

import type { ɵTriggerInstance } from '@idux/components/_private/trigger'

import { type ComputedRef, type Ref, nextTick, watch } from 'vue'

import { useEventListener, useState } from '@idux/cdk/utils'

export interface TriggerFocusStateContext {
handleTriggerFocus: (evt: FocusEvent) => void
resetTriggerFocus: () => void
}

export function useTriggerFocusState(
triggerRef: Ref<ɵTriggerInstance | undefined>,
triggerFocused: ComputedRef<boolean>,
): TriggerFocusStateContext {
const [focusTarget, setFocusTarget] = useState<HTMLElement | null>(null)
const [selectionStart, setSelectionStart] = useState<number | null>(null)

const updateSelectionStart = () => {
const element = focusTarget.value as HTMLInputElement

setTimeout(() => {
if (element.selectionDirection === 'backward') {
setSelectionStart(element.selectionStart)
} else {
setSelectionStart(element.selectionEnd)
}
})
}

let stops: ((() => void) | undefined)[] | undefined
const destroyListeners = () => stops?.forEach(stop => stop?.())

watch(focusTarget, (target, oldTarget) => {
if (target === oldTarget) {
return
}

if (!(target instanceof HTMLInputElement)) {
setSelectionStart(null)
destroyListeners()
return
}

updateSelectionStart()

if (stops?.length) {
return
}

stops = [
useEventListener(focusTarget, 'select', updateSelectionStart),
useEventListener(focusTarget, 'input', updateSelectionStart),
useEventListener(focusTarget, 'mousedown', updateSelectionStart),
useEventListener(focusTarget, 'keydown', updateSelectionStart),
]
})

const handleTriggerFocus = (evt: FocusEvent) => {
nextTick(() => {
if (triggerFocused.value && evt.target instanceof HTMLElement) {
setFocusTarget(evt.target)
}
})
}

const resetTriggerFocus = () => {
const target = focusTarget.value

if (!target) {
triggerRef.value?.focus()
return
}

target.focus()

if (target instanceof HTMLInputElement) {
target.setSelectionRange(selectionStart.value, selectionStart.value)
}
}

return {
handleTriggerFocus,
resetTriggerFocus,
}
}
2 changes: 2 additions & 0 deletions packages/components/control-trigger/src/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import type { ComputedRef, InjectionKey, Ref } from 'vue'

export interface ControlTriggerContext {
props: ControlTriggerProps
overlayFocused: ComputedRef<boolean>
mergedPrefixCls: ComputedRef<string>
bindOverlayMonitor: (overlayRef: Ref<ɵOverlayInstance | undefined>, overlayOpened: Ref<boolean>) => void
resetTriggerFocus: () => void
}

export const controlTriggerToken: InjectionKey<ControlTriggerContext> = Symbol('controlTriggerToken')
2 changes: 2 additions & 0 deletions packages/components/control-trigger/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const controlTriggerProps = {
type: [String, HTMLElement, Function] as PropType<OverlayContainerType>,
default: undefined,
},
overlayTabindex: { type: Number, default: undefined },
overlayContainerFallback: String,
overlayLazy: { type: Boolean, default: true },
overlayMatchWidth: { type: [Boolean, String] as PropType<boolean | 'minWidth'>, default: undefined },
Expand Down Expand Up @@ -50,6 +51,7 @@ export const controlTrigglerOverlayProps = {
type: Boolean,
default: undefined,
},
tabindex: { type: Number, default: undefined },
onAfterLeave: [Function, Array] as PropType<MaybeArray<() => void>>,
}

Expand Down
1 change: 1 addition & 0 deletions packages/components/date-picker/docs/Api.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
| `overlayClassName` | 日期面板的 `class` | `string` | - | - | - |
| `overlayContainer` | 自定义浮层容器节点 | `string \| HTMLElement \| (trigger?: Element) => string \| HTMLElement` | - || - |
| `overlayRender` | 自定义日期面板内容的渲染 | `(children:VNode[]) => VNodeChild` | - | - | - |
| `overlayTabindex` | 自定义浮层tabindex | `number` | - || - |
| `readonly` | 只读模式 | `boolean` | - | - | - |
| `size` | 设置选择器大小 | `'sm' \| 'md' \| 'lg'` | `md` || - |
| `status` | 手动指定校验状态 | `valid \| invalid \| validating` | - | - | - |
Expand Down
Loading

0 comments on commit 0778970

Please sign in to comment.