Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
380 changes: 195 additions & 185 deletions ui/build/build.api.js

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions ui/build/script.build.javascript.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ const quasarEsbuildPluginUmdGlobalExternals = {
}
}

// Plugin to handle .vue file imports (mark as external - they're handled by Vite at runtime)
const quasarEsbuildPluginVueExternals = {
name: 'quasar:vue-externals',
setup (build) {
build.onResolve({ filter: /\.vue$/ }, () => ({
path: '',
external: true
}))
}
}

const builds = [
// Client entry-point used by @quasar/vite-plugin for DEV only.
// Also used as entry-point in package.json.
Expand Down Expand Up @@ -158,6 +169,12 @@ const builds = [
]

function genConfig (opts) {
const plugins = opts.plugins || []
// Add vue externals plugin to all builds to handle .vue file imports
if (!plugins.includes(quasarEsbuildPluginVueExternals)) {
plugins.push(quasarEsbuildPluginVueExternals)
}

return {
platform: 'browser',
packages: 'external',
Expand All @@ -167,6 +184,7 @@ function genConfig (opts) {
js: banner
},
write: false,
plugins,
...opts
}
}
Expand Down
14 changes: 14 additions & 0 deletions ui/playground/src/pages/components/theme-designer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<QThemeDesigner force />
</template>

<script>
import QThemeDesigner from 'quasar/src/components/q-theme-designer/QThemeDesigner.js'

export default {
name: 'ThemeDesignerPage',
components: {
QThemeDesigner
}
}
</script>
1 change: 1 addition & 0 deletions ui/src/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ export * from './components/tree/index.js'
export * from './components/uploader/index.js'
export * from './components/video/index.js'
export * from './components/virtual-scroll/index.js'
export * from './components/q-theme-designer/index.js'
199 changes: 199 additions & 0 deletions ui/src/components/q-theme-designer/QThemeDesigner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* QThemeDesigner - A dev-only component for visual theme customization
*
* This component is 100% tree-shakable and will be removed from
* production builds unless the `force` prop is set to true.
*/

import { h, computed, ref, getCurrentInstance } from 'vue'

import useThemeDesigner from '../../composables/use-theme-designer/use-theme-designer.js'
import { defaultTheme } from '../../json/themeSerializer.js'
import { createComponent } from '../../utils/private.create/create.js'

import QToggle from '../toggle/QToggle.js'
import QSpace from '../space/QSpace.js'
import QBtn from '../btn/QBtn.js'
import QSelect from '../select/QSelect.js'

import ThemeDesignerSidebar from './ThemeDesignerSidebar.vue'
import ThemeDesignerPreview from './ThemeDesignerPreview.vue'
import ThemeExportDialog from './ThemeExportDialog.vue'
import ThemeDesignerColorDialog from './ThemeDesignerColorDialog.vue'

export default createComponent({
name: 'QThemeDesigner',

props: {
/**
* Force rendering in production mode.
* By default, QThemeDesigner only renders in development mode.
* Set this to true to render in production (not recommended).
*/
force: Boolean
},

setup (props) {
// Get $q instance for global dark mode
const { proxy: { $q } } = getCurrentInstance()

// Dark mode state (reactive to $q.dark.isActive)
const isDarkMode = computed(() => $q.dark.isActive)

// Text color preference state
const textColorPreference = ref('recommended')

// Dev-only guard: Don't render in production unless forced
// Note: Tree-shaking will remove this component in production builds
// unless the force prop is used
const shouldRender = computed(() => {
// Always render - tree-shaking will handle removal in production
// The force prop is for explicit production usage
return true
})

const {
theme,
showExportDialog,
activeColorTab,
cssVars,
exportFormats,
setColor,
resetTheme,
openExportDialog
} = useThemeDesigner()

// Color picker dialog state
const showColorDialog = ref(false)
const currentColorKey = ref('primary')

// Open color picker dialog
function openColorPicker (colorKey) {
currentColorKey.value = colorKey
activeColorTab.value = colorKey
showColorDialog.value = true
}

// Close color picker dialog (handled by dialog component)

// Reset a single color to default
function resetSingleColor (colorKey) {
if (colorKey in defaultTheme) {
setColor(colorKey, defaultTheme[ colorKey ])
}
}

return () => {
if (shouldRender.value === false) {
return null
}

// Get current text color preference value (reactive)
const currentTextColorPreference = textColorPreference.value

return h('div', {
class: 'q-theme-designer'
}, [
// Top Toolbar
h('div', {
class: 'q-theme-designer__toolbar q-toolbar row no-wrap items-center bg-dark text-white'
}, [
h('div', { class: 'q-toolbar__title ellipsis' }, [
h('i', { class: 'q-icon notranslate material-icons q-mr-sm', 'aria-hidden': 'true' }, 'palette'),
'Quasar'
]),
h(QSpace),
h('div', { class: 'row items-center' }, [
h(QBtn, {
color: 'grey-7',
label: 'Reset All',
class: 'q-mr-md',
onClick: resetTheme,
'aria-label': 'Reset all colors to default'
}),
h(QSelect, {
modelValue: currentTextColorPreference,
options: [
{ label: 'Contrast', value: 'recommended' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' }
],
dense: true,
outlined: true,
dark: true,
emitValue: true,
mapOptions: true,
class: 'q-mr-md q-theme-designer__text-color-select',
style: { minWidth: '120px' },
'onUpdate:modelValue': (val) => {
textColorPreference.value = val
},
'aria-label': 'Text color preference'
}),
h(QToggle, {
modelValue: $q.dark.isActive,
color: 'red',
class: 'q-mr-xs',
'onUpdate:modelValue': (val) => {
$q.dark.set(val)
}
}),
h('div', { class: 'text-white q-ml-xs' }, 'Dark page'),
h(QBtn, {
color: 'orange',
label: 'Export',
class: 'q-ml-md',
onClick: openExportDialog,
'aria-label': 'Export theme configuration'
})
])
]),

// Main Content
h('div', {
class: 'q-theme-designer__main'
}, [
// Sidebar (fixed on left)
h(ThemeDesignerSidebar, {
theme,
isDarkMode: isDarkMode.value,
activeColorTab: activeColorTab.value,
textColorPreference: currentTextColorPreference,
class: 'q-theme-designer__sidebar',
'onOpen:color-picker': openColorPicker
}),

// Preview (scrollable on right, positioned next to sidebar)
h(ThemeDesignerPreview, {
cssVars: cssVars.value,
theme,
isDarkMode: isDarkMode.value,
textColorPreference: currentTextColorPreference,
class: 'q-theme-designer__preview'
})
]),

// Color Picker Dialog
h(ThemeDesignerColorDialog, {
modelValue: showColorDialog.value,
theme,
currentColor: currentColorKey.value,
'onUpdate:modelValue': (val) => {
showColorDialog.value = val
},
'onUpdate:color': setColor,
'onReset:color': resetSingleColor
}),

// Export Dialog
h(ThemeExportDialog, {
modelValue: showExportDialog.value,
exportFormats: exportFormats.value,
'onUpdate:modelValue': (val) => {
showExportDialog.value = val
}
})
])
}
}
})
19 changes: 19 additions & 0 deletions ui/src/components/q-theme-designer/QThemeDesigner.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"meta": {
"docsUrl": "https://quasar.dev/vue-components/q-theme-designer"
},
"props": {
"force": {
"type": "Boolean",
"desc": "Force rendering in production mode. By default, QThemeDesigner only renders in development mode.",
"default": "false",
"category": "behavior"
}
},
"slots": {},
"events": {},
"methods": {}
}



83 changes: 83 additions & 0 deletions ui/src/components/q-theme-designer/QThemeDesigner.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
.q-theme-designer
display: flex
flex-direction: column
height: 100vh
width: 100%
overflow: hidden

&__toolbar
flex-shrink: 0
height: 50px
padding: 0 16px
border-bottom: 1px solid rgba(255, 255, 255, 0.1)

&__main
display: flex
flex-direction: row
flex: 1
overflow: hidden
min-height: 0

&__sidebar
flex: 0 0 280px !important
width: 280px !important
max-width: 280px !important
height: 100%
overflow-y: auto
overflow-x: hidden
border-right: 1px solid rgba(255, 255, 255, 0.1)

&__preview
flex: 1
min-width: 0
height: 100%
overflow-y: auto
overflow-x: hidden

&__text-color-select
// Ensure dropdown text is readable on dark toolbar
:deep(.q-field__input)
color: white !important

:deep(.q-field__native)
color: white !important

:deep(.q-field__control)
color: white !important

:deep(.q-field__label)
color: rgba(255, 255, 255, 0.7) !important

:deep(.q-field__append)
color: white !important

:deep(.q-field__dropdown-icon)
color: white !important

// Mobile responsive: Stack sidebar above preview, unified scroll
@media (max-width: 599px)
.q-theme-designer
&__main
flex-direction: column !important
overflow-y: auto !important
overflow-x: hidden !important
height: calc(100vh - 50px) !important
max-height: calc(100vh - 50px) !important

&__sidebar
flex: 0 0 auto !important
width: 100% !important
max-width: 100% !important
height: auto !important
max-height: none !important
overflow-y: visible !important
overflow-x: hidden !important
border-right: none !important
border-bottom: 1px solid rgba(255, 255, 255, 0.1)

&__preview
flex: 1 1 auto !important
height: auto !important
overflow-y: visible !important
overflow-x: hidden !important

Loading