Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions core/src/components/UnifiedSearch/SearchResult.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
-->
<template>
<NcListItem
:id="elementId"
class="result-item"
:name="title"
:bold="false"
:active="active"
:href="resourceUrl"
target="_self">
<template #icon>
Expand Down Expand Up @@ -80,11 +82,21 @@ export default {
},

/**
* Only used for the first result as a visual feedback
* so we can keep the search input focused but pressing
* enter still opens the first result
* DOM id set on the option element (the <li>). The combobox input points
* aria-activedescendant at this id to name the active row while focus stays
* in the input.
*/
focused: {
elementId: {
type: String,
default: undefined,
},

/**
* Whether this row is the selected result. Highlights it (via NcListItem's
* active state) so the auto-selected first result and arrow navigation are
* visible while the search input keeps focus.
*/
active: {
type: Boolean,
default: false,
},
Expand Down
113 changes: 112 additions & 1 deletion core/src/components/UnifiedSearch/UnifiedSearchInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@
class="unified-search-input__input"
aria-autocomplete="list"
:aria-expanded="expanded ? 'true' : 'false'"
:aria-controls="expanded ? resultsContainerId : undefined"
:aria-activedescendant="expanded ? (activeDescendantId || undefined) : undefined"
:aria-label="placeholderText"
:value="query"
@focus="isFocused = true"
@blur="isFocused = false"
@input="onInput">
@input="onInput"
@keydown="onKeyDown">
<NcButton
v-if="query.length > 0"
variant="tertiary-no-background"
Expand All @@ -53,6 +56,15 @@
<IconClose :size="20" />
</template>
</NcButton>
<!-- Decorative focus-shortcut hint, shown only while resting (unfocused +
empty) so it never collides with the clear button. -->
<span
v-if="!isActive"
class="unified-search-input__shortcut"
aria-hidden="true">
<kbd>{{ shortcutModifier }}</kbd>
<kbd>K</kbd>
</span>
</div>
</search>
</template>
Expand Down Expand Up @@ -80,17 +92,40 @@ import IconMagnify from 'vue-material-design-icons/Magnify.vue'
const props = defineProps<{
/** Whether the popover the input controls is open. Bound to aria-expanded. */
expanded?: boolean
/** Id of the active result row, for aria-activedescendant. Empty when none. */
activeDescendantId?: string
query: string
}>()

const emit = defineEmits<{
click: [mouseEvent: MouseEvent]
'update:query': [query: string]
/** Move the result selection while focus stays in the input. */
navigate: [direction: 'next' | 'prev' | 'first' | 'last']
/** Open the currently selected result (Enter). */
activate: []
}>()

const isSmallMobile = useIsSmallMobile()
const placeholderText = t('core', 'Apps, files, messages, and more')

// Id of the results popover the input controls (aria-controls). Kept in sync with
// the id on UnifiedSearchModal's panel.
const resultsContainerId = 'unified-search-results'

// Maps the navigation keys to a selection direction. Keys not listed are left
// alone. Home/End are deliberately absent: in the combobox pattern they move the
// textbox caret, not the result selection.
const directionByKey: Record<string, 'next' | 'prev'> = {
ArrowDown: 'next',
ArrowUp: 'prev',
}

// Glyph for the shortcut hint: ⌘ on macOS, Ctrl elsewhere. Display only; the
// handler itself accepts both metaKey and ctrlKey.
const isMac = /mac/i.test(navigator.platform)
const shortcutModifier = isMac ? '⌘' : 'Ctrl'

const inputRef = ref<HTMLInputElement>()
const isFocused = ref(false)

Expand All @@ -111,6 +146,46 @@ function clearQuery() {
emit('update:query', '')
inputRef.value?.focus()
}

/**
* Combobox key handling. While the popover is open arrows move the selection via
* aria-activedescendant and Enter opens the active row, with focus staying in the
* input; otherwise the input keeps normal typing and caret movement (Home/End),
* except Escape, which drops focus like a native find bar.
*
* @param event the keydown event
*/
function onKeyDown(event: KeyboardEvent) {
// Never intercept keys mid-IME-composition (CJK etc.): the popover is already
// open, so Enter/Arrows must reach the input to commit or edit the composition.
if (event.isComposing) {
return
}
// Escape with the popover closed drops focus, like a native find bar. While
// open, the modal owns Escape, so only the closed case is handled here.
if (event.key === 'Escape' && !props.expanded) {
inputRef.value?.blur()
return
}
if (!props.expanded) {
return
}
const direction = directionByKey[event.key]
if (direction) {
event.preventDefault()
emit('navigate', direction)
} else if (event.key === 'Enter') {
event.preventDefault()
emit('activate')
}
}

/** Focus the text field. Used by the global shortcut; a no-op on the mobile button. */
function focus() {
inputRef.value?.focus()
}

defineExpose({ focus })
</script>

<style lang="scss" scoped>
Expand Down Expand Up @@ -257,6 +332,42 @@ function clearQuery() {
flex-shrink: 0;
margin-inline-end: 2px;
}

// Pinned to the trailing edge, overlaid on the input (pointer-events: none so a
// click there still focuses the field).
&__shortcut {
position: absolute;
inset-inline-end: var(--default-grid-baseline);
top: 50%;
transform: translateY(-50%);
display: flex;
gap: var(--default-grid-baseline);
pointer-events: none;

// On a narrow field the centred placeholder runs under the hint, so drop it
// below a usable width. Keyed to the field's own inline-size (its container),
// not the viewport, so it holds however crowded the header gets.
@container (max-width: 400px) {
display: none;
}

kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 12px;
height: 12px;
padding-inline: 5px;
border-radius: var(--border-radius-small, 4px);
border: 1px solid color-mix(in srgb, var(--color-background-plain-text) 20%, transparent);
border-block-end-width: 2px;
box-shadow: none;
color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain));
font-family: var(--font-face);
font-size: 13px;
line-height: 1;
}
}
}

// On dark themes the plain overlay is nearly invisible on the header, so tint
Expand Down
Loading
Loading