@prsm/mono-components 0.1.0
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/.claude/settings.local.json +13 -0
- package/.lore +83 -0
- package/histoire.config.js +43 -0
- package/package.json +39 -0
- package/postcss.config.js +6 -0
- package/src/components/Badge.vue +36 -0
- package/src/components/Button.vue +44 -0
- package/src/components/Checkbox.vue +51 -0
- package/src/components/CheckboxCards.vue +61 -0
- package/src/components/CodeEditor.vue +299 -0
- package/src/components/Collapsible.vue +69 -0
- package/src/components/CollapsibleGroup.vue +38 -0
- package/src/components/Combobox.vue +179 -0
- package/src/components/ContextMenu.vue +65 -0
- package/src/components/ContextMenuPanel.vue +115 -0
- package/src/components/DataTable.vue +326 -0
- package/src/components/Dropdown.vue +127 -0
- package/src/components/GhostInput.vue +29 -0
- package/src/components/Input.vue +23 -0
- package/src/components/KeyValue.vue +149 -0
- package/src/components/LabeledTextarea.vue +64 -0
- package/src/components/LabeledTextareaGroup.vue +14 -0
- package/src/components/Mention.vue +79 -0
- package/src/components/Modal.vue +109 -0
- package/src/components/MultiCombobox.vue +209 -0
- package/src/components/NavTree.vue +98 -0
- package/src/components/NumberInput.vue +128 -0
- package/src/components/PopConfirm.vue +94 -0
- package/src/components/Popover.vue +53 -0
- package/src/components/RadioCards.vue +37 -0
- package/src/components/RadioGroup.vue +57 -0
- package/src/components/RangeSlider.vue +165 -0
- package/src/components/ScrollBox.vue +78 -0
- package/src/components/SectionHeader.vue +18 -0
- package/src/components/Select.vue +187 -0
- package/src/components/Switch.vue +85 -0
- package/src/components/Tabs.vue +34 -0
- package/src/components/TagInput.vue +80 -0
- package/src/components/Textarea.vue +97 -0
- package/src/components/ToastContainer.vue +104 -0
- package/src/components/ToggleButtons.vue +45 -0
- package/src/components/ToggleGroup.vue +30 -0
- package/src/components/Tooltip.vue +56 -0
- package/src/components/Tree.vue +188 -0
- package/src/composables/toast.js +54 -0
- package/src/composables/useClickOutside.js +23 -0
- package/src/composables/useMention.js +291 -0
- package/src/composables/usePointerDrag.js +39 -0
- package/src/histoire-setup.js +1 -0
- package/src/index.js +43 -0
- package/src/style.css +96 -0
- package/stories/Badge.story.vue +24 -0
- package/stories/Button.story.vue +45 -0
- package/stories/Checkbox.story.vue +31 -0
- package/stories/CheckboxCards.story.vue +51 -0
- package/stories/CodeEditor.story.vue +71 -0
- package/stories/Collapsible.story.vue +84 -0
- package/stories/Combobox.story.vue +44 -0
- package/stories/ContextMenu.story.vue +59 -0
- package/stories/DataTable.story.vue +185 -0
- package/stories/Dropdown.story.vue +49 -0
- package/stories/GhostInput.story.vue +24 -0
- package/stories/Input.story.vue +23 -0
- package/stories/KeyValue.story.vue +104 -0
- package/stories/LabeledTextarea.story.vue +44 -0
- package/stories/Mention.story.vue +166 -0
- package/stories/Modal.story.vue +86 -0
- package/stories/MultiCombobox.story.vue +76 -0
- package/stories/NavTree.story.vue +184 -0
- package/stories/NumberInput.story.vue +31 -0
- package/stories/Overview.story.vue +85 -0
- package/stories/PopConfirm.story.vue +39 -0
- package/stories/RadioCards.story.vue +66 -0
- package/stories/RadioGroup.story.vue +52 -0
- package/stories/RangeSlider.story.vue +75 -0
- package/stories/ScrollBox.story.vue +54 -0
- package/stories/SectionHeader.story.vue +22 -0
- package/stories/Select.story.vue +34 -0
- package/stories/Switch.story.vue +42 -0
- package/stories/Tabs.story.vue +34 -0
- package/stories/TagInput.story.vue +54 -0
- package/stories/Textarea.story.vue +28 -0
- package/stories/Toast.story.vue +28 -0
- package/stories/ToggleButtons.story.vue +57 -0
- package/stories/ToggleGroup.story.vue +34 -0
- package/stories/Tooltip.story.vue +55 -0
- package/stories/Tree.story.vue +115 -0
- package/tailwind.config.js +9 -0
- package/tailwind.preset.js +79 -0
- package/vite.config.js +6 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from "vue"
|
|
3
|
+
import { Icon } from "@iconify/vue"
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
open: { type: Boolean, default: undefined },
|
|
7
|
+
title: { type: String, default: null },
|
|
8
|
+
bordered: { type: Boolean, default: undefined },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits(["update:open"])
|
|
12
|
+
|
|
13
|
+
const group = inject("collapsible-group", null)
|
|
14
|
+
const id = Symbol()
|
|
15
|
+
const internalOpen = ref(false)
|
|
16
|
+
|
|
17
|
+
if (group?.register) group.register(id)
|
|
18
|
+
|
|
19
|
+
const showBorder = computed(() => {
|
|
20
|
+
if (props.bordered !== undefined) return props.bordered
|
|
21
|
+
if (group && group.bordered !== undefined) return group.bordered
|
|
22
|
+
return true
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const isOpen = computed(() => {
|
|
26
|
+
if (group) return group.isOpen(id)
|
|
27
|
+
if (props.open !== undefined) return props.open
|
|
28
|
+
return internalOpen.value
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function toggle() {
|
|
32
|
+
if (group) {
|
|
33
|
+
group.toggle(id)
|
|
34
|
+
} else if (props.open !== undefined) {
|
|
35
|
+
emit("update:open", !props.open)
|
|
36
|
+
} else {
|
|
37
|
+
internalOpen.value = !internalOpen.value
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div :class="showBorder ? 'border-b border-line-subtle' : ''">
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
class="flex items-center gap-1 w-full px-2 py-1 bg-transparent border border-transparent font-mono text-base text-fg-0 cursor-pointer hover:bg-2 text-left"
|
|
47
|
+
@click="toggle"
|
|
48
|
+
>
|
|
49
|
+
<Icon
|
|
50
|
+
icon="material-symbols:chevron-right"
|
|
51
|
+
class="text-base text-fg-2 shrink-0 transition-transform duration-150"
|
|
52
|
+
:class="isOpen ? 'rotate-90' : ''"
|
|
53
|
+
/>
|
|
54
|
+
<slot name="trigger">
|
|
55
|
+
<span>{{ title }}</span>
|
|
56
|
+
</slot>
|
|
57
|
+
</button>
|
|
58
|
+
<div
|
|
59
|
+
class="collapsible-body grid transition-[grid-template-rows] duration-150 ease-out"
|
|
60
|
+
:class="isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
|
|
61
|
+
>
|
|
62
|
+
<div class="overflow-hidden">
|
|
63
|
+
<div class="px-2 py-1">
|
|
64
|
+
<slot />
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { reactive, provide, ref, onMounted } from "vue"
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
exclusive: { type: Boolean, default: false },
|
|
6
|
+
defaultOpen: { type: Boolean, default: false },
|
|
7
|
+
bordered: { type: Boolean, default: true },
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const openIds = reactive(new Set())
|
|
11
|
+
const registeredIds = ref([])
|
|
12
|
+
|
|
13
|
+
function register(id) {
|
|
14
|
+
registeredIds.value.push(id)
|
|
15
|
+
if (props.defaultOpen) openIds.add(id)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toggle(id) {
|
|
19
|
+
if (openIds.has(id)) {
|
|
20
|
+
openIds.delete(id)
|
|
21
|
+
} else {
|
|
22
|
+
if (props.exclusive) openIds.clear()
|
|
23
|
+
openIds.add(id)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isOpen(id) {
|
|
28
|
+
return openIds.has(id)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
provide("collapsible-group", { toggle, isOpen, register, bordered: props.bordered })
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<div :class="bordered ? 'border-t border-line-subtle' : ''">
|
|
36
|
+
<slot />
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch, nextTick } from "vue"
|
|
3
|
+
import { useFloating, flip, shift, offset, size } from "@floating-ui/vue"
|
|
4
|
+
import { Icon } from "@iconify/vue"
|
|
5
|
+
import { useClickOutside } from "../composables/useClickOutside.js"
|
|
6
|
+
import ScrollBox from "./ScrollBox.vue"
|
|
7
|
+
|
|
8
|
+
const props = defineProps({
|
|
9
|
+
modelValue: { type: [String, Number, Boolean, null], default: null },
|
|
10
|
+
options: { type: Array, required: true },
|
|
11
|
+
placeholder: { type: String, default: "select..." },
|
|
12
|
+
filterPlaceholder: { type: String, default: "search..." },
|
|
13
|
+
inline: { type: Boolean, default: false },
|
|
14
|
+
defaultOpen: { type: Boolean, default: false }
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits(["update:modelValue", "close"])
|
|
18
|
+
|
|
19
|
+
const open = ref(props.defaultOpen)
|
|
20
|
+
const query = ref("")
|
|
21
|
+
const activeIndex = ref(-1)
|
|
22
|
+
const trigger = ref(null)
|
|
23
|
+
const inputRef = ref(null)
|
|
24
|
+
const menu = ref(null)
|
|
25
|
+
const maxWidth = ref(null)
|
|
26
|
+
const maxHeight = ref(null)
|
|
27
|
+
|
|
28
|
+
const { floatingStyles } = useFloating(trigger, menu, {
|
|
29
|
+
placement: "bottom-start",
|
|
30
|
+
strategy: "fixed",
|
|
31
|
+
middleware: [
|
|
32
|
+
offset(4),
|
|
33
|
+
flip(),
|
|
34
|
+
shift({ crossAxis: true, padding: 0 }),
|
|
35
|
+
size({
|
|
36
|
+
padding: 8,
|
|
37
|
+
apply({ availableWidth, availableHeight }) {
|
|
38
|
+
maxWidth.value = availableWidth
|
|
39
|
+
maxHeight.value = Math.min(availableHeight, 200)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
],
|
|
43
|
+
open
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const normalizedOptions = computed(() => {
|
|
47
|
+
return props.options.map(opt => {
|
|
48
|
+
if (typeof opt === "object" && opt !== null) {
|
|
49
|
+
return { value: opt.value, label: opt.label ?? String(opt.value) }
|
|
50
|
+
}
|
|
51
|
+
return { value: opt, label: String(opt) }
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const filtered = computed(() => {
|
|
56
|
+
if (!query.value) return normalizedOptions.value
|
|
57
|
+
const q = query.value.toLowerCase()
|
|
58
|
+
return normalizedOptions.value.filter(o => o.label.toLowerCase().includes(q))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const selectedLabel = computed(() => {
|
|
62
|
+
const found = normalizedOptions.value.find(o => o.value === props.modelValue)
|
|
63
|
+
return found?.label ?? ""
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
watch(open, (isOpen) => {
|
|
67
|
+
if (isOpen) {
|
|
68
|
+
query.value = ""
|
|
69
|
+
activeIndex.value = -1
|
|
70
|
+
nextTick(() => inputRef.value?.focus())
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function close() {
|
|
75
|
+
if (!open.value) return
|
|
76
|
+
open.value = false
|
|
77
|
+
emit("close")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function select(value) {
|
|
81
|
+
emit("update:modelValue", value)
|
|
82
|
+
close()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleKeydown(e) {
|
|
86
|
+
if (e.key === "ArrowDown") {
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
activeIndex.value = Math.min(activeIndex.value + 1, filtered.value.length - 1)
|
|
89
|
+
scrollToActive()
|
|
90
|
+
} else if (e.key === "ArrowUp") {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
|
93
|
+
scrollToActive()
|
|
94
|
+
} else if (e.key === "Enter" && activeIndex.value >= 0) {
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
select(filtered.value[activeIndex.value].value)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scrollToActive() {
|
|
101
|
+
nextTick(() => {
|
|
102
|
+
const el = menu.value?.querySelector("[data-active]")
|
|
103
|
+
if (el) el.scrollIntoView({ block: "nearest" })
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
watch(query, () => {
|
|
108
|
+
activeIndex.value = -1
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
useClickOutside([trigger, menu], close)
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<div ref="trigger" :class="inline ? 'relative flex w-full' : 'relative inline-flex'">
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
class="flex items-center gap-1 font-mono text-base text-fg-0 cursor-pointer min-w-0 flex-1"
|
|
119
|
+
:class="inline
|
|
120
|
+
? 'bg-transparent border-none p-0 m-0'
|
|
121
|
+
: ['px-2 py-1 bg-0 border rounded-sm hover:bg-1', open ? 'border-accent' : 'border-line']"
|
|
122
|
+
@click.stop="open ? close() : (open = true)"
|
|
123
|
+
>
|
|
124
|
+
<span
|
|
125
|
+
class="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis"
|
|
126
|
+
:class="modelValue == null ? 'text-fg-3' : ''"
|
|
127
|
+
>
|
|
128
|
+
{{ selectedLabel || placeholder }}
|
|
129
|
+
</span>
|
|
130
|
+
<Icon
|
|
131
|
+
v-if="!inline"
|
|
132
|
+
icon="material-symbols:expand-more"
|
|
133
|
+
class="text-xl text-fg-2 shrink-0 transition-transform duration-150"
|
|
134
|
+
:class="open ? 'rotate-180' : ''"
|
|
135
|
+
/>
|
|
136
|
+
</button>
|
|
137
|
+
<Teleport to="body">
|
|
138
|
+
<Transition name="combobox">
|
|
139
|
+
<div
|
|
140
|
+
v-if="open"
|
|
141
|
+
ref="menu"
|
|
142
|
+
class="combobox-menu bg-1 border border-line rounded-sm z-[1000] overflow-hidden flex flex-col w-max"
|
|
143
|
+
:style="{ ...floatingStyles, maxWidth: maxWidth + 'px', maxHeight: maxHeight + 'px' }"
|
|
144
|
+
>
|
|
145
|
+
<div class="px-1.5 py-1.5 border-b border-line-subtle">
|
|
146
|
+
<input
|
|
147
|
+
ref="inputRef"
|
|
148
|
+
v-model="query"
|
|
149
|
+
type="text"
|
|
150
|
+
class="w-full px-1.5 py-0.5 text-base bg-0 border border-line rounded-sm font-mono text-fg-0 outline-none focus:border-accent"
|
|
151
|
+
:placeholder="filterPlaceholder"
|
|
152
|
+
@keydown="handleKeydown"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
<ScrollBox class="flex-1">
|
|
156
|
+
<button
|
|
157
|
+
v-for="(opt, i) in filtered"
|
|
158
|
+
:key="opt.value"
|
|
159
|
+
type="button"
|
|
160
|
+
class="block w-full px-2 py-1 bg-transparent border-none font-mono text-base text-left cursor-pointer whitespace-nowrap overflow-hidden text-ellipsis hover:bg-3"
|
|
161
|
+
:class="[
|
|
162
|
+
opt.value === modelValue ? 'text-accent' : (i === activeIndex ? 'text-fg-0' : 'text-fg-1 hover:text-fg-0'),
|
|
163
|
+
i === activeIndex ? 'bg-3' : ''
|
|
164
|
+
]"
|
|
165
|
+
:data-active="i === activeIndex ? '' : undefined"
|
|
166
|
+
@click="select(opt.value)"
|
|
167
|
+
@mouseenter="activeIndex = i"
|
|
168
|
+
>
|
|
169
|
+
{{ opt.label }}
|
|
170
|
+
</button>
|
|
171
|
+
<div v-if="filtered.length === 0" class="px-2.5 py-2 text-base text-fg-3">
|
|
172
|
+
no matches
|
|
173
|
+
</div>
|
|
174
|
+
</ScrollBox>
|
|
175
|
+
</div>
|
|
176
|
+
</Transition>
|
|
177
|
+
</Teleport>
|
|
178
|
+
</div>
|
|
179
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, onMounted, onUnmounted, nextTick } from "vue"
|
|
3
|
+
import ContextMenuPanel from "./ContextMenuPanel.vue"
|
|
4
|
+
|
|
5
|
+
const triggerRef = ref(null)
|
|
6
|
+
const open = ref(false)
|
|
7
|
+
const cursorX = ref(0)
|
|
8
|
+
const cursorY = ref(0)
|
|
9
|
+
const virtualRef = ref({ getBoundingClientRect: () => new DOMRect() })
|
|
10
|
+
|
|
11
|
+
defineProps({
|
|
12
|
+
items: { type: Array, required: true }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function handleContextMenu(e) {
|
|
16
|
+
e.preventDefault()
|
|
17
|
+
cursorX.value = e.clientX
|
|
18
|
+
cursorY.value = e.clientY
|
|
19
|
+
virtualRef.value = {
|
|
20
|
+
getBoundingClientRect: () => new DOMRect(e.clientX, e.clientY, 0, 0)
|
|
21
|
+
}
|
|
22
|
+
open.value = true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function close() {
|
|
26
|
+
open.value = false
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function handleClickOutside(e) {
|
|
30
|
+
if (!open.value) return
|
|
31
|
+
close()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function handleKeydown(e) {
|
|
35
|
+
if (e.key === "Escape" && open.value) {
|
|
36
|
+
e.stopPropagation()
|
|
37
|
+
close()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
onMounted(() => {
|
|
42
|
+
document.addEventListener("click", handleClickOutside, true)
|
|
43
|
+
document.addEventListener("keydown", handleKeydown)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
onUnmounted(() => {
|
|
47
|
+
document.removeEventListener("click", handleClickOutside, true)
|
|
48
|
+
document.removeEventListener("keydown", handleKeydown)
|
|
49
|
+
})
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div ref="triggerRef" class="contents" @contextmenu="handleContextMenu">
|
|
54
|
+
<slot />
|
|
55
|
+
</div>
|
|
56
|
+
<Teleport to="body">
|
|
57
|
+
<ContextMenuPanel
|
|
58
|
+
v-if="open"
|
|
59
|
+
:items="items"
|
|
60
|
+
:anchor="virtualRef"
|
|
61
|
+
placement="bottom-start"
|
|
62
|
+
@close="close"
|
|
63
|
+
/>
|
|
64
|
+
</Teleport>
|
|
65
|
+
</template>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, onBeforeUnmount } from "vue"
|
|
3
|
+
import { useFloating, flip, shift, offset } from "@floating-ui/vue"
|
|
4
|
+
import { Icon } from "@iconify/vue"
|
|
5
|
+
import ScrollBox from "./ScrollBox.vue"
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
items: { type: Array, required: true },
|
|
9
|
+
anchor: { type: Object, required: true },
|
|
10
|
+
placement: { type: String, default: "bottom-start" }
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits(["close"])
|
|
14
|
+
|
|
15
|
+
const menuRef = ref(null)
|
|
16
|
+
const activeChild = ref(null)
|
|
17
|
+
const childAnchor = ref(null)
|
|
18
|
+
let hoverTimer = null
|
|
19
|
+
|
|
20
|
+
const anchorRef = computed(() => props.anchor)
|
|
21
|
+
const menuEl = computed(() => menuRef.value?.$el ?? menuRef.value)
|
|
22
|
+
|
|
23
|
+
const { floatingStyles } = useFloating(anchorRef, menuEl, {
|
|
24
|
+
placement: computed(() => props.placement),
|
|
25
|
+
strategy: "fixed",
|
|
26
|
+
middleware: [
|
|
27
|
+
offset(4),
|
|
28
|
+
flip({ fallbackPlacements: ["left-start", "right-start", "bottom-start", "top-start"] }),
|
|
29
|
+
shift({ padding: 8 })
|
|
30
|
+
]
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function handleAction(item) {
|
|
34
|
+
if (item.disabled || item.children) return
|
|
35
|
+
if (item.action) item.action()
|
|
36
|
+
emit("close")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleMouseEnter(item, e) {
|
|
40
|
+
clearTimeout(hoverTimer)
|
|
41
|
+
if (item.children) {
|
|
42
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
43
|
+
hoverTimer = setTimeout(() => {
|
|
44
|
+
activeChild.value = item
|
|
45
|
+
childAnchor.value = {
|
|
46
|
+
getBoundingClientRect: () => rect
|
|
47
|
+
}
|
|
48
|
+
}, 150)
|
|
49
|
+
} else {
|
|
50
|
+
activeChild.value = null
|
|
51
|
+
childAnchor.value = null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleMouseLeaveMenu() {
|
|
56
|
+
clearTimeout(hoverTimer)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onBeforeUnmount(() => {
|
|
60
|
+
clearTimeout(hoverTimer)
|
|
61
|
+
})
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<ScrollBox
|
|
66
|
+
ref="menuRef"
|
|
67
|
+
:style="floatingStyles"
|
|
68
|
+
class="fixed z-[1100] bg-1 border border-line rounded-sm py-0.5 min-w-[160px] max-h-[300px]"
|
|
69
|
+
@mouseleave="handleMouseLeaveMenu"
|
|
70
|
+
>
|
|
71
|
+
<template v-for="(item, i) in items" :key="i">
|
|
72
|
+
<div
|
|
73
|
+
v-if="item.type === 'separator'"
|
|
74
|
+
class="my-0.5 border-b border-line-subtle"
|
|
75
|
+
/>
|
|
76
|
+
<div
|
|
77
|
+
v-else-if="item.type === 'header'"
|
|
78
|
+
class="px-2 py-1 text-xs text-fg-3 uppercase tracking-wide"
|
|
79
|
+
>
|
|
80
|
+
{{ item.label }}
|
|
81
|
+
</div>
|
|
82
|
+
<button
|
|
83
|
+
v-else
|
|
84
|
+
type="button"
|
|
85
|
+
class="flex items-center gap-1.5 w-full px-2 py-1 bg-transparent border-none font-mono text-base text-left cursor-pointer whitespace-nowrap"
|
|
86
|
+
:class="[
|
|
87
|
+
item.disabled ? 'text-fg-3 cursor-default' : 'text-fg-1 hover:bg-3 hover:text-fg-0',
|
|
88
|
+
activeChild === item ? 'bg-3 text-fg-0' : ''
|
|
89
|
+
]"
|
|
90
|
+
@click="handleAction(item)"
|
|
91
|
+
@mouseenter="handleMouseEnter(item, $event)"
|
|
92
|
+
>
|
|
93
|
+
<span class="w-[16px] shrink-0 flex items-center justify-center text-fg-2">
|
|
94
|
+
<Icon v-if="item.checked" icon="material-symbols:check" class="text-base text-accent" />
|
|
95
|
+
<Icon v-else-if="item.icon" :icon="item.icon" class="text-base" />
|
|
96
|
+
</span>
|
|
97
|
+
<span class="flex-1">{{ item.label }}</span>
|
|
98
|
+
<span v-if="item.kbd" class="text-xs text-fg-3 ml-4">{{ item.kbd }}</span>
|
|
99
|
+
<Icon
|
|
100
|
+
v-if="item.children"
|
|
101
|
+
icon="material-symbols:chevron-right"
|
|
102
|
+
class="text-base text-fg-3 shrink-0"
|
|
103
|
+
/>
|
|
104
|
+
</button>
|
|
105
|
+
</template>
|
|
106
|
+
</ScrollBox>
|
|
107
|
+
|
|
108
|
+
<ContextMenuPanel
|
|
109
|
+
v-if="activeChild?.children && childAnchor"
|
|
110
|
+
:items="activeChild.children"
|
|
111
|
+
:anchor="childAnchor"
|
|
112
|
+
placement="right-start"
|
|
113
|
+
@close="emit('close')"
|
|
114
|
+
/>
|
|
115
|
+
</template>
|