@astrake/lumora-ui 0.1.5 → 0.2.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/package.json +9 -1
  3. package/src/components/LuAlert.vue +33 -0
  4. package/src/components/LuBreadcrumb.vue +63 -0
  5. package/src/components/LuCard.vue +8 -1
  6. package/src/components/LuCheckbox.vue +94 -0
  7. package/src/components/LuCodeBlock.vue +168 -0
  8. package/src/components/LuForm.types.ts +24 -0
  9. package/src/components/LuForm.vue +121 -0
  10. package/src/components/LuInput.vue +57 -5
  11. package/src/components/LuMenu.vue +86 -0
  12. package/src/components/LuMenuItem.vue +37 -0
  13. package/src/components/LuModal.vue +115 -0
  14. package/src/components/LuPagination.vue +118 -0
  15. package/src/components/LuRadio.vue +55 -0
  16. package/src/components/LuRadioGroup.types.ts +10 -0
  17. package/src/components/LuRadioGroup.vue +66 -0
  18. package/src/components/LuSelect.vue +38 -6
  19. package/src/components/LuSkeleton.vue +15 -0
  20. package/src/components/LuSpinner.vue +36 -0
  21. package/src/components/LuSwitch.vue +48 -12
  22. package/src/components/LuTag.vue +35 -0
  23. package/src/components/LuTextarea.vue +62 -0
  24. package/src/components/LuThemeSelect.vue +1 -1
  25. package/src/components/LuToggleButton.vue +35 -0
  26. package/src/components/LuToggleGroup.vue +27 -0
  27. package/src/components/__tests__/LuForm.test.ts +206 -0
  28. package/src/components/index.ts +18 -0
  29. package/src/context.ts +8 -5
  30. package/src/index.ts +2 -2
  31. package/src/layout/LuDock.vue +53 -20
  32. package/src/layout/LuDockItem.vue +3 -1
  33. package/src/layout/LuFill.vue +15 -5
  34. package/src/layout/LuFixed.vue +15 -5
  35. package/src/layout/LuGrid.vue +28 -5
  36. package/src/layout/LuScroll.vue +3 -1
  37. package/src/layout/LuSplitPane.vue +3 -3
  38. package/src/layout/LuSplitResizer.vue +5 -3
  39. package/src/layout/LuStack.vue +16 -11
  40. package/src/lumora.css +16 -0
  41. package/src/plugin.ts +3 -2
  42. package/src/shell/desktop/LuDesktopRailItem.vue +2 -2
  43. package/src/shell/desktop/LuDesktopShell.vue +3 -3
  44. package/src/shell/embedded/LuEmbeddedShell.vue +2 -2
  45. package/src/shell/embedded/LuEmbeddedStatusBar.vue +16 -0
  46. package/src/shell/embedded/LuEmbeddedTopBar.vue +17 -0
  47. package/src/shell/index.ts +4 -1
  48. package/src/shell/mobile/LuMobileHeader.vue +17 -0
  49. package/src/shell/mobile/LuMobileNavBar.vue +15 -0
  50. package/src/shell/mobile/LuMobileShell.vue +2 -2
  51. package/src/skins/default.ts +361 -29
  52. package/src/tailwind.ts +25 -0
  53. package/src/utils.ts +95 -0
