diff --git a/docs/app/components/content/examples/dropdown-menu/DropdownMenuMatchContentWidthWithTriggerWidthExample.vue b/docs/app/components/content/examples/dropdown-menu/DropdownMenuMatchContentWidthWithTriggerWidthExample.vue new file mode 100644 index 0000000000..be2b19d187 --- /dev/null +++ b/docs/app/components/content/examples/dropdown-menu/DropdownMenuMatchContentWidthWithTriggerWidthExample.vue @@ -0,0 +1,40 @@ + + + diff --git a/docs/content/docs/2.components/dropdown-menu.md b/docs/content/docs/2.components/dropdown-menu.md index 4de2512267..b4b4928167 100644 --- a/docs/content/docs/2.components/dropdown-menu.md +++ b/docs/content/docs/2.components/dropdown-menu.md @@ -382,6 +382,19 @@ defineShortcuts(extractShortcuts(items)) In this example, :kbd{value="meta"} :kbd{value="E"}, :kbd{value="meta"} :kbd{value="I"} and :kbd{value="meta"} :kbd{value="N"} would trigger the `select` function of the corresponding item. :: +### Match content & trigger width + +You can set `--reka-dropdown-menu-trigger-width` css variable as content width to match the trigger button width. + +::component-example +--- + +collapse: true +name: 'dropdown-menu-match-content-width-with-trigger-width-example' +--- + +:: + ## API ### Props diff --git a/package.json b/package.json index cd33665041..9fa61e2a9e 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,8 @@ ], "scripts": { "build": "nuxt-module-build build", + "build:repl:prepare": "node scripts/generate-repl-exports.mjs", + "build:repl": "pnpm build:repl:prepare && vite build -c vite.repl.config.ts", "prepack": "pnpm build", "dev": "nuxt dev playgrounds/nuxt --uiDev", "dev:build": "nuxt build playgrounds/nuxt", @@ -171,7 +173,8 @@ "release-it": "^19.0.4", "vitest": "^3.2.4", "vitest-environment-nuxt": "^1.0.1", - "vue-tsc": "^3.0.6" + "vue-tsc": "^3.0.6", + "@vitejs/plugin-vue": "^5.1.4" }, "peerDependencies": { "@inertiajs/vue3": "^2.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93af274b0d..ed8f52c5d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,9 @@ importers: '@release-it/conventional-changelog': specifier: ^10.0.1 version: 10.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.2.0)(release-it@19.0.4(@types/node@24.0.7)(magicast@0.3.5)) + '@vitejs/plugin-vue': + specifier: ^5.1.4 + version: 5.2.4(vite@7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -2781,6 +2784,13 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 vue: ^3.0.0 + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitejs/plugin-vue@6.0.1': resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10335,6 +10345,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@5.2.4(vite@7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3))': + dependencies: + vite: 7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0) + vue: 3.5.21(typescript@5.8.3) + '@vitejs/plugin-vue@6.0.1(vite@7.0.6(@types/node@24.0.7)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(yaml@2.8.0))(vue@3.5.21(typescript@5.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 diff --git a/scripts/generate-repl-exports.mjs b/scripts/generate-repl-exports.mjs new file mode 100644 index 0000000000..4eb3601031 --- /dev/null +++ b/scripts/generate-repl-exports.mjs @@ -0,0 +1,45 @@ +import { writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join, resolve } from 'pathe' +import { pascalCase } from 'scule' +import { glob } from 'tinyglobby' + +const rootDir = dirname(fileURLToPath(import.meta.url)) +const r = (...p) => resolve(rootDir, '..', ...p) + +const componentsDir = 'src/runtime/components' +const outFile = r('src/repl/components.generated.ts') + +// Collect only top-level .vue files (no recursion) to exclude typography/prose etc. +const files = (await glob('*.vue', { cwd: componentsDir, absolute: false })).sort() + +// Map base component name -> relative path (prefer shallower path if duplicate) +const seen = new Map() +for (const rel of files) { + const baseFile = rel.split('/').pop() + if (!baseFile) continue + const rawBase = baseFile.replace(/\.vue$/i, '') + // Convert to PascalCase in case filenames have dashes (e.g., page-hero.vue) + const base = pascalCase(rawBase) + // All are depth 1 in this mode; simply record if not already present + if (seen.has(base)) continue + seen.set(base, { rel, depth: 1 }) +} + +const names = Array.from(seen.keys()).sort() +const lines = [ + '// AUTO-GENERATED FILE. DO NOT EDIT.', + '// Generated by scripts/generate-repl-exports.mjs', + '' +] +for (const base of names) { + if (base.startsWith('_')) continue + const exportName = 'U' + base + const rel = seen.get(base).rel + const relPath = join('..', 'runtime', 'components', rel) + lines.push(`export { default as ${exportName} } from ${JSON.stringify(relPath)}`) +} +lines.push('') + +writeFileSync(outFile, lines.join('\n'), 'utf8') +console.log(`Generated ${outFile} (top-level only) with ${names.length} component exports (scanned ${files.length} files).`) diff --git a/src/repl/app.config.ts b/src/repl/app.config.ts new file mode 100644 index 0000000000..f307d9eb66 --- /dev/null +++ b/src/repl/app.config.ts @@ -0,0 +1,15 @@ +// Minimal app.config used by Vue REPL builds +// Mirrors a tiny subset of Nuxt appConfig consumed by useAppConfig and components +const appConfig = { + ui: { + icons: { + dark: 'i-lucide-moon', + light: 'i-lucide-sun' + } + }, + colorMode: { + preference: 'system' + } +} + +export default appConfig diff --git a/src/repl/components.generated.ts b/src/repl/components.generated.ts new file mode 100644 index 0000000000..f494aad8ef --- /dev/null +++ b/src/repl/components.generated.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated by scripts/generate-repl-exports.mjs diff --git a/src/repl/image-component.ts b/src/repl/image-component.ts new file mode 100644 index 0000000000..d4ffeda342 --- /dev/null +++ b/src/repl/image-component.ts @@ -0,0 +1,13 @@ +import { defineComponent, h } from 'vue' + +// Minimal image component stub for REPL builds +export default defineComponent({ + name: 'UiImageStub', + props: { + src: { type: String, default: '' }, + alt: { type: String, default: '' } + }, + setup(props, { attrs }) { + return () => h('img', { ...attrs, src: props.src, alt: props.alt }) + } +}) diff --git a/src/repl/index.ts b/src/repl/index.ts new file mode 100644 index 0000000000..0d8c3001c7 --- /dev/null +++ b/src/repl/index.ts @@ -0,0 +1,12 @@ +// ESM entry for Vue REPL usage +// Expose a curated set of Vue-compatible components and utilities. + +// Vue overrides live under runtime/vue/components +// Generated top-level component exports +export * from './components.generated' + +// Minimal composables often used in examples +export { useAppConfig } from '../runtime/vue/composables/useAppConfig' + +// Types re-export for TS users in REPL-like setups (optional) +export type * from '../runtime/types' diff --git a/vite.repl.config.ts b/vite.repl.config.ts new file mode 100644 index 0000000000..5ed49bbfad --- /dev/null +++ b/vite.repl.config.ts @@ -0,0 +1,81 @@ +// This Vite config builds a single-file ESM bundle that can be loaded by the Vue REPL. +// The REPL expects a plain ES module URL and already provides its own Vue runtime. +// Our goal here is to: +// - Compile our .vue Single File Components into JavaScript (via @vitejs/plugin-vue) +// - Stub/alias Nuxt-only virtual imports so the code can run outside Nuxt +// - Produce dist/repl-esm/index.js that you can host and reference in an import map +import vue from '@vitejs/plugin-vue' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'pathe' +import { defineConfig } from 'vite' + +const rootDir = dirname(fileURLToPath(import.meta.url)) +const r = (...p: string[]) => join(rootDir, ...p) + +export default defineConfig({ + // Plugins run in order. + // - emptyTheme(): treats all `#build/ui/*` imports as an empty theme object. + // - vue(): compiles .vue files. + plugins: [emptyTheme(), vue()], + resolve: { + alias: [ + // Nuxt auto-imports (`#imports`) don't exist in a plain Vite build. + // Point them to a small stub that provides minimal replacements used by our components. + { find: '#imports', replacement: r('src/runtime/vue/stubs.ts') }, + + // The library uses `useAppConfig()` which reads from `#build/app.config` in Nuxt. + // For REPL builds, we provide a tiny `app.config.ts` with the few values our examples need. + { find: '#build/app.config', replacement: r('src/repl/app.config.ts') }, + + // Some components import an image component via a virtual module. + // In the REPL, we alias it to a simple wrapper. + { find: '#build/ui-image-component', replacement: r('src/repl/image-component.ts') } + ] + }, + build: { + lib: { + // A small, curated entry that re-exports only the components and utilities + // we want to expose in the REPL (see src/repl/index.ts). + entry: r('src/repl/index.ts'), + // We only need ESM for the Vue REPL. + formats: ['es'], + // Create an easy-to-reference file name. + fileName: () => 'index.js', + // UMD name is unused for ESM, but Vite requires a name in lib mode. + name: 'NuxtUiRepl' + }, + // Output folder for the REPL bundle. + outDir: r('dist/repl-esm'), + // Clean the folder before each build. + emptyOutDir: true, + rollupOptions: { + // IMPORTANT: Do not bundle Vue. The Vue REPL provides its own Vue runtime. + // Marking it external keeps our bundle lightweight and avoids version conflicts. + external: ['vue'], + output: { + globals: { vue: 'Vue' } + } + } + } +}) + +// Simplest possible theme handling for REPL builds: +// We do NOT expose theme customization and we don't run Nuxt's code generator. +// Map ALL `#build/ui/*` imports to a single virtual module returning an empty theme +// object with the minimal shape expected by components that call tv(theme). +// This eliminates the need for any files under src/repl/theme. +export function emptyTheme() { + const VIRTUAL_ID = '\0nuxt-ui-empty-theme' + const THEME_CODE = 'export default { base: "", variants: {} }\n' + return { + name: 'nuxt-ui:repl-empty-theme', + resolveId(id: string) { + if (/^#build\/ui\//.test(id)) return VIRTUAL_ID + return null + }, + load(id: string) { + if (id === VIRTUAL_ID) return THEME_CODE + return null + } + } +}