@astrake/lumora-ui 0.1.6 → 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 (48) hide show
  1. package/CHANGELOG.md +46 -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/LuInput.vue +20 -0
  9. package/src/components/LuMenu.vue +86 -0
  10. package/src/components/LuMenuItem.vue +37 -0
  11. package/src/components/LuModal.vue +115 -0
  12. package/src/components/LuPagination.vue +118 -0
  13. package/src/components/LuRadio.vue +55 -0
  14. package/src/components/LuRadioGroup.types.ts +10 -0
  15. package/src/components/LuRadioGroup.vue +66 -0
  16. package/src/components/LuSkeleton.vue +15 -0
  17. package/src/components/LuSpinner.vue +36 -0
  18. package/src/components/LuSwitch.vue +8 -6
  19. package/src/components/LuTag.vue +35 -0
  20. package/src/components/LuTextarea.vue +62 -0
  21. package/src/components/LuToggleButton.vue +35 -0
  22. package/src/components/LuToggleGroup.vue +27 -0
  23. package/src/components/index.ts +16 -0
  24. package/src/context.ts +8 -5
  25. package/src/index.ts +2 -2
  26. package/src/layout/LuDock.vue +53 -20
  27. package/src/layout/LuDockItem.vue +3 -1
  28. package/src/layout/LuFill.vue +15 -5
  29. package/src/layout/LuFixed.vue +15 -5
  30. package/src/layout/LuGrid.vue +28 -5
  31. package/src/layout/LuScroll.vue +3 -1
  32. package/src/layout/LuSplitPane.vue +3 -3
  33. package/src/layout/LuSplitResizer.vue +5 -3
  34. package/src/layout/LuStack.vue +16 -11
  35. package/src/lumora.css +16 -0
  36. package/src/plugin.ts +3 -2
  37. package/src/shell/desktop/LuDesktopRailItem.vue +2 -2
  38. package/src/shell/desktop/LuDesktopShell.vue +3 -3
  39. package/src/shell/embedded/LuEmbeddedShell.vue +2 -2
  40. package/src/shell/embedded/LuEmbeddedStatusBar.vue +16 -0
  41. package/src/shell/embedded/LuEmbeddedTopBar.vue +17 -0
  42. package/src/shell/index.ts +4 -1
  43. package/src/shell/mobile/LuMobileHeader.vue +17 -0
  44. package/src/shell/mobile/LuMobileNavBar.vue +15 -0
  45. package/src/shell/mobile/LuMobileShell.vue +2 -2
  46. package/src/skins/default.ts +361 -29
  47. package/src/tailwind.ts +25 -0
  48. package/src/utils.ts +95 -0
package/CHANGELOG.md CHANGED
@@ -4,13 +4,58 @@
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
+
7
53
  ## [0.1.6] — 2026-04-25
8
54
 
9
55
  ### Maintenance
10
56
 
11
57
  - bump version to 0.1.6 - add LuForm validation orchestrator (`1f11734`)
12
58
 
13
-
14
59
  ---
15
60
 
16
61
  ## [0.1.6] — 2026-04-26
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@astrake/lumora-ui",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Headless Vue 3 component framework for three surface targets — Mobile, Desktop, and Embedded — with a unified --lu-* design token system.",
5
5
  "author": "Anuvab Chakraborty (https://github.com/madlybong)",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
+ "./style": "./src/lumora.css",
11
+ "./tailwind": "./src/tailwind.ts",
10
12
  "./layout": "./src/layout/index.ts",
11
13
  "./shell": "./src/shell/index.ts",
12
14
  "./components": "./src/components/index.ts",
@@ -56,6 +58,12 @@
56
58
  "check": "vue-tsc -p ./tsconfig.json"
57
59
  },
58
60
  "peerDependencies": {
61
+ "tailwindcss": "^4.0.0",
59
62
  "vue": "^3.5.0"
63
+ },
64
+ "dependencies": {
65
+ "clsx": "^2.1.1",
66
+ "shiki": "^4.0.2",
67
+ "tailwind-merge": "^3.5.0"
60
68
  }