package/CHANGELOG.md CHANGED
@@ -4,13 +4,77 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.2.0] — 2026-04-27
8
+
9
+ ### Maintenance
10
+
11
+ - bump version to 0.2.0 (`d52e5c1`)
12
+
13
+
14
+ ---
15
+
16
+ ## [0.2.0] — 2026-04-27
17
+
18
+ ### Added
19
+ - `LuCodeBlock` — Native Shiki-powered syntax highlighter with `default` and `preview` variants (supporting `tabbed` and `split` layouts).
20
+ - `LuToggleGroup` & `LuToggleButton` — New layout primitives for grouped button selections.
21
+ - `LuMenu` — Refactored and renamed from legacy `LuDropdown` to align with the core primitives naming convention.
22
+ - New showcase documentation dedicated to Toggle Group and components.
23
+
24
+ ### Fixed
25
+ - WPF-like edge-docking regressions in `LuDock` via a newly implemented programmatic render function.
26
+ - Reactivity bugs in `LuSplit` (`LuSplitPane`, `LuSplitResizer`) failing to unwrap injected `direction` refs.
27
+ - Missing `LuSplit` component skins.
28
+ - Various visual hierarchy issues in documentation via typography `LuText` adjustments.
29
+
30
+ ### Changed
31
+ - **Zero-Raw-HTML Adherence**: Removed all raw DOM nodes (`div`, `span`, etc.) and `PreviewBlock` from the showcase application in favor of 100% native `Lu*` primitives.
32
+ - Integrated Shiki to `@astrake/lumora-ui` core package dependencies.
33
+
34
+ ---
35
+
36
+ ## [0.1.7] — 2026-04-26
37
+
38
+ ### Breaking Changes (structurally additive — no API surface removed)
39
+ - Framework now ships `lumora.css` — consumers must add `import '@astrake/lumora-ui/style'` to their app entry
40
+
41
+ ### Fixed
42
+ - Shell components (`LuDesktopShell`, `LuMobileShell`, `LuEmbeddedShell`) no longer require Tailwind CSS to be configured for `node_modules` — structural layout is now framework-owned CSS
43
+ - Layout primitives (`LuDock`, `LuStack`, `LuFill`, `LuScroll`, `LuGrid`, etc.) structural classes are now framework-owned, not Tailwind fallbacks
44
+ - `LuDesktopRailItem` icon and label structural defaults are now framework-owned
45
+ - Plugin config is now `shallowReactive` — dynamic skin switching and `config.skin = newSkin` mutations now correctly trigger reactive re-renders in all components
46
+
47
+ ### Added
48
+ - `lumora.css` — zero-dependency CSS baseline for all structural/layout defaults; exported at `@astrake/lumora-ui/style`
49
+ - `tailwindContent()` helper exported at `@astrake/lumora-ui/tailwind` — resolves correct `node_modules` source glob for Tailwind `content` config
50
+
51
+ ---
52
+
53
+ ## [0.1.6] — 2026-04-25
54
+
55
+ ### Maintenance
56
+
57
+ - bump version to 0.1.6 - add LuForm validation orchestrator (`1f11734`)
58
+
59
+ ---
60
+
61
+ ## [0.1.6] — 2026-04-26
62
+
63
+ ### Added
64
+ - `LuForm` — headless validation orchestrator component with slot-based API
65
+ - `LuForm.types.ts` — `LuFormRules`, `LuFormErrors`, `LuFormValidator`, `LuFormContext` types
66
+ - Form context integration for `LuInput`, `LuSelect`, `LuSwitch` — `name`, `error` props; register/unregister lifecycle
67
+ - `LuFormContextKey` injection key (internal Symbol) for child-field coordination
68
+ - 10 vitest test cases covering submit, validation, reset, blur, disabled, and programmatic API
69
+
70
+ ---
71
+
7
72
  ## [0.1.5] — 2026-04-25
8
73
 
9
74
  ### Fixed
10
75
 
11
76
  - ci workflow errors — npm publish auth and correct artifact path (`af81e69`)
12
77
 
13
-
14
78
  ---
15
79
 
16
80
  ## [0.1.4] — 2026-04-25
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@astrake/lumora-ui",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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",
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
+ "./style": "./src/lumora.css",
11
+ "./tailwind": "./src/tailwind.ts",
10
12
  "./layout": "./src/layout/index.ts",
11
13
  "./shell": "./src/shell/index.ts",
12
14
  "./components": "./src/components/index.ts",
@@ -56,6 +58,12 @@
56
58
  "check": "vue-tsc -p ./tsconfig.json"
