@astrake/lumora-ui 0.1.1 → 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 +92 -0
- package/README.md +177 -0
- package/package.json +4 -3
- package/src/components/LuForm.types.ts +24 -0
- package/src/components/LuForm.vue +121 -0
- package/src/components/LuInput.vue +37 -5
- package/src/components/LuSelect.vue +38 -6
- package/src/components/LuSwitch.vue +41 -7
- package/src/components/LuThemeSelect.vue +1 -1
- package/src/components/__tests__/LuForm.test.ts +206 -0
- package/src/components/index.ts +2 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
---
|
|
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
|
+
|
|
27
|
+
## [0.1.5] — 2026-04-25
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- ci workflow errors — npm publish auth and correct artifact path (`af81e69`)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## [0.1.4] — 2026-04-25
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- prepack hook to include README and CHANGELOG in npm tarball (`e69bdfb`)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## [0.1.3] — 2026-04-25
|
|
45
|
+
|
|
46
|
+
### Maintenance
|
|
47
|
+
|
|
48
|
+
- fix CI pipeline failures (`cde8248`)
|
|
49
|
+
- verify automated release pipeline (`c153464`)
|
|
50
|
+
|
|
51
|
+
All notable changes to `@astrake/lumora-ui` are documented here.
|
|
52
|
+
|
|
53
|
+
Format: [Keep a Changelog](https://keepachangelog.com/) · Commits: [Conventional Commits](https://www.conventionalcommits.org/)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## [0.1.0] — 2026-04-25
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- Initial workspace scaffold (`packages/core`, `apps/showcase`, `tools/`)
|
|
63
|
+
- Headless Vue 3 primitive component library targeting three surface types:
|
|
64
|
+
- **Mobile (`LuM*`):** touch-first components for PWA apps (≥44px targets, bottom nav, swipe)
|
|
65
|
+
- **Desktop (`LuD*`):** feature-rich browser/Electron components (sidebars, tables, modals)
|
|
66
|
+
- **Embedded (`LuE*`):** fixed-viewport components for kiosk/IoT/in-car screens
|
|
67
|
+
- **Shared (`Lu*`):** cross-surface primitives (`LuIcon`, `LuSpinner`, `LuBadge`, `LuPortal`)
|
|
68
|
+
- Design token system (`--lu-*` CSS custom properties) — color, typography, spacing, radius, shadow, motion
|
|
69
|
+
- Three package entry points: `@astrake/lumora-ui`, `/mobile`, `/desktop`, `/embedded`
|
|
70
|
+
- Skin configuration system via `LumoraUI` Vue plugin
|
|
71
|
+
- Shell components (`LuMobileShell`, `LuDesktopShell`, `LuEmbeddedShell`) with named-slot architecture
|
|
72
|
+
- Layout primitives and composable layer
|
|
73
|
+
- Reference showcase app (Vite + Vue 3) with `/mobile`, `/desktop`, `/embedded` routes
|
|
74
|
+
- `tools/build.ts` — Vite library-mode build (three entry points)
|
|
75
|
+
- `tools/check.ts` — vue-tsc typecheck
|
|
76
|
+
- `tools/version.ts` — VERSION → package.json sync
|
|
77
|
+
- `tools/changelog.ts` — automated changelog generator (zero npm deps)
|
|
78
|
+
- `LICENSE` — MIT license
|
|
79
|
+
- `SECURITY.md` — responsible disclosure policy
|
|
80
|
+
- `README.md` — badges, install, usage, design token overview, automation table, disclaimer
|
|
81
|
+
- `docs/LEGAL.md` — full warranty disclaimer and legal notice
|
|
82
|
+
- `docs/PROJECT.md` — project overview
|
|
83
|
+
- `docs/ARCHITECTURE.md` — architecture guide
|
|
84
|
+
- `docs/DEVELOPMENT.md` — development workflow
|
|
85
|
+
- `docs/RELEASES.md` — release workflow documentation
|
|
86
|
+
- `docs/AI_AGENT_GUIDE.md` — AI coding agent guide
|
|
87
|
+
- `AGENTS.md` — agent working rules and repo map
|
|
88
|
+
- `.npmrc` — `@astrake` scope, `git-tag-version=false`
|
|
89
|
+
- GitHub Actions: `ci.yml` — install, version-check, typecheck, test, build, artifact upload
|
|
90
|
+
- GitHub Actions: `release.yml` — dual trigger, changelog, GitHub Release, npm publish
|
|
91
|
+
- GitHub Actions: `version-check.yml` — VERSION single-source-of-truth enforcement
|
|
92
|
+
- GitHub Actions: `codeql.yml` — weekly TypeScript security scan
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @astrake/lumora-ui
|
|
2
|
+
|
|
3
|
+
[](https://github.com/madlybong/LumoraUI/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@astrake/lumora-ui)
|
|
5
|
+
[](https://www.npmjs.com/package/@astrake/lumora-ui)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://bun.sh)
|
|
8
|
+
[](https://vuejs.org)
|
|
9
|
+
|
|
10
|
+
> Headless **Vue 3** component framework targeting three distinct surface types —
|
|
11
|
+
> Mobile, Desktop, and Embedded — with a unified `--lu-*` design token system.
|
|
12
|
+
|
|
13
|
+
**[Documentation](https://ui.lumora.astrake.com)** · **[npm](https://www.npmjs.com/package/@astrake/lumora-ui)** · **[Changelog](./CHANGELOG.md)** · **[Issues](https://github.com/madlybong/LumoraUI/issues)**
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
`@astrake/lumora-ui` provides a single component library with three surface targets:
|
|
20
|
+
|
|
21
|
+
| Target | Components | Use case |
|
|
22
|
+
|--------|-----------|---------|
|
|
23
|
+
| **Mobile** (`LuM*`) | `LuMButton`, `LuMInput`, `LuMCard`, `LuMList`, `LuMBottomSheet`, `LuMNavBar` | PWA-ready apps — touch targets ≥44px, swipe gestures, bottom nav |
|
|
24
|
+
| **Desktop** (`LuD*`) | `LuDButton`, `LuDInput`, `LuDTable`, `LuDSidebar`, `LuDDropdown`, `LuDModal` | Feature-rich browser / Electron apps — data tables, keyboard shortcuts |
|
|
25
|
+
| **Embedded** (`LuE*`) | `LuEButton`, `LuEDisplay`, `LuEGrid`, `LuEStatusBar`, `LuEAlert`, `LuENumpad` | Kiosk, IoT, in-car screens — fixed viewport, high contrast, minimal JS |
|
|
26
|
+
| **Shared** (`Lu*`) | `LuIcon`, `LuSpinner`, `LuBadge`, `LuPortal` | Cross-surface primitives |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun add @astrake/lumora-ui
|
|
34
|
+
# or
|
|
35
|
+
npm install @astrake/lumora-ui
|
|
36
|
+
# or
|
|
37
|
+
pnpm add @astrake/lumora-ui
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Peer dependency:**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bun add vue@^3.5.0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// Import all targets
|
|
52
|
+
import { LuIcon, LuSpinner } from "@astrake/lumora-ui"
|
|
53
|
+
|
|
54
|
+
// Import a specific target (recommended for tree-shaking)
|
|
55
|
+
import { LuMButton, LuMCard } from "@astrake/lumora-ui/mobile"
|
|
56
|
+
import { LuDTable, LuDSidebar } from "@astrake/lumora-ui/desktop"
|
|
57
|
+
import { LuEDisplay, LuEGrid } from "@astrake/lumora-ui/embedded"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Register the plugin:**
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// main.ts
|
|
64
|
+
import { createApp } from "vue"
|
|
65
|
+
import { LumoraUI } from "@astrake/lumora-ui"
|
|
66
|
+
import App from "./App.vue"
|
|
67
|
+
|
|
68
|
+
createApp(App).use(LumoraUI).mount("#app")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Design Token System
|
|
74
|
+
|
|
75
|
+
All targets share a `--lu-*` CSS custom property namespace:
|
|
76
|
+
|
|
77
|
+
| Token group | Examples |
|
|
78
|
+
|-------------|---------|
|
|
79
|
+
| Color | `--lu-color-surface`, `--lu-color-accent`, `--lu-color-border` |
|
|
80
|
+
| Typography | `--lu-text-sm`, `--lu-font-weight-semibold` |
|
|
81
|
+
| Spacing | `--lu-space-1` … `--lu-space-16` (4px grid) |
|
|
82
|
+
| Radius | `--lu-radius-sm` … `--lu-radius-full` |
|
|
83
|
+
| Shadow | `--lu-shadow-sm` … `--lu-shadow-lg` |
|
|
84
|
+
| Motion | `--lu-duration-fast`, `--lu-easing-standard` |
|
|
85
|
+
|
|
86
|
+
Each target root overrides tokens as appropriate (e.g., Embedded reduces shadow and motion values for performance).
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Runtime Stack
|
|
91
|
+
|
|
92
|
+
| Concern | Technology |
|
|
93
|
+
|---------|-----------|
|
|
94
|
+
| Component framework | Vue 3.5+ (Composition API) |
|
|
95
|
+
| Language | TypeScript 5.9+ |
|
|
96
|
+
| Bundler | Vite (library mode, three entry points) |
|
|
97
|
+
| Type check | vue-tsc |
|
|
98
|
+
| Tests | Vitest + @vue/test-utils |
|
|
99
|
+
| Package manager | Bun 1.3.12 |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Repo Shape
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
LumoraUI/
|
|
107
|
+
├── packages/core/ ← @astrake/lumora-ui (published package)
|
|
108
|
+
│ └── src/
|
|
109
|
+
│ ├── tokens/ ← CSS custom properties (--lu-*)
|
|
110
|
+
│ ├── shared/ ← cross-target primitives (Lu*)
|
|
111
|
+
│ ├── mobile/ ← Mobile components (LuM*)
|
|
112
|
+
│ ├── desktop/ ← Desktop components (LuD*)
|
|
113
|
+
│ ├── embedded/ ← Embedded components (LuE*)
|
|
114
|
+
│ └── index.ts
|
|
115
|
+
├── apps/showcase/ ← reference Vite + Vue 3 app
|
|
116
|
+
├── tools/ ← build, check, version, changelog scripts
|
|
117
|
+
└── docs/ ← architecture, development, releases, legal
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Local Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone https://github.com/madlybong/LumoraUI.git
|
|
126
|
+
cd LumoraUI
|
|
127
|
+
bun install
|
|
128
|
+
bun run check # typecheck
|
|
129
|
+
bun test # run test suite
|
|
130
|
+
bun run dev # start the showcase app
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Documentation
|
|
136
|
+
|
|
137
|
+
- [Project Overview](./docs/PROJECT.md)
|
|
138
|
+
- [Architecture Guide](./docs/ARCHITECTURE.md)
|
|
139
|
+
- [Development Workflow](./docs/DEVELOPMENT.md)
|
|
140
|
+
- [Release Workflow](./docs/RELEASES.md)
|
|
141
|
+
- [AI Agent Guide](./docs/AI_AGENT_GUIDE.md)
|
|
142
|
+
- [Legal Notice & Disclaimer](./docs/LEGAL.md)
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Automation
|
|
147
|
+
|
|
148
|
+
| Trigger | Workflow | Effect |
|
|
149
|
+
|---------|----------|--------|
|
|
150
|
+
| Push to `main` | CI | Install → typecheck → test → build |
|
|
151
|
+
| PR with `VERSION` change | Version Check | Ensures VERSION is the single source of truth |
|
|
152
|
+
| `VERSION` bump on `main` | Release | Build → changelog → GitHub Release → npm publish |
|
|
153
|
+
| Manual tag `v*` | Release | Same as above |
|
|
154
|
+
| Every Monday | CodeQL | Security scan |
|
|
155
|
+
|
|
156
|
+
**Required secrets:** `NPM_TOKEN` — see [docs/RELEASES.md](./docs/RELEASES.md).
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Contributing
|
|
161
|
+
|
|
162
|
+
Contributions are welcome. Please read the working rules in [AGENTS.md](./AGENTS.md) before submitting a PR.
|
|
163
|
+
By contributing, you agree your work is licensed under MIT. See [docs/LEGAL.md](./docs/LEGAL.md).
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Disclaimer
|
|
168
|
+
|
|
169
|
+
> `@astrake/lumora-ui` is provided **"as is"** without warranty of any kind.
|
|
170
|
+
> The author accepts no liability for damages arising from the use of this software.
|
|
171
|
+
> See [docs/LEGAL.md](./docs/LEGAL.md) for the full warranty disclaimer and legal notice.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[MIT](./LICENSE) © 2026 [Anuvab Chakraborty](https://github.com/madlybong)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrake/lumora-ui",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -50,9 +50,10 @@
|
|
|
50
50
|
"CHANGELOG.md"
|
|
51
51
|
],
|
|
52
52
|
"scripts": {
|
|
53
|
+
"prepack": "bun -e \"await Bun.write('README.md', await Bun.file('../../README.md').text()); await Bun.write('CHANGELOG.md', await Bun.file('../../CHANGELOG.md').text())\"",
|
|
54
|
+
"postpack": "bun -e \"import { unlinkSync } from 'fs'; try { unlinkSync('README.md') } catch(e){} try { unlinkSync('CHANGELOG.md') } catch(e){}\"",
|
|
53
55
|
"build": "bun run ../../tools/build.ts",
|
|
54
|
-
"check": "vue-tsc -p ./tsconfig.json"
|
|
55
|
-
"test": "vitest run"
|
|
56
|
+
"check": "vue-tsc -p ./tsconfig.json"
|
|
56
57
|
},
|
|
57
58
|
"peerDependencies": {
|
|
58
59
|
"vue": "^3.5.0"
|
|
@@ -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
|
|
25
|
-
|
|
37
|
+
const value = (event.target as HTMLInputElement).value;
|
|
38
|
+
internalValue.value = value;
|
|
39
|
+
emit("update:modelValue", value);
|
|
26
40
|
};
|
|
27
41
|
|
|
28
|
-
const
|
|
29
|
-
|
|
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
|
|
30
|
-
|
|
42
|
+
const value = (event.target as HTMLSelectElement).value;
|
|
43
|
+
internalValue.value = value;
|
|
44
|
+
emit("update:modelValue", value);
|
|
31
45
|
};
|
|
32
46
|
|
|
33
|
-
const
|
|
34
|
-
|
|
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="
|
|
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>
|
|
@@ -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
|
+
});
|
package/src/components/index.ts
CHANGED
|
@@ -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";
|