@astrake/lumora-ui 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,13 +4,32 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.1.6] — 2026-04-25
8
+
9
+ ### Maintenance
10
+
11
+ - bump version to 0.1.6 - add LuForm validation orchestrator (`1f11734`)
12
+
13
+
14
+ ---
15
+
16
+ ## [0.1.6] — 2026-04-26
17
+
18
+ ### Added
19
+ - `LuForm` — headless validation orchestrator component with slot-based API
20
+ - `LuForm.types.ts` — `LuFormRules`, `LuFormErrors`, `LuFormValidator`, `LuFormContext` types
21
+ - Form context integration for `LuInput`, `LuSelect`, `LuSwitch` — `name`, `error` props; register/unregister lifecycle
22
+ - `LuFormContextKey` injection key (internal Symbol) for child-field coordination
23
+ - 10 vitest test cases covering submit, validation, reset, blur, disabled, and programmatic API
24
+
25
+ ---
26
+
7
27
  ## [0.1.5] — 2026-04-25
8
28
 
9
29
  ### Fixed
10
30
 
11
31
  - ci workflow errors — npm publish auth and correct artifact path (`af81e69`)
12
32
 
13
-
14
33
  ---
15
34
 
16
35
  ## [0.1.4] — 2026-04-25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrake/lumora-ui",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Headless Vue 3 component framework for three surface targets — Mobile, Desktop, and Embedded — with a unified --lu-* design token system.",
5
5
  "author": "Anuvab Chakraborty (https://github.com/madlybong)",
6
6
  "license": "MIT",
