Skip to content

Commit 41d586a

Browse files
committed
Restore & ignore some component files
1 parent b4a8350 commit 41d586a

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
/** Tooltip text */
4+
text: string
5+
/** Position: 'top' | 'bottom' | 'left' | 'right' */
6+
position?: 'top' | 'bottom' | 'left' | 'right'
7+
/** is tooltip visible */
8+
isVisible: boolean
9+
}>()
10+
</script>
11+
12+
<template>
13+
<TooltipBase :text :isVisible :position :tooltip-attr="{ 'aria-live': 'polite' }"
14+
><slot
15+
/></TooltipBase>
16+
</template>

app/components/UserCombobox.vue

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
/** List of suggested usernames (e.g., org members) */
4+
suggestions: string[]
5+
/** Placeholder text */
6+
placeholder?: string
7+
/** Whether the input is disabled */
8+
disabled?: boolean
9+
/** Accessible label for the input */
10+
label?: string
11+
}>()
12+
13+
const emit = defineEmits<{
14+
/** Emitted when a user is selected/submitted */
15+
select: [username: string, isInSuggestions: boolean]
16+
}>()
17+
18+
const inputValue = shallowRef('')
19+
const isOpen = shallowRef(false)
20+
const highlightedIndex = shallowRef(-1)
21+
const listRef = useTemplateRef('listRef')
22+
23+
// Generate unique ID for accessibility
24+
const inputId = useId()
25+
const listboxId = `${inputId}-listbox`
26+
27+
// Filter suggestions based on input
28+
const filteredSuggestions = computed(() => {
29+
if (!inputValue.value.trim()) {
30+
return props.suggestions.slice(0, 10) // Show first 10 when empty
31+
}
32+
const query = inputValue.value.toLowerCase().replace(/^@/, '')
33+
return props.suggestions.filter(s => s.toLowerCase().includes(query)).slice(0, 10)
34+
})
35+
36+
// Check if current input matches a suggestion exactly
37+
const isExactMatch = computed(() => {
38+
const normalized = inputValue.value.trim().replace(/^@/, '').toLowerCase()
39+
return props.suggestions.some(s => s.toLowerCase() === normalized)
40+
})
41+
42+
// Show hint when typing a non-member username
43+
const showNewUserHint = computed(() => {
44+
const value = inputValue.value.trim().replace(/^@/, '')
45+
return value.length > 0 && !isExactMatch.value && filteredSuggestions.value.length === 0
46+
})
47+
48+
function handleInput() {
49+
isOpen.value = true
50+
highlightedIndex.value = -1
51+
}
52+
53+
function handleFocus() {
54+
isOpen.value = true
55+
}
56+
57+
function handleBlur(event: FocusEvent) {
58+
// Don't close if clicking within the dropdown
59+
const relatedTarget = event.relatedTarget as HTMLElement | null
60+
if (relatedTarget && listRef.value?.contains(relatedTarget)) {
61+
return
62+
}
63+
// Delay to allow click to register
64+
setTimeout(() => {
65+
isOpen.value = false
66+
highlightedIndex.value = -1
67+
}, 150)
68+
}
69+
70+
function selectSuggestion(username: string) {
71+
inputValue.value = username
72+
isOpen.value = false
73+
highlightedIndex.value = -1
74+
emit('select', username, true)
75+
inputValue.value = ''
76+
}
77+
78+
function handleSubmit() {
79+
const username = inputValue.value.trim().replace(/^@/, '')
80+
if (!username) return
81+
82+
const inSuggestions = props.suggestions.some(s => s.toLowerCase() === username.toLowerCase())
83+
emit('select', username, inSuggestions)
84+
inputValue.value = ''
85+
isOpen.value = false
86+
}
87+
88+
function handleKeydown(event: KeyboardEvent) {
89+
if (!isOpen.value) {
90+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
91+
isOpen.value = true
92+
event.preventDefault()
93+
}
94+
return
95+
}
96+
97+
switch (event.key) {
98+
case 'ArrowDown':
99+
event.preventDefault()
100+
if (highlightedIndex.value < filteredSuggestions.value.length - 1) {
101+
highlightedIndex.value++
102+
}
103+
break
104+
case 'ArrowUp':
105+
event.preventDefault()
106+
if (highlightedIndex.value > 0) {
107+
highlightedIndex.value--
108+
}
109+
break
110+
case 'Enter': {
111+
event.preventDefault()
112+
const selectedSuggestion = filteredSuggestions.value[highlightedIndex.value]
113+
if (highlightedIndex.value >= 0 && selectedSuggestion) {
114+
selectSuggestion(selectedSuggestion)
115+
} else {
116+
handleSubmit()
117+
}
118+
break
119+
}
120+
case 'Escape':
121+
isOpen.value = false
122+
highlightedIndex.value = -1
123+
break
124+
}
125+
}
126+
127+
// Scroll highlighted item into view
128+
watch(highlightedIndex, index => {
129+
if (index >= 0 && listRef.value) {
130+
const item = listRef.value.children[index] as HTMLElement
131+
item?.scrollIntoView({ block: 'nearest' })
132+
}
133+
})
134+
135+
// Check for reduced motion preference
136+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
137+
</script>
138+
139+
<template>
140+
<div class="relative">
141+
<label v-if="label" :for="inputId" class="sr-only">{{ label }}</label>
142+
<input
143+
:id="inputId"
144+
v-model="inputValue"
145+
type="text"
146+
:placeholder="placeholder ?? $t('user.combobox.default_placeholder')"
147+
:disabled="disabled"
148+
v-bind="noCorrect"
149+
role="combobox"
150+
aria-autocomplete="list"
151+
:aria-expanded="isOpen && (filteredSuggestions.length > 0 || showNewUserHint)"
152+
aria-haspopup="listbox"
153+
:aria-controls="listboxId"
154+
:aria-activedescendant="
155+
highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined
156+
"
157+
class="w-full px-2 py-1 font-mono text-sm bg-bg-subtle border border-border rounded text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-accent/70 disabled:opacity-50 disabled:cursor-not-allowed"
158+
@input="handleInput"
159+
@focus="handleFocus"
160+
@blur="handleBlur"
161+
@keydown="handleKeydown"
162+
/>
163+
164+
<!-- Dropdown -->
165+
<Transition
166+
:enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'"
167+
:enter-from-class="prefersReducedMotion ? '' : 'opacity-0'"
168+
enter-to-class="opacity-100"
169+
:leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'"
170+
leave-from-class="opacity-100"
171+
:leave-to-class="prefersReducedMotion ? '' : 'opacity-0'"
172+
>
173+
<ul
174+
v-if="isOpen && (filteredSuggestions.length > 0 || showNewUserHint)"
175+
:id="listboxId"
176+
ref="listRef"
177+
role="listbox"
178+
:aria-label="label ?? $t('user.combobox.suggestions_label')"
179+
class="absolute z-50 w-full mt-1 py-1 bg-bg-elevated border border-border rounded shadow-lg max-h-48 overflow-y-auto"
180+
>
181+
<!-- Suggestions from org -->
182+
<li
183+
v-for="(username, index) in filteredSuggestions"
184+
:id="`${listboxId}-option-${index}`"
185+
:key="username"
186+
role="option"
187+
:aria-selected="highlightedIndex === index"
188+
class="px-2 py-1 font-mono text-sm transition-colors duration-100"
189+
:class="
190+
highlightedIndex === index
191+
? 'bg-bg-muted text-fg'
192+
: 'text-fg-muted hover:bg-bg-subtle hover:text-fg'
193+
"
194+
@mouseenter="highlightedIndex = index"
195+
@click="selectSuggestion(username)"
196+
>
197+
@{{ username }}
198+
</li>
199+
200+
<!-- Hint for new user -->
201+
<li
202+
v-if="showNewUserHint"
203+
class="px-2 py-1 font-mono text-xs text-fg-subtle border-t border-border mt-1 pt-2"
204+
role="status"
205+
aria-live="polite"
206+
>
207+
<span class="i-lucide:info w-3 h-3 me-1 align-middle" aria-hidden="true" />
208+
{{
209+
$t('user.combobox.press_enter_to_add', {
210+
username: inputValue.trim().replace(/^@/, ''),
211+
})
212+
}}
213+
<span class="text-amber-400">{{ $t('user.combobox.add_to_org_hint') }}</span>
214+
</li>
215+
</ul>
216+
</Transition>
217+
</div>
218+
</template>

knip.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const config: KnipConfig = {
4040
'h3-next',
4141
],
4242
ignoreUnresolved: ['#oauth/config'],
43+
ignoreFiles: ['app/components/Tooltip/Announce.vue', 'app/components/UserCombobox.vue'],
4344
},
4445
'cli': {
4546
project: ['src/**/*.ts!', '!src/mock-*.ts'],

0 commit comments

Comments
 (0)