Svelte drop downs for Version and Message Docs

This commit is contained in:
simonredfern 2025-12-08 19:25:01 +01:00
parent 046ba2d96d
commit 1a8dfb3975
8 changed files with 539 additions and 50 deletions

7
components.d.ts vendored
View File

@ -22,9 +22,6 @@ declare module 'vue' {
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElContainter: typeof import('element-plus/es')['ElContainter']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
@ -36,8 +33,6 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElTag: typeof import('element-plus/es')['ElTag']
@ -45,12 +40,12 @@ declare module 'vue' {
GlossarySearchNav: typeof import('./src/components/GlossarySearchNav.vue')['default']
HeaderNav: typeof import('./src/components/HeaderNav.vue')['default']
Menu: typeof import('./src/components/Menu.vue')['default']
MessageDocsContent: typeof import('./src/components/MessageDocsContent.vue')['default']
MessageDocsSearchNav: typeof import('./src/components/MessageDocsSearchNav.vue')['default']
Preview: typeof import('./src/components/Preview.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SearchNav: typeof import('./src/components/SearchNav.vue')['default']
SvelteDropdown: typeof import('./src/components/SvelteDropdown.vue')['default']
ToolCall: typeof import('./src/components/ToolCall.vue')['default']
}
}

258
src-svelte/Dropdown.svelte Normal file
View File

@ -0,0 +1,258 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script lang="ts">
interface Props {
label?: string
items?: string[]
hoverColor?: string
backgroundColor?: string
}
let {
label = 'Dropdown',
items = [],
hoverColor = '#32b9ce',
backgroundColor = '#e8f4f8'
}: Props = $props()
let isOpen = $state(false)
let dropdownRef = $state<HTMLDivElement>()
let timeoutId: number | null = null
function handleMouseEnter() {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
isOpen = true
}
function handleMouseLeave() {
timeoutId = window.setTimeout(() => {
isOpen = false
}, 1000)
}
function handleSelect(item: string) {
const event = new CustomEvent('select', {
detail: item,
bubbles: true,
composed: true
})
dropdownRef?.dispatchEvent(event)
isOpen = false
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
isOpen = false
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}
})
</script>
<div
bind:this={dropdownRef}
class="dropdown-container"
style:--hover-bg={backgroundColor}
style:--hover-color={hoverColor}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role="navigation"
>
<button
class="dropdown-trigger"
onclick={() => isOpen = !isOpen}
aria-expanded={isOpen}
aria-haspopup="true"
>
{label}
<svg
class="arrow-icon"
class:rotated={isOpen}
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="currentColor" d="M831.872 340.864 512 652.672 192.128 340.864a30.592 30.592 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.592 30.592 0 0 0-42.752 0z"></path>
</svg>
</button>
{#if isOpen}
<div class="dropdown-menu">
<div class="dropdown-content">
{#each items as item}
<button
class="dropdown-item"
onclick={() => handleSelect(item)}
>
{item}
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.dropdown-container {
position: relative;
display: inline-block;
}
.dropdown-trigger {
padding: 9px;
margin: 3px;
color: #39455f;
font-family: 'Roboto', sans-serif;
font-size: 14px;
text-decoration: none;
border-radius: 8px;
background: transparent;
border: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
}
.dropdown-trigger:hover {
background-color: var(--hover-bg) !important;
color: var(--hover-color) !important;
}
.arrow-icon {
width: 14px;
height: 14px;
transition: transform 0.3s ease;
}
.arrow-icon.rotated {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: white;
border: 1px solid #e4e7ed;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
z-index: 2000;
min-width: 180px;
max-width: 280px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-content {
max-height: 400px;
overflow-y: auto;
padding: 6px 0;
}
.dropdown-item {
width: 100%;
padding: 10px 20px;
margin: 0;
color: #606266;
font-family: 'Roboto', sans-serif;
font-size: 14px;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-item:hover {
background-color: var(--hover-bg);
color: var(--hover-color);
}
.dropdown-item:active {
background-color: var(--hover-bg);
opacity: 0.8;
}
/* Custom scrollbar styling */
.dropdown-content::-webkit-scrollbar {
width: 6px;
}
.dropdown-content::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
.dropdown-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.dropdown-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Firefox scrollbar */
.dropdown-content {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f5f5f5;
}
</style>

View File

@ -27,7 +27,6 @@
<script setup lang="ts">
import { ref, inject, watchEffect, onMounted, computed } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { OBP_API_DEFAULT_RESOURCE_DOC_VERSION, getCurrentUser } from '../obp'
import { getOBPAPIVersions } from '../obp/api-version'
@ -38,6 +37,7 @@ import {
HEADER_LINKS_BACKGROUND_COLOR as headerLinksBackgroundColorSetting
} from '../obp/style-setting'
import { obpApiActiveVersionsKey, obpGroupedMessageDocsKey, obpMyCollectionsEndpointKey } from '@/obp/keys'
import SvelteDropdown from './SvelteDropdown.vue'
const route = useRoute()
const router = useRouter()
@ -82,7 +82,11 @@ const handleMore = (command: string) => {
if (element !== null) {
element.textContent = command;
}
if (command.includes('_')) {
if (command === '/message-docs') {
// Navigate to message docs list
console.log('Navigating to message docs list')
router.push({ name: 'message-docs-list' })
} else if (command.includes('_')) {
console.log('Navigating to message docs:', command)
router.push({ name: 'message-docs', params: { id: command } })
} else {
@ -145,31 +149,24 @@ const getCurrentPath = () => {
<a v-if="showObpApiManagerButton && hasObpApiManagerHost" v-bind:href="obpApiManagerHost" class="router-link" id="header-nav-api-manager">
{{ $t('header.api_manager') }}
</a>
<el-dropdown
class="menu-right router-link"
id="header-nav-more"
@command="handleMore"
trigger="hover"
placement="bottom-end"
:teleported="true"
max-height="700px"
>
<span class="el-dropdown-link">
{{ $t('header.more') }}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="value in obpApiVersions" :command="value" :key="value">{{
value
}}</el-dropdown-item>
<el-dropdown-item v-for="value in obpMessageDocs" :command="value" :key="value">
Message Docs for: {{ value }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<SvelteDropdown
class="menu-right"
id="header-nav-versions"
label="Versions"
:items="obpApiVersions"
:hover-color="headerLinksHoverColor"
:background-color="headerLinksBackgroundColor"
@select="handleMore"
/>
<SvelteDropdown
class="menu-right"
id="header-nav-message-docs"
label="Message Docs"
:items="obpMessageDocs"
:hover-color="headerLinksHoverColor"
:background-color="headerLinksBackgroundColor"
@select="handleMore"
/>
<!--<span class="el-dropdown-link">
<RouterLink class="router-link" id="header-nav-spaces" to="/spaces">{{
$t('header.spaces')
@ -261,21 +258,10 @@ a.logoff-button {
color: #39455f;
}
/*override element plus*/
.el-dropdown-menu__item:hover {
color: v-bind(headerLinksHoverColor) !important;
}
/* Fix dropdown menu overflow */
.el-dropdown-menu {
max-height: 400px;
overflow-y: auto;
}
/* Ensure dropdown trigger behaves correctly */
#header-nav-more .el-dropdown-link {
cursor: pointer;
display: inline-flex;
align-items: center;
/* Custom dropdown containers */
#header-nav-versions,
#header-nav-message-docs {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@ -0,0 +1,123 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { mount, unmount } from 'svelte'
import Dropdown from '../../src-svelte/Dropdown.svelte'
interface Props {
label?: string
items?: string[]
hoverColor?: string
backgroundColor?: string
}
const props = withDefaults(defineProps<Props>(), {
label: 'Dropdown',
items: () => [],
hoverColor: '#32b9ce',
backgroundColor: '#e8f4f8'
})
const emit = defineEmits<{
select: [value: string]
}>()
const containerRef = ref<HTMLDivElement>()
let svelteComponent: any = null
onMounted(() => {
if (containerRef.value) {
try {
svelteComponent = mount(Dropdown, {
target: containerRef.value,
props: {
label: props.label,
items: props.items,
hoverColor: props.hoverColor,
backgroundColor: props.backgroundColor
}
})
// Listen for the custom 'select' event from Svelte component
containerRef.value.addEventListener('select', (event: Event) => {
const customEvent = event as CustomEvent
emit('select', customEvent.detail)
})
} catch (error) {
console.error('Failed to mount Svelte Dropdown:', error)
}
}
})
onBeforeUnmount(() => {
if (svelteComponent) {
try {
unmount(svelteComponent)
} catch (error) {
console.error('Failed to unmount Svelte Dropdown:', error)
}
}
})
// Watch for prop changes and update Svelte component
watch(
() => props.items,
(newItems) => {
if (svelteComponent && containerRef.value) {
// Remount with new props
unmount(svelteComponent)
svelteComponent = mount(Dropdown, {
target: containerRef.value,
props: {
label: props.label,
items: newItems,
hoverColor: props.hoverColor,
backgroundColor: props.backgroundColor
}
})
// Re-add event listener
containerRef.value.addEventListener('select', (event: Event) => {
const customEvent = event as CustomEvent
emit('select', customEvent.detail)
})
}
}
)
</script>
<template>
<div ref="containerRef" class="svelte-dropdown-wrapper"></div>
</template>
<style scoped>
.svelte-dropdown-wrapper {
display: inline-block;
}
</style>

View File

@ -29,6 +29,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import GlossaryView from '../views/GlossaryView.vue'
import HelpView from '../views/HelpView.vue'
import MessageDocsView from '../views/MessageDocsView.vue'
import MessageDocsListView from '../views/MessageDocsListView.vue'
import BodyView from '../views/BodyView.vue'
import Content from '../components/Content.vue'
import Preview from '../components/Preview.vue'
@ -63,6 +64,11 @@ export default async function router(): Promise<any> {
name: 'help',
component: isServerActive ? HelpView : InternalServerErrorView
},
{
path: '/message-docs',
name: 'message-docs-list',
component: isServerActive ? MessageDocsListView : InternalServerErrorView
},
{
path: '/message-docs/:id',
name: 'message-docs',

View File

@ -0,0 +1,107 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import { useRouter } from 'vue-router'
import { obpGroupedMessageDocsKey } from '@/obp/keys'
const router = useRouter()
const groupedMessageDocs = ref(inject(obpGroupedMessageDocsKey)!)
const connectorList = computed(() => {
return Object.keys(groupedMessageDocs.value || {}).sort()
})
function navigateToConnector(connectorId: string) {
router.push(`/message-docs/${connectorId}`)
}
</script>
<template>
<el-container class="message-docs-list-container">
<el-main>
<h1>Message Documentation</h1>
<div class="message-docs-list">
<div v-if="connectorList.length === 0" class="empty-message">
No message documentation available
</div>
<div v-else>
<a
v-for="connector in connectorList"
:key="connector"
@click="navigateToConnector(connector)"
class="message-doc-link"
>
{{ connector }}
</a>
</div>
</div>
</el-main>
</el-container>
</template>
<style scoped>
.message-docs-list-container {
min-height: calc(100vh - 60px);
padding: 2rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #303133;
margin-bottom: 1.5rem;
}
.message-docs-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message-doc-link {
padding: 12px 16px;
color: #409eff;
text-decoration: none;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
display: block;
}
.message-doc-link:hover {
background-color: #ecf5ff;
color: #337ecc;
}
.empty-message {
color: #909399;
font-style: italic;
}
</style>

12
svelte.config.mjs Normal file
View File

@ -0,0 +1,12 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
compilerOptions: {
// Enable runes mode for Svelte 5
runes: true
}
}

View File

@ -3,6 +3,7 @@ import { fileURLToPath, URL } from 'node:url'
import { loadEnv, defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
@ -14,6 +15,7 @@ export default defineConfig({
plugins: [
vue(),
vueJsx(),
svelte(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
@ -28,7 +30,7 @@ export default defineConfig({
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', '.vue']
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', '.vue', '.svelte']
},
define: {
__VUE_I18N_FULL_INSTALL__: true,