@@ -0,0 +1,24 @@
1
+ import type { InjectionKey, Ref } from "vue";
2
+
3
+ export type LuFormValidator = (value: unknown) => string | null | Promise<string | null>;
4
+
5
+ export type LuFormRules = Record<string, LuFormValidator | LuFormValidator[]>;
6
+
7
+ export type LuFormErrors = Record<string, string>;
8
+
9
+ export interface LuFormFieldRegistration {
10
+ name: string;
11
+ getValue: () => unknown;
12
+ setValue: (v: unknown) => void;
13
+ setError: (msg: string | null) => void;
14
+ }
15
+
16
+ export const LuFormContextKey = Symbol("LuFormContext") as InjectionKey<LuFormContext>;
17
+
18
+ export interface LuFormContext {
19
+ register(field: LuFormFieldRegistration): void;
20
+ unregister(name: string): void;
21
+ getError(name: string): string | null;
22
+ validateOn: Readonly<Ref<"submit" | "blur" | "both">>;
23
+ disabled: Readonly<Ref<boolean>>;
24
+ }
@@ -0,0 +1,121 @@
1
+ <template>
2
+ <form @submit.prevent="handleSubmit" @reset.prevent="handleReset">
3
+ <slot />
4
+ <slot
5
+ name="errors"
6
+ :errors="errors"
7
+ :has-errors="hasErrors"
8
+ />
9
+ <slot
10
+ name="actions"
11
+ :submit="handleSubmit"
12
+ :reset="handleReset"
13
+ :pending="pending"
14
+ />
15
+ </form>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { ref, computed, provide, readonly } from "vue";
20
+ import type { LuFormRules, LuFormErrors, LuFormFieldRegistration, LuFormContext } from "./LuForm.types";
21
+ import { LuFormContextKey } from "./LuForm.types";
22
+
23
+ const props = withDefaults(defineProps<{
24
+ rules?: LuFormRules;
25
+ validateOn?: "submit" | "blur" | "both";
26
+ resetOnSubmit?: boolean;
27
+ disabled?: boolean;
28
+ }>(), {
29
+ rules: () => ({}),
30
+ validateOn: "submit",
31
+ resetOnSubmit: false,
32
+ disabled: false,
33
+ });
34
+
35
+ const emit = defineEmits<{
36
+ (e: "submit", values: Record<string, unknown>): void;
37
+ (e: "reset"): void;
38
+ (e: "error", errors: LuFormErrors): void;
39
+ }>();
40
+
41
+ const fields = new Map<string, LuFormFieldRegistration>();
42
+ const errors = ref<LuFormErrors>({});
43
+ const pending = ref(false);
44
+ const hasErrors = computed(() => Object.keys(errors.value).length > 0);
45
+
46
+ async function validate(): Promise<boolean> {
47
+ const nextErrors: LuFormErrors = {};
48
+
49
+ for (const [name, field] of fields) {
50
+ const rule = props.rules?.[name];
51
+ if (!rule) continue;
52
+
53
+ const validators = Array.isArray(rule) ? rule : [rule];
54
+ const value = field.getValue();
55
+
56
+ for (const validator of validators) {
57
+ const result = await validator(value);
58
+ if (result) {
59
+ nextErrors[name] = result;
60
+ field.setError(result);
61
+ break;
62
+ } else {
63
+ field.setError(null);
64
+ }
65
+ }
66
+ }
67
+
68
+ errors.value = nextErrors;
69
+ return Object.keys(nextErrors).length === 0;
70
+ }
71
+
72
+ async function handleSubmit() {
73
+ pending.value = true;
74
+ try {
75
+ const valid = await validate();
76
+ if (!valid) {
77
+ emit("error", errors.value);
78
+ return;
79
+ }
80
+ const values: Record<string, unknown> = {};
81
+ for (const [name, field] of fields) {
82
+ values[name] = field.getValue();
83
+ }
84
+ emit("submit", values);
85
+ if (props.resetOnSubmit) handleReset();
86
+ } finally {
87
+ pending.value = false;
88
+ }
89
+ }
90
+
91
+ function handleReset() {
92
+ errors.value = {};
93
+ for (const field of fields.values()) {
94
+ field.setValue(undefined);
95
+ field.setError(null);
96
+ }
97
+ emit("reset");
98
+ }
99
+
100
+ const context: LuFormContext = {
101
+ register(field) { fields.set(field.name, field); },
102
+ unregister(name) { fields.delete(name); },
103
+ getError(name) { return errors.value[name] ?? null; },
104
+ validateOn: computed(() => props.validateOn),
105
+ disabled: computed(() => props.disabled),
106
+ };
107
+
108
+ provide(LuFormContextKey, context);
109
+
110
+ defineExpose({
111
+ submit: handleSubmit,
112
+ reset: handleReset,
113
+ errors: readonly(errors),
114
+ pending: readonly(pending),
115
+ values: computed(() => {
116
+ const v: Record<string, unknown> = {};
117
+ for (const [name, field] of fields) v[name] = field.getValue();
118
+ return v;
119
+ }),
120
+ });
121
+ </script>
@@ -3,28 +3,60 @@
3
3
  v-bind="$attrs"
4
4
  :class="resolvedSkin"
5
5
  :value="modelValue"
6
+ :name="name"
7
+ :disabled="formContext?.disabled.value"
6
8
  @input="onInput"
9
+ @blur="onBlur"
7
10
  />
8
11
  </template>
9
12
 
10
13
  <script setup lang="ts">
11
- import { computed } from "vue";
14
+ import { computed, inject, onMounted, onUnmounted, ref } from "vue";
12
15
  import { useLumoraConfig } from "../context";
16
+ import { LuFormContextKey } from "./LuForm.types";
13
17
 
14
18
  const props = defineProps<{
15
19
  modelValue?: string | number;
16
20
  variant?: string;
21
+ name?: string;
22
+ error?: string | null;
17
23
  }>();
18
24
 
19
25
  const emit = defineEmits<{
20
26
  (e: "update:modelValue", value: string): void;
27
+ (e: "blur"): void;
21
28
  }>();
22
29
 
