feat(components): [cascader] add checkOnClickNode and prefix prop (#21089)

* feat(components): [cascader] Hide radio in single mode

* feat(components): [cascader] Renamed prop to \`selectOnClick\`

* feat(components): [cascader] Update doc

* feat(components): [cascader] Adjust how data is fetched

* feat(components): [cascader] update type

* Update packages/components/cascader-panel/src/node-content.ts

Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com>

* feat(components): [cascader] Merge and fix v-model bug

* feat(components): [cascader] Renamed to checkOnClickNode

* feat(components): [cascader] Renamed to checkOnClickNode

* feat(components): [cascader] merge

* feat(components): [cascader] Add a check

* feat(components): [cascader] Add test case

* feat(components): [cascader] Fix type error

* feat(components): [cascader] Add checkOnClickNode, showRadio props

* feat(components): [cascader] Update doc

* Update packages/components/cascader/src/cascader.ts

Done, thanks!

Co-authored-by: btea <2356281422@qq.com>

* fix: showRadio defaults checkOnClickNode

* test: showRadio fallback behavior

* refactor: rename prop and support checkOnClickNode for multiple mode

* doc:update

* Update packages/components/cascader-panel/src/config.ts

Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com>

* docs: tweak props

* docs: update doc

* chore: update doc & fix toggle check

* Update docs/en-US/component/cascader.md

Co-authored-by: btea <2356281422@qq.com>

* Update docs/en-US/component/cascader.md

Co-authored-by: btea <2356281422@qq.com>

* Update docs/en-US/component/cascader.md

Co-authored-by: btea <2356281422@qq.com>

* Update cascader-panel.test.tsx

---------

Co-authored-by: WANGXIAOYU1995 <109521682+WANGXIAOYU1995@users.noreply.github.com>
Co-authored-by: Noblet Ouways <91417411+Dsaquel@users.noreply.github.com>
Co-authored-by: btea <2356281422@qq.com>
Co-authored-by: Dsaquel <291874700n@gmail.com>
This commit is contained in:
snowbitx
2025-07-22 17:23:20 +08:00
committed by GitHub
parent 926a4b2d7c
commit 7ec0f3a442
9 changed files with 447 additions and 23 deletions

View File

@@ -148,6 +148,16 @@ cascader/custom-tag
:::
## Click to Select Node ^(2.10.5)
Allows selecting nodes by clicking, with optional control visibility.
:::demo Select nodes on click via `checkOnClickNode`. You can also hide the selection control with `showPrefix`.
cascader/check-on-click-node
:::
## Cascader API
### Cascader Attributes
@@ -250,20 +260,22 @@ cascader/custom-tag
## CascaderProps
| Attribute | Description | Type | Default |
| -------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -------- |
| expandTrigger | trigger mode of expanding options | ^[enum]`'click' \| 'hover'` | click |
| multiple | whether multiple selection is enabled | ^[boolean] | false |
| checkStrictly | whether checked state of a node not affects its parent and child nodes | ^[boolean] | false |
| emitPath | when checked nodes change, whether to emit an array of node's path, if false, only emit the value of node. | ^[boolean] | true |
| lazy | whether to dynamic load child nodes, use with `lazyload` attribute | ^[boolean] | false |
| lazyLoad | method for loading child nodes data, only works when `lazy` is true | ^[Function]`(node: Node, resolve: Resolve) => void` | — |
| value | specify which key of node object is used as the node's value | ^[string] | value |
| label | specify which key of node object is used as the node's label | ^[string] | label |
| children | specify which key of node object is used as the node's children | ^[string] | children |
| disabled | specify which key of node object is used as the node's disabled | ^[string] | disabled |
| leaf | specify which key of node object is used as the node's leaf field | ^[string] | leaf |
| hoverThreshold | hover threshold of expanding options | ^[number] | 500 |
| Attribute | Description | Type | Default |
| -------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -------- |
| expandTrigger | trigger mode of expanding options | ^[enum]`'click' \| 'hover'` | click |
| multiple | whether multiple selection is enabled | ^[boolean] | false |
| checkStrictly | whether checked state of a node not affects its parent and child nodes | ^[boolean] | false |
| emitPath | when checked nodes change, whether to emit an array of node's path, if false, only emit the value of node. | ^[boolean] | true |
| lazy | whether to dynamic load child nodes, use with `lazyload` attribute | ^[boolean] | false |
| lazyLoad | method for loading child nodes data, only works when `lazy` is true | ^[Function]`(node: Node, resolve: Resolve) => void` | — |
| value | specify which key of node object is used as the node's value | ^[string] | value |
| label | specify which key of node object is used as the node's label | ^[string] | label |
| children | specify which key of node object is used as the node's children | ^[string] | children |
| disabled | specify which key of node object is used as the node's disabled | ^[string] | disabled |
| leaf | specify which key of node object is used as the node's leaf field | ^[string] | leaf |
| hoverThreshold | hover threshold of expanding options | ^[number] | 500 |
| checkOnClickNode ^(2.10.5) | whether to check or uncheck node when clicking on the node | ^[boolean] | false |
| showPrefix ^(2.10.5) | whether to show the radio or checkbox prefix | ^[boolean] | true |
## Type Declarations

View File

@@ -0,0 +1,309 @@
<template>
<el-switch
v-model="showPrefix"
active-text="show prefix"
inactive-text="hide prefix"
/>
<div class="m-4">
<p>Click to select any level (Single mode)</p>
<el-cascader v-model="value" :options="options" :props="props1" clearable />
</div>
<div class="m-4">
<p>Click to select any level (Multiple mode)</p>
<el-cascader
v-model="value2"
:options="options"
:props="props2"
clearable
/>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
const value = ref()
const value2 = ref()
const showPrefix = ref(true)
const props1 = computed(() => ({
showPrefix: showPrefix.value,
checkStrictly: true,
checkOnClickNode: true,
}))
const props2 = computed(() => ({
showPrefix: showPrefix.value,
multiple: true,
checkStrictly: true,
checkOnClickNode: true,
}))
const options = [
{
value: 'guide',
label: 'Guide',
children: [
{
value: 'disciplines',
label: 'Disciplines',
children: [
{
value: 'consistency',
label: 'Consistency',
},
{
value: 'feedback',
label: 'Feedback',
},
{
value: 'efficiency',
label: 'Efficiency',
},
{
value: 'controllability',
label: 'Controllability',
},
],
},
{
value: 'navigation',
label: 'Navigation',
children: [
{
value: 'side nav',
label: 'Side Navigation',
},
{
value: 'top nav',
label: 'Top Navigation',
},
],
},
],
},
{
value: 'component',
label: 'Component',
children: [
{
value: 'basic',
label: 'Basic',
children: [
{
value: 'layout',
label: 'Layout',
},
{
value: 'color',
label: 'Color',
},
{
value: 'typography',
label: 'Typography',
},
{
value: 'icon',
label: 'Icon',
},
{
value: 'button',
label: 'Button',
},
],
},
{
value: 'form',
label: 'Form',
children: [
{
value: 'radio',
label: 'Radio',
},
{
value: 'checkbox',
label: 'Checkbox',
},
{
value: 'input',
label: 'Input',
},
{
value: 'input-number',
label: 'InputNumber',
},
{
value: 'select',
label: 'Select',
},
{
value: 'cascader',
label: 'Cascader',
},
{
value: 'switch',
label: 'Switch',
},
{
value: 'slider',
label: 'Slider',
},
{
value: 'time-picker',
label: 'TimePicker',
},
{
value: 'date-picker',
label: 'DatePicker',
},
{
value: 'datetime-picker',
label: 'DateTimePicker',
},
{
value: 'upload',
label: 'Upload',
},
{
value: 'rate',
label: 'Rate',
},
{
value: 'form',
label: 'Form',
},
],
},
{
value: 'data',
label: 'Data',
children: [
{
value: 'table',
label: 'Table',
},
{
value: 'tag',
label: 'Tag',
},
{
value: 'progress',
label: 'Progress',
},
{
value: 'tree',
label: 'Tree',
},
{
value: 'pagination',
label: 'Pagination',
},
{
value: 'badge',
label: 'Badge',
},
],
},
{
value: 'notice',
label: 'Notice',
children: [
{
value: 'alert',
label: 'Alert',
},
{
value: 'loading',
label: 'Loading',
},
{
value: 'message',
label: 'Message',
},
{
value: 'message-box',
label: 'MessageBox',
},
{
value: 'notification',
label: 'Notification',
},
],
},
{
value: 'navigation',
label: 'Navigation',
children: [
{
value: 'menu',
label: 'Menu',
},
{
value: 'tabs',
label: 'Tabs',
},
{
value: 'breadcrumb',
label: 'Breadcrumb',
},
{
value: 'dropdown',
label: 'Dropdown',
},
{
value: 'steps',
label: 'Steps',
},
],
},
{
value: 'others',
label: 'Others',
children: [
{
value: 'dialog',
label: 'Dialog',
},
{
value: 'tooltip',
label: 'Tooltip',
},
{
value: 'popover',
label: 'Popover',
},
{
value: 'card',
label: 'Card',
},
{
value: 'carousel',
label: 'Carousel',
},
{
value: 'collapse',
label: 'Collapse',
},
],
},
],
},
{
value: 'resource',
label: 'Resource',
children: [
{
value: 'axure',
label: 'Axure Components',
},
{
value: 'sketch',
label: 'Sketch Templates',
},
{
value: 'docs',
label: 'Design Documentation',
},
],
},
]
</script>

View File

@@ -84,6 +84,14 @@ export const DefaultProps: CascaderConfig = {
* @description hover threshold of expanding options
*/
hoverThreshold: 500,
/**
* @description whether to check or uncheck node when clicking on the node
*/
checkOnClickNode: false,
/**
* @description whether to show the radio or checkbox prefix
*/
showPrefix: true,
}
export const cascaderPanelProps = buildProps({

View File

@@ -19,17 +19,26 @@ export default defineComponent({
required: true,
},
renderLabelFn: Function as PropType<RenderLabel>,
checkOnClickNode: Boolean,
disabled: Boolean,
},
setup(props) {
setup(props, { emit }) {
const ns = useNamespace('cascader-node')
const { renderLabelFn, node } = props
const { renderLabelFn, node, disabled, checkOnClickNode } = props
const { data, label: nodeLabel } = node
const label = () => {
const renderLabel = renderLabelFn?.({ node, data })
return isVNodeEmpty(renderLabel) ? nodeLabel : renderLabel ?? nodeLabel
}
return () => <span class={ns.e('label')}>{label()}</span>
function handleClick() {
if (disabled || !checkOnClickNode) return
emit('handleSelectCheck', !node.checked)
}
return () => (
<span class={ns.e('label')} onClick={handleClick}>
{label()}
</span>
)
},
})

View File

@@ -40,6 +40,8 @@ export interface CascaderProps {
disabled?: string | isDisabled
leaf?: string | isLeaf
hoverThreshold?: number
checkOnClickNode?: boolean
showPrefix?: boolean
}
export type Nullable<T> = null | T

View File

@@ -20,7 +20,7 @@
>
<!-- prefix -->
<el-checkbox
v-if="multiple"
v-if="multiple && showPrefix"
:model-value="node.checked"
:indeterminate="node.indeterminate"
:disabled="isDisabled"
@@ -28,7 +28,7 @@
@update:model-value="handleSelectCheck"
/>
<el-radio
v-else-if="checkStrictly"
v-else-if="checkStrictly && showPrefix"
:model-value="checkedNodeId"
:label="node.uid"
:disabled="isDisabled"
@@ -45,8 +45,14 @@
<check />
</el-icon>
<node-content :render-label-fn="panel.renderLabelFn" :node="node" />
<!-- content -->
<node-content
:render-label-fn="panel.renderLabelFn"
:node="node"
:disabled="isDisabled"
:check-on-click-node="checkOnClickNode"
@handle-select-check="handleSelectCheck"
/>
<!-- postfix -->
<template v-if="!isLeaf">
<el-icon v-if="node.loading" :class="[ns.is('loading'), ns.e('postfix')]">
@@ -92,6 +98,8 @@ const ns = useNamespace('cascader-node')
const isHoverMenu = computed(() => panel.isHoverMenu)
const multiple = computed(() => panel.config.multiple)
const checkStrictly = computed(() => panel.config.checkStrictly)
const showPrefix = computed(() => panel.config.showPrefix)
const checkOnClickNode = computed(() => panel.config.checkOnClickNode)
const checkedNodeId = computed(() => panel.checkedNodes[0]?.uid)
const isDisabled = computed(() => props.node.isDisabled)
const isLeaf = computed(() => props.node.isLeaf)

View File

@@ -51,6 +51,7 @@ const AXIOM = 'Rem is the best girl'
const TRIGGER = '.el-cascader'
const NODE = '.el-cascader-node'
const NODE_LABEL = '.el-cascader-node__label'
const TAG = '.el-tag'
const SUGGESTION_ITEM = '.el-cascader__suggestion-item'
const SUGGESTION_PANEL = '.el-cascader__suggestion-panel'
@@ -856,4 +857,64 @@ describe('Cascader.vue', () => {
expect(newCascaderNodes[3].text()).toBe('Testing')
})
})
describe('Cascader - click to select node', () => {
it('selects parent node directly on click', async () => {
const value = ref([])
const props = { checkStrictly: true, checkOnClickNode: true }
const wrapper = _mount(() => (
<Cascader v-model={value.value} options={OPTIONS} props={props} />
))
const trigger = wrapper.find(TRIGGER)
await trigger.trigger('click')
await nextTick()
const nodes = document.querySelectorAll(NODE_LABEL)
await (nodes[0] as HTMLElement).click()
await nextTick()
const newNodes = document.querySelectorAll(NODE_LABEL)
const hangzhouNode = Array.from(newNodes).find((el) =>
el.textContent?.includes('Hangzhou')
)
expect(hangzhouNode).toBeTruthy()
await (hangzhouNode as HTMLElement).click()
await nextTick()
expect(value.value).toEqual(['zhejiang', 'hangzhou'])
const input = wrapper.find('input')
expect((input.element as HTMLInputElement).value).toBe(
'Zhejiang / Hangzhou'
)
})
it('hides radio but still selects node on click when showPrefix is false', async () => {
const value = ref([])
const props = {
checkStrictly: true,
checkOnClickNode: true,
showPrefix: false,
}
const wrapper = _mount(() => (
<Cascader v-model={value.value} options={OPTIONS} props={props} />
))
const trigger = wrapper.find(TRIGGER)
await trigger.trigger('click')
await nextTick()
const radios = wrapper.findAll('input[type="radio"]')
expect(radios.length).toBe(0)
const nodes = document.querySelectorAll(NODE_LABEL)
await (nodes[0] as HTMLElement).click()
await nextTick()
const newNodes = document.querySelectorAll(NODE_LABEL)
const hangzhouNode = Array.from(newNodes).find((el) =>
el.textContent?.includes('Hangzhou')
)
expect(hangzhouNode).toBeTruthy()
await (hangzhouNode as HTMLElement).click()
await nextTick()
expect(value.value).toEqual(['zhejiang', 'hangzhou'])
const input = wrapper.find('input')
expect((input.element as HTMLInputElement).value).toBe(
'Zhejiang / Hangzhou'
)
})
})
})

View File

@@ -142,6 +142,17 @@ export const cascaderProps = buildProps({
type: Boolean,
default: true,
},
/**
* @description whether to check or uncheck node when clicking on the node
*/
checkOnClickNode: Boolean,
/**
* @description whether to show the radio or checkbox prefix
*/
showPrefix: {
type: Boolean,
default: true,
},
...useEmptyValuesProps,
})

View File

@@ -30,7 +30,11 @@ import { useId, useNamespace } from '@element-plus/hooks'
import { useOption } from './useOption'
import { COMPONENT_NAME, optionProps } from './option'
import type { OptionExposed, OptionInternalInstance, OptionStates } from './type'
import type {
OptionExposed,
OptionInternalInstance,
OptionStates,
} from './type'
export default defineComponent({
name: COMPONENT_NAME,