61
69
  }
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <div :class="resolvedSkin" role="alert">
3
+ <div v-if="$slots.icon || icon" :class="resolvedIconWrapperSkin">
4
+ <slot name="icon">
5
+ <LuIcon v-if="icon" :name="icon" />
6
+ </slot>
7
+ </div>
8
+ <div :class="resolvedContentSkin">
9
+ <slot />
10
+ </div>
11
+ <div v-if="$slots.action" :class="resolvedActionSkin">
12
+ <slot name="action" />
13
+ </div>
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { computed } from "vue";
19
+ import { useLumoraConfig } from "../context";
20
+ import LuIcon from "./LuIcon.vue";
21
+
22
+ const props = defineProps<{
23
+ variant?: string;
24
+ icon?: string;
25
+ }>();
26
+
27
+ const { resolveSkin } = useLumoraConfig();
28
+
29
+ const resolvedSkin = computed(() => resolveSkin("LuAlert", props.variant));
30
+ const resolvedIconWrapperSkin = computed(() => resolveSkin("LuAlertIcon", props.variant));
31
+ const resolvedContentSkin = computed(() => resolveSkin("LuAlertContent", props.variant));
32
+ const resolvedActionSkin = computed(() => resolveSkin("LuAlertAction", props.variant));
33
+ </script>
@@ -0,0 +1,63 @@
1
+ <template>
2
+ <nav aria-label="breadcrumb">
3
+ <ol :class="resolvedSkin">
4
+ <li v-for="(item, index) in items" :key="index" :class="resolvedItemSkin">
5
+ <LuLink
6
+ v-if="item.href || item.to"
7
+ :href="item.href"
8
+ :to="item.to"
9
+ :variant="item.current ? 'nav-active' : 'nav'"
10
+ :aria-current="item.current ? 'page' : undefined"
11
+ :class="resolvedLinkSkin"
12
+ >
13
+ <LuIcon v-if="item.icon" :name="item.icon" class="mr-2 h-4 w-4" />
14
+ {{ item.label }}
15
+ </LuLink>
16
+ <span
17
+ v-else
18
+ :class="resolvedPageSkin"
19
+ :aria-current="item.current ? 'page' : undefined"
20
+ >
21
+ <LuIcon v-if="item.icon" :name="item.icon" class="mr-2 h-4 w-4" />
22
+ {{ item.label }}
23
+ </span>
24
+ <LuIcon
25
+ v-if="index < items.length - 1"
26
+ :name="separatorIcon"
27
+ :class="resolvedSeparatorSkin"
28
+ />
29
+ </li>
30
+ </ol>
31
+ </nav>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ import { computed } from "vue";
36
+ import { useLumoraConfig } from "../context";
37
+ import LuLink from "./LuLink.vue";
38
+ import LuIcon from "./LuIcon.vue";
39
+
40
+ export interface LuBreadcrumbItem {
41
+ label: string;
42
+ href?: string;
43
+ to?: any;
44
+ current?: boolean;
45
+ icon?: string;
46
+ }
47
+
48
+ const props = withDefaults(defineProps<{
49
+ items: LuBreadcrumbItem[];
50
+ variant?: string;
51
+ separatorIcon?: string;
52
+ }>(), {
53
+ separatorIcon: 'chevron-right'
54
+ });
55
+
56
+ const { resolveSkin } = useLumoraConfig();
57
+
58
+ const resolvedSkin = computed(() => resolveSkin("LuBreadcrumb", props.variant));
59
+ const resolvedItemSkin = computed(() => resolveSkin("LuBreadcrumbItem", props.variant));
60
+ const resolvedLinkSkin = computed(() => resolveSkin("LuBreadcrumbLink", props.variant));
61
+ const resolvedPageSkin = computed(() => resolveSkin("LuBreadcrumbPage", props.variant));
62
+ const resolvedSeparatorSkin = computed(() => resolveSkin("LuBreadcrumbSeparator", props.variant));
63
+ </script>
@@ -7,14 +7,21 @@
7
7
  <script setup lang="ts">