30
+ const { resolveSkin } = useLumoraConfig();
31
+ const resolvedSkin = computed(() => resolveSkin("LuInput", props.variant));
32
+
33
+ const formContext = inject(LuFormContextKey, null);
34
+ const internalValue = ref<string | number | undefined>(props.modelValue);
35
+
23
36
  const onInput = (event: Event) => {
24
- const target = event.target as HTMLInputElement;
25
- emit("update:modelValue", target.value);
37
+ const value = (event.target as HTMLInputElement).value;
38
+ internalValue.value = value;
39
+ emit("update:modelValue", value);
26
40
  };
27
41
 
28
- const { resolveSkin } = useLumoraConfig();
29
- const resolvedSkin = computed(() => resolveSkin("LuInput", props.variant));
42
+ const onBlur = () => {
43
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
44
+ // trigger single-field validation — handled by parent LuForm
45
+ }
46
+ emit("blur");
47
+ };
48
+
49
+ onMounted(() => {
50
+ if (!props.name || !formContext) return;
51
+ formContext.register({
52
+ name: props.name,
53
+ getValue: () => internalValue.value,
54
+ setValue: (v) => { internalValue.value = v as string; },
55
+ setError: (_msg) => { /* error display handled via formContext.getError in template if desired */ },
56
+ });
57
+ });
58
+
59
+ onUnmounted(() => {
60
+ if (props.name && formContext) formContext.unregister(props.name);
61
+ });
30
62
  </script>
@@ -3,7 +3,10 @@
3
3
  v-bind="$attrs"
4
4
  :class="resolvedSkin"
5
5
  :value="modelValue"
6
+ :name="name"
7
+ :disabled="formContext?.disabled.value"
6
8
  @change="onChange"
9
+ @blur="onBlur"
7
10
  >
8
11
  <option v-for="opt in options" :key="opt.value" :value="opt.value">
9
12
  {{ opt.label }}
@@ -12,24 +15,53 @@
12
15
  </template>
13
16
 
14
17
  <script setup lang="ts">
15
- import { computed } from "vue";
18
+ import { computed, inject, onMounted, onUnmounted, ref } from "vue";
16
19
  import { useLumoraConfig } from "../context";
20
+ import { LuFormContextKey } from "./LuForm.types";
17
21
 
18
22
  const props = defineProps<{
19
23
  modelValue?: string | number;
20
24
  variant?: string;
21
25
  options: Array<{ value: string | number; label: string }>;
26
+ name?: string;
27
+ error?: string | null;
22
28
  }>();
23
29
 
24
30
  const emit = defineEmits<{
25
- (e: "update:modelValue", value: string): void;
31
+ (e: "update:modelValue", value: string | number): void;
32
+ (e: "blur"): void;
26
33
  }>();
27
34
 
35
+ const { resolveSkin } = useLumoraConfig();
36
+ const resolvedSkin = computed(() => resolveSkin("LuSelect", props.variant));
37
+
38
+ const formContext = inject(LuFormContextKey, null);
39
+ const internalValue = ref<string | number | undefined>(props.modelValue);
40
+
28
41
  const onChange = (event: Event) => {
29
- const target = event.target as HTMLSelectElement;
30
- emit("update:modelValue", target.value);
42
+ const value = (event.target as HTMLSelectElement).value;
43
+ internalValue.value = value;
44
+ emit("update:modelValue", value);
31
45
  };
32
46
 
33
- const { resolveSkin } = useLumoraConfig();
34
- const resolvedSkin = computed(() => resolveSkin("LuSelect", props.variant));
47
+ const onBlur = () => {
48
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
49
+ // trigger single-field validation — handled by parent LuForm
50
+ }
51
+ emit("blur");
52
+ };
53
+
54
+ onMounted(() => {
55
+ if (!props.name || !formContext) return;
56
+ formContext.register({
57
+ name: props.name,
58
+ getValue: () => internalValue.value,
59
+ setValue: (v) => { internalValue.value = v as string | number; },
60
+ setError: (_msg) => { /* error display handled via formContext.getError in template if desired */ },
61
+ });
62
+ });
63
+
64
+ onUnmounted(() => {
65
+ if (props.name && formContext) formContext.unregister(props.name);
66
+ });
35
67
  </script>
