@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
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <svg
3
+ :class="resolvedSkin"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ fill="none"
6
+ viewBox="0 0 24 24"
7
+ role="status"
8
+ aria-label="Loading"
9
+ >
10
+ <circle
11
+ class="opacity-25"
12
+ cx="12"
13
+ cy="12"
14
+ r="10"
15
+ stroke="currentColor"
16
+ stroke-width="4"
17
+ ></circle>
18
+ <path
19
+ class="opacity-75"
20
+ fill="currentColor"
21
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
22
+ ></path>
23
+ </svg>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import { computed } from "vue";
28
+ import { useLumoraConfig } from "../context";
29
+
30
+ const props = defineProps<{
31
+ variant?: string;
32
+ }>();
33
+
34
+ const { resolveSkin } = useLumoraConfig();
35
+ const resolvedSkin = computed(() => resolveSkin("LuSpinner", props.variant));
36
+ </script>
@@ -2,39 +2,75 @@
2
2
  <button
3
3
  role="switch"
4
4
  type="button"
5
- :aria-checked="modelValue"
6
- :disabled="disabled"
7
- :class="[resolvedSkin, modelValue ? activeSkin : '']"
5
+ :name="name"
6
+ :aria-checked="isChecked"
7
+ :disabled="mergedDisabled"
8
+ :class="cn(resolvedSkin, isChecked ? activeSkin : '')"
8
9
  @click="toggle"
10
+ @blur="onBlur"
9
11
  >
10
- <span :class="[thumbSkin, modelValue ? thumbActiveSkin : '']" />
12
+ <span :class="cn(thumbSkin, isChecked ? 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";
20
+ import { cn } from "../utils";
17
21
 
18
22
  const props = defineProps<{
19
23
  modelValue?: boolean;
20
24
  variant?: string;
21
25
  disabled?: boolean;
26
+ name?: string;
27
+ error?: string | null;
22
28
  }>();
23
29
 
24
30
  const emit = defineEmits<{
25
31
  (e: "update:modelValue", value: boolean): void;
32
+ (e: "blur"): void;
26
33
  }>();
27
34
 
28
- const toggle = () => {
29
- if (props.disabled) return;
30
- emit("update:modelValue", !props.modelValue);
31
- };
32
-
33
35
  const { resolveSkin } = useLumoraConfig();
34
36
 
35
37
  const resolvedSkin = computed(() => resolveSkin("LuSwitch", props.variant));
36
- const activeSkin = computed(() => resolveSkin("LuSwitch", "active"));
38
+ const activeSkin = computed(() => resolveSkin("LuSwitch", "checked"));
37
39
 
38
40
  const thumbSkin = computed(() => resolveSkin("LuSwitchThumb", props.variant));
39
- const thumbActiveSkin = computed(() => resolveSkin("LuSwitchThumb", "active"));
41
+ const thumbActiveSkin = computed(() => resolveSkin("LuSwitchThumb", "checked"));
42
+
43
+ const formContext = inject(LuFormContextKey, null);
44
+ const internalValue = ref<boolean | undefined>(props.modelValue);
45
+ const isChecked = computed(() => props.modelValue !== undefined ? props.modelValue : !!internalValue.value);
46
+
47
+ const mergedDisabled = computed(() => props.disabled || formContext?.disabled.value);
48
+
49
+ const toggle = () => {
50
+ if (mergedDisabled.value) return;
51
+ const newValue = !isChecked.value;
52
+ internalValue.value = newValue;
53
+ emit("update:modelValue", newValue);
54
+ };
55
+
56
+ const onBlur = () => {
57
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
58
+ // trigger single-field validation — handled by parent LuForm
59
+ }
60
+ emit("blur");
61
+ };
62
+
63
+ onMounted(() => {
64
+ if (!props.name || !formContext) return;
65
+ formContext.register({
66
+ name: props.name,
67
+ getValue: () => internalValue.value,
68
+ setValue: (v) => { internalValue.value = Boolean(v); },
69
+ setError: (_msg) => { /* error display handled via formContext.getError in template if desired */ },
70
+ });
71
+ });
72
+
73
+ onUnmounted(() => {
74
+ if (props.name && formContext) formContext.unregister(props.name);
75
+ });
40
76
  </script>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <span :class="resolvedSkin">
3
+ <slot />
4
+ <button
5
+ v-if="closable"
6
+ type="button"
7
+ :class="resolvedCloseButtonSkin"
8
+ @click.stop="emit('close')"
9
+ aria-label="Remove"
10
+ >
11
+ <LuIcon name="x" :class="resolvedIconSkin" />
12
+ </button>
13
+ </span>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { computed } from "vue";
18
+ import { useLumoraConfig } from "../context";
19
+ import LuIcon from "./LuIcon.vue";
20
+
21
+ const props = defineProps<{
22
+ variant?: string;
23
+ closable?: boolean;
24
+ }>();
25
+
26
+ const emit = defineEmits<{
27
+ (e: "close"): void;
28
+ }>();
29
+
30
+ const { resolveSkin } = useLumoraConfig();
31
+
32
+ const resolvedSkin = computed(() => resolveSkin("LuTag", props.variant));
33
+ const resolvedCloseButtonSkin = computed(() => resolveSkin("LuTagCloseButton", props.variant));
34
+ const resolvedIconSkin = computed(() => resolveSkin("LuTagIcon", props.variant));
35
+ </script>
@@ -0,0 +1,62 @@
1
+ <template>
2
+ <textarea
3
+ v-bind="$attrs"
4
+ :class="resolvedSkin"
5
+ :value="modelValue"
6
+ :name="name"
7
+ :disabled="formContext?.disabled.value"
8
+ @input="onInput"
9
+ @blur="onBlur"
10
+ />
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { computed, inject, onMounted, onUnmounted, ref } from "vue";
15
+ import { useLumoraConfig } from "../context";
16
+ import { LuFormContextKey } from "./LuForm.types";
17
+
18
+ const props = defineProps<{
19
+ modelValue?: string | number;
20
+ variant?: string;
21
+ name?: string;
22
+ error?: string | null;
23
+ }>();
24
+
25
+ const emit = defineEmits<{
26
+ (e: "update:modelValue", value: string): void;
27
+ (e: "blur"): void;
28
+ }>();
29
+
30
+ const { resolveSkin } = useLumoraConfig();
31
+ const resolvedSkin = computed(() => resolveSkin("LuTextarea", props.variant));
32
+
33
+ const formContext = inject(LuFormContextKey, null);
34
+ const internalValue = ref<string | number | undefined>(props.modelValue);
35
+
36
+ const onInput = (event: Event) => {
37
+ const value = (event.target as HTMLTextAreaElement).value;
38
+ internalValue.value = value;
39
+ emit("update:modelValue", value);
40
+ };
41
+
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
+ });
62
+ </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,35 @@
1
+ <template>
2
+ <button
3
+ v-bind="$attrs"
4
+ :class="[resolvedSkin, isActive ? activeSkin : '']"
5
+ @click="onClick"
6
+ :aria-pressed="isActive"
7
+ >
8
+ <slot />
9
+ </button>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ import { computed, inject, type Ref } from "vue";
14
+ import { useLumoraConfig } from "../context";
15
+
16
+ const props = defineProps<{
17
+ value: string | number | boolean;
18
+ variant?: string;
19
+ }>();
20
+
21
+ const groupValue = inject<Ref<string | number | boolean | undefined>>("lu-toggle-value");
22
+ const setGroupValue = inject<(value: string | number | boolean) => void>("lu-toggle-set");
23
+
24
+ const isActive = computed(() => groupValue?.value === props.value);
25
+
26
+ const onClick = () => {
27
+ if (setGroupValue) {
28
+ setGroupValue(props.value);
29
+ }
30
+ };
31
+
32
+ const { resolveSkin } = useLumoraConfig();
33
+ const resolvedSkin = computed(() => resolveSkin("LuToggleButton", props.variant));
34
+ const activeSkin = computed(() => resolveSkin("LuToggleButton", "active"));
35
+ </script>
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <div v-bind="$attrs" :class="resolvedSkin" role="group">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { computed, provide, type Ref } from "vue";
9
+ import { useLumoraConfig } from "../context";
10
+
11
+ const props = defineProps<{
12
+ modelValue?: string | number | boolean;
13
+ variant?: string;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ (e: "update:modelValue", value: string | number | boolean): void;
18
+ }>();
19
+
20
+ provide("lu-toggle-value", computed(() => props.modelValue));
21
+ provide("lu-toggle-set", (value: string | number | boolean) => {
22
+ emit("update:modelValue", value);
23
+ });
24
+
25
+ const { resolveSkin } = useLumoraConfig();
26
+ const resolvedSkin = computed(() => resolveSkin("LuToggleGroup", props.variant));
27
+ </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,21 @@ 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 { default as LuTextarea } from "./LuTextarea.vue";
30
+ export { default as LuCheckbox } from "./LuCheckbox.vue";
31
+ export { default as LuRadioGroup } from "./LuRadioGroup.vue";
32
+ export { default as LuRadio } from "./LuRadio.vue";
33
+ export { default as LuAlert } from "./LuAlert.vue";
34
+ export { default as LuSpinner } from "./LuSpinner.vue";
35
+ export { default as LuSkeleton } from "./LuSkeleton.vue";
36
+ export { default as LuTag } from "./LuTag.vue";
37
+ export { default as LuBreadcrumb } from "./LuBreadcrumb.vue";
38
+ export { default as LuMenu } from "./LuMenu.vue";
39
+ export { default as LuMenuItem } from "./LuMenuItem.vue";
40
+ export { default as LuPagination } from "./LuPagination.vue";
41
+ export { default as LuModal } from "./LuModal.vue";
42
+ export { default as LuToggleGroup } from "./LuToggleGroup.vue";
43
+ export { default as LuToggleButton } from "./LuToggleButton.vue";
44
+ export { default as LuCodeBlock } from "./LuCodeBlock.vue";
45
+ export type { LuFormRules, LuFormErrors, LuFormValidator, LuFormContext } from "./LuForm.types";
package/src/context.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { inject, isRef, type InjectionKey, type Component } from "vue";
2
2
  import type { LumoraUIConfig, SkinMap } from "./types";
3
+ import { extendSkin, cn } from "./utils";
4
+ import { defaultSkin } from "./skins/default";
3
5
 
4
6
  export const LumoraUIConfigKey: InjectionKey<LumoraUIConfig> = Symbol("LumoraUIConfig");
5
7
 
@@ -7,11 +9,11 @@ export function useLumoraConfig() {
7
9
  const config = inject(LumoraUIConfigKey, {});
8
10
 
9
11
  const resolveSkin = (componentName: string, variant?: string): string => {
10
- if (!config.skin) return "";
12
+ // Unwrap the user's skin map if it's a ref
13
+ const userSkinMap = isRef(config.skin) ? config.skin.value : (config.skin || {});
11
14
 
12
- // Unwrap the skin map if it's a ref
13
- const skinMap = isRef(config.skin) ? config.skin.value : config.skin;
14
- if (!skinMap) return "";
15
+ // The base structural skin merged with any consumer overrides
16
+ const skinMap = extendSkin(defaultSkin, userSkinMap);
15
17
 
16
18
  const componentSkin = skinMap[componentName];
17
19
  if (!componentSkin) return "";
@@ -24,7 +26,8 @@ export function useLumoraConfig() {
24
26
  classes.push(componentSkin[variant] as string);
25
27
  }
26
28
 
27
- return classes.join(" ");
29
+ // Use cn() so that variant classes intelligently override default classes
30
+ return cn(classes);
28
31
  };
29
32
 
30
33
  const resolveIcon = (name: string, size?: number): Component | null => {
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from "./types";
2
- export * from "./context";
3
- export * from "./plugin";
2
+ export { useLumoraConfig, LumoraUIConfigKey } from "./context";
3
+ export { createLumoraUI } from "./plugin";
4
4
 
5
5
  export * from "./layout/index";
6
6
  export * from "./shell/index";
@@ -1,23 +1,56 @@
1
- <template>
2
- <div v-bind="$attrs" :class="resolvedSkin">
3
- <slot />
4
- </div>
5
- </template>
6
-
7
- <script setup lang="ts">
8
- import { computed } from "vue";
1
+ <script lang="ts">
2
+ import { h, defineComponent, computed, Fragment, VNode, Comment } from "vue";
9
3
  import { useLumoraConfig } from "../context";
10
4
 
11
- const props = defineProps<{
12
- direction?: "vertical" | "horizontal";
13
- variant?: string;
14
- }>();
15
-
16
- const { resolveSkin } = useLumoraConfig();
17
- const resolvedSkin = computed(() =>
18
- resolveSkin("LuDock", props.direction ?? props.variant) ||
19
- (props.direction === "horizontal"
20
- ? "flex flex-row h-full w-full overflow-hidden"
21
- : "flex flex-col h-full w-full overflow-hidden")
22
- );
5
+ export default defineComponent({
6
+ name: "LuDock",
7
+ props: {
8
+ variant: String,
9
+ },
10
+ setup(props, { slots, attrs }) {
11
+ const { resolveSkin } = useLumoraConfig();
12
+ const resolvedSkin = computed(() => resolveSkin("LuDock", props.variant));
13
+
14
+ function flattenChildren(children: any[]): VNode[] {
15
+ let result: VNode[] = [];
16
+ for (const child of children) {
17
+ if (Array.isArray(child)) {
18
+ result.push(...flattenChildren(child));
19
+ } else if (child.type === Fragment) {
20
+ result.push(...flattenChildren(child.children as any[]));
21
+ } else if (child && child.type && child.type !== Comment) {
22
+ result.push(child);
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+
28
+ return () => {
29
+ const defaultSlot = slots.default ? slots.default() : [];
30
+ const children = flattenChildren(defaultSlot);
31
+
32
+ if (children.length === 0) {
33
+ return h("div", { ...attrs, class: resolvedSkin.value });
34
+ }
35
+
36
+ let currentContainer = h("div", { class: "flex flex-col flex-1 min-h-0 min-w-0 overflow-hidden" }, [children[children.length - 1]]);
37
+
38
+ for (let i = children.length - 2; i >= 0; i--) {
39
+ const item = children[i];
40
+ const edge = item.props?.dock || "left";
41
+
42
+ const isVertical = edge === "top" || edge === "bottom";
43
+ const flexDirection = isVertical ? "flex-col" : "flex-row";
44
+
45
+ const nodes = (edge === "top" || edge === "left")
46
+ ? [item, currentContainer]
47
+ : [currentContainer, item];
48
+
49
+ currentContainer = h("div", { class: `flex ${flexDirection} flex-1 min-h-0 min-w-0 overflow-hidden` }, nodes);
50
+ }
51
+
52
+ return h("div", { ...attrs, class: resolvedSkin.value }, [currentContainer]);
53
+ };
54
+ }
55
+ });
23
56
  </script>
@@ -14,5 +14,7 @@ const props = defineProps<{
14
14
  }>();
15
15
 
16
16
  const { resolveSkin } = useLumoraConfig();
17
- const resolvedSkin = computed(() => resolveSkin("LuDockItem", props.dock ?? props.variant));
17
+ const resolvedSkin = computed(() => [
18
+ resolveSkin("LuDockItem", props.dock ?? props.variant)
19
+ ]);
18
20
  </script>