mirror of https://github.com/halo-dev/halo
feat: add dynamic list input (#6146)
#### What type of PR is this? /kind feature /area ui /milestone 2.17.x #### What this PR does / why we need it: 为 formkit 增加动态列表的 input。 使用方式: ``` - $formkit: list name: users label: Users addLabel: Add User min: 1 max: 3 itemType: string children: - $formkit: text index: "$index" validation: required ``` > [!NOTE] > `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。 #### How to test it? 测试动态数组是否正常可用。保存的结果是否为 `{users: ["", ""]}` #### Which issue(s) this PR fixes: Fixes #6098 #### Does this PR introduce a user-facing change? ```release-note 为 Formkit 增加动态列表的 input 组件 list ```pull/6151/head
parent
ae6724a2b6
commit
5d5df7c7a9
|
@ -25,6 +25,17 @@
|
|||
6. downControl: 是否显示下移按钮,默认为 `true`
|
||||
7. insertControl: 是否显示插入按钮,默认为 `true`
|
||||
8. removeControl: 是否显示删除按钮,默认为 `true`
|
||||
- `list`: 动态列表,定义一个数组列表。
|
||||
- 参数
|
||||
1. itemType: 列表项的数据类型,用于初始化数据类型,可选参数 `string`, `number`, `boolean`, `object`,默认为 `string`
|
||||
1. min: 最小数量,默认为 `0`
|
||||
2. max: 最大数量,默认为 `Infinity`,即无限制。
|
||||
3. addLabel: 添加按钮的文本,默认为 `添加`
|
||||
4. addButton: 是否显示添加按钮,默认为 `true`
|
||||
5. upControl: 是否显示上移按钮,默认为 `true`
|
||||
6. downControl: 是否显示下移按钮,默认为 `true`
|
||||
7. insertControl: 是否显示插入按钮,默认为 `true`
|
||||
8. removeControl: 是否显示删除按钮,默认为 `true`
|
||||
- `menuCheckbox`:选择一组菜单
|
||||
- `menuRadio`:选择一个菜单
|
||||
- `menuItemSelect`:选择菜单项
|
||||
|
@ -70,6 +81,69 @@ const postName = ref("");
|
|||
label: 底部菜单组
|
||||
```
|
||||
|
||||
### list
|
||||
|
||||
list 是一个数组类型的输入组件,可以让使用者可视化的操作数组。它支持动态添加、删除、上移、下移、插入数组项等操作。
|
||||
|
||||
在 Vue SFC 中以组件形式使用:
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
const users = ref([]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormKit
|
||||
:min="1"
|
||||
:max="3"
|
||||
type="list"
|
||||
label="Users"
|
||||
add-label="Add User"
|
||||
item-type="string"
|
||||
>
|
||||
<template #default="{ index }">
|
||||
<FormKit
|
||||
type="text"
|
||||
:index="index"
|
||||
validation="required"
|
||||
/>
|
||||
</template>
|
||||
</FormKit>
|
||||
</template>
|
||||
```
|
||||
|
||||
在 FormKit Schema 中使用:
|
||||
|
||||
```yaml
|
||||
- $formkit: list
|
||||
name: users
|
||||
label: Users
|
||||
addLabel: Add User
|
||||
min: 1
|
||||
max: 3
|
||||
itemType: string
|
||||
children:
|
||||
- $formkit: text
|
||||
index: "$index"
|
||||
validation: required
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `list` 组件有且只有一个子节点,并且必须为子节点传递 `index` 属性。若想提供多个字段,则建议使用 `group` 组件包裹。
|
||||
|
||||
|
||||
最终得到的数据类似于:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
"Jack",
|
||||
"John"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Repeater
|
||||
|
||||
Repeater 是一个集合类型的输入组件,可以让使用者可视化的操作集合。
|
||||
|
|
|
@ -22,6 +22,7 @@ import { attachmentPolicySelect } from "./inputs/attachment-policy-select";
|
|||
import { attachmentGroupSelect } from "./inputs/attachment-group-select";
|
||||
import { password } from "./inputs/password";
|
||||
import { verificationForm } from "./inputs/verify-form";
|
||||
import { list } from "./inputs/list";
|
||||
|
||||
import radioAlt from "./plugins/radio-alt";
|
||||
import stopImplicitSubmission from "./plugins/stop-implicit-submission";
|
||||
|
@ -41,6 +42,7 @@ const config: DefaultConfigOptions = {
|
|||
autoScrollToErrors,
|
||||
],
|
||||
inputs: {
|
||||
list,
|
||||
form,
|
||||
password,
|
||||
group,
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts" setup>
|
||||
import { VButton, IconAddCircle } from "@halo-dev/components";
|
||||
import type { FormKitFrameworkContext } from "@formkit/core";
|
||||
import type { PropType } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
context: {
|
||||
type: Object as PropType<FormKitFrameworkContext>,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
onClick: {
|
||||
type: Function as PropType<() => void>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const handleAppendClick = () => {
|
||||
if (!props.disabled && props.onClick) {
|
||||
props.onClick();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="context.classes.add" @click="handleAppendClick">
|
||||
<VButton :disabled="disabled" type="secondary">
|
||||
<template #icon>
|
||||
<IconAddCircle class="h-full w-full" />
|
||||
</template>
|
||||
{{ context.addLabel || $t("core.common.buttons.add") }}
|
||||
</VButton>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,79 @@
|
|||
import type { FormKitNode } from "@formkit/core";
|
||||
import { undefine } from "@formkit/utils";
|
||||
|
||||
export const lists = function (node: FormKitNode) {
|
||||
node._c.sync = true;
|
||||
node.on("created", listFeature.bind(null, node));
|
||||
};
|
||||
|
||||
const fn = (node: FormKitNode): object | string | boolean | number => {
|
||||
switch (node.props.itemType.toLocaleLowerCase()) {
|
||||
case "object":
|
||||
return {};
|
||||
case "boolean":
|
||||
return false;
|
||||
case "number":
|
||||
return 0;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
function createValue(num: number, node: FormKitNode) {
|
||||
return new Array(num).fill("").map(() => fn(node));
|
||||
}
|
||||
|
||||
function listFeature(node: FormKitNode) {
|
||||
node.props.removeControl = node.props.removeControl ?? true;
|
||||
node.props.upControl = node.props.upControl ?? true;
|
||||
node.props.downControl = node.props.downControl ?? true;
|
||||
node.props.insertControl = node.props.insertControl ?? true;
|
||||
node.props.addButton = node.props.addButton ?? true;
|
||||
node.props.addLabel = node.props.addLabel ?? false;
|
||||
node.props.addAttrs = node.props.addAttrs ?? {};
|
||||
node.props.min = node.props.min ? Number(node.props.min) : 0;
|
||||
node.props.max = node.props.max ? Number(node.props.max) : Infinity;
|
||||
node.props.itemType = node.props.itemType ?? "string";
|
||||
if (node.props.min > node.props.max) {
|
||||
throw Error("list: min must be less than max");
|
||||
}
|
||||
|
||||
if ("disabled" in node.props) {
|
||||
node.props.disabled = undefine(node.props.disabled);
|
||||
}
|
||||
|
||||
if (Array.isArray(node.value)) {
|
||||
if (node.value.length < node.props.min) {
|
||||
const value = createValue(node.props.min - node.value.length, node);
|
||||
node.input(node.value.concat(value), false);
|
||||
} else {
|
||||
if (node.value.length > node.props.max) {
|
||||
node.input(node.value.slice(0, node.props.max), false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
node.input(createValue(node.props.min, node), false);
|
||||
}
|
||||
|
||||
if (node.context) {
|
||||
const fns = node.context.fns;
|
||||
fns.createShift = (index: number, offset: number) => () => {
|
||||
const value = node._value as unknown[];
|
||||
value.splice(index + offset, 0, value.splice(index, 1)[0]),
|
||||
node.input(value, false);
|
||||
};
|
||||
fns.createInsert = (index: number) => () => {
|
||||
const value = node._value as unknown[];
|
||||
value.splice(index + 1, 0, fn(node)), node.input(value, false);
|
||||
};
|
||||
fns.createAppend = () => () => {
|
||||
const value = node._value as unknown[];
|
||||
console.log(fn(node));
|
||||
value.push(fn(node)), node.input(value, false);
|
||||
};
|
||||
fns.createRemover = (index: number) => () => {
|
||||
const value = node._value as unknown[];
|
||||
value.splice(index, 1), node.input(value, false);
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
import type { FormKitTypeDefinition } from "@formkit/core";
|
||||
|
||||
import {
|
||||
disablesChildren,
|
||||
renamesRadios,
|
||||
fieldset,
|
||||
messages,
|
||||
message,
|
||||
outer,
|
||||
legend,
|
||||
help,
|
||||
inner,
|
||||
prefix,
|
||||
$if,
|
||||
suffix,
|
||||
} from "@formkit/inputs";
|
||||
import {
|
||||
addButton,
|
||||
content,
|
||||
controls,
|
||||
down,
|
||||
downControl,
|
||||
downIcon,
|
||||
empty,
|
||||
insert,
|
||||
insertControl,
|
||||
insertIcon,
|
||||
item,
|
||||
items,
|
||||
remove,
|
||||
removeControl,
|
||||
removeIcon,
|
||||
up,
|
||||
upControl,
|
||||
upIcon,
|
||||
} from "./sections";
|
||||
import { i18n } from "@/locales";
|
||||
import {
|
||||
IconAddCircle,
|
||||
IconArrowDownCircleLine,
|
||||
IconArrowUpCircleLine,
|
||||
IconCloseCircle,
|
||||
} from "@halo-dev/components";
|
||||
import AddButton from "./AddButton.vue";
|
||||
import { lists } from "./features/lists";
|
||||
|
||||
/**
|
||||
* Input definition for a dynamic list input.
|
||||
* @public
|
||||
*/
|
||||
export const list: FormKitTypeDefinition = {
|
||||
/**
|
||||
* The actual schema of the input, or a function that returns the schema.
|
||||
*/
|
||||
schema: outer(
|
||||
fieldset(
|
||||
legend("$label"),
|
||||
help("$help"),
|
||||
inner(
|
||||
prefix(),
|
||||
$if(
|
||||
"$value.length === 0",
|
||||
$if("$slots.empty", empty()),
|
||||
$if(
|
||||
"$slots.default",
|
||||
items(
|
||||
item(
|
||||
content("$slots.default"),
|
||||
controls(
|
||||
up(upControl(upIcon())),
|
||||
remove(removeControl(removeIcon())),
|
||||
insert(insertControl(insertIcon())),
|
||||
down(downControl(downIcon()))
|
||||
)
|
||||
)
|
||||
),
|
||||
suffix()
|
||||
)
|
||||
),
|
||||
suffix(),
|
||||
addButton(`$addLabel || (${i18n.global.t("core.common.buttons.add")})`)
|
||||
)
|
||||
),
|
||||
messages(message("$message.value"))
|
||||
),
|
||||
/**
|
||||
* The type of node, can be a list, group, or input.
|
||||
*/
|
||||
type: "list",
|
||||
/**
|
||||
* An array of extra props to accept for this input.
|
||||
*/
|
||||
props: [
|
||||
"min",
|
||||
"max",
|
||||
"upControl",
|
||||
"downControl",
|
||||
"removeControl",
|
||||
"insertControl",
|
||||
"addLabel",
|
||||
"addButton",
|
||||
"itemType",
|
||||
],
|
||||
/**
|
||||
* Additional features that should be added to your input
|
||||
*/
|
||||
features: [lists, disablesChildren, renamesRadios],
|
||||
|
||||
library: {
|
||||
IconAddCircle,
|
||||
IconCloseCircle,
|
||||
IconArrowUpCircleLine,
|
||||
IconArrowDownCircleLine,
|
||||
AddButton,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
isComponent,
|
||||
isDOM,
|
||||
type FormKitSchemaNode,
|
||||
type FormKitExtendableSchemaRoot,
|
||||
type FormKitSchemaCondition,
|
||||
type FormKitSectionsSchema,
|
||||
} from "@formkit/core";
|
||||
import {
|
||||
extendSchema,
|
||||
type FormKitSchemaExtendableSection,
|
||||
type FormKitSection,
|
||||
} from "@formkit/inputs";
|
||||
|
||||
export function createListSection() {
|
||||
return (
|
||||
section: string,
|
||||
el: string | null | (() => FormKitSchemaNode),
|
||||
fragment = false
|
||||
) => {
|
||||
return createSection(
|
||||
section,
|
||||
el,
|
||||
fragment
|
||||
) as FormKitSection<FormKitSchemaExtendableSection>;
|
||||
};
|
||||
}
|
||||
|
||||
function createSection(
|
||||
section: string,
|
||||
el: string | null | (() => FormKitSchemaNode),
|
||||
fragment = false
|
||||
): FormKitSection<
|
||||
FormKitExtendableSchemaRoot | FormKitSchemaExtendableSection
|
||||
> {
|
||||
return (
|
||||
...children: Array<
|
||||
FormKitSchemaExtendableSection | string | FormKitSchemaCondition
|
||||
>
|
||||
) => {
|
||||
const extendable = (extensions: FormKitSectionsSchema) => {
|
||||
const node = !el || typeof el === "string" ? { $el: el } : el();
|
||||
|
||||
if ("string" != typeof node) {
|
||||
if (isDOM(node) || isComponent(node) || "$formkit" in node) {
|
||||
if (children.length && !node.children) {
|
||||
node.children = [
|
||||
...children.map((child) =>
|
||||
typeof child === "function" ? child(extensions) : child
|
||||
),
|
||||
];
|
||||
}
|
||||
if (!node.meta) {
|
||||
node.meta = { section };
|
||||
}
|
||||
if (isDOM(node)) {
|
||||
node.attrs = {
|
||||
class: `$classes.${section}`,
|
||||
...(node.attrs || {}),
|
||||
};
|
||||
}
|
||||
if ("$formkit" in node) {
|
||||
node.outerClass = `$classes.${section}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
if: `$slots.${section}`,
|
||||
then: `$slots.${section}`,
|
||||
else:
|
||||
section in extensions
|
||||
? extendSchema(node as FormKitSchemaNode, extensions[section])
|
||||
: node,
|
||||
} as FormKitSchemaCondition;
|
||||
};
|
||||
extendable._s = section;
|
||||
return fragment ? createRoot(extendable) : extendable;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Returns an extendable schema root node.
|
||||
*
|
||||
* @param rootSection - Creates the root node.
|
||||
*
|
||||
* @returns {@link @formkit/core#FormKitExtendableSchemaRoot | FormKitExtendableSchemaRoot}
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function createRoot(
|
||||
rootSection: FormKitSchemaExtendableSection
|
||||
): FormKitExtendableSchemaRoot {
|
||||
return (extensions: FormKitSectionsSchema) => {
|
||||
return [rootSection(extensions)];
|
||||
};
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
import { createListSection } from "../listSection";
|
||||
|
||||
const listSection = createListSection();
|
||||
|
||||
export const addButton = listSection("addButton", () => ({
|
||||
$cmp: "AddButton",
|
||||
props: {
|
||||
onClick: "$fns.createAppend()",
|
||||
disabled: "$value.length >= $max",
|
||||
context: "$node.context",
|
||||
},
|
||||
bind: "$addAttrs",
|
||||
if: "$addButton",
|
||||
type: "button",
|
||||
}));
|
||||
|
||||
export const content = listSection("content", () => ({
|
||||
$el: "div",
|
||||
attrs: {
|
||||
key: "$item",
|
||||
index: "$index",
|
||||
"data-index": "$index",
|
||||
},
|
||||
}));
|
||||
|
||||
export const controlLabel = listSection("controlLabel", "span");
|
||||
|
||||
export const controls = listSection("controls", () => ({
|
||||
$el: "ul",
|
||||
if: "$removeControl || $insertControl || $upControl || $downControl",
|
||||
}));
|
||||
|
||||
export const down = listSection("down", () => ({
|
||||
$el: "li",
|
||||
if: "$downControl",
|
||||
}));
|
||||
|
||||
export const downControl = listSection("downControl", () => ({
|
||||
$el: "button",
|
||||
attrs: {
|
||||
disabled: "$index >= $value.length - 1",
|
||||
onClick: "$fns.createShift($index, 1)",
|
||||
type: "button",
|
||||
class: `$classes.control`,
|
||||
},
|
||||
}));
|
||||
|
||||
export const downIcon = listSection("downIcon", () => ({
|
||||
$cmp: "IconArrowDownCircleLine",
|
||||
}));
|
||||
|
||||
export const empty = listSection("empty", () => ({
|
||||
$el: "div",
|
||||
}));
|
||||
|
||||
export const fieldset = listSection("fieldset", () => ({
|
||||
$el: "fieldset",
|
||||
attrs: {
|
||||
id: "$id",
|
||||
disabled: "$disabled",
|
||||
},
|
||||
}));
|
||||
|
||||
export const insert = listSection("insert", () => ({
|
||||
$el: "li",
|
||||
if: "$insertControl",
|
||||
}));
|
||||
|
||||
export const insertControl = listSection("insertControl", () => ({
|
||||
$el: "button",
|
||||
attrs: {
|
||||
disabled: "$value.length >= $max",
|
||||
onClick: "$fns.createInsert($index)",
|
||||
type: "button",
|
||||
class: `$classes.control`,
|
||||
},
|
||||
}));
|
||||
|
||||
export const insertIcon = listSection("insertIcon", () => ({
|
||||
$cmp: "IconAddCircle",
|
||||
}));
|
||||
|
||||
export const item = listSection("item", () => ({
|
||||
$el: "li",
|
||||
for: ["item", "index", "$items"],
|
||||
attrs: {
|
||||
role: "listitem",
|
||||
key: "$item",
|
||||
index: "$index",
|
||||
"data-index": "$index",
|
||||
},
|
||||
}));
|
||||
|
||||
export const items = listSection("items", () => ({
|
||||
$el: "ul",
|
||||
attrs: {
|
||||
role: "list",
|
||||
},
|
||||
}));
|
||||
|
||||
export const remove = listSection("remove", () => ({
|
||||
$el: "li",
|
||||
if: "$removeControl",
|
||||
}));
|
||||
|
||||
export const removeControl = listSection("removeControl", () => ({
|
||||
$el: "button",
|
||||
attrs: {
|
||||
disabled: "$value.length <= $min",
|
||||
onClick: "$fns.createRemover($index)",
|
||||
type: "button",
|
||||
class: `$classes.control`,
|
||||
},
|
||||
}));
|
||||
|
||||
export const removeIcon = listSection("removeIcon", () => ({
|
||||
$cmp: "IconCloseCircle",
|
||||
}));
|
||||
|
||||
export const up = listSection("up", () => ({
|
||||
$el: "li",
|
||||
if: "$upControl",
|
||||
}));
|
||||
|
||||
export const upControl = listSection("upControl", () => ({
|
||||
$el: "button",
|
||||
attrs: {
|
||||
disabled: "$index <= 0",
|
||||
onClick: "$fns.createShift($index, -1)",
|
||||
type: "button",
|
||||
class: `$classes.control`,
|
||||
},
|
||||
}));
|
||||
|
||||
export const upIcon = listSection("upIcon", () => ({
|
||||
$cmp: "IconArrowUpCircleLine",
|
||||
}));
|
|
@ -98,6 +98,24 @@ const theme: Record<string, Record<string, string>> = {
|
|||
control:
|
||||
"cursor-pointer text-gray-500 transition-all hover:text-primary disabled:!cursor-not-allowed disabled:opacity-50 disabled:hover:!text-gray-500",
|
||||
},
|
||||
list: {
|
||||
label: textClassification.label,
|
||||
legend: `${textClassification.label} px-2`,
|
||||
fieldset: boxClassification.fieldset,
|
||||
wrapper: boxClassification.wrapper,
|
||||
help: `${boxClassification.wrapper} mb-2`,
|
||||
inner: "flex flex-col gap-4",
|
||||
items: "flex flex-col w-full gap-2 rounded-base",
|
||||
item: "grid grid-cols-12 focus-within:border-primary transition-all gap-1.5 overflow-visible",
|
||||
content: "flex-1 col-span-9 divide-y divide-gray-100",
|
||||
controls: "flex items-center justify-center gap-1 col-span-3",
|
||||
control:
|
||||
"cursor-pointer text-gray-500 transition-all hover:text-primary disabled:!cursor-not-allowed disabled:opacity-50 disabled:hover:!text-gray-500",
|
||||
up: "flex",
|
||||
remove: "flex",
|
||||
insert: "flex",
|
||||
down: "flex",
|
||||
},
|
||||
group: {
|
||||
label: textClassification.label,
|
||||
legend: `${textClassification.label} px-2`,
|
||||
|
|
Loading…
Reference in New Issue