@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.
@@ -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.0",
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",