57
59
  },
58
60
  "peerDependencies": {
61
+ "tailwindcss": "^4.0.0",
59
62
  "vue": "^3.5.0"
63
+ },
64
+ "dependencies": {
65
+ "clsx": "^2.1.1",
66
+ "shiki": "^4.0.2",
67
+ "tailwind-merge": "^3.5.0"
60
68
  }
61
69
  }
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <div :class="resolvedSkin" role="alert">
3
+ <div v-if="$slots.icon || icon" :class="resolvedIconWrapperSkin">
4
+ <slot name="icon">
5
+ <LuIcon v-if="icon" :name="icon" />
6
+ </slot>
7
+ </div>
8
+ <div :class="resolvedContentSkin">
9
+ <slot />
10
+ </div>
11
+ <div v-if="$slots.action" :class="resolvedActionSkin">
12
+ <slot name="action" />
13
+ </div>
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { computed } from "vue";
19
+ import { useLumoraConfig } from "../context";
20
+ import LuIcon from "./LuIcon.vue";
21
+
22
+ const props = defineProps<{
23
+ variant?: string;
24
+ icon?: string;
25
+ }>();
26
+
27
+ const { resolveSkin } = useLumoraConfig();
28
+
29
+ const resolvedSkin = computed(() => resolveSkin("LuAlert", props.variant));
30
+ const resolvedIconWrapperSkin = computed(() => resolveSkin("LuAlertIcon", props.variant));
31
+ const resolvedContentSkin = computed(() => resolveSkin("LuAlertContent", props.variant));
32
+ const resolvedActionSkin = computed(() => resolveSkin("LuAlertAction", props.variant));
33
+ </script>
@@ -0,0 +1,63 @@
1
+ <template>
2
+ <nav aria-label="breadcrumb">
3
+ <ol :class="resolvedSkin">
4
+ <li v-for="(item, index) in items" :key="index" :class="resolvedItemSkin">
5
+ <LuLink
6
+ v-if="item.href || item.to"
7
+ :href="item.href"
8
+ :to="item.to"
9
+ :variant="item.current ? 'nav-active' : 'nav'"
10
+ :aria-current="item.current ? 'page' : undefined"
11
+ :class="resolvedLinkSkin"
12
+ >
13
+ <LuIcon v-if="item.icon" :name="item.icon" class="mr-2 h-4 w-4" />
14
+ {{ item.label }}
15
+ </LuLink>
16
+ <span
17
+ v-else
18
+ :class="resolvedPageSkin"
19
+ :aria-current="item.current ? 'page' : undefined"
20
+ >
21
+ <LuIcon v-if="item.icon" :name="item.icon" class="mr-2 h-4 w-4" />
22
+ {{ item.label }}
23
+ </span>
24
+ <LuIcon
25
+ v-if="index < items.length - 1"
26
+ :name="separatorIcon"
27
+ :class="resolvedSeparatorSkin"
28
+ />
29
+ </li>
30
+ </ol>
31
+ </nav>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ import { computed } from "vue";
36
+ import { useLumoraConfig } from "../context";
37
+ import LuLink from "./LuLink.vue";
38
+ import LuIcon from "./LuIcon.vue";
39
+
40
+ export interface LuBreadcrumbItem {
41
+ label: string;
42
+ href?: string;
43
+ to?: any;
44
+ current?: boolean;
45
+ icon?: string;
46
+ }
47
+
48
+ const props = withDefaults(defineProps<{
49
+ items: LuBreadcrumbItem[];
50
+ variant?: string;
51
+ separatorIcon?: string;
52
+ }>(), {
53
+ separatorIcon: 'chevron-right'
54
+ });
55
+
56
+ const { resolveSkin } = useLumoraConfig();
57
+
58
+ const resolvedSkin = computed(() => resolveSkin("LuBreadcrumb", props.variant));
59
+ const resolvedItemSkin = computed(() => resolveSkin("LuBreadcrumbItem", props.variant));
60
+ const resolvedLinkSkin = computed(() => resolveSkin("LuBreadcrumbLink", props.variant));
61
+ const resolvedPageSkin = computed(() => resolveSkin("LuBreadcrumbPage", props.variant));
62
+ const resolvedSeparatorSkin = computed(() => resolveSkin("LuBreadcrumbSeparator", props.variant));
63
+ </script>
@@ -7,14 +7,21 @@
7
7
  <script setup lang="ts">