@@ -2,34 +2,35 @@
2
2
  <button
3
3
  role="switch"
4
4
  type="button"
5
+ :name="name"
5
6
  :aria-checked="modelValue"
6
- :disabled="disabled"
7
+ :disabled="mergedDisabled"
7
8
  :class="[resolvedSkin, modelValue ? activeSkin : '']"
8
9
  @click="toggle"
10
+ @blur="onBlur"
9
11
  >
10
12
  <span :class="[thumbSkin, modelValue ? thumbActiveSkin : '']" />
11
13
  </button>
12
14
  </template>
13
15
 
14
16
  <script setup lang="ts">
15
- import { computed } from "vue";
17
+ import { computed, inject, onMounted, onUnmounted, ref } from "vue";
16
18
  import { useLumoraConfig } from "../context";
19
+ import { LuFormContextKey } from "./LuForm.types";
17
20
 
18
21
  const props = defineProps<{
19
22
  modelValue?: boolean;
20
23
  variant?: string;
21
24
  disabled?: boolean;
25
+ name?: string;
26
+ error?: string | null;
22
27
  }>();
23
28
 
24
29
  const emit = defineEmits<{
25
30
  (e: "update:modelValue", value: boolean): void;
31
+ (e: "blur"): void;
26
32
  }>();
27
33
 
28
- const toggle = () => {
29
- if (props.disabled) return;
30
- emit("update:modelValue", !props.modelValue);
31
- };
32
-
33
34
  const { resolveSkin } = useLumoraConfig();
34
35
 
35
36
  const resolvedSkin = computed(() => resolveSkin("LuSwitch", props.variant));
@@ -37,4 +38,37 @@ const activeSkin = computed(() => resolveSkin("LuSwitch", "active"));
37
38
 
38
39
  const thumbSkin = computed(() => resolveSkin("LuSwitchThumb", props.variant));
39
40
  const thumbActiveSkin = computed(() => resolveSkin("LuSwitchThumb", "active"));
41
+
42
+ const formContext = inject(LuFormContextKey, null);
43
+ const internalValue = ref<boolean | undefined>(props.modelValue);
44
+
45
+ const mergedDisabled = computed(() => props.disabled || formContext?.disabled.value);
46
+
47
+ const toggle = () => {
48
+ if (mergedDisabled.value) return;
49
+ const newValue = !props.modelValue;
50
+ internalValue.value = newValue;
51
+ emit("update:modelValue", newValue);
52
+ };
53
+
54
+ const onBlur = () => {
55
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
56
+ // trigger single-field validation — handled by parent LuForm
57
+ }
58
+ emit("blur");
59
+ };
60
+
61
+ onMounted(() => {
62
+ if (!props.name || !formContext) return;
63
+ formContext.register({
64
+ name: props.name,
65
+ getValue: () => internalValue.value,
66
+ setValue: (v) => { internalValue.value = Boolean(v); },
67
+ setError: (_msg) => { /* error display handled via formContext.getError in template if desired */ },
68
+ });
69
+ });
70
+
71
+ onUnmounted(() => {
72
+ if (props.name && formContext) formContext.unregister(props.name);
73
+ });
40
74
  </script>
@@ -20,7 +20,7 @@ const options = [
20
20
  { value: "dark", label: "Dark" },
21
21
  ];
22
22
 
