mirror of https://github.com/halo-dev/halo
refactor: enhance formkit select component to support additional features (#6473)
#### What type of PR is this? /kind feature /area ui /milestone 2.19.x #### What this PR does / why we need it: 此 PR 使用自定义的 Select 组件替换了原有的 Formkit Select 组件,原 Select 组件类型变为 `nativeSelect`。 新的 Select 组件具有如下功能: - 增加多选、单选两种模式。 - 支持对内容进行搜索、过滤。 - 可使用接口远程加载,并可自定义数据获取。 - 支持扩展远程加载方式。 - 可创建新选项。 - 支持清空所有已选择项。 - 多选状态下可控制最大选择数量。 - 多选状态下可进行排序。 重构后的 Select 组件将自动兼容旧版组件。 使用方式如下: ```vue <FormKit type="select" label="What country makes the best food?" name="countries" placeholder="Select a country" allow-create clearable sortable multiple searchable :max-count="3" :options="[ { label: 'France', value: 'fr'}, { label: 'Germany', value: 'de'}, { label: 'Spain', value: 'es'}, { label: 'Italy', value: 'ie' }, { label: 'Greece', value: 'gr'}, ]" help="Don’t worry, you can’t get this one wrong." /> ``` #### How to test it? 1. 需要测试已使用的 Select 组件功能是否发生了变化。 测试在多选、单选状态下,Select 组件的功能是否可以正常使用。 测试在远程加载时,数据获取是否正常,是否可正常分页,加载状态是否显示。 #### Which issue(s) this PR fixes: see https://github.com/halo-dev/halo/issues/4931#issuecomment-2060637101 see #6369 #### Does this PR introduce a user-facing change? ```release-note 重构 FormKit 选择器组件以支持更多功能 ```pull/6526/head
parent
3db80bfaf3
commit
3be91fcb6f
|
@ -57,6 +57,20 @@
|
|||
- `secret`: 用于选择或者管理密钥(Secret)
|
||||
- 参数
|
||||
1. requiredKey:用于确认所需密钥的字段名称
|
||||
- `select`: 自定义的选择器组件,用于在备选项中选择一个或多个选项
|
||||
- 参数
|
||||
1. `options`:静态数据源。当 `action` 或 `remote` 存在时,此参数无效。
|
||||
2. `action`:远程动态数据源的接口地址。
|
||||
3. `requestOption`: 动态数据源的请求参数,可以通过此参数来指定如何获取数据,适配不同的接口。当 `action` 存在时,此参数有效。
|
||||
4. `remote`:标识当前是否由用户自定义的远程数据源。
|
||||
5. `remoteOption`:当 `remote` 为 `true` 时,此配置项必须存在,用于为 Select 组件提供处理搜索及查询键值对的方法。
|
||||
6. `remoteOptimize`:是否开启远程数据源优化,默认为 `true`。开启后,将会对远程数据源进行优化,减少请求次数。仅在动态数据源下有效。
|
||||
7. `allowCreate`:是否允许创建新选项,默认为 `false`。仅在静态数据源下有效。
|
||||
8. `clearable`:是否允许清空选项,默认为 `false`。
|
||||
9. `multiple`:是否多选,默认为 `false`。
|
||||
10. `maxCount`:多选时最大可选数量,默认为 `Infinity`。仅在多选时有效。
|
||||
11. `sortable`:是否支持拖动排序,默认为 `false`。仅在多选时有效。
|
||||
12. `searchable`:是否支持搜索内容,默认为 `false`。
|
||||
|
||||
在 Vue 单组件中使用:
|
||||
|
||||
|
@ -84,6 +98,143 @@ const postName = ref("");
|
|||
label: 底部菜单组
|
||||
```
|
||||
|
||||
### select
|
||||
|
||||
select 是一个选择器类型的输入组件,使用者可以从一批待选数据中选择一个或多个选项。它支持单选、多选操作,并且支持静态数据及远程动态数据加载等多种方式。
|
||||
|
||||
#### 在 Vue SFC 中以组件形式使用
|
||||
|
||||
静态数据源:
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup></script>
|
||||
<template>
|
||||
<FormKit
|
||||
type="select"
|
||||
label="What country makes the best food?"
|
||||
name="countries"
|
||||
placeholder="Select a country"
|
||||
allow-create
|
||||
clearable
|
||||
sortable
|
||||
multiple
|
||||
searchable
|
||||
:options="[
|
||||
{ label: "China", value: "China" },
|
||||
{ label: "USA", value: "USA" },
|
||||
{ label: "Japan", value: "Japan" },
|
||||
{ label: "Korea", value: "Korea" },
|
||||
{ label: "France", value: "France" },
|
||||
{ label: "Italy", value: "Italy" },
|
||||
{ label: "Germany", value: "Germany" },
|
||||
{ label: "UK", value: "UK" },
|
||||
{ label: "Canada", value: "Canada" },
|
||||
{ label: "Australia", value: "Australia" },
|
||||
]"
|
||||
help="Don’t worry, you can’t get this one wrong."
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
动态数据源:
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
const ANONYMOUSUSER_NAME = "anonymousUser";
|
||||
const DELETEDUSER_NAME = "ghost";
|
||||
|
||||
const handleSelectPostAuthorRemote = {
|
||||
search: async ({ keyword, page, size }) => {
|
||||
const { data } = await consoleApiClient.user.listUsers({
|
||||
page,
|
||||
size,
|
||||
keyword,
|
||||
fieldSelector: [
|
||||
`name!=${ANONYMOUSUSER_NAME}`,
|
||||
`name!=${DELETEDUSER_NAME}`,
|
||||
],
|
||||
});
|
||||
return {
|
||||
options: data.items.map((item) => ({
|
||||
label: item.user.spec.displayName,
|
||||
value: item.user.metadata.name,
|
||||
})),
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
size: data.size,
|
||||
};
|
||||
},
|
||||
|
||||
findOptionsByValues: () => {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<FormKit
|
||||
type="select"
|
||||
label="The author of the post is?"
|
||||
name="post_author"
|
||||
placeholder="Select a user"
|
||||
searchable
|
||||
remote
|
||||
:remote-option="handleSelectPostAuthorRemote"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 在 FormKit Schema 中使用
|
||||
|
||||
静态数据源:
|
||||
|
||||
```yaml
|
||||
- $formkit: select
|
||||
name: countries
|
||||
label: What country makes the best food?
|
||||
sortable: true
|
||||
multiple: true
|
||||
clearable: true
|
||||
placeholder: Select a country
|
||||
options:
|
||||
- label: France
|
||||
value: fr
|
||||
- label: Germany
|
||||
value: de
|
||||
- label: Spain
|
||||
value: es
|
||||
- label: Italy
|
||||
value: ie
|
||||
- label: Greece
|
||||
value: gr
|
||||
```
|
||||
|
||||
远程动态数据源:
|
||||
|
||||
支持远程动态数据源,通过 `action` 和 `requestOption` 参数来指定如何获取数据。
|
||||
|
||||
请求的接口将会自动拼接 `page`、`size` 与 `keyword` 参数,其中 `keyword` 为搜索关键词。
|
||||
|
||||
```yaml
|
||||
- $formkit: select
|
||||
name: postName
|
||||
label: Choose an post
|
||||
clearable: true
|
||||
action: /apis/api.console.halo.run/v1alpha1/posts
|
||||
requestOption:
|
||||
method: GET
|
||||
pageField: page
|
||||
sizeField: size
|
||||
totalField: total
|
||||
itemsField: items
|
||||
labelField: post.spec.title
|
||||
valueField: post.metadata.name
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 当远程数据具有分页时,可能会出现默认选项不在第一页的情况,此时 Select 组件将会发送另一个查询请求,以获取默认选项的数据。此接口会携带如下参数 `labelSelector: ${requestOption.valueField}=in(value1,value2,value3)`。
|
||||
|
||||
> 其中,value1, value2, value3 为默认选项的值。返回值与查询一致,通过 `requestOption` 解析。
|
||||
|
||||
### list
|
||||
|
||||
list 是一个数组类型的输入组件,可以让使用者可视化的操作数组。它支持动态添加、删除、上移、下移、插入数组项等操作。
|
||||
|
|
|
@ -52,6 +52,7 @@
|
|||
"@codemirror/view": "^6.5.1",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@formkit/core": "^1.5.9",
|
||||
"@formkit/drag-and-drop": "^0.1.6",
|
||||
"@formkit/i18n": "^1.5.9",
|
||||
"@formkit/inputs": "^1.5.9",
|
||||
"@formkit/themes": "^1.5.9",
|
||||
|
|
|
@ -44,6 +44,9 @@ importers:
|
|||
'@formkit/core':
|
||||
specifier: ^1.5.9
|
||||
version: 1.5.9
|
||||
'@formkit/drag-and-drop':
|
||||
specifier: ^0.1.6
|
||||
version: 0.1.6
|
||||
'@formkit/i18n':
|
||||
specifier: ^1.5.9
|
||||
version: 1.5.9
|
||||
|
@ -2351,6 +2354,9 @@ packages:
|
|||
'@formkit/dev@1.5.9':
|
||||
resolution: {integrity: sha512-aeD53iH6WD/3jKiYyGmZgvocGQv77NHHD4MF5+I/DvApu0IP1gTArsmBFaBDEVr7t5o/xO2zH06Up7sJcA0+mA==}
|
||||
|
||||
'@formkit/drag-and-drop@0.1.6':
|
||||
resolution: {integrity: sha512-wZyxvk7WTbQ12q8ZGvLoYner1ktBOUf+lCblJT3P0LyqpjGCKTfQMKJtwToKQzJgTbhvow4LBu+yP92Mux321w==}
|
||||
|
||||
'@formkit/i18n@1.5.9':
|
||||
resolution: {integrity: sha512-4FVqE1YciXSwl2KUuGRvpizZXBnwZACVRMrNjSn2WokVsOPYdmgwP1+35nG6LVU6i8bcOv/8fASCLUO3ADe7mw==}
|
||||
|
||||
|
@ -12685,6 +12691,8 @@ snapshots:
|
|||
'@formkit/core': 1.5.9
|
||||
'@formkit/utils': 1.5.9
|
||||
|
||||
'@formkit/drag-and-drop@0.1.6': {}
|
||||
|
||||
'@formkit/i18n@1.5.9':
|
||||
dependencies:
|
||||
'@formkit/core': 1.5.9
|
||||
|
|
|
@ -23,6 +23,8 @@ import { singlePageSelect } from "./inputs/singlePage-select";
|
|||
import { tagCheckbox } from "./inputs/tag-checkbox";
|
||||
import { tagSelect } from "./inputs/tag-select";
|
||||
import { verificationForm } from "./inputs/verify-form";
|
||||
import { select as nativeSelect } from "@formkit/inputs";
|
||||
import { select } from "./inputs/select";
|
||||
import theme from "./theme";
|
||||
|
||||
import { userSelect } from "./inputs/user-select";
|
||||
|
@ -67,6 +69,8 @@ const config: DefaultConfigOptions = {
|
|||
tagSelect,
|
||||
verificationForm,
|
||||
userSelect,
|
||||
nativeSelect,
|
||||
select,
|
||||
},
|
||||
locales: { zh, en },
|
||||
locale: "zh",
|
||||
|
|
|
@ -21,6 +21,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const attachmentGroupSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -18,6 +18,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const attachmentPolicySelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -20,6 +20,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const menuItemSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder", "menuItems"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -24,6 +24,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const postSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -35,6 +35,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const roleSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="relative flex max-w-full flex-auto flex-wrap">
|
||||
<slot></slot>
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-item inline-flex max-w-full flex-none items-center self-center"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts" setup>
|
||||
import MultipleSelectItem from "./MultipleSelectItem.vue";
|
||||
import MultipleOverflowItem from "./MultipleOverflowItem.vue";
|
||||
import { useDragAndDrop } from "@formkit/drag-and-drop/vue";
|
||||
import { watch } from "vue";
|
||||
import type {
|
||||
NodeDragEventData,
|
||||
NodeTouchEventData,
|
||||
} from "@formkit/drag-and-drop";
|
||||
|
||||
const props = defineProps<{
|
||||
selectedOptions: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
sortable: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "deleteItem",
|
||||
index: number,
|
||||
option?: {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
): void;
|
||||
(event: "sort", value: Array<{ label: string; value: string }>): void;
|
||||
}>();
|
||||
|
||||
const [parent, options] = useDragAndDrop<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>(props.selectedOptions, {
|
||||
disabled: !props.sortable,
|
||||
sortable: true,
|
||||
handleEnd: (
|
||||
data:
|
||||
| NodeDragEventData<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
| NodeTouchEventData<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
) => {
|
||||
const nodeData = data.targetData.node.data;
|
||||
const dragBeforeIndex = props.selectedOptions.findIndex(
|
||||
(option) => option.value === nodeData.value.value
|
||||
);
|
||||
if (dragBeforeIndex != nodeData.index) {
|
||||
emit("sort", options.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selectedOptions,
|
||||
(value) => {
|
||||
options.value = value;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="parent">
|
||||
<MultipleOverflowItem
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
>
|
||||
<MultipleSelectItem
|
||||
class="mx-1 my-0.5 ml-0"
|
||||
@delete-select-item="emit('deleteItem', index, option)"
|
||||
>
|
||||
<span
|
||||
tabindex="-1"
|
||||
class="select-item mr-1 inline-block cursor-default overflow-hidden truncate whitespace-pre"
|
||||
>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</MultipleSelectItem>
|
||||
</MultipleOverflowItem>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
import { IconClose } from "@halo-dev/components";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "deleteSelectItem"): void;
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex select-none items-center self-center rounded bg-gray-200 px-2 py-1 text-sm font-medium text-gray-800"
|
||||
>
|
||||
<slot></slot>
|
||||
<span @click.stop="emit('deleteSelectItem')">
|
||||
<IconClose
|
||||
class="h-4 w-4 cursor-pointer text-gray-400 hover:text-gray-700"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts" setup>
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
|
||||
defineProps<{
|
||||
searchable: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "search", value: string, e?: Event): void;
|
||||
(event: "enter", value: string): void;
|
||||
}>();
|
||||
|
||||
const inputHTMLRef = ref<HTMLInputElement | null>(null);
|
||||
const inputMirrorRef = ref<HTMLSpanElement | null>(null);
|
||||
const searchInputContainerRef = ref<HTMLSpanElement | null>(null);
|
||||
const inputValue = ref("");
|
||||
const inputMirrorValue = ref("");
|
||||
|
||||
watch(
|
||||
inputMirrorValue,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (searchInputContainerRef.value && inputMirrorRef.value) {
|
||||
searchInputContainerRef.value.style.width =
|
||||
inputMirrorRef.value.offsetWidth + 10 + "px";
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
const handleInputSearch = (event: Event) => {
|
||||
if (event instanceof InputEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
inputMirrorValue.value = target.value;
|
||||
}
|
||||
emit("search", inputValue.value, event);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
inputHTML: inputHTMLRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative max-w-full cursor-text">
|
||||
<span ref="searchInputContainerRef" class="relative flex max-w-full">
|
||||
<input
|
||||
ref="inputHTMLRef"
|
||||
v-model="inputValue"
|
||||
:readonly="!searchable"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="m-0 h-full w-full cursor-auto !appearance-none border-none bg-transparent p-0 pe-0 ps-0 text-base outline-none"
|
||||
@input="handleInputSearch"
|
||||
/>
|
||||
<span
|
||||
ref="inputMirrorRef"
|
||||
class="invisible absolute end-auto start-0 m-0 whitespace-pre border-none p-0 text-base outline-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ inputMirrorValue }}
|
||||
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,113 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
import MultipleOverflow from "./MultipleOverflow.vue";
|
||||
import MultipleOverflowItem from "./MultipleOverflowItem.vue";
|
||||
import MultipleSelect from "./MultipleSelect.vue";
|
||||
import MultipleSelectSearchInput from "./MultipleSelectSearchInput.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sortable: boolean;
|
||||
placeholder?: string;
|
||||
selectedOptions: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
searchable: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: "Select...",
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "search", value: string): void;
|
||||
(event: "blur", value: FocusEvent): void;
|
||||
(
|
||||
event: "deleteItem",
|
||||
index: number,
|
||||
option?: {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
): void;
|
||||
(event: "sort", value: Array<{ label: string; value: string }>): void;
|
||||
}>();
|
||||
|
||||
const inputRef = ref();
|
||||
const inputValue = ref("");
|
||||
const isCombinationInput = ref(false);
|
||||
|
||||
const handleSearch = (value: string, event?: Event) => {
|
||||
inputValue.value = value;
|
||||
if (event && event instanceof InputEvent) {
|
||||
isCombinationInput.value = event.isComposing;
|
||||
} else {
|
||||
isCombinationInput.value = false;
|
||||
}
|
||||
emit("search", value);
|
||||
};
|
||||
|
||||
const handleSearchInputBackspace = () => {
|
||||
// If the input is in composition mode, do not delete the selected item
|
||||
if (isCombinationInput.value) {
|
||||
return;
|
||||
}
|
||||
if (!inputValue.value && props.selectedOptions.length > 0) {
|
||||
emit("deleteItem", props.selectedOptions.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const showPlaceholder = computed(() => {
|
||||
return (
|
||||
!props.selectedOptions.length &&
|
||||
!inputValue.value &&
|
||||
!isCombinationInput.value
|
||||
);
|
||||
});
|
||||
|
||||
const handleFocusout = (event: FocusEvent) => {
|
||||
if (event.relatedTarget) {
|
||||
const target = event.relatedTarget as HTMLElement;
|
||||
if (target && target.closest(".select-item")) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<MultipleOverflow
|
||||
class="cursor-text"
|
||||
:class="{
|
||||
'!cursor-pointer': !searchable,
|
||||
}"
|
||||
>
|
||||
<MultipleSelect
|
||||
:selected-options="selectedOptions"
|
||||
:sortable="sortable"
|
||||
@delete-item="(index, option) => emit('deleteItem', index, option)"
|
||||
@sort="(options) => emit('sort', options)"
|
||||
/>
|
||||
<template #input>
|
||||
<MultipleOverflowItem>
|
||||
<MultipleSelectSearchInput
|
||||
ref="inputRef"
|
||||
:searchable="searchable"
|
||||
@search="handleSearch"
|
||||
@keydown.backspace="handleSearchInputBackspace"
|
||||
@focusout="handleFocusout"
|
||||
></MultipleSelectSearchInput>
|
||||
|
||||
<span
|
||||
v-if="showPlaceholder"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-full truncate text-sm"
|
||||
>
|
||||
<span class="w-full text-gray-400">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
</span>
|
||||
</MultipleOverflowItem>
|
||||
</template>
|
||||
</MultipleOverflow>
|
||||
</template>
|
|
@ -0,0 +1,311 @@
|
|||
<script lang="ts" setup>
|
||||
import { IconArrowDownLine, IconCloseCircle } from "@halo-dev/components";
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
defineEmits,
|
||||
type PropType,
|
||||
onUnmounted,
|
||||
} from "vue";
|
||||
|
||||
import SelectSelector from "./SelectSelector.vue";
|
||||
import MultipleSelectSelector from "./MultipleSelectSelector.vue";
|
||||
import SelectDropdownContainer from "./SelectDropdownContainer.vue";
|
||||
import { Dropdown } from "floating-vue";
|
||||
|
||||
const props = defineProps({
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowCreate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxCount: {
|
||||
type: Number,
|
||||
default: NaN,
|
||||
},
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
options: {
|
||||
type: Array as PropType<
|
||||
Array<
|
||||
Record<string, unknown> & {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
>
|
||||
>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nextLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selected: {
|
||||
type: Array as PropType<
|
||||
Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
remote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update", value: Array<{ label: string; value: string }>): void;
|
||||
(event: "search", value: string, e?: Event): void;
|
||||
(event: "loadMore"): void;
|
||||
}>();
|
||||
|
||||
const selectContainerRef = ref<HTMLElement>();
|
||||
const inputRef = ref<HTMLInputElement | null>();
|
||||
const searchKeyword = ref<string>("");
|
||||
const isDropdownVisible = ref<boolean>(false);
|
||||
const selectedOptions = computed({
|
||||
get: () => props.selected,
|
||||
set: (value) => {
|
||||
emit("update", value);
|
||||
},
|
||||
});
|
||||
const hasClearable = computed(
|
||||
() =>
|
||||
props.clearable && (selectedOptions.value.length > 0 || searchKeyword.value)
|
||||
);
|
||||
|
||||
const getInputHTMLRef = () => {
|
||||
nextTick(() => {
|
||||
inputRef.value = selectContainerRef.value?.querySelector("input");
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getInputHTMLRef();
|
||||
if (selectContainerRef.value) {
|
||||
observer.observe(selectContainerRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
// resolve the issue of the dropdown position when the container size changes
|
||||
// https://github.com/Akryum/floating-vue/issues/977#issuecomment-1651898070
|
||||
const observer = new ResizeObserver(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
const handleOptionSelect = (
|
||||
option: Record<string, unknown> & {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
) => {
|
||||
if (!props.multiple) {
|
||||
selectedOptions.value = [option];
|
||||
isDropdownVisible.value = false;
|
||||
} else {
|
||||
const index = selectedOptions.value.findIndex(
|
||||
(selected) => selected.value === option.value
|
||||
);
|
||||
if (index === -1) {
|
||||
selectedOptions.value.push(option);
|
||||
} else {
|
||||
selectedOptions.value.splice(index, 1);
|
||||
}
|
||||
selectedOptions.value = [...selectedOptions.value];
|
||||
}
|
||||
clearInputValue();
|
||||
};
|
||||
|
||||
/**
|
||||
* When the search box loses focus due to option selection, check if the focus is on an option, and keep the focus if so.
|
||||
*
|
||||
* @param event FocusEvent
|
||||
*/
|
||||
const handleSearchFocusout = (event: FocusEvent) => {
|
||||
const target = event.relatedTarget as HTMLElement;
|
||||
if (props.multiple && inputRef.value) {
|
||||
if (
|
||||
target &&
|
||||
(target.closest("#select-option") || target.closest(".select-container"))
|
||||
) {
|
||||
inputRef.value.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearInputValue();
|
||||
isDropdownVisible.value = false;
|
||||
};
|
||||
|
||||
const handleDeleteSelectItem = (index: number) => {
|
||||
selectedOptions.value.splice(index, 1);
|
||||
selectedOptions.value = [...selectedOptions.value];
|
||||
};
|
||||
|
||||
const handleSearchClick = () => {
|
||||
if (!isDropdownVisible.value && inputRef.value) {
|
||||
inputRef.value.focus();
|
||||
isDropdownVisible.value = true;
|
||||
return;
|
||||
}
|
||||
if (isDropdownVisible.value) {
|
||||
isDropdownVisible.value = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (value: string, event?: Event) => {
|
||||
searchKeyword.value = value;
|
||||
if (!isDropdownVisible.value && !!value) {
|
||||
isDropdownVisible.value = true;
|
||||
}
|
||||
emit("search", value, event);
|
||||
};
|
||||
|
||||
const clearInputValue = () => {
|
||||
if (!inputRef.value) {
|
||||
return;
|
||||
}
|
||||
inputRef.value.value = "";
|
||||
// Manually trigger input event
|
||||
const event = new Event("input", { bubbles: true });
|
||||
inputRef.value.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
if (key === "Enter" || key.startsWith("Arrow")) {
|
||||
if (!isDropdownVisible.value) {
|
||||
isDropdownVisible.value = true;
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "Escape" && isDropdownVisible.value) {
|
||||
clearInputValue();
|
||||
isDropdownVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllSelectedOptions = () => {
|
||||
if (!hasClearable.value) {
|
||||
return;
|
||||
}
|
||||
selectedOptions.value = [];
|
||||
clearInputValue();
|
||||
};
|
||||
|
||||
const handleSortSelectedOptions = (
|
||||
options: Array<{ label: string; value: string }>
|
||||
) => {
|
||||
emit("update", options);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown
|
||||
:triggers="[]"
|
||||
:shown="isDropdownVisible"
|
||||
auto-size
|
||||
:auto-hide="false"
|
||||
:distance="10"
|
||||
container="body"
|
||||
class="w-full"
|
||||
popper-class="select-container-dropdown"
|
||||
>
|
||||
<div
|
||||
ref="selectContainerRef"
|
||||
tabindex="-1"
|
||||
class="select-container relative items-center"
|
||||
@focusout.stop="handleSearchFocusout"
|
||||
@click.stop="handleSearchClick"
|
||||
>
|
||||
<div class="relative h-full items-center rounded-md pe-7 ps-3 text-sm">
|
||||
<component
|
||||
:is="multiple ? MultipleSelectSelector : SelectSelector"
|
||||
v-bind="{
|
||||
placeholder: placeholder,
|
||||
isDropdownVisible,
|
||||
selectedOptions,
|
||||
sortable,
|
||||
searchable,
|
||||
}"
|
||||
@search="handleSearch"
|
||||
@delete-item="handleDeleteSelectItem"
|
||||
@keydown="handleKeyDown"
|
||||
@sort="handleSortSelectedOptions"
|
||||
></component>
|
||||
</div>
|
||||
<span
|
||||
class="absolute inset-y-0 right-2.5 flex items-center text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<IconArrowDownLine
|
||||
class="pointer-events-none"
|
||||
:class="{
|
||||
'group-hover/select:hidden': hasClearable,
|
||||
}"
|
||||
/>
|
||||
<IconCloseCircle
|
||||
class="hidden cursor-pointer"
|
||||
:class="{
|
||||
'group-hover/select:block': hasClearable,
|
||||
}"
|
||||
@click.stop="clearAllSelectedOptions"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<template #popper>
|
||||
<SelectDropdownContainer
|
||||
v-if="isDropdownVisible"
|
||||
:loading="loading"
|
||||
:next-loading="nextLoading"
|
||||
:options="options"
|
||||
:remote="remote"
|
||||
:keyword="searchKeyword"
|
||||
:multiple="multiple || false"
|
||||
:selected-options="selectedOptions"
|
||||
:allow-create="allowCreate"
|
||||
:max-count="maxCount"
|
||||
@selected="handleOptionSelect"
|
||||
@load-more="emit('loadMore')"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.select-container-dropdown {
|
||||
.v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, watch } from "vue";
|
||||
import SelectOption from "./SelectOption.vue";
|
||||
import { VEmpty, VLoading } from "@halo-dev/components";
|
||||
import { useTimeout } from "@vueuse/shared";
|
||||
import { i18n } from "@/locales";
|
||||
|
||||
const props = defineProps<{
|
||||
options?: Array<Record<string, unknown> & { label: string; value: string }>;
|
||||
keyword?: string;
|
||||
selectedOptions?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
multiple: boolean;
|
||||
loading: boolean;
|
||||
nextLoading: boolean;
|
||||
remote: boolean;
|
||||
allowCreate: boolean;
|
||||
maxCount: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "selected",
|
||||
value: Record<string, unknown> & { label: string; value: string }
|
||||
): void;
|
||||
(event: "loadMore"): void;
|
||||
}>();
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
if (!props.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.remote) {
|
||||
return props.options;
|
||||
}
|
||||
|
||||
const keyword = props.keyword;
|
||||
if (!keyword) {
|
||||
return props.options;
|
||||
}
|
||||
|
||||
const options = props.options.filter((option) => {
|
||||
return option.label
|
||||
.toLocaleLowerCase()
|
||||
.includes(keyword.toLocaleLowerCase());
|
||||
});
|
||||
|
||||
if (props.allowCreate) {
|
||||
const hasKeyword = options.some((option) => {
|
||||
return option.value === keyword;
|
||||
});
|
||||
|
||||
if (!hasKeyword) {
|
||||
options.unshift({
|
||||
label: keyword,
|
||||
value: keyword,
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const { ready, start, stop } = useTimeout(200, { controls: true });
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(loading) => {
|
||||
stop();
|
||||
if (loading) {
|
||||
start();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (props.remote) {
|
||||
emit("loadMore");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="ready && loading && !nextLoading">
|
||||
<VLoading></VLoading>
|
||||
</div>
|
||||
<div v-else-if="filterOptions && filterOptions.length > 0">
|
||||
<SelectOption
|
||||
v-bind="$props"
|
||||
:options="filterOptions"
|
||||
@selected="(option) => emit('selected', option)"
|
||||
@load-more="handleLoadMore"
|
||||
></SelectOption>
|
||||
</div>
|
||||
<div v-else>
|
||||
<VEmpty :title="i18n.global.t('core.formkit.select.no_data')"></VEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,654 @@
|
|||
<script lang="ts" setup>
|
||||
import type { FormKitFrameworkContext } from "@formkit/core";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowReactive,
|
||||
shallowRef,
|
||||
watch,
|
||||
type PropType,
|
||||
} from "vue";
|
||||
import SelectContainer from "./SelectContainer.vue";
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
import { get, has, type PropertyPath } from "lodash-es";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
|
||||
export interface SelectProps {
|
||||
/**
|
||||
* URL for asynchronous requests.
|
||||
*/
|
||||
action?: string;
|
||||
|
||||
/**
|
||||
* Configuration for asynchronous requests.
|
||||
*/
|
||||
requestOption?: SelectActionRequest;
|
||||
|
||||
/**
|
||||
* Enables remote search, controlled by `remoteOption` when enabled.
|
||||
* Differs from `action`, which controls the asynchronous request options.
|
||||
*/
|
||||
remote?: boolean;
|
||||
|
||||
/**
|
||||
* Configuration for remote search, required when `remote` is true.
|
||||
*/
|
||||
remoteOption?: SelectRemoteOption;
|
||||
|
||||
/**
|
||||
* Enables remote search optimization, default is true.
|
||||
*/
|
||||
remoteOptimize?: boolean;
|
||||
|
||||
/**
|
||||
* Allows the creation of new options, only available in local mode.
|
||||
*/
|
||||
allowCreate?: boolean;
|
||||
|
||||
/**
|
||||
* Allows options to be cleared.
|
||||
*/
|
||||
clearable?: boolean;
|
||||
|
||||
/**
|
||||
* Enables multiple selection, default is false.
|
||||
*/
|
||||
multiple?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum number of selections allowed in multiple mode, valid only when `multiple` is true.
|
||||
*/
|
||||
maxCount?: number;
|
||||
|
||||
/**
|
||||
* Allows sorting in multiple selection mode, default is true. Only valid when `multiple` is true.
|
||||
*/
|
||||
sortable?: boolean;
|
||||
|
||||
/**
|
||||
* Default placeholder text.
|
||||
*/
|
||||
placeholder?: string;
|
||||
|
||||
/**
|
||||
* Whether to enable search, default is false.
|
||||
*/
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectResponse {
|
||||
options: Array<
|
||||
Record<string, unknown> & {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
>;
|
||||
|
||||
page: number;
|
||||
|
||||
size: number;
|
||||
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SelectRemoteRequest {
|
||||
keyword: string;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface SelectRemoteOption {
|
||||
search: ({
|
||||
keyword,
|
||||
page,
|
||||
size,
|
||||
}: SelectRemoteRequest) => Promise<SelectResponse>;
|
||||
|
||||
findOptionsByValues: (values: string[]) => Promise<
|
||||
Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface SelectActionRequest {
|
||||
method?: "GET" | "POST";
|
||||
|
||||
/**
|
||||
* Parses the returned data from the request.
|
||||
*/
|
||||
parseData?: (data: unknown) => SelectResponse;
|
||||
|
||||
/**
|
||||
* Field name for the page number in the request parameters, default is `page`.
|
||||
*/
|
||||
pageField?: PropertyPath;
|
||||
|
||||
/**
|
||||
* Field name for size, default is `size`.
|
||||
*/
|
||||
sizeField?: PropertyPath;
|
||||
|
||||
/**
|
||||
* Field name for total, default is `total`.
|
||||
*/
|
||||
totalField?: PropertyPath;
|
||||
|
||||
/**
|
||||
* Field name for items, default is `items`.
|
||||
*/
|
||||
itemsField?: PropertyPath;
|
||||
|
||||
/**
|
||||
* Field name for label, default is `label`.
|
||||
*/
|
||||
labelField?: PropertyPath;
|
||||
|
||||
/**
|
||||
* Field name for value, default is `value`.
|
||||
*/
|
||||
valueField?: PropertyPath;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object as PropType<FormKitFrameworkContext>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const options = shallowRef<
|
||||
| Array<
|
||||
Record<string, unknown> & {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
>
|
||||
| undefined
|
||||
>(undefined);
|
||||
const selectOptions = shallowRef<
|
||||
| Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
const selectProps: SelectProps = shallowReactive({
|
||||
multiple: false,
|
||||
maxCount: NaN,
|
||||
sortable: true,
|
||||
placeholder: "",
|
||||
});
|
||||
|
||||
const hasSelected = ref(false);
|
||||
const isRemote = computed(() => !!selectProps.action || !!selectProps.remote);
|
||||
const hasMoreOptions = computed(
|
||||
() => options.value && options.value.length < total.value
|
||||
);
|
||||
|
||||
const initSelectProps = () => {
|
||||
const nodeProps = props.context.node.props;
|
||||
selectProps.maxCount = nodeProps.maxCount ?? NaN;
|
||||
selectProps.placeholder = nodeProps.placeholder ?? "";
|
||||
selectProps.action = nodeProps.action ?? "";
|
||||
selectProps.remoteOptimize = nodeProps.remoteOptimize ?? true;
|
||||
selectProps.requestOption = {
|
||||
...{
|
||||
method: "GET",
|
||||
itemsField: "items",
|
||||
labelField: "label",
|
||||
valueField: "value",
|
||||
pageField: "page",
|
||||
sizeField: "size",
|
||||
parseData: undefined,
|
||||
},
|
||||
...(nodeProps.requestOption ?? {}),
|
||||
};
|
||||
selectProps.multiple = !isFalse(nodeProps.multiple);
|
||||
selectProps.sortable = !isFalse(nodeProps.sortable);
|
||||
selectProps.remote = !isFalse(nodeProps.remote);
|
||||
selectProps.allowCreate = !isFalse(nodeProps.allowCreate);
|
||||
selectProps.clearable = !isFalse(nodeProps.clearable);
|
||||
selectProps.searchable = !isFalse(nodeProps.searchable);
|
||||
if (selectProps.remote) {
|
||||
if (!nodeProps.remoteOption) {
|
||||
throw new Error("remoteOption is required when remote is true.");
|
||||
}
|
||||
selectProps.remoteOption = nodeProps.remoteOption;
|
||||
}
|
||||
};
|
||||
|
||||
const isFalse = (value: string | boolean | undefined | null) => {
|
||||
return [undefined, null, "false", false].includes(value);
|
||||
};
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isFetchingMore = ref(false);
|
||||
const page = ref(1);
|
||||
const size = ref(20);
|
||||
const total = ref(0);
|
||||
const searchKeyword = ref("");
|
||||
const noNeedFetchOptions = ref(false);
|
||||
// be no need to fetch options when total is less than or equal to size, cache all options
|
||||
const cacheAllOptions = ref<
|
||||
Array<{ label: string; value: string }> | undefined
|
||||
>(undefined);
|
||||
|
||||
const requestOptions = async (
|
||||
searchParams: SelectRemoteRequest
|
||||
): Promise<SelectResponse> => {
|
||||
const responseData = {
|
||||
options: [],
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: 0,
|
||||
};
|
||||
if (!selectProps.action) {
|
||||
return responseData;
|
||||
}
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
method: selectProps.requestOption?.method || "GET",
|
||||
url: selectProps.action,
|
||||
[selectProps.requestOption?.method === "GET" ? "params" : "data"]:
|
||||
searchParams,
|
||||
};
|
||||
const response = await axiosInstance.request(requestConfig);
|
||||
const { data } = response;
|
||||
const parseSelectData = parseSelectResponse(data);
|
||||
if (!parseSelectData) {
|
||||
throw new Error(
|
||||
"Error parsing response, please check the requestOption object."
|
||||
);
|
||||
}
|
||||
return parseSelectData;
|
||||
};
|
||||
|
||||
const parseSelectResponse = (data: object): SelectResponse | undefined => {
|
||||
if (!selectProps.requestOption) {
|
||||
return;
|
||||
}
|
||||
const { parseData } = selectProps.requestOption;
|
||||
if (parseData) {
|
||||
return parseData(data);
|
||||
}
|
||||
const { labelField, valueField, itemsField } = selectProps.requestOption;
|
||||
if (!has(data, itemsField as PropertyPath)) {
|
||||
console.error(
|
||||
`itemsField: ${itemsField?.toString()} not found in response data.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const items = get(data, itemsField as PropertyPath);
|
||||
return {
|
||||
options: formatOptionsData(
|
||||
items,
|
||||
labelField as PropertyPath,
|
||||
valueField as PropertyPath
|
||||
),
|
||||
page: get(data, selectProps.requestOption.pageField as PropertyPath, "1"),
|
||||
size: get(data, selectProps.requestOption.sizeField as PropertyPath, "20"),
|
||||
total: get(data, selectProps.requestOption.totalField as PropertyPath, "0"),
|
||||
};
|
||||
};
|
||||
|
||||
const formatOptionsData = (
|
||||
items: Array<object>,
|
||||
labelField: PropertyPath,
|
||||
valueField: PropertyPath
|
||||
) => {
|
||||
if (!items) {
|
||||
console.warn(
|
||||
"Select options: data items are empty, please check the itemsField."
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return items.map((item) => {
|
||||
if (!has(item, labelField as PropertyPath)) {
|
||||
console.error(
|
||||
`labelField: ${labelField?.toString()} not found in response data items.`
|
||||
);
|
||||
return { label: "", value: "" };
|
||||
}
|
||||
if (!has(item, valueField as PropertyPath)) {
|
||||
console.error(
|
||||
`valueField: ${valueField?.toString()} not found in response data items.`
|
||||
);
|
||||
return { label: "", value: "" };
|
||||
}
|
||||
return {
|
||||
label: get(item, labelField),
|
||||
value: get(item, valueField),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the mapping of selected values and available options.
|
||||
*
|
||||
* If the selected value is found in the current options, it will be converted to a label and value format.
|
||||
* If the selected value is not found in the current options, the `mapUnresolvedOptions` method will be used.
|
||||
*/
|
||||
const fetchSelectedOptions = async (): Promise<
|
||||
| Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
| undefined
|
||||
> => {
|
||||
const node = props.context.node;
|
||||
const value = node.value;
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedValues: string[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
selectedValues.push(...value);
|
||||
} else if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
selectedValues.push(value.toString());
|
||||
}
|
||||
|
||||
const currentOptions = options.value?.filter((option) =>
|
||||
selectedValues.includes(option.value.toString())
|
||||
);
|
||||
|
||||
// Get options that are not yet mapped.
|
||||
const unmappedSelectValues = selectedValues.filter(
|
||||
(value) => !currentOptions?.find((option) => option.value === value)
|
||||
);
|
||||
if (unmappedSelectValues.length === 0) {
|
||||
return currentOptions?.sort((a, b) =>
|
||||
selectedValues.indexOf(a.value) > selectedValues.indexOf(b.value) ? 1 : -1
|
||||
);
|
||||
}
|
||||
|
||||
// Map the unresolved options to label and value format.
|
||||
const mappedSelectOptions = await mapUnresolvedOptions(unmappedSelectValues);
|
||||
// Merge currentOptions and mappedSelectOptions, then sort them according to selectValues order.
|
||||
return [...(currentOptions || []), ...mappedSelectOptions].sort((a, b) =>
|
||||
selectedValues.indexOf(a.value) > selectedValues.indexOf(b.value) ? 1 : -1
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps unresolved options to label and value format.
|
||||
*
|
||||
* There are several possible scenarios:
|
||||
*
|
||||
* 1. If it's an asynchronous request for options, fetch the label and value via an API call.
|
||||
* a. If all selected values are found in the response, return the data directly.
|
||||
* b. If only some of the values are found, check if new options can be created.
|
||||
* If allowed, create new options for the remaining values.
|
||||
* 2. If it's a static option and creating new options is allowed, create new options for the remaining values.
|
||||
* If not allowed, return an empty array.
|
||||
*
|
||||
* @param unmappedSelectValues Unresolved options
|
||||
*/
|
||||
const mapUnresolvedOptions = async (
|
||||
unmappedSelectValues: string[]
|
||||
): Promise<
|
||||
Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
> => {
|
||||
if (!selectProps.action || !selectProps.remote) {
|
||||
if (selectProps.allowCreate) {
|
||||
// TODO: Add mapped values to options
|
||||
return unmappedSelectValues.map((value) => ({ label: value, value }));
|
||||
}
|
||||
// Creation not allowed but there are unmapped values, return an empty array and issue a warning.
|
||||
console.warn(
|
||||
`It is not allowed to create options but has unmapped values. ${unmappedSelectValues}`
|
||||
);
|
||||
return unmappedSelectValues.map((value) => ({ label: value, value }));
|
||||
}
|
||||
|
||||
// Asynchronous request for options, fetch label and value via API.
|
||||
let mappedOptions: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}> = [];
|
||||
if (selectProps.action) {
|
||||
mappedOptions = await fetchRemoteMappedOptions(unmappedSelectValues);
|
||||
} else if (selectProps.remote) {
|
||||
const remoteOption = selectProps.remoteOption as SelectRemoteOption;
|
||||
mappedOptions = await remoteOption.findOptionsByValues(
|
||||
unmappedSelectValues
|
||||
);
|
||||
}
|
||||
// Get values that are still unresolved.
|
||||
const unmappedValues = unmappedSelectValues.filter(
|
||||
(value) => !mappedOptions.find((option) => option.value === value)
|
||||
);
|
||||
if (unmappedValues.length === 0) {
|
||||
return mappedOptions;
|
||||
}
|
||||
|
||||
if (!selectProps.allowCreate) {
|
||||
console.warn(
|
||||
`It is not allowed to create options but has unmapped values. ${unmappedSelectValues}`
|
||||
);
|
||||
return mappedOptions;
|
||||
}
|
||||
|
||||
// Create new options for remaining values.
|
||||
return [
|
||||
...mappedOptions,
|
||||
...unmappedValues.map((value) => ({ label: value, value })),
|
||||
];
|
||||
};
|
||||
|
||||
const fetchRemoteMappedOptions = async (
|
||||
unmappedSelectValues: string[]
|
||||
): Promise<Array<{ label: string; value: string }>> => {
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
method: selectProps.requestOption?.method || "GET",
|
||||
url: selectProps.action,
|
||||
};
|
||||
if (requestConfig.method === "GET") {
|
||||
requestConfig.params = {
|
||||
labelSelector: `${selectProps.requestOption?.valueField?.toString()}=in(${unmappedSelectValues.join(
|
||||
","
|
||||
)})`,
|
||||
};
|
||||
} else {
|
||||
requestConfig.data = {
|
||||
labelSelector: `${selectProps.requestOption?.valueField?.toString()}=in(${unmappedSelectValues.join(
|
||||
","
|
||||
)})`,
|
||||
};
|
||||
}
|
||||
const response = await axiosInstance.request(requestConfig);
|
||||
const { data } = response;
|
||||
const parsedData = parseSelectResponse(data);
|
||||
if (!parsedData) {
|
||||
throw new Error(
|
||||
"fetchRemoteMappedOptions error, please check the requestOption object."
|
||||
);
|
||||
}
|
||||
return parsedData.options;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
initSelectProps();
|
||||
if (!isRemote.value) {
|
||||
options.value = props.context.attrs.options;
|
||||
} else {
|
||||
const response = await fetchOptions();
|
||||
if (response) {
|
||||
options.value = response.options;
|
||||
if (selectProps.remoteOptimize) {
|
||||
if (total.value !== 0 && total.value <= size.value) {
|
||||
noNeedFetchOptions.value = true;
|
||||
cacheAllOptions.value = response.options;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [options.value, props.context.value],
|
||||
async () => {
|
||||
if (!hasSelected.value && options.value) {
|
||||
selectOptions.value = await fetchSelectedOptions();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// When attr options are processed asynchronously, it is necessary to monitor
|
||||
// changes in attr options and update options accordingly.
|
||||
watch(
|
||||
() => props.context.attrs.options,
|
||||
async (attrOptions) => {
|
||||
if (!isRemote.value) {
|
||||
options.value = attrOptions;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleUpdate = (value: Array<{ label: string; value: string }>) => {
|
||||
const values = value.map((item) => item.value);
|
||||
hasSelected.value = true;
|
||||
selectOptions.value = value;
|
||||
if (selectProps.multiple) {
|
||||
props.context.node.input(values);
|
||||
return;
|
||||
}
|
||||
if (values.length === 0) {
|
||||
props.context.node.input("");
|
||||
return;
|
||||
}
|
||||
props.context.node.input(values[0]);
|
||||
};
|
||||
|
||||
const fetchOptions = async (
|
||||
tempKeyword = searchKeyword.value,
|
||||
tempPage = page.value,
|
||||
tempSize = size.value
|
||||
): Promise<SelectResponse | undefined> => {
|
||||
if (isLoading.value || !isRemote.value) {
|
||||
return;
|
||||
}
|
||||
// If the total number of options is less than the page size, no more requests are made.
|
||||
if (noNeedFetchOptions.value) {
|
||||
const filterOptions = cacheAllOptions.value?.filter((option) =>
|
||||
option.label.includes(tempKeyword)
|
||||
);
|
||||
return {
|
||||
options: filterOptions || [],
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
total: filterOptions?.length || 0,
|
||||
};
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: SelectResponse | undefined;
|
||||
if (selectProps.action) {
|
||||
response = await requestOptions({
|
||||
page: tempPage,
|
||||
size: tempSize,
|
||||
keyword: tempKeyword,
|
||||
});
|
||||
}
|
||||
if (selectProps.remote) {
|
||||
const remoteOption = selectProps.remoteOption as SelectRemoteOption;
|
||||
response = await remoteOption.search({
|
||||
keyword: tempKeyword,
|
||||
page: tempPage,
|
||||
size: tempSize,
|
||||
});
|
||||
}
|
||||
page.value = response?.page || 1;
|
||||
size.value = response?.size || 20;
|
||||
total.value = response?.total || 0;
|
||||
return response as SelectResponse;
|
||||
} catch (error) {
|
||||
console.error("fetchOptions error", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedFetchOptions = useDebounceFn(async () => {
|
||||
const response = await fetchOptions(searchKeyword.value, 1);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
options.value = response.options;
|
||||
}, 500);
|
||||
|
||||
const handleSearch = async (value: string, event?: Event) => {
|
||||
if (event && event instanceof InputEvent) {
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// When the search keyword does not change, the data is no longer requested.
|
||||
if (
|
||||
value === searchKeyword.value &&
|
||||
options.value &&
|
||||
options.value?.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
searchKeyword.value = value;
|
||||
if (selectProps.action || selectProps.remote) {
|
||||
if (noNeedFetchOptions.value) {
|
||||
const response = await fetchOptions(searchKeyword.value, 1);
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
options.value = response.options;
|
||||
} else {
|
||||
debouncedFetchOptions();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = async () => {
|
||||
if (!hasMoreOptions.value || isFetchingMore.value || isLoading.value) {
|
||||
return;
|
||||
}
|
||||
isFetchingMore.value = true;
|
||||
const response = await fetchOptions(searchKeyword.value, page.value + 1);
|
||||
isLoading.value = false;
|
||||
isFetchingMore.value = false;
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
options.value = [...(options.value || []), ...response.options];
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<SelectContainer
|
||||
:allow-create="selectProps.allowCreate"
|
||||
:max-count="selectProps.maxCount"
|
||||
:multiple="selectProps.multiple"
|
||||
:sortable="selectProps.sortable"
|
||||
:placeholder="selectProps.placeholder"
|
||||
:loading="isLoading"
|
||||
:next-loading="isFetchingMore"
|
||||
:options="options"
|
||||
:selected="selectOptions"
|
||||
:remote="isRemote"
|
||||
:clearable="selectProps.clearable"
|
||||
:searchable="selectProps.searchable"
|
||||
@update="handleUpdate"
|
||||
@search="handleSearch"
|
||||
@load-more="handleNextPage"
|
||||
/>
|
||||
</template>
|
|
@ -0,0 +1,204 @@
|
|||
<script lang="ts" setup>
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
import { vScroll } from "@vueuse/components";
|
||||
import { useEventListener, type UseScrollReturn } from "@vueuse/core";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import SelectOptionItem from "./SelectOptionItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
options: Array<Record<string, unknown> & { label: string; value: string }>;
|
||||
selectedOptions?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
multiple: boolean;
|
||||
maxCount: number;
|
||||
nextLoading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "selected",
|
||||
value: Record<string, unknown> & { label: string; value: string }
|
||||
): void;
|
||||
(event: "loadMore"): void;
|
||||
}>();
|
||||
|
||||
const selectedIndex = ref<number>(0);
|
||||
const selectOptionRef = ref<HTMLElement>();
|
||||
|
||||
const selectedValues = computed(() =>
|
||||
props.selectedOptions?.map((option) => option.value)
|
||||
);
|
||||
|
||||
const getSelectedIndex = () => {
|
||||
if (props.multiple) {
|
||||
return 0;
|
||||
}
|
||||
if (selectedValues.value && selectedValues.value.length > 0) {
|
||||
const value = selectedValues.value[0];
|
||||
const index = props.options.findIndex((option) => option.value === value);
|
||||
return index === -1 ? 0 : index;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const key = event.key;
|
||||
if (key === "ArrowUp") {
|
||||
selectedIndex.value =
|
||||
selectedIndex.value - 1 < 0
|
||||
? props.options.length - 1
|
||||
: selectedIndex.value - 1;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
selectedIndex.value =
|
||||
selectedIndex.value + 1 >= props.options.length
|
||||
? 0
|
||||
: selectedIndex.value + 1;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
handleSelected(selectedIndex.value);
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(document, "keydown", handleKeydown);
|
||||
|
||||
const handleSelected = (index: number) => {
|
||||
const option = props.options[index];
|
||||
if (reachMaximumLimit.value) {
|
||||
const index = props.selectedOptions?.findIndex(
|
||||
(selected) => selected.value === option.value
|
||||
);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
selectedIndex.value = index;
|
||||
if (option) {
|
||||
emit("selected", option);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollIntoView = () => {
|
||||
if (selectedIndex.value === -1) {
|
||||
return;
|
||||
}
|
||||
const optionElement = document.querySelector(
|
||||
`#select-option > div:nth-child(${selectedIndex.value + 1})`
|
||||
);
|
||||
if (optionElement) {
|
||||
optionElement.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reachMaximumLimit = computed(() => {
|
||||
if (!props.multiple || isNaN(props.maxCount)) {
|
||||
return false;
|
||||
}
|
||||
if (props.selectedOptions && props.selectedOptions.length >= props.maxCount) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isDisabled = (option: { label: string; value: string }) => {
|
||||
return (
|
||||
reachMaximumLimit.value &&
|
||||
selectedValues.value &&
|
||||
!selectedValues.value.includes(option.value)
|
||||
);
|
||||
};
|
||||
|
||||
const handleOptionScroll = (state: UseScrollReturn) => {
|
||||
if (selectOptionRef.value) {
|
||||
const scrollHeight = (selectOptionRef.value as HTMLElement).scrollHeight;
|
||||
const clientHeight = (selectOptionRef.value as HTMLElement).clientHeight;
|
||||
const scrollPercentage =
|
||||
(state.y.value / (scrollHeight - clientHeight)) * 100;
|
||||
if (scrollPercentage > 50) {
|
||||
emit("loadMore");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
() => {
|
||||
selectedIndex.value = getSelectedIndex();
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => selectedIndex.value,
|
||||
() => {
|
||||
handleScrollIntoView();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="select-option"
|
||||
ref="selectOptionRef"
|
||||
v-scroll="[handleOptionScroll, { throttle: 10 }]"
|
||||
class="select max-h-64 cursor-pointer overflow-y-auto p-1.5"
|
||||
role="list"
|
||||
tabindex="-1"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<template v-for="(option, index) in options" :key="option.value">
|
||||
<SelectOptionItem
|
||||
class="select-option-item"
|
||||
:option="option"
|
||||
:class="{
|
||||
'hover:bg-zinc-100': !isDisabled(option),
|
||||
'bg-zinc-100': !isDisabled(option) && selectedIndex === index,
|
||||
'selected !bg-zinc-200/60':
|
||||
selectedValues && selectedValues.includes(option.value),
|
||||
'cursor-not-allowed opacity-25': isDisabled(option),
|
||||
}"
|
||||
@mousedown.stop="handleSelected(index)"
|
||||
>
|
||||
</SelectOptionItem>
|
||||
</template>
|
||||
<div v-if="nextLoading">
|
||||
<VLoading></VLoading>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
.select-option-item:has(+ .select-option-item:not(.selected))
|
||||
+ .select-option-item.selected {
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.select-option-item.selected + .select-option-item.selected {
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
|
||||
.select-option-item.selected:has(+ .select-option-item.selected) {
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
|
||||
.select-option-item.selected + .select-option-item:not(.selected) {
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
option: Record<string, unknown> & { label: string; value: unknown };
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-8 w-full items-center rounded px-3 py-1 text-base">
|
||||
<span class="flex-1 truncate text-sm"> {{ option.label }} </span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
defineProps<{
|
||||
searchable: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "search", value: string, e?: Event): void;
|
||||
(event: "enter", value: string): void;
|
||||
}>();
|
||||
|
||||
const inputHTMLRef = ref<HTMLInputElement | null>(null);
|
||||
const searchInputContainerRef = ref<HTMLSpanElement | null>(null);
|
||||
const inputValue = ref("");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full cursor-text">
|
||||
<span ref="searchInputContainerRef" class="relative flex w-full">
|
||||
<input
|
||||
ref="inputHTMLRef"
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
:readonly="!searchable"
|
||||
autocomplete="off"
|
||||
class="m-0 h-full w-full cursor-auto !appearance-none border-none bg-transparent p-0 pe-0 ps-0 text-base outline-none"
|
||||
:class="{
|
||||
'!cursor-pointer': !searchable,
|
||||
}"
|
||||
@input="(event) => emit('search', inputValue, event)"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-full truncate text-sm"
|
||||
>
|
||||
<slot name="placeholder"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,56 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import SelectSearchInput from "./SelectSearchInput.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
placeholder?: string;
|
||||
selectedOptions: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
isDropdownVisible: boolean;
|
||||
searchable: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "search", value: string, e?: Event): void;
|
||||
}>();
|
||||
|
||||
const selectLabel = computed(() => {
|
||||
if (props.selectedOptions && props.selectedOptions.length > 0) {
|
||||
return props.selectedOptions[0].label.toString();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const inputValue = ref("");
|
||||
const isCombinationInput = ref(false);
|
||||
const handleSearch = (value: string, event?: Event) => {
|
||||
inputValue.value = value;
|
||||
if (event && event instanceof InputEvent) {
|
||||
isCombinationInput.value = event.isComposing;
|
||||
} else {
|
||||
isCombinationInput.value = false;
|
||||
}
|
||||
emit("search", value, event);
|
||||
};
|
||||
|
||||
const showPlaceholder = computed(() => {
|
||||
return !inputValue.value && !isCombinationInput.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSearchInput :searchable="searchable" @search="handleSearch">
|
||||
<template #placeholder>
|
||||
<span
|
||||
v-if="showPlaceholder"
|
||||
:class="{
|
||||
'text-gray-400': isDropdownVisible || !selectLabel,
|
||||
}"
|
||||
>
|
||||
{{ selectLabel || placeholder }}
|
||||
</span>
|
||||
</template>
|
||||
</SelectSearchInput>
|
||||
</template>
|
|
@ -0,0 +1,62 @@
|
|||
import type { FormKitTypeDefinition } from "@formkit/core";
|
||||
import {
|
||||
help,
|
||||
icon,
|
||||
inner,
|
||||
label,
|
||||
message,
|
||||
messages,
|
||||
outer,
|
||||
prefix,
|
||||
suffix,
|
||||
wrapper,
|
||||
} from "@formkit/inputs";
|
||||
import SelectMain from "./SelectMain.vue";
|
||||
import { SelectSection } from "./sections/index";
|
||||
|
||||
/**
|
||||
* Custom Select component.
|
||||
*
|
||||
* features:
|
||||
*
|
||||
* 1. Supports both single and multiple selection, controlled by the `multiple` prop. The display format of the input differs between single and multiple selection modes.
|
||||
* 2. Supports passing in an `options` prop to render dropdown options, or using the `action` prop to pass a function for dynamically retrieving options. The handling differs based on the method:
|
||||
* a. If `options` is passed, it will be used directly to render the dropdown options.
|
||||
* b. If the `action` prop is provided, it should be used to fetch options from an API, and additional features like pagination and search may also be enabled.
|
||||
* 3. Supports sorting functionality. If sorting is enabled, the list allows drag-and-drop sorting, and the current position of the node can be displayed in the dropdown.
|
||||
* 4. Supports an add feature. If the target content is not in the list, it allows adding the currently entered content as a `value`.
|
||||
* 5. Allows restricting the maximum number of selections, controlled by the `maxCount` prop.
|
||||
*/
|
||||
export const select: FormKitTypeDefinition = {
|
||||
schema: outer(
|
||||
wrapper(
|
||||
label("$label"),
|
||||
inner(icon("prefix"), prefix(), SelectSection(), suffix(), icon("suffix"))
|
||||
),
|
||||
help("$help"),
|
||||
messages(message("$message.value"))
|
||||
),
|
||||
|
||||
type: "input",
|
||||
|
||||
props: [
|
||||
"clearable",
|
||||
"multiple",
|
||||
"maxCount",
|
||||
"sortable",
|
||||
"action",
|
||||
"requestOption",
|
||||
"placeholder",
|
||||
"remote",
|
||||
"remoteOption",
|
||||
"allowCreate",
|
||||
"remoteOptimize",
|
||||
"searchable",
|
||||
],
|
||||
|
||||
library: {
|
||||
SelectMain: SelectMain,
|
||||
},
|
||||
|
||||
schemaMemoKey: "custom-select",
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import { createSection } from "@formkit/inputs";
|
||||
|
||||
export const SelectSection = createSection("SelectMain", () => ({
|
||||
$cmp: "SelectMain",
|
||||
props: {
|
||||
context: "$node.context",
|
||||
},
|
||||
}));
|
|
@ -24,6 +24,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const singlePageSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -21,6 +21,6 @@ function optionsHandler(node: FormKitNode) {
|
|||
export const userSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
forceTypeProp: "nativeSelect",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
||||
|
|
|
@ -25,6 +25,13 @@ const buttonClassification = {
|
|||
"bg-blue-500 hover:bg-blue-700 text-white text-sm font-normal py-3 px-5 rounded",
|
||||
};
|
||||
|
||||
const selectClassification = {
|
||||
label: textClassification.label,
|
||||
wrapper: textClassification.wrapper,
|
||||
inner:
|
||||
"group/select py-0.5 min-h-[36px] inline-flex items-center w-full relative box-border border border-gray-300 formkit-invalid:border-red-500 rounded-base overflow-hidden focus-within:border-primary focus-within:shadow-sm w-full sm:max-w-lg transition-all",
|
||||
};
|
||||
|
||||
const theme: Record<string, Record<string, string>> = {
|
||||
global: {
|
||||
form: "divide-y divide-gray-100",
|
||||
|
@ -64,7 +71,8 @@ const theme: Record<string, Record<string, string>> = {
|
|||
"form-range appearance-none w-full h-2 p-0 bg-gray-200 rounded-full focus:outline-none focus:ring-0 focus:shadow-none",
|
||||
},
|
||||
search: textClassification,
|
||||
select: textClassification,
|
||||
select: selectClassification,
|
||||
nativeSelect: textClassification,
|
||||
submit: buttonClassification,
|
||||
tel: textClassification,
|
||||
text: textClassification,
|
||||
|
|
|
@ -1705,6 +1705,8 @@ core:
|
|||
content_cache:
|
||||
toast_recovered: Recovered unsaved content from cache
|
||||
formkit:
|
||||
select:
|
||||
no_data: No data
|
||||
category_select:
|
||||
creation_label: Create {text} category
|
||||
tag_select:
|
||||
|
|
|
@ -1617,6 +1617,8 @@ core:
|
|||
content_cache:
|
||||
toast_recovered: 已从缓存中恢复未保存的内容
|
||||
formkit:
|
||||
select:
|
||||
no_data: 暂无数据
|
||||
category_select:
|
||||
creation_label: 创建 {text} 分类
|
||||
tag_select:
|
||||
|
|
|
@ -1574,6 +1574,8 @@ core:
|
|||
content_cache:
|
||||
toast_recovered: 已從緩存中恢復未保存的內容
|
||||
formkit:
|
||||
select:
|
||||
no_data: 暫無數據
|
||||
category_select:
|
||||
creation_label: 創建 {text} 分類
|
||||
tag_select:
|
||||
|
|
Loading…
Reference in New Issue