@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,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>