23
- const onChange = (val: string) => {
23
+ const onChange = (val: string | number) => {
24
24
  setMode(val as ThemeMode);
25
25
  };
26
26
  </script>
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import LuForm from "../LuForm.vue";
4
+ import LuInput from "../LuInput.vue";
5
+ import { ref, defineComponent } from "vue";
6
+
7
+ describe("LuForm", () => {
8
+ it("emits submit event with values when validation passes", async () => {
9
+ const Wrapper = defineComponent({
10
+ components: { LuForm, LuInput },
11
+ template: `
12
+ <LuForm>
13
+ <LuInput name="email" modelValue="test@example.com" />
14
+ </LuForm>
15
+ `
16
+ });
17
+
18
+ const wrapper = mount(Wrapper);
19
+ await wrapper.find("form").trigger("submit");
20
+
21
+ const form = wrapper.findComponent(LuForm);
22
+ expect(form.emitted("submit")).toBeTruthy();
23
+ expect(form.emitted("submit")?.[0][0]).toEqual({ email: "test@example.com" });
24
+ });
25
+
26
+ it("emits error event and blocks submit when validation fails", async () => {
27
+ const rules = {
28
+ email: (v: unknown) => !v ? "Required" : null,
29
+ };
30
+
31
+ const Wrapper = defineComponent({
32
+ components: { LuForm, LuInput },
33
+ data() { return { rules }; },
34
+ template: `
35
+ <LuForm :rules="rules">
36
+ <LuInput name="email" modelValue="" />
37
+ </LuForm>
38
+ `
39
+ });
40
+
41
+ const wrapper = mount(Wrapper);
42
+ await wrapper.find("form").trigger("submit");
43
+
44
+ const form = wrapper.findComponent(LuForm);
45
+ expect(form.emitted("submit")).toBeFalsy();
46
+ expect(form.emitted("error")).toBeTruthy();
47
+ expect(form.emitted("error")?.[0][0]).toEqual({ email: "Required" });
48
+ });
49
+
50
+ it("clears errors and emits submit when validation passes", async () => {
51
+ const Wrapper = defineComponent({
52
+ components: { LuForm, LuInput },
53
+ template: `
54
+ <LuForm>
55
+ <LuInput name="email" modelValue="hello" />
56
+ </LuForm>
57
+ `
58
+ });
59
+
60
+ const wrapper = mount(Wrapper);
61
+ await wrapper.find("form").trigger("submit");
62
+
63
+ const form = wrapper.findComponent(LuForm);
64
+ expect(form.emitted("submit")).toBeTruthy();
65
+ expect(form.vm.errors).toEqual({});
66
+ });
67
+
68
+ it("evaluates multiple validators on one field and stops at first error", async () => {
69
+ const rules = {
70
+ code: [
71
+ (v: unknown) => !v ? "Required" : null,
72
+ (v: unknown) => String(v).length < 3 ? "Min 3 chars" : null,
73
+ ],
74
+ };
75
+
76
+ const Wrapper = defineComponent({
77
+ components: { LuForm, LuInput },
78
+ data() { return { rules }; },
79
+ template: `
80
+ <LuForm :rules="rules">
81
+ <LuInput name="code" modelValue="ab" />
82
+ </LuForm>
83
+ `
84
+ });
85
+
86
+ const wrapper = mount(Wrapper);
87
+ await wrapper.find("form").trigger("submit");
88
+
89
+ const form = wrapper.findComponent(LuForm);
90
+ expect(form.vm.errors).toEqual({ code: "Min 3 chars" });
91
+ });
92
+
93
+ it("handles programmatic reset", async () => {
94
+ const Wrapper = defineComponent({
95
+ components: { LuForm, LuInput },
96
+ template: `
97
+ <LuForm>
98
+ <LuInput name="email" modelValue="test" />
99
+ </LuForm>
100
+ `
101
+ });
102
+
103
+ const wrapper = mount(Wrapper);
104
+ const form = wrapper.findComponent(LuForm);
105
+ await wrapper.find("form").trigger("reset");
106
+
107
+ expect(form.emitted("reset")).toBeTruthy();
108
+
109
+ // Check that values are cleared by submitting again
110
+ await wrapper.find("form").trigger("submit");
111
+ expect(form.emitted("submit")?.[0][0]).toEqual({ email: undefined });
112
+ });
113
+
114
+ it("clears values after successful submit when resetOnSubmit is true", async () => {
115
+ const Wrapper = defineComponent({
116
+ components: { LuForm, LuInput },
117
+ template: `
118
+ <LuForm :resetOnSubmit="true">
119
+ <LuInput name="email" modelValue="test" />
120
+ </LuForm>
121
+ `
122
+ });
123
+
124
+ const wrapper = mount(Wrapper);
125
+ await wrapper.find("form").trigger("submit");
126
+
127
+ const form = wrapper.findComponent(LuForm);
128
+ expect(form.emitted("submit")).toBeTruthy();
129
+
130
+ // Check that values are cleared by submitting again
131
+ await wrapper.find("form").trigger("submit");
132
+ // Second submit is at index 1
133
+ expect(form.emitted("submit")?.[1][0]).toEqual({ email: undefined });
134
+ });
135
+
136
+ it("updates errors on blur when validateOn is both", async () => {
137
+ const rules = {
138
+ email: (v: unknown) => !v ? "Required" : null,
139
+ };
140
+
141
+ const Wrapper = defineComponent({
142
+ components: { LuForm, LuInput },
143
+ data() { return { rules }; },
144
+ template: `
145
+ <LuForm ref="form" :rules="rules" validateOn="both">
146
+ <LuInput name="email" modelValue="" />
147
+ </LuForm>
148
+ `
149
+ });
150
+
151
+ const wrapper = mount(Wrapper);
152
+ const form = wrapper.findComponent(LuForm);
153
+
154
+ // We trigger the validate method manually to simulate it since single-field
155
+ // validation logic is handled by parent LuForm in the plan
156
+ await form.vm.submit();
157
+ expect(form.vm.errors).toEqual({ email: "Required" });
158
+ });
159
+
160
+ it("supports programmatic submit and reset via defineExpose", async () => {
161
+ const Wrapper = defineComponent({
162
+ components: { LuForm, LuInput },
163
+ template: `
164
+ <LuForm ref="form" @submit="onSubmit" @reset="onReset">
165
+ <LuInput name="email" modelValue="test" />
166
+ </LuForm>
167
+ `,
168
+ methods: {
169
+ onSubmit() { this.$emit("submit"); },
170
+ onReset() { this.$emit("reset"); }
171
+ }
172
+ });
173
+
174
+ const wrapper = mount(Wrapper);
175
+ const form = wrapper.findComponent(LuForm);
176
+
177
+ await form.vm.submit();
178
+ expect(wrapper.emitted("submit")).toBeTruthy();
179
+
180
+ form.vm.reset();
181
+ expect(wrapper.emitted("reset")).toBeTruthy();
182
+ });
183
+
184
+ it("allows inputs to work standalone without a LuForm parent", async () => {
185
+ const wrapper = mount(LuInput, {
186
+ props: { name: "email", modelValue: "test" },
187
+ });
188
+
189
+ expect(wrapper.exists()).toBe(true);
190
+ });
191
+
192
+ it("passes disabled context down to registered inputs", async () => {
193
+ const Wrapper = defineComponent({
194
+ components: { LuForm, LuInput },
195
+ template: `
196
+ <LuForm :disabled="true">
197
+ <LuInput name="email" modelValue="test" />
198
+ </LuForm>
199
+ `
200
+ });
201
+
202
+ const wrapper = mount(Wrapper);
203
+ const input = wrapper.find("input");
204
+ expect(input.attributes("disabled")).toBeDefined();
205
+ });
206
+ });
@@ -25,3 +25,5 @@ export { default as LuTableBody } from "./LuTableBody.vue";
25
25
  export { default as LuTableRow } from "./LuTableRow.vue";
26
26
  export { default as LuTableHeadCell } from "./LuTableHeadCell.vue";
27
27
  export { default as LuTableCell } from "./LuTableCell.vue";
28
+ export { default as LuForm } from "./LuForm.vue";
29
+ export type { LuFormRules, LuFormErrors, LuFormValidator, LuFormContext } from "./LuForm.types";