@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
@@ -1,30 +1,82 @@
1
1
  <template>
2
+ <div class="relative w-full" v-if="$slots.prepend || $slots.append">
3
+ <div v-if="$slots.prepend" :class="prependSkin">
4
+ <slot name="prepend" />
5
+ </div>
6
+ <input
7
+ v-bind="$attrs"
8
+ :class="[resolvedSkin, $slots.prepend && 'pl-9', $slots.append && 'pr-9']"
9
+ :value="modelValue"
10
+ :name="name"
11
+ :disabled="formContext?.disabled.value"
12
+ @input="onInput"
13
+ @blur="onBlur"
14
+ />
15
+ <div v-if="$slots.append" :class="appendSkin">
16
+ <slot name="append" />
17
+ </div>
18
+ </div>
2
19
  <input
20
+ v-else
3
21
  v-bind="$attrs"
4
22
  :class="resolvedSkin"
5
23
  :value="modelValue"
24
+ :name="name"
25
+ :disabled="formContext?.disabled.value"
6
26
  @input="onInput"
27
+ @blur="onBlur"
7
28
  />
8
29
  </template>
9
30
 
10
31
  <script setup lang="ts">
11
- import { computed } from "vue";
32
+ import { computed, inject, onMounted, onUnmounted, ref } from "vue";
12
33
  import { useLumoraConfig } from "../context";
34
+ import { LuFormContextKey } from "./LuForm.types";
13
35
 
14
36
  const props = defineProps<{
15
37
  modelValue?: string | number;
16
38
  variant?: string;
39
+ name?: string;
40
+ error?: string | null;
17
41
  }>();
18
42
 
19
43
  const emit = defineEmits<{
20
44
  (e: "update:modelValue", value: string): void;
45
+ (e: "blur"): void;
21
46
  }>();
22
47
 
48
+ const { resolveSkin } = useLumoraConfig();
49
+ const resolvedSkin = computed(() => resolveSkin("LuInput", props.variant));
50
+ const prependSkin = computed(() => resolveSkin("LuInputPrepend", props.variant));
51
+ const appendSkin = computed(() => resolveSkin("LuInputAppend", props.variant));
52
+
53
+ const formContext = inject(LuFormContextKey, null);
54
+ const internalValue = ref<string | number | undefined>(props.modelValue);
55
+
23
56
  const onInput = (event: Event) => {
24
- const target = event.target as HTMLInputElement;
25
- emit("update:modelValue", target.value);
57
+ const value = (event.target as HTMLInputElement).value;
58
+ internalValue.value = value;
59
+ emit("update:modelValue", value);
26
60
  };
27
61
 
28
- const { resolveSkin } = useLumoraConfig();
29
- const resolvedSkin = computed(() => resolveSkin("LuInput", props.variant));
62
+ const onBlur = () => {
63
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
64
+ // trigger single-field validation — handled by parent LuForm
65
+ }
66
+ emit("blur");
67
+ };
68
+
69
+ onMounted(() => {
70
+ if (!props.name || !formContext) return;
71
+ formContext.register({
72
+ name: props.name,
73
+ getValue: () => internalValue.value,
74
+ setValue: (v) => { internalValue.value = v as string; },
75
+ setError: (_msg) => { /* error display handled via formContext.getError in template if desired */ },
76
+ });
77
+ });
78
+
79
+ onUnmounted(() => {
80
+ if (props.name && formContext) formContext.unregister(props.name);
81
+ });
30
82
  </script>
