quasar-ui-danx 0.5.0 → 0.5.2

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 (81) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/dist/danx.es.js +16119 -10641
  3. package/dist/danx.es.js.map +1 -1
  4. package/dist/danx.umd.js +202 -123
  5. package/dist/danx.umd.js.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +8 -1
  8. package/src/components/Utility/Buttons/ActionButton.vue +15 -5
  9. package/src/components/Utility/Code/CodeViewer.vue +41 -16
  10. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  11. package/src/components/Utility/Code/CodeViewerFooter.vue +3 -1
  12. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  13. package/src/components/Utility/Code/MarkdownContent.vue +31 -163
  14. package/src/components/Utility/Code/index.ts +3 -0
  15. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  16. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  17. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  18. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  19. package/src/components/Utility/Markdown/MarkdownEditor.vue +233 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +296 -0
  21. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  22. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  23. package/src/components/Utility/Markdown/index.ts +11 -0
  24. package/src/components/Utility/Markdown/types.ts +27 -0
  25. package/src/components/Utility/Widgets/LabelPillWidget.vue +20 -0
  26. package/src/components/Utility/index.ts +1 -0
  27. package/src/composables/index.ts +1 -0
  28. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  29. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  30. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  31. package/src/composables/markdown/features/useCodeBlocks.spec.ts +805 -0
  32. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  33. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  34. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  35. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  36. package/src/composables/markdown/features/useHeadings.ts +290 -0
  37. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  38. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  39. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  40. package/src/composables/markdown/features/useLinks.spec.ts +388 -0
  41. package/src/composables/markdown/features/useLinks.ts +374 -0
  42. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  43. package/src/composables/markdown/features/useLists.ts +747 -0
  44. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  45. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  46. package/src/composables/markdown/features/useTables.ts +1107 -0
  47. package/src/composables/markdown/index.ts +16 -0
  48. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  49. package/src/composables/markdown/useMarkdownEditor.ts +1077 -0
  50. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  51. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  52. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  53. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  54. package/src/composables/useCodeFormat.ts +17 -10
  55. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  56. package/src/composables/useCodeViewerEditor.ts +174 -20
  57. package/src/helpers/formats/highlightCSS.ts +236 -0
  58. package/src/helpers/formats/highlightHTML.ts +483 -0
  59. package/src/helpers/formats/highlightJavaScript.ts +346 -0
  60. package/src/helpers/formats/highlightSyntax.ts +15 -4
  61. package/src/helpers/formats/index.ts +3 -0
  62. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  63. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  64. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +425 -0
  65. package/src/helpers/formats/markdown/index.ts +7 -0
  66. package/src/helpers/formats/markdown/linePatterns.spec.ts +498 -0
  67. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  68. package/src/styles/danx.scss +3 -3
  69. package/src/styles/index.scss +5 -5
  70. package/src/styles/themes/danx/code.scss +257 -1
  71. package/src/styles/themes/danx/index.scss +10 -10
  72. package/src/styles/themes/danx/markdown.scss +59 -0
  73. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  74. package/src/test/helpers/editorTestUtils.ts +253 -0
  75. package/src/test/helpers/index.ts +1 -0
  76. package/src/test/highlighters.test.ts +153 -0
  77. package/src/test/setup.test.ts +12 -0
  78. package/src/test/setup.ts +12 -0
  79. package/src/types/widgets.d.ts +2 -2
  80. package/vite.config.js +5 -1
  81. package/vitest.config.ts +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quasar-ui-danx",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "author": "Dan <dan@flytedesk.com>",
5
5
  "description": "DanX Vue / Quasar component library",
6
6
  "license": "MIT",
@@ -15,6 +15,9 @@
15
15
  "build:bundle": "vite build",
16
16
  "build": "yarn clean && yarn build:types && yarn build:bundle",
17
17
  "preview": "vite preview",
18
+ "test": "vitest",
19
+ "test:run": "vitest run",
20
+ "test:coverage": "vitest run --coverage",
18
21
  "publish:patch": "./scripts/publish.sh patch",
19
22
  "publish:minor": "./scripts/publish.sh minor",
20
23
  "publish:major": "./scripts/publish.sh major"
@@ -34,6 +37,8 @@
34
37
  "@typescript-eslint/eslint-plugin": "^7.6.0",
35
38
  "@typescript-eslint/parser": "^7.6.0",
