From 5fcfe5c903302c89319fc7d3a5692f7f22876c9a Mon Sep 17 00:00:00 2001 From: Sean Luis Date: Fri, 21 Feb 2025 12:27:59 -0300 Subject: [PATCH 01/11] feat: enhance search functionality and theme toggle component with improved conditions and transitions --- components/SearchInput.vue | 464 +++++++++++++++++++++++++++++++++++++ components/ThemeToggle.vue | 77 ++++-- pages/index.vue | 30 +-- services/github.ts | 35 ++- 4 files changed, 560 insertions(+), 46 deletions(-) create mode 100644 components/SearchInput.vue diff --git a/components/SearchInput.vue b/components/SearchInput.vue new file mode 100644 index 0000000..0c7a727 --- /dev/null +++ b/components/SearchInput.vue @@ -0,0 +1,464 @@ + + + + + diff --git a/components/ThemeToggle.vue b/components/ThemeToggle.vue index c6103a7..8f0e554 100644 --- a/components/ThemeToggle.vue +++ b/components/ThemeToggle.vue @@ -3,36 +3,85 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Button } from '#components' const colorMode = useColorMode() + +// Función para cambiar el tema de manera inmediata +const changeTheme = (theme: 'light' | 'dark' | 'system') => { + colorMode.preference = theme + // Si es system, usar la preferencia del sistema + if (theme === 'system') { + colorMode.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } else { + colorMode.value = theme + } + + nextTick(() => { + document.documentElement.setAttribute('data-theme', colorMode.value) + }) +} + + diff --git a/pages/index.vue b/pages/index.vue index 85d1272..4d1b8d1 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,6 +1,5 @@ @@ -409,16 +730,33 @@ watch(() => props.modelValue, (newValue) => { } .suggestion-item { - padding: 0.375rem 0.5rem; + padding: 0.5rem; display: flex; align-items: center; - gap: 0.5rem; + gap: 0.625rem; cursor: pointer; - font-size: 0.875rem; + border-radius: 0.375rem; + transition: background-color 0.2s ease; + font-size: 0.75rem; + line-height: 1rem; } .suggestion-item:hover { - background-color: hsl(var(--muted)); + background-color: hsl(var(--accent)); +} + +.suggestion-item.is-used { + background-color: hsl(var(--muted)/0.1); +} + +.suggestion-item.is-used:hover { + background-color: hsl(var(--muted)/0.2); +} + +.suggestion-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* ...rest of existing styles... */ @@ -461,4 +799,462 @@ watch(() => props.modelValue, (newValue) => { background-color: #1a384d; color: #2f81f7; } + +.search-expanded { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3.5rem; /* Match header height */ + z-index: 50; + display: flex; + align-items: center; + background-color: hsl(var(--background)); + border-bottom: 1px solid hsl(var(--border)); + backdrop-filter: blur(8px); +} + +.search-input-expanded { + max-width: 60rem; + margin: 0 auto; + width: 100%; + position: relative; +} + +.suggestions-dropdown { + position: fixed; + top: 3.5rem; /* Position right below the header */ + left: 50%; + transform: translateX(-50%); + width: 60rem !important; + max-width: calc(100vw - 2rem); + margin-top: 0; + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); +} + +/* Ensure proper stacking */ +:root { + --search-z-index: 100; /* Increased to appear above header */ +} + +.search-expanded { + z-index: var(--search-z-index); +} + +.suggestions-dropdown { + z-index: calc(var(--search-z-index) + 1); +} + +/* Add backdrop blur for better visibility */ +.search-expanded { + background-color: hsl(var(--background)/95%); + backdrop-filter: blur(8px); +} + +/* Add smooth transitions */ +.search-expanded, +.search-input-expanded, +.suggestions-dropdown { + transition: all 0.2s ease-out; +} + +.search-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 100; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 4rem; +} + +.search-dialog { + width: 100%; + max-width: 80rem; + margin: 0 1rem; +} + +.search-dialog-content { + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + padding: 1.5rem; + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); +} + +/* Transition animations */ +.search-overlay-enter-active, +.search-overlay-leave-active { + transition: opacity 0.2s ease; +} + +.search-overlay-enter-from, +.search-overlay-leave-to { + opacity: 0; +} + +.search-overlay-enter-active .search-dialog, +.search-overlay-leave-active .search-dialog { + transition: transform 0.15s ease-out; +} + +.search-overlay-enter-from .search-dialog, +.search-overlay-leave-to .search-dialog { + transform: translateY(-1rem); +} + +/* Asegurar que el diálogo permanezca visible */ +.search-dialog { + transform: translateY(0); +} + +.search-active { + @apply invisible; +} + +.suggestions-dropdown-inline { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + z-index: 50; + padding: 0.5rem 0; + max-height: 16rem; + overflow-y: auto; +} + +/* Asegurar que el input mantenga el texto visible */ +.text-transparent { + -webkit-text-fill-color: transparent; + color: transparent; + caret-color: currentColor; /* Mantener el cursor visible */ +} + +.search-input-mirror { + overflow-x: auto; + white-space: pre; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +.search-input-mirror::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ +} + +.search-input { + white-space: pre; + overflow-x: auto; +} + +/* Ajustar el estilo del texto transparente para mejor visibilidad */ +.text-transparent { + -webkit-text-fill-color: transparent; + color: transparent; +} + +/* Asegurar que los badges mantengan su espacio */ +.search-badge { + flex-shrink: 0; + display: inline-flex; + white-space: pre; +} + +/* Asegurar que el contenedor mantenga el scroll sincronizado */ +.relative.flex-1 { + overflow: hidden; +} + +.search-wrapper { + position: relative; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.search-wrapper::-webkit-scrollbar { + display: none; +} + +.search-mirror { + position: absolute; + top: 0; + left: 0; + padding: inherit; + pointer-events: none; + visibility: visible; + white-space: pre; + display: flex; + align-items: center; +} + +.search-input { + width: 100%; + background: transparent; + outline: none; + color: transparent; + caret-color: currentColor; + position: relative; + white-space: pre; + padding: inherit; +} + +.search-input.has-content { + -webkit-text-fill-color: transparent; +} + +.search-input::placeholder { + -webkit-text-fill-color: initial; + color: initial; +} + +.search-badge { + display: inline-flex; + align-items: center; + background-color: #ddf4ff; + color: #0969da; + border-radius: .2rem; + padding: 0 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; + margin: 0 1px; + white-space: pre; +} + +:root[class~="dark"] .search-badge { + background-color: #1a384d; + color: #2f81f7; +} + +.search-container { + position: relative; + min-width: 0; + width: 100%; +} + +.search-content-wrapper { + position: relative; + width: 100%; + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; +} + +.search-content-wrapper::-webkit-scrollbar { + display: none; +} + +.search-mirror { + position: absolute; + top: 0; + left: 0; + right: 0; + pointer-events: none; + white-space: pre; + padding: 2px 0; + min-width: min-content; + display: flex; + align-items: center; +} + +.search-input { + width: 90%; + background: transparent; + outline: none; + position: relative; + white-space: pre; + padding: 2px 0; + min-width: 90%; + color: transparent; + caret-color: currentColor; +} + +.search-input::placeholder { + color: hsl(var(--muted-foreground)); + -webkit-text-fill-color: initial; +} + +.search-badge { + display: inline-flex; + align-items: center; + background-color: #ddf4ff; + color: #0969da; + border-radius: .2rem; + padding: 0 0.5rem; + font-size: 0.875rem; + line-height: 1.25rem; + font-weight: 500; +} + +.search-input-container { + position: relative; + width: 100%; + overflow: hidden; +} + +.search-input-wrapper { + position: relative; + width: 100%; +} + +.search-highlight-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + white-space: pre; + overflow: hidden; + display: flex; + align-items: center; + gap: 0.25rem; /* Nuevo: separación consistente entre elementos */ +} + +.search-input-field { + width: 100%; + background: transparent; + outline: none; + color: transparent; + caret-color: currentColor; + white-space: pre; +} + +.search-input-field::placeholder { + color: hsl(var(--muted-foreground)); + -webkit-text-fill-color: initial; +} + +.search-badge { + display: inline-flex; + align-items: center; + padding: 0 0.25rem; + border-radius: 0.2rem; + background: #ddf4ff; + color: #0969da; + font-size: 0.875rem; + font-weight: 500; + white-space: pre; + /* Se puede eliminar o reducir "margin" ya que el gap se encarga */ +} + +:root[class~="dark"] .search-badge { + background: #1a384d; + color: #2f81f7; +} + +.extra-space { + /* Forzar al menos 2 caracteres de espacio extra */ + min-width: 2ch; + flex-shrink: 0; +} + +/* Remove duplicate styles and keep only these essential ones */ +.search-scroll-container { + display: flex; + align-items: center; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + margin-right: -8px; /* Compensar el padding del contenedor */ + padding-right: 8px; +} + +.search-scroll-container::-webkit-scrollbar { + display: none; +} + +/* Ajustar el input */ +.search-input-field { + background: transparent; + outline: none; + color: transparent; + caret-color: currentColor; + min-width: 100%; + position: relative; + white-space: pre; + padding: 2px 0; +} + +.search-input-field.has-content { + -webkit-text-fill-color: transparent; +} + +.search-input-field::placeholder { + -webkit-text-fill-color: initial; + color: hsl(var(--muted-foreground)); +} + +/* Ajustar la capa de resaltado */ +.search-highlight { + display: flex; + align-items: center; + white-space: pre; + padding: 2px 0; +} + +/* Asegurar que los badges mantengan su forma */ +.search-badge { + display: inline-flex; + align-items: center; + white-space: pre; + flex-shrink: 0; +} + +/* Ajustar el cursor */ +.typing-cursor { + width: 2px; + height: 1.2em; + background-color: currentColor; + pointer-events: none; +} + +.suggestions-group + .suggestions-group { + margin-top: 0.5rem; +} + +.search-badge-mini { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + padding: 0 0.375rem; + border-radius: 0.25rem; + font-size: 0.75rem; + line-height: 1.25rem; +} + +.suggestion-item.is-used { + background-color: hsl(var(--muted)/0.1); +} + +.suggestion-item.is-used:hover { + background-color: hsl(var(--destructive)/0.1); +} + +.suggestions-container { + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + max-height: 24rem; + overflow-y: auto; +} + +.suggestion-item.is-used:hover { + background-color: hsl(var(--destructive)/0.1); + color: hsl(var(--destructive)); +} + +.suggestion-item.is-used:hover .search-badge-mini { + background-color: hsl(var(--destructive)/0.2); + color: hsl(var(--destructive)); +} diff --git a/components/search/SearchDialog.vue b/components/search/SearchDialog.vue new file mode 100644 index 0000000..45cca6d --- /dev/null +++ b/components/search/SearchDialog.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/components/search/SearchHighlight.vue b/components/search/SearchHighlight.vue new file mode 100644 index 0000000..1d206e7 --- /dev/null +++ b/components/search/SearchHighlight.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/components/search/SearchInput.vue b/components/search/SearchInput.vue new file mode 100644 index 0000000..c15c8ea --- /dev/null +++ b/components/search/SearchInput.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/components/search/SearchSuggestions.vue b/components/search/SearchSuggestions.vue new file mode 100644 index 0000000..52f4e09 --- /dev/null +++ b/components/search/SearchSuggestions.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/components/search/composables/useSearchInput.ts b/components/search/composables/useSearchInput.ts new file mode 100644 index 0000000..1882a4a --- /dev/null +++ b/components/search/composables/useSearchInput.ts @@ -0,0 +1,171 @@ +import { ref, computed } from 'vue' +import type { SearchToken } from '../types' +import { QUALIFIERS } from '../constants' + +export function useSearchInput() { + const searchInput = ref('') + const input = ref(null) + const cursorPosition = ref(0) + const isInputFocused = ref(false) + const containerWidth = ref(0) + const searchContainer = ref(null) + + const colorText = (text: string) => { + const parts: { text: string; type: 'qualifier' | 'value' | 'normal' }[] = [] + let currentPosition = 0 + + while (currentPosition < text.length) { + let matched = false + + // Buscar qualifiers + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + parts.push({ + text: qualifier, + type: 'qualifier' + }) + + currentPosition += qualifier.length + + let valueStart = currentPosition + while (currentPosition < text.length && text[currentPosition] !== ' ') { + currentPosition++ + } + + if (currentPosition > valueStart) { + parts.push({ + text: text.slice(valueStart, currentPosition), + type: 'value' + }) + } + + matched = true + break + } + } + + if (!matched) { + let normalText = '' + while (currentPosition < text.length) { + let isQualifier = false + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + isQualifier = true + break + } + } + if (isQualifier) break + normalText += text[currentPosition] + currentPosition++ + } + if (normalText) { + parts.push({ + text: normalText, + type: 'normal' + }) + } + } + } + + return parts + } + + const coloredParts = computed(() => colorText(searchInput.value)) + + const parseSearchTokens = (value: string): SearchToken[] => { + const tokens: SearchToken[] = [] + let i = 0 + + while (i < value.length) { + let matchedQualifier = false + + for (const qualifier of Object.keys(QUALIFIERS)) { + if (value.slice(i).toLowerCase().startsWith(qualifier.toLowerCase())) { + let j = i + qualifier.length + let qualifierValue = '' + + while (j < value.length && value[j] !== ' ') { + qualifierValue += value[j] + j++ + } + + if (qualifierValue) { + tokens.push({ + type: 'qualifier', + qualifier: qualifier.slice(0, -1), + value: qualifierValue + }) + } + + i = j + matchedQualifier = true + break + } + } + + if (!matchedQualifier) { + if (value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + i++ + } else { + let textValue = '' + let start = i + + while (i < value.length) { + let isQualifier = false + for (const qualifier of Object.keys(QUALIFIERS)) { + if (value.slice(i).toLowerCase().startsWith(qualifier.toLowerCase())) { + isQualifier = true + break + } + } + if (isQualifier || value[i] === ' ') break + textValue += value[i] + i++ + } + + if (textValue) { + tokens.push({ type: 'text', value: textValue }) + } + } + } + } + + return tokens + } + + const updateCursorPosition = () => { + if (input.value) { + const inputElement = input.value + cursorPosition.value = inputElement.selectionStart || 0 + + // Usar el mismo contenedor que el input para medir + const container = document.createElement('div') + container.style.position = 'absolute' + container.style.top = '0' + container.style.left = '0' + container.style.visibility = 'hidden' + container.style.whiteSpace = 'pre' + container.style.font = window.getComputedStyle(inputElement).font + + // Medir solo hasta la posición del cursor + container.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) + + document.body.appendChild(container) + cursorPosition.value = container.offsetWidth + document.body.removeChild(container) + } + } + + return { + searchInput, + input, + cursorPosition, + isInputFocused, + searchContainer, + containerWidth, + coloredParts, + parseSearchTokens, + updateCursorPosition + } +} diff --git a/components/search/composables/useSearchShortcuts.ts b/components/search/composables/useSearchShortcuts.ts new file mode 100644 index 0000000..43d3f0a --- /dev/null +++ b/components/search/composables/useSearchShortcuts.ts @@ -0,0 +1,28 @@ +import { onMounted, onUnmounted } from 'vue' + +export function useSearchShortcuts( + handleFocus: () => void, + input: () => HTMLInputElement | null +) { + const handleGlobalShortcut = (event: KeyboardEvent) => { + if ( + event.key === '/' && + !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName) + ) { + event.preventDefault() + handleFocus() + } + } + + onMounted(() => { + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleGlobalShortcut) + }) + + return { + handleGlobalShortcut + } +} diff --git a/components/search/composables/useSearchSuggestions.ts b/components/search/composables/useSearchSuggestions.ts new file mode 100644 index 0000000..9eb71c9 --- /dev/null +++ b/components/search/composables/useSearchSuggestions.ts @@ -0,0 +1,43 @@ +import { ref, computed } from 'vue' +import type { SearchToken, QualifierSuggestion } from '../types' +import { QUALIFIERS } from '../constants' + +export function useSearchSuggestions( + searchTokens: () => SearchToken[], + getCurrentInput: () => string +) { + const showSuggestions = ref(false) + + const suggestions = computed(() => { + const usedQualifiers = searchTokens() + .filter(token => token.type === 'qualifier') + .map(token => ({ + qualifier: token.qualifier + ':', + value: token.value + })) + + return Object.keys(QUALIFIERS).map(qualifier => ({ + qualifier, + isUsed: usedQualifiers.some(used => used.qualifier === qualifier), + value: usedQualifiers.find(used => used.qualifier === qualifier)?.value + })) + }) + + const handleSuggestionSelect = (qualifier: string) => { + return qualifier.endsWith(':') ? qualifier : `${qualifier}:` + } + + const handleSuggestionRemove = (qualifier: string, value: string) => { + const currentInput = getCurrentInput() + const fullQualifier = qualifier.endsWith(':') ? qualifier : `${qualifier}:` + const pattern = new RegExp(`${fullQualifier}\\s*${value}\\s*`, 'i') + return currentInput.replace(pattern, '').trim() + } + + return { + showSuggestions, + suggestions, + handleSuggestionSelect, + handleSuggestionRemove + } +} diff --git a/components/search/constants.ts b/components/search/constants.ts new file mode 100644 index 0000000..5ce7de2 --- /dev/null +++ b/components/search/constants.ts @@ -0,0 +1,14 @@ +export const QUALIFIERS = { + 'repo:': { label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, + 'user:': { label: 'User', icon: 'octicon:person-16' }, + 'org:': { label: 'Organization', icon: 'octicon:organization-16' }, + 'in:': { label: 'Search in', icon: 'octicon:search-16' }, + 'size:': { label: 'Size', icon: 'octicon:file-16' }, + 'stars:': { label: 'Stars', icon: 'octicon:star-16' }, + 'language:': { label: 'Language', icon: 'octicon:code-16' }, + 'created:': { label: 'Created date', icon: 'octicon:calendar-16' }, + 'pushed:': { label: 'Push date', icon: 'octicon:git-commit-16' }, + 'topic:': { label: 'Topic', icon: 'octicon:hash-16' }, + 'is:': { label: 'State', icon: 'octicon:circle-16' }, + 'fork:': { label: 'Fork', icon: 'octicon:repo-forked-16' }, +} as const diff --git a/components/search/types.ts b/components/search/types.ts new file mode 100644 index 0000000..48c5150 --- /dev/null +++ b/components/search/types.ts @@ -0,0 +1,18 @@ +export interface SearchToken { + type: 'qualifier' | 'text' | 'space' + value: string + qualifier?: string +} + +export interface Qualifier { + label: string + icon: string +} + +export interface QualifierSuggestion { + qualifier: string + isUsed: boolean + value?: string +} + +export type SearchMode = 'inline' | 'modal' diff --git a/pages/index.vue b/pages/index.vue index 4d1b8d1..3cda3e0 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -10,6 +10,7 @@ import { SelectValue, } from '#components' import { OrderOptions, SortOptions } from '~/types' +import SearchInput from '~/components/SearchInput.vue' // Agregar meta tags dinámicos useSeoMeta({ @@ -170,6 +171,7 @@ const clearFilter = (key: keyof Filters) => {
From b9c0e4f56a1da84d1b7a0f98fe9f660460c45c97 Mon Sep 17 00:00:00 2001 From: Sean Luis Date: Sat, 22 Feb 2025 01:10:35 -0300 Subject: [PATCH 03/11] feat: add search input handling, qualifiers, and scroll lock functionality --- components/SearchInput.vue | 547 ++++----------------------- composables/useQualifiers.ts | 22 ++ composables/useScrollLock.ts | 24 ++ composables/useSearchInput.ts | 99 +++++ composables/useSearchInteractions.ts | 163 ++++++++ composables/useSearchParser.ts | 122 ++++++ pages/index.vue | 2 +- types/search.ts | 25 ++ 8 files changed, 528 insertions(+), 476 deletions(-) create mode 100644 composables/useQualifiers.ts create mode 100644 composables/useScrollLock.ts create mode 100644 composables/useSearchInput.ts create mode 100644 composables/useSearchInteractions.ts create mode 100644 composables/useSearchParser.ts create mode 100644 types/search.ts diff --git a/components/SearchInput.vue b/components/SearchInput.vue index 13a48f4..cb4af0c 100644 --- a/components/SearchInput.vue +++ b/components/SearchInput.vue @@ -1,326 +1,71 @@ @@ -498,8 +93,9 @@ const suggestionGroups = computed(() => {
{ {
{

Search repositories

@@ -660,10 +257,10 @@ const suggestionGroups = computed(() => { { + originalPosition.value = window.scrollY + document.body.style.position = 'fixed' + document.body.style.top = `-${originalPosition.value}px` + document.body.style.width = '100%' + } + + const unlockScroll = () => { + document.body.style.position = '' + document.body.style.top = '' + document.body.style.width = '' + window.scrollTo(0, originalPosition.value) + } + + return { + lockScroll, + unlockScroll + } +} diff --git a/composables/useSearchInput.ts b/composables/useSearchInput.ts new file mode 100644 index 0000000..cf02972 --- /dev/null +++ b/composables/useSearchInput.ts @@ -0,0 +1,99 @@ +import { ref, nextTick, onMounted, onUnmounted } from 'vue' + +export function useSearchInput(onHandleFocus?: () => void) { + const input = ref(null) + const cursorPosition = ref(0) + const searchContainer = ref(null) + const containerWidth = ref(0) + const dropdownPosition = ref({ left: 0, top: 0 }) + const isInputFocused = ref(false) + + const updateCursorPosition = () => { + nextTick(() => { + if (input.value) { + const inputElement = input.value + cursorPosition.value = inputElement.selectionStart || 0 + const container = inputElement.parentElement + + if (container) { + const measureElement = document.createElement('span') + measureElement.style.font = window.getComputedStyle(inputElement).font + measureElement.style.visibility = 'hidden' + measureElement.style.position = 'absolute' + measureElement.style.whiteSpace = 'pre' + measureElement.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) + document.body.appendChild(measureElement) + + const cursorOffset = measureElement.offsetWidth + const containerWidth = container.offsetWidth + const scrollLeft = container.scrollLeft + + document.body.removeChild(measureElement) + + if (cursorOffset > scrollLeft + containerWidth - 80) { + container.scrollLeft = cursorOffset - containerWidth + 80 + } + else if (cursorOffset < scrollLeft + 80) { + container.scrollLeft = Math.max(0, cursorOffset - 80) + } + } + } + }) + } + + const handleInputScroll = (event: Event) => { + const input = event.target as HTMLElement + const mirror = input.previousElementSibling as HTMLElement + if (mirror) { + mirror.scrollLeft = input.scrollLeft + } + } + + const updateContainerWidth = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } + } + + const updateDropdownPosition = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } + } + + const handleGlobalShortcut = (event: KeyboardEvent) => { + if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { + event.preventDefault() + isInputFocused.value = true + onHandleFocus?.() + nextTick(() => { + input.value?.focus() + }) + } + } + + onMounted(() => { + updateContainerWidth() + window.addEventListener('resize', updateContainerWidth) + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('resize', updateContainerWidth) + window.removeEventListener('keydown', handleGlobalShortcut) + }) + + return { + input, + cursorPosition, + searchContainer, + containerWidth, + dropdownPosition, + isInputFocused, + updateCursorPosition, + handleInputScroll, + updateContainerWidth, + updateDropdownPosition, + handleGlobalShortcut + } +} diff --git a/composables/useSearchInteractions.ts b/composables/useSearchInteractions.ts new file mode 100644 index 0000000..787271d --- /dev/null +++ b/composables/useSearchInteractions.ts @@ -0,0 +1,163 @@ +import { ref, computed, nextTick } from 'vue' +import type { SearchToken } from '../types/search' +import { useSearchParser } from './useSearchParser' + +export function useSearchInteractions( + props: { modelValue: string, mode?: 'inline' | 'modal' }, + emit: { (e: 'update:modelValue', value: string): void, (e: 'search'): void }, + input: any, + updateCursorPosition: () => void, + lockScroll: () => void, + unlockScroll: () => void +) { + const { parseSearchTokens } = useSearchParser() + const searchInput = ref(props.modelValue) + const showSuggestions = ref(false) + const isInputFocused = ref(false) + const isExpanded = ref(false) + + const isInlineMode = computed(() => props.mode === 'inline') + const searchTokens = computed(() => parseSearchTokens(searchInput.value)) + + const handleInput = (event: Event) => { + const value = (event.target as HTMLInputElement).value + searchInput.value = value + emit('update:modelValue', value) + updateCursorPosition() + showSuggestions.value = true // Mantener sugerencias visibles al escribir + } + + const handleFocus = () => { + if (!isInlineMode.value && !isExpanded.value) { + isExpanded.value = true + isInputFocused.value = true + showSuggestions.value = true + lockScroll() + nextTick(() => { + input.value?.focus() + }) + } else { + isInputFocused.value = true + showSuggestions.value = true + } + } + + const closeSearch = () => { + if (!isInlineMode.value) { + isExpanded.value = false + isInputFocused.value = false + showSuggestions.value = false + unlockScroll() + } else { + isInputFocused.value = false + showSuggestions.value = false + } + } + + const handleBlur = (event: FocusEvent) => { + const dialogContent = document.querySelector('.search-dialog-content') + if (dialogContent?.contains(event.relatedTarget as Node)) { + return + } + + if (!event.relatedTarget || !dialogContent?.contains(event.relatedTarget as Node)) { + closeSearch() + } + } + + const handleClose = () => { + closeSearch() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + emit('search') + closeSearch() + } else if (event.key === 'Escape' && isExpanded.value) { + closeSearch() + } + updateCursorPosition() + } + + const handleSuggestionClick = (suggestion: string) => { + const currentValue = searchInput.value + const space = currentValue && !currentValue.endsWith(' ') ? ' ' : '' + searchInput.value = currentValue + space + suggestion + emit('update:modelValue', searchInput.value) + input.value?.focus() + updateCursorPosition() + showSuggestions.value = true + } + + const removeQualifier = (qualifierToRemove: string, valueToRemove: string) => { + const tokens = searchTokens.value + let newSearchText = '' + let skipNext = false + let needsSpace = false + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + + // Si este token debe ser saltado + if (skipNext) { + skipNext = false + continue + } + + // Si es el qualifier que queremos remover + if (token.type === 'qualifier' && + token.qualifier + ':' === qualifierToRemove && + token.value === valueToRemove) { + // Si hay un espacio después, marcarlo para saltar + if (i + 1 < tokens.length && tokens[i + 1].type === 'space') { + skipNext = true + } + // Si hay un espacio antes, no añadir espacio extra + if (i > 0 && tokens[i - 1].type === 'space') { + needsSpace = false + } + continue + } + + // Añadir espacio si es necesario + if (needsSpace && token.type !== 'space') { + newSearchText += ' ' + } + + // Añadir el token actual + if (token.type === 'qualifier') { + newSearchText += token.qualifier + ':' + token.value + needsSpace = true + } else if (token.type === 'text') { + newSearchText += token.value + needsSpace = true + } else if (token.type === 'space') { + needsSpace = false + } + } + + searchInput.value = newSearchText.trim() + emit('update:modelValue', searchInput.value) + nextTick(() => { + input.value?.focus() + updateCursorPosition() + }) + } + + return { + searchInput, + showSuggestions, + isInputFocused, + isExpanded, + isInlineMode, + searchTokens, // Exportar searchTokens + handleInput, + handleFocus, + closeSearch, + handleBlur, + handleClose, + handleKeyDown, + handleSuggestionClick, + removeQualifier + } +} diff --git a/composables/useSearchParser.ts b/composables/useSearchParser.ts new file mode 100644 index 0000000..677e3ee --- /dev/null +++ b/composables/useSearchParser.ts @@ -0,0 +1,122 @@ +import type { ColoredPart, SearchToken } from '~/types/search' +import { QUALIFIERS } from './useQualifiers' + +export function useSearchParser() { + const parseSearchTokens = (value: string): SearchToken[] => { + const tokens: SearchToken[] = [] + let i = 0 + + while (i < value.length) { + let matchedQualifier = false + + for (const qualifier of Object.keys(QUALIFIERS)) { + if (value.slice(i).toLowerCase().startsWith(qualifier.toLowerCase())) { + let j = i + qualifier.length + let qualifierValue = '' + + while (j < value.length && value[j] !== ' ') { + qualifierValue += value[j] + j++ + } + + if (qualifierValue) { + tokens.push({ + type: 'qualifier', + qualifier: qualifier.slice(0, -1), + value: qualifierValue + }) + } + + i = j + matchedQualifier = true + break + } + } + + if (!matchedQualifier) { + if (value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + } else { + let textValue = '' + while (i < value.length && !Object.keys(QUALIFIERS).some(q => + value.slice(i).toLowerCase().startsWith(q.toLowerCase()) + ) && value[i] !== ' ') { + textValue += value[i] + i++ + } + if (textValue) { + tokens.push({ type: 'text', value: textValue }) + } + continue + } + } + i++ + } + + return tokens + } + + const colorText = (text: string): ColoredPart[] => { + const parts: ColoredPart[] = [] + let currentPosition = 0 + + while (currentPosition < text.length) { + let matched = false + + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + parts.push({ + text: qualifier, + type: 'qualifier' + }) + + currentPosition += qualifier.length + + let valueStart = currentPosition + while (currentPosition < text.length && text[currentPosition] !== ' ') { + currentPosition++ + } + + if (currentPosition > valueStart) { + parts.push({ + text: text.slice(valueStart, currentPosition), + type: 'value' + }) + } + + matched = true + break + } + } + + if (!matched) { + let normalText = '' + while (currentPosition < text.length) { + let isQualifier = false + for (const qualifier of Object.keys(QUALIFIERS)) { + if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { + isQualifier = true + break + } + } + if (isQualifier) break + normalText += text[currentPosition] + currentPosition++ + } + if (normalText) { + parts.push({ + text: normalText, + type: 'normal' + }) + } + } + } + + return parts + } + + return { + parseSearchTokens, + colorText + } +} diff --git a/pages/index.vue b/pages/index.vue index 3cda3e0..1a364b5 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -171,7 +171,7 @@ const clearFilter = (key: keyof Filters) => {
diff --git a/types/search.ts b/types/search.ts new file mode 100644 index 0000000..ccc4f7d --- /dev/null +++ b/types/search.ts @@ -0,0 +1,25 @@ +export interface SearchToken { + type: 'qualifier' | 'text' | 'space'; + value: string; + qualifier?: string; +} + +export interface QualifierInfo { + label: string; + icon: string; +} + +export interface QualifierData { + [key: string]: QualifierInfo; +} + +export interface SuggestionItem { + qualifier: string; + isUsed: boolean; + value?: string; +} + +export interface ColoredPart { + text: string; + type: 'qualifier' | 'value' | 'normal'; +} From 106825179f8e1aef6b18669e4e19d6864ac404a1 Mon Sep 17 00:00:00 2001 From: Sean Luis Date: Sat, 22 Feb 2025 11:26:08 -0300 Subject: [PATCH 04/11] feat: simplify search input container and enhance cursor visibility in inline mode --- components/SearchInput.vue | 112 ++++++++++++++++++++++--------------- pages/index.vue | 2 +- 2 files changed, 68 insertions(+), 46 deletions(-) diff --git a/components/SearchInput.vue b/components/SearchInput.vue index cb4af0c..69f8313 100644 --- a/components/SearchInput.vue +++ b/components/SearchInput.vue @@ -109,53 +109,45 @@ watch(() => props.modelValue, (newValue) => { class="mr-2 h-4 w-4 shrink-0 opacity-50" /> - -
-
- -
- - - -
- - - + +
+ + - - + placeholder="Search repositories..." + @input="handleInput" + @keydown="handleKeyDown" + @focus="handleFocus" + @blur="handleBlur" + @scroll="handleInputScroll" + />
props.modelValue, (newValue) => { background-color: currentColor; margin-left: 1px; animation: blink 1s step-end infinite; + position: relative; + top: 1px; } @keyframes blink { @@ -854,4 +848,32 @@ watch(() => props.modelValue, (newValue) => { background-color: hsl(var(--destructive)/0.2); color: hsl(var(--destructive)); } + +.search-input-field { + background: transparent; + outline: none; + position: relative; + white-space: pre; + padding: 2px 0; + caret-color: currentColor; /* Asegura que el cursor sea visible */ + color: currentColor; /* Color del texto igual al tema actual */ +} + +/* Solo hacer el texto transparente cuando tiene contenido y no está en modo inline */ +.search-input-field.has-content:not(.inline-mode) { + color: transparent; + -webkit-text-fill-color: transparent; +} + +/* Asegurar que el cursor sea visible en modo inline */ +.search-input-field.inline-mode { + color: currentColor; + -webkit-text-fill-color: currentColor; +} + +/* Mantener el placeholder visible */ +.search-input-field::placeholder { + -webkit-text-fill-color: initial; + color: hsl(var(--muted-foreground)); +} diff --git a/pages/index.vue b/pages/index.vue index 1a364b5..3cda3e0 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -171,7 +171,7 @@ const clearFilter = (key: keyof Filters) => {
From 7c103f03afdb59fe03d52d0b6993c12cb36cc5e4 Mon Sep 17 00:00:00 2001 From: Sean Luis Date: Sat, 22 Feb 2025 11:50:38 -0300 Subject: [PATCH 05/11] feat: reorganize search component structure and remove unused types and files --- components/SearchInput.vue | 879 -------------- components/search/SearchDialog.vue | 100 -- components/search/SearchHighlight.vue | 51 - components/search/SearchInput.vue | 1015 ++++++++++++----- components/search/SearchSuggestions.vue | 105 -- .../search/composables}/useQualifiers.ts | 2 +- .../search/composables}/useScrollLock.ts | 0 .../search/composables/useSearchInput.ts | 210 ++-- .../composables}/useSearchInteractions.ts | 2 +- .../search/composables}/useSearchParser.ts | 2 +- .../search/composables/useSearchShortcuts.ts | 28 - .../composables/useSearchSuggestions.ts | 43 - components/search/constants.ts | 14 - {types => components/search}/search.ts | 0 components/search/types.ts | 18 - composables/useSearchInput.ts | 99 -- pages/index.vue | 2 +- 17 files changed, 833 insertions(+), 1737 deletions(-) delete mode 100644 components/SearchInput.vue delete mode 100644 components/search/SearchDialog.vue delete mode 100644 components/search/SearchHighlight.vue delete mode 100644 components/search/SearchSuggestions.vue rename {composables => components/search/composables}/useQualifiers.ts (93%) rename {composables => components/search/composables}/useScrollLock.ts (100%) rename {composables => components/search/composables}/useSearchInteractions.ts (98%) rename {composables => components/search/composables}/useSearchParser.ts (97%) delete mode 100644 components/search/composables/useSearchShortcuts.ts delete mode 100644 components/search/composables/useSearchSuggestions.ts delete mode 100644 components/search/constants.ts rename {types => components/search}/search.ts (100%) delete mode 100644 components/search/types.ts delete mode 100644 composables/useSearchInput.ts diff --git a/components/SearchInput.vue b/components/SearchInput.vue deleted file mode 100644 index 69f8313..0000000 --- a/components/SearchInput.vue +++ /dev/null @@ -1,879 +0,0 @@ - - - - - diff --git a/components/search/SearchDialog.vue b/components/search/SearchDialog.vue deleted file mode 100644 index 45cca6d..0000000 --- a/components/search/SearchDialog.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - - - diff --git a/components/search/SearchHighlight.vue b/components/search/SearchHighlight.vue deleted file mode 100644 index 1d206e7..0000000 --- a/components/search/SearchHighlight.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - - - diff --git a/components/search/SearchInput.vue b/components/search/SearchInput.vue index c15c8ea..291903d 100644 --- a/components/search/SearchInput.vue +++ b/components/search/SearchInput.vue @@ -1,16 +1,14 @@ diff --git a/components/search/SearchSuggestions.vue b/components/search/SearchSuggestions.vue deleted file mode 100644 index 52f4e09..0000000 --- a/components/search/SearchSuggestions.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - diff --git a/composables/useQualifiers.ts b/components/search/composables/useQualifiers.ts similarity index 93% rename from composables/useQualifiers.ts rename to components/search/composables/useQualifiers.ts index bfa6fe0..7fb2af2 100644 --- a/composables/useQualifiers.ts +++ b/components/search/composables/useQualifiers.ts @@ -1,4 +1,4 @@ -import type { QualifierData } from "~/types/search" +import type { QualifierData } from "~/components/search/search" export const QUALIFIERS: QualifierData = { 'repo:': { label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, diff --git a/composables/useScrollLock.ts b/components/search/composables/useScrollLock.ts similarity index 100% rename from composables/useScrollLock.ts rename to components/search/composables/useScrollLock.ts diff --git a/components/search/composables/useSearchInput.ts b/components/search/composables/useSearchInput.ts index 1882a4a..cf02972 100644 --- a/components/search/composables/useSearchInput.ts +++ b/components/search/composables/useSearchInput.ts @@ -1,171 +1,99 @@ -import { ref, computed } from 'vue' -import type { SearchToken } from '../types' -import { QUALIFIERS } from '../constants' +import { ref, nextTick, onMounted, onUnmounted } from 'vue' -export function useSearchInput() { - const searchInput = ref('') +export function useSearchInput(onHandleFocus?: () => void) { const input = ref(null) const cursorPosition = ref(0) - const isInputFocused = ref(false) - const containerWidth = ref(0) const searchContainer = ref(null) + const containerWidth = ref(0) + const dropdownPosition = ref({ left: 0, top: 0 }) + const isInputFocused = ref(false) - const colorText = (text: string) => { - const parts: { text: string; type: 'qualifier' | 'value' | 'normal' }[] = [] - let currentPosition = 0 - - while (currentPosition < text.length) { - let matched = false + const updateCursorPosition = () => { + nextTick(() => { + if (input.value) { + const inputElement = input.value + cursorPosition.value = inputElement.selectionStart || 0 + const container = inputElement.parentElement - // Buscar qualifiers - for (const qualifier of Object.keys(QUALIFIERS)) { - if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { - parts.push({ - text: qualifier, - type: 'qualifier' - }) + if (container) { + const measureElement = document.createElement('span') + measureElement.style.font = window.getComputedStyle(inputElement).font + measureElement.style.visibility = 'hidden' + measureElement.style.position = 'absolute' + measureElement.style.whiteSpace = 'pre' + measureElement.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) + document.body.appendChild(measureElement) - currentPosition += qualifier.length + const cursorOffset = measureElement.offsetWidth + const containerWidth = container.offsetWidth + const scrollLeft = container.scrollLeft - let valueStart = currentPosition - while (currentPosition < text.length && text[currentPosition] !== ' ') { - currentPosition++ + document.body.removeChild(measureElement) + + if (cursorOffset > scrollLeft + containerWidth - 80) { + container.scrollLeft = cursorOffset - containerWidth + 80 } - - if (currentPosition > valueStart) { - parts.push({ - text: text.slice(valueStart, currentPosition), - type: 'value' - }) + else if (cursorOffset < scrollLeft + 80) { + container.scrollLeft = Math.max(0, cursorOffset - 80) } - - matched = true - break } } + }) + } - if (!matched) { - let normalText = '' - while (currentPosition < text.length) { - let isQualifier = false - for (const qualifier of Object.keys(QUALIFIERS)) { - if (text.slice(currentPosition).toLowerCase().startsWith(qualifier.toLowerCase())) { - isQualifier = true - break - } - } - if (isQualifier) break - normalText += text[currentPosition] - currentPosition++ - } - if (normalText) { - parts.push({ - text: normalText, - type: 'normal' - }) - } - } + const handleInputScroll = (event: Event) => { + const input = event.target as HTMLElement + const mirror = input.previousElementSibling as HTMLElement + if (mirror) { + mirror.scrollLeft = input.scrollLeft } - - return parts } - const coloredParts = computed(() => colorText(searchInput.value)) - - const parseSearchTokens = (value: string): SearchToken[] => { - const tokens: SearchToken[] = [] - let i = 0 - - while (i < value.length) { - let matchedQualifier = false - - for (const qualifier of Object.keys(QUALIFIERS)) { - if (value.slice(i).toLowerCase().startsWith(qualifier.toLowerCase())) { - let j = i + qualifier.length - let qualifierValue = '' - - while (j < value.length && value[j] !== ' ') { - qualifierValue += value[j] - j++ - } - - if (qualifierValue) { - tokens.push({ - type: 'qualifier', - qualifier: qualifier.slice(0, -1), - value: qualifierValue - }) - } - - i = j - matchedQualifier = true - break - } - } - - if (!matchedQualifier) { - if (value[i] === ' ') { - tokens.push({ type: 'space', value: ' ' }) - i++ - } else { - let textValue = '' - let start = i - - while (i < value.length) { - let isQualifier = false - for (const qualifier of Object.keys(QUALIFIERS)) { - if (value.slice(i).toLowerCase().startsWith(qualifier.toLowerCase())) { - isQualifier = true - break - } - } - if (isQualifier || value[i] === ' ') break - textValue += value[i] - i++ - } - - if (textValue) { - tokens.push({ type: 'text', value: textValue }) - } - } - } + const updateContainerWidth = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth } + } - return tokens + const updateDropdownPosition = () => { + if (searchContainer.value) { + containerWidth.value = searchContainer.value.offsetWidth + } } - const updateCursorPosition = () => { - if (input.value) { - const inputElement = input.value - cursorPosition.value = inputElement.selectionStart || 0 - - // Usar el mismo contenedor que el input para medir - const container = document.createElement('div') - container.style.position = 'absolute' - container.style.top = '0' - container.style.left = '0' - container.style.visibility = 'hidden' - container.style.whiteSpace = 'pre' - container.style.font = window.getComputedStyle(inputElement).font - - // Medir solo hasta la posición del cursor - container.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) - - document.body.appendChild(container) - cursorPosition.value = container.offsetWidth - document.body.removeChild(container) + const handleGlobalShortcut = (event: KeyboardEvent) => { + if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { + event.preventDefault() + isInputFocused.value = true + onHandleFocus?.() + nextTick(() => { + input.value?.focus() + }) } } + onMounted(() => { + updateContainerWidth() + window.addEventListener('resize', updateContainerWidth) + window.addEventListener('keydown', handleGlobalShortcut) + }) + + onUnmounted(() => { + window.removeEventListener('resize', updateContainerWidth) + window.removeEventListener('keydown', handleGlobalShortcut) + }) + return { - searchInput, input, cursorPosition, - isInputFocused, searchContainer, containerWidth, - coloredParts, - parseSearchTokens, - updateCursorPosition + dropdownPosition, + isInputFocused, + updateCursorPosition, + handleInputScroll, + updateContainerWidth, + updateDropdownPosition, + handleGlobalShortcut } } diff --git a/composables/useSearchInteractions.ts b/components/search/composables/useSearchInteractions.ts similarity index 98% rename from composables/useSearchInteractions.ts rename to components/search/composables/useSearchInteractions.ts index 787271d..77cab63 100644 --- a/composables/useSearchInteractions.ts +++ b/components/search/composables/useSearchInteractions.ts @@ -1,5 +1,5 @@ import { ref, computed, nextTick } from 'vue' -import type { SearchToken } from '../types/search' +import type { SearchToken } from '../search' import { useSearchParser } from './useSearchParser' export function useSearchInteractions( diff --git a/composables/useSearchParser.ts b/components/search/composables/useSearchParser.ts similarity index 97% rename from composables/useSearchParser.ts rename to components/search/composables/useSearchParser.ts index 677e3ee..b37bb5b 100644 --- a/composables/useSearchParser.ts +++ b/components/search/composables/useSearchParser.ts @@ -1,4 +1,4 @@ -import type { ColoredPart, SearchToken } from '~/types/search' +import type { ColoredPart, SearchToken } from '~/components/search/search' import { QUALIFIERS } from './useQualifiers' export function useSearchParser() { diff --git a/components/search/composables/useSearchShortcuts.ts b/components/search/composables/useSearchShortcuts.ts deleted file mode 100644 index 43d3f0a..0000000 --- a/components/search/composables/useSearchShortcuts.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { onMounted, onUnmounted } from 'vue' - -export function useSearchShortcuts( - handleFocus: () => void, - input: () => HTMLInputElement | null -) { - const handleGlobalShortcut = (event: KeyboardEvent) => { - if ( - event.key === '/' && - !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName) - ) { - event.preventDefault() - handleFocus() - } - } - - onMounted(() => { - window.addEventListener('keydown', handleGlobalShortcut) - }) - - onUnmounted(() => { - window.removeEventListener('keydown', handleGlobalShortcut) - }) - - return { - handleGlobalShortcut - } -} diff --git a/components/search/composables/useSearchSuggestions.ts b/components/search/composables/useSearchSuggestions.ts deleted file mode 100644 index 9eb71c9..0000000 --- a/components/search/composables/useSearchSuggestions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ref, computed } from 'vue' -import type { SearchToken, QualifierSuggestion } from '../types' -import { QUALIFIERS } from '../constants' - -export function useSearchSuggestions( - searchTokens: () => SearchToken[], - getCurrentInput: () => string -) { - const showSuggestions = ref(false) - - const suggestions = computed(() => { - const usedQualifiers = searchTokens() - .filter(token => token.type === 'qualifier') - .map(token => ({ - qualifier: token.qualifier + ':', - value: token.value - })) - - return Object.keys(QUALIFIERS).map(qualifier => ({ - qualifier, - isUsed: usedQualifiers.some(used => used.qualifier === qualifier), - value: usedQualifiers.find(used => used.qualifier === qualifier)?.value - })) - }) - - const handleSuggestionSelect = (qualifier: string) => { - return qualifier.endsWith(':') ? qualifier : `${qualifier}:` - } - - const handleSuggestionRemove = (qualifier: string, value: string) => { - const currentInput = getCurrentInput() - const fullQualifier = qualifier.endsWith(':') ? qualifier : `${qualifier}:` - const pattern = new RegExp(`${fullQualifier}\\s*${value}\\s*`, 'i') - return currentInput.replace(pattern, '').trim() - } - - return { - showSuggestions, - suggestions, - handleSuggestionSelect, - handleSuggestionRemove - } -} diff --git a/components/search/constants.ts b/components/search/constants.ts deleted file mode 100644 index 5ce7de2..0000000 --- a/components/search/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const QUALIFIERS = { - 'repo:': { label: 'Repository (user/repo)', icon: 'octicon:repo-16' }, - 'user:': { label: 'User', icon: 'octicon:person-16' }, - 'org:': { label: 'Organization', icon: 'octicon:organization-16' }, - 'in:': { label: 'Search in', icon: 'octicon:search-16' }, - 'size:': { label: 'Size', icon: 'octicon:file-16' }, - 'stars:': { label: 'Stars', icon: 'octicon:star-16' }, - 'language:': { label: 'Language', icon: 'octicon:code-16' }, - 'created:': { label: 'Created date', icon: 'octicon:calendar-16' }, - 'pushed:': { label: 'Push date', icon: 'octicon:git-commit-16' }, - 'topic:': { label: 'Topic', icon: 'octicon:hash-16' }, - 'is:': { label: 'State', icon: 'octicon:circle-16' }, - 'fork:': { label: 'Fork', icon: 'octicon:repo-forked-16' }, -} as const diff --git a/types/search.ts b/components/search/search.ts similarity index 100% rename from types/search.ts rename to components/search/search.ts diff --git a/components/search/types.ts b/components/search/types.ts deleted file mode 100644 index 48c5150..0000000 --- a/components/search/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface SearchToken { - type: 'qualifier' | 'text' | 'space' - value: string - qualifier?: string -} - -export interface Qualifier { - label: string - icon: string -} - -export interface QualifierSuggestion { - qualifier: string - isUsed: boolean - value?: string -} - -export type SearchMode = 'inline' | 'modal' diff --git a/composables/useSearchInput.ts b/composables/useSearchInput.ts deleted file mode 100644 index cf02972..0000000 --- a/composables/useSearchInput.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { ref, nextTick, onMounted, onUnmounted } from 'vue' - -export function useSearchInput(onHandleFocus?: () => void) { - const input = ref(null) - const cursorPosition = ref(0) - const searchContainer = ref(null) - const containerWidth = ref(0) - const dropdownPosition = ref({ left: 0, top: 0 }) - const isInputFocused = ref(false) - - const updateCursorPosition = () => { - nextTick(() => { - if (input.value) { - const inputElement = input.value - cursorPosition.value = inputElement.selectionStart || 0 - const container = inputElement.parentElement - - if (container) { - const measureElement = document.createElement('span') - measureElement.style.font = window.getComputedStyle(inputElement).font - measureElement.style.visibility = 'hidden' - measureElement.style.position = 'absolute' - measureElement.style.whiteSpace = 'pre' - measureElement.textContent = inputElement.value.substring(0, inputElement.selectionStart || 0) - document.body.appendChild(measureElement) - - const cursorOffset = measureElement.offsetWidth - const containerWidth = container.offsetWidth - const scrollLeft = container.scrollLeft - - document.body.removeChild(measureElement) - - if (cursorOffset > scrollLeft + containerWidth - 80) { - container.scrollLeft = cursorOffset - containerWidth + 80 - } - else if (cursorOffset < scrollLeft + 80) { - container.scrollLeft = Math.max(0, cursorOffset - 80) - } - } - } - }) - } - - const handleInputScroll = (event: Event) => { - const input = event.target as HTMLElement - const mirror = input.previousElementSibling as HTMLElement - if (mirror) { - mirror.scrollLeft = input.scrollLeft - } - } - - const updateContainerWidth = () => { - if (searchContainer.value) { - containerWidth.value = searchContainer.value.offsetWidth - } - } - - const updateDropdownPosition = () => { - if (searchContainer.value) { - containerWidth.value = searchContainer.value.offsetWidth - } - } - - const handleGlobalShortcut = (event: KeyboardEvent) => { - if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes((event.target as HTMLElement).tagName)) { - event.preventDefault() - isInputFocused.value = true - onHandleFocus?.() - nextTick(() => { - input.value?.focus() - }) - } - } - - onMounted(() => { - updateContainerWidth() - window.addEventListener('resize', updateContainerWidth) - window.addEventListener('keydown', handleGlobalShortcut) - }) - - onUnmounted(() => { - window.removeEventListener('resize', updateContainerWidth) - window.removeEventListener('keydown', handleGlobalShortcut) - }) - - return { - input, - cursorPosition, - searchContainer, - containerWidth, - dropdownPosition, - isInputFocused, - updateCursorPosition, - handleInputScroll, - updateContainerWidth, - updateDropdownPosition, - handleGlobalShortcut - } -} diff --git a/pages/index.vue b/pages/index.vue index 3cda3e0..2a9c199 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -10,7 +10,7 @@ import { SelectValue, } from '#components' import { OrderOptions, SortOptions } from '~/types' -import SearchInput from '~/components/SearchInput.vue' +import SearchInput from '~/components/search/SearchInput.vue' // Agregar meta tags dinámicos useSeoMeta({ From 63dbb190d739f83d0edf1aba713d58ec71c2b545 Mon Sep 17 00:00:00 2001 From: Sean Luis Date: Sat, 22 Feb 2025 17:20:45 -0300 Subject: [PATCH 06/11] feat: update search input styles for full-width display and improved responsiveness --- components/search/SearchInput.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/search/SearchInput.vue b/components/search/SearchInput.vue index 291903d..fbc4e22 100644 --- a/components/search/SearchInput.vue +++ b/components/search/SearchInput.vue @@ -658,13 +658,13 @@ watch(() => props.modelValue, (newValue) => { } .search-input { - width: 90%; + width: 100%; background: transparent; outline: none; position: relative; white-space: pre; padding: 2px 0; - min-width: 90%; + min-width: 100%; color: transparent; caret-color: currentColor; } From a1a59062270359c727ea7d165487990b5fc36789 Mon Sep 17 00:00:00 2001 From: Sean Luis Date: Sun, 23 Feb 2025 16:02:19 -0300 Subject: [PATCH 07/11] feat: implement search text highlighting and improve input handling --- components/search/SearchInput.vue | 154 ++++++++++++++---- .../composables/useSearchHighlighter.ts | 27 +++ .../search/composables/useSearchParser.ts | 73 +++++---- pages/index.vue | 4 +- 4 files changed, 190 insertions(+), 68 deletions(-) create mode 100644 components/search/composables/useSearchHighlighter.ts diff --git a/components/search/SearchInput.vue b/components/search/SearchInput.vue index fbc4e22..6920c71 100644 --- a/components/search/SearchInput.vue +++ b/components/search/SearchInput.vue @@ -5,6 +5,7 @@ import { useSearchParser } from './composables/useSearchParser' import { useScrollLock } from './composables/useScrollLock' import { useSearchInput } from './composables/useSearchInput' import { useSearchInteractions } from './composables/useSearchInteractions' +import { useSearchHighlighter } from './composables/useSearchHighlighter' // Nuevo: importar highlighter const props = defineProps<{ modelValue: string @@ -63,8 +64,11 @@ const { removeQualifier } = searchInteractions -// 4. Finalmente las computed properties que dependen de todo lo anterior -const coloredParts = computed(() => colorText(searchValue.value)) +// Nuevo: usar el highlighter que devuelve un string HTML +const { highlightText } = useSearchHighlighter() +const highlightedHTML = computed(() => highlightText(searchValue.value)) + +// Nuevo: agregar computed property 'suggestions' const suggestions = computed(() => { const usedQualifiers = searchTokens.value .filter(token => token.type === 'qualifier') @@ -72,7 +76,6 @@ const suggestions = computed(() => { qualifier: token.qualifier + ':', value: token.value })) - return Object.keys(QUALIFIERS).map(qualifier => ({ qualifier, isUsed: usedQualifiers.some(used => used.qualifier === qualifier), @@ -80,6 +83,9 @@ const suggestions = computed(() => { })) }) +// Eliminar o comentar el uso anterior de coloredParts +// const coloredParts = computed(() => colorText(searchValue.value)) + // Watchers watch(() => props.modelValue, (newValue) => { if (newValue !== searchValue.value) { @@ -111,25 +117,16 @@ watch(() => props.modelValue, (newValue) => {
- + props.modelValue, (newValue) => { 'has-content': searchValue, 'text-transparent': searchValue }" - placeholder="Search repositories..." + placeholder="Press / to search repositories..." @input="handleInput" @keydown="handleKeyDown" @focus="handleFocus" @@ -230,20 +227,12 @@ watch(() => props.modelValue, (newValue) => { />
- + @@ -253,7 +242,7 @@ watch(() => props.modelValue, (newValue) => { type="text" class="search-input w-full bg-transparent outline-none relative" :class="{ 'text-transparent': searchValue }" - placeholder="Search repositories..." + placeholder="Press / to search repositories..." @input="handleInput" @keydown="handleKeyDown" @scroll="handleInputScroll" @@ -353,6 +342,42 @@ watch(() => props.modelValue, (newValue) => { -webkit-text-fill-color: currentColor; } +.typing-cursor { + display: inline-block; + width: 1px; /* Más fino */ + height: 1.25em; + background-color: currentColor; + margin-left: 1px; + animation: blink 1s step-end infinite; + position: relative; + opacity: 0.8; /* Más sutil */ + top: 1px; +} + +/* Ajustar la animación para que sea más suave */ +@keyframes blink { + 0%, 100% { + opacity: 0.8; + } + 50% { + opacity: 0; + } +} + +/* Asegurar que el caret del input nativo esté oculto */ +.search-input { + caret-color: transparent !important; +} + +/* Solo mostrar el caret personalizado cuando el input está enfocado */ +.search-input:focus + .typing-cursor { + display: block; +} + +.search-input:not(:focus) + .typing-cursor { + display: none; +} + .typing-cursor { display: inline-block; width: 2px; @@ -876,4 +901,73 @@ watch(() => props.modelValue, (newValue) => { -webkit-text-fill-color: initial; color: hsl(var(--muted-foreground)); } + +/* Nuevos estilos para el resaltado */ +:deep(.search-token-qualifier) { + color: #0969da; + font-weight: 500; + white-space: pre; +} + +:deep(.search-token-value) { + display: inline-flex; + align-items: center; + background-color: #ddf4ff; + color: #0969da; + border-radius: 3px; + padding: 0 4px; + margin: 0 1px; + font-weight: 500; + white-space: pre; +} + +:deep(.search-token-text) { + color: currentColor; + white-space: pre; +} + +/* Dark mode */ +:root[class~="dark"] :deep(.search-token-qualifier), +:root[class~="dark"] :deep(.search-token-value) { + color: #2f81f7; +} + +:root[class~="dark"] :deep(.search-token-value) { + background-color: #1a384d; +} + +/* Asegurar que el texto del input sea transparente cuando tiene contenido */ +.search-input.has-content { + -webkit-text-fill-color: transparent; + color: transparent; +} + +/* Asegurar que el input mantenga el texto transparente */ +.search-input { + -webkit-text-fill-color: transparent; + color: transparent; + caret-color: currentColor; +} + +.search-input::placeholder { + -webkit-text-fill-color: var(--tw-text-opacity, 1); + color: hsl(var(--muted-foreground)); +} + +/* Ajustar el contenedor del mirror */ +.search-input-mirror { + white-space: pre; + overflow-x: hidden; +} + +/* Actualizar los estilos del input para controlar la visibilidad del caret */ +.search-input { + -webkit-text-fill-color: transparent; + color: transparent; + caret-color: transparent; /* Ocultar caret por defecto */ +} + +.search-input:focus { + caret-color: currentColor; /* Mostrar caret solo cuando está enfocado */ +} diff --git a/components/search/composables/useSearchHighlighter.ts b/components/search/composables/useSearchHighlighter.ts new file mode 100644 index 0000000..8f5e979 --- /dev/null +++ b/components/search/composables/useSearchHighlighter.ts @@ -0,0 +1,27 @@ +import { useSearchParser } from './useSearchParser' + +export function useSearchHighlighter() { + const { parseSearchTokens } = useSearchParser() + + const highlightText = (text: string): string => { + if (!text) return '' + + const tokens = parseSearchTokens(text) + return tokens.map(token => { + switch (token.type) { + case 'qualifier': + // Ahora el qualifier se muestra como texto normal + return `${token.qualifier}:` + + (token.value ? `${token.value}` : '') + case 'text': + return `${token.value}` + case 'space': + return ' ' + default: + return token.value + } + }).join('') + } + + return { highlightText } +} diff --git a/components/search/composables/useSearchParser.ts b/components/search/composables/useSearchParser.ts index b37bb5b..ddb58c5 100644 --- a/components/search/composables/useSearchParser.ts +++ b/components/search/composables/useSearchParser.ts @@ -5,54 +5,55 @@ export function useSearchParser() { const parseSearchTokens = (value: string): SearchToken[] => { const tokens: SearchToken[] = [] let i = 0 - + while (i < value.length) { - let matchedQualifier = false + // Check for qualifiers + const qualifierMatch = Object.keys(QUALIFIERS).find(q => + value.slice(i).toLowerCase().startsWith(q.toLowerCase()) + ) - for (const qualifier of Object.keys(QUALIFIERS)) { - if (value.slice(i).toLowerCase().startsWith(qualifier.toLowerCase())) { - let j = i + qualifier.length - let qualifierValue = '' - - while (j < value.length && value[j] !== ' ') { - qualifierValue += value[j] - j++ - } - - if (qualifierValue) { - tokens.push({ - type: 'qualifier', - qualifier: qualifier.slice(0, -1), - value: qualifierValue - }) - } - - i = j - matchedQualifier = true - break + if (qualifierMatch) { + // Move past the qualifier + i += qualifierMatch.length + + // Collect the value after the qualifier + let qualifierValue = '' + while (i < value.length && value[i] !== ' ') { + qualifierValue += value[i] + i++ } - } - - if (!matchedQualifier) { + + tokens.push({ + type: 'qualifier', + qualifier: qualifierMatch.slice(0, -1), // Remove trailing colon + value: qualifierValue + }) + + // Add space if there is one + if (i < value.length && value[i] === ' ') { + tokens.push({ type: 'space', value: ' ' }) + i++ + } + } else { + // Handle regular text if (value[i] === ' ') { tokens.push({ type: 'space', value: ' ' }) + i++ } else { - let textValue = '' - while (i < value.length && !Object.keys(QUALIFIERS).some(q => - value.slice(i).toLowerCase().startsWith(q.toLowerCase()) - ) && value[i] !== ' ') { - textValue += value[i] + let text = '' + while (i < value.length && + !Object.keys(QUALIFIERS).some(q => value.slice(i).toLowerCase().startsWith(q.toLowerCase())) && + value[i] !== ' ') { + text += value[i] i++ } - if (textValue) { - tokens.push({ type: 'text', value: textValue }) + if (text) { + tokens.push({ type: 'text', value: text }) } - continue } } - i++ } - + return tokens } diff --git a/pages/index.vue b/pages/index.vue index 2a9c199..6a7a203 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -171,14 +171,14 @@ const clearFilter = (key: keyof Filters) => {