mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
309
docs/examples/cascader/check-on-click-node.vue
Normal file
309
docs/examples/cascader/check-on-click-node.vue
Normal 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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user