36
39
  "@vitejs/plugin-vue": "^5.0.4",
40
+ "@vitest/coverage-v8": "^4.0.16",
41
+ "@vue/test-utils": "^2.4.6",
37
42
  "autoprefixer": "^10.4.19",
38
43
  "chalk": "^4.1.0",
39
44
  "core-js": "^3.0.0",
@@ -42,6 +47,7 @@
42
47
  "eslint-plugin-import": "^2.29.1",
43
48
  "eslint-plugin-vue": "^9.24.1",
44
49
  "fs-extra": "^8.1.0",
50
+ "jsdom": "^27.4.0",
45
51
  "postcss": "^8.4.38",
46
52
  "quasar": "^2.0.0",
47
53
  "sass": "^1.33.0",
@@ -51,6 +57,7 @@
51
57
  "vite": "^5.2.8",
52
58
  "vite-plugin-dts": "^3.8.1",
53
59
  "vite-svg-loader": "^5.1.0",
60
+ "vitest": "^4.0.16",
54
61
  "vue": "^3.4.21",
55
62
  "vue-eslint-parser": "^9.4.2",
56
63
  "vue-router": "^4.3.2"
@@ -8,14 +8,14 @@
8
8
  >
9
9
  <div class="flex items-center flex-nowrap">
10
10
  <component
11
+ v-if="icon || typeOptions.icon"
11
12
  :is="icon || typeOptions.icon"
12
13
  class="transition-all"
13
14
  :class="resolvedIconClass"
14
15
  />
15
16
  <div
16
17
  v-if="label || label === 0"
17
- class="ml-2"
18
- :class="labelClass"
18
+ :class="[labelClass, { 'ml-2': icon || typeOptions.icon }]"
19
19
  >
20
20
  {{ label }}
21
21
  </div>
@@ -87,7 +87,7 @@ import { ActionTarget, ResourceAction } from "../../../types";
87
87
 