8
8
  import { computed } from "vue";
9
9
  import { useLumoraConfig } from "../context";
10
+ import { resolveLayoutProps, cn } from "../utils";
10
11
 
11
12
  const props = withDefaults(defineProps<{
12
13
  variant?: string;
13
14
  as?: string;
15
+ padding?: string | number;
16
+ width?: string;
17
+ height?: string;
14
18
  }>(), {
15
19
  as: "div"
16
20
  });
17
21
 
18
22
  const { resolveSkin } = useLumoraConfig();
19
- const resolvedSkin = computed(() => resolveSkin("LuCard", props.variant));
23
+ const resolvedSkin = computed(() => cn(
24
+ resolveSkin("LuCard", props.variant),
25
+ resolveLayoutProps(props)
26
+ ));
20
27
  </script>
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <div :class="resolvedContainerSkin">
3
+ <input
4
+ type="checkbox"
5
+ v-bind="$attrs"
6
+ :class="resolvedSkin"
7
+ :checked="modelValue"
8
+ :name="name"
9
+ :disabled="formContext?.disabled.value"
10
+ @change="onChange"
11
+ @blur="onBlur"
12
+ />
13
+ <label v-if="$slots.default || label" :class="resolvedLabelSkin" @click.prevent="toggle">
14
+ <slot>{{ label }}</slot>
15
+ </label>
16
+ </div>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { computed, inject, onMounted, onUnmounted, ref, watch } from "vue";
21
+ import { useLumoraConfig } from "../context";
22
+ import { LuFormContextKey } from "./LuForm.types";
23
+
24
+ const props = defineProps<{
25
+ modelValue?: boolean;
26
+ variant?: string;
27
+ name?: string;
28
+ label?: string;
29
+ error?: string | null;
30
+ }>();
31
+
32
+ const emit = defineEmits<{
33
+ (e: "update:modelValue", value: boolean): void;
34
+ (e: "change", value: boolean): void;
35
+ (e: "blur"): void;
36
+ }>();
37
+
38
+ const { resolveSkin } = useLumoraConfig();
39
+ const resolvedContainerSkin = computed(() => resolveSkin("LuCheckboxContainer", props.variant));
40
+ const resolvedSkin = computed(() => resolveSkin("LuCheckbox", props.variant));
41
+ const resolvedLabelSkin = computed(() => resolveSkin("LuCheckboxLabel", props.variant));
42
+
43
+ const formContext = inject(LuFormContextKey, null);
44
+ const internalValue = ref<boolean>(!!props.modelValue);
45
+
46
+ watch(() => props.modelValue, (newVal) => {
47
+ internalValue.value = !!newVal;
48
+ });
49
+
50
+ const onChange = (event: Event) => {
51
+ const target = event.target as HTMLInputElement;
52
+ const value = target.checked;
53
+ internalValue.value = value;
54
+ emit("update:modelValue", value);
55
+ emit("change", value);
56
+
57
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
58
+ // trigger validation handled by parent
59
+ }
60
+ };
61
+
62
+ const toggle = () => {
63
+ if (formContext?.disabled.value || ("disabled" in props && (props as any).disabled !== false)) return;
64
+ const newValue = !internalValue.value;
65
+ internalValue.value = newValue;
66
+ emit("update:modelValue", newValue);
67
+ emit("change", newValue);
68
+
69
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
70
+ // trigger validation handled by parent
71
+ }
72
+ };
73
+
74
+ const onBlur = () => {
75
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
76
+ // trigger validation handled by parent
77
+ }
78
+ emit("blur");
79
+ };
80
+
81
+ onMounted(() => {
82
+ if (!props.name || !formContext) return;
83
+ formContext.register({
84
+ name: props.name,
85
+ getValue: () => internalValue.value,
86
+ setValue: (v) => { internalValue.value = !!v; },
87
+ setError: (_msg) => {},
88
+ });
89
+ });
90
+
91
+ onUnmounted(() => {
92
+ if (props.name && formContext) formContext.unregister(props.name);
93
+ });
94
+ </script>
@@ -0,0 +1,168 @@
1
+ <template>
2
+ <div v-bind="$attrs" :class="resolvedSkin.container">
3
+ <div v-if="title || description" :class="resolvedSkin.header">
4
+ <h3 v-if="title" :class="resolvedSkin.title">{{ title }}</h3>
5
+ <p v-if="description" :class="resolvedSkin.description">{{ description }}</p>
6
+ </div>
7
+
8
+ <template v-if="variant === 'preview'">
9
+ <div :class="resolvedSkin.card">
10
+ <template v-if="layout === 'tabbed'">
11
+ <LuTabs v-model="activeTab" variant="default">
12
+ <LuTabList variant="card-header">
13
+ <LuTab value="preview">Preview</LuTab>
14
+ <LuTab value="code">Code</LuTab>
15
+ </LuTabList>
16
+ </LuTabs>
17
+
18
+ <div v-if="activeTab === 'preview'" :class="resolvedSkin.previewArea">
19
+ <slot name="preview" />
20
+ </div>
21
+
22
+ <div v-if="activeTab === 'code'" :class="resolvedSkin.codeArea">
23
+ <div :class="resolvedSkin.codeHeader">
24
+ <div :class="resolvedSkin.badge">{{ lang }}</div>
25
+ <button @click="copyCode" :class="resolvedSkin.copyButton" aria-label="Copy code">
26
+ <LuIcon :name="copied ? 'check' : 'copy'" class="w-4 h-4" />
27
+ </button>
28
+ </div>
29
+ <div :class="resolvedSkin.codeContent" v-html="html"></div>
30
+ </div>
31
+ </template>
32
+
33
+ <template v-else-if="layout === 'split'">
34
+ <div :class="resolvedSkin.splitContainer">
35
+ <div :class="resolvedSkin.previewArea">
36
+ <slot name="preview" />
37
+ </div>
38
+ <div :class="resolvedSkin.splitCodeArea">
39
+ <div :class="resolvedSkin.codeHeader">
40
+ <div :class="resolvedSkin.badge">{{ lang }}</div>
41
+ <button @click="copyCode" :class="resolvedSkin.copyButton" aria-label="Copy code">
42
+ <LuIcon :name="copied ? 'check' : 'copy'" class="w-4 h-4" />
43
+ </button>
44
+ </div>
45
+ <div :class="resolvedSkin.codeContent" v-html="html"></div>
46
+ </div>
47
+ </div>
48
+ </template>
49
+ </div>
50
+ </template>
51
+
52
+ <template v-else>
53
+ <div :class="resolvedSkin.card">
54
+ <div :class="resolvedSkin.codeArea">
55
+ <div :class="resolvedSkin.codeHeader">
56
+ <div :class="resolvedSkin.badge">{{ lang }}</div>
57
+ <button @click="copyCode" :class="resolvedSkin.copyButton" aria-label="Copy code">
58
+ <LuIcon :name="copied ? 'check' : 'copy'" class="w-4 h-4" />
59
+ </button>
60
+ </div>
61
+ <div :class="resolvedSkin.codeContent" v-html="html"></div>
62
+ </div>
63
+ </div>
64
+ </template>
65
+ </div>
66
+ </template>
67
+
68
+ <script lang="ts">
69
+ import { createHighlighter, type Highlighter } from 'shiki';
70
+
71
+ let globalHighlighter: Highlighter | null = null;
72
+ let highlighterPromise: Promise<Highlighter> | null = null;
73
+
74
+ async function getHighlighter() {
75
+ if (globalHighlighter) return globalHighlighter;
76
+ if (!highlighterPromise) {
77
+ highlighterPromise = createHighlighter({
78
+ themes: ['one-dark-pro'],
79
+ langs: ['bash', 'vue', 'ts', 'html', 'css', 'json']
80
+ });
81
+ }
82
+ globalHighlighter = await highlighterPromise;
83
+ return globalHighlighter;
84
+ }
85
+ </script>
86
+
87
+ <script setup lang="ts">
88
+ import { ref, watch, onMounted, computed } from "vue";
89
+ import { useLumoraConfig } from "../context";
90
+ import LuTabs from "./LuTabs.vue";
91
+ import LuTabList from "./LuTabList.vue";
92
+ import LuTab from "./LuTab.vue";
93
+ import LuIcon from "./LuIcon.vue";
94
+
95
+ const props = withDefaults(defineProps<{
96
+ code: string;
97
+ lang?: string;
98
+ variant?: "default" | "preview";
99
+ layout?: "tabbed" | "split";
100
+ title?: string;
101
+ description?: string;
102
+ }>(), {
103
+ lang: "vue",
104
+ variant: "default",
105
+ layout: "tabbed"
106
+ });
107
+
108
+ const { resolveSkin } = useLumoraConfig();
109
+ const resolvedSkin = computed(() => {
110
+ return {
111
+ container: resolveSkin("LuCodeBlock", "container"),
112
+ header: resolveSkin("LuCodeBlock", "header"),
113
+ title: resolveSkin("LuCodeBlock", "title"),
114
+ description: resolveSkin("LuCodeBlock", "description"),
115
+ card: resolveSkin("LuCodeBlock", "card"),
116
+ previewArea: resolveSkin("LuCodeBlock", "previewArea"),
117
+ codeArea: resolveSkin("LuCodeBlock", "codeArea"),
118
+ splitCodeArea: resolveSkin("LuCodeBlock", "splitCodeArea"),
119
+ splitContainer: resolveSkin("LuCodeBlock", "splitContainer"),
120
+ codeHeader: resolveSkin("LuCodeBlock", "codeHeader"),
121
+ badge: resolveSkin("LuCodeBlock", "badge"),
122
+ copyButton: resolveSkin("LuCodeBlock", "copyButton"),
123
+ codeContent: resolveSkin("LuCodeBlock", "codeContent"),
124
+ };
125
+ });
126
+
127
+ const html = ref('');
128
+ const activeTab = ref<'preview' | 'code'>('preview');
129
+ const copied = ref(false);
130
+
131
+ const highlight = async () => {
132
+ if (!props.code) {
133
+ html.value = '';
134
+ return;
135
+ }
136
+
137
+ try {
138
+ const highlighter = await getHighlighter();
139
+
140
+ html.value = highlighter.codeToHtml(props.code, {
141
+ lang: props.lang || 'vue',
142
+ theme: 'one-dark-pro'
143
+ });
144
+ } catch (e) {
145
+ console.error('Failed to highlight code', e);
146
+ // fallback to plain pre
147
+ html.value = `<pre><code>${props.code.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>`;
148
+ }
149
+ };
150
+
151
+ onMounted(() => {
152
+ highlight();
153
+ });
154
+
155
+ watch(() => [props.code, props.lang], () => {
156
+ highlight();
157
+ });
158
+
159
+ const copyCode = async () => {
160
+ try {
161
+ await navigator.clipboard.writeText(props.code);
162
+ copied.value = true;
163
+ setTimeout(() => copied.value = false, 2000);
164
+ } catch (err) {
165
+ console.error('Failed to copy', err);
166
+ }
167
+ };
168
+ </script>
@@ -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>