@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,64 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, ref } from "vue"
|
|
3
|
+
import { Icon } from "@iconify/vue"
|
|
4
|
+
import Textarea from "./Textarea.vue"
|
|
5
|
+
|
|
6
|
+
defineProps({
|
|
7
|
+
label: { type: String, required: true },
|
|
8
|
+
icon: { type: String, default: null },
|
|
9
|
+
modelValue: { type: String, default: "" },
|
|
10
|
+
placeholder: { type: String, default: "" },
|
|
11
|
+
autoGrow: { type: Boolean, default: false },
|
|
12
|
+
rows: { type: Number, default: 3 }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits(["update:modelValue"])
|
|
16
|
+
const grouped = inject("labeled-textarea-grouped", false)
|
|
17
|
+
const wrapper = ref(null)
|
|
18
|
+
|
|
19
|
+
function focusTextarea() {
|
|
20
|
+
wrapper.value?.querySelector("textarea")?.focus()
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div
|
|
26
|
+
ref="wrapper"
|
|
27
|
+
class="labeled-textarea"
|
|
28
|
+
:class="[
|
|
29
|
+
grouped
|
|
30
|
+
? 'grouped'
|
|
31
|
+
: 'border border-line rounded-sm overflow-hidden focus-within:border-accent'
|
|
32
|
+
]"
|
|
33
|
+
>
|
|
34
|
+
<div class="px-2 py-1.5 bg-1 border-b border-line flex items-center gap-1.5" @mousedown.prevent="focusTextarea">
|
|
35
|
+
<Icon v-if="icon" :icon="icon" class="text-sm text-fg-3" />
|
|
36
|
+
<span class="text-xs text-fg-3 uppercase tracking-wide cursor-default">{{ label }}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<Textarea
|
|
39
|
+
:model-value="modelValue"
|
|
40
|
+
:placeholder="placeholder"
|
|
41
|
+
:auto-grow="autoGrow"
|
|
42
|
+
:rows="rows"
|
|
43
|
+
class="!border-0 !rounded-none block"
|
|
44
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
.labeled-textarea.grouped {
|
|
51
|
+
position: relative;
|
|
52
|
+
border-top: 1px solid var(--border);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.labeled-textarea.grouped:first-child {
|
|
56
|
+
border-top: none;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.labeled-textarea.grouped:focus-within {
|
|
60
|
+
z-index: 1;
|
|
61
|
+
outline: 1px solid var(--accent);
|
|
62
|
+
outline-offset: -1px;
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { provide } from "vue"
|
|
3
|
+
|
|
4
|
+
provide("labeled-textarea-grouped", true)
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="border border-line rounded-sm overflow-hidden">
|
|
9
|
+
<slot />
|
|
10
|
+
<div v-if="$slots.footer" class="px-3 py-1.5 bg-1 border-t border-line">
|
|
11
|
+
<slot name="footer" />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch, nextTick } from "vue"
|
|
3
|
+
import { useFloating, flip, shift, offset, size } from "@floating-ui/vue"
|
|
4
|
+
import ScrollBox from "./ScrollBox.vue"
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
open: { type: Boolean, default: false },
|
|
8
|
+
items: { type: Array, default: () => [] },
|
|
9
|
+
activeIndex: { type: Number, default: 0 },
|
|
10
|
+
loading: { type: Boolean, default: false },
|
|
11
|
+
anchor: { type: Object, default: null }
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits(["select", "hover"])
|
|
15
|
+
|
|
16
|
+
const panel = ref(null)
|
|
17
|
+
const maxHeight = ref(null)
|
|
18
|
+
|
|
19
|
+
const anchorRef = computed(() => props.anchor)
|
|
20
|
+
|
|
21
|
+
const { floatingStyles } = useFloating(anchorRef, panel, {
|
|
22
|
+
placement: "bottom-start",
|
|
23
|
+
strategy: "fixed",
|
|
24
|
+
middleware: [
|
|
25
|
+
offset(4),
|
|
26
|
+
flip(),
|
|
27
|
+
shift({ crossAxis: true, padding: 8 }),
|
|
28
|
+
size({
|
|
29
|
+
padding: 8,
|
|
30
|
+
apply({ availableHeight }) {
|
|
31
|
+
maxHeight.value = Math.min(availableHeight, 200)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
],
|
|
35
|
+
open: computed(() => props.open)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
watch(() => props.activeIndex, () => {
|
|
39
|
+
nextTick(() => {
|
|
40
|
+
const el = panel.value?.querySelector("[data-active]")
|
|
41
|
+
if (el) el.scrollIntoView({ block: "nearest" })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<Teleport to="body">
|
|
48
|
+
<div
|
|
49
|
+
v-if="open && (items.length > 0 || loading)"
|
|
50
|
+
ref="panel"
|
|
51
|
+
class="bg-1 border border-line rounded-sm z-[1000] overflow-hidden flex flex-col min-w-[160px]"
|
|
52
|
+
:style="{ ...floatingStyles, maxHeight: maxHeight + 'px' }"
|
|
53
|
+
>
|
|
54
|
+
<ScrollBox class="flex-1 py-0.5">
|
|
55
|
+
<button
|
|
56
|
+
v-for="(item, i) in items"
|
|
57
|
+
:key="item.value"
|
|
58
|
+
type="button"
|
|
59
|
+
class="flex items-center w-full py-1 bg-transparent font-mono text-base text-left cursor-pointer whitespace-nowrap overflow-hidden text-ellipsis"
|
|
60
|
+
:class="i === activeIndex
|
|
61
|
+
? 'bg-3 text-fg-0 mention-item-active'
|
|
62
|
+
: 'text-fg-2 hover:bg-2 hover:text-fg-0'"
|
|
63
|
+
:data-active="i === activeIndex ? '' : undefined"
|
|
64
|
+
@mousedown.prevent="emit('select', item)"
|
|
65
|
+
@mouseenter="emit('hover', i)"
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
class="w-0.5 self-stretch shrink-0 rounded-full"
|
|
69
|
+
:class="i === activeIndex ? 'bg-accent' : 'bg-transparent'"
|
|
70
|
+
/>
|
|
71
|
+
<span class="px-2 flex-1 overflow-hidden text-ellipsis">{{ item.label }}</span>
|
|
72
|
+
</button>
|
|
73
|
+
</ScrollBox>
|
|
74
|
+
<div v-if="loading && items.length === 0" class="px-2 py-1.5 text-base text-fg-3">
|
|
75
|
+
loading...
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</Teleport>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, onUnmounted } from "vue"
|
|
3
|
+
import ScrollBox from "./ScrollBox.vue"
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
open: { type: Boolean, default: false },
|
|
7
|
+
closeOnBackdrop: { type: Boolean, default: true },
|
|
8
|
+
closeOnEscape: { type: Boolean, default: true }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits(["update:open"])
|
|
12
|
+
|
|
13
|
+
const panelRef = ref(null)
|
|
14
|
+
let previouslyFocused = null
|
|
15
|
+
|
|
16
|
+
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
17
|
+
|
|
18
|
+
function close() {
|
|
19
|
+
emit("update:open", false)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function handleBackdropClick() {
|
|
23
|
+
if (props.closeOnBackdrop) close()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handleKeydown(e) {
|
|
27
|
+
if (e.key === "Escape" && props.closeOnEscape) {
|
|
28
|
+
close()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const panel = panelRef.value
|
|
32
|
+
if (e.key === "Tab" && panel) {
|
|
33
|
+
const focusable = Array.from(panel.querySelectorAll(FOCUSABLE))
|
|
34
|
+
if (!focusable.length) return
|
|
35
|
+
const first = focusable[0]
|
|
36
|
+
const last = focusable[focusable.length - 1]
|
|
37
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
last.focus()
|
|
40
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
first.focus()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function activate() {
|
|
48
|
+
previouslyFocused = document.activeElement
|
|
49
|
+
document.body.style.overflow = "hidden"
|
|
50
|
+
document.addEventListener("keydown", handleKeydown)
|
|
51
|
+
|
|
52
|
+
const panel = panelRef.value
|
|
53
|
+
if (!panel) return
|
|
54
|
+
const target = panel.querySelector("[autofocus]")
|
|
55
|
+
|| panel.querySelector(FOCUSABLE)
|
|
56
|
+
if (target) target.focus({ preventScroll: true })
|
|
57
|
+
panel.scrollTop = 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function deactivate() {
|
|
61
|
+
document.removeEventListener("keydown", handleKeydown)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function restoreFocus() {
|
|
65
|
+
document.body.style.overflow = ""
|
|
66
|
+
if (previouslyFocused?.focus) previouslyFocused.focus()
|
|
67
|
+
previouslyFocused = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onUnmounted(() => {
|
|
71
|
+
document.body.style.overflow = ""
|
|
72
|
+
document.removeEventListener("keydown", handleKeydown)
|
|
73
|
+
})
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<Teleport to="body">
|
|
78
|
+
<Transition
|
|
79
|
+
name="modal"
|
|
80
|
+
@after-enter="activate"
|
|
81
|
+
@before-leave="deactivate"
|
|
82
|
+
@after-leave="restoreFocus"
|
|
83
|
+
>
|
|
84
|
+
<div
|
|
85
|
+
v-if="open"
|
|
86
|
+
class="fixed inset-0 z-[1000] flex items-center justify-center"
|
|
87
|
+
>
|
|
88
|
+
<div
|
|
89
|
+
class="modal-backdrop absolute inset-0 bg-black/40"
|
|
90
|
+
@click="handleBackdropClick"
|
|
91
|
+
/>
|
|
92
|
+
<div
|
|
93
|
+
ref="panelRef"
|
|
94
|
+
class="modal-panel relative bg-1 border rounded-sm max-w-[90vw] max-h-[85vh] flex flex-col"
|
|
95
|
+
>
|
|
96
|
+
<div v-if="$slots.header" class="shrink-0 border-b border-line-subtle">
|
|
97
|
+
<slot name="header" />
|
|
98
|
+
</div>
|
|
99
|
+
<ScrollBox class="flex-1 min-h-0">
|
|
100
|
+
<slot />
|
|
101
|
+
</ScrollBox>
|
|
102
|
+
<div v-if="$slots.footer" class="shrink-0 border-t border-line-subtle">
|
|
103
|
+
<slot name="footer" />
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</Transition>
|
|
108
|
+
</Teleport>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,209 @@
|
|
|
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: Array, default: () => [] },
|
|
10
|
+
options: { type: Array, required: true },
|
|
11
|
+
placeholder: { type: String, default: "select..." },
|
|
12
|
+
filterPlaceholder: { type: String, default: "search..." }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits(["update:modelValue"])
|
|
16
|
+
|
|
17
|
+
const open = ref(false)
|
|
18
|
+
const query = ref("")
|
|
19
|
+
const activeIndex = ref(-1)
|
|
20
|
+
const trigger = ref(null)
|
|
21
|
+
const inputRef = ref(null)
|
|
22
|
+
const menu = ref(null)
|
|
23
|
+
const maxWidth = ref(null)
|
|
24
|
+
const maxHeight = ref(null)
|
|
25
|
+
|
|
26
|
+
const { floatingStyles } = useFloating(trigger, menu, {
|
|
27
|
+
placement: "bottom-start",
|
|
28
|
+
strategy: "fixed",
|
|
29
|
+
middleware: [
|
|
30
|
+
offset(4),
|
|
31
|
+
flip(),
|
|
32
|
+
shift({ crossAxis: true, padding: 0 }),
|
|
33
|
+
size({
|
|
34
|
+
padding: 8,
|
|
35
|
+
apply({ availableWidth, availableHeight }) {
|
|
36
|
+
maxWidth.value = availableWidth
|
|
37
|
+
maxHeight.value = Math.min(availableHeight, 200)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
],
|
|
41
|
+
open
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const normalizedOptions = computed(() => {
|
|
45
|
+
return props.options.map(opt => {
|
|
46
|
+
if (typeof opt === "object" && opt !== null) {
|
|
47
|
+
return { value: opt.value, label: opt.label ?? String(opt.value) }
|
|
48
|
+
}
|
|
49
|
+
return { value: opt, label: String(opt) }
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const filtered = computed(() => {
|
|
54
|
+
if (!query.value) return normalizedOptions.value
|
|
55
|
+
const q = query.value.toLowerCase()
|
|
56
|
+
return normalizedOptions.value.filter(o => o.label.toLowerCase().includes(q))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const selectedSet = computed(() => new Set(props.modelValue))
|
|
60
|
+
|
|
61
|
+
const selectedLabels = computed(() => {
|
|
62
|
+
return props.modelValue.map(v => {
|
|
63
|
+
const found = normalizedOptions.value.find(o => o.value === v)
|
|
64
|
+
return found?.label ?? String(v)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
watch(open, (isOpen) => {
|
|
69
|
+
if (isOpen) {
|
|
70
|
+
query.value = ""
|
|
71
|
+
activeIndex.value = -1
|
|
72
|
+
nextTick(() => inputRef.value?.focus())
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
function close() {
|
|
77
|
+
if (!open.value) return
|
|
78
|
+
open.value = false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toggle(value) {
|
|
82
|
+
const next = selectedSet.value.has(value)
|
|
83
|
+
? props.modelValue.filter(v => v !== value)
|
|
84
|
+
: [...props.modelValue, value]
|
|
85
|
+
emit("update:modelValue", next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function remove(value) {
|
|
89
|
+
emit("update:modelValue", props.modelValue.filter(v => v !== value))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clearAll() {
|
|
93
|
+
emit("update:modelValue", [])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleKeydown(e) {
|
|
97
|
+
if (e.key === "ArrowDown") {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
activeIndex.value = Math.min(activeIndex.value + 1, filtered.value.length - 1)
|
|
100
|
+
scrollToActive()
|
|
101
|
+
} else if (e.key === "ArrowUp") {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
|
104
|
+
scrollToActive()
|
|
105
|
+
} else if (e.key === "Enter" && activeIndex.value >= 0) {
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
toggle(filtered.value[activeIndex.value].value)
|
|
108
|
+
} else if (e.key === "Backspace" && !query.value && props.modelValue.length) {
|
|
109
|
+
remove(props.modelValue[props.modelValue.length - 1])
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scrollToActive() {
|
|
114
|
+
nextTick(() => {
|
|
115
|
+
const el = menu.value?.querySelector("[data-active]")
|
|
116
|
+
if (el) el.scrollIntoView({ block: "nearest" })
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
watch(query, () => {
|
|
121
|
+
activeIndex.value = -1
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
useClickOutside([trigger, menu], close)
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<div ref="trigger" class="relative inline-flex">
|
|
129
|
+
<div
|
|
130
|
+
class="flex items-center gap-1 p-1 bg-0 border rounded-sm font-mono text-base cursor-pointer min-w-0 w-full"
|
|
131
|
+
:class="open ? 'border-accent' : 'border-line'"
|
|
132
|
+
@click="open = true"
|
|
133
|
+
>
|
|
134
|
+
<div class="flex-1 flex items-center flex-wrap gap-1 min-w-0 mr-2">
|
|
135
|
+
<span
|
|
136
|
+
v-for="(label, i) in selectedLabels"
|
|
137
|
+
:key="modelValue[i]"
|
|
138
|
+
class="inline-flex items-center gap-0.5 px-1 py-px bg-3 text-fg-0 text-xs rounded-sm whitespace-nowrap"
|
|
139
|
+
>
|
|
140
|
+
{{ label }}
|
|
141
|
+
<Icon
|
|
142
|
+
icon="material-symbols:close"
|
|
143
|
+
class="text-xs text-fg-2 hover:text-fg-0 cursor-pointer"
|
|
144
|
+
@click.stop="remove(modelValue[i])"
|
|
145
|
+
/>
|
|
146
|
+
</span>
|
|
147
|
+
<span v-if="!modelValue.length" class="inline-flex items-center px-1 py-px text-fg-3 text-xs">{{ placeholder }}</span>
|
|
148
|
+
</div>
|
|
149
|
+
<Icon
|
|
150
|
+
v-if="modelValue.length"
|
|
151
|
+
icon="material-symbols:close"
|
|
152
|
+
class="text-lg text-fg-2 hover:text-fg-0 shrink-0 cursor-pointer"
|
|
153
|
+
@click.stop="clearAll"
|
|
154
|
+
/>
|
|
155
|
+
<Icon
|
|
156
|
+
v-else
|
|
157
|
+
icon="material-symbols:expand-more"
|
|
158
|
+
class="text-xl text-fg-2 shrink-0 transition-transform duration-150"
|
|
159
|
+
:class="open ? 'rotate-180' : ''"
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
<Teleport to="body">
|
|
163
|
+
<div
|
|
164
|
+
v-if="open"
|
|
165
|
+
ref="menu"
|
|
166
|
+
class="bg-1 border border-line rounded-sm z-[1000] overflow-hidden flex flex-col w-max"
|
|
167
|
+
:style="{ ...floatingStyles, maxWidth: maxWidth + 'px', maxHeight: maxHeight + 'px' }"
|
|
168
|
+
>
|
|
169
|
+
<div class="px-1.5 py-1.5 border-b border-line-subtle">
|
|
170
|
+
<input
|
|
171
|
+
ref="inputRef"
|
|
172
|
+
v-model="query"
|
|
173
|
+
type="text"
|
|
174
|
+
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"
|
|
175
|
+
:placeholder="filterPlaceholder"
|
|
176
|
+
@keydown="handleKeydown"
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
<ScrollBox class="flex-1">
|
|
180
|
+
<button
|
|
181
|
+
v-for="(opt, i) in filtered"
|
|
182
|
+
:key="opt.value"
|
|
183
|
+
type="button"
|
|
184
|
+
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 hover:bg-3"
|
|
185
|
+
:class="[
|
|
186
|
+
i === activeIndex ? 'bg-3 text-fg-0' : 'text-fg-1 hover:text-fg-0'
|
|
187
|
+
]"
|
|
188
|
+
:data-active="i === activeIndex ? '' : undefined"
|
|
189
|
+
@click="toggle(opt.value)"
|
|
190
|
+
@mouseenter="activeIndex = i"
|
|
191
|
+
>
|
|
192
|
+
<span
|
|
193
|
+
class="w-[14px] h-[14px] rounded-sm border flex items-center justify-center shrink-0"
|
|
194
|
+
:class="selectedSet.has(opt.value) ? 'bg-accent border-accent' : 'bg-0 border-line'"
|
|
195
|
+
>
|
|
196
|
+
<svg v-if="selectedSet.has(opt.value)" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
197
|
+
<path d="M2 5L4 7L8 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
198
|
+
</svg>
|
|
199
|
+
</span>
|
|
200
|
+
{{ opt.label }}
|
|
201
|
+
</button>
|
|
202
|
+
<div v-if="filtered.length === 0" class="px-2.5 py-2 text-base text-fg-3">
|
|
203
|
+
no matches
|
|
204
|
+
</div>
|
|
205
|
+
</ScrollBox>
|
|
206
|
+
</div>
|
|
207
|
+
</Teleport>
|
|
208
|
+
</div>
|
|
209
|
+
</template>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { reactive, computed } from "vue"
|
|
3
|
+
import { Icon } from "@iconify/vue"
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
items: { type: Array, required: true },
|
|
7
|
+
modelValue: { type: [String, Number, null], default: null },
|
|
8
|
+
defaultExpanded: { type: [Array, Boolean], default: () => [] }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits(["update:modelValue", "select"])
|
|
12
|
+
|
|
13
|
+
const expandedKeys = reactive(new Set(
|
|
14
|
+
props.defaultExpanded === true ? collectGroupKeys(props.items) : props.defaultExpanded
|
|
15
|
+
))
|
|
16
|
+
|
|
17
|
+
function collectGroupKeys(nodes) {
|
|
18
|
+
const keys = []
|
|
19
|
+
for (const n of nodes) {
|
|
20
|
+
if (n.children?.length) {
|
|
21
|
+
keys.push(n.key)
|
|
22
|
+
keys.push(...collectGroupKeys(n.children))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return keys
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const flatVisible = computed(() => {
|
|
29
|
+
const result = []
|
|
30
|
+
|
|
31
|
+
function walk(nodes, depth) {
|
|
32
|
+
for (const node of nodes) {
|
|
33
|
+
const isGroup = node.children?.length > 0
|
|
34
|
+
const expanded = isGroup && expandedKeys.has(node.key)
|
|
35
|
+
result.push({ node, depth, isGroup, expanded })
|
|
36
|
+
if (expanded) walk(node.children, depth + 1)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
walk(props.items, 0)
|
|
41
|
+
return result
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function toggle(key) {
|
|
45
|
+
if (expandedKeys.has(key)) expandedKeys.delete(key)
|
|
46
|
+
else expandedKeys.add(key)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleClick(node, isGroup) {
|
|
50
|
+
if (isGroup) {
|
|
51
|
+
toggle(node.key)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
emit("update:modelValue", node.key)
|
|
55
|
+
emit("select", node.key)
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<div>
|
|
61
|
+
<div
|
|
62
|
+
v-for="{ node, depth, isGroup, expanded } in flatVisible"
|
|
63
|
+
:key="node.key"
|
|
64
|
+
class="flex items-center gap-1 py-1 pr-2 cursor-pointer font-mono text-base select-none"
|
|
65
|
+
:class="[
|
|
66
|
+
isGroup
|
|
67
|
+
? 'text-fg-2 hover:text-fg-1'
|
|
68
|
+
: node.key === modelValue
|
|
69
|
+
? 'text-fg-0 bg-3'
|
|
70
|
+
: 'text-fg-1 hover:bg-2 hover:text-fg-0'
|
|
71
|
+
]"
|
|
72
|
+
:style="{ paddingLeft: (depth * 20 + 8) + 'px' }"
|
|
73
|
+
@click="handleClick(node, isGroup)"
|
|
74
|
+
>
|
|
75
|
+
<span v-if="isGroup" class="w-[16px] shrink-0 flex items-center justify-center">
|
|
76
|
+
<Icon
|
|
77
|
+
icon="material-symbols:chevron-right"
|
|
78
|
+
class="text-sm transition-transform duration-150"
|
|
79
|
+
:class="expanded ? 'rotate-90' : ''"
|
|
80
|
+
/>
|
|
81
|
+
</span>
|
|
82
|
+
<Icon
|
|
83
|
+
v-if="node.icon"
|
|
84
|
+
:icon="node.icon"
|
|
85
|
+
class="text-base shrink-0"
|
|
86
|
+
:class="node.key === modelValue ? 'text-accent' : 'text-fg-2'"
|
|
87
|
+
/>
|
|
88
|
+
<span class="truncate flex-1">{{ node.label }}</span>
|
|
89
|
+
<slot :name="`trailing-${node.key}`" :node="node" />
|
|
90
|
+
<span v-if="node.badge != null" class="text-xs text-fg-3 bg-4 px-1 py-px rounded-sm shrink-0">
|
|
91
|
+
{{ node.badge }}
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
<div v-if="flatVisible.length === 0" class="px-3 py-2 text-base text-fg-3">
|
|
95
|
+
no items
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</template>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch } from "vue"
|
|
3
|
+
import { Icon } from "@iconify/vue"
|
|
4
|
+
import { usePointerDrag } from "../composables/usePointerDrag.js"
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
modelValue: { type: Number, default: 0 },
|
|
8
|
+
min: { type: Number, default: -Infinity },
|
|
9
|
+
max: { type: Number, default: Infinity },
|
|
10
|
+
step: { type: Number, default: 1 },
|
|
11
|
+
precision: { type: Number, default: null },
|
|
12
|
+
inline: { type: Boolean, default: false },
|
|
13
|
+
sensitivity: { type: Number, default: 0.15 }
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits(["update:modelValue"])
|
|
17
|
+
|
|
18
|
+
const displayValue = ref(String(props.modelValue))
|
|
19
|
+
const dragging = ref(false)
|
|
20
|
+
|
|
21
|
+
watch(() => props.modelValue, v => {
|
|
22
|
+
if (!dragging.value) {
|
|
23
|
+
displayValue.value = formatValue(v)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const effectivePrecision = computed(() => {
|
|
28
|
+
if (props.precision != null) return props.precision
|
|
29
|
+
const s = String(props.step)
|
|
30
|
+
const dot = s.indexOf(".")
|
|
31
|
+
return dot === -1 ? 0 : s.length - dot - 1
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
function formatValue(v) {
|
|
35
|
+
const clamped = clamp(v)
|
|
36
|
+
return effectivePrecision.value > 0
|
|
37
|
+
? clamped.toFixed(effectivePrecision.value)
|
|
38
|
+
: String(Math.round(clamped))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clamp(v) {
|
|
42
|
+
return Math.min(props.max, Math.max(props.min, v))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nudge(delta) {
|
|
46
|
+
const current = parseFloat(displayValue.value)
|
|
47
|
+
const base = isNaN(current) ? props.modelValue : current
|
|
48
|
+
const next = clamp(base + delta)
|
|
49
|
+
emit("update:modelValue", Number(formatValue(next)))
|
|
50
|
+
displayValue.value = formatValue(next)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getStep(e) {
|
|
54
|
+
if (e.shiftKey) return props.step * 10
|
|
55
|
+
if (e.metaKey || e.ctrlKey) return props.step * 0.1
|
|
56
|
+
return props.step
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleKeydown(e) {
|
|
60
|
+
if (e.key === "ArrowUp") {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
nudge(getStep(e))
|
|
63
|
+
} else if (e.key === "ArrowDown") {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
nudge(-getStep(e))
|
|
66
|
+
} else if (e.key === "Enter") {
|
|
67
|
+
handleBlur()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleBlur() {
|
|
72
|
+
const parsed = parseFloat(displayValue.value)
|
|
73
|
+
if (isNaN(parsed)) {
|
|
74
|
+
displayValue.value = formatValue(props.modelValue)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const clamped = clamp(parsed)
|
|
78
|
+
const formatted = formatValue(clamped)
|
|
79
|
+
displayValue.value = formatted
|
|
80
|
+
emit("update:modelValue", Number(formatted))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function handleInput(e) {
|
|
84
|
+
displayValue.value = e.target.value
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dragAccum = ref(0)
|
|
88
|
+
|
|
89
|
+
const { start: startDrag } = usePointerDrag({
|
|
90
|
+
onStart() {
|
|
91
|
+
dragging.value = true
|
|
92
|
+
const current = parseFloat(displayValue.value)
|
|
93
|
+
dragAccum.value = isNaN(current) ? props.modelValue : current
|
|
94
|
+
},
|
|
95
|
+
onDrag(dx) {
|
|
96
|
+
dragAccum.value = clamp(dragAccum.value + dx * props.step * props.sensitivity)
|
|
97
|
+
const rounded = Number(formatValue(dragAccum.value))
|
|
98
|
+
if (rounded !== Number(displayValue.value)) {
|
|
99
|
+
displayValue.value = formatValue(dragAccum.value)
|
|
100
|
+
emit("update:modelValue", rounded)
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
onEnd() {
|
|
104
|
+
dragging.value = false
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<template>
|
|
110
|
+
<div :class="inline ? 'flex items-center relative w-full' : 'inline-flex items-center relative'">
|
|
111
|
+
<input
|
|
112
|
+
:value="displayValue"
|
|
113
|
+
class="font-mono text-fg-0 focus:outline-none w-full"
|
|
114
|
+
:class="inline
|
|
115
|
+
? 'bg-transparent border-none p-0 m-0 pr-5 text-base'
|
|
116
|
+
: 'px-2 py-1 pr-7 text-base bg-0 border border-line rounded-sm focus:border-accent'"
|
|
117
|
+
@input="handleInput"
|
|
118
|
+
@blur="handleBlur"
|
|
119
|
+
@keydown="handleKeydown"
|
|
120
|
+
/>
|
|
121
|
+
<div
|
|
122
|
+
class="absolute right-0 top-0 bottom-0 flex items-center justify-center w-6 cursor-ew-resize text-fg-3 hover:text-accent"
|
|
123
|
+
@mousedown.prevent="startDrag"
|
|
124
|
+
>
|
|
125
|
+
<Icon icon="material-symbols:swap-horiz" class="text-base" />
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|