8
8
  import { computed } from "vue";
9
9
  import { useLumoraConfig } from "../context";
10
+ import { resolveLayoutProps, cn } from "../utils";
10
11
 
11
12
  const props = withDefaults(defineProps<{
12
13
  variant?: string;
13
14
  as?: string;
15
+ padding?: string | number;
16
+ width?: string;
17
+ height?: string;
14
18
  }>(), {
15
19
  as: "div"
16
20
  });
17
21
 
18
22
  const { resolveSkin } = useLumoraConfig();
19
- const resolvedSkin = computed(() => resolveSkin("LuCard", props.variant));
23
+ const resolvedSkin = computed(() => cn(
24
+ resolveSkin("LuCard", props.variant),
25
+ resolveLayoutProps(props)
26
+ ));
20
27
  </script>
@@ -0,0 +1,94 @@
1
+ <template>
2
+ <div :class="resolvedContainerSkin">
3
+ <input
4
+ type="checkbox"
5
+ v-bind="$attrs"
6
+ :class="resolvedSkin"
7
+ :checked="modelValue"
8
+ :name="name"
9
+ :disabled="formContext?.disabled.value"
10
+ @change="onChange"
11
+ @blur="onBlur"
12
+ />
13
+ <label v-if="$slots.default || label" :class="resolvedLabelSkin" @click.prevent="toggle">
14
+ <slot>{{ label }}</slot>
15
+ </label>
16
+ </div>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { computed, inject, onMounted, onUnmounted, ref, watch } from "vue";
21
+ import { useLumoraConfig } from "../context";
22
+ import { LuFormContextKey } from "./LuForm.types";
23
+
24
+ const props = defineProps<{
25
+ modelValue?: boolean;
26
+ variant?: string;
27
+ name?: string;
28
+ label?: string;
29
+ error?: string | null;
30
+ }>();
31
+
32
+ const emit = defineEmits<{
33
+ (e: "update:modelValue", value: boolean): void;
34
+ (e: "change", value: boolean): void;
35
+ (e: "blur"): void;
36
+ }>();
37
+
38
+ const { resolveSkin } = useLumoraConfig();
39
+ const resolvedContainerSkin = computed(() => resolveSkin("LuCheckboxContainer", props.variant));
40
+ const resolvedSkin = computed(() => resolveSkin("LuCheckbox", props.variant));
41
+ const resolvedLabelSkin = computed(() => resolveSkin("LuCheckboxLabel", props.variant));
42
+
43
+ const formContext = inject(LuFormContextKey, null);
44
+ const internalValue = ref<boolean>(!!props.modelValue);
45
+
46
+ watch(() => props.modelValue, (newVal) => {
47
+ internalValue.value = !!newVal;
48
+ });
49
+
50
+ const onChange = (event: Event) => {
51
+ const target = event.target as HTMLInputElement;
52
+ const value = target.checked;
53
+ internalValue.value = value;
54
+ emit("update:modelValue", value);
55
+ emit("change", value);
56
+
57
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
58
+ // trigger validation handled by parent
59
+ }
60
+ };
61
+
62
+ const toggle = () => {
63
+ if (formContext?.disabled.value || ("disabled" in props && (props as any).disabled !== false)) return;
64
+ const newValue = !internalValue.value;
65
+ internalValue.value = newValue;
66
+ emit("update:modelValue", newValue);
67
+ emit("change", newValue);
68
+
69
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
70
+ // trigger validation handled by parent
71
+ }
72
+ };
73
+
74
+ const onBlur = () => {
75
+ if (props.name && formContext && (formContext.validateOn.value === "blur" || formContext.validateOn.value === "both")) {
76
+ // trigger validation handled by parent
77
+ }
78
+ emit("blur");
79
+ };
80
+
81
+ onMounted(() => {
82
+ if (!props.name || !formContext) return;
83
+ formContext.register({
84
+ name: props.name,
85
+ getValue: () => internalValue.value,
86
+ setValue: (v) => { internalValue.value = !!v; },
87
+ setError: (_msg) => {},
88
+ });
89
+ });
90
+
91
+ onUnmounted(() => {
92
+ if (props.name && formContext) formContext.unregister(props.name);
93
+ });
94
+ </script>
@@ -0,0 +1,168 @@
1
+ <template>
2
+ <div v-bind="$attrs" :class="resolvedSkin.container">
3
+ <div v-if="title || description" :class="resolvedSkin.header">
4
+ <h3 v-if="title" :class="resolvedSkin.title">{{ title }}</h3>
5
+ <p v-if="description" :class="resolvedSkin.description">{{ description }}</p>
6
+ </div>
7
+
8
+ <template v-if="variant === 'preview'">
9
+ <div :class="resolvedSkin.card">
10
+ <template v-if="layout === 'tabbed'">
11
+ <LuTabs v-model="activeTab" variant="default">
12
+ <LuTabList variant="card-header">
13
+ <LuTab value="preview">Preview</LuTab>
14
+ <LuTab value="code">Code</LuTab>
15
+ </LuTabList>
16
+ </LuTabs>
17
+
18
+ <div v-if="activeTab === 'preview'" :class="resolvedSkin.previewArea">
19
+ <slot name="preview" />
20
+ </div>
21
+
22
+ <div v-if="activeTab === 'code'" :class="resolvedSkin.codeArea">
23
+ <div :class="resolvedSkin.codeHeader">
24
+ <div :class="resolvedSkin.badge">{{ lang }}</div>
25
+ <button @click="copyCode" :class="resolvedSkin.copyButton" aria-label="Copy code">
26
+ <LuIcon :name="copied ? 'check' : 'copy'" class="w-4 h-4" />
27
+ </button>
28
+ </div>
29
+ <div :class="resolvedSkin.codeContent" v-html="html"></div>
30
+ </div>
31
+ </template>
32
+
33
+ <template v-else-if="layout === 'split'">
34
+ <div :class="resolvedSkin.splitContainer">
35
+ <div :class="resolvedSkin.previewArea">
36
+ <slot name="preview" />
37
+ </div>
38
+ <div :class="resolvedSkin.splitCodeArea">
39
+ <div :class="resolvedSkin.codeHeader">
40
+ <div :class="resolvedSkin.badge">{{ lang }}</div>
41
+ <button @click="copyCode" :class="resolvedSkin.copyButton" aria-label="Copy code">
42
+ <LuIcon :name="copied ? 'check' : 'copy'" class="w-4 h-4" />
43
+ </button>
44
+ </div>
45
+ <div :class="resolvedSkin.codeContent" v-html="html"></div>
46
+ </div>
47
+ </div>
48
+ </template>
49
+ </div>
50
+ </template>
51
+
52
+ <template v-else>
53
+ <div :class="resolvedSkin.card">
54
+ <div :class="resolvedSkin.codeArea">
55
+ <div :class="resolvedSkin.codeHeader">
56
+ <div :class="resolvedSkin.badge">{{ lang }}</div>
57
+ <button @click="copyCode" :class="resolvedSkin.copyButton" aria-label="Copy code">
58
+ <LuIcon :name="copied ? 'check' : 'copy'" class="w-4 h-4" />
59
+ </button>
60
+ </div>
61
+ <div :class="resolvedSkin.codeContent" v-html="html"></div>
62
+ </div>
63
+ </div>
64
+ </template>
65
+ </div>
66
+ </template>
67
+
68
+ <script lang="ts">
69
+ import { createHighlighter, type Highlighter } from 'shiki';
70
+
71
+ let globalHighlighter: Highlighter | null = null;
72
+ let highlighterPromise: Promise<Highlighter> | null = null;
73
+
74
+ async function getHighlighter() {
75
+ if (globalHighlighter) return globalHighlighter;
76
+ if (!highlighterPromise) {
77
+ highlighterPromise = createHighlighter({
78
+ themes: ['one-dark-pro'],
79
+ langs: ['bash', 'vue', 'ts', 'html', 'css', 'json']
80
+ });
81
+ }
82
+ globalHighlighter = await highlighterPromise;
83
+ return globalHighlighter;
84
+ }
85
+ </script>
86
+
87
+ <script setup lang="ts">
88
+ import { ref, watch, onMounted, computed } from "vue";
89
+ import { useLumoraConfig } from "../context";
90
+ import LuTabs from "./LuTabs.vue";
91
+ import LuTabList from "./LuTabList.vue";
92
+ import LuTab from "./LuTab.vue";
93
+ import LuIcon from "./LuIcon.vue";
94
+
95
+ const props = withDefaults(defineProps<{
96
+ code: string;
97
+ lang?: string;
98
+ variant?: "default" | "preview";
99
+ layout?: "tabbed" | "split";
100
+ title?: string;
101
+ description?: string;
102
+ }>(), {
103
+ lang: "vue",
104
+ variant: "default",
105
+ layout: "tabbed"
106
+ });
107
+
108
+ const { resolveSkin } = useLumoraConfig();
109
+ const resolvedSkin = computed(() => {
110
+ return {
111
+ container: resolveSkin("LuCodeBlock", "container"),
112
+ header: resolveSkin("LuCodeBlock", "header"),
113
+ title: resolveSkin("LuCodeBlock", "title"),
114
+ description: resolveSkin("LuCodeBlock", "description"),
115
+ card: resolveSkin("LuCodeBlock", "card"),
116
+ previewArea: resolveSkin("LuCodeBlock", "previewArea"),
117
+ codeArea: resolveSkin("LuCodeBlock", "codeArea"),
118
+ splitCodeArea: resolveSkin("LuCodeBlock", "splitCodeArea"),
119
+ splitContainer: resolveSkin("LuCodeBlock", "splitContainer"),
120
+ codeHeader: resolveSkin("LuCodeBlock", "codeHeader"),
121
+ badge: resolveSkin("LuCodeBlock", "badge"),
122
+ copyButton: resolveSkin("LuCodeBlock", "copyButton"),
123
+ codeContent: resolveSkin("LuCodeBlock", "codeContent"),
124
+ };
125
+ });
126
+
127
+ const html = ref('');
128
+ const activeTab = ref<'preview' | 'code'>('preview');
129
+ const copied = ref(false);
130
+
131
+ const highlight = async () => {
132
+ if (!props.code) {
133
+ html.value = '';
134
+ return;
135
+ }
136
+
137
+ try {
138
+ const highlighter = await getHighlighter();
139
+
140
+ html.value = highlighter.codeToHtml(props.code, {
141
+ lang: props.lang || 'vue',
142
+ theme: 'one-dark-pro'
143
+ });
144
+ } catch (e) {
145
+ console.error('Failed to highlight code', e);
146
+ // fallback to plain pre
147
+ html.value = `<pre><code>${props.code.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>`;
148
+ }
149
+ };
150
+
151
+ onMounted(() => {
152
+ highlight();
153
+ });
154
+
155
+ watch(() => [props.code, props.lang], () => {
156
+ highlight();
157
+ });
158
+
159
+ const copyCode = async () => {
160
+ try {
161
+ await navigator.clipboard.writeText(props.code);
162
+ copied.value = true;
163
+ setTimeout(() => copied.value = false, 2000);
164
+ } catch (err) {
165
+ console.error('Failed to copy', err);
166
+ }
167
+ };
168
+ </script>
@@ -1,5 +1,23 @@
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"
@@ -29,6 +47,8 @@ const emit = defineEmits<{
29
47
 
30
48
  const { resolveSkin } = useLumoraConfig();
31
49
  const resolvedSkin = computed(() => resolveSkin("LuInput", props.variant));
50
+ const prependSkin = computed(() => resolveSkin("LuInputPrepend", props.variant));
51
+ const appendSkin = computed(() => resolveSkin("LuInputAppend", props.variant));
32
52
 
33
53
  const formContext = inject(LuFormContextKey, null);
34
54
  const internalValue = ref<string | number | undefined>(props.modelValue);
@@ -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>