diff --git a/packages/common-helpers/src/form/input.ts b/packages/common-helpers/src/form/input.ts
index c57c7638..d0250150 100644
--- a/packages/common-helpers/src/form/input.ts
+++ b/packages/common-helpers/src/form/input.ts
@@ -4,11 +4,12 @@ import isEqual from "lodash-es/isEqual";
import type {
iFormInput,
iFormInputDefault,
+ iFormOption,
iFormValue,
- iSelectOption,
tFormAutocomplete,
tFormIcon,
tFormInputDefault,
+ tOptionsLoaderFn,
} from "@open-xamu-co/ui-common-types";
import {
eFormType,
@@ -130,7 +131,7 @@ export class FormInput<
implements iFormInput
{
// private
- private _options: iSelectOption[];
+ private _options: iFormOption[];
private _values: V[];
private _defaults?: [
iFormInputDefault,
@@ -145,6 +146,7 @@ export class FormInput<
// public readonly
public readonly name: string;
public readonly title?: string;
+ public readonly optionsFilter?: tOptionsLoaderFn;
/**
* Form input constructor
@@ -163,46 +165,47 @@ export class FormInput<
this.name = formInput.name;
this.multiple = formInput.multiple ?? false;
this.title = formInput.title;
- this._options = formInput.options?.map(toOption) ?? [];
+
+ // Initialize options array, skip if function
+ if (Array.isArray(formInput.options)) {
+ this._options = formInput.options.map(toOption);
+ this.optionsFilter = undefined;
+ } else {
+ this._options = [];
+ this.optionsFilter = formInput.options;
+ }
+
this._defaults = formInput.defaults;
this.min = formInput.min ?? 1;
this.meta = formInput.meta || {};
- // max cannot be lower than min or more than options if they exist
+ // Max cannot be lower than min or more than options if they exist
const maxValue = this._options.length || formInput.max || 9e9;
this.max = maxValue < this.min ? this.min : maxValue;
this._values = formInput.values ||= [];
if (isChoiceType(this.type)) {
- // autoset single value if required
- if (this.required && !this._values.length) {
- const values = this.options.map(({ value }) => value);
-
- this._values = values.slice(0, Math.max(1, this.min)) as V[];
- }
+ // Autoset values if required
+ if (this.required && !this._values.length) this.autosetValues();
} else if (this.type !== eFormType.FILE) {
- const length = Math.max(1, this.min); // negative values fallback
+ const length = Math.max(1, this.min); // Negative values fallback
const values = Array(length).fill(getDefault(formInput.type, formInput.defaults));
- // use defaults
+ // Use defaults
if (this._values.length < length) this._values = values;
}
}
- get options(): iSelectOption[] {
+ get options(): iFormOption[] {
return this._options;
}
- set options(updatedOptions: iSelectOption[] | undefined) {
+ set options(updatedOptions: iFormOption[] | undefined) {
this._options = updatedOptions || [];
if (isChoiceType(this.type)) {
- // autoset single value if required
- if (this.required && !this._values.length) {
- const values = this.options.map(({ value }) => value);
-
- this._values = values.slice(0, Math.max(1, this.min));
- }
+ // Autoset values if required
+ if (this.required && !this._values.length) this.autosetValues();
}
this.rerender();
@@ -217,12 +220,8 @@ export class FormInput<
this._values = [];
if (isChoiceType(this.type)) {
- // autoset single value if required
- if (this.required && !this._values.length) {
- const values = this.options.map(({ value }) => value);
-
- this._values = values.slice(0, Math.max(1, this.min));
- }
+ // Autoset values if required
+ if (this.required && !this._values.length) this.autosetValues();
} else if (this.type !== eFormType.FILE) {
const length = Math.max(1, this.min); // negative values fallback
const values = Array(length).fill(getDefault(this.type, this.defaults));
@@ -246,6 +245,19 @@ export class FormInput<
this.rerender();
}
+ /** Autoset values */
+ private autosetValues() {
+ const autosetValuesArr = [];
+
+ for (let i = 0; i < Math.max(1, this.min); i++) {
+ const option = this._options[i];
+
+ autosetValuesArr.push((option?.value || "") as V);
+ }
+
+ this._values = autosetValuesArr as V[];
+ }
+
/**
* set rerender function
*
diff --git a/packages/common-helpers/src/format.ts b/packages/common-helpers/src/format.ts
index 9a6172c0..bdfbd991 100644
--- a/packages/common-helpers/src/format.ts
+++ b/packages/common-helpers/src/format.ts
@@ -1,4 +1,11 @@
-import type { iFormOption, iSelectOption } from "@open-xamu-co/ui-common-types";
+import type { iFormInputOptions, iFormOption, iSelectOption } from "@open-xamu-co/ui-common-types";
+
+/**
+ * Safe length for Vue keys / counts when `options` may be a static array or an async loader.
+ */
+export function getFormInputOptionsLength(options?: iFormInputOptions): number {
+ return Array.isArray(options) ? options.length : 0;
+}
/**
* create iSelectOption or iFormOption from compatible values
diff --git a/packages/common-helpers/src/i18n.ts b/packages/common-helpers/src/i18n.ts
index 344ebfe1..a47c84d6 100644
--- a/packages/common-helpers/src/i18n.ts
+++ b/packages/common-helpers/src/i18n.ts
@@ -50,16 +50,16 @@ export default function useI18n -1 && plurals.length > 1) {
+ if (plurals.length > 1) {
if (plurals.length === 2) {
// product, products
locale = plurals[count > 1 ? 1 : 0];
} else if (plurals.length === 3) {
// no products, a product, products
- locale = plurals[count ? (count > 1 ? 2 : 1) : 0];
+ locale = plurals[count > 0 ? (count > 1 ? 2 : 1) : 0];
}
}
diff --git a/packages/common-types/src/form/class.ts b/packages/common-types/src/form/class.ts
index e5daa554..0f5d0e5c 100644
--- a/packages/common-types/src/form/class.ts
+++ b/packages/common-types/src/form/class.ts
@@ -7,11 +7,12 @@ import type {
import type {
iFormInput,
iFormInputDefault,
+ iFormOption,
iFormValue,
tFormAutocomplete,
tFormIcon,
+ tOptionsLoaderFn,
} from "./types";
-import type { iSelectOption } from "../values.js";
export declare abstract class tFormInputDefault<
T extends eFormTypeBase | eFormTypeSimple | eFormTypeComplex = eFormTypeSimple,
@@ -44,12 +45,13 @@ export declare abstract class tFormInput<
// public readonly
public readonly name: string;
public readonly title?: string;
- public readonly multiple: boolean;
- public readonly min: number;
- public readonly max: number;
- public readonly meta?: Record;
+ public readonly optionsFilter?: tOptionsLoaderFn;
// public
- public options: iSelectOption[];
+ public multiple: boolean;
+ public min: number;
+ public max: number;
+ public meta?: Record;
+ public options: iFormOption[];
public values: V[];
public defaults?: [
iFormInputDefault,
diff --git a/packages/common-types/src/form/types.ts b/packages/common-types/src/form/types.ts
index 776f0eea..f252466e 100644
--- a/packages/common-types/src/form/types.ts
+++ b/packages/common-types/src/form/types.ts
@@ -113,15 +113,29 @@ export interface iFormInputDefault<
autocomplete?: tFormAutocomplete;
}
+export type tOptionsLoaderFn =
+ | ((v?: string | number, signal?: AbortSignal) => iFormOption[])
+ | ((v?: string | number, signal?: AbortSignal) => Promise);
+
+/** If a function is given, it should be responsible of determining if v is a value or an alias (search) */
+export type iFormInputOptions = (string | number | iFormOption)[] | tOptionsLoaderFn;
+
/**
* Complex input, sub input support
+ * Used as input for the classes
*/
export interface iFormInput<
V extends iFormValue | iFormValue[],
T extends eFormTypeBase | eFormTypeSimple | eFormTypeComplex,
> extends iFormInputDefault {
name: string;
- options?: (string | number | iFormOption)[];
+ /**
+ * Options for select, checkbox, radio inputs
+ * The usage of a function requires a compatible component (SelectFilter)
+ *
+ * @values array of options or function that returns options
+ */
+ options?: iFormInputOptions;
/**
* An array of values to simplify validation
*
@@ -189,6 +203,13 @@ export interface iFetchResponse {
[x: string]: any;
}
+/**
+ * Takes the parsed inputs values and perform a request (content creation, update, etc).
+ *
+ * @param values Values of the input
+ * @param fetcher Request function
+ * @returns Response with data and errors if any
+ */
export type tResponseFn = Record> = (
values: V
) => Promise>;
diff --git a/packages/components-vue/src/components/base/Select.vue b/packages/components-vue/src/components/base/Select.vue
index 3fb1df47..9d939d98 100644
--- a/packages/components-vue/src/components/base/Select.vue
+++ b/packages/components-vue/src/components/base/Select.vue
@@ -64,7 +64,8 @@
const { t } = useHelpers(useI18n);
const selectOptions = computed(() => {
- return (props.options ?? []).map(toOption);
+ // Only use array type, skip if function
+ return Array.isArray(props.options) ? props.options.map(toOption) : [];
});
/** Prefer a predictable identifier */
const selectId = computed(() => {
diff --git a/packages/components-vue/src/components/collapse/Simple.vue b/packages/components-vue/src/components/collapse/Simple.vue
index 05b4953d..84fc0e7d 100644
--- a/packages/components-vue/src/components/collapse/Simple.vue
+++ b/packages/components-vue/src/components/collapse/Simple.vue
@@ -1,12 +1,12 @@
-
diff --git a/packages/components-vue/src/components/form/Simple.vue b/packages/components-vue/src/components/form/Simple.vue
index 56c3241b..ff310d0c 100644
--- a/packages/components-vue/src/components/form/Simple.vue
+++ b/packages/components-vue/src/components/form/Simple.vue
@@ -29,7 +29,7 @@
{{ getSuggestedTitle(input) }}
(() => {
- return (props.options || []).map(toOption).filter(({ hidden }) => !hidden);
+ // Only use array type, skip if function
+ if (!Array.isArray(props.options)) return [];
+
+ return props.options.reduce((acc, current) => {
+ const option = toOption(current);
+
+ if (!option.hidden) acc.push(option);
+
+ return acc;
+ }, []);
});
function choose(value: string | number) {
diff --git a/packages/components-vue/src/components/select/Filter.stories.ts b/packages/components-vue/src/components/select/Filter.stories.ts
index 724044c9..01f6f324 100644
--- a/packages/components-vue/src/components/select/Filter.stories.ts
+++ b/packages/components-vue/src/components/select/Filter.stories.ts
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { ref } from "vue";
-import type { iSelectOption } from "@open-xamu-co/ui-common-types";
+import type { iFormOption, iSelectOption } from "@open-xamu-co/ui-common-types";
import SelectFilter from "./Filter.vue";
@@ -36,4 +36,42 @@ export const WithOptions: Story = {
}),
};
+const allOptions: iFormOption[] = [
+ { value: "alpha", alias: "Alpha" },
+ { value: "beta", alias: "Beta" },
+ { value: "betaLike", alias: "Beta Like" },
+ { value: "gamma", alias: "Gamma" },
+];
+
+/** Emulate async options loader */
+export async function mockOptionsLoader(query?: string | number): Promise {
+ await new Promise((r) => setTimeout(r, 200));
+
+ const q = String(query ?? "")
+ .trim()
+ .toLowerCase();
+
+ if (!q) return allOptions;
+
+ return allOptions.filter(
+ (o) =>
+ String(o.value).toLowerCase().includes(q) ||
+ String(o.alias ?? o.value)
+ .toLowerCase()
+ .includes(q)
+ );
+}
+
+export const AsyncOptions: Story = {
+ render: (args) => ({
+ components: { SelectFilter },
+ setup() {
+ const model = ref("betaLike");
+
+ return { args, model, mockOptionsLoader };
+ },
+ template: '',
+ }),
+};
+
export default meta;
diff --git a/packages/components-vue/src/components/select/Filter.vue b/packages/components-vue/src/components/select/Filter.vue
index 152db056..1b2b5b38 100644
--- a/packages/components-vue/src/components/select/Filter.vue
+++ b/packages/components-vue/src/components/select/Filter.vue
@@ -1,7 +1,12 @@
-
-
+
+
diff --git a/packages/components-vue/src/composables/async.ts b/packages/components-vue/src/composables/async.ts
index d32921f8..2bd1f3af 100644
--- a/packages/components-vue/src/composables/async.ts
+++ b/packages/components-vue/src/composables/async.ts
@@ -22,16 +22,16 @@ type tAsyncDataHandler = (nuxtApp?: any, options?: { signal?: AbortSignal })
*
* @see https://nuxt.com/docs/api/composables/use-async-data#type
*/
-export function useAsyncDataFn(
+export function useAsyncDataFn(
handler: tAsyncDataHandler,
options?: AsyncDataOptions
): iAsyncData;
-export function useAsyncDataFn(
+export function useAsyncDataFn(
key: string,
handler: tAsyncDataHandler,
options?: AsyncDataOptions
): iAsyncData;
-export function useAsyncDataFn(
+export function useAsyncDataFn(
handlerOrKey: string | tAsyncDataHandler,
optionsOrHandler?: tAsyncDataHandler | AsyncDataOptions,
options?: AsyncDataOptions
diff --git a/packages/components-vue/src/types/props/base.ts b/packages/components-vue/src/types/props/base.ts
index b75dca1a..9be2b228 100644
--- a/packages/components-vue/src/types/props/base.ts
+++ b/packages/components-vue/src/types/props/base.ts
@@ -1,5 +1,4 @@
import type {
- iFormOption,
tFormAutocomplete,
tIndicative,
tProp,
@@ -8,6 +7,7 @@ import type {
tThemeModifier,
tThemeTuple,
tSizeModifier,
+ iFormInputOptions,
} from "@open-xamu-co/ui-common-types";
export interface iUseModifiersProps {
@@ -115,7 +115,7 @@ export interface iInputProps extends iInputLikeProps {
}
export interface iSelectProps extends iInputLikeProps {
- options?: Array;
+ options?: iFormInputOptions;
/**
* Multiple fields
*/
diff --git a/packages/components-vue/vite.config.d.ts b/packages/components-vue/vite.config.d.ts
new file mode 100644
index 00000000..5980175a
--- /dev/null
+++ b/packages/components-vue/vite.config.d.ts
@@ -0,0 +1,3 @@
+declare const _default: import("vite").UserConfig;
+
+export default _default;
diff --git a/packages/components-vue/vite.config.js b/packages/components-vue/vite.config.js
new file mode 100644
index 00000000..a9743787
--- /dev/null
+++ b/packages/components-vue/vite.config.js
@@ -0,0 +1,42 @@
+import path from "node:path";
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [vue()],
+ resolve: { dedupe: ["vue"] },
+ build: {
+ lib: {
+ entry: {
+ // root
+ index: path.resolve(__dirname, "./src"),
+ // plugin
+ plugin: path.resolve(__dirname, "./src/plugin"),
+ // theme
+ theme: path.resolve(__dirname, "./src/composables/theme"),
+ },
+ name: "@open-xamu-co/ui-components-vue",
+ formats: ["es", "cjs"],
+ },
+ rollupOptions: {
+ // make sure to externalize deps that shouldn't be bundled into your library
+ external: [
+ "vue",
+ "lodash-es",
+ "sweetalert2",
+ "ts-md5",
+ "validator",
+ "vue-color",
+ "@open-xamu-co/ui-common-enums",
+ "@open-xamu-co/ui-common-helpers",
+ ],
+ output: {
+ // Provide global variables to use in the UMD build for externalized deps
+ globals: { vue: "Vue" },
+ },
+ preserveEntrySignatures: "strict",
+ treeshake: { moduleSideEffects: false },
+ },
+ },
+});
diff --git a/packages/components-vue/vitest.config.d.ts b/packages/components-vue/vitest.config.d.ts
new file mode 100644
index 00000000..21c65ce9
--- /dev/null
+++ b/packages/components-vue/vitest.config.d.ts
@@ -0,0 +1,3 @@
+declare const _default: Record;
+
+export default _default;
diff --git a/packages/components-vue/vitest.config.js b/packages/components-vue/vitest.config.js
new file mode 100644
index 00000000..f9154f8d
--- /dev/null
+++ b/packages/components-vue/vitest.config.js
@@ -0,0 +1,93 @@
+var __spreadArray =
+ (this && this.__spreadArray) ||
+ function (to, from, pack) {
+ if (pack || arguments.length === 2) {
+ for (var i = 0, l = from.length, ar; i < l; i++) {
+ if (ar || !(i in from)) {
+ if (!ar) ar = Array.prototype.slice.call(from, 0, i);
+
+ ar[i] = from[i];
+ }
+ }
+ }
+
+ return to.concat(ar || Array.prototype.slice.call(from));
+ };
+
+import { fileURLToPath } from "node:url";
+import { mergeConfig, defineConfig, configDefaults, coverageConfigDefaults } from "vitest/config";
+import viteConfig from "./vite.config";
+import path from "node:path";
+import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
+import { playwright } from "@vitest/browser-playwright";
+
+/**
+ * Coverage threshold for tests
+ * TODO: Increase test coverage to 80%
+ */
+var coverage = 40;
+var dirname =
+ typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
+
+// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
+export default mergeConfig(
+ viteConfig,
+ defineConfig({
+ test: {
+ coverage: {
+ provider: "v8",
+ thresholds: {
+ lines: coverage, // 58.84%
+ functions: coverage, // 50.82%
+ branches: coverage, // 47.15%
+ statements: coverage, // 55.74%
+ },
+ exclude: __spreadArray(
+ __spreadArray([], coverageConfigDefaults.exclude, true),
+ ["e2e/**", ".storybook/**"],
+ false
+ ),
+ },
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: "e2e",
+ environment: "jsdom",
+ root: fileURLToPath(new URL("./", import.meta.url)),
+ },
+ },
+ {
+ extends: true,
+ plugins: [
+ // The plugin will run tests for the stories defined in your Storybook config
+ // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
+ storybookTest({
+ // The location of your Storybook config, main.js|ts
+ configDir: path.join(dirname, ".storybook"),
+ // This should match your package.json script to run Storybook
+ // The --ci flag will skip prompts and not open a browser
+ storybookScript: "yarn dev --ci",
+ tags: { include: ["test"], exclude: ["experimental"] },
+ }),
+ ],
+ test: {
+ name: "storybook",
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright({}),
+ instances: [{ browser: "chromium" }],
+ },
+ setupFiles: [".storybook/vitest.setup.ts"],
+ },
+ },
+ ],
+ exclude: __spreadArray(
+ __spreadArray([], configDefaults.exclude, true),
+ ["e2e/**", ".storybook/**"],
+ false
+ ),
+ },
+ })
+);
diff --git a/packages/components-vue/vitest.config.ts b/packages/components-vue/vitest.config.ts
index bf4f36dc..c6082f22 100644
--- a/packages/components-vue/vitest.config.ts
+++ b/packages/components-vue/vitest.config.ts
@@ -21,10 +21,10 @@ export default mergeConfig(
coverage: {
provider: "v8",
thresholds: {
- lines: coverage, // 61.97%
- functions: coverage, // 49.66%
- branches: coverage, // 53.04%
- statements: coverage, // 58.97%
+ lines: coverage, // 58.84%
+ functions: coverage, // 50.82%
+ branches: coverage, // 47.15%
+ statements: coverage, // 55.74%
},
exclude: [...coverageConfigDefaults.exclude, "e2e/**", ".storybook/**"],
},
diff --git a/packages/styles/src/components/action/_inputCheckbox.scss b/packages/styles/src/components/action/_inputCheckbox.scss
index 59e91b2d..10ce8ea0 100644
--- a/packages/styles/src/components/action/_inputCheckbox.scss
+++ b/packages/styles/src/components/action/_inputCheckbox.scss
@@ -7,7 +7,7 @@
$radius-action: module.strip-unit(module.$size-radius-action);
@layer definitions {
- input[type^="c"].#{module.$component-input-checkbox} {
+ input[type="checkbox"].#{module.$component-input-checkbox} {
@include module.action-toggle-styles;
// Toggle size
diff --git a/packages/styles/src/components/action/_inputRadio.scss b/packages/styles/src/components/action/_inputRadio.scss
index 6b3e780f..c20b0570 100644
--- a/packages/styles/src/components/action/_inputRadio.scss
+++ b/packages/styles/src/components/action/_inputRadio.scss
@@ -7,7 +7,7 @@
$radius-action-round: module.strip-unit(module.$size-radius-action-round);
@layer definitions {
- input[type^="r"].#{module.$component-input-radio} {
+ input[type="radio"].#{module.$component-input-radio} {
@include module.action-toggle-styles;
// Toggle size
diff --git a/packages/styles/src/layouts/_navList.scss b/packages/styles/src/layouts/_navList.scss
index 8ffa848e..cb7c43de 100644
--- a/packages/styles/src/layouts/_navList.scss
+++ b/packages/styles/src/layouts/_navList.scss
@@ -12,22 +12,28 @@
@include utils.flex-box(row, nowrap, space-between, center);
i.#{utils.$component-icon},
.#{utils.$component-svg} {
- width: 1.8rem;
+ &.#{utils.$prefix-default}--indicator {
+ width: 1.8rem;
+ }
}
}
nav.#{utils.$layout-list} {
- > input[type^="c"] {
+ > input[type="radio"],
+ > input[type="checkbox"] {
display: none;
}
&.#{utils.$status-is-active},
- > input[type^="c"]:checked ~ {
+ > input[type="radio"]:checked ~,
+ > input[type="checkbox"]:checked ~ {
.#{utils.$status-toggle-list} {
font-weight: utils.weight(bold);
margin-bottom: 1rem;
i.#{utils.$component-icon},
.#{utils.$component-svg} {
- transform: rotate(180deg);
+ &.#{utils.$prefix-default}--indicator {
+ transform: rotate(180deg);
+ }
}
}
.#{utils.$status-toggle-list} + {