@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,94 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, watch, nextTick } from "vue"
|
|
3
|
+
import { useFloating, flip, shift, offset } from "@floating-ui/vue"
|
|
4
|
+
import { Icon } from "@iconify/vue"
|
|
5
|
+
import { useClickOutside } from "../composables/useClickOutside.js"
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
message: { type: String, default: "Are you sure?" },
|
|
9
|
+
confirmLabel: { type: String, default: "Ok" },
|
|
10
|
+
cancelLabel: { type: String, default: "Cancel" },
|
|
11
|
+
icon: { type: String, default: "material-symbols:warning-outline" },
|
|
12
|
+
placement: { type: String, default: "bottom-start" }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits(["confirm", "cancel"])
|
|
16
|
+
|
|
17
|
+
const open = ref(false)
|
|
18
|
+
const trigger = ref(null)
|
|
19
|
+
const panel = ref(null)
|
|
20
|
+
|
|
21
|
+
const { floatingStyles } = useFloating(trigger, panel, {
|
|
22
|
+
placement: props.placement,
|
|
23
|
+
strategy: "fixed",
|
|
24
|
+
middleware: [
|
|
25
|
+
offset(4),
|
|
26
|
+
flip(),
|
|
27
|
+
shift({ crossAxis: true, padding: 0 })
|
|
28
|
+
],
|
|
29
|
+
open
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
useClickOutside([trigger, panel], () => {
|
|
33
|
+
if (open.value) cancel()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
watch(open, async (val) => {
|
|
37
|
+
if (!val) return
|
|
38
|
+
await nextTick()
|
|
39
|
+
const focusable = panel.value?.querySelector("button, [tabindex], input, select, textarea, a[href]")
|
|
40
|
+
focusable?.focus()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
function toggle() {
|
|
44
|
+
open.value = !open.value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function confirm() {
|
|
48
|
+
open.value = false
|
|
49
|
+
emit("confirm")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cancel() {
|
|
53
|
+
open.value = false
|
|
54
|
+
emit("cancel")
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<div class="inline-flex">
|
|
60
|
+
<div ref="trigger" @click="toggle">
|
|
61
|
+
<slot />
|
|
62
|
+
</div>
|
|
63
|
+
<Teleport to="body">
|
|
64
|
+
<div
|
|
65
|
+
v-if="open"
|
|
66
|
+
ref="panel"
|
|
67
|
+
class="bg-1 border border-line rounded-sm p-3 z-[1000] w-max"
|
|
68
|
+
:style="floatingStyles"
|
|
69
|
+
@keydown.escape="cancel"
|
|
70
|
+
>
|
|
71
|
+
<div class="flex items-center gap-1.5 mb-2">
|
|
72
|
+
<Icon :icon="icon" class="text-xl text-warning shrink-0" />
|
|
73
|
+
<span class="text-base text-fg-0">{{ message }}</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="flex items-center justify-end gap-1.5">
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="inline-flex items-center justify-center px-2 py-1 text-base font-mono rounded-sm cursor-pointer bg-2 border border-line text-fg-0 hover:bg-3 active:bg-4"
|
|
79
|
+
@click="cancel"
|
|
80
|
+
>
|
|
81
|
+
<span class="inline-flex items-center min-h-[1.5em]">{{ cancelLabel }}</span>
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
class="inline-flex items-center justify-center px-2 py-1 text-base font-mono rounded-sm cursor-pointer bg-accent border border-accent text-white hover:bg-accent-hover"
|
|
86
|
+
@click="confirm"
|
|
87
|
+
>
|
|
88
|
+
<span class="inline-flex items-center min-h-[1.5em]">{{ confirmLabel }}</span>
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</Teleport>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref } from "vue"
|
|
3
|
+
import { useFloating, flip, shift, offset } from "@floating-ui/vue"
|
|
4
|
+
import { useClickOutside } from "../composables/useClickOutside.js"
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
placement: { type: String, default: "bottom-start" },
|
|
8
|
+
offsetVal: { type: Number, default: 4 }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const open = ref(false)
|
|
12
|
+
const trigger = ref(null)
|
|
13
|
+
const content = ref(null)
|
|
14
|
+
|
|
15
|
+
const { floatingStyles } = useFloating(trigger, content, {
|
|
16
|
+
placement: props.placement,
|
|
17
|
+
strategy: "fixed",
|
|
18
|
+
middleware: [
|
|
19
|
+
offset(props.offsetVal),
|
|
20
|
+
flip(),
|
|
21
|
+
shift({ crossAxis: true, padding: 8 })
|
|
22
|
+
],
|
|
23
|
+
open
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
useClickOutside([trigger, content], () => {
|
|
27
|
+
open.value = false
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function toggle() {
|
|
31
|
+
open.value = !open.value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
defineExpose({ open, toggle })
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<div class="inline-flex">
|
|
39
|
+
<div ref="trigger" @click="toggle">
|
|
40
|
+
<slot name="trigger" />
|
|
41
|
+
</div>
|
|
42
|
+
<Teleport to="body">
|
|
43
|
+
<div
|
|
44
|
+
v-if="open"
|
|
45
|
+
ref="content"
|
|
46
|
+
class="z-[1000]"
|
|
47
|
+
:style="floatingStyles"
|
|
48
|
+
>
|
|
49
|
+
<slot />
|
|
50
|
+
</div>
|
|
51
|
+
</Teleport>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
modelValue: { type: [String, Number, null], default: null },
|
|
4
|
+
options: { type: Array, required: true }
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
const emit = defineEmits(["update:modelValue"])
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div class="flex flex-col gap-1">
|
|
12
|
+
<button
|
|
13
|
+
v-for="opt in options"
|
|
14
|
+
:key="opt.value"
|
|
15
|
+
type="button"
|
|
16
|
+
class="flex items-start gap-2 px-2 py-1.5 rounded-sm border cursor-pointer text-left font-mono"
|
|
17
|
+
:class="modelValue === opt.value
|
|
18
|
+
? 'border-accent bg-3'
|
|
19
|
+
: 'border-line bg-0 hover:bg-2'"
|
|
20
|
+
@click="emit('update:modelValue', opt.value)"
|
|
21
|
+
>
|
|
22
|
+
<span
|
|
23
|
+
class="mt-0.5 w-[14px] h-[14px] rounded-full border shrink-0 flex items-center justify-center"
|
|
24
|
+
:class="modelValue === opt.value ? 'border-accent' : 'border-line'"
|
|
25
|
+
>
|
|
26
|
+
<span
|
|
27
|
+
v-if="modelValue === opt.value"
|
|
28
|
+
class="w-[8px] h-[8px] rounded-full bg-accent"
|
|
29
|
+
/>
|
|
30
|
+
</span>
|
|
31
|
+
<div class="flex flex-col gap-px">
|
|
32
|
+
<span class="text-base text-fg-0">{{ opt.label }}</span>
|
|
33
|
+
<span v-if="opt.description" class="text-xs text-fg-2">{{ opt.description }}</span>
|
|
34
|
+
</div>
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { Icon } from "@iconify/vue"
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
modelValue: { type: [String, Number, Boolean], default: null },
|
|
6
|
+
options: { type: Array, required: true },
|
|
7
|
+
direction: {
|
|
8
|
+
type: String,
|
|
9
|
+
default: "vertical",
|
|
10
|
+
validator: v => ["horizontal", "vertical"].includes(v)
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits(["update:modelValue"])
|
|
15
|
+
|
|
16
|
+
function select(value) {
|
|
17
|
+
emit("update:modelValue", value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isSelected(value) {
|
|
21
|
+
return props.modelValue === value
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div
|
|
27
|
+
class="inline-flex"
|
|
28
|
+
:class="direction === 'horizontal' ? 'flex-row gap-1' : 'flex-col gap-0.5'"
|
|
29
|
+
>
|
|
30
|
+
<label
|
|
31
|
+
v-for="opt in options"
|
|
32
|
+
:key="opt.value"
|
|
33
|
+
class="inline-flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:bg-2"
|
|
34
|
+
@click.prevent="select(opt.value)"
|
|
35
|
+
>
|
|
36
|
+
<template v-if="opt.icon">
|
|
37
|
+
<Icon
|
|
38
|
+
:icon="opt.icon"
|
|
39
|
+
class="text-base shrink-0"
|
|
40
|
+
:class="isSelected(opt.value) ? 'text-accent' : 'text-fg-2'"
|
|
41
|
+
/>
|
|
42
|
+
</template>
|
|
43
|
+
<template v-else>
|
|
44
|
+
<span
|
|
45
|
+
class="w-[14px] h-[14px] rounded-full border flex items-center justify-center shrink-0"
|
|
46
|
+
:class="isSelected(opt.value) ? 'border-accent' : 'border-line'"
|
|
47
|
+
>
|
|
48
|
+
<span
|
|
49
|
+
v-if="isSelected(opt.value)"
|
|
50
|
+
class="w-[8px] h-[8px] rounded-full bg-accent"
|
|
51
|
+
/>
|
|
52
|
+
</span>
|
|
53
|
+
</template>
|
|
54
|
+
<span class="text-base font-mono text-fg-0 select-none">{{ opt.label }}</span>
|
|
55
|
+
</label>
|
|
56
|
+
</div>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch, nextTick, onUnmounted } from "vue"
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
modelValue: { type: Number, default: 0 },
|
|
6
|
+
min: { type: Number, default: 0 },
|
|
7
|
+
max: { type: Number, default: 100 },
|
|
8
|
+
step: { type: Number, default: 1 },
|
|
9
|
+
precision: { type: Number, default: null },
|
|
10
|
+
label: { type: String, default: null }
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits(["update:modelValue"])
|
|
14
|
+
|
|
15
|
+
const barRef = ref(null)
|
|
16
|
+
const editing = ref(false)
|
|
17
|
+
const inputRef = ref(null)
|
|
18
|
+
const displayValue = ref(String(props.modelValue))
|
|
19
|
+
|
|
20
|
+
const CLICK_THRESHOLD = 3
|
|
21
|
+
|
|
22
|
+
watch(() => props.modelValue, v => {
|
|
23
|
+
if (!editing.value) {
|
|
24
|
+
displayValue.value = formatValue(v)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const effectivePrecision = computed(() => {
|
|
29
|
+
if (props.precision != null) return props.precision
|
|
30
|
+
const s = String(props.step)
|
|
31
|
+
const dot = s.indexOf(".")
|
|
32
|
+
return dot === -1 ? 0 : s.length - dot - 1
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const fillPercent = computed(() => {
|
|
36
|
+
if (props.max === props.min) return 0
|
|
37
|
+
return ((props.modelValue - props.min) / (props.max - props.min)) * 100
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function formatValue(v) {
|
|
41
|
+
const clamped = clamp(v)
|
|
42
|
+
return effectivePrecision.value > 0
|
|
43
|
+
? clamped.toFixed(effectivePrecision.value)
|
|
44
|
+
: String(Math.round(clamped))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clamp(v) {
|
|
48
|
+
return Math.min(props.max, Math.max(props.min, v))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function snap(v) {
|
|
52
|
+
const stepped = Math.round((v - props.min) / props.step) * props.step + props.min
|
|
53
|
+
return clamp(stepped)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function valueFromX(clientX) {
|
|
57
|
+
const rect = barRef.value.getBoundingClientRect()
|
|
58
|
+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
|
59
|
+
return snap(props.min + ratio * (props.max - props.min))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let startX = 0
|
|
63
|
+
let didDrag = false
|
|
64
|
+
|
|
65
|
+
function handleMousedown(e) {
|
|
66
|
+
if (editing.value) return
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
startX = e.clientX
|
|
69
|
+
didDrag = false
|
|
70
|
+
document.addEventListener("mousemove", handleMousemove)
|
|
71
|
+
document.addEventListener("mouseup", handleMouseup)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleMousemove(e) {
|
|
75
|
+
if (Math.abs(e.clientX - startX) > CLICK_THRESHOLD) {
|
|
76
|
+
didDrag = true
|
|
77
|
+
}
|
|
78
|
+
if (didDrag) {
|
|
79
|
+
const next = valueFromX(e.clientX)
|
|
80
|
+
const formatted = Number(formatValue(next))
|
|
81
|
+
if (formatted !== props.modelValue) {
|
|
82
|
+
emit("update:modelValue", formatted)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleMouseup(e) {
|
|
88
|
+
document.removeEventListener("mousemove", handleMousemove)
|
|
89
|
+
document.removeEventListener("mouseup", handleMouseup)
|
|
90
|
+
if (!didDrag) {
|
|
91
|
+
enterEdit()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onUnmounted(() => {
|
|
96
|
+
document.removeEventListener("mousemove", handleMousemove)
|
|
97
|
+
document.removeEventListener("mouseup", handleMouseup)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
function enterEdit() {
|
|
101
|
+
editing.value = true
|
|
102
|
+
displayValue.value = formatValue(props.modelValue)
|
|
103
|
+
nextTick(() => {
|
|
104
|
+
inputRef.value?.focus()
|
|
105
|
+
inputRef.value?.select()
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commitEdit() {
|
|
110
|
+
const parsed = parseFloat(displayValue.value)
|
|
111
|
+
if (!isNaN(parsed)) {
|
|
112
|
+
const clamped = snap(parsed)
|
|
113
|
+
emit("update:modelValue", Number(formatValue(clamped)))
|
|
114
|
+
}
|
|
115
|
+
editing.value = false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cancelEdit() {
|
|
119
|
+
displayValue.value = formatValue(props.modelValue)
|
|
120
|
+
editing.value = false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleEditKeydown(e) {
|
|
124
|
+
if (e.key === "Enter") {
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
commitEdit()
|
|
127
|
+
} else if (e.key === "Escape") {
|
|
128
|
+
e.preventDefault()
|
|
129
|
+
cancelEdit()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<template>
|
|
135
|
+
<div
|
|
136
|
+
ref="barRef"
|
|
137
|
+
class="relative h-[26px] bg-3 rounded-sm cursor-ew-resize select-none overflow-hidden border border-line"
|
|
138
|
+
@mousedown="handleMousedown"
|
|
139
|
+
>
|
|
140
|
+
<div
|
|
141
|
+
class="absolute inset-y-0 left-0 bg-accent"
|
|
142
|
+
:style="{ width: fillPercent + '%' }"
|
|
143
|
+
/>
|
|
144
|
+
<div class="absolute inset-0 flex items-center px-2">
|
|
145
|
+
<template v-if="editing">
|
|
146
|
+
<input
|
|
147
|
+
ref="inputRef"
|
|
148
|
+
v-model="displayValue"
|
|
149
|
+
class="w-full bg-transparent border-none outline-none font-mono text-base text-fg-0 text-center p-0 m-0 relative z-10"
|
|
150
|
+
@blur="commitEdit"
|
|
151
|
+
@keydown="handleEditKeydown"
|
|
152
|
+
/>
|
|
153
|
+
</template>
|
|
154
|
+
<template v-else>
|
|
155
|
+
<span v-if="label" class="text-xs text-fg-0 mr-auto relative z-10">{{ label }}</span>
|
|
156
|
+
<span
|
|
157
|
+
class="font-mono text-base text-fg-0 relative z-10"
|
|
158
|
+
:class="label ? '' : 'mx-auto'"
|
|
159
|
+
>
|
|
160
|
+
{{ formatValue(modelValue) }}
|
|
161
|
+
</span>
|
|
162
|
+
</template>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
horizontal: { type: Boolean, default: false },
|
|
4
|
+
tag: { type: String, default: "div" }
|
|
5
|
+
})
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<component
|
|
10
|
+
:is="tag"
|
|
11
|
+
class="scroll-box"
|
|
12
|
+
:class="horizontal ? 'scroll-box-both' : 'scroll-box-vertical'"
|
|
13
|
+
>
|
|
14
|
+
<slot />
|
|
15
|
+
</component>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.scroll-box {
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
--scrollbar-size: 8px;
|
|
22
|
+
--scrollbar-thumb: var(--bg-5);
|
|
23
|
+
--scrollbar-thumb-hover: var(--bg-6);
|
|
24
|
+
--scrollbar-track: transparent;
|
|
25
|
+
--scrollbar-track-hover: var(--bg-2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.scroll-box-vertical {
|
|
29
|
+
overflow-y: auto;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.scroll-box-both {
|
|
33
|
+
overflow: auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* firefox */
|
|
37
|
+
.scroll-box {
|
|
38
|
+
scrollbar-width: thin;
|
|
39
|
+
scrollbar-color: transparent transparent;
|
|
40
|
+
transition: scrollbar-color 0.15s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.scroll-box:hover {
|
|
44
|
+
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* webkit */
|
|
48
|
+
.scroll-box::-webkit-scrollbar {
|
|
49
|
+
width: var(--scrollbar-size);
|
|
50
|
+
height: var(--scrollbar-size);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.scroll-box::-webkit-scrollbar-track {
|
|
54
|
+
background: var(--scrollbar-track);
|
|
55
|
+
transition: background 0.15s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.scroll-box:hover::-webkit-scrollbar-track {
|
|
59
|
+
background: var(--scrollbar-track-hover);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.scroll-box::-webkit-scrollbar-thumb {
|
|
63
|
+
background: transparent;
|
|
64
|
+
border-radius: var(--scrollbar-size);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.scroll-box:hover::-webkit-scrollbar-thumb {
|
|
68
|
+
background: var(--scrollbar-thumb);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.scroll-box::-webkit-scrollbar-thumb:hover {
|
|
72
|
+
background: var(--scrollbar-thumb-hover);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.scroll-box::-webkit-scrollbar-corner {
|
|
76
|
+
background: transparent;
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
label: { type: String, required: true },
|
|
4
|
+
description: { type: String, default: null }
|
|
5
|
+
})
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div class="flex items-start justify-between gap-4 px-2 py-2">
|
|
10
|
+
<div>
|
|
11
|
+
<div class="text-xs text-fg-1 uppercase tracking-wide cursor-default">{{ label }}</div>
|
|
12
|
+
<div v-if="description" class="text-xs text-fg-3 mt-0.5 cursor-default">{{ description }}</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div v-if="$slots.actions" class="flex items-center gap-2 shrink-0">
|
|
15
|
+
<slot name="actions" />
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, useAttrs, 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
|
+
defineOptions({ inheritAttrs: false })
|
|
9
|
+
|
|
10
|
+
const attrs = useAttrs()
|
|
11
|
+
|
|
12
|
+
const attrsWithoutClass = computed(() => {
|
|
13
|
+
const { class: _, ...rest } = attrs
|
|
14
|
+
return rest
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
modelValue: { type: [String, Number, Boolean, null], default: null },
|
|
19
|
+
options: { type: Array, required: true },
|
|
20
|
+
placeholder: { type: String, default: "select..." },
|
|
21
|
+
inline: { type: Boolean, default: false },
|
|
22
|
+
defaultOpen: { type: Boolean, default: false },
|
|
23
|
+
disabled: { type: Boolean, default: false }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits(["update:modelValue", "close"])
|
|
27
|
+
|
|
28
|
+
const open = ref(props.defaultOpen)
|
|
29
|
+
const trigger = ref(null)
|
|
30
|
+
const menu = ref(null)
|
|
31
|
+
const maxWidth = ref(null)
|
|
32
|
+
const maxHeight = ref(null)
|
|
33
|
+
const highlightedIndex = ref(-1)
|
|
34
|
+
|
|
35
|
+
const { floatingStyles } = useFloating(trigger, menu, {
|
|
36
|
+
placement: "bottom-start",
|
|
37
|
+
strategy: "fixed",
|
|
38
|
+
middleware: [
|
|
39
|
+
offset(4),
|
|
40
|
+
flip(),
|
|
41
|
+
shift({ crossAxis: true, padding: 0 }),
|
|
42
|
+
size({
|
|
43
|
+
padding: 8,
|
|
44
|
+
apply({ availableWidth, availableHeight }) {
|
|
45
|
+
maxWidth.value = availableWidth
|
|
46
|
+
maxHeight.value = Math.min(availableHeight, 200)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
],
|
|
50
|
+
open
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const normalizedOptions = computed(() => {
|
|
54
|
+
return props.options.map(opt => {
|
|
55
|
+
if (typeof opt === "object" && opt !== null) {
|
|
56
|
+
return { value: opt.value, label: opt.label ?? String(opt.value) }
|
|
57
|
+
}
|
|
58
|
+
return { value: opt, label: String(opt) }
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const selectedLabel = computed(() => {
|
|
63
|
+
const found = normalizedOptions.value.find(o => o.value === props.modelValue)
|
|
64
|
+
return found?.label ?? props.placeholder
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function close() {
|
|
68
|
+
if (!open.value) return
|
|
69
|
+
open.value = false
|
|
70
|
+
highlightedIndex.value = -1
|
|
71
|
+
emit("close")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function openMenu() {
|
|
75
|
+
if (props.disabled) return
|
|
76
|
+
const currentIndex = normalizedOptions.value.findIndex(o => o.value === props.modelValue)
|
|
77
|
+
highlightedIndex.value = currentIndex !== -1 ? currentIndex : 0
|
|
78
|
+
open.value = true
|
|
79
|
+
nextTick(scrollToHighlighted)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toggle() {
|
|
83
|
+
if (props.disabled) return
|
|
84
|
+
if (open.value) close()
|
|
85
|
+
else openMenu()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function select(value) {
|
|
89
|
+
emit("update:modelValue", value)
|
|
90
|
+
close()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scrollToHighlighted() {
|
|
94
|
+
const menuEl = menu.value?.$el ?? menu.value
|
|
95
|
+
if (!menuEl) return
|
|
96
|
+
const items = menuEl.querySelectorAll("[data-option]")
|
|
97
|
+
items[highlightedIndex.value]?.scrollIntoView({ block: "nearest" })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onKeydown(e) {
|
|
101
|
+
if (e.key === "Escape") {
|
|
102
|
+
close()
|
|
103
|
+
trigger.value?.focus()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!open.value) {
|
|
108
|
+
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
109
|
+
e.preventDefault()
|
|
110
|
+
openMenu()
|
|
111
|
+
}
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const opts = normalizedOptions.value
|
|
116
|
+
if (e.key === "ArrowDown") {
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
highlightedIndex.value = (highlightedIndex.value + 1) % opts.length
|
|
119
|
+
nextTick(scrollToHighlighted)
|
|
120
|
+
} else if (e.key === "ArrowUp") {
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
highlightedIndex.value = (highlightedIndex.value - 1 + opts.length) % opts.length
|
|
123
|
+
nextTick(scrollToHighlighted)
|
|
124
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
if (highlightedIndex.value >= 0) select(opts[highlightedIndex.value].value)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
useClickOutside([trigger, menu], close)
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<template>
|
|
134
|
+
<div :class="inline ? 'relative flex w-full' : 'relative inline-flex'" v-bind="attrsWithoutClass">
|
|
135
|
+
<button
|
|
136
|
+
ref="trigger"
|
|
137
|
+
type="button"
|
|
138
|
+
class="flex items-center gap-1 font-mono text-base min-w-0 flex-1 outline-none focus-visible:outline-1 focus-visible:outline-accent"
|
|
139
|
+
:class="[
|
|
140
|
+
disabled ? 'cursor-default opacity-50' : 'cursor-pointer',
|
|
141
|
+
inline
|
|
142
|
+
? 'bg-transparent border-none p-0 m-0'
|
|
143
|
+
: ['px-2 py-1 bg-0 border rounded-sm', disabled ? 'border-line' : ['hover:bg-1', open ? 'border-accent' : 'border-line']],
|
|
144
|
+
attrs.class ?? (disabled ? 'text-fg-2' : 'text-fg-0')
|
|
145
|
+
]"
|
|
146
|
+
@mousedown.prevent.stop="toggle"
|
|
147
|
+
@keydown="onKeydown"
|
|
148
|
+
>
|
|
149
|
+
<span
|
|
150
|
+
class="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis"
|
|
151
|
+
:class="modelValue == null ? 'text-fg-3' : ''"
|
|
152
|
+
>
|
|
153
|
+
{{ selectedLabel }}
|
|
154
|
+
</span>
|
|
155
|
+
<Icon
|
|
156
|
+
v-if="!inline"
|
|
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
|
+
</button>
|
|
162
|
+
<Teleport to="body">
|
|
163
|
+
<ScrollBox
|
|
164
|
+
v-if="open"
|
|
165
|
+
ref="menu"
|
|
166
|
+
class="bg-1 border border-line rounded-sm z-[1000] w-max"
|
|
167
|
+
:style="{ ...floatingStyles, maxWidth: maxWidth + 'px', maxHeight: maxHeight + 'px' }"
|
|
168
|
+
>
|
|
169
|
+
<button
|
|
170
|
+
v-for="(opt, i) in normalizedOptions"
|
|
171
|
+
:key="opt.value"
|
|
172
|
+
type="button"
|
|
173
|
+
data-option
|
|
174
|
+
class="block w-full px-2 py-1 border-none font-mono text-base text-left cursor-pointer whitespace-nowrap overflow-hidden text-ellipsis hover:bg-3 hover:text-fg-0"
|
|
175
|
+
:class="[
|
|
176
|
+
opt.value === modelValue ? 'text-accent' : 'text-fg-1',
|
|
177
|
+
i === highlightedIndex ? 'bg-3 text-fg-0' : 'bg-transparent'
|
|
178
|
+
]"
|
|
179
|
+
@click="select(opt.value)"
|
|
180
|
+
@pointerenter="highlightedIndex = i"
|
|
181
|
+
>
|
|
182
|
+
{{ opt.label }}
|
|
183
|
+
</button>
|
|
184
|
+
</ScrollBox>
|
|
185
|
+
</Teleport>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|