Skip to content

Commit 701c75a

Browse files
feat(module): devtools integration (nuxt#2196)
Co-authored-by: Benjamin Canac <[email protected]>
1 parent 7fc6b38 commit 701c75a

File tree

100 files changed

+2062
-59
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

100 files changed

+2062
-59
lines changed

.github/workflows/ci-v3.yml

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ jobs:
4343
- name: Prepare
4444
run: pnpm run dev:prepare
4545

46+
- name: Devtools prepare
47+
run: pnpm run devtools:prepare
48+
4649
- name: Lint
4750
run: pnpm run lint
4851

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
.component-meta/
2+
component-meta.*
3+
14
# Nuxt dev/build outputs
25
.output
36
.data

build.config.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import { defineBuildConfig } from 'unbuild'
22

33
export default defineBuildConfig({
44
entries: [
5+
// Include devtools runtime files
6+
{ input: './src/devtools/runtime', builder: 'mkdist', outDir: 'dist/devtools/runtime' },
7+
// Vue support
58
'./src/unplugin',
69
'./src/vite'
710
],
811
rollup: {
912
emitCJS: true
1013
},
1114
replace: {
12-
'process.env.DEV': 'false'
15+
'process.env.DEV': 'false',
16+
'process.env.NUXT_UI_DEVTOOLS_LOCAL': 'false'
1317
},
1418
hooks: {
1519
'mkdist:entry:options'(ctx, entry, options) {

devtools/app/app.config.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default defineAppConfig({
2+
ui: {
3+
colors: {
4+
primary: 'green',
5+
neutral: 'zinc'
6+
}
7+
}
8+
})

devtools/app/app.vue

+222
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
<script setup lang="ts">
2+
import type { Component } from '../../src/devtools/meta'
3+
import { watchDebounced } from '@vueuse/core'
4+
5+
// Disable devtools in component renderer iframe
6+
// @ts-expect-error - Nuxt Devtools internal value
7+
window.__NUXT_DEVTOOLS_DISABLE__ = true
8+
9+
const component = useState<Component | undefined>('__ui-devtools-component')
10+
const state = useState<Record<string, any>>('__ui-devtools-state', () => ({}))
11+
12+
const { data: components, status, error } = useAsyncData<Array<Component>>('__ui-devtools-components', async () => {
13+
const componentMeta = await $fetch<Record<string, Component>>('/api/component-meta')
14+
15+
if (!component.value || !componentMeta[component.value.slug]) {
16+
component.value = componentMeta['button']
17+
}
18+
19+
state.value.props = Object.values(componentMeta).reduce((acc, comp) => {
20+
const componentDefaultProps = comp.meta?.props.reduce((acc, prop) => {
21+
if (prop.default) acc[prop.name] = prop.default
22+
return acc
23+
}, {} as Record<string, any>)
24+
25+
acc[comp.slug] = {
26+
...comp.defaultVariants, // Default values from the theme template
27+
...componentDefaultProps, // Default values from vue props
28+
...componentMeta[comp.slug]?.meta?.devtools?.defaultProps // Default values from devtools extended meta
29+
}
30+
31+
return acc
32+
}, {} as Record<string, any>)
33+
34+
return Object.values(componentMeta)
35+
})
36+
37+
const componentProps = computed(() => {
38+
if (!component.value) return
39+
return state.value.props[component.value?.slug]
40+
})
41+
42+
const componentPropsMeta = computed(() => {
43+
return component.value?.meta?.props.filter(prop => prop.name !== 'ui').sort((a, b) => a.name.localeCompare(b.name))
44+
})
45+
46+
function updateRenderer() {
47+
if (!component.value) return
48+
const event: Event & { data?: any } = new Event('nuxt-ui-devtools:update-renderer')
49+
event.data = {
50+
props: state.value.props?.[component.value.slug], slots: state.value.slots?.[component.value?.slug]
51+
}
52+
window.dispatchEvent(event)
53+
}
54+
55+
watchDebounced(state, updateRenderer, { deep: true, debounce: 200, maxWait: 500 })
56+
onMounted(() => window.addEventListener('nuxt-ui-devtools:component-loaded', onComponentLoaded))
57+
onUnmounted(() => window.removeEventListener('nuxt-ui-devtools:component-loaded', onComponentLoaded))
58+
59+
function onComponentLoaded() {
60+
if (!component.value) return
61+
updateRenderer()
62+
}
63+
64+
const tabs = computed(() => {
65+
if (!component.value) return
66+
return [
67+
{ label: 'Props', slot: 'props', icon: 'i-heroicons-cog-6-tooth', disabled: !component.value.meta?.props?.length }
68+
]
69+
})
70+
71+
function openDocs() {
72+
if (!component.value) return
73+
window.parent.open(`https://ui3.nuxt.dev/components/${component.value.slug}`)
74+
}
75+
76+
const colorMode = useColorMode()
77+
const isDark = computed({
78+
get() {
79+
return colorMode.value === 'dark'
80+
},
81+
set(value) {
82+
colorMode.preference = value ? 'dark' : 'light'
83+
84+
const event: Event & { isDark?: boolean } = new Event('nuxt-ui-devtools:set-color-mode')
85+
event.isDark = value
86+
window.dispatchEvent(event)
87+
}
88+
})
89+
</script>
90+
91+
<template>
92+
<UApp class="flex justify-center items-center h-screen w-full relative font-sans">
93+
<div v-if="status === 'pending' || error || !component || !components?.length">
94+
<div v-if="error" class="flex flex-col justify-center items-center h-screen w-screen text-center text-[var(--ui-color-error-500)]">
95+
<UILogo class="h-8" />
96+
<UIcon name="i-heroicons-exclamation-circle" size="20" class="mt-2" />
97+
<p>
98+
{{ (error.data as any)?.error ?? 'Unexpected error' }}
99+
</p>
100+
</div>
101+
</div>
102+
<template v-else>
103+
<div
104+
class="top-0 h-[49px] border-b border-[var(--ui-border)] flex justify-center"
105+
>
106+
<span />
107+
108+
<UInputMenu
109+
v-model="component"
110+
variant="none"
111+
:items="components"
112+
placeholder="Search component..."
113+
class="top-0 translate-y-0 w-full mx-2"
114+
icon="i-heroicons-magnifying-glass"
115+
/>
116+
117+
<div class="absolute top-[49px] bottom-0 inset-x-0 grid xl:grid-cols-8 grid-cols-4 bg-[var(--ui-bg)]">
118+
<div class="col-span-1 border-r border-[var(--ui-border)] hidden xl:block overflow-y-auto">
119+
<UNavigationMenu
120+
:items="components.map((c) => ({ ...c, active: c.slug === component?.slug, onSelect: () => component = c }))"
121+
orientation="vertical"
122+
:ui="{ link: 'before:rounded-none' }"
123+
/>
124+
</div>
125+
126+
<div class="xl:col-span-5 col-span-2 relative">
127+
<ComponentPreview :component="component" :props="componentProps" class="h-full" />
128+
<div class="flex gap-2 absolute top-1 right-2">
129+
<UButton
130+
:icon="isDark ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'"
131+
variant="ghost"
132+
color="neutral"
133+
@click="isDark = !isDark"
134+
/>
135+
<UButton
136+
v-if="component"
137+
variant="ghost"
138+
color="neutral"
139+
icon="i-heroicons-arrow-top-right-on-square"
140+
@click="openDocs()"
141+
>
142+
Open docs
143+
</UButton>
144+
</div>
145+
</div>
146+
147+
<div class="border-l border-[var(--ui-border)] flex flex-col col-span-2 overflow-y-auto">
148+
<UTabs color="neutral" variant="link" :items="tabs" class="relative" :ui="{ list: 'sticky top-0 bg-[var(--ui-bg)] z-50' }">
149+
<template #props>
150+
<div v-for="prop in componentPropsMeta" :key="'prop-' + prop.name" class="px-3 py-5 border-b border-[var(--ui-border)]">
151+
<ComponentPropInput
152+
v-model="componentProps[prop.name]"
153+
:meta="prop"
154+
:ignore="component.meta?.devtools?.ignoreProps?.includes(prop.name)"
155+
/>
156+
</div>
157+
</template>
158+
</UTabs>
159+
</div>
160+
</div>
161+
</div>
162+
</template>
163+
</UApp>
164+
</template>
165+
166+
<style>
167+
@import 'tailwindcss';
168+
@import '@nuxt/ui';
169+
170+
@theme {
171+
--font-family-sans: 'DM Sans', sans-serif;
172+
173+
--color-primary-50: var(--ui-color-primary-50);
174+
--color-primary-100: var(--ui-color-primary-100);
175+
--color-primary-200: var(--ui-color-primary-200);
176+
--color-primary-300: var(--ui-color-primary-300);
177+
--color-primary-400: var(--ui-color-primary-400);
178+
--color-primary-500: var(--ui-color-primary-500);
179+
--color-primary-600: var(--ui-color-primary-600);
180+
--color-primary-700: var(--ui-color-primary-700);
181+
--color-primary-800: var(--ui-color-primary-800);
182+
--color-primary-900: var(--ui-color-primary-900);
183+
--color-primary-950: var(--ui-color-primary-950);
184+
185+
--color-neutral-50: var(--ui-color-neutral-50);
186+
--color-neutral-100: var(--ui-color-neutral-100);
187+
--color-neutral-200: var(--ui-color-neutral-200);
188+
--color-neutral-300: var(--ui-color-neutral-300);
189+
--color-neutral-400: var(--ui-color-neutral-400);
190+
--color-neutral-500: var(--ui-color-neutral-500);
191+
--color-neutral-600: var(--ui-color-neutral-600);
192+
--color-neutral-700: var(--ui-color-neutral-700);
193+
--color-neutral-800: var(--ui-color-neutral-800);
194+
--color-neutral-900: var(--ui-color-neutral-900);
195+
--color-neutral-950: var(--ui-color-neutral-950);
196+
}
197+
198+
:root {
199+
--ui-border: var(--ui-color-neutral-200);
200+
--ui-bg: white;
201+
}
202+
203+
.dark {
204+
--ui-border: var(--ui-color-neutral-800);
205+
--ui-bg: var(--ui-color-neutral-900);
206+
}
207+
208+
.shiki
209+
.shiki span {
210+
background-color: transparent !important;
211+
}
212+
213+
html.dark .shiki,
214+
html.dark .shiki span {
215+
color: var(--shiki-dark) !important;
216+
background-color: transparent !important;
217+
/* Optional, if you also want font styles */
218+
font-style: var(--shiki-dark-font-style) !important;
219+
font-weight: var(--shiki-dark-font-weight) !important;
220+
text-decoration: var(--shiki-dark-text-decoration) !important;
221+
}
222+
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import { ref, computed, onMounted } from 'vue'
3+
4+
const collapsed = ref(true)
5+
const wrapper = ref<HTMLElement | null>(null)
6+
const content = ref<HTMLElement | null>(null)
7+
8+
const overflow = computed(() => {
9+
if (!content.value || !wrapper.value) return false
10+
return content.value.scrollHeight > 48 * 4
11+
})
12+
13+
onMounted(() => {
14+
if (wrapper.value) {
15+
wrapper.value.style.transition = 'max-height 0.3s ease' // Set transition for max-height
16+
}
17+
})
18+
</script>
19+
20+
<template>
21+
<div class="border rounded border-[var(--ui-border)]">
22+
<div
23+
ref="wrapper"
24+
:class="['overflow-hidden', collapsed && overflow ? 'max-h-48' : 'max-h-none']"
25+
>
26+
<div ref="content">
27+
<slot />
28+
</div>
29+
</div>
30+
<UButton
31+
v-if="overflow"
32+
class="bg-[var(--ui-bg)] group w-full flex justify-center my-1 border-t border-[var(--ui-border)] rounded-t-none"
33+
variant="link"
34+
color="neutral"
35+
trailing-icon="i-heroicons-chevron-down"
36+
:data-state="collapsed ? 'closed' : 'open'"
37+
:ui="{ trailingIcon: 'transition group-data-[state=open]:rotate-180' }"
38+
@click="collapsed = !collapsed"
39+
>
40+
{{ collapsed ? 'Expand' : 'Collapse' }}
41+
</UButton>
42+
</div>
43+
</template>

0 commit comments

Comments
 (0)