From 7ec0f3a4426ae0941b287cd58e7556b9bbf26b9d Mon Sep 17 00:00:00 2001 From: snowbitx <109521682+snowbitx@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:23:20 +0800 Subject: [PATCH] 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> --- docs/en-US/component/cascader.md | 40 ++- .../examples/cascader/check-on-click-node.vue | 309 ++++++++++++++++++ .../components/cascader-panel/src/config.ts | 8 + .../cascader-panel/src/node-content.tsx | 17 +- .../components/cascader-panel/src/node.ts | 2 + .../components/cascader-panel/src/node.vue | 16 +- .../cascader/__tests__/cascader.test.tsx | 61 ++++ packages/components/cascader/src/cascader.ts | 11 + packages/components/select/src/option.vue | 6 +- 9 files changed, 447 insertions(+), 23 deletions(-) create mode 100644 docs/examples/cascader/check-on-click-node.vue diff --git a/docs/en-US/component/cascader.md b/docs/en-US/component/cascader.md index f6c53fb9db..3561c3f12f 100644 --- a/docs/en-US/component/cascader.md +++ b/docs/en-US/component/cascader.md @@ -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 diff --git a/docs/examples/cascader/check-on-click-node.vue b/docs/examples/cascader/check-on-click-node.vue new file mode 100644 index 0000000000..e8e1b84bf7 --- /dev/null +++ b/docs/examples/cascader/check-on-click-node.vue @@ -0,0 +1,309 @@ + + + + Click to select any level (Single mode) + + + + Click to select any level (Multiple mode) + + + + + diff --git a/packages/components/cascader-panel/src/config.ts b/packages/components/cascader-panel/src/config.ts index c77007553a..da147f72cb 100644 --- a/packages/components/cascader-panel/src/config.ts +++ b/packages/components/cascader-panel/src/config.ts @@ -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({ diff --git a/packages/components/cascader-panel/src/node-content.tsx b/packages/components/cascader-panel/src/node-content.tsx index 6acb72e130..3ef459bbc9 100644 --- a/packages/components/cascader-panel/src/node-content.tsx +++ b/packages/components/cascader-panel/src/node-content.tsx @@ -19,17 +19,26 @@ export default defineComponent({ required: true, }, renderLabelFn: Function as PropType, + 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 () => {label()} + function handleClick() { + if (disabled || !checkOnClickNode) return + emit('handleSelectCheck', !node.checked) + } + return () => ( + + {label()} + + ) }, }) diff --git a/packages/components/cascader-panel/src/node.ts b/packages/components/cascader-panel/src/node.ts index e11e43b967..eb59ba3ae0 100644 --- a/packages/components/cascader-panel/src/node.ts +++ b/packages/components/cascader-panel/src/node.ts @@ -40,6 +40,8 @@ export interface CascaderProps { disabled?: string | isDisabled leaf?: string | isLeaf hoverThreshold?: number + checkOnClickNode?: boolean + showPrefix?: boolean } export type Nullable = null | T diff --git a/packages/components/cascader-panel/src/node.vue b/packages/components/cascader-panel/src/node.vue index 015e56f0a3..7293e38f34 100644 --- a/packages/components/cascader-panel/src/node.vue +++ b/packages/components/cascader-panel/src/node.vue @@ -20,7 +20,7 @@ > - - + + @@ -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) diff --git a/packages/components/cascader/__tests__/cascader.test.tsx b/packages/components/cascader/__tests__/cascader.test.tsx index 74156d03b6..d508bc43a4 100644 --- a/packages/components/cascader/__tests__/cascader.test.tsx +++ b/packages/components/cascader/__tests__/cascader.test.tsx @@ -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(() => ( + + )) + 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(() => ( + + )) + 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' + ) + }) + }) }) diff --git a/packages/components/cascader/src/cascader.ts b/packages/components/cascader/src/cascader.ts index d17ae1bdc7..3779716094 100644 --- a/packages/components/cascader/src/cascader.ts +++ b/packages/components/cascader/src/cascader.ts @@ -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, }) diff --git a/packages/components/select/src/option.vue b/packages/components/select/src/option.vue index a241a6c45c..ce74ace746 100644 --- a/packages/components/select/src/option.vue +++ b/packages/components/select/src/option.vue @@ -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,
Click to select any level (Single mode)
Click to select any level (Multiple mode)