@@ -0,0 +1,86 @@
1
+ <template>
2
+ <div :class="resolvedSkin" ref="dropdownRef">
3
+ <div @click="toggle" :class="resolvedTriggerSkin" aria-haspopup="true" :aria-expanded="isOpen">
4
+ <slot name="trigger">
5
+ <LuButton variant="default">Options <LuIcon name="chevron-down" class="ml-2 h-4 w-4" /></LuButton>
6
+ </slot>
7
+ </div>
8
+ <transition
9
+ enter-active-class="transition ease-out duration-100"
10
+ enter-from-class="transform opacity-0 scale-95"
11
+ enter-to-class="transform opacity-100 scale-100"
12
+ leave-active-class="transition ease-in duration-75"
13
+ leave-from-class="transform opacity-100 scale-100"
14
+ leave-to-class="transform opacity-0 scale-95"
15
+ >
16
+ <div v-if="isOpen" :class="[resolvedContentSkin, alignClass]">
17
+ <div :class="resolvedGroupSkin" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
18
+ <slot />
19
+ </div>
20
+ </div>
21
+ </transition>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts">
26
+ import { computed, ref, onMounted, onBeforeUnmount } from "vue";
27
+ import { useLumoraConfig } from "../context";
28
+ import LuButton from "./LuButton.vue";
29
+ import LuIcon from "./LuIcon.vue";
30
+
31
+ const props = withDefaults(defineProps<{
32
+ variant?: string;
33
+ align?: 'left' | 'right';
34
+ }>(), {
35
+ align: 'left'
36
+ });
37
+
38
+ const emit = defineEmits<{
39
+ (e: "open"): void;
40
+ (e: "close"): void;
41
+ }>();
42
+
43
+ const isOpen = ref(false);
44
+ const dropdownRef = ref<HTMLElement | null>(null);
45
+
46
+ const { resolveSkin } = useLumoraConfig();
47
+
48
+ const resolvedSkin = computed(() => resolveSkin("LuMenu", props.variant));
49
+ const resolvedTriggerSkin = computed(() => resolveSkin("LuMenuTrigger", props.variant));
50
+ const resolvedContentSkin = computed(() => resolveSkin("LuMenuContent", props.variant));
51
+ const resolvedGroupSkin = computed(() => resolveSkin("LuMenuGroup", props.variant));
52
+
53
+ const alignClass = computed(() => {
54
+ return props.align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left';
55
+ });
56
+
57
+ const toggle = () => {
58
+ isOpen.value = !isOpen.value;
59
+ if (isOpen.value) {
60
+ emit("open");
61
+ } else {
62
+ emit("close");
63
+ }
64
+ };
65
+
66
+ const close = () => {
67
+ if (isOpen.value) {
68
+ isOpen.value = false;
69
+ emit("close");
70
+ }
71
+ };
72
+
73
+ const handleClickOutside = (event: MouseEvent) => {
74
+ if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
75
+ close();
76
+ }
77
+ };
78
+
79
+ onMounted(() => {
80
+ document.addEventListener('click', handleClickOutside);
81
+ });
82
+
83
+ onBeforeUnmount(() => {
84
+ document.removeEventListener('click', handleClickOutside);
85
+ });
86
+ </script>
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ :class="resolvedSkin"
5
+ role="menuitem"
6
+ :disabled="disabled"
7
+ :data-disabled="disabled ? '' : undefined"
8
+ @click="onClick"
9
+ >
10
+ <slot />
11
+ </button>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { computed } from "vue";
16
+ import { useLumoraConfig } from "../context";
17
+
18
+ const props = defineProps<{
19
+ variant?: string;
20
+ disabled?: boolean;
21
+ }>();
22
+
23
+ const emit = defineEmits<{
24
+ (e: "click", event: MouseEvent): void;
25
+ }>();
26
+
27
+ const { resolveSkin } = useLumoraConfig();
28
+ const resolvedSkin = computed(() => resolveSkin("LuMenuItem", props.variant));
29
+
30
+ const onClick = (event: MouseEvent) => {
31
+ if (props.disabled) {
32
+ event.preventDefault();
33
+ return;
34
+ }
35
+ emit("click", event);
36
+ };
37
+ </script>
@@ -0,0 +1,115 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <transition
4
+ enter-active-class="transition ease-out duration-200"
5
+ enter-from-class="opacity-0"
6
+ enter-to-class="opacity-100"
7
+ leave-active-class="transition ease-in duration-150"
8
+ leave-from-class="opacity-100"
9
+ leave-to-class="opacity-0"
10
+ >
11
+ <div v-if="modelValue" :class="resolvedOverlaySkin" @click="handleOverlayClick" aria-modal="true" role="dialog" tabindex="-1">
12
+ <transition
13
+ enter-active-class="transition ease-out duration-200"
14
+ enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
15
+ enter-to-class="opacity-100 translate-y-0 sm:scale-100"
16
+ leave-active-class="transition ease-in duration-150"
17
+ leave-from-class="opacity-100 translate-y-0 sm:scale-100"
18
+ leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
19
+ appear
20
+ >
21
+ <div v-if="modelValue" :class="resolvedSkin" @click.stop>
22
+ <div v-if="$slots.header || title" :class="resolvedHeaderSkin">
23
+ <slot name="header">
24
+ <LuText variant="section-title">{{ title }}</LuText>
25
+ </slot>
26
+ <button v-if="closable" type="button" :class="resolvedCloseButtonSkin" @click="close" aria-label="Close modal">
27
+ <LuIcon name="x" class="h-5 w-5" />
28
+ </button>
29
+ </div>
30
+
31
+ <div :class="resolvedContentSkin">
32
+ <slot />
33
+ </div>
34
+
35
+ <div v-if="$slots.footer" :class="resolvedFooterSkin">
36
+ <slot name="footer" />
37
+ </div>
38
+ </div>
39
+ </transition>
40
+ </div>
41
+ </transition>
42
+ </Teleport>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { computed, onMounted, onBeforeUnmount, watch } from "vue";
47
+ import { useLumoraConfig } from "../context";
48
+ import LuText from "./LuText.vue";
49
+ import LuIcon from "./LuIcon.vue";
50
+
51
+ const props = withDefaults(defineProps<{
52
+ modelValue: boolean;
53
+ title?: string;
54
+ variant?: string;
55
+ closable?: boolean;
56
+ closeOnOverlayClick?: boolean;
57
+ }>(), {
58
+ closable: true,
59
+ closeOnOverlayClick: true,
60
+ });
61
+
62
+ const emit = defineEmits<{
63
+ (e: "update:modelValue", value: boolean): void;
64
+ (e: "close"): void;
65
+ }>();
66
+
67
+ const { resolveSkin } = useLumoraConfig();
68
+
69
+ const resolvedOverlaySkin = computed(() => resolveSkin("LuModalOverlay", props.variant));
70
+ const resolvedSkin = computed(() => resolveSkin("LuModal", props.variant));
71
+ const resolvedHeaderSkin = computed(() => resolveSkin("LuModalHeader", props.variant));
72
+ const resolvedContentSkin = computed(() => resolveSkin("LuModalContent", props.variant));
73
+ const resolvedFooterSkin = computed(() => resolveSkin("LuModalFooter", props.variant));
74
+ const resolvedCloseButtonSkin = computed(() => resolveSkin("LuModalCloseButton", props.variant));
75
+
76
+ const close = () => {
77
+ if (props.closable) {
78
+ emit("update:modelValue", false);
79
+ emit("close");
80
+ }
81
+ };
82
+
83
+ const handleOverlayClick = () => {
84
+ if (props.closeOnOverlayClick) {
85
+ close();
86
+ }
87
+ };
88
+
89
+ const handleEscKey = (e: KeyboardEvent) => {
90
+ if (e.key === 'Escape' && props.modelValue) {
91
+ close();
92
+ }
93
+ };
94
+
95
+ watch(() => props.modelValue, (isOpen) => {
96
+ if (typeof document !== 'undefined') {
97
+ if (isOpen) {
98
+ document.body.style.overflow = 'hidden';
99
+ } else {
100
+ document.body.style.overflow = '';
101
+ }
102
+ }
103
+ }, { immediate: true });
104
+
105
+ onMounted(() => {
106
+ document.addEventListener('keydown', handleEscKey);
107
+ });
108
+
109
+ onBeforeUnmount(() => {
110
+ document.removeEventListener('keydown', handleEscKey);
111
+ if (typeof document !== 'undefined') {
112
+ document.body.style.overflow = '';
113
+ }
114
+ });
115
+ </script>
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <nav aria-label="Pagination" :class="resolvedSkin">
3
+ <LuButton
4
+ variant="ghost"
5
+ :disabled="modelValue <= 1"
6
+ @click="prevPage"
7
+ :class="resolvedButtonSkin"
8
+ aria-label="Previous page"
9
+ >
10
+ <LuIcon name="chevron-left" class="h-4 w-4" />
11
+ </LuButton>
12
+
13
+ <div :class="resolvedPagesSkin">
14
+ <template v-for="page in pages" :key="page">
15
+ <span v-if="page === '...'" :class="resolvedEllipsisSkin">...</span>
16
+ <LuButton
17
+ v-else
18
+ :variant="page === modelValue ? 'primary' : 'ghost'"
19
+ @click="goToPage(page as number)"
20
+ :class="resolvedPageButtonSkin"
21
+ :aria-current="page === modelValue ? 'page' : undefined"
22
+ >
23
+ {{ page }}
24
+ </LuButton>
25
+ </template>
26
+ </div>
27
+
28
+ <LuButton
29
+ variant="ghost"
30
+ :disabled="modelValue >= totalPages"
31
+ @click="nextPage"
32
+ :class="resolvedButtonSkin"
33
+ aria-label="Next page"
34
+ >
35
+ <LuIcon name="chevron-right" class="h-4 w-4" />
36
+ </LuButton>
37
+ </nav>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { computed } from "vue";
42
+ import { useLumoraConfig } from "../context";
43
+ import LuButton from "./LuButton.vue";
44
+ import LuIcon from "./LuIcon.vue";
45
+
46
+ const props = withDefaults(defineProps<{
47
+ modelValue: number;
48
+ total: number;
49
+ pageSize?: number;
50
+ siblingCount?: number;
51
+ variant?: string;
52
+ }>(), {
53
+ pageSize: 10,
54
+ siblingCount: 1,
55
+ });
56
+
57
+ const emit = defineEmits<{
58
+ (e: "update:modelValue", value: number): void;
59
+ (e: "change", value: number): void;
60
+ }>();
61
+
62
+ const totalPages = computed(() => Math.ceil(props.total / props.pageSize));
63
+
64
+ const pages = computed(() => {
65
+ const current = props.modelValue;
66
+ const total = totalPages.value;
67
+ const siblings = props.siblingCount;
68
+
69
+ if (total <= 1) return [1];
70
+
71
+ const leftSiblingIndex = Math.max(current - siblings, 1);
72
+ const rightSiblingIndex = Math.min(current + siblings, total);
73
+
74
+ const showLeftEllipsis = leftSiblingIndex > 2;
75
+ const showRightEllipsis = rightSiblingIndex < total - 1;
76
+
77
+ if (!showLeftEllipsis && showRightEllipsis) {
78
+ const leftItemCount = 3 + 2 * siblings;
79
+ const leftRange = Array.from({ length: Math.min(leftItemCount, total) }, (_, i) => i + 1);
80
+ return [...leftRange, '...', total];
81
+ }
82
+
83
+ if (showLeftEllipsis && !showRightEllipsis) {
84
+ const rightItemCount = 3 + 2 * siblings;
85
+ const rightRange = Array.from({ length: Math.min(rightItemCount, total) }, (_, i) => total - rightItemCount + i + 1);
86
+ return [1, '...', ...rightRange];
87
+ }
88
+
89
+ if (showLeftEllipsis && showRightEllipsis) {
90
+ const middleRange = Array.from({ length: rightSiblingIndex - leftSiblingIndex + 1 }, (_, i) => leftSiblingIndex + i);
91
+ return [1, '...', ...middleRange, '...', total];
92
+ }
93
+
94
+ return Array.from({ length: total }, (_, i) => i + 1);
95
+ });
96
+
97
+ const { resolveSkin } = useLumoraConfig();
98
+
99
+ const resolvedSkin = computed(() => resolveSkin("LuPagination", props.variant));
100
+ const resolvedButtonSkin = computed(() => resolveSkin("LuPaginationButton", props.variant));
101
+ const resolvedPagesSkin = computed(() => resolveSkin("LuPaginationPages", props.variant));
102
+ const resolvedPageButtonSkin = computed(() => resolveSkin("LuPaginationPageButton", props.variant));
103
+ const resolvedEllipsisSkin = computed(() => resolveSkin("LuPaginationEllipsis", props.variant));
104
+
105
+ const goToPage = (page: number) => {
106
+ if (page === props.modelValue) return;
107
+ emit("update:modelValue", page);
108
+ emit("change", page);
109
+ };
110
+
111
+ const prevPage = () => {
112
+ if (props.modelValue > 1) goToPage(props.modelValue - 1);
113
+ };
114
+
115
+ const nextPage = () => {
116
+ if (props.modelValue < totalPages.value) goToPage(props.modelValue + 1);
117
+ };
118
+ </script>
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <div :class="resolvedContainerSkin">
3
+ <input
4
+ type="radio"
5
+ v-bind="$attrs"
6
+ :class="resolvedSkin"
7
+ :name="radioGroup?.name"
8
+ :value="value"
9
+ :checked="isChecked"
10
+ :disabled="radioGroup?.disabled.value || disabled"
11
+ @change="onChange"
12
+ />
13
+ <label v-if="$slots.default || label" :class="resolvedLabelSkin" @click.prevent="onClick">
14
+ <slot>{{ label }}</slot>
15
+ </label>
16
+ </div>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { computed, inject } from "vue";
21
+ import { useLumoraConfig } from "../context";
22
+ import { LuRadioGroupContextKey } from "./LuRadioGroup.types";
23
+
24
+ const props = defineProps<{
25
+ value: string | number;
26
+ variant?: string;
27
+ label?: string;
28
+ disabled?: boolean;
29
+ }>();
30
+
31
+ const { resolveSkin } = useLumoraConfig();
32
+ const resolvedContainerSkin = computed(() => resolveSkin("LuRadioContainer", props.variant));
33
+ const resolvedSkin = computed(() => resolveSkin("LuRadio", props.variant));
34
+ const resolvedLabelSkin = computed(() => resolveSkin("LuRadioLabel", props.variant));
35
+
36
+ const radioGroup = inject(LuRadioGroupContextKey, null);
37
+
38
+ if (!radioGroup) {
39
+ console.warn("LuRadio must be used within a LuRadioGroup");
40
+ }
41
+
42
+ const isChecked = computed(() => {
43
+ return radioGroup?.modelValue.value === props.value;
44
+ });
45
+
46
+ const onChange = () => {
47
+ if (props.disabled || radioGroup?.disabled.value) return;
48
+ radioGroup?.updateValue(props.value);
49
+ };
50
+
51
+ const onClick = () => {
52
+ if (props.disabled || radioGroup?.disabled.value) return;
53
+ radioGroup?.updateValue(props.value);
54
+ };
55
+ </script>
@@ -0,0 +1,10 @@
1
+ import type { InjectionKey, Ref } from 'vue';
2
+
3
+ export interface LuRadioGroupContext {
4
+ name: string;
5
+ modelValue: Ref<string | number | undefined>;
6
+ updateValue: (value: string | number) => void;
7
+ disabled: Ref<boolean>;
8
+ }
9
+
10
+ export const LuRadioGroupContextKey: InjectionKey<LuRadioGroupContext> = Symbol('LuRadioGroupContext');
@@ -0,0 +1,66 @@
1
+ <template>
2
+ <div :class="resolvedSkin" role="radiogroup" :aria-disabled="disabled || formContext?.disabled.value">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { computed, inject, onMounted, onUnmounted, provide, ref, watch } from "vue";
9
+ import { useLumoraConfig } from "../context";
10
+ import { LuFormContextKey } from "./LuForm.types";
11
+ import { LuRadioGroupContextKey } from "./LuRadioGroup.types";
12
+
13
+ const props = defineProps<{
14
+ modelValue?: string | number;
15
+ name: string;
16
+ variant?: string;
17
+ disabled?: boolean;
18
+ }>();
19
+
20
+ const emit = defineEmits<{
21
+ (e: "update:modelValue", value: string | number): void;
22
+ (e: "change", value: string | number): void;
23
+ }>();
24
+
25
+ const { resolveSkin } = useLumoraConfig();
26
+ const resolvedSkin = computed(() => resolveSkin("LuRadioGroup", props.variant));
27
+
28
+ const formContext = inject(LuFormContextKey, null);
29
+ const internalValue = ref<string | number | undefined>(props.modelValue);
30
+
31
+ watch(() => props.modelValue, (newVal) => {
32
+ internalValue.value = newVal;
33
+ });
34
+
35
+ const updateValue = (value: string | number) => {
36
+ if (props.disabled || formContext?.disabled.value) return;
37
+ internalValue.value = value;
38
+ emit("update:modelValue", value);
39
+ emit("change", value);
40
+
41
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
42
+ // validation
43
+ }
44
+ };
45
+
46
+ provide(LuRadioGroupContextKey, {
47
+ name: props.name,
48
+ modelValue: computed(() => internalValue.value),
49
+ updateValue,
50
+ disabled: computed(() => !!props.disabled || !!formContext?.disabled.value)
51
+ });
52
+
53
+ onMounted(() => {
54
+ if (!props.name || !formContext) return;
55
+ formContext.register({
56
+ name: props.name,
57
+ getValue: () => internalValue.value,
58
+ setValue: (v) => { internalValue.value = v as string | number; },
59
+ setError: (_msg) => {},
60
+ });
61
+ });
62
+
63
+ onUnmounted(() => {
64
+ if (props.name && formContext) formContext.unregister(props.name);
65
+ });
66
+ </script>
@@ -3,7 +3,10 @@
3
3
  v-bind="$attrs"
