@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,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebSearch",
|
|
5
|
+
"WebFetch(domain:raw.githubusercontent.com)",
|
|
6
|
+
"WebFetch(domain:github.com)",
|
|
7
|
+
"WebFetch(domain:histoire.dev)",
|
|
8
|
+
"mcp__github__search_code",
|
|
9
|
+
"mcp__github__get_file_contents",
|
|
10
|
+
"Bash(ls:*)"
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|
package/.lore
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
-- philosophy --
|
|
2
|
+
|
|
3
|
+
use these components when they fit naturally. if a component is close but not exactly what you need, do not force it. read the component source for patterns and inspiration, then build something custom in the project. a well-built one-off is better than a shoehorned library component.
|
|
4
|
+
|
|
5
|
+
- exact fit: use it directly
|
|
6
|
+
- close fit: read source, consider wrapper or slot. if it feels forced, build custom
|
|
7
|
+
- not a fit: build custom, follow same design system conventions (monospace, grayscale, amber accent, tight spacing)
|
|
8
|
+
|
|
9
|
+
-- installation --
|
|
10
|
+
|
|
11
|
+
not published to npm. add via file dependency:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{ "dependencies": { "mono-components": "file:/Users/jonathanpyers/code/mono-components" } }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
peer dependency: vue ^3.4
|
|
18
|
+
|
|
19
|
+
-- project setup --
|
|
20
|
+
|
|
21
|
+
ships a tailwind v3 preset. use it instead of defining theme colors manually:
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// tailwind.config.js
|
|
25
|
+
import preset from "mono-components/tailwind"
|
|
26
|
+
export default {
|
|
27
|
+
presets: [preset],
|
|
28
|
+
content: [
|
|
29
|
+
"./index.html",
|
|
30
|
+
"./src/**/*.{vue,js}",
|
|
31
|
+
"./node_modules/mono-components/src/**/*.vue"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
import base styles in main.js:
|
|
37
|
+
```js
|
|
38
|
+
import "mono-components/style.css"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
all exports are named. no default export:
|
|
42
|
+
```js
|
|
43
|
+
import { Button, Select, DataTable, toast } from "mono-components"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
-- decision guide --
|
|
47
|
+
|
|
48
|
+
"i need a select":
|
|
49
|
+
- few options, no search needed: Select
|
|
50
|
+
- many options or search by name: Combobox
|
|
51
|
+
- multi-select: MultiCombobox
|
|
52
|
+
- action menu (options do things, not set values): Dropdown
|
|
53
|
+
|
|
54
|
+
"i need a toggle":
|
|
55
|
+
- on/off setting: Switch
|
|
56
|
+
- yes/no with label: Checkbox
|
|
57
|
+
- pick one from icon options: ToggleGroup
|
|
58
|
+
- pick many from options: ToggleButtons
|
|
59
|
+
- pick one with labels: RadioGroup
|
|
60
|
+
- pick one with descriptions: RadioCards (single select, radio circles)
|
|
61
|
+
- pick many with descriptions: CheckboxCards (multi select, square checkmarks)
|
|
62
|
+
|
|
63
|
+
"i need to show data":
|
|
64
|
+
- tabular, possibly editable: DataTable
|
|
65
|
+
- key-value pairs, detail view: KeyValue
|
|
66
|
+
- status label: Badge
|
|
67
|
+
|
|
68
|
+
"i need floating content":
|
|
69
|
+
- generic anchored panel: Popover
|
|
70
|
+
- confirmation before action: PopConfirm
|
|
71
|
+
- info on hover: Tooltip
|
|
72
|
+
- blocking dialog: Modal
|
|
73
|
+
- right-click actions: ContextMenu
|
|
74
|
+
|
|
75
|
+
"i need hierarchy/navigation":
|
|
76
|
+
- data tree with search: Tree
|
|
77
|
+
- sidebar nav: NavTree
|
|
78
|
+
- tab switching: Tabs
|
|
79
|
+
- collapsible sections: Collapsible + CollapsibleGroup
|
|
80
|
+
|
|
81
|
+
-- story files --
|
|
82
|
+
|
|
83
|
+
each component has a story file in stories/ showing usage examples and prop combinations. when adapting a component or building custom, read the story for real usage patterns.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineConfig } from "histoire"
|
|
2
|
+
import { HstVue } from "@histoire/plugin-vue"
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [HstVue()],
|
|
6
|
+
setupFile: "./src/histoire-setup.js",
|
|
7
|
+
theme: {
|
|
8
|
+
title: "mono-components",
|
|
9
|
+
defaultColorScheme: "auto",
|
|
10
|
+
colors: {
|
|
11
|
+
gray: {
|
|
12
|
+
50: "#fcfcfe",
|
|
13
|
+
100: "#f3f4f7",
|
|
14
|
+
200: "#e8eaee",
|
|
15
|
+
300: "#dbdde3",
|
|
16
|
+
400: "#b8bdc7",
|
|
17
|
+
500: "#a0a6b2",
|
|
18
|
+
600: "#616872",
|
|
19
|
+
700: "#191a1e",
|
|
20
|
+
750: "#141518",
|
|
21
|
+
800: "#111214",
|
|
22
|
+
850: "#0d0e10",
|
|
23
|
+
900: "#0a0b0c",
|
|
24
|
+
950: "#060607"
|
|
25
|
+
},
|
|
26
|
+
primary: {
|
|
27
|
+
50: "#fef4ec",
|
|
28
|
+
100: "#fde4c8",
|
|
29
|
+
200: "#fbc890",
|
|
30
|
+
300: "#f09848",
|
|
31
|
+
400: "#cf6510",
|
|
32
|
+
500: "#b65400",
|
|
33
|
+
600: "#9a4700",
|
|
34
|
+
700: "#7d3900",
|
|
35
|
+
800: "#632d00",
|
|
36
|
+
900: "#4a2100"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
vite: {
|
|
41
|
+
base: "/"
|
|
42
|
+
}
|
|
43
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prsm/mono-components",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./style.css": "./src/style.css",
|
|
9
|
+
"./tailwind": "./tailwind.preset.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"story:dev": "histoire dev",
|
|
13
|
+
"story:build": "histoire build"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"vue": "^3.4"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@codemirror/autocomplete": "^6.20.0",
|
|
20
|
+
"@codemirror/commands": "^6.10.2",
|
|
21
|
+
"@codemirror/lang-sql": "^6.10.0",
|
|
22
|
+
"@codemirror/language": "^6.12.1",
|
|
23
|
+
"@codemirror/state": "^6.5.4",
|
|
24
|
+
"@codemirror/view": "^6.39.14",
|
|
25
|
+
"@floating-ui/vue": "^1.1.6",
|
|
26
|
+
"@iconify/vue": "^4.3.0",
|
|
27
|
+
"@lezer/highlight": "^1.2.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@histoire/plugin-vue": "^0.17.17",
|
|
31
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
32
|
+
"autoprefixer": "^10.4.20",
|
|
33
|
+
"histoire": "^0.17.17",
|
|
34
|
+
"postcss": "^8.5.1",
|
|
35
|
+
"tailwindcss": "^3.4.17",
|
|
36
|
+
"vite": "^5.4.0",
|
|
37
|
+
"vue": "^3.5.13"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
variant: {
|
|
6
|
+
type: String,
|
|
7
|
+
default: "neutral",
|
|
8
|
+
validator: v => ["success", "warning", "error", "info", "neutral"].includes(v)
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const colorMap = {
|
|
13
|
+
success: "var(--success)",
|
|
14
|
+
warning: "var(--warning)",
|
|
15
|
+
error: "var(--error)",
|
|
16
|
+
info: "var(--info)",
|
|
17
|
+
neutral: "var(--fg-2)"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const badgeStyle = computed(() => {
|
|
21
|
+
const color = colorMap[props.variant]
|
|
22
|
+
return {
|
|
23
|
+
color,
|
|
24
|
+
backgroundColor: `color-mix(in srgb, ${color} 10%, transparent)`
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<span
|
|
31
|
+
class="inline-flex items-center gap-1 text-sm py-1 px-1.5 rounded-sm font-mono"
|
|
32
|
+
:style="badgeStyle"
|
|
33
|
+
>
|
|
34
|
+
<slot />
|
|
35
|
+
</span>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
variant: {
|
|
4
|
+
type: String,
|
|
5
|
+
default: "default",
|
|
6
|
+
validator: v => ["default", "primary", "outline", "icon", "danger", "ghost"].includes(v)
|
|
7
|
+
},
|
|
8
|
+
size: {
|
|
9
|
+
type: String,
|
|
10
|
+
default: "default",
|
|
11
|
+
validator: v => ["default", "small"].includes(v)
|
|
12
|
+
},
|
|
13
|
+
type: {
|
|
14
|
+
type: String,
|
|
15
|
+
default: "button"
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const variantClasses = {
|
|
20
|
+
default: "bg-2 border border-line text-fg-0 hover:bg-3 active:bg-5",
|
|
21
|
+
primary: "bg-accent border border-accent text-white hover:bg-accent-hover active:brightness-75",
|
|
22
|
+
outline: "bg-transparent border border-line text-fg-0 hover:bg-2 active:bg-4",
|
|
23
|
+
icon: "bg-transparent border border-transparent text-fg-1 hover:bg-2 active:bg-4",
|
|
24
|
+
danger: "bg-error border border-error text-white hover:brightness-110 active:brightness-75",
|
|
25
|
+
ghost: "bg-transparent border border-transparent text-fg-0 hover:bg-2 active:bg-4"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sizeClasses = {
|
|
29
|
+
default: "px-2 py-1 text-base",
|
|
30
|
+
small: "px-1.5 py-0.5 text-xs"
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<button
|
|
36
|
+
:type="type"
|
|
37
|
+
class="inline-flex items-center justify-center gap-1 rounded-sm font-mono cursor-pointer whitespace-nowrap"
|
|
38
|
+
:class="[variantClasses[variant], sizeClasses[size]]"
|
|
39
|
+
>
|
|
40
|
+
<span class="inline-flex items-center justify-center gap-1 min-h-[1.5em]">
|
|
41
|
+
<slot />
|
|
42
|
+
</span>
|
|
43
|
+
</button>
|
|
44
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
modelValue: { type: Boolean, default: false },
|
|
6
|
+
indeterminate: { type: Boolean, default: false },
|
|
7
|
+
label: { type: String, default: null }
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits(["update:modelValue"])
|
|
11
|
+
|
|
12
|
+
const isChecked = computed(() => props.modelValue)
|
|
13
|
+
|
|
14
|
+
function toggle() {
|
|
15
|
+
emit("update:modelValue", !props.modelValue)
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<label
|
|
21
|
+
v-if="label"
|
|
22
|
+
class="inline-flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:bg-2"
|
|
23
|
+
@click.prevent="toggle"
|
|
24
|
+
>
|
|
25
|
+
<span
|
|
26
|
+
class="w-[14px] h-[14px] rounded-sm border flex items-center justify-center shrink-0"
|
|
27
|
+
:class="isChecked || indeterminate ? 'bg-accent border-accent' : 'bg-0 border-line'"
|
|
28
|
+
>
|
|
29
|
+
<svg v-if="isChecked && !indeterminate" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
30
|
+
<path d="M2 5L4 7L8 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
31
|
+
</svg>
|
|
32
|
+
<svg v-else-if="indeterminate" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
33
|
+
<path d="M2.5 5H7.5" stroke="white" stroke-width="1.5" stroke-linecap="round" />
|
|
34
|
+
</svg>
|
|
35
|
+
</span>
|
|
36
|
+
<span class="text-base font-mono text-fg-0 select-none">{{ label }}</span>
|
|
37
|
+
</label>
|
|
38
|
+
<span
|
|
39
|
+
v-else
|
|
40
|
+
class="inline-flex items-center justify-center w-[14px] h-[14px] rounded-sm border cursor-pointer"
|
|
41
|
+
:class="isChecked || indeterminate ? 'bg-accent border-accent' : 'bg-0 border-line'"
|
|
42
|
+
@click="toggle"
|
|
43
|
+
>
|
|
44
|
+
<svg v-if="isChecked && !indeterminate" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
45
|
+
<path d="M2 5L4 7L8 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
46
|
+
</svg>
|
|
47
|
+
<svg v-else-if="indeterminate" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
48
|
+
<path d="M2.5 5H7.5" stroke="white" stroke-width="1.5" stroke-linecap="round" />
|
|
49
|
+
</svg>
|
|
50
|
+
</span>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
modelValue: { type: Array, default: () => [] },
|
|
4
|
+
options: { type: Array, required: true }
|
|
5
|
+
})
|
|
6
|
+
|
|
7
|
+
const emit = defineEmits(["update:modelValue"])
|
|
8
|
+
|
|
9
|
+
function toggle(value, current) {
|
|
10
|
+
const next = current.includes(value)
|
|
11
|
+
? current.filter(v => v !== value)
|
|
12
|
+
: [...current, value]
|
|
13
|
+
emit("update:modelValue", next)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isChecked(opt, modelValue) {
|
|
17
|
+
return opt.disabled || modelValue.includes(opt.value)
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="flex flex-col gap-1">
|
|
23
|
+
<button
|
|
24
|
+
v-for="opt in options"
|
|
25
|
+
:key="opt.value"
|
|
26
|
+
type="button"
|
|
27
|
+
class="flex items-start gap-2 px-2 py-1.5 rounded-sm border text-left font-mono"
|
|
28
|
+
:class="[
|
|
29
|
+
opt.disabled
|
|
30
|
+
? 'border-line bg-0 opacity-50 cursor-default'
|
|
31
|
+
: isChecked(opt, modelValue)
|
|
32
|
+
? 'border-accent bg-3 cursor-pointer'
|
|
33
|
+
: 'border-line bg-0 hover:bg-2 cursor-pointer'
|
|
34
|
+
]"
|
|
35
|
+
:disabled="opt.disabled"
|
|
36
|
+
@click="!opt.disabled && toggle(opt.value, modelValue)"
|
|
37
|
+
>
|
|
38
|
+
<span
|
|
39
|
+
class="mt-0.5 w-[14px] h-[14px] rounded-sm border shrink-0 flex items-center justify-center"
|
|
40
|
+
:class="isChecked(opt, modelValue) ? 'border-accent bg-accent' : 'border-line'"
|
|
41
|
+
>
|
|
42
|
+
<svg
|
|
43
|
+
v-if="isChecked(opt, modelValue)"
|
|
44
|
+
class="w-[10px] h-[10px] text-bg-0"
|
|
45
|
+
viewBox="0 0 10 10"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
stroke-width="1.5"
|
|
49
|
+
stroke-linecap="round"
|
|
50
|
+
stroke-linejoin="round"
|
|
51
|
+
>
|
|
52
|
+
<path d="M2 5.5L4 7.5L8 3" />
|
|
53
|
+
</svg>
|
|
54
|
+
</span>
|
|
55
|
+
<div class="flex flex-col gap-px">
|
|
56
|
+
<span class="text-base text-fg-0">{{ opt.label }}</span>
|
|
57
|
+
<span v-if="opt.description" class="text-xs text-fg-2">{{ opt.description }}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue"
|
|
3
|
+
import { EditorView, keymap, placeholder as phPlugin } from "@codemirror/view"
|
|
4
|
+
import { EditorState, Compartment } from "@codemirror/state"
|
|
5
|
+
import { sql, StandardSQL } from "@codemirror/lang-sql"
|
|
6
|
+
import { autocompletion, completionKeymap } from "@codemirror/autocomplete"
|
|
7
|
+
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"
|
|
8
|
+
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language"
|
|
9
|
+
import { tags } from "@lezer/highlight"
|
|
10
|
+
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
modelValue: { type: String, default: "" },
|
|
13
|
+
placeholder: { type: String, default: "" },
|
|
14
|
+
schema: { type: Object, default: () => ({}) },
|
|
15
|
+
language: { type: String, default: "sql" },
|
|
16
|
+
readonly: { type: Boolean, default: false },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits(["update:modelValue"])
|
|
20
|
+
|
|
21
|
+
const container = ref(null)
|
|
22
|
+
let view = null
|
|
23
|
+
const langCompartment = new Compartment()
|
|
24
|
+
const schemaCompartment = new Compartment()
|
|
25
|
+
const readonlyCompartment = new Compartment()
|
|
26
|
+
|
|
27
|
+
const monoTheme = EditorView.theme({
|
|
28
|
+
"&": {
|
|
29
|
+
fontSize: "12px",
|
|
30
|
+
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace",
|
|
31
|
+
backgroundColor: "var(--bg-0)",
|
|
32
|
+
color: "var(--fg-0)",
|
|
33
|
+
},
|
|
34
|
+
"&.cm-focused": {
|
|
35
|
+
outline: "none",
|
|
36
|
+
},
|
|
37
|
+
".cm-content": {
|
|
38
|
+
padding: "4px 0",
|
|
39
|
+
caretColor: "var(--fg-0)",
|
|
40
|
+
},
|
|
41
|
+
".cm-cursor": {
|
|
42
|
+
borderLeftColor: "var(--fg-0)",
|
|
43
|
+
},
|
|
44
|
+
".cm-selectionBackground": {
|
|
45
|
+
backgroundColor: "var(--bg-3) !important",
|
|
46
|
+
},
|
|
47
|
+
"&.cm-focused .cm-selectionBackground": {
|
|
48
|
+
backgroundColor: "var(--bg-3) !important",
|
|
49
|
+
},
|
|
50
|
+
".cm-activeLine": {
|
|
51
|
+
backgroundColor: "transparent",
|
|
52
|
+
},
|
|
53
|
+
".cm-gutters": {
|
|
54
|
+
display: "none",
|
|
55
|
+
},
|
|
56
|
+
".cm-line": {
|
|
57
|
+
padding: "0 8px",
|
|
58
|
+
},
|
|
59
|
+
".cm-placeholder": {
|
|
60
|
+
color: "var(--fg-3)",
|
|
61
|
+
fontStyle: "normal",
|
|
62
|
+
},
|
|
63
|
+
".cm-tooltip": {
|
|
64
|
+
backgroundColor: "var(--bg-1)",
|
|
65
|
+
border: "1px solid var(--border)",
|
|
66
|
+
borderRadius: "3px",
|
|
67
|
+
boxShadow: "0 2px 8px rgba(0,0,0,.15)",
|
|
68
|
+
},
|
|
69
|
+
".cm-tooltip-autocomplete": {
|
|
70
|
+
"& > ul": {
|
|
71
|
+
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', monospace",
|
|
72
|
+
fontSize: "11px",
|
|
73
|
+
},
|
|
74
|
+
"& > ul > li": {
|
|
75
|
+
padding: "2px 8px",
|
|
76
|
+
color: "var(--fg-0)",
|
|
77
|
+
},
|
|
78
|
+
"& > ul > li[aria-selected]": {
|
|
79
|
+
backgroundColor: "var(--bg-3)",
|
|
80
|
+
color: "var(--fg-0)",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
".cm-completionLabel": {
|
|
84
|
+
color: "var(--fg-0)",
|
|
85
|
+
},
|
|
86
|
+
".cm-completionDetail": {
|
|
87
|
+
color: "var(--fg-3)",
|
|
88
|
+
fontStyle: "normal",
|
|
89
|
+
marginLeft: "8px",
|
|
90
|
+
},
|
|
91
|
+
".cm-completionMatchedText": {
|
|
92
|
+
color: "var(--accent)",
|
|
93
|
+
textDecoration: "none",
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// hardcoded because HighlightStyle injects into a static sheet where
|
|
98
|
+
// css vars can be unreliable depending on host context
|
|
99
|
+
const hl = {
|
|
100
|
+
keyword: "#c4813a",
|
|
101
|
+
string: "#7a9e6e",
|
|
102
|
+
number: "#d19a66",
|
|
103
|
+
comment: "#666666",
|
|
104
|
+
fn: "#6eb2d6",
|
|
105
|
+
punctuation: "#888888",
|
|
106
|
+
foreground: "#b0b0b0",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const monoHighlight = HighlightStyle.define([
|
|
110
|
+
{ tag: tags.keyword, color: hl.keyword, fontWeight: "600" },
|
|
111
|
+
{ tag: tags.operator, color: hl.punctuation },
|
|
112
|
+
{ tag: tags.string, color: hl.string },
|
|
113
|
+
{ tag: tags.number, color: hl.number },
|
|
114
|
+
{ tag: tags.bool, color: hl.keyword, fontWeight: "600" },
|
|
115
|
+
{ tag: tags.null, color: hl.punctuation },
|
|
116
|
+
{ tag: tags.comment, color: hl.comment, fontStyle: "italic" },
|
|
117
|
+
{ tag: tags.typeName, color: hl.fn },
|
|
118
|
+
{ tag: tags.function(tags.variableName), color: hl.fn },
|
|
119
|
+
{ tag: tags.definition(tags.variableName), color: "#e5e5e5" },
|
|
120
|
+
{ tag: tags.variableName, color: "#e5e5e5" },
|
|
121
|
+
{ tag: tags.punctuation, color: hl.punctuation },
|
|
122
|
+
{ tag: tags.paren, color: hl.punctuation },
|
|
123
|
+
{ tag: tags.squareBracket, color: hl.punctuation },
|
|
124
|
+
{ tag: tags.brace, color: hl.punctuation },
|
|
125
|
+
{ tag: tags.separator, color: hl.punctuation },
|
|
126
|
+
{ tag: tags.special(tags.string), color: hl.string },
|
|
127
|
+
])
|
|
128
|
+
|
|
129
|
+
const autoHeight = EditorView.theme({
|
|
130
|
+
"&": { height: "auto" },
|
|
131
|
+
".cm-scroller": { overflow: "visible" },
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
function buildSchema() {
|
|
135
|
+
if (!props.schema || Object.keys(props.schema).length === 0) return {}
|
|
136
|
+
const tables = {}
|
|
137
|
+
for (const [table, cols] of Object.entries(props.schema)) {
|
|
138
|
+
tables[table] = cols.map(c => typeof c === "string" ? c : c.name || c)
|
|
139
|
+
}
|
|
140
|
+
return tables
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function schemaCompletions(context) {
|
|
144
|
+
const tables = buildSchema()
|
|
145
|
+
const word = context.matchBefore(/\w+/)
|
|
146
|
+
if (!word || word.from === word.to) return null
|
|
147
|
+
const cols = new Set()
|
|
148
|
+
for (const colList of Object.values(tables)) {
|
|
149
|
+
for (const c of colList) cols.add(c)
|
|
150
|
+
}
|
|
151
|
+
if (cols.size === 0) return null
|
|
152
|
+
return {
|
|
153
|
+
from: word.from,
|
|
154
|
+
options: [...cols].map(c => ({ label: c, type: "property" })),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildLangExtension() {
|
|
159
|
+
const tables = buildSchema()
|
|
160
|
+
return sql({
|
|
161
|
+
dialect: StandardSQL,
|
|
162
|
+
schema: Object.keys(tables).length ? tables : undefined,
|
|
163
|
+
upperCaseKeywords: false,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const updateListener = EditorView.updateListener.of((update) => {
|
|
168
|
+
if (update.docChanged) {
|
|
169
|
+
const value = update.state.doc.toString()
|
|
170
|
+
emit("update:modelValue", value)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
function focusedClass() {
|
|
175
|
+
return EditorView.theme({
|
|
176
|
+
"&.cm-editor": {
|
|
177
|
+
borderColor: "var(--border)",
|
|
178
|
+
borderWidth: "1px",
|
|
179
|
+
borderStyle: "solid",
|
|
180
|
+
borderRadius: "3px",
|
|
181
|
+
transition: "border-color 0.1s",
|
|
182
|
+
},
|
|
183
|
+
"&.cm-editor.cm-focused": {
|
|
184
|
+
borderColor: "var(--accent)",
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
onMounted(() => {
|
|
190
|
+
const extensions = [
|
|
191
|
+
monoTheme,
|
|
192
|
+
focusedClass(),
|
|
193
|
+
syntaxHighlighting(monoHighlight),
|
|
194
|
+
autoHeight,
|
|
195
|
+
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
196
|
+
history(),
|
|
197
|
+
autocompletion({ activateOnTyping: true }),
|
|
198
|
+
keymap.of(completionKeymap),
|
|
199
|
+
schemaCompartment.of(EditorState.languageData.of(() => [{ autocomplete: schemaCompletions }])),
|
|
200
|
+
langCompartment.of(buildLangExtension()),
|
|
201
|
+
readonlyCompartment.of(EditorState.readOnly.of(props.readonly)),
|
|
202
|
+
updateListener,
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if (props.placeholder) {
|
|
206
|
+
extensions.push(phPlugin(props.placeholder))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const state = EditorState.create({
|
|
210
|
+
doc: props.modelValue,
|
|
211
|
+
extensions,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
view = new EditorView({
|
|
215
|
+
state,
|
|
216
|
+
parent: container.value,
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
onBeforeUnmount(() => {
|
|
221
|
+
if (view) {
|
|
222
|
+
view.destroy()
|
|
223
|
+
view = null
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
watch(() => props.modelValue, (val) => {
|
|
228
|
+
if (!view) return
|
|
229
|
+
const current = view.state.doc.toString()
|
|
230
|
+
if (val !== current) {
|
|
231
|
+
view.dispatch({
|
|
232
|
+
changes: { from: 0, to: view.state.doc.length, insert: val },
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
watch(() => props.schema, () => {
|
|
238
|
+
if (!view) return
|
|
239
|
+
view.dispatch({
|
|
240
|
+
effects: [
|
|
241
|
+
langCompartment.reconfigure(buildLangExtension()),
|
|
242
|
+
schemaCompartment.reconfigure(EditorState.languageData.of(() => [{ autocomplete: schemaCompletions }])),
|
|
243
|
+
],
|
|
244
|
+
})
|
|
245
|
+
}, { deep: true })
|
|
246
|
+
|
|
247
|
+
watch(() => props.readonly, (val) => {
|
|
248
|
+
if (!view) return
|
|
249
|
+
view.dispatch({
|
|
250
|
+
effects: readonlyCompartment.reconfigure(EditorState.readOnly.of(val)),
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
</script>
|
|
254
|
+
|
|
255
|
+
<template>
|
|
256
|
+
<div ref="container" class="mono-code-editor" />
|
|
257
|
+
</template>
|
|
258
|
+
|
|
259
|
+
<style scoped>
|
|
260
|
+
.mono-code-editor {
|
|
261
|
+
min-height: 24px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.mono-code-editor :deep(.cm-editor) {
|
|
265
|
+
scrollbar-width: thin;
|
|
266
|
+
scrollbar-color: transparent transparent;
|
|
267
|
+
transition: scrollbar-color 0.15s;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.mono-code-editor :deep(.cm-editor:hover) {
|
|
271
|
+
scrollbar-color: var(--bg-5) transparent;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.mono-code-editor :deep(.cm-editor)::-webkit-scrollbar {
|
|
275
|
+
width: 8px;
|
|
276
|
+
height: 8px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.mono-code-editor :deep(.cm-editor)::-webkit-scrollbar-track {
|
|
280
|
+
background: transparent;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.mono-code-editor :deep(.cm-editor:hover)::-webkit-scrollbar-track {
|
|
284
|
+
background: var(--bg-2);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.mono-code-editor :deep(.cm-editor)::-webkit-scrollbar-thumb {
|
|
288
|
+
background: transparent;
|
|
289
|
+
border-radius: 8px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.mono-code-editor :deep(.cm-editor:hover)::-webkit-scrollbar-thumb {
|
|
293
|
+
background: var(--bg-5);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.mono-code-editor :deep(.cm-editor)::-webkit-scrollbar-thumb:hover {
|
|
297
|
+
background: var(--bg-6);
|
|
298
|
+
}
|
|
299
|
+
</style>
|