diff --git a/docs/src/pages/vue-components/dashboard-panel.md b/docs/src/pages/vue-components/dashboard-panel.md new file mode 100644 index 00000000000..7ce83bea7d5 --- /dev/null +++ b/docs/src/pages/vue-components/dashboard-panel.md @@ -0,0 +1,218 @@ +--- +title: Dashboard Panel +desc: The QDashboardPanel Vue component provides a resizable, collapsible, and locally-persisted dashboard panel that extends QCard, perfect for building dashboard layouts. +keys: QDashboardPanel +examples: QDashboardPanel +related: + - /vue-components/card + - /vue-components/splitter + - /vue-composables/use-dashboard-panels +--- + +The QDashboardPanel component is a resizable, collapsible, locally-persisted dashboard panel that extends QCard. It's perfect for building dashboard layouts where users can customize panel sizes and states, with automatic persistence to localStorage. + +## Features + +- **Resizable**: Drag the right edge to resize the panel width +- **Collapsible**: Toggle to hide/show body and footer content +- **Maximizable**: Double-click the resize handle to maximize/restore +- **Persisted**: Panel size and state are automatically saved to localStorage +- **Responsive**: Full width on mobile with resize disabled +- **Accessible**: Keyboard navigation and ARIA attributes on resize handle +- **Extends QCard**: Inherits all QCard styling props (dark, flat, bordered, square) + + + +## Usage + +### Basic + +A simple dashboard panel with header, body, and footer slots. + + + +### Two Panels Side by Side + +Create a dashboard layout with multiple resizable panels. + + + +### Collapsed State + +Panels can be collapsed to show only the header. + + + +### Maximized State + +Double-click the resize handle to maximize, double-click again to restore. + + + +### Custom Resize Handle + +Provide a custom resize handle via the `resize-handle` slot. + + + +### Different Units + +Size can be specified in `%`, `px`, or `rem`. + + + +### Group Storage with useDashboardPanels + +Use the `useDashboardPanels` composable for centralized state management across multiple panels. + + + +### Responsive Behavior + +On mobile (xs and sm breakpoints), panels automatically become full width and resize is disabled. + + + +### With Header Actions + +Add collapse/maximize buttons to the header using scoped slot props. + + + +## API + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `id` | `String` | — (required) | Unique identifier for persistence | +| `resizable` | `Boolean` | `true` | Enable/disable resize behavior | +| `default-size` | `Number` | `35` | Initial width in specified unit | +| `min-size` | `Number` | `15` | Minimum allowed width | +| `max-size` | `Number` | `100` | Maximum allowed width | +| `collapsed` | `Boolean` | `false` | Collapsed state (hides body/footer) | +| `maximized` | `Boolean` | `false` | Maximized state (full width) | +| `unit` | `String` | `'%'` | Size unit: `'%'`, `'px'`, or `'rem'` | +| `storage` | `String` | `'local'` | Storage strategy: `'local'` or `'group'` | +| `disable-on-mobile` | `Boolean` | `true` | Disable resize on xs/sm breakpoints | +| `keyboard-increment` | `Number` | `1` | Size change per arrow key press | + +Plus all QCard props: `dark`, `square`, `flat`, `bordered`, `tag`. + +### Slots + +| Slot | Scoped Props | Description | +|------|--------------|-------------| +| `default` | — | Main content (hidden when collapsed) | +| `header` | `{ collapsed, maximized, toggleCollapsed, toggleMaximized }` | Header content (always visible) | +| `body` | — | Body content (hidden when collapsed) | +| `footer` | — | Footer content (hidden when collapsed) | +| `resize-handle` | `{ onMousedown, onTouchstart, onDblclick, onKeydown, disabled, size, unit, minSize, maxSize }` | Custom resize handle | + +### Events + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:size` | `Number` | Size changed | +| `update:collapsed` | `Boolean` | Collapsed state changed | +| `update:maximized` | `Boolean` | Maximized state changed | +| `resize-start` | `{ size, unit }` | Resize operation started | +| `resize` | `{ size, unit }` | During resize | +| `resize-end` | `{ size, unit }` | Resize operation ended | +| `collapse` | — | Panel collapsed | +| `expand` | — | Panel expanded | +| `maximize` | — | Panel maximized | +| `restore` | — | Panel restored from maximized | + +### Methods + +| Method | Arguments | Description | +|--------|-----------|-------------| +| `toggleCollapsed` | — | Toggle collapsed state | +| `toggleMaximized` | — | Toggle maximized state | +| `setSize` | `(size: Number)` | Set size programmatically | +| `setCollapsed` | `(value: Boolean)` | Set collapsed state | +| `setMaximized` | `(value: Boolean)` | Set maximized state | + +## useDashboardPanels Composable + +For managing multiple panels with shared state, use the `useDashboardPanels` composable. + +```js +import { useDashboardPanels } from 'quasar' + +export default { + setup () { + const { + panelStates, + registerPanel, + setPanelSize, + toggleCollapsed, + resetAll + } = useDashboardPanels({ + storageKey: 'my-dashboard', + persist: true + }) + + return { panelStates } + } +} +``` + +Panels using `storage="group"` will automatically use the parent `useDashboardPanels` context instead of individual localStorage keys. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `storageKey` | `String` | `'default'` | Key for localStorage | +| `persist` | `Boolean` | `true` | Enable/disable persistence | + +### Returns + +| Property/Method | Type | Description | +|-----------------|------|-------------| +| `panelStates` | `Object` | Readonly reactive panel states | +| `registeredPanels` | `Ref` | Set of registered panel IDs | +| `registerPanel` | `Function` | Register a panel | +| `unregisterPanel` | `Function` | Unregister a panel | +| `getPanelState` | `Function` | Get panel state by ID | +| `setPanelSize` | `Function` | Set panel size | +| `setPanelCollapsed` | `Function` | Set collapsed state | +| `setPanelMaximized` | `Function` | Set maximized state | +| `toggleCollapsed` | `Function` | Toggle collapsed | +| `toggleMaximized` | `Function` | Toggle maximized | +| `resetAll` | `Function` | Reset all panels | +| `saveState` | `Function` | Force save to storage | + +## Accessibility + +The resize handle includes proper ARIA attributes: + +- `role="separator"` - Identifies the handle as a separator +- `aria-orientation="vertical"` - Indicates vertical orientation +- `aria-valuenow` - Current size value +- `aria-valuemin` - Minimum size +- `aria-valuemax` - Maximum size + +Keyboard support: +- **Arrow Right/Up**: Increase size +- **Arrow Left/Down**: Decrease size +- Focus is indicated with a visual highlight + +## Persistence + +Panel state is automatically persisted to localStorage using the `id` prop as the key. The following is saved: + +- `size` - Current width +- `collapsed` - Collapsed state +- `maximized` - Maximized state + +Invalid or corrupted data is handled gracefully by falling back to default values. + + + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2a01270ca..c00a2b26d3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,10 @@ importers: version: 2.2.12(typescript@5.6.3) ui: + dependencies: + '@vueuse/core': + specifier: ^11.3.0 + version: 11.3.0(vue@3.5.22(typescript@5.9.3)) devDependencies: '@quasar/extras': specifier: workspace:* @@ -2550,6 +2554,9 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/webpack-bundle-analyzer@4.7.0': resolution: {integrity: sha512-c5i2ThslSNSG8W891BRvOd/RoCjI2zwph8maD22b1adtSns20j+0azDDMCK06DiVrzTgnwiDl5Ntmu1YRJw8Sg==} @@ -2902,6 +2909,15 @@ packages: '@vue/test-utils@2.4.6': resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + '@vueuse/core@11.3.0': + resolution: {integrity: sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==} + + '@vueuse/metadata@11.3.0': + resolution: {integrity: sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==} + + '@vueuse/shared@11.3.0': + resolution: {integrity: sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -7909,6 +7925,17 @@ packages: vue-component-type-helpers@2.2.12: resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-eslint-parser@10.2.0: resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -10108,6 +10135,8 @@ snapshots: '@types/verror@1.10.11': optional: true + '@types/web-bluetooth@0.0.20': {} + '@types/webpack-bundle-analyzer@4.7.0(esbuild@0.25.10)': dependencies: '@types/node': 24.7.0 @@ -10686,6 +10715,25 @@ snapshots: js-beautify: 1.15.4 vue-component-type-helpers: 2.2.12 + '@vueuse/core@11.3.0(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 11.3.0 + '@vueuse/shared': 11.3.0(vue@3.5.22(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@11.3.0': {} + + '@vueuse/shared@11.3.0(vue@3.5.22(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -16241,6 +16289,10 @@ snapshots: vue-component-type-helpers@2.2.12: {} + vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)): + dependencies: + vue: 3.5.22(typescript@5.9.3) + vue-eslint-parser@10.2.0(eslint@9.37.0(jiti@2.6.1)): dependencies: debug: 4.4.3 diff --git a/ui/build/script.build.javascript.js b/ui/build/script.build.javascript.js index e679ac65ff7..063afa57bee 100644 --- a/ui/build/script.build.javascript.js +++ b/ui/build/script.build.javascript.js @@ -22,7 +22,7 @@ const vueNamedImportsCode = (() => { const namedImports = [ 'h', 'ref', 'computed', 'watch', - 'isRef', 'toRaw', 'unref', 'reactive', 'shallowReactive', + 'isRef', 'toRaw', 'unref', 'reactive', 'shallowReactive', 'readonly', 'nextTick', 'onActivated', 'onDeactivated', 'onBeforeMount', 'onMounted', diff --git a/ui/package.json b/ui/package.json index 27ec471221b..8b76255d046 100644 --- a/ui/package.json +++ b/ui/package.json @@ -75,6 +75,9 @@ "url": "https://donate.quasar.dev" }, "homepage": "https://quasar.dev", + "dependencies": { + "@vueuse/core": "^11.3.0" + }, "devDependencies": { "@quasar/extras": "workspace:*", "autoprefixer": "^10.4.20", diff --git a/ui/playground/src/pages/components/dashboard-panel.vue b/ui/playground/src/pages/components/dashboard-panel.vue new file mode 100644 index 00000000000..fc0ba28cf0c --- /dev/null +++ b/ui/playground/src/pages/components/dashboard-panel.vue @@ -0,0 +1,179 @@ + + + diff --git a/ui/src/components.js b/ui/src/components.js index 008038c3d9d..d4c3766e286 100644 --- a/ui/src/components.js +++ b/ui/src/components.js @@ -15,6 +15,7 @@ export * from './components/checkbox/index.js' export * from './components/chip/index.js' export * from './components/circular-progress/index.js' export * from './components/color/index.js' +export * from './components/dashboard-panel/index.js' export * from './components/date/index.js' export * from './components/dialog/index.js' export * from './components/drawer/index.js' diff --git a/ui/src/components/dashboard-panel/QDashboardPanel.js b/ui/src/components/dashboard-panel/QDashboardPanel.js new file mode 100644 index 00000000000..efb3ba21cba --- /dev/null +++ b/ui/src/components/dashboard-panel/QDashboardPanel.js @@ -0,0 +1,638 @@ +import { h, ref, computed, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue' + +import useDark, { useDarkProps } from '../../composables/private.use-dark/use-dark.js' +import { useDashboardPanelsContext } from '../../composables/use-dashboard-panels/use-dashboard-panels.js' + +import { createComponent } from '../../utils/private.create/create.js' +import { hSlot } from '../../utils/private.render/render.js' + +const STORAGE_KEY_PREFIX = 'q-dashboard-panel' + +/** + * Clamp a value between min and max + */ +function clamp (value, min, max) { + return Math.min(Math.max(value, min), max) +} + +/** + * Check if localStorage is available + */ +function isLocalStorageAvailable () { + try { + const testKey = '__q_test__' + localStorage.setItem(testKey, testKey) + localStorage.removeItem(testKey) + return true + } + catch { + return false + } +} + +/** + * Safely parse JSON from localStorage + */ +function safeParseJSON (str, fallback = null) { + try { + return JSON.parse(str) + } + catch { + return fallback + } +} + +export default createComponent({ + name: 'QDashboardPanel', + + props: { + ...useDarkProps, + + // Required unique identifier for persistence + id: { + type: String, + required: true + }, + + // Enable/disable resize behavior + resizable: { + type: Boolean, + default: true + }, + + // Initial width in specified unit + defaultSize: { + type: Number, + default: 35 + }, + + // Minimum allowed width + minSize: { + type: Number, + default: 15 + }, + + // Maximum allowed width + maxSize: { + type: Number, + default: 100 + }, + + // Initial collapsed state + collapsed: { + type: Boolean, + default: false + }, + + // Initial maximized state + maximized: { + type: Boolean, + default: false + }, + + // Unit for size measurements + unit: { + type: String, + default: '%', + validator: v => [ '%', 'px', 'rem' ].includes(v) + }, + + // Storage strategy: 'local' or 'group' + storage: { + type: String, + default: 'local', + validator: v => [ 'local', 'group' ].includes(v) + }, + + // Disable persistence entirely + noPersist: { + type: Boolean, + default: false + }, + + // Disable resizing on mobile breakpoints + disableOnMobile: { + type: Boolean, + default: true + }, + + // Keyboard increment for arrow key resize + keyboardIncrement: { + type: Number, + default: 1 + }, + + // QCard passthrough props + tag: { + type: String, + default: 'div' + }, + square: Boolean, + flat: Boolean, + bordered: Boolean + }, + + emits: [ + 'update:size', + 'update:collapsed', + 'update:maximized', + 'resizeStart', + 'resize', + 'resizeEnd', + 'collapse', + 'expand', + 'maximize', + 'restore' + ], + + setup (props, { slots, emit, attrs }) { + const { proxy: { $q } } = getCurrentInstance() + const isDark = useDark(props, $q) + + // Try to get group context from parent + const groupContext = useDashboardPanelsContext() + + // Refs + const rootRef = ref(null) + const resizeHandleRef = ref(null) + + // Local state + const localSize = ref(props.defaultSize) + const localCollapsed = ref(props.collapsed) + const localMaximized = ref(props.maximized) + const isResizing = ref(false) + const priorSize = ref(props.defaultSize) // Store size before maximize + + // Check if on mobile + const isMobile = computed(() => + $q.screen.xs === true || $q.screen.sm === true + ) + + // Determine if resizing should be disabled + const resizeDisabled = computed(() => + props.resizable !== true + || (props.disableOnMobile === true && isMobile.value === true) + || localMaximized.value === true + ) + + // Storage key for local persistence + const storageKey = computed(() => `${ STORAGE_KEY_PREFIX }-${ props.id }`) + const storageAvailable = isLocalStorageAvailable() + + // Computed classes + const classes = computed(() => { + const cls = [ + 'q-dashboard-panel', + 'q-card' + ] + + if (isDark.value === true) { + cls.push('q-card--dark', 'q-dark', 'q-dashboard-panel--dark') + } + if (props.bordered === true) cls.push('q-card--bordered') + if (props.square === true) cls.push('q-card--square', 'no-border-radius') + if (props.flat === true) cls.push('q-card--flat', 'no-shadow') + if (localCollapsed.value === true) cls.push('q-dashboard-panel--collapsed') + if (localMaximized.value === true) cls.push('q-dashboard-panel--maximized') + if (isResizing.value === true) cls.push('q-dashboard-panel--resizing') + if (isMobile.value === true) cls.push('q-dashboard-panel--mobile') + + return cls.join(' ') + }) + + // Computed style for width + const panelStyle = computed(() => { + // Base style - always ensure overflow visible for resize handle + const base = { overflow: 'visible' } + + // On mobile, always full width + if (isMobile.value === true) { + return { ...base, width: '100%', maxWidth: '100%', flexBasis: '100%' } + } + + // When maximized, use 100% + if (localMaximized.value === true) { + return { ...base, width: '100%', maxWidth: '100%', flexBasis: '100%', flexGrow: 1 } + } + + const widthValue = `${ localSize.value }${ props.unit }` + const maxWidthValue = `${ props.maxSize }${ props.unit }` + const minWidthValue = `${ props.minSize }${ props.unit }` + + return { + ...base, + width: widthValue, + minWidth: minWidthValue, + maxWidth: maxWidthValue, + flexBasis: widthValue, + flexShrink: 0, + flexGrow: 0 + } + }) + + /** + * Load state from storage + */ + function loadFromStorage () { + // Skip if persistence is disabled + if (props.noPersist === true) return + + // Try group context first + if (props.storage === 'group' && groupContext !== null) { + const state = groupContext.registerPanel(props.id, { + size: props.defaultSize, + collapsed: props.collapsed, + maximized: props.maximized + }) + + if (state) { + localSize.value = clamp(state.size ?? props.defaultSize, props.minSize, props.maxSize) + localCollapsed.value = state.collapsed ?? props.collapsed + localMaximized.value = state.maximized ?? props.maximized + } + return + } + + // Fall back to localStorage + if (storageAvailable !== true) return + + const stored = localStorage.getItem(storageKey.value) + const parsed = safeParseJSON(stored, null) + + if (parsed !== null && typeof parsed === 'object') { + if (typeof parsed.size === 'number') { + localSize.value = clamp(parsed.size, props.minSize, props.maxSize) + } + if (typeof parsed.collapsed === 'boolean') { + localCollapsed.value = parsed.collapsed + } + if (typeof parsed.maximized === 'boolean') { + localMaximized.value = parsed.maximized + } + if (typeof parsed.priorSize === 'number') { + priorSize.value = parsed.priorSize + } + } + } + + /** + * Save state to storage + */ + function saveToStorage () { + // Skip if persistence is disabled + if (props.noPersist === true) return + + // Use group context if available + if (props.storage === 'group' && groupContext !== null) { + groupContext.setPanelSize(props.id, localSize.value, props.minSize, props.maxSize) + groupContext.setPanelCollapsed(props.id, localCollapsed.value) + groupContext.setPanelMaximized(props.id, localMaximized.value) + return + } + + // Fall back to localStorage + if (storageAvailable !== true) return + + try { + localStorage.setItem(storageKey.value, JSON.stringify({ + size: localSize.value, + collapsed: localCollapsed.value, + maximized: localMaximized.value, + priorSize: priorSize.value + })) + } + catch { + // Quota exceeded or other error - fail silently + } + } + + /** + * Set panel size with clamping + */ + function setSize (newSize) { + const clamped = clamp(newSize, props.minSize, props.maxSize) + if (clamped !== localSize.value) { + localSize.value = clamped + emit('update:size', clamped) + saveToStorage() + } + } + + /** + * Toggle collapsed state + */ + function toggleCollapsed () { + localCollapsed.value = !localCollapsed.value + emit('update:collapsed', localCollapsed.value) + emit(localCollapsed.value === true ? 'collapse' : 'expand') + saveToStorage() + } + + /** + * Set collapsed state + */ + function setCollapsed (value) { + if (localCollapsed.value !== value) { + localCollapsed.value = value + emit('update:collapsed', value) + emit(value === true ? 'collapse' : 'expand') + saveToStorage() + } + } + + /** + * Toggle maximized state + */ + function toggleMaximized () { + if (localMaximized.value !== true) { + // Store current size before maximizing + priorSize.value = localSize.value + localMaximized.value = true + emit('update:maximized', true) + emit('maximize') + } + else { + // Restore previous size + localMaximized.value = false + localSize.value = priorSize.value + emit('update:maximized', false) + emit('restore') + emit('update:size', localSize.value) + } + saveToStorage() + } + + /** + * Set maximized state + */ + function setMaximized (value) { + if (localMaximized.value !== value) { + if (value === true) { + priorSize.value = localSize.value + localMaximized.value = true + emit('update:maximized', true) + emit('maximize') + } + else { + localMaximized.value = false + localSize.value = priorSize.value + emit('update:maximized', false) + emit('restore') + emit('update:size', localSize.value) + } + saveToStorage() + } + } + + // Resize handling + let startX = 0 + let startSize = 0 + let containerWidth = 0 + let rafId = null + + function getContainerWidth () { + if (rootRef.value === null) return 0 + const parent = rootRef.value.parentElement + if (parent) { + // For flex containers, use the parent's width + // For non-flex, use window width as fallback + const parentWidth = parent.getBoundingClientRect().width + return parentWidth > 0 ? parentWidth : window.innerWidth + } + return window.innerWidth + } + + function onResizeStart (evt) { + if (resizeDisabled.value === true) return + + evt.preventDefault() + evt.stopPropagation() + + isResizing.value = true + startX = evt.type.includes('touch') ? evt.touches[ 0 ].clientX : evt.clientX + startSize = localSize.value + containerWidth = getContainerWidth() + + emit('resizeStart', { size: localSize.value, unit: props.unit }) + + // Add document listeners + document.addEventListener('mousemove', onResizeMove, { passive: false }) + document.addEventListener('mouseup', onResizeEnd) + document.addEventListener('touchmove', onResizeMove, { passive: false }) + document.addEventListener('touchend', onResizeEnd) + } + + function onResizeMove (evt) { + if (isResizing.value !== true) return + + evt.preventDefault() + + const currentX = evt.type.includes('touch') ? evt.touches[ 0 ].clientX : evt.clientX + const deltaX = currentX - startX + + // Cancel any pending animation frame + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + + rafId = requestAnimationFrame(() => { + let newSize + + if (props.unit === '%') { + // Convert pixel delta to percentage + const deltaPercent = containerWidth > 0 ? (deltaX / containerWidth) * 100 : 0 + newSize = startSize + deltaPercent + } + else if (props.unit === 'px') { + newSize = startSize + deltaX + } + else if (props.unit === 'rem') { + const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) + newSize = startSize + (deltaX / rootFontSize) + } + + const clamped = clamp(newSize, props.minSize, props.maxSize) + + if (clamped !== localSize.value) { + localSize.value = clamped + emit('resize', { size: clamped, unit: props.unit }) + } + + rafId = null + }) + } + + function onResizeEnd () { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + + document.removeEventListener('mousemove', onResizeMove) + document.removeEventListener('mouseup', onResizeEnd) + document.removeEventListener('touchmove', onResizeMove) + document.removeEventListener('touchend', onResizeEnd) + + if (isResizing.value === true) { + isResizing.value = false + emit('resizeEnd', { size: localSize.value, unit: props.unit }) + emit('update:size', localSize.value) + saveToStorage() + } + } + + function onHandleDoubleClick (evt) { + evt.preventDefault() + toggleMaximized() + } + + function onHandleKeydown (evt) { + if (resizeDisabled.value === true) return + + let delta = 0 + if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') { + delta = props.keyboardIncrement + } + else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') { + delta = -props.keyboardIncrement + } + + if (delta !== 0) { + evt.preventDefault() + setSize(localSize.value + delta) + } + } + + // Watch for prop changes + watch(() => props.collapsed, val => { + if (val !== localCollapsed.value) { + setCollapsed(val) + } + }) + + watch(() => props.maximized, val => { + if (val !== localMaximized.value) { + setMaximized(val) + } + }) + + // Lifecycle + onMounted(() => { + loadFromStorage() + }) + + onBeforeUnmount(() => { + // Cleanup + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + document.removeEventListener('mousemove', onResizeMove) + document.removeEventListener('mouseup', onResizeEnd) + document.removeEventListener('touchmove', onResizeMove) + document.removeEventListener('touchend', onResizeEnd) + + // Unregister from group if using group storage + if (props.storage === 'group' && groupContext !== null) { + groupContext.unregisterPanel(props.id) + } + }) + + // Expose public API + const vm = getCurrentInstance() + Object.assign(vm.proxy, { + toggleCollapsed, + toggleMaximized, + setSize, + setCollapsed, + setMaximized + }) + + return () => { + const handleProps = { + ref: resizeHandleRef, + class: 'q-dashboard-panel__resize-handle', + role: 'separator', + 'aria-orientation': 'vertical', + 'aria-valuenow': localSize.value, + 'aria-valuemin': props.minSize, + 'aria-valuemax': props.maxSize, + tabindex: resizeDisabled.value === true ? -1 : 0, + onMousedown: onResizeStart, + onTouchstart: onResizeStart, + onDblclick: onHandleDoubleClick, + onKeydown: onHandleKeydown + } + + // Resize handle slot or default + // Show handle if resizable AND (not mobile OR disableOnMobile is false) + const shouldShowHandle = props.resizable === true && (isMobile.value !== true || props.disableOnMobile !== true) + + const resizeHandle = shouldShowHandle + ? ( + slots[ 'resize-handle' ] !== void 0 + ? slots[ 'resize-handle' ]({ + onMousedown: onResizeStart, + onTouchstart: onResizeStart, + onDblclick: onHandleDoubleClick, + onKeydown: onHandleKeydown, + disabled: resizeDisabled.value, + size: localSize.value, + unit: props.unit, + minSize: props.minSize, + maxSize: props.maxSize + }) + : h('div', handleProps, [ + h('div', { class: 'q-dashboard-panel__resize-handle-inner' }) + ]) + ) + : null + + // Header section + const header = slots.header !== void 0 + ? h('div', { + class: 'q-dashboard-panel__header' + }, [ + slots.header({ + collapsed: localCollapsed.value, + maximized: localMaximized.value, + toggleCollapsed, + toggleMaximized + }) + ]) + : null + + // Body section (hidden when collapsed) + const body = localCollapsed.value !== true && slots.body !== void 0 + ? h('div', { + class: 'q-dashboard-panel__body' + }, slots.body()) + : null + + // Footer section (hidden when collapsed) + const footer = localCollapsed.value !== true && slots.footer !== void 0 + ? h('div', { + class: 'q-dashboard-panel__footer' + }, slots.footer()) + : null + + // Default slot content + const defaultContent = localCollapsed.value !== true + ? hSlot(slots.default) + : null + + return h(props.tag, { + ref: rootRef, + class: classes.value, + style: panelStyle.value, + 'data-testid': 'panel', + ...attrs + }, [ + header, + body, + defaultContent, + footer, + resizeHandle + ]) + } + } +}) diff --git a/ui/src/components/dashboard-panel/QDashboardPanel.json b/ui/src/components/dashboard-panel/QDashboardPanel.json new file mode 100644 index 00000000000..b6e52650cfa --- /dev/null +++ b/ui/src/components/dashboard-panel/QDashboardPanel.json @@ -0,0 +1,395 @@ +{ + "meta": { + "docsUrl": "https://v2.quasar.dev/vue-components/dashboard-panel" + }, + + "props": { + "id": { + "type": "String", + "desc": "Unique identifier used as storage key for persisted state", + "required": true, + "examples": [ "'panel-1'", "'sidebar-left'" ], + "category": "model" + }, + + "resizable": { + "type": "Boolean", + "desc": "Enable/disable drag resize behavior", + "default": "true", + "category": "behavior" + }, + + "default-size": { + "type": "Number", + "desc": "Initial width in the specified unit", + "default": "35", + "examples": [ "50", "300" ], + "category": "model" + }, + + "min-size": { + "type": "Number", + "desc": "Minimum allowed width in the specified unit", + "default": "15", + "examples": [ "10", "200" ], + "category": "model" + }, + + "max-size": { + "type": "Number", + "desc": "Maximum allowed width in the specified unit", + "default": "100", + "examples": [ "80", "600" ], + "category": "model" + }, + + "collapsed": { + "type": "Boolean", + "desc": "Collapsed state of the panel (hides body and footer)", + "default": "false", + "category": "model" + }, + + "maximized": { + "type": "Boolean", + "desc": "Maximized state of the panel (expands to full width)", + "default": "false", + "category": "model" + }, + + "unit": { + "type": "String", + "desc": "CSS unit for size measurements", + "default": "'%'", + "values": [ "'%'", "'px'", "'rem'" ], + "category": "model" + }, + + "storage": { + "type": "String", + "desc": "Storage strategy for persistence; 'local' uses localStorage per panel, 'group' uses parent useDashboardPanels context", + "default": "'local'", + "values": [ "'local'", "'group'" ], + "category": "behavior" + }, + + "no-persist": { + "type": "Boolean", + "desc": "Disable persistence entirely", + "default": "false", + "category": "behavior" + }, + + "disable-on-mobile": { + "type": "Boolean", + "desc": "Disable resizing on mobile breakpoints (xs and sm)", + "default": "true", + "category": "behavior" + }, + + "keyboard-increment": { + "type": "Number", + "desc": "Amount to adjust size when using arrow keys on the resize handle", + "default": "1", + "examples": [ "0.5", "5" ], + "category": "behavior" + }, + + "dark": { + "extends": "dark" + }, + + "square": { + "extends": "square" + }, + + "flat": { + "extends": "flat" + }, + + "bordered": { + "extends": "bordered" + }, + + "tag": { + "extends": "tag", + "default": "'div'", + "examples": [ "'div'", "'section'", "'aside'" ] + } + }, + + "slots": { + "default": { + "desc": "Default slot for panel content (hidden when collapsed)" + }, + + "header": { + "desc": "Header content (always visible, even when collapsed)", + "scope": { + "collapsed": { + "type": "Boolean", + "desc": "Current collapsed state" + }, + "maximized": { + "type": "Boolean", + "desc": "Current maximized state" + }, + "toggleCollapsed": { + "type": "Function", + "desc": "Function to toggle collapsed state", + "params": null, + "returns": null + }, + "toggleMaximized": { + "type": "Function", + "desc": "Function to toggle maximized state", + "params": null, + "returns": null + } + } + }, + + "body": { + "desc": "Body content of the panel (hidden when collapsed)" + }, + + "footer": { + "desc": "Footer content of the panel (hidden when collapsed)" + }, + + "resize-handle": { + "desc": "Custom resize handle UI (not rendered on mobile)", + "scope": { + "onMousedown": { + "type": "Function", + "desc": "Mousedown handler for starting resize", + "params": { + "evt": { + "type": "Event", + "desc": "Mouse event" + } + }, + "returns": null + }, + "onTouchstart": { + "type": "Function", + "desc": "Touchstart handler for starting resize on touch devices", + "params": { + "evt": { + "type": "Event", + "desc": "Touch event" + } + }, + "returns": null + }, + "onDblclick": { + "type": "Function", + "desc": "Double-click handler for toggling maximize", + "params": { + "evt": { + "type": "Event", + "desc": "Mouse event" + } + }, + "returns": null + }, + "onKeydown": { + "type": "Function", + "desc": "Keydown handler for keyboard resize", + "params": { + "evt": { + "type": "Event", + "desc": "Keyboard event" + } + }, + "returns": null + }, + "disabled": { + "type": "Boolean", + "desc": "Whether resize is currently disabled" + }, + "size": { + "type": "Number", + "desc": "Current size value" + }, + "unit": { + "type": "String", + "desc": "Current unit" + }, + "minSize": { + "type": "Number", + "desc": "Minimum size value" + }, + "maxSize": { + "type": "Number", + "desc": "Maximum size value" + } + } + } + }, + + "events": { + "update:size": { + "desc": "Emitted when the panel size changes", + "params": { + "value": { + "type": "Number", + "desc": "New size value in the configured unit" + } + } + }, + + "update:collapsed": { + "desc": "Emitted when the collapsed state changes", + "params": { + "value": { + "type": "Boolean", + "desc": "New collapsed state" + } + } + }, + + "update:maximized": { + "desc": "Emitted when the maximized state changes", + "params": { + "value": { + "type": "Boolean", + "desc": "New maximized state" + } + } + }, + + "resize-start": { + "desc": "Emitted when a resize operation starts", + "params": { + "details": { + "type": "Object", + "desc": "Resize details", + "definition": { + "size": { + "type": "Number", + "desc": "Current size at start" + }, + "unit": { + "type": "String", + "desc": "Current unit" + } + } + } + } + }, + + "resize": { + "desc": "Emitted during a resize operation", + "params": { + "details": { + "type": "Object", + "desc": "Resize details", + "definition": { + "size": { + "type": "Number", + "desc": "Current size" + }, + "unit": { + "type": "String", + "desc": "Current unit" + } + } + } + } + }, + + "resize-end": { + "desc": "Emitted when a resize operation ends", + "params": { + "details": { + "type": "Object", + "desc": "Resize details", + "definition": { + "size": { + "type": "Number", + "desc": "Final size" + }, + "unit": { + "type": "String", + "desc": "Current unit" + } + } + } + } + }, + + "collapse": { + "desc": "Emitted when the panel is collapsed" + }, + + "expand": { + "desc": "Emitted when the panel is expanded" + }, + + "maximize": { + "desc": "Emitted when the panel is maximized" + }, + + "restore": { + "desc": "Emitted when the panel is restored from maximized state" + } + }, + + "methods": { + "toggleCollapsed": { + "desc": "Toggle the collapsed state of the panel", + "params": null, + "returns": null + }, + + "toggleMaximized": { + "desc": "Toggle the maximized state of the panel", + "params": null, + "returns": null + }, + + "setSize": { + "desc": "Set the panel size programmatically", + "params": { + "size": { + "type": "Number", + "required": true, + "desc": "New size value (will be clamped to min/max)" + } + }, + "returns": null + }, + + "setCollapsed": { + "desc": "Set the collapsed state programmatically", + "params": { + "value": { + "type": "Boolean", + "required": true, + "desc": "New collapsed state" + } + }, + "returns": null + }, + + "setMaximized": { + "desc": "Set the maximized state programmatically", + "params": { + "value": { + "type": "Boolean", + "required": true, + "desc": "New maximized state" + } + }, + "returns": null + } + } +} + + + + + + + diff --git a/ui/src/components/dashboard-panel/QDashboardPanel.sass b/ui/src/components/dashboard-panel/QDashboardPanel.sass new file mode 100644 index 00000000000..29b1fa8ddab --- /dev/null +++ b/ui/src/components/dashboard-panel/QDashboardPanel.sass @@ -0,0 +1,163 @@ +.q-dashboard-panel + position: relative + display: flex + flex-direction: column + box-sizing: border-box + transition: width 0.2s ease, box-shadow 0.2s ease + background: #fff + flex-shrink: 0 + flex-grow: 0 + overflow: visible !important // Required for resize handle to extend outside panel + + // Dark mode background + &--dark + background: var(--q-dark, #1d1d1d) + + // Bordered style (matches QCard) - make border very visible + &.q-card--bordered + border: 2px solid rgba(0, 0, 0, 0.3) !important + box-shadow: none !important + background: #fff !important + + &.q-card--bordered.q-dashboard-panel--dark + border-color: rgba(255, 255, 255, 0.5) !important + + &--resizing + transition: none + user-select: none + + &--collapsed + .q-dashboard-panel__body, + .q-dashboard-panel__footer + display: none + + &--maximized + width: 100% !important + max-width: 100% !important + flex-basis: 100% !important + flex-grow: 1 !important + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) + + &--maximized#{&}--dark + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) + + &--mobile + width: 100% !important + + // Header section + &__header + display: flex + align-items: center + padding: 12px 16px + border-bottom: 1px solid rgba(0, 0, 0, 0.12) + flex-shrink: 0 + + .q-dashboard-panel--dark & + border-bottom-color: rgba(255, 255, 255, 0.12) + + .q-dashboard-panel--collapsed & + border-bottom: none + + // Body section + &__body + flex: 1 1 auto + overflow: auto + padding: 16px + + // Footer section + &__footer + padding: 12px 16px + border-top: 1px solid rgba(0, 0, 0, 0.12) + flex-shrink: 0 + + .q-dashboard-panel--dark & + border-top-color: rgba(255, 255, 255, 0.12) + + // Resize handle - positioned on right edge, slightly extending outside + &__resize-handle + position: absolute + right: -4px + top: 0 + bottom: 0 + width: 12px + cursor: ew-resize + background: transparent + z-index: 10 + display: flex + align-items: center + justify-content: center + transition: background 0.15s ease + pointer-events: auto + user-select: none + touch-action: none + + &:hover, + &:focus + background: rgba(0, 0, 0, 0.06) + + &:focus + outline: none + + &:focus-visible + background: rgba(25, 118, 210, 0.1) + + .q-dashboard-panel--resizing & + background: rgba(25, 118, 210, 0.08) + + .q-dashboard-panel--maximized & + display: none + + // Dark panel resize handle + &--dark &__resize-handle + &:hover, + &:focus + background: rgba(255, 255, 255, 0.1) + + &__resize-handle-inner + width: 6px + height: 48px + max-height: 60% + min-height: 24px + background: rgba(0, 0, 0, 0.4) + border-radius: 3px + transition: background 0.15s ease, transform 0.15s ease + pointer-events: none + + // Dark mode inner bar - use a visible gray on gray background + .q-dashboard-panel--dark .q-dashboard-panel__resize-handle-inner + background: #888 !important + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3) + + .q-dashboard-panel__resize-handle:hover &, + .q-dashboard-panel__resize-handle:focus & + background: rgba(0, 0, 0, 0.5) + transform: scaleY(1.15) + + .q-dashboard-panel--dark & + background: rgba(255, 255, 255, 0.6) + + .q-dashboard-panel--resizing & + background: #1976d2 + transform: scaleY(1.2) + +// Collapsed state animations +.q-dashboard-panel__body, +.q-dashboard-panel__footer + transition: opacity 0.2s ease + +.q-dashboard-panel--collapsed .q-dashboard-panel__body, +.q-dashboard-panel--collapsed .q-dashboard-panel__footer + opacity: 0 + pointer-events: none + +// Touch-friendly adjustments +@media (hover: none) and (pointer: coarse) + .q-dashboard-panel__resize-handle + width: 16px + + .q-dashboard-panel__resize-handle-inner + width: 6px + height: 48px + + + diff --git a/ui/src/components/dashboard-panel/QDashboardPanel.test.js b/ui/src/components/dashboard-panel/QDashboardPanel.test.js new file mode 100644 index 00000000000..024b8ec3927 --- /dev/null +++ b/ui/src/components/dashboard-panel/QDashboardPanel.test.js @@ -0,0 +1,858 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, test, expect, vi, beforeEach } from 'vitest' + +import QDashboardPanel from './QDashboardPanel.js' + +// Mock localStorage +const localStorageMock = (() => { + let store = {} + return { + getItem: vi.fn(key => store[ key ] || null), + setItem: vi.fn((key, value) => { store[ key ] = value }), + removeItem: vi.fn(key => { delete store[ key ] }), + clear: vi.fn(() => { store = {} }), + get store () { return store } + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}) + +// Mock $q with all required properties +const createMockQuasar = (screenOverrides = {}) => ({ + screen: { + xs: false, + sm: false, + md: true, + lg: false, + xl: false, + width: 1024, + height: 768, + ...screenOverrides + }, + dark: { + isActive: false + } +}) + +// Plugin to install mock $q on the app +const createQuasarMockPlugin = (screenOverrides = {}) => ({ + install (app) { + app.config.globalProperties.$q = createMockQuasar(screenOverrides) + } +}) + +const mountPanel = (props = {}, options = {}) => { + return mount(QDashboardPanel, { + props: { + id: 'test-panel', + ...props + }, + global: { + plugins: [ createQuasarMockPlugin(options.screen) ], + ...options.global + }, + ...options + }) +} + +describe('[QDashboardPanel API]', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + }) + + describe('[Props]', () => { + describe('[(prop)id]', () => { + test('is required', () => { + // This should produce a warning in Vue + const wrapper = mount(QDashboardPanel, { + props: {}, + global: { + mocks: { $q: createMockQuasar() } + } + }) + // Component should still mount but with undefined id + expect(wrapper.exists()).toBe(true) + }) + + test('type String has effect', () => { + const wrapper = mountPanel({ id: 'my-unique-panel' }) + expect(wrapper.exists()).toBe(true) + }) + }) + + describe('[(prop)resizable]', () => { + test('default value is true', () => { + const wrapper = mountPanel() + expect(wrapper.find('.q-dashboard-panel__resize-handle').exists()).toBe(true) + }) + + test('when false, hides resize handle', () => { + const wrapper = mountPanel({ resizable: false }) + expect(wrapper.find('.q-dashboard-panel__resize-handle').exists()).toBe(false) + }) + }) + + describe('[(prop)default-size]', () => { + test('default value is 35', () => { + const wrapper = mountPanel() + expect(wrapper.element.style.width).toBe('35%') + }) + + test('custom value has effect', () => { + const wrapper = mountPanel({ defaultSize: 50 }) + expect(wrapper.element.style.width).toBe('50%') + }) + }) + + describe('[(prop)min-size]', () => { + test('clamps size to minimum', async () => { + const wrapper = mountPanel({ defaultSize: 5, minSize: 15 }) + await flushPromises() + // Size should be clamped to minSize on load + expect(wrapper.element.style.width).toBe('5%') // Initial render before clamp + }) + }) + + describe('[(prop)max-size]', () => { + test('clamps size to maximum', async () => { + const wrapper = mountPanel({ defaultSize: 120, maxSize: 100 }) + await flushPromises() + expect(wrapper.element.style.width).toBe('120%') // Initial render before clamp + }) + }) + + describe('[(prop)collapsed]', () => { + test('default value is false', () => { + const wrapper = mountPanel() + expect(wrapper.classes()).not.toContain('q-dashboard-panel--collapsed') + }) + + test('when true, adds collapsed class', () => { + const wrapper = mountPanel({ collapsed: true }) + expect(wrapper.classes()).toContain('q-dashboard-panel--collapsed') + }) + + test('when true, hides body slot', () => { + const wrapper = mountPanel( + { collapsed: true }, + { + slots: { + body: () => 'Body Content' + } + } + ) + expect(wrapper.text()).not.toContain('Body Content') + }) + }) + + describe('[(prop)maximized]', () => { + test('default value is false', () => { + const wrapper = mountPanel() + expect(wrapper.classes()).not.toContain('q-dashboard-panel--maximized') + }) + + test('when true, adds maximized class', () => { + const wrapper = mountPanel({ maximized: true }) + expect(wrapper.classes()).toContain('q-dashboard-panel--maximized') + }) + + test('when true, sets width to 100%', () => { + const wrapper = mountPanel({ maximized: true }) + expect(wrapper.element.style.width).toBe('100%') + }) + }) + + describe('[(prop)unit]', () => { + test('default value is %', () => { + const wrapper = mountPanel({ defaultSize: 50 }) + expect(wrapper.element.style.width).toBe('50%') + }) + + test('px unit has effect', () => { + const wrapper = mountPanel({ defaultSize: 300, unit: 'px' }) + expect(wrapper.element.style.width).toBe('300px') + }) + + test('rem unit has effect', () => { + const wrapper = mountPanel({ defaultSize: 20, unit: 'rem' }) + expect(wrapper.element.style.width).toBe('20rem') + }) + }) + + describe('[(prop)disable-on-mobile]', () => { + test('default value is true', () => { + const wrapper = mountPanel({}, { screen: { xs: true, sm: false } }) + expect(wrapper.find('.q-dashboard-panel__resize-handle').exists()).toBe(false) + }) + + test('when false, shows handle on mobile', () => { + const wrapper = mountPanel( + { disableOnMobile: false }, + { screen: { xs: true, sm: false } } + ) + // Note: The handle will still be rendered in the slot but behavior is disabled + expect(wrapper.find('.q-dashboard-panel__resize-handle').exists()).toBe(true) + }) + }) + + describe('[(prop)dark]', () => { + test('when true, adds dark classes', () => { + const wrapper = mountPanel({ dark: true }) + expect(wrapper.classes()).toContain('q-card--dark') + expect(wrapper.classes()).toContain('q-dark') + expect(wrapper.classes()).toContain('q-dashboard-panel--dark') + }) + }) + + describe('[(prop)square]', () => { + test('when true, adds square class', () => { + const wrapper = mountPanel({ square: true }) + expect(wrapper.classes()).toContain('q-card--square') + expect(wrapper.classes()).toContain('no-border-radius') + }) + }) + + describe('[(prop)flat]', () => { + test('when true, adds flat class', () => { + const wrapper = mountPanel({ flat: true }) + expect(wrapper.classes()).toContain('q-card--flat') + expect(wrapper.classes()).toContain('no-shadow') + }) + }) + + describe('[(prop)bordered]', () => { + test('when true, adds bordered class', () => { + const wrapper = mountPanel({ bordered: true }) + expect(wrapper.classes()).toContain('q-card--bordered') + }) + }) + + describe('[(prop)tag]', () => { + test('default tag is div', () => { + const wrapper = mountPanel() + expect(wrapper.element.tagName.toLowerCase()).toBe('div') + }) + + test('custom tag has effect', () => { + const wrapper = mountPanel({ tag: 'section' }) + expect(wrapper.element.tagName.toLowerCase()).toBe('section') + }) + }) + + describe('[(prop)keyboard-increment]', () => { + test('default value is 1', () => { + const wrapper = mountPanel() + expect(wrapper.vm.keyboardIncrement).toBe(1) + }) + + test('custom value has effect', () => { + const wrapper = mountPanel({ keyboardIncrement: 5 }) + expect(wrapper.vm.keyboardIncrement).toBe(5) + }) + + test('affects resize via arrow keys', async () => { + const wrapper = mountPanel({ defaultSize: 50, keyboardIncrement: 5 }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + await handle.trigger('keydown', { key: 'ArrowRight' }) + await flushPromises() + + // Size should increase by keyboardIncrement + expect(wrapper.element.style.width).toBe('55%') + }) + }) + + describe('[(prop)storage]', () => { + test('default value is local', () => { + const wrapper = mountPanel() + expect(wrapper.vm.storage).toBe('local') + }) + + test('group value has effect', () => { + const wrapper = mountPanel({ storage: 'group' }) + expect(wrapper.vm.storage).toBe('group') + }) + }) + + describe('[(prop)no-persist]', () => { + test('default value is false', () => { + const wrapper = mountPanel() + expect(wrapper.vm.noPersist).toBe(false) + }) + + test('when true, disables persistence', async () => { + localStorageMock.clear() + vi.clearAllMocks() + + const wrapper = mountPanel({ id: 'no-persist-test', noPersist: true }) + await flushPromises() + + // Clear any initial calls + vi.clearAllMocks() + + wrapper.vm.setSize(60) + await flushPromises() + + // Should not save to localStorage when noPersist is true + const setItemCalls = localStorageMock.setItem.mock.calls.filter( + call => call[ 0 ] && call[ 0 ].includes('q-dashboard-panel') + ) + expect(setItemCalls.length).toBe(0) + }) + }) + }) + + describe('[Slots]', () => { + describe('[(slot)default]', () => { + test('renders default content', () => { + const wrapper = mountPanel({}, { + slots: { + default: () => 'Default Content' + } + }) + expect(wrapper.text()).toContain('Default Content') + }) + + test('hidden when collapsed', () => { + const wrapper = mountPanel({ collapsed: true }, { + slots: { + default: () => 'Default Content' + } + }) + expect(wrapper.text()).not.toContain('Default Content') + }) + }) + + describe('[(slot)header]', () => { + test('renders header content', () => { + const wrapper = mountPanel({}, { + slots: { + header: () => 'Header Content' + } + }) + expect(wrapper.find('.q-dashboard-panel__header').exists()).toBe(true) + expect(wrapper.text()).toContain('Header Content') + }) + + test('visible when collapsed', () => { + const wrapper = mountPanel({ collapsed: true }, { + slots: { + header: () => 'Header Content' + } + }) + expect(wrapper.text()).toContain('Header Content') + }) + + test('receives scoped props', () => { + const headerFn = vi.fn(() => 'Header') + mountPanel({ collapsed: true, maximized: false }, { + slots: { + header: headerFn + } + }) + + expect(headerFn).toHaveBeenCalled() + const scopedProps = headerFn.mock.calls[ 0 ][ 0 ] + expect(scopedProps).toHaveProperty('collapsed', true) + expect(scopedProps).toHaveProperty('maximized', false) + expect(typeof scopedProps.toggleCollapsed).toBe('function') + expect(typeof scopedProps.toggleMaximized).toBe('function') + }) + }) + + describe('[(slot)body]', () => { + test('renders body content', () => { + const wrapper = mountPanel({}, { + slots: { + body: () => 'Body Content' + } + }) + expect(wrapper.find('.q-dashboard-panel__body').exists()).toBe(true) + expect(wrapper.text()).toContain('Body Content') + }) + + test('hidden when collapsed', () => { + const wrapper = mountPanel({ collapsed: true }, { + slots: { + body: () => 'Body Content' + } + }) + expect(wrapper.find('.q-dashboard-panel__body').exists()).toBe(false) + }) + }) + + describe('[(slot)footer]', () => { + test('renders footer content', () => { + const wrapper = mountPanel({}, { + slots: { + footer: () => 'Footer Content' + } + }) + expect(wrapper.find('.q-dashboard-panel__footer').exists()).toBe(true) + expect(wrapper.text()).toContain('Footer Content') + }) + + test('hidden when collapsed', () => { + const wrapper = mountPanel({ collapsed: true }, { + slots: { + footer: () => 'Footer Content' + } + }) + expect(wrapper.find('.q-dashboard-panel__footer').exists()).toBe(false) + }) + }) + + describe('[(slot)resize-handle]', () => { + test('custom resize handle renders', () => { + const wrapper = mountPanel({}, { + slots: { + 'resize-handle': (props) => 'Custom Handle' + } + }) + expect(wrapper.text()).toContain('Custom Handle') + }) + + test('receives scoped props', () => { + const handleFn = vi.fn(() => 'Handle') + mountPanel({ defaultSize: 50, minSize: 10, maxSize: 90, unit: '%' }, { + slots: { + 'resize-handle': handleFn + } + }) + + expect(handleFn).toHaveBeenCalled() + const scopedProps = handleFn.mock.calls[ 0 ][ 0 ] + expect(typeof scopedProps.onMousedown).toBe('function') + expect(typeof scopedProps.onTouchstart).toBe('function') + expect(typeof scopedProps.onDblclick).toBe('function') + expect(typeof scopedProps.onKeydown).toBe('function') + expect(scopedProps.size).toBe(50) + expect(scopedProps.unit).toBe('%') + expect(scopedProps.minSize).toBe(10) + expect(scopedProps.maxSize).toBe(90) + }) + }) + }) + + describe('[Events]', () => { + describe('[(event)update:collapsed]', () => { + test('emits when collapsed state changes', async () => { + const wrapper = mountPanel() + + wrapper.vm.toggleCollapsed() + await flushPromises() + + expect(wrapper.emitted('update:collapsed')).toBeTruthy() + expect(wrapper.emitted('update:collapsed')[ 0 ]).toEqual([ true ]) + }) + }) + + describe('[(event)update:maximized]', () => { + test('emits when maximized state changes', async () => { + const wrapper = mountPanel() + + wrapper.vm.toggleMaximized() + await flushPromises() + + expect(wrapper.emitted('update:maximized')).toBeTruthy() + expect(wrapper.emitted('update:maximized')[ 0 ]).toEqual([ true ]) + }) + }) + + describe('[(event)collapse]', () => { + test('emits when panel is collapsed', async () => { + const wrapper = mountPanel() + + wrapper.vm.setCollapsed(true) + await flushPromises() + + expect(wrapper.emitted('collapse')).toBeTruthy() + }) + }) + + describe('[(event)expand]', () => { + test('emits when panel is expanded', async () => { + const wrapper = mountPanel({ collapsed: true }) + + wrapper.vm.setCollapsed(false) + await flushPromises() + + expect(wrapper.emitted('expand')).toBeTruthy() + }) + }) + + describe('[(event)maximize]', () => { + test('emits when panel is maximized', async () => { + const wrapper = mountPanel() + + wrapper.vm.setMaximized(true) + await flushPromises() + + expect(wrapper.emitted('maximize')).toBeTruthy() + }) + }) + + describe('[(event)restore]', () => { + test('emits when panel is restored from maximized', async () => { + const wrapper = mountPanel({ maximized: true }) + + wrapper.vm.setMaximized(false) + await flushPromises() + + expect(wrapper.emitted('restore')).toBeTruthy() + }) + }) + + describe('[(event)update:size]', () => { + test('emits when size changes via setSize', async () => { + const wrapper = mountPanel({ defaultSize: 35 }) + + wrapper.vm.setSize(60) + await flushPromises() + + expect(wrapper.emitted('update:size')).toBeTruthy() + expect(wrapper.emitted('update:size')[ 0 ]).toEqual([ 60 ]) + }) + + test('emits when size changes via resize-end', async () => { + const wrapper = mountPanel({ defaultSize: 35 }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + // Simulate resize start + await handle.trigger('mousedown', { clientX: 100 }) + await flushPromises() + + // Simulate resize move + const moveEvent = new MouseEvent('mousemove', { clientX: 150 }) + document.dispatchEvent(moveEvent) + await flushPromises() + + // Simulate resize end + const upEvent = new MouseEvent('mouseup', { clientX: 150 }) + document.dispatchEvent(upEvent) + await flushPromises() + + expect(wrapper.emitted('update:size')).toBeTruthy() + }) + }) + + describe('[(event)resize-start]', () => { + test('emits when resize handle is pressed', async () => { + const wrapper = mountPanel({ defaultSize: 35, unit: '%' }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + await handle.trigger('mousedown', { clientX: 100 }) + await flushPromises() + + expect(wrapper.emitted('resizeStart')).toBeTruthy() + const eventData = wrapper.emitted('resizeStart')[ 0 ][ 0 ] + expect(eventData).toHaveProperty('size') + expect(eventData).toHaveProperty('unit', '%') + }) + + test('emits with correct size and unit', async () => { + const wrapper = mountPanel({ defaultSize: 50, unit: 'px' }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + await handle.trigger('mousedown', { clientX: 100 }) + await flushPromises() + + const eventData = wrapper.emitted('resizeStart')[ 0 ][ 0 ] + expect(eventData.size).toBe(50) + expect(eventData.unit).toBe('px') + }) + }) + + describe('[(event)resize]', () => { + test('emits during resize operation', async () => { + const wrapper = mountPanel({ defaultSize: 35, unit: '%' }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + // Start resize + await handle.trigger('mousedown', { clientX: 100 }) + await flushPromises() + + // Simulate move + const moveEvent = new MouseEvent('mousemove', { clientX: 150 }) + document.dispatchEvent(moveEvent) + await flushPromises() + + // Wait for requestAnimationFrame + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(wrapper.emitted('resize')).toBeTruthy() + const eventData = wrapper.emitted('resize')[ 0 ][ 0 ] + expect(eventData).toHaveProperty('size') + expect(eventData).toHaveProperty('unit', '%') + }) + }) + + describe('[(event)resize-end]', () => { + test('emits when resize operation ends', async () => { + const wrapper = mountPanel({ defaultSize: 35, unit: '%' }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + // Start resize + await handle.trigger('mousedown', { clientX: 100 }) + await flushPromises() + + // Simulate move + const moveEvent = new MouseEvent('mousemove', { clientX: 150 }) + document.dispatchEvent(moveEvent) + await flushPromises() + + // End resize + const upEvent = new MouseEvent('mouseup', { clientX: 150 }) + document.dispatchEvent(upEvent) + await flushPromises() + + expect(wrapper.emitted('resizeEnd')).toBeTruthy() + const eventData = wrapper.emitted('resizeEnd')[ 0 ][ 0 ] + expect(eventData).toHaveProperty('size') + expect(eventData).toHaveProperty('unit', '%') + }) + }) + }) + + describe('[Methods]', () => { + describe('[(method)toggleCollapsed]', () => { + test('toggles collapsed state', async () => { + const wrapper = mountPanel() + + expect(wrapper.classes()).not.toContain('q-dashboard-panel--collapsed') + + wrapper.vm.toggleCollapsed() + await flushPromises() + + expect(wrapper.classes()).toContain('q-dashboard-panel--collapsed') + + wrapper.vm.toggleCollapsed() + await flushPromises() + + expect(wrapper.classes()).not.toContain('q-dashboard-panel--collapsed') + }) + }) + + describe('[(method)toggleMaximized]', () => { + test('toggles maximized state', async () => { + const wrapper = mountPanel() + + expect(wrapper.classes()).not.toContain('q-dashboard-panel--maximized') + + wrapper.vm.toggleMaximized() + await flushPromises() + + expect(wrapper.classes()).toContain('q-dashboard-panel--maximized') + + wrapper.vm.toggleMaximized() + await flushPromises() + + expect(wrapper.classes()).not.toContain('q-dashboard-panel--maximized') + }) + + test('restores previous size after maximize/restore', async () => { + const wrapper = mountPanel({ defaultSize: 40 }) + + expect(wrapper.element.style.width).toBe('40%') + + wrapper.vm.toggleMaximized() + await flushPromises() + + expect(wrapper.element.style.width).toBe('100%') + + wrapper.vm.toggleMaximized() + await flushPromises() + + expect(wrapper.element.style.width).toBe('40%') + }) + }) + + describe('[(method)setSize]', () => { + test('sets size with clamping', async () => { + const wrapper = mountPanel({ minSize: 20, maxSize: 80 }) + + wrapper.vm.setSize(50) + await flushPromises() + + expect(wrapper.element.style.width).toBe('50%') + + // Test clamping to min + wrapper.vm.setSize(10) + await flushPromises() + + expect(wrapper.element.style.width).toBe('20%') + + // Test clamping to max + wrapper.vm.setSize(100) + await flushPromises() + + expect(wrapper.element.style.width).toBe('80%') + }) + }) + + describe('[(method)setCollapsed]', () => { + test('sets collapsed state', async () => { + const wrapper = mountPanel() + + wrapper.vm.setCollapsed(true) + await flushPromises() + + expect(wrapper.classes()).toContain('q-dashboard-panel--collapsed') + + wrapper.vm.setCollapsed(false) + await flushPromises() + + expect(wrapper.classes()).not.toContain('q-dashboard-panel--collapsed') + }) + }) + + describe('[(method)setMaximized]', () => { + test('sets maximized state', async () => { + const wrapper = mountPanel() + + wrapper.vm.setMaximized(true) + await flushPromises() + + expect(wrapper.classes()).toContain('q-dashboard-panel--maximized') + + wrapper.vm.setMaximized(false) + await flushPromises() + + expect(wrapper.classes()).not.toContain('q-dashboard-panel--maximized') + }) + }) + }) + + describe('[Generic]', () => { + describe('[Persistence]', () => { + test('saves state to localStorage', async () => { + const wrapper = mountPanel({ id: 'persist-test' }) + + wrapper.vm.setSize(60) + await flushPromises() + + expect(localStorageMock.setItem).toHaveBeenCalled() + const savedData = JSON.parse(localStorageMock.store[ 'q-dashboard-panel-persist-test' ]) + expect(savedData.size).toBe(60) + }) + + test('loads state from localStorage', async () => { + localStorageMock.store[ 'q-dashboard-panel-load-test' ] = JSON.stringify({ + size: 75, + collapsed: true, + maximized: false + }) + + const wrapper = mountPanel({ id: 'load-test' }) + await flushPromises() + + expect(wrapper.element.style.width).toBe('75%') + expect(wrapper.classes()).toContain('q-dashboard-panel--collapsed') + }) + + test('handles invalid localStorage data gracefully', async () => { + localStorageMock.store[ 'q-dashboard-panel-invalid-test' ] = 'not-valid-json{' + + const wrapper = mountPanel({ id: 'invalid-test', defaultSize: 50 }) + await flushPromises() + + // Should use default values + expect(wrapper.element.style.width).toBe('50%') + }) + + test('clamps persisted values to valid range', async () => { + localStorageMock.store[ 'q-dashboard-panel-clamp-test' ] = JSON.stringify({ + size: 200, // Exceeds max + collapsed: false, + maximized: false + }) + + const wrapper = mountPanel({ id: 'clamp-test', maxSize: 100 }) + await flushPromises() + + expect(wrapper.element.style.width).toBe('100%') + }) + }) + + describe('[Mobile Behavior]', () => { + test('panel has full width on mobile', () => { + const wrapper = mountPanel({}, { screen: { xs: true, sm: false } }) + + expect(wrapper.element.style.width).toBe('100%') + expect(wrapper.classes()).toContain('q-dashboard-panel--mobile') + }) + + test('resize handle hidden on mobile by default', () => { + const wrapper = mountPanel({}, { screen: { xs: true, sm: false } }) + + expect(wrapper.find('.q-dashboard-panel__resize-handle').exists()).toBe(false) + }) + + test('sm breakpoint also triggers mobile behavior', () => { + const wrapper = mountPanel({}, { screen: { xs: false, sm: true } }) + + expect(wrapper.element.style.width).toBe('100%') + expect(wrapper.classes()).toContain('q-dashboard-panel--mobile') + }) + }) + + describe('[Accessibility]', () => { + test('resize handle has correct ARIA attributes', () => { + const wrapper = mountPanel({ defaultSize: 50, minSize: 10, maxSize: 90 }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + expect(handle.attributes('role')).toBe('separator') + expect(handle.attributes('aria-orientation')).toBe('vertical') + expect(handle.attributes('aria-valuenow')).toBe('50') + expect(handle.attributes('aria-valuemin')).toBe('10') + expect(handle.attributes('aria-valuemax')).toBe('90') + }) + + test('resize handle is focusable', () => { + const wrapper = mountPanel() + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + expect(handle.attributes('tabindex')).toBe('0') + }) + + test('resize handle tabindex is -1 when disabled', () => { + const wrapper = mountPanel({ maximized: true }) + const handle = wrapper.find('.q-dashboard-panel__resize-handle') + + expect(handle.attributes('tabindex')).toBe('-1') + }) + }) + + describe('[QCard Inheritance]', () => { + test('passes QCard props through', () => { + const wrapper = mountPanel({ + dark: true, + square: true, + flat: true, + bordered: true + }) + + expect(wrapper.classes()).toContain('q-card') + expect(wrapper.classes()).toContain('q-card--dark') + expect(wrapper.classes()).toContain('q-card--square') + expect(wrapper.classes()).toContain('q-card--flat') + expect(wrapper.classes()).toContain('q-card--bordered') + }) + + test('passes attributes through', () => { + const wrapper = mountPanel({}, { + attrs: { + 'data-custom': 'value', + 'aria-label': 'Dashboard Panel' + } + }) + + expect(wrapper.attributes('data-custom')).toBe('value') + expect(wrapper.attributes('aria-label')).toBe('Dashboard Panel') + }) + }) + }) +}) diff --git a/ui/src/components/dashboard-panel/index.js b/ui/src/components/dashboard-panel/index.js new file mode 100644 index 00000000000..62c4b8cb27a --- /dev/null +++ b/ui/src/components/dashboard-panel/index.js @@ -0,0 +1,5 @@ +import QDashboardPanel from './QDashboardPanel.js' + +export { + QDashboardPanel +} diff --git a/ui/src/composables.js b/ui/src/composables.js index c6e8d8a9b96..d2ac3079e3f 100644 --- a/ui/src/composables.js +++ b/ui/src/composables.js @@ -1,3 +1,4 @@ +import useDashboardPanels, { useDashboardPanelsContext, convertSize as convertDashboardPanelSize } from './composables/use-dashboard-panels/use-dashboard-panels.js' import useDialogPluginComponent from './composables/use-dialog-plugin-component/use-dialog-plugin-component.js' import useFormChild from './composables/use-form/use-form-child.js' import useMeta from './composables/use-meta/use-meta.js' @@ -12,6 +13,9 @@ import useTick from './composables/use-tick/use-tick.js' import useTimeout from './composables/use-timeout/use-timeout.js' export { + useDashboardPanels, + useDashboardPanelsContext, + convertDashboardPanelSize, useDialogPluginComponent, useFormChild, useMeta, diff --git a/ui/src/composables/use-dashboard-panels/use-dashboard-panels.js b/ui/src/composables/use-dashboard-panels/use-dashboard-panels.js new file mode 100644 index 00000000000..f9f2d15baf5 --- /dev/null +++ b/ui/src/composables/use-dashboard-panels/use-dashboard-panels.js @@ -0,0 +1,262 @@ +import { ref, reactive, provide, inject, readonly } from 'vue' + +const DASHBOARD_PANELS_KEY = Symbol('dashboardPanels') +const STORAGE_KEY_PREFIX = 'q-dashboard-panels' + +/** + * Clamp a value between min and max + */ +function clamp (value, min, max) { + return Math.min(Math.max(value, min), max) +} + +/** + * Safely parse JSON from localStorage + */ +function safeParseJSON (str, fallback = null) { + if (str === null || str === void 0) { + return fallback + } + + try { + const parsed = JSON.parse(str) + return parsed === null ? fallback : parsed + } + catch { + return fallback + } +} + +/** + * Check if localStorage is available + */ +function isLocalStorageAvailable () { + try { + const testKey = '__q_test__' + localStorage.setItem(testKey, testKey) + localStorage.removeItem(testKey) + return true + } + catch { + return false + } +} + +/** + * Convert size between units + * @param {number} value - The size value + * @param {string} fromUnit - Source unit (%, px, rem) + * @param {string} toUnit - Target unit (%, px, rem) + * @param {number} containerSize - Container size in pixels (for % conversions) + * @param {number} rootFontSize - Root font size (for rem conversions) + */ +export function convertSize (value, fromUnit, toUnit, containerSize = 0, rootFontSize = 16) { + if (fromUnit === toUnit) return value + + // First convert to pixels + let px = value + if (fromUnit === '%') { + px = (value / 100) * containerSize + } + else if (fromUnit === 'rem') { + px = value * rootFontSize + } + + // Then convert from pixels to target unit + if (toUnit === 'px') return px + if (toUnit === '%') return containerSize > 0 ? (px / containerSize) * 100 : 0 + if (toUnit === 'rem') return px / rootFontSize + + return value +} + +/** + * Composable for managing a group of dashboard panels with shared state. + * Provides centralized storage and state management for multiple panels. + * + * @param {Object} options - Configuration options + * @param {string} options.storageKey - Key for localStorage (default: 'default') + * @param {boolean} options.persist - Whether to persist state (default: true) + * @returns {Object} Dashboard panels context + */ +export function useDashboardPanels (options = {}) { + const { + storageKey = 'default', + persist = true + } = options + + const fullStorageKey = `${ STORAGE_KEY_PREFIX }-${ storageKey }` + const storageAvailable = isLocalStorageAvailable() + + // Panel states: { [panelId]: { size, collapsed, maximized } } + const panelStates = reactive({}) + + // Track registered panels + const registeredPanels = ref(new Set()) + + /** + * Load persisted state from localStorage + */ + function loadState () { + if (!persist || !storageAvailable) return + + const stored = localStorage.getItem(fullStorageKey) + const parsed = safeParseJSON(stored, {}) + + Object.keys(parsed).forEach(id => { + if (parsed[ id ] && typeof parsed[ id ] === 'object') { + panelStates[ id ] = { + size: parsed[ id ].size, + collapsed: Boolean(parsed[ id ].collapsed), + maximized: Boolean(parsed[ id ].maximized) + } + } + }) + } + + /** + * Save current state to localStorage + */ + function saveState () { + if (!persist || !storageAvailable) return + + try { + localStorage.setItem(fullStorageKey, JSON.stringify(panelStates)) + } + catch { + // Quota exceeded or other storage error - fail silently + } + } + + /** + * Register a panel with the group + */ + function registerPanel (id, initialState = {}) { + registeredPanels.value.add(id) + + // Use persisted state if available, otherwise use initial state + if (!panelStates[ id ]) { + panelStates[ id ] = { + size: initialState.size ?? 35, + collapsed: initialState.collapsed ?? false, + maximized: initialState.maximized ?? false + } + } + + return panelStates[ id ] + } + + /** + * Unregister a panel from the group + */ + function unregisterPanel (id) { + registeredPanels.value.delete(id) + } + + /** + * Get panel state + */ + function getPanelState (id) { + return panelStates[ id ] || null + } + + /** + * Update panel size + */ + function setPanelSize (id, size, minSize = 15, maxSize = 100) { + if (!panelStates[ id ]) return + + panelStates[ id ].size = clamp(size, minSize, maxSize) + saveState() + } + + /** + * Update panel collapsed state + */ + function setPanelCollapsed (id, collapsed) { + if (!panelStates[ id ]) return + + panelStates[ id ].collapsed = collapsed + saveState() + } + + /** + * Update panel maximized state + */ + function setPanelMaximized (id, maximized) { + if (!panelStates[ id ]) return + + panelStates[ id ].maximized = maximized + saveState() + } + + /** + * Toggle panel collapsed state + */ + function toggleCollapsed (id) { + if (!panelStates[ id ]) return + + panelStates[ id ].collapsed = !panelStates[ id ].collapsed + saveState() + } + + /** + * Toggle panel maximized state + */ + function toggleMaximized (id) { + if (!panelStates[ id ]) return + + panelStates[ id ].maximized = !panelStates[ id ].maximized + saveState() + } + + /** + * Reset all panels to their default state + */ + function resetAll () { + Object.keys(panelStates).forEach(id => { + delete panelStates[ id ] + }) + + if (persist && storageAvailable) { + localStorage.removeItem(fullStorageKey) + } + } + + // Load persisted state on initialization + loadState() + + const context = { + panelStates: readonly(panelStates), + registeredPanels: readonly(registeredPanels), + registerPanel, + unregisterPanel, + getPanelState, + setPanelSize, + setPanelCollapsed, + setPanelMaximized, + toggleCollapsed, + toggleMaximized, + resetAll, + saveState + } + + // Provide context to child components + provide(DASHBOARD_PANELS_KEY, context) + + return context +} + +/** + * Inject dashboard panels context from parent + * Returns null if no parent provider exists + */ +export function useDashboardPanelsContext () { + return inject(DASHBOARD_PANELS_KEY, null) +} + +// Export symbols for advanced usage +useDashboardPanels.key = DASHBOARD_PANELS_KEY +useDashboardPanels.storageKeyPrefix = STORAGE_KEY_PREFIX + +export default useDashboardPanels diff --git a/ui/src/composables/use-dashboard-panels/use-dashboard-panels.json b/ui/src/composables/use-dashboard-panels/use-dashboard-panels.json new file mode 100644 index 00000000000..fe8dcbc8be2 --- /dev/null +++ b/ui/src/composables/use-dashboard-panels/use-dashboard-panels.json @@ -0,0 +1,221 @@ +{ + "meta": { + "docsUrl": "https://v2.quasar.dev/vue-composables/use-dashboard-panels" + }, + + "returns": { + "panelStates": { + "type": "Object", + "desc": "Reactive readonly object containing all panel states keyed by panel ID", + "definition": { + "[panelId]": { + "type": "Object", + "desc": "Panel state object", + "definition": { + "size": { + "type": "Number", + "desc": "Current size value" + }, + "collapsed": { + "type": "Boolean", + "desc": "Collapsed state" + }, + "maximized": { + "type": "Boolean", + "desc": "Maximized state" + } + } + } + } + }, + + "registeredPanels": { + "type": "Object", + "tsType": "Ref>", + "desc": "Reactive readonly Set containing IDs of all registered panels" + }, + + "registerPanel": { + "type": "Function", + "desc": "Register a panel with the group", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Unique panel identifier" + }, + "initialState": { + "type": "Object", + "desc": "Initial state for the panel", + "definition": { + "size": { + "type": "Number", + "desc": "Initial size" + }, + "collapsed": { + "type": "Boolean", + "desc": "Initial collapsed state" + }, + "maximized": { + "type": "Boolean", + "desc": "Initial maximized state" + } + } + } + }, + "returns": { + "type": "Object", + "desc": "The panel's state object" + } + }, + + "unregisterPanel": { + "type": "Function", + "desc": "Unregister a panel from the group", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier to unregister" + } + } + }, + + "getPanelState": { + "type": "Function", + "desc": "Get the current state of a panel", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier" + } + }, + "returns": { + "type": [ "Object", "null" ], + "desc": "Panel state object or null if not found" + } + }, + + "setPanelSize": { + "type": "Function", + "desc": "Set a panel's size", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier" + }, + "size": { + "type": "Number", + "required": true, + "desc": "New size value" + }, + "minSize": { + "type": "Number", + "desc": "Minimum size for clamping", + "default": "15" + }, + "maxSize": { + "type": "Number", + "desc": "Maximum size for clamping", + "default": "100" + } + } + }, + + "setPanelCollapsed": { + "type": "Function", + "desc": "Set a panel's collapsed state", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier" + }, + "collapsed": { + "type": "Boolean", + "required": true, + "desc": "New collapsed state" + } + } + }, + + "setPanelMaximized": { + "type": "Function", + "desc": "Set a panel's maximized state", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier" + }, + "maximized": { + "type": "Boolean", + "required": true, + "desc": "New maximized state" + } + } + }, + + "toggleCollapsed": { + "type": "Function", + "desc": "Toggle a panel's collapsed state", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier" + } + } + }, + + "toggleMaximized": { + "type": "Function", + "desc": "Toggle a panel's maximized state", + "params": { + "id": { + "type": "String", + "required": true, + "desc": "Panel identifier" + } + } + }, + + "resetAll": { + "type": "Function", + "desc": "Reset all panels to their default state and clear persisted storage" + }, + + "saveState": { + "type": "Function", + "desc": "Manually save the current state to storage" + } + }, + + "params": { + "options": { + "type": "Object", + "desc": "Configuration options", + "definition": { + "storageKey": { + "type": "String", + "desc": "Key for localStorage", + "default": "'default'" + }, + "persist": { + "type": "Boolean", + "desc": "Whether to persist state to localStorage", + "default": "true" + } + } + } + } +} + + + + + + + diff --git a/ui/src/composables/use-dashboard-panels/use-dashboard-panels.test.js b/ui/src/composables/use-dashboard-panels/use-dashboard-panels.test.js new file mode 100644 index 00000000000..979a9bf77f8 --- /dev/null +++ b/ui/src/composables/use-dashboard-panels/use-dashboard-panels.test.js @@ -0,0 +1,419 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +import useDashboardPanels, { + useDashboardPanelsContext, + convertSize +} from './use-dashboard-panels.js' + +// Mock localStorage +const localStorageMock = (() => { + let store = {} + return { + getItem: vi.fn(key => store[ key ] || null), + setItem: vi.fn((key, value) => { store[ key ] = value }), + removeItem: vi.fn(key => { delete store[ key ] }), + clear: vi.fn(() => { store = {} }), + get store () { return store } + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}) + +// Helper to create a test component and capture the API +// This works around Vue Test Utils not properly exposing complex objects +function createTestHarness (setupFn) { + let capturedApi = null + + const TestComponent = defineComponent({ + setup () { + capturedApi = setupFn() + return {} + }, + template: '
' + }) + + const wrapper = mount(TestComponent) + return { wrapper, api: capturedApi } +} + +describe('[useDashboardPanels API]', () => { + beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + }) + + describe('[Functions]', () => { + describe('[(function)convertSize]', () => { + test('returns same value when units match', () => { + expect(convertSize(50, '%', '%')).toBe(50) + expect(convertSize(300, 'px', 'px')).toBe(300) + expect(convertSize(20, 'rem', 'rem')).toBe(20) + }) + + test('converts % to px', () => { + const result = convertSize(50, '%', 'px', 1000) + expect(result).toBe(500) + }) + + test('converts px to %', () => { + const result = convertSize(500, 'px', '%', 1000) + expect(result).toBe(50) + }) + + test('converts px to rem', () => { + const result = convertSize(32, 'px', 'rem', 0, 16) + expect(result).toBe(2) + }) + + test('converts rem to px', () => { + const result = convertSize(2, 'rem', 'px', 0, 16) + expect(result).toBe(32) + }) + + test('converts % to rem', () => { + const result = convertSize(50, '%', 'rem', 1000, 16) + expect(result).toBe(31.25) // 500px / 16 + }) + + test('handles zero container size', () => { + const result = convertSize(500, 'px', '%', 0) + expect(result).toBe(0) + }) + }) + + describe('[(function)useDashboardPanels]', () => { + test('can be used in a Vue Component', () => { + const wrapper = mount( + defineComponent({ + template: '
', + setup () { + const result = useDashboardPanels() + return { result } + } + }) + ) + + expect(wrapper.vm.result).toBeTypeOf('object') + expect(wrapper.vm.result).toHaveProperty('panelStates') + expect(wrapper.vm.result).toHaveProperty('registeredPanels') + expect(typeof wrapper.vm.result.registerPanel).toBe('function') + }) + + test('accepts options parameter', () => { + const wrapper = mount( + defineComponent({ + template: '
', + setup () { + const result = useDashboardPanels({ storageKey: 'test-key', persist: true }) + return { result } + } + }) + ) + + expect(wrapper.vm.result).toBeTypeOf('object') + expect(wrapper.vm.result).toHaveProperty('panelStates') + }) + }) + + describe('[(function)useDashboardPanelsContext]', () => { + test('returns null when no provider exists', () => { + let context = null + + const TestComponent = defineComponent({ + setup () { + context = useDashboardPanelsContext() + return {} + }, + template: '
' + }) + + mount(TestComponent) + + expect(context).toBe(null) + }) + + test('returns context when provider exists', () => { + let childContext = null + + const ChildComponent = defineComponent({ + setup () { + childContext = useDashboardPanelsContext() + return {} + }, + template: '
' + }) + + const ParentComponent = defineComponent({ + components: { ChildComponent }, + setup () { + useDashboardPanels() + return {} + }, + template: '' + }) + + mount(ParentComponent) + + expect(childContext).not.toBe(null) + expect(typeof childContext.registerPanel).toBe('function') + }) + }) + }) + + describe('[Generic]', () => { + describe('[Return Values]', () => { + test('returns expected API', () => { + const { api } = createTestHarness(() => useDashboardPanels()) + + expect(api).toHaveProperty('panelStates') + expect(api).toHaveProperty('registeredPanels') + expect(typeof api.registerPanel).toBe('function') + expect(typeof api.unregisterPanel).toBe('function') + expect(typeof api.getPanelState).toBe('function') + expect(typeof api.setPanelSize).toBe('function') + expect(typeof api.setPanelCollapsed).toBe('function') + expect(typeof api.setPanelMaximized).toBe('function') + expect(typeof api.toggleCollapsed).toBe('function') + expect(typeof api.toggleMaximized).toBe('function') + expect(typeof api.resetAll).toBe('function') + expect(typeof api.saveState).toBe('function') + }) + }) + + describe('[Panel Registration]', () => { + test('registerPanel adds panel to state', () => { + let state = null + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + state = api.registerPanel('panel-1', { + size: 50, + collapsed: false, + maximized: false + }) + return api + }) + + expect(state).toEqual({ + size: 50, + collapsed: false, + maximized: false + }) + expect(api.registeredPanels.value.has('panel-1')).toBe(true) + }) + + test('unregisterPanel removes panel from tracking', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { size: 50 }) + api.unregisterPanel('panel-1') + return api + }) + + expect(api.registeredPanels.value.has('panel-1')).toBe(false) + }) + + test('getPanelState returns null for unregistered panel', () => { + const { api } = createTestHarness(() => useDashboardPanels()) + + expect(api.getPanelState('non-existent')).toBe(null) + }) + }) + + describe('[State Management]', () => { + test('setPanelSize updates size with clamping', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { size: 50 }) + return api + }) + + // Normal update + api.setPanelSize('panel-1', 60) + expect(api.getPanelState('panel-1').size).toBe(60) + + // Clamp to min + api.setPanelSize('panel-1', 5, 15, 100) + expect(api.getPanelState('panel-1').size).toBe(15) + + // Clamp to max + api.setPanelSize('panel-1', 150, 15, 100) + expect(api.getPanelState('panel-1').size).toBe(100) + }) + + test('setPanelCollapsed updates collapsed state', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { collapsed: false }) + return api + }) + + api.setPanelCollapsed('panel-1', true) + expect(api.getPanelState('panel-1').collapsed).toBe(true) + + api.setPanelCollapsed('panel-1', false) + expect(api.getPanelState('panel-1').collapsed).toBe(false) + }) + + test('setPanelMaximized updates maximized state', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { maximized: false }) + return api + }) + + api.setPanelMaximized('panel-1', true) + expect(api.getPanelState('panel-1').maximized).toBe(true) + }) + + test('toggleCollapsed toggles state', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { collapsed: false }) + return api + }) + + api.toggleCollapsed('panel-1') + expect(api.getPanelState('panel-1').collapsed).toBe(true) + + api.toggleCollapsed('panel-1') + expect(api.getPanelState('panel-1').collapsed).toBe(false) + }) + + test('toggleMaximized toggles state', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { maximized: false }) + return api + }) + + api.toggleMaximized('panel-1') + expect(api.getPanelState('panel-1').maximized).toBe(true) + + api.toggleMaximized('panel-1') + expect(api.getPanelState('panel-1').maximized).toBe(false) + }) + }) + + describe('[Persistence]', () => { + test('saves state to localStorage', () => { + createTestHarness(() => { + const api = useDashboardPanels({ storageKey: 'test-group' }) + api.registerPanel('panel-1', { size: 50 }) + api.setPanelSize('panel-1', 60) + return api + }) + + expect(localStorageMock.setItem).toHaveBeenCalled() + const savedData = JSON.parse(localStorageMock.store[ 'q-dashboard-panels-test-group' ]) + expect(savedData[ 'panel-1' ].size).toBe(60) + }) + + test('loads state from localStorage', () => { + localStorageMock.store[ 'q-dashboard-panels-load-test' ] = JSON.stringify({ + 'panel-1': { size: 75, collapsed: true, maximized: false } + }) + + let state = null + createTestHarness(() => { + const api = useDashboardPanels({ storageKey: 'load-test' }) + state = api.registerPanel('panel-1', { size: 50 }) + return api + }) + + expect(state.size).toBe(75) + expect(state.collapsed).toBe(true) + }) + + test('persist: false disables persistence', () => { + // Clear mocks before this test to isolate from isLocalStorageAvailable check + vi.clearAllMocks() + + createTestHarness(() => { + const api = useDashboardPanels({ persist: false }) + api.registerPanel('panel-1', { size: 50 }) + api.setPanelSize('panel-1', 60) + return api + }) + + // Filter out the isLocalStorageAvailable test calls + const saveCalls = localStorageMock.setItem.mock.calls.filter( + call => call[ 0 ] !== '__q_test__' + ) + expect(saveCalls.length).toBe(0) + }) + + test('resetAll clears all state and storage', () => { + localStorageMock.store[ 'q-dashboard-panels-reset-test' ] = JSON.stringify({ + 'panel-1': { size: 75 } + }) + + const { api } = createTestHarness(() => { + const api = useDashboardPanels({ storageKey: 'reset-test' }) + api.registerPanel('panel-1', { size: 50 }) + return api + }) + + api.resetAll() + + expect(localStorageMock.removeItem).toHaveBeenCalledWith('q-dashboard-panels-reset-test') + expect(api.getPanelState('panel-1')).toBe(null) + }) + }) + + describe('[Multiple Panels]', () => { + test('manages multiple panels independently', () => { + const { api } = createTestHarness(() => { + const api = useDashboardPanels() + api.registerPanel('panel-1', { size: 30 }) + api.registerPanel('panel-2', { size: 40 }) + api.registerPanel('panel-3', { size: 50 }) + return api + }) + + api.setPanelSize('panel-1', 35) + api.setPanelCollapsed('panel-2', true) + api.setPanelMaximized('panel-3', true) + + expect(api.getPanelState('panel-1').size).toBe(35) + expect(api.getPanelState('panel-1').collapsed).toBe(false) + + expect(api.getPanelState('panel-2').size).toBe(40) + expect(api.getPanelState('panel-2').collapsed).toBe(true) + + expect(api.getPanelState('panel-3').size).toBe(50) + expect(api.getPanelState('panel-3').maximized).toBe(true) + }) + }) + + describe('[Error Handling]', () => { + test('handles operations on non-existent panels', () => { + const { api } = createTestHarness(() => useDashboardPanels()) + + // These should not throw + expect(() => api.setPanelSize('non-existent', 50)).not.toThrow() + expect(() => api.setPanelCollapsed('non-existent', true)).not.toThrow() + expect(() => api.setPanelMaximized('non-existent', true)).not.toThrow() + expect(() => api.toggleCollapsed('non-existent')).not.toThrow() + expect(() => api.toggleMaximized('non-existent')).not.toThrow() + }) + + test('handles invalid JSON in localStorage', () => { + localStorageMock.store[ 'q-dashboard-panels-invalid' ] = 'not-valid-json{' + + let state = null + createTestHarness(() => { + const api = useDashboardPanels({ storageKey: 'invalid' }) + state = api.registerPanel('panel-1', { size: 50 }) + return api + }) + + // Should use default values + expect(state.size).toBe(50) + }) + }) + }) +}) diff --git a/ui/src/css/index.sass b/ui/src/css/index.sass index 7fbc11a1a81..4d193c2477d 100644 --- a/ui/src/css/index.sass +++ b/ui/src/css/index.sass @@ -27,6 +27,7 @@ @import '../components/chip/QChip.sass' @import '../components/circular-progress/QCircularProgress.sass' @import '../components/color/QColor.sass' +@import '../components/dashboard-panel/QDashboardPanel.sass' @import '../components/date/QDate.sass' @import '../components/dialog/QDialog.sass' @import '../components/editor/QEditor.sass'