@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.
Files changed (90) hide show
  1. package/.claude/settings.local.json +13 -0
  2. package/.lore +83 -0
  3. package/histoire.config.js +43 -0
  4. package/package.json +39 -0
  5. package/postcss.config.js +6 -0
  6. package/src/components/Badge.vue +36 -0
  7. package/src/components/Button.vue +44 -0
  8. package/src/components/Checkbox.vue +51 -0
  9. package/src/components/CheckboxCards.vue +61 -0
  10. package/src/components/CodeEditor.vue +299 -0
  11. package/src/components/Collapsible.vue +69 -0
  12. package/src/components/CollapsibleGroup.vue +38 -0
  13. package/src/components/Combobox.vue +179 -0
  14. package/src/components/ContextMenu.vue +65 -0
  15. package/src/components/ContextMenuPanel.vue +115 -0
  16. package/src/components/DataTable.vue +326 -0
  17. package/src/components/Dropdown.vue +127 -0
  18. package/src/components/GhostInput.vue +29 -0
  19. package/src/components/Input.vue +23 -0
  20. package/src/components/KeyValue.vue +149 -0
  21. package/src/components/LabeledTextarea.vue +64 -0
  22. package/src/components/LabeledTextareaGroup.vue +14 -0
  23. package/src/components/Mention.vue +79 -0
  24. package/src/components/Modal.vue +109 -0
  25. package/src/components/MultiCombobox.vue +209 -0
  26. package/src/components/NavTree.vue +98 -0
  27. package/src/components/NumberInput.vue +128 -0
  28. package/src/components/PopConfirm.vue +94 -0
  29. package/src/components/Popover.vue +53 -0
  30. package/src/components/RadioCards.vue +37 -0
  31. package/src/components/RadioGroup.vue +57 -0
  32. package/src/components/RangeSlider.vue +165 -0
  33. package/src/components/ScrollBox.vue +78 -0
  34. package/src/components/SectionHeader.vue +18 -0
  35. package/src/components/Select.vue +187 -0
  36. package/src/components/Switch.vue +85 -0
  37. package/src/components/Tabs.vue +34 -0
  38. package/src/components/TagInput.vue +80 -0
  39. package/src/components/Textarea.vue +97 -0
  40. package/src/components/ToastContainer.vue +104 -0
  41. package/src/components/ToggleButtons.vue +45 -0
  42. package/src/components/ToggleGroup.vue +30 -0
  43. package/src/components/Tooltip.vue +56 -0
  44. package/src/components/Tree.vue +188 -0
  45. package/src/composables/toast.js +54 -0
  46. package/src/composables/useClickOutside.js +23 -0
  47. package/src/composables/useMention.js +291 -0
  48. package/src/composables/usePointerDrag.js +39 -0
  49. package/src/histoire-setup.js +1 -0
  50. package/src/index.js +43 -0
  51. package/src/style.css +96 -0
  52. package/stories/Badge.story.vue +24 -0
  53. package/stories/Button.story.vue +45 -0
  54. package/stories/Checkbox.story.vue +31 -0
  55. package/stories/CheckboxCards.story.vue +51 -0
  56. package/stories/CodeEditor.story.vue +71 -0
  57. package/stories/Collapsible.story.vue +84 -0
  58. package/stories/Combobox.story.vue +44 -0
  59. package/stories/ContextMenu.story.vue +59 -0
  60. package/stories/DataTable.story.vue +185 -0
  61. package/stories/Dropdown.story.vue +49 -0
  62. package/stories/GhostInput.story.vue +24 -0
  63. package/stories/Input.story.vue +23 -0
  64. package/stories/KeyValue.story.vue +104 -0
  65. package/stories/LabeledTextarea.story.vue +44 -0
  66. package/stories/Mention.story.vue +166 -0
  67. package/stories/Modal.story.vue +86 -0
  68. package/stories/MultiCombobox.story.vue +76 -0
  69. package/stories/NavTree.story.vue +184 -0
  70. package/stories/NumberInput.story.vue +31 -0
  71. package/stories/Overview.story.vue +85 -0
  72. package/stories/PopConfirm.story.vue +39 -0
  73. package/stories/RadioCards.story.vue +66 -0
  74. package/stories/RadioGroup.story.vue +52 -0
  75. package/stories/RangeSlider.story.vue +75 -0
  76. package/stories/ScrollBox.story.vue +54 -0
  77. package/stories/SectionHeader.story.vue +22 -0
  78. package/stories/Select.story.vue +34 -0
  79. package/stories/Switch.story.vue +42 -0
  80. package/stories/Tabs.story.vue +34 -0
  81. package/stories/TagInput.story.vue +54 -0
  82. package/stories/Textarea.story.vue +28 -0
  83. package/stories/Toast.story.vue +28 -0
  84. package/stories/ToggleButtons.story.vue +57 -0
  85. package/stories/ToggleGroup.story.vue +34 -0
  86. package/stories/Tooltip.story.vue +55 -0
  87. package/stories/Tree.story.vue +115 -0
  88. package/tailwind.config.js +9 -0
  89. package/tailwind.preset.js +79 -0
  90. 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>