@tekkare/romulus 0.1.0 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +13 -1
- package/dist/runtime/assets/styles/components.css +1 -0
- package/dist/runtime/assets/styles/tokens.css +1 -1
- package/dist/runtime/assets/styles/utilities.css +1 -0
- package/dist/runtime/components/ActionBar.vue +95 -0
- package/dist/runtime/components/AdvancedSearch.vue +110 -0
- package/dist/runtime/components/Badge.vue +15 -0
- package/dist/runtime/components/Breadcrumb.vue +46 -0
- package/dist/runtime/components/Button.vue +17 -9
- package/dist/runtime/components/ChartCard.vue +129 -0
- package/dist/runtime/components/DataTable.vue +92 -0
- package/dist/runtime/components/Drawer.vue +79 -0
- package/dist/runtime/components/FilterCheckbox.vue +26 -0
- package/dist/runtime/components/FilterComplex.vue +205 -0
- package/dist/runtime/components/FilterDropdown.vue +119 -0
- package/dist/runtime/components/Icon.vue +43 -0
- package/dist/runtime/components/Kpi.vue +63 -0
- package/dist/runtime/components/NavGroup.vue +37 -0
- package/dist/runtime/components/NavLink.vue +60 -0
- package/dist/runtime/components/Pill.vue +37 -0
- package/dist/runtime/components/PillTabs.vue +34 -0
- package/dist/runtime/components/SearchBar.vue +38 -0
- package/dist/runtime/components/Sidebar.vue +143 -0
- package/dist/runtime/components/Tabs.vue +105 -0
- package/dist/runtime/components/ToolbarFilter.vue +134 -0
- package/dist/runtime/utils/format.d.ts +27 -0
- package/dist/runtime/utils/format.js +7 -0
- package/package.json +4 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export interface RmNavLinkProps {
|
|
3
|
+
/** Phosphor icon name (e.g. "ChartBar", "Flask") */
|
|
4
|
+
icon: string
|
|
5
|
+
/** Link label */
|
|
6
|
+
label: string
|
|
7
|
+
/** Navigation path */
|
|
8
|
+
to?: string
|
|
9
|
+
/** Whether the link is currently active */
|
|
10
|
+
active?: boolean
|
|
11
|
+
/** Caret type: 'expand' (down/up), 'navigate' (right), 'none' */
|
|
12
|
+
caret?: 'expand' | 'navigate' | 'none'
|
|
13
|
+
/** Whether the submenu is expanded (only for caret='expand') */
|
|
14
|
+
expanded?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
withDefaults(defineProps<RmNavLinkProps>(), {
|
|
18
|
+
to: undefined,
|
|
19
|
+
active: false,
|
|
20
|
+
caret: 'none',
|
|
21
|
+
expanded: false,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
defineEmits<{
|
|
25
|
+
click: []
|
|
26
|
+
}>()
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<button
|
|
31
|
+
class="rm-nav-link"
|
|
32
|
+
:class="{
|
|
33
|
+
'rm-nav-link--active': active,
|
|
34
|
+
}"
|
|
35
|
+
@click="$emit('click')"
|
|
36
|
+
>
|
|
37
|
+
<span class="rm-nav-link__icon">
|
|
38
|
+
<RmIcon :name="icon" :size="18" />
|
|
39
|
+
</span>
|
|
40
|
+
<span class="rm-nav-link__label">{{ label }}</span>
|
|
41
|
+
|
|
42
|
+
<!-- Expand caret (down/up) -->
|
|
43
|
+
<span
|
|
44
|
+
v-if="caret === 'expand'"
|
|
45
|
+
class="rm-nav-link__caret"
|
|
46
|
+
:class="{ 'rm-nav-link__caret--expanded': expanded }"
|
|
47
|
+
>
|
|
48
|
+
<RmIcon name="CaretDown" :size="14" />
|
|
49
|
+
</span>
|
|
50
|
+
|
|
51
|
+
<!-- Navigate caret (right) -->
|
|
52
|
+
<span v-if="caret === 'navigate'" class="rm-nav-link__caret">
|
|
53
|
+
<RmIcon name="CaretRight" :size="14" />
|
|
54
|
+
</span>
|
|
55
|
+
</button>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style scoped>
|
|
59
|
+
.rm-nav-link{background:none;border:none;border-radius:var(--r-radius-lg);cursor:pointer;gap:8px;padding:6px 12px 6px 6px;text-align:left;width:100%}.rm-nav-link,.rm-nav-link__icon{align-items:center;display:flex;transition:all var(--r-transition-fast)}.rm-nav-link__icon{background-color:var(--r-color-gray-100);border-radius:var(--r-radius-md);color:var(--r-color-gray-500);flex-shrink:0;height:28px;justify-content:center;width:28px}.rm-nav-link__label{color:#1e293b;flex:1;font-family:var(--r-font-family);font-size:14px;font-weight:400;line-height:160%;overflow:hidden;text-overflow:ellipsis;transition:all var(--r-transition-fast);white-space:nowrap}.rm-nav-link__caret{align-items:center;color:var(--r-color-gray-400);display:flex;flex-shrink:0;transition:transform .25s ease,color var(--r-transition-fast)}.rm-nav-link__caret--expanded{transform:rotate(-180deg)}.rm-nav-link:not(.rm-nav-link--active):hover{background-color:var(--r-color-primary-50)}.rm-nav-link:not(.rm-nav-link--active):hover .rm-nav-link__icon{background-color:var(--r-color-primary-100);color:var(--r-color-primary-600)}.rm-nav-link:not(.rm-nav-link--active):hover .rm-nav-link__caret{color:var(--r-color-primary-600)}.rm-nav-link--active{background-color:var(--r-color-gray-100)}.rm-nav-link--active .rm-nav-link__icon{background-color:var(--r-color-primary-600);color:var(--r-color-white)}.rm-nav-link--active .rm-nav-link__caret{color:var(--r-color-primary-600)}
|
|
60
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
|
|
4
|
+
withDefaults(defineProps<{
|
|
5
|
+
icon?: Component
|
|
6
|
+
label: string
|
|
7
|
+
active?: boolean
|
|
8
|
+
variant?: 'default' | 'primary' | 'secondary' | 'pharma' | 'discovery' | 'databank'
|
|
9
|
+
}>(), {
|
|
10
|
+
icon: undefined,
|
|
11
|
+
active: false,
|
|
12
|
+
variant: 'default',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
defineEmits<{
|
|
16
|
+
click: []
|
|
17
|
+
}>()
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<button
|
|
22
|
+
class="toolbar-pill"
|
|
23
|
+
:class="[
|
|
24
|
+
active && 'toolbar-pill--active',
|
|
25
|
+
`toolbar-pill--${variant}`,
|
|
26
|
+
]"
|
|
27
|
+
@click="$emit('click')"
|
|
28
|
+
>
|
|
29
|
+
<component :is="icon" v-if="icon" :size="16" />
|
|
30
|
+
<span>{{ label }}</span>
|
|
31
|
+
<slot />
|
|
32
|
+
</button>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.toolbar-pill{align-items:center;background-color:#fff;border:1px solid #dbdef0;border-radius:38px;color:var(--r-color-gray-600);cursor:pointer;display:inline-flex;font-family:inherit;font-size:var(--r-text-sm);font-weight:var(--r-font-weight-medium);gap:4px;padding:4px 12px;transition:all var(--r-transition-fast);white-space:nowrap}.toolbar-pill:hover{box-shadow:0 1px 2px 0 rgba(30,41,59,.1)}.toolbar-pill--active{background-color:#2176ff;border-color:#2176ff;color:var(--r-color-white)}.toolbar-pill--active:hover{background-color:#1a65e0;border-color:#1a65e0;box-shadow:none}.toolbar-pill--primary{background-color:#2176ff;border-color:#2176ff;color:var(--r-color-white)}.toolbar-pill--primary:hover{background-color:#1a65e0;border-color:#1a65e0;box-shadow:none}.toolbar-pill--secondary{background-color:var(--r-color-gray-100);border-color:transparent;color:var(--r-color-gray-800)}.toolbar-pill--secondary:hover{background-color:var(--r-color-gray-200);border-color:transparent}.toolbar-pill--pharma{background-color:#f7520e;border-color:#f7520e;color:var(--r-color-white)}.toolbar-pill--pharma:hover{background-color:#d9470c;border-color:#d9470c}.toolbar-pill--discovery{background-color:#07816f;border-color:#07816f;color:var(--r-color-white)}.toolbar-pill--discovery:hover{background-color:#066b5c;border-color:#066b5c}.toolbar-pill--databank{background-color:#e04380;border-color:#e04380;color:var(--r-color-white)}.toolbar-pill--databank:hover{background-color:#c73a6f;border-color:#c73a6f}
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export interface RmPillTabsProps {
|
|
3
|
+
/** Tab labels */
|
|
4
|
+
tabs: string[]
|
|
5
|
+
/** Active tab index */
|
|
6
|
+
modelValue?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
withDefaults(defineProps<RmPillTabsProps>(), {
|
|
10
|
+
modelValue: 0,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
defineEmits<{
|
|
14
|
+
'update:modelValue': [index: number]
|
|
15
|
+
}>()
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="rm-pill-tabs">
|
|
20
|
+
<button
|
|
21
|
+
v-for="(tab, idx) in tabs"
|
|
22
|
+
:key="idx"
|
|
23
|
+
class="rm-pill-tabs__tab"
|
|
24
|
+
:class="{ 'rm-pill-tabs__tab--active': modelValue === idx }"
|
|
25
|
+
@click="$emit('update:modelValue', idx)"
|
|
26
|
+
>
|
|
27
|
+
{{ tab }}
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<style scoped>
|
|
33
|
+
.rm-pill-tabs{display:flex;gap:4px}.rm-pill-tabs__tab{background:transparent;border:none;border-radius:var(--r-radius-lg);color:var(--r-color-gray-500);cursor:pointer;font-family:var(--r-font-family);font-size:14px;font-weight:400;padding:6px 14px;transition:all var(--r-transition-fast)}.rm-pill-tabs__tab:hover{color:var(--r-color-gray-700)}.rm-pill-tabs__tab--active{background-color:var(--r-color-gray-100);color:var(--r-color-gray-900);font-weight:600}
|
|
34
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhMagnifyingGlass } from '@phosphor-icons/vue'
|
|
3
|
+
|
|
4
|
+
const model = defineModel<string>({ default: '' })
|
|
5
|
+
|
|
6
|
+
withDefaults(defineProps<{
|
|
7
|
+
advancedActive?: boolean
|
|
8
|
+
}>(), {
|
|
9
|
+
advancedActive: false,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
defineEmits<{
|
|
13
|
+
advancedSearch: []
|
|
14
|
+
}>()
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="search-bar" :class="{ 'search-bar--advanced-active': advancedActive }">
|
|
19
|
+
<PhMagnifyingGlass :size="16" class="search-bar__icon" />
|
|
20
|
+
<input
|
|
21
|
+
v-model="model"
|
|
22
|
+
type="text"
|
|
23
|
+
placeholder="Search..."
|
|
24
|
+
class="search-bar__input"
|
|
25
|
+
>
|
|
26
|
+
<button
|
|
27
|
+
class="search-bar__advanced"
|
|
28
|
+
:class="{ 'search-bar__advanced--active': advancedActive }"
|
|
29
|
+
@click="$emit('advancedSearch')"
|
|
30
|
+
>
|
|
31
|
+
Advanced search
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.search-bar{align-items:center;border:1px solid var(--r-color-gray-200);border-radius:var(--r-radius-full);display:inline-flex;gap:var(--r-space-2);height:32px;min-width:200px;padding:var(--r-space-1) var(--r-space-3);transition:border-color var(--r-transition-fast)}.search-bar:focus-within{border-color:var(--r-color-primary-400)}.search-bar__icon{color:var(--r-color-gray-400);flex-shrink:0}.search-bar__input{background:transparent;border:none;color:var(--r-color-gray-700);font-family:inherit;font-size:var(--r-text-sm);outline:none;width:100%}.search-bar__input::-moz-placeholder{color:var(--r-color-gray-400)}.search-bar__input::placeholder{color:var(--r-color-gray-400)}.search-bar__advanced{background:none;border:none;border-left:1px solid var(--r-color-gray-200);color:var(--r-color-gray-500);cursor:pointer;flex-shrink:0;font-family:inherit;font-size:var(--r-text-sm);padding:0 0 0 var(--r-space-2);text-decoration:underline;transition:color var(--r-transition-fast);white-space:nowrap}.search-bar__advanced:hover{color:var(--r-color-primary-600)}.search-bar--advanced-active{border-color:#4164ee}.search-bar__advanced--active{color:#4164ee;font-weight:var(--r-font-weight-medium)}
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export interface RmSidebarMenuItem {
|
|
3
|
+
/** Display label */
|
|
4
|
+
label: string
|
|
5
|
+
/** Phosphor icon name (e.g. "ChartBar", "Flask") */
|
|
6
|
+
icon: string
|
|
7
|
+
/** Navigation path (if no children) */
|
|
8
|
+
to?: string
|
|
9
|
+
/** Sub-menu items */
|
|
10
|
+
children?: RmSidebarMenuItem[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RmSidebarProps {
|
|
14
|
+
/** Application name displayed in the header */
|
|
15
|
+
name: string
|
|
16
|
+
/** Menu items */
|
|
17
|
+
menus: RmSidebarMenuItem[]
|
|
18
|
+
/** Whether the sidebar is open */
|
|
19
|
+
open?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<RmSidebarProps>(), {
|
|
23
|
+
open: true,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
defineEmits<{
|
|
27
|
+
toggle: []
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const route = useRoute()
|
|
31
|
+
const router = useRouter()
|
|
32
|
+
const expandedMenu = ref<string | null>(null)
|
|
33
|
+
|
|
34
|
+
function onSubmenuEnter(el: Element) {
|
|
35
|
+
const element = el as HTMLElement
|
|
36
|
+
element.style.height = '0'
|
|
37
|
+
element.style.overflow = 'hidden'
|
|
38
|
+
element.offsetHeight // force reflow
|
|
39
|
+
element.style.transition = 'height 250ms ease, opacity 250ms ease'
|
|
40
|
+
element.style.opacity = '0'
|
|
41
|
+
element.style.height = element.scrollHeight + 'px'
|
|
42
|
+
element.style.opacity = '1'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onSubmenuAfterEnter(el: Element) {
|
|
46
|
+
const element = el as HTMLElement
|
|
47
|
+
element.style.height = ''
|
|
48
|
+
element.style.overflow = ''
|
|
49
|
+
element.style.transition = ''
|
|
50
|
+
element.style.opacity = ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onSubmenuLeave(el: Element) {
|
|
54
|
+
const element = el as HTMLElement
|
|
55
|
+
element.style.height = element.scrollHeight + 'px'
|
|
56
|
+
element.style.overflow = 'hidden'
|
|
57
|
+
element.offsetHeight // force reflow
|
|
58
|
+
element.style.transition = 'height 200ms ease, opacity 200ms ease'
|
|
59
|
+
element.style.height = '0'
|
|
60
|
+
element.style.opacity = '0'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function onSubmenuAfterLeave(el: Element) {
|
|
64
|
+
const element = el as HTMLElement
|
|
65
|
+
element.style.height = ''
|
|
66
|
+
element.style.overflow = ''
|
|
67
|
+
element.style.transition = ''
|
|
68
|
+
element.style.opacity = ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isActive(item: RmSidebarMenuItem): boolean {
|
|
72
|
+
if (item.to) return route.path === item.to
|
|
73
|
+
return item.children?.some(c => isActive(c)) ?? false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleClick(item: RmSidebarMenuItem) {
|
|
77
|
+
if (item.children) {
|
|
78
|
+
expandedMenu.value = expandedMenu.value === item.label ? null : item.label
|
|
79
|
+
}
|
|
80
|
+
else if (item.to) {
|
|
81
|
+
router.push(item.to)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<aside class="rm-sidebar" :class="{ 'rm-sidebar--collapsed': !open }">
|
|
88
|
+
<div class="rm-sidebar__header">
|
|
89
|
+
<div v-if="open" class="rm-sidebar__brand">
|
|
90
|
+
<RmIcon name="DiamondsFour" :size="22" weight="duotone" class="rm-sidebar__logo" />
|
|
91
|
+
<span class="rm-sidebar__name">{{ name }}</span>
|
|
92
|
+
</div>
|
|
93
|
+
<button class="rm-sidebar__toggle" @click="$emit('toggle')">
|
|
94
|
+
<RmIcon name="SidebarSimple" :size="20" />
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<nav class="rm-sidebar__nav">
|
|
99
|
+
<template v-for="item in menus" :key="item.label">
|
|
100
|
+
<RmNavLink
|
|
101
|
+
:icon="item.icon"
|
|
102
|
+
:label="open ? item.label : ''"
|
|
103
|
+
:active="isActive(item)"
|
|
104
|
+
:caret="item.children && open ? 'expand' : 'none'"
|
|
105
|
+
:expanded="expandedMenu === item.label"
|
|
106
|
+
@click="handleClick(item)"
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
<Transition
|
|
110
|
+
@enter="onSubmenuEnter"
|
|
111
|
+
@after-enter="onSubmenuAfterEnter"
|
|
112
|
+
@leave="onSubmenuLeave"
|
|
113
|
+
@after-leave="onSubmenuAfterLeave"
|
|
114
|
+
>
|
|
115
|
+
<div
|
|
116
|
+
v-if="open && item.children && expandedMenu === item.label"
|
|
117
|
+
class="rm-sidebar__submenu"
|
|
118
|
+
>
|
|
119
|
+
<RmNavLink
|
|
120
|
+
v-for="(child, idx) in item.children"
|
|
121
|
+
:key="child.label"
|
|
122
|
+
:icon="child.icon"
|
|
123
|
+
:label="child.label"
|
|
124
|
+
:active="isActive(child)"
|
|
125
|
+
:expandable="false"
|
|
126
|
+
:style="{ animationDelay: `${idx * 50}ms` }"
|
|
127
|
+
class="rm-sidebar__submenu-item"
|
|
128
|
+
@click="child.to && router.push(child.to)"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</Transition>
|
|
132
|
+
</template>
|
|
133
|
+
</nav>
|
|
134
|
+
|
|
135
|
+
<div v-if="$slots.footer" class="rm-sidebar__footer">
|
|
136
|
+
<slot name="footer" />
|
|
137
|
+
</div>
|
|
138
|
+
</aside>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style scoped>
|
|
142
|
+
.rm-sidebar{background-color:var(--r-color-white);border-right:1px solid var(--r-color-gray-200);bottom:0;display:flex;flex-direction:column;left:0;overflow-y:auto;position:fixed;top:0;transition:width var(--r-transition-base);width:240px;z-index:50}.rm-sidebar--collapsed{width:64px}.rm-sidebar__header{align-items:center;border-bottom:1px solid var(--r-color-gray-200);display:flex;justify-content:space-between;min-height:64px;padding:0 16px}.rm-sidebar__brand{align-items:center;display:flex;gap:8px}.rm-sidebar__logo{color:var(--r-color-primary-600)}.rm-sidebar__name{color:var(--r-color-gray-900);font-family:var(--r-font-family);font-size:16px;font-weight:700;white-space:nowrap}.rm-sidebar__toggle{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--r-color-gray-500);cursor:pointer;display:flex;justify-content:center;padding:4px}.rm-sidebar__toggle:hover{background-color:var(--r-color-gray-100)}.rm-sidebar__nav{display:flex;flex:1;flex-direction:column;gap:6px;padding:8px}.rm-sidebar__submenu{display:flex;flex-direction:column;gap:6px;padding-left:20px}.rm-sidebar__submenu-item{animation:rm-submenu-fade-in .25s ease both}@keyframes rm-submenu-fade-in{0%{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:translateX(0)}}.rm-sidebar__footer{border-top:1px solid var(--r-color-gray-200);padding:12px}
|
|
143
|
+
</style>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export interface RmTabsProps {
|
|
3
|
+
/** Tab labels */
|
|
4
|
+
tabs: string[]
|
|
5
|
+
/** Active tab index */
|
|
6
|
+
modelValue?: number
|
|
7
|
+
/** Color variant */
|
|
8
|
+
variant?: 'default' | 'pharma' | 'discovery' | 'databank'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<RmTabsProps>(), {
|
|
12
|
+
modelValue: 0,
|
|
13
|
+
variant: 'default',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const defaultColors = { color: '#4164EE', bg: 'rgba(65, 100, 238, 0.05)' }
|
|
17
|
+
|
|
18
|
+
const variantColors: Record<string, { color: string; bg: string }> = {
|
|
19
|
+
default: defaultColors,
|
|
20
|
+
pharma: { color: '#F7520E', bg: 'rgba(247, 82, 14, 0.05)' },
|
|
21
|
+
discovery: { color: '#07816F', bg: 'rgba(7, 129, 111, 0.05)' },
|
|
22
|
+
databank: { color: '#E04380', bg: 'rgba(224, 67, 128, 0.05)' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const currentColors = computed((): { color: string; bg: string } => variantColors[props.variant] ?? defaultColors)
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{
|
|
28
|
+
'update:modelValue': [index: number]
|
|
29
|
+
}>()
|
|
30
|
+
|
|
31
|
+
const route = useRoute()
|
|
32
|
+
const router = useRouter()
|
|
33
|
+
|
|
34
|
+
const tabRefs = ref<HTMLElement[]>([])
|
|
35
|
+
const indicatorStyle = ref<Record<string, string>>({})
|
|
36
|
+
|
|
37
|
+
function slugify(name: string): string {
|
|
38
|
+
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function updateIndicator(index: number) {
|
|
42
|
+
nextTick(() => {
|
|
43
|
+
const tab = tabRefs.value[index]
|
|
44
|
+
if (tab) {
|
|
45
|
+
indicatorStyle.value = {
|
|
46
|
+
width: `${tab.offsetWidth}px`,
|
|
47
|
+
transform: `translateX(${tab.offsetLeft}px)`,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function selectTab(index: number) {
|
|
54
|
+
emit('update:modelValue', index)
|
|
55
|
+
updateIndicator(index)
|
|
56
|
+
|
|
57
|
+
const tabSlug = slugify(props.tabs[index] ?? '')
|
|
58
|
+
router.replace({ query: { ...route.query, tab: tabSlug } })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
if (route.query.tab) {
|
|
63
|
+
const tabParam = route.query.tab as string
|
|
64
|
+
const index = props.tabs.findIndex(t => slugify(t) === tabParam)
|
|
65
|
+
if (index >= 0) {
|
|
66
|
+
emit('update:modelValue', index)
|
|
67
|
+
updateIndicator(index)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
updateIndicator(props.modelValue)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
watch(() => props.modelValue, (val) => updateIndicator(val))
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<div class="rm-tabs">
|
|
79
|
+
<div class="rm-tabs__list">
|
|
80
|
+
<button
|
|
81
|
+
v-for="(tab, idx) in tabs"
|
|
82
|
+
:key="idx"
|
|
83
|
+
:ref="(el) => { if (el) tabRefs[idx] = el as HTMLElement }"
|
|
84
|
+
class="rm-tabs__tab"
|
|
85
|
+
:class="{ 'rm-tabs__tab--active': modelValue === idx }"
|
|
86
|
+
:style="{
|
|
87
|
+
'--tab-active-color': currentColors.color,
|
|
88
|
+
'--tab-active-bg': currentColors.bg,
|
|
89
|
+
...(modelValue === idx ? { color: currentColors.color, background: currentColors.bg } : {}),
|
|
90
|
+
}"
|
|
91
|
+
@click="selectTab(idx)"
|
|
92
|
+
>
|
|
93
|
+
{{ tab }}
|
|
94
|
+
</button>
|
|
95
|
+
<span
|
|
96
|
+
class="rm-tabs__indicator"
|
|
97
|
+
:style="{ ...indicatorStyle, backgroundColor: currentColors.color }"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
102
|
+
|
|
103
|
+
<style scoped>
|
|
104
|
+
.rm-tabs{background-color:var(--r-color-white);border-bottom:1px solid var(--r-color-gray-200)}.rm-tabs__list{display:flex;padding:8px 20px 0;position:relative}.rm-tabs__tab{background:none;border:none;border-radius:4px 4px 0 0;color:var(--r-color-gray-500);cursor:pointer;font-family:var(--r-font-family);font-size:14px;font-weight:400;padding:12px 16px;position:relative;transition:color var(--r-transition-fast),background var(--r-transition-fast);white-space:nowrap}.rm-tabs__tab:hover{background:var(--tab-active-bg);color:var(--r-color-gray-700)}.rm-tabs__tab--active{font-weight:500}.rm-tabs__indicator{border-radius:2px 2px 0 0;bottom:0;height:2px;left:0;position:absolute;transition:transform .3s cubic-bezier(.4,0,.2,1),width .3s cubic-bezier(.4,0,.2,1)}
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PhCaretDown, PhCheck } from '@phosphor-icons/vue'
|
|
3
|
+
import type { Component } from 'vue'
|
|
4
|
+
|
|
5
|
+
export interface FilterOption {
|
|
6
|
+
label: string
|
|
7
|
+
value: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
icon?: Component
|
|
12
|
+
label: string
|
|
13
|
+
prefix?: string
|
|
14
|
+
options: FilterOption[]
|
|
15
|
+
variant?: 'default' | 'primary' | 'secondary' | 'pharma' | 'discovery' | 'databank'
|
|
16
|
+
multiple?: boolean
|
|
17
|
+
}>(), {
|
|
18
|
+
icon: undefined,
|
|
19
|
+
prefix: undefined,
|
|
20
|
+
variant: 'default',
|
|
21
|
+
multiple: true,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const selected = defineModel<string[]>({ default: () => [] })
|
|
25
|
+
|
|
26
|
+
const isOpen = ref(false)
|
|
27
|
+
const filterRef = ref<HTMLElement>()
|
|
28
|
+
const hasUserChanged = ref(false)
|
|
29
|
+
|
|
30
|
+
// Initialise avec la première option par défaut (synchrone pour éviter hydration mismatch)
|
|
31
|
+
if (selected.value.length === 0 && props.options.length > 0) {
|
|
32
|
+
selected.value = [props.options[0]!.value]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isActive = computed(() => hasUserChanged.value && selected.value.length > 0)
|
|
36
|
+
|
|
37
|
+
const pillLabel = computed(() => {
|
|
38
|
+
if (props.prefix) {
|
|
39
|
+
if (selected.value.length === 0) return `${props.prefix} ${props.label}`
|
|
40
|
+
const option = props.options.find(o => o.value === selected.value[0])
|
|
41
|
+
return `${props.prefix} ${option ? option.label : props.label}`
|
|
42
|
+
}
|
|
43
|
+
if (selected.value.length === 0) return props.label
|
|
44
|
+
if (selected.value.length === 1) {
|
|
45
|
+
const option = props.options.find(o => o.value === selected.value[0])
|
|
46
|
+
return option ? option.label : props.label
|
|
47
|
+
}
|
|
48
|
+
return `${props.label} (${selected.value.length})`
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const pillVariant = computed(() => {
|
|
52
|
+
if (props.prefix) return props.variant
|
|
53
|
+
if (isActive.value) return props.variant === 'default' ? 'primary' : props.variant
|
|
54
|
+
return 'default'
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
function toggle() {
|
|
58
|
+
isOpen.value = !isOpen.value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toggleOption(value: string) {
|
|
62
|
+
hasUserChanged.value = true
|
|
63
|
+
if (props.multiple) {
|
|
64
|
+
const index = selected.value.indexOf(value)
|
|
65
|
+
if (index === -1) {
|
|
66
|
+
selected.value = [...selected.value, value]
|
|
67
|
+
} else {
|
|
68
|
+
selected.value = selected.value.filter(v => v !== value)
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
selected.value = [value]
|
|
72
|
+
isOpen.value = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSelected(value: string) {
|
|
77
|
+
return selected.value.includes(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onClickOutside(e: Event) {
|
|
81
|
+
if (filterRef.value && !filterRef.value.contains(e.target as Node)) {
|
|
82
|
+
isOpen.value = false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onMounted(() => document.addEventListener('click', onClickOutside))
|
|
87
|
+
onUnmounted(() => document.removeEventListener('click', onClickOutside))
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<div ref="filterRef" class="toolbar-filter">
|
|
92
|
+
<RmPill
|
|
93
|
+
:icon="icon"
|
|
94
|
+
:label="pillLabel"
|
|
95
|
+
:active="isActive && variant === 'default'"
|
|
96
|
+
:variant="pillVariant"
|
|
97
|
+
@click="toggle"
|
|
98
|
+
>
|
|
99
|
+
<template #default>
|
|
100
|
+
<PhCaretDown
|
|
101
|
+
:size="12"
|
|
102
|
+
class="toolbar-filter__caret"
|
|
103
|
+
:class="{ 'toolbar-filter__caret--open': isOpen }"
|
|
104
|
+
/>
|
|
105
|
+
</template>
|
|
106
|
+
</RmPill>
|
|
107
|
+
|
|
108
|
+
<Transition name="dropdown">
|
|
109
|
+
<div v-if="isOpen" class="toolbar-filter__dropdown">
|
|
110
|
+
<button
|
|
111
|
+
v-for="option in options"
|
|
112
|
+
:key="option.value"
|
|
113
|
+
class="toolbar-filter__option"
|
|
114
|
+
:class="{ 'toolbar-filter__option--selected': isSelected(option.value) }"
|
|
115
|
+
@click="toggleOption(option.value)"
|
|
116
|
+
>
|
|
117
|
+
<span
|
|
118
|
+
:class="[
|
|
119
|
+
multiple ? 'toolbar-filter__checkbox' : 'toolbar-filter__radio',
|
|
120
|
+
isSelected(option.value) && (multiple ? 'toolbar-filter__checkbox--checked' : 'toolbar-filter__radio--checked'),
|
|
121
|
+
]"
|
|
122
|
+
>
|
|
123
|
+
<PhCheck v-if="multiple && isSelected(option.value)" :size="10" weight="bold" />
|
|
124
|
+
</span>
|
|
125
|
+
<span>{{ option.label }}</span>
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
</Transition>
|
|
129
|
+
</div>
|
|
130
|
+
</template>
|
|
131
|
+
|
|
132
|
+
<style scoped>
|
|
133
|
+
.toolbar-filter{position:relative}.toolbar-filter__caret{opacity:.6;transition:transform var(--r-transition-fast)}.toolbar-filter__caret--open{transform:rotate(180deg)}.toolbar-filter__dropdown{background-color:var(--r-color-white);border:1px solid var(--r-color-gray-200);border-radius:var(--r-radius-lg);box-shadow:0 4px 12px rgba(0,0,0,.08),0 2px 4px rgba(0,0,0,.04);left:0;max-height:260px;min-width:200px;overflow-y:auto;padding:var(--r-space-1);position:absolute;top:calc(100% + var(--r-space-2));z-index:50}.toolbar-filter__option{align-items:center;background:none;border:none;border-radius:var(--r-radius-md);color:var(--r-color-gray-700);cursor:pointer;display:flex;font-family:inherit;font-size:var(--r-text-sm);gap:var(--r-space-2);padding:var(--r-space-2) var(--r-space-3);text-align:left;transition:background-color var(--r-transition-fast);width:100%}.toolbar-filter__option:hover{background-color:var(--r-color-gray-50)}.toolbar-filter__option--selected{color:var(--r-color-gray-900);font-weight:var(--r-font-weight-medium)}.toolbar-filter__checkbox{align-items:center;border:1.5px solid var(--r-color-gray-300);border-radius:var(--r-radius-sm);display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--r-transition-fast);width:16px}.toolbar-filter__checkbox--checked{background-color:var(--r-color-primary-600);border-color:var(--r-color-primary-600);color:var(--r-color-white)}.toolbar-filter__radio{align-items:center;border:1.5px solid var(--r-color-gray-300);border-radius:var(--r-radius-full);display:flex;flex-shrink:0;height:16px;justify-content:center;transition:all var(--r-transition-fast);width:16px}.toolbar-filter__radio--checked{border-color:var(--r-color-primary-600)}.toolbar-filter__radio--checked:after{background-color:var(--r-color-primary-600);border-radius:var(--r-radius-full);content:"";height:8px;width:8px}.dropdown-enter-active,.dropdown-leave-active{transition:opacity var(--r-transition-fast),transform var(--r-transition-fast)}.dropdown-enter-from,.dropdown-leave-to{opacity:0;transform:translateY(-4px)}
|
|
134
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatage OIP - locale francaise. Regles reprises du skill
|
|
3
|
+
* `tekkare-dashboard-design` (`utils/oip-format.ts`), qui fait foi.
|
|
4
|
+
*
|
|
5
|
+
* Regle dates : le mois est TOUJOURS en toutes lettres. Ce n'est pas une
|
|
6
|
+
* regle esthetique mais une regle de securite de lecture : `05/09/2025` se
|
|
7
|
+
* lit « 5 septembre » en France et « 9 mai » au format US. Interdits
|
|
8
|
+
* partout, y compris dans une cellule de tableau : tout en chiffres, ISO,
|
|
9
|
+
* mois abrege anglais.
|
|
10
|
+
*
|
|
11
|
+
* Regle nombres : virgule decimale, espace avant le %, separateur de
|
|
12
|
+
* milliers insecable via `toLocaleString('fr-FR')`.
|
|
13
|
+
*/
|
|
14
|
+
/** "17 juin 2026" - prose, foot « Verifie le », tableaux, partout. */
|
|
15
|
+
export declare const rmDate: (d: Date | string | number) => string;
|
|
16
|
+
/** "juin 2026" - quand le jour est inconnu. */
|
|
17
|
+
export declare const rmMonth: (d: Date | string | number) => string;
|
|
18
|
+
/** "1 234" - separateur de milliers francais. */
|
|
19
|
+
export declare const rmNumber: (n: number) => string;
|
|
20
|
+
/** "2 043 €" */
|
|
21
|
+
export declare const rmPrice: (n: number) => string;
|
|
22
|
+
/** "9,2 Md€" · "43 M€" · "12 k" - grands nombres abreges. */
|
|
23
|
+
export declare const rmBig: (n: number) => string;
|
|
24
|
+
/** "+18,2 %" / "-3,1 %" - signe explicite, virgule, espace avant le %. */
|
|
25
|
+
export declare const rmPct: (n: number, digits?: number) => string;
|
|
26
|
+
/** "8,7" - decimale francaise sans unite (ratios : HR, OR, taux). */
|
|
27
|
+
export declare const rmDecimal: (n: number, digits?: number) => string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const rmDate = (d) => new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
|
2
|
+
export const rmMonth = (d) => new Date(d).toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
|
|
3
|
+
export const rmNumber = (n) => n.toLocaleString("fr-FR");
|
|
4
|
+
export const rmPrice = (n) => n.toLocaleString("fr-FR") + " \u20AC";
|
|
5
|
+
export const rmBig = (n) => n >= 1e9 ? (n / 1e9).toFixed(1).replace(".", ",") + " Md\u20AC" : n >= 1e6 ? (n / 1e6).toFixed(0) + " M\u20AC" : n >= 1e3 ? (n / 1e3).toFixed(0) + " k" : String(n);
|
|
6
|
+
export const rmPct = (n, digits = 1) => (n > 0 ? "+" : "") + n.toFixed(digits).replace(".", ",") + " %";
|
|
7
|
+
export const rmDecimal = (n, digits = 1) => n.toFixed(digits).replace(".", ",");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekkare/romulus",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Romulus Design System - Nuxt module by Tekkare",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "nuxt-module-build build",
|
|
25
25
|
"dev": "nuxi dev playground",
|
|
26
|
-
"typecheck": "nuxi typecheck"
|
|
26
|
+
"typecheck": "nuxi typecheck"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@nuxt/kit": "^3.16.0"
|
|
29
|
+
"@nuxt/kit": "^3.16.0",
|
|
30
|
+
"@phosphor-icons/vue": "^2.2.1"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@nuxt/module-builder": "^0.8.4",
|