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
Takagi 2024-08-26 17:03:14 +08:00 committed by GitHub
parent 3db80bfaf3
commit 3be91fcb6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1936 additions and 8 deletions

View File

@ -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="Dont worry, you cant 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 是一个数组类型的输入组件,可以让使用者可视化的操作数组。它支持动态添加、删除、上移、下移、插入数组项等操作。

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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")],
};

View File

@ -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")],
};

View File

@ -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")],
};

View File

@ -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")],
};

View File

@ -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")],
};

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }}
&nbsp;
</span>
</span>
</div>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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",
};

View File

@ -0,0 +1,8 @@
import { createSection } from "@formkit/inputs";
export const SelectSection = createSection("SelectMain", () => ({
$cmp: "SelectMain",
props: {
context: "$node.context",
},
}));

View File

@ -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")],
};

View File

@ -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")],
};

View File

@ -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,

View File

@ -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:

View File

@ -1617,6 +1617,8 @@ core:
content_cache:
toast_recovered: 已从缓存中恢复未保存的内容
formkit:
select:
no_data: 暂无数据
category_select:
creation_label: 创建 {text} 分类
tag_select:

View File

@ -1574,6 +1574,8 @@ core:
content_cache:
toast_recovered: 已從緩存中恢復未保存的內容
formkit:
select:
no_data: 暫無數據
category_select:
creation_label: 創建 {text} 分類
tag_select: