@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.
- package/CHANGELOG.md +65 -1
- package/package.json +9 -1
- package/src/components/LuAlert.vue +33 -0
- package/src/components/LuBreadcrumb.vue +63 -0
- package/src/components/LuCard.vue +8 -1
- package/src/components/LuCheckbox.vue +94 -0
- package/src/components/LuCodeBlock.vue +168 -0
- package/src/components/LuForm.types.ts +24 -0
- package/src/components/LuForm.vue +121 -0
- package/src/components/LuInput.vue +57 -5
- package/src/components/LuMenu.vue +86 -0
- package/src/components/LuMenuItem.vue +37 -0
- package/src/components/LuModal.vue +115 -0
- package/src/components/LuPagination.vue +118 -0
- package/src/components/LuRadio.vue +55 -0
- package/src/components/LuRadioGroup.types.ts +10 -0
- package/src/components/LuRadioGroup.vue +66 -0
- package/src/components/LuSelect.vue +38 -6
- package/src/components/LuSkeleton.vue +15 -0
- package/src/components/LuSpinner.vue +36 -0
- package/src/components/LuSwitch.vue +48 -12
- package/src/components/LuTag.vue +35 -0
- package/src/components/LuTextarea.vue +62 -0
- package/src/components/LuThemeSelect.vue +1 -1
- package/src/components/LuToggleButton.vue +35 -0
- package/src/components/LuToggleGroup.vue +27 -0
- package/src/components/__tests__/LuForm.test.ts +206 -0
- package/src/components/index.ts +18 -0
- package/src/context.ts +8 -5
- package/src/index.ts +2 -2
- package/src/layout/LuDock.vue +53 -20
- package/src/layout/LuDockItem.vue +3 -1
- package/src/layout/LuFill.vue +15 -5
- package/src/layout/LuFixed.vue +15 -5
- package/src/layout/LuGrid.vue +28 -5
- package/src/layout/LuScroll.vue +3 -1
- package/src/layout/LuSplitPane.vue +3 -3
- package/src/layout/LuSplitResizer.vue +5 -3
- package/src/layout/LuStack.vue +16 -11
- package/src/lumora.css +16 -0
- package/src/plugin.ts +3 -2
- package/src/shell/desktop/LuDesktopRailItem.vue +2 -2
- package/src/shell/desktop/LuDesktopShell.vue +3 -3
- package/src/shell/embedded/LuEmbeddedShell.vue +2 -2
- package/src/shell/embedded/LuEmbeddedStatusBar.vue +16 -0
- package/src/shell/embedded/LuEmbeddedTopBar.vue +17 -0
- package/src/shell/index.ts +4 -1
- package/src/shell/mobile/LuMobileHeader.vue +17 -0
- package/src/shell/mobile/LuMobileNavBar.vue +15 -0
- package/src/shell/mobile/LuMobileShell.vue +2 -2
- package/src/skins/default.ts +361 -29
- package/src/tailwind.ts +25 -0
- 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.
|
|
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(() =>
|
|
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, '<').replace(/>/g, '>')}</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>
|