88
88
  export interface ActionButtonProps {
89
89
  type?: "save" | "trash" | "back" | "create" | "edit" | "copy" | "folder" | "document" | "play" | "stop" | "pause" | "refresh" | "restart" | "confirm" | "cancel" | "export" | "import" | "minus" | "merge" | "check" | "clock" | "view" | "database" | "users" | "close";
90
- color?: "red" | "blue" | "blue-invert" | "sky" | "sky-invert" | "green" | "green-invert" | "lime" | "white" | "gray" | "slate" | "slate-invert" | "yellow" | "orange" | "amber" | "purple" | "teal" | "teal-invert";
90
+ color?: "red" | "blue" | "blue-invert" | "sky" | "sky-invert" | "sky-soft" | "green" | "green-invert" | "green-soft" | "lime" | "white" | "gray" | "slate" | "slate-invert" | "slate-soft" | "yellow" | "orange" | "amber" | "purple" | "blue-soft" | "red-soft" | "teal" | "teal-invert";
91
91
  size?: "xxs" | "xs" | "sm" | "md" | "lg";
92
92
  icon?: object | string;
93
93
  iconClass?: string;
@@ -126,7 +126,7 @@ const props = withDefaults(defineProps<ActionButtonProps>(), {
126
126
  const mappedSizeClass = {
127
127
  xxs: {
128
128
  icon: "w-2",
129
- button: "px-.5 h-5"
129
+ button: "px-1 h-4 text-[10px]"
130
130
  },
131
131
  xs: {
132
132
  icon: "w-3",
@@ -157,16 +157,22 @@ const colorClass = computed(() => {
157
157
  switch (props.color) {
158
158
  case "red":
159
159
  return "text-red-900 bg-red-300 hover:bg-red-400";
160
+ case "red-soft":
161
+ return "text-red-700 bg-red-100 hover:bg-red-200";
160
162
  case "lime":
161
163
  return "text-lime-900 bg-lime-300 hover:bg-lime-400";
162
164
  case "green":
163
165
  return "text-green-900 bg-green-300 hover:bg-green-400";
164
166
  case "green-invert":
165
167
  return "text-green-300 bg-green-900 hover:bg-green-800";
168
+ case "green-soft":
169
+ return "text-green-700 bg-green-100 hover:bg-green-200";
166
170
  case "blue":
167
171
  return "text-blue-900 bg-blue-300 hover:bg-blue-400";
168
172
  case "blue-invert":
169
173
  return "text-blue-300 bg-blue-900 hover:bg-blue-800";
174
+ case "blue-soft":
175
+ return "text-blue-700 bg-blue-100 hover:bg-blue-200";
170
176
  case "teal":
171
177
  return "text-teal-800 bg-teal-200 hover:bg-teal-400";
172
178
  case "teal-invert":
@@ -175,6 +181,8 @@ const colorClass = computed(() => {
175
181
  return "text-sky-900 bg-sky-300 hover:bg-sky-400";
176
182
  case "sky-invert":
177
183
  return "text-sky-400 bg-sky-800 hover:bg-sky-900";
184
+ case "sky-soft":
185
+ return "text-sky-700 bg-sky-100 hover:bg-sky-200";
178
186
  case "white":
179
187
  return "text-white bg-gray-800 hover:bg-gray-200";
180
188
  case "yellow":
@@ -189,6 +197,8 @@ const colorClass = computed(() => {
189
197
  return "text-slate-900 bg-slate-300 hover:bg-slate-400";
190
198
  case "slate-invert":
191
199
  return "text-slate-300 bg-slate-900 hover:bg-slate-800";
200
+ case "slate-soft":
201
+ return "text-slate-600 bg-slate-100 hover:bg-slate-200";
192
202
  case "purple":
193
203
  return "text-purple-300 bg-purple-900 hover:bg-purple-800";
194
204
  default:
@@ -251,7 +261,7 @@ const typeOptions = computed(() => {
251
261
  case "close":
252
262
  return { icon: CloseIcon };
253
263
  default:
254
- return { icon: EditIcon };
264
+ return { icon: null };
255
265
  }
256
266
  });
257
267
 
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  class="dx-code-viewer group flex flex-col"
4
- :class="{ 'is-collapsed': isCollapsed }"
4
+ :class="[{ 'is-collapsed': isCollapsed }, props.theme === 'light' ? 'theme-light' : '']"
5
5
  >
6
6
  <FieldLabel
7
7
  v-if="label"
@@ -15,18 +15,25 @@
15
15
  :preview="collapsedPreview"
16
16
  :format="currentFormat"
17
17
  :available-formats="currentFormat === 'json' || currentFormat === 'yaml' ? ['json', 'yaml'] : currentFormat === 'text' || currentFormat === 'markdown' ? ['text', 'markdown'] : []"
18
+ :allow-any-language="allowAnyLanguage"
18
19
  @expand="toggleCollapse"
19
20
  @format-change="onFormatChange"
20
21
  />
21
22
 
22
23
  <!-- Expanded view - full code viewer -->
23
24
  <template v-else>
24
- <div class="code-wrapper relative flex flex-col flex-1 min-h-0">
25
+ <div
26
+ class="code-wrapper relative flex flex-col flex-1 min-h-0"
27
+ tabindex="0"
28
+ @keydown="editor.onKeyDown"
29
+ >
25
30
  <!-- Language badge - shows popout format options on hover -->
26
31
  <LanguageBadge
32
+ ref="languageBadgeRef"
27
33
  :format="currentFormat"
28
34
  :available-formats="currentFormat === 'json' || currentFormat === 'yaml' ? ['json', 'yaml'] : currentFormat === 'text' || currentFormat === 'markdown' ? ['text', 'markdown'] : []"
29
35
  :toggleable="true"
36
+ :allow-any-language="allowAnyLanguage"
30
37
  @click.stop
31
38
  @change="onFormatChange"
32
39
  />
@@ -77,10 +84,12 @@
77
84
 
78
85
  <!-- Footer with char count and edit toggle -->
79
86
  <CodeViewerFooter
87
+ v-if="!hideFooter"
80
88
  :char-count="editor.charCount.value"
81
89
  :validation-error="editor.validationError.value"
82
90
  :can-edit="canEdit && currentFormat !== 'markdown'"
83
91
  :is-editing="editor.isEditing.value"
92
+ :show-version="showVersion"
84
93
  @toggle-edit="editor.toggleEdit"
85
94
  />
86
95
  </div>
@@ -110,6 +119,10 @@ export interface CodeViewerProps {
110
119
  collapsible?: boolean;
111
120
  defaultCollapsed?: boolean;
112
121
  defaultCodeFormat?: "json" | "yaml";
122
+ allowAnyLanguage?: boolean;
123
+ theme?: "dark" | "light";
124
+ showVersion?: boolean;
125
+ hideFooter?: boolean;
113
126
  }
114
127
 
115
128
  const props = withDefaults(defineProps<CodeViewerProps>(), {
@@ -120,13 +133,18 @@ const props = withDefaults(defineProps<CodeViewerProps>(), {
120
133
  canEdit: false,
121
134
  editable: false,
122
135
  collapsible: false,
123
- defaultCollapsed: true
136
+ defaultCollapsed: true,
137
+ theme: "dark",
138
+ showVersion: false,
139
+ hideFooter: false
124
140
  });
125
141
 
126
142
  const emit = defineEmits<{
127
143
  "update:modelValue": [value: object | string | null];
128
144
  "update:format": [format: CodeFormat];
129
145
  "update:editable": [editable: boolean];
146
+ "exit": [];
147
+ "delete": [];
130
148
  }>();
131
149
 
132
150
  // Initialize composable with current props
@@ -138,6 +156,7 @@ const codeFormat = useCodeFormat({
138
156
  // Local state
139
157
  const currentFormat = ref<CodeFormat>(props.format);
140
158
  const codeRef = ref<HTMLPreElement | null>(null);
159
+ const languageBadgeRef = ref<InstanceType<typeof LanguageBadge> | null>(null);
141
160
 
142
161
  // Collapsed state (for collapsible mode)
143
162
  const isCollapsed = ref(props.collapsible && props.defaultCollapsed);
@@ -154,9 +173,26 @@ function toggleCollapse() {
154
173
  isCollapsed.value = !isCollapsed.value;
155
174
  }
156
175
 
157
- // Sync composable format with current format
176
+ // Initialize editor composable
177
+ const editor = useCodeViewerEditor({
178
+ codeRef,
179
+ codeFormat,
180
+ currentFormat,
181
+ canEdit: toRef(props, "canEdit"),
182
+ editable: toRef(props, "editable"),
183
+ onEmitModelValue: (value) => emit("update:modelValue", value),
184
+ onEmitEditable: (editable) => emit("update:editable", editable),
185
+ onEmitFormat: (format) => onFormatChange(format),
186
+ onExit: () => emit("exit"),
187
+ onDelete: () => emit("delete"),
188
+ onOpenLanguageSearch: () => languageBadgeRef.value?.openSearchPanel()
189
+ });
190
+
191
+ // Sync composable format with current format and update editor content
158
192
  watch(currentFormat, (newFormat) => {
159
193
  codeFormat.setFormat(newFormat);
194
+ // Update editor content when format changes (needed for syntax re-highlighting)
195
+ editor.updateEditingContentOnFormatChange();
160
196
  });
161
197
 
162
198
  // Watch for external format changes
@@ -170,17 +206,6 @@ watch(() => props.modelValue, () => {
170
206
  editor.syncEditingContentFromValue();
171
207
  });
172
208
 
173
- // Initialize editor composable
174
- const editor = useCodeViewerEditor({
175
- codeRef,
176
- codeFormat,
177
- currentFormat,
178
- canEdit: toRef(props, "canEdit"),
179
- editable: toRef(props, "editable"),
180
- onEmitModelValue: (value) => emit("update:modelValue", value),
181
- onEmitEditable: (editable) => emit("update:editable", editable)
182
- });
183
-
184
209
  // Sync internal editable state with prop
185
210
  watch(() => props.editable, (newValue) => {
186
211
  editor.syncEditableFromProp(newValue);
@@ -198,7 +223,7 @@ const { collapsedPreview } = useCodeViewerCollapse({
198
223
  function onFormatChange(newFormat: CodeFormat) {
199
224
  currentFormat.value = newFormat;
200
225
  emit("update:format", newFormat);
201
- editor.updateEditingContentOnFormatChange();
226
+ // Note: editor.updateEditingContentOnFormatChange() is called by the currentFormat watcher
202
227
  }
203
228
 
204
229
  // Get the raw markdown content for MarkdownContent component
@@ -11,6 +11,7 @@
11
11
  :format="format"
12
12
  :available-formats="availableFormats"
13
13
  :toggleable="availableFormats.length > 1"
14
+ :allow-any-language="allowAnyLanguage"
14
15
  @click.stop
15
16
  @change="(fmt) => $emit('format-change', fmt)"
16
17
  />
@@ -25,6 +26,7 @@ defineProps<{
25
26
  preview: string;
26
27
  format: string;
27
28
  availableFormats?: string[];
29
+ allowAnyLanguage?: boolean;
28
30
  }>();
29
31
 
30
32
  defineEmits<{
@@ -12,6 +12,7 @@
12
12
  </template>
13
13
  <template v-else>
14
14
  {{ charCount.toLocaleString() }} chars
15
+ <span v-if="showVersion" class="ml-2 text-sky-400">[v1.0.5]</span>
15
16
  </template>
16
17
  </div>
17
18
  <!-- Edit toggle button -->
@@ -26,13 +27,13 @@
26
27
  @click="$emit('toggle-edit')"
27
28
  >
28
29
  <EditIcon class="w-3.5 h-3.5" />
29
- <QTooltip>{{ isEditing ? 'Exit edit mode' : 'Edit content' }}</QTooltip>
30
30
  </QBtn>
31
31
  </div>
32
32
  </template>
33
33
 
34
34
  <script setup lang="ts">
35
35
  import { FaSolidPencil as EditIcon } from "danx-icon";
36
+ import { QBtn } from "quasar";
36
37
  import { computed } from "vue";
37
38
  import { ValidationError } from "../../../composables/useCodeFormat";
38
39
 
@@ -41,6 +42,7 @@ export interface CodeViewerFooterProps {
41
42
  validationError: ValidationError | null;
42
43
  canEdit: boolean;
43
44
  isEditing: boolean;
45
+ showVersion?: boolean;
44
46
  }
45
47
 
46
48
  const props = defineProps<CodeViewerFooterProps>();
@@ -3,8 +3,21 @@
3
3
  class="dx-language-badge-container"
4
4
  :class="{ 'is-toggleable': toggleable && availableFormats.length > 1 }"
5
5
  @mouseenter="showOptions = true"
6
- @mouseleave="showOptions = false"
6
+ @mouseleave="onMouseLeave"
7
7
  >
8
+ <!-- Search icon (when allowAnyLanguage is true) - placed first/leftmost -->
9
+ <transition name="slide-left">
10
+ <div
11
+ v-if="showOptions && allowAnyLanguage"
12
+ class="dx-language-option dx-language-search-trigger"
13
+ @click.stop="openSearchPanel"
14
+ >
15
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
16
+ <path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
17
+ </svg>
18
+ </div>
19
+ </transition>
20
+
8
21
  <!-- Other format options (slide out to the left) -->
9
22
  <transition name="slide-left">
10
23
  <div
@@ -23,36 +36,197 @@
23
36
  </transition>
24
37
 
25
38
  <!-- Current format badge (stays in place) -->
26
- <div class="dx-language-badge" :class="{ 'is-active': showOptions && otherFormats.length > 0 }">
39
+ <div class="dx-language-badge" :class="{ 'is-active': showOptions && (otherFormats.length > 0 || allowAnyLanguage) }">
27
40
  {{ format.toUpperCase() }}
28
41
  </div>
42
+
43
+ <!-- Search dropdown panel -->
44
+ <transition name="fade">
45
+ <div
46
+ v-if="showSearchPanel"
47
+ class="dx-language-search-panel"
48
+ @click.stop
49
+ >
50
+ <input
51
+ ref="searchInputRef"
52
+ v-model="searchQuery"
53
+ type="text"
54
+ class="dx-language-search-input"
55
+ placeholder="Search languages..."
56
+ @input="onSearchQueryChange"
57
+ @keydown.down.prevent="navigateDown"
58
+ @keydown.up.prevent="navigateUp"
59
+ @keydown.enter.prevent="selectCurrentItem"
60
+ @keydown.escape="closeSearchPanel"
61
+ />
62
+ <div class="dx-language-search-list">
63
+ <div
64
+ v-for="(lang, index) in filteredLanguages"
65
+ :key="lang"
66
+ class="dx-language-search-item"
67
+ :class="{ 'is-selected': index === selectedIndex }"
68
+ @click="selectLanguage(lang)"
69
+ @mouseenter="selectedIndex = index"
70
+ >
71
+ {{ lang.toUpperCase() }}
72
+ </div>
73
+ <div v-if="filteredLanguages.length === 0" class="dx-language-search-empty">
74
+ No languages found
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </transition>
29
79
  </div>
30
80
  </template>
31
81
 
32
82
  <script setup lang="ts">
33
- import { computed, ref } from "vue";
83
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
84
+
85
+ // All supported languages (sorted alphabetically)
86
+ const ALL_LANGUAGES = [
87
+ "bash",
88
+ "c",
89
+ "cpp",
90
+ "css",
91
+ "dockerfile",
92
+ "go",
93
+ "graphql",
94
+ "html",
95
+ "java",
96
+ "javascript",
97
+ "json",
98
+ "kotlin",
99
+ "markdown",
100
+ "php",
101
+ "python",
102
+ "ruby",
103
+ "rust",
104
+ "scss",
105
+ "sql",
106
+ "swift",
107
+ "text",
108
+ "typescript",
109
+ "xml",
110
+ "yaml"
111
+ ];
34
112
 
35
113
  export interface LanguageBadgeProps {
36
114
  format: string;
37
115
  availableFormats?: string[];
38
116
  toggleable?: boolean;
117
+ allowAnyLanguage?: boolean;
39
118
  }
40
119
 
41
120
  const props = withDefaults(defineProps<LanguageBadgeProps>(), {
42
121
  availableFormats: () => [],
43
- toggleable: true
122
+ toggleable: true,
123
+ allowAnyLanguage: false
44
124
  });
45
125
 
46
- defineEmits<{
126
+ const emit = defineEmits<{
47
127
  change: [format: string];
48
128
  }>();
49
129
 
50
130
  const showOptions = ref(false);
131
+ const showSearchPanel = ref(false);
132
+ const searchQuery = ref("");
133
+ const searchInputRef = ref<HTMLInputElement | null>(null);
134
+ const selectedIndex = ref(0);
51
135
 
52
136
  // Get formats other than the current one
53
137
  const otherFormats = computed(() => {
54
138
  return props.availableFormats.filter(f => f !== props.format);
55
139
  });
140
+
141
+ // Filter languages based on search query
142
+ const filteredLanguages = computed(() => {
143
+ if (!searchQuery.value) {
144
+ return ALL_LANGUAGES;
145
+ }
146
+ const query = searchQuery.value.toLowerCase();
147
+ return ALL_LANGUAGES.filter(lang => lang.toLowerCase().includes(query));
148
+ });
149
+
150
+ // Reset selectedIndex when search query changes
151
+ function onSearchQueryChange() {
152
+ selectedIndex.value = 0;
153
+ }
154
+
155
+ // Keyboard navigation functions
156
+ function navigateDown() {
157
+ if (filteredLanguages.value.length === 0) return;
158
+ selectedIndex.value = (selectedIndex.value + 1) % filteredLanguages.value.length;
159
+ scrollSelectedIntoView();
160
+ }
161
+
162
+ function navigateUp() {
163
+ if (filteredLanguages.value.length === 0) return;
164
+ selectedIndex.value = selectedIndex.value === 0
165
+ ? filteredLanguages.value.length - 1
166
+ : selectedIndex.value - 1;
167
+ scrollSelectedIntoView();
168
+ }
169
+
170
+ function selectCurrentItem() {
171
+ if (filteredLanguages.value.length > 0) {
172
+ selectLanguage(filteredLanguages.value[selectedIndex.value]);
173
+ }
174
+ }
175
+
176
+ function scrollSelectedIntoView() {
177
+ nextTick(() => {
178
+ const list = document.querySelector(".dx-language-search-list");
179
+ const selected = list?.querySelector(".is-selected");
180
+ selected?.scrollIntoView({ block: "nearest" });
181
+ });
182
+ }
183
+
184
+ function openSearchPanel() {
185
+ showSearchPanel.value = true;
186
+ searchQuery.value = "";
187
+ selectedIndex.value = 0;
188
+ nextTick(() => {
189
+ searchInputRef.value?.focus();
190
+ });
191
+ }
192
+
193
+ function closeSearchPanel() {
194
+ showSearchPanel.value = false;
195
+ searchQuery.value = "";
196
+ }
197
+
198
+ function selectLanguage(lang: string) {
199
+ emit("change", lang);
200
+ closeSearchPanel();
201
+ }
202
+
203
+ function onMouseLeave() {
204
+ showOptions.value = false;
205
+ // Don't close search panel on mouse leave - let click outside handle it
206
+ }
207
+
208
+ function handleClickOutside(event: MouseEvent) {
209
+ if (showSearchPanel.value) {
210
+ const target = event.target as HTMLElement;
211
+ const panel = document.querySelector(".dx-language-search-panel");
212
+ if (panel && !panel.contains(target)) {
213
+ closeSearchPanel();
214
+ }
215
+ }
216
+ }
217
+
218
+ onMounted(() => {
219
+ document.addEventListener("mousedown", handleClickOutside);
220
+ });
221
+
222
+ onBeforeUnmount(() => {
223
+ document.removeEventListener("mousedown", handleClickOutside);
224
+ });
225
+
226
+ // Expose openSearchPanel for external callers (e.g., keyboard shortcut from CodeViewer)
227
+ defineExpose({
228
+ openSearchPanel
229
+ });
56
230
  </script>
57
231
 
58
232
  <style lang="scss">
@@ -94,6 +268,19 @@ const otherFormats = computed(() => {
94
268
  }
95
269
  }
96
270
 
271
+ // Search trigger inherits from .dx-language-option, only adds flex centering for the icon
272
+ .dx-language-search-trigger {
273
+ display: flex;
274
+ align-items: center;
275
+ justify-content: center;
276
+ padding: 2px 6px;
277
+
278
+ svg {
279
+ width: 12px;
280
+ height: 12px;
281
+ }
282
+ }
283
+
97
284
  .dx-language-badge {
98
285
  padding: 2px 8px;
99
286
  font-size: 0.7em;
@@ -108,6 +295,81 @@ const otherFormats = computed(() => {
108
295
  }
109
296
  }
110
297
 
298
+ .dx-language-search-panel {
299
+ position: absolute;
300
+ top: 100%;
301
+ right: 0;
302
+ margin-top: 4px;
303
+ background: rgba(0, 0, 0, 0.9);
304
+ border-radius: 6px;
305
+ border: 1px solid rgba(255, 255, 255, 0.1);
306
+ min-width: 160px;
307
+ overflow: hidden;
308
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
309
+ }
310
+
311
+ .dx-language-search-input {
312
+ width: 100%;
313
+ padding: 8px 12px;
314
+ font-size: 0.8em;
315
+ background: rgba(255, 255, 255, 0.05);
316
+ border: none;
317
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
318
+ color: rgba(255, 255, 255, 0.9);
319
+ outline: none;
320
+
321
+ &::placeholder {
322
+ color: rgba(255, 255, 255, 0.4);
323
+ }
324
+
325
+ &:focus {
326
+ background: rgba(255, 255, 255, 0.08);
327
+ }
328
+ }
329
+
330
+ .dx-language-search-list {
331
+ max-height: 200px;
332
+ overflow-y: auto;
333
+
334
+ &::-webkit-scrollbar {
335
+ width: 6px;
336
+ }
337
+
338
+ &::-webkit-scrollbar-track {
339
+ background: rgba(0, 0, 0, 0.2);
340
+ }
341
+
342
+ &::-webkit-scrollbar-thumb {
343
+ background: rgba(255, 255, 255, 0.2);
344
+ border-radius: 3px;
345
+
346
+ &:hover {
347
+ background: rgba(255, 255, 255, 0.3);
348
+ }
349
+ }
350
+ }
351
+
352
+ .dx-language-search-item {
353
+ padding: 6px 12px;
354
+ font-size: 0.75em;
355
+ color: rgba(255, 255, 255, 0.7);
356
+ cursor: pointer;
357
+ transition: all 0.15s;
358
+
359
+ &:hover,
360
+ &.is-selected {
361
+ background: rgba(255, 255, 255, 0.15);
362
+ color: rgba(255, 255, 255, 0.95);
363
+ }
364
+ }
365
+
366
+ .dx-language-search-empty {
367
+ padding: 12px;
368
+ font-size: 0.75em;
369
+ color: rgba(255, 255, 255, 0.4);
370
+ text-align: center;
371
+ }
372
+
111
373
  // Slide animation for options
112
374
  .slide-left-enter-active,
113
375
  .slide-left-leave-active {
@@ -119,4 +381,15 @@ const otherFormats = computed(() => {
119
381
  opacity: 0;
120
382
  transform: translateX(10px);
121
383
  }
384
+
385
+ // Fade animation for search panel
386
+ .fade-enter-active,
387
+ .fade-leave-active {
388
+ transition: opacity 0.15s ease;
389
+ }
390
+
391
+ .fade-enter-from,
392
+ .fade-leave-to {
393
+ opacity: 0;
394
+ }
122
395
  </style>