diff --git a/ui/docs/custom-formkit-input/README.md b/ui/docs/custom-formkit-input/README.md index eb0f95199..671638660 100644 --- a/ui/docs/custom-formkit-input/README.md +++ b/ui/docs/custom-formkit-input/README.md @@ -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 + + + +``` + +在 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 是一个集合类型的输入组件,可以让使用者可视化的操作集合。 diff --git a/ui/src/formkit/formkit.config.ts b/ui/src/formkit/formkit.config.ts index 84861bf41..34df47daf 100644 --- a/ui/src/formkit/formkit.config.ts +++ b/ui/src/formkit/formkit.config.ts @@ -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, diff --git a/ui/src/formkit/inputs/list/AddButton.vue b/ui/src/formkit/inputs/list/AddButton.vue new file mode 100644 index 000000000..52be837ba --- /dev/null +++ b/ui/src/formkit/inputs/list/AddButton.vue @@ -0,0 +1,37 @@ + + + diff --git a/ui/src/formkit/inputs/list/features/lists.ts b/ui/src/formkit/inputs/list/features/lists.ts new file mode 100644 index 000000000..86ff42f2c --- /dev/null +++ b/ui/src/formkit/inputs/list/features/lists.ts @@ -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); + }; + } +} diff --git a/ui/src/formkit/inputs/list/index.ts b/ui/src/formkit/inputs/list/index.ts new file mode 100644 index 000000000..05631cf72 --- /dev/null +++ b/ui/src/formkit/inputs/list/index.ts @@ -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, + }, +}; diff --git a/ui/src/formkit/inputs/list/listSection.ts b/ui/src/formkit/inputs/list/listSection.ts new file mode 100644 index 000000000..816c6ae01 --- /dev/null +++ b/ui/src/formkit/inputs/list/listSection.ts @@ -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; + }; +} + +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)]; + }; +} diff --git a/ui/src/formkit/inputs/list/sections/index.ts b/ui/src/formkit/inputs/list/sections/index.ts new file mode 100644 index 000000000..ec806819b --- /dev/null +++ b/ui/src/formkit/inputs/list/sections/index.ts @@ -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", +})); diff --git a/ui/src/formkit/theme.ts b/ui/src/formkit/theme.ts index d0f315be1..fc1c20f71 100644 --- a/ui/src/formkit/theme.ts +++ b/ui/src/formkit/theme.ts @@ -98,6 +98,24 @@ const theme: Record> = { 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`,