4
4
  :class="resolvedSkin"
5
5
  :value="modelValue"
6
+ :name="name"
7
+ :disabled="formContext?.disabled.value"
6
8
  @change="onChange"
9
+ @blur="onBlur"
7
10
  >
8
11
  <option v-for="opt in options" :key="opt.value" :value="opt.value">
9
12
  {{ opt.label }}
@@ -12,24 +15,53 @@
12
15
  </template>
13
16
 
14
17
  <script setup lang="ts">
15
- import { computed } from "vue";
18
+ import { computed, inject, onMounted, onUnmounted, ref } from "vue";
16
19
  import { useLumoraConfig } from "../context";
20
+ import { LuFormContextKey } from "./LuForm.types";
17
21
 
18
22
  const props = defineProps<{
19
23
  modelValue?: string | number;
20
24
  variant?: string;
21
25
  options: Array<{ value: string | number; label: string }>;
26
+ name?: string;
27
+ error?: string | null;
22
28
  }>();
23
29
 
24
30
  const emit = defineEmits<{
25
- (e: "update:modelValue", value: string): void;
31
+ (e: "update:modelValue", value: string | number): void;
32
+ (e: "blur"): void;
26
33
  }>();
27
34
 
35
+ const { resolveSkin } = useLumoraConfig();
36
+ const resolvedSkin = computed(() => resolveSkin("LuSelect", props.variant));
37
+
38
+ const formContext = inject(LuFormContextKey, null);
39
+ const internalValue = ref<string | number | undefined>(props.modelValue);
40
+
28
41
  const onChange = (event: Event) => {
29
- const target = event.target as HTMLSelectElement;
30
- emit("update:modelValue", target.value);
42
+ const value = (event.target as HTMLSelectElement).value;
43
+ internalValue.value = value;
44
+ emit("update:modelValue", value);
31
45
  };
32
46
 
33
- const { resolveSkin } = useLumoraConfig();
34
- const resolvedSkin = computed(() => resolveSkin("LuSelect", props.variant));
47
+ const onBlur = () => {
48
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
49
+ // trigger single-field validation — handled by parent LuForm
50
+ }
51
+ emit("blur");
52
+ };
53
+
54
+ onMounted(() => {
55
+ if (!props.name || !formContext) return;
56
+ formContext.register({
57
+ name: props.name,
58
+ getValue: () => internalValue.value,
59
+ setValue: (v) => { internalValue.value = v as string | number; },
60
+ setError: (_msg) => { /* error display handled via formContext.getError in template if desired */ },
61
+ });
62
+ });
63
+
64
+ onUnmounted(() => {
65
+ if (props.name && formContext) formContext.unregister(props.name);
66
+ });
35
67
  </script>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <div :class="resolvedSkin" aria-hidden="true" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import { computed } from "vue";
7
+ import { useLumoraConfig } from "../context";
8
+
9
+ const props = defineProps<{
10
+ variant?: string;
11
+ }>();
12
+
13
+ const { resolveSkin } = useLumoraConfig();
14
+ const resolvedSkin = computed(() => resolveSkin("LuSkeleton", props.variant));
15
+ </script>