@tekkare/romulus 0.1.0 → 0.1.5

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.
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
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",