mediawiki-skins-Vector/resources/skins.vector.search/App.vue
Jon Robson 98bee1c71f Allow gadget/browser extension extensibility of empty search state
Add hook that renders when search displays for first time to allow
experimentation.

Code can call
```
mw.hook( 'search.display' ).add((node)=> { node.innerHTML = 'hello world' } );

Bug: T371294
Change-Id: Ib3ec73b8ed66877c11e0d8d290a6b564a013702b
2024-08-08 21:19:27 +01:00

281 lines
6.5 KiB
Vue

<template>
<cdx-typeahead-search
:id="id"
ref="searchForm"
class="vector-typeahead-search"
:class="rootClasses"
:search-results-label="$i18n( 'searchresults' ).text()"
:accesskey="searchAccessKey"
:autocapitalize="autocapitalizeValue"
:title="searchTitle"
:placeholder="searchPlaceholder"
:aria-label="searchPlaceholder"
:initial-input-value="searchQuery"
:button-label="$i18n( 'searchbutton' ).text()"
:form-action="action"
:show-thumbnail="showThumbnail"
:highlight-query="highlightQuery"
:auto-expand-width="autoExpandWidth"
:search-results="suggestions"
:search-footer-url="searchFooterUrl"
:visible-item-limit="visibleItemLimit"
@load-more="onLoadMore"
@input="onInput"
@search-result-click="instrumentation.onSuggestionClick"
@submit="onSubmit"
@focus="onFocus"
@blur="onBlur"
>
<template #default>
<div ref="emptyState">empty</div>
<input
type="hidden"
name="title"
:value="searchPageTitle"
>
<input
type="hidden"
name="wprov"
:value="wprov"
>
</template>
<template #search-results-pending>
{{ $i18n( 'vector-search-loader' ).text() }}
</template>
<!-- eslint-disable-next-line vue/no-template-shadow -->
<template #search-footer-text="{ searchQuery }">
<span v-i18n-html:vector-searchsuggest-containing="[ searchQuery ]"></span>
</template>
</cdx-typeahead-search>
</template>
<script>
const { CdxTypeaheadSearch } = mw.loader.require( 'skins.vector.search.codex.scripts' ),
{ defineComponent, nextTick } = require( 'vue' ),
client = require( './restSearchClient.js' ),
restClient = client( mw.config ),
urlGenerator = require( './urlGenerator.js' )( mw.config ),
instrumentation = require( './instrumentation.js' );
// @vue/component
module.exports = exports = defineComponent( {
name: 'App',
compatConfig: {
MODE: 3
},
compilerOptions: {
whitespace: 'condense'
},
components: { CdxTypeaheadSearch },
props: {
id: {
type: String,
required: true
},
autocapitalizeValue: {
type: String,
default: undefined
},
searchPageTitle: {
type: String,
default: 'Special:Search'
},
autofocusInput: {
type: Boolean,
default: false
},
action: {
type: String,
default: ''
},
/** The keyboard shortcut to focus search. */
searchAccessKey: {
type: String,
default: undefined
},
/** The access key informational tip for search. */
searchTitle: {
type: String,
default: undefined
},
/** The ghost text shown when no search query is entered. */
searchPlaceholder: {
type: String,
default: undefined
},
/**
* The search query string taken from the server-side rendered input immediately before
* client render.
*/
searchQuery: {
type: String,
default: undefined
},
showThumbnail: {
type: Boolean,
required: true,
default: false
},
showDescription: {
type: Boolean,
default: false
},
highlightQuery: {
type: Boolean,
default: false
},
autoExpandWidth: {
type: Boolean,
default: false
}
},
data() {
return {
// -1 here is the default "active suggestion index".
wprov: instrumentation.getWprovFromResultIndex( -1 ),
// Suggestions to be shown in the TypeaheadSearch menu.
suggestions: [],
// Link to the search page for the current search query.
searchFooterUrl: '',
// The current search query. Used to detect whether a fetch response is stale.
currentSearchQuery: '',
// Whether to apply a CSS class that disables the CSS transitions on the text input
disableTransitions: this.autofocusInput,
instrumentation: instrumentation.listeners,
isFocused: false
};
},
computed: {
rootClasses() {
return {
'vector-search-box-disable-transitions': this.disableTransitions,
'vector-typeahead-search--active': this.isFocused
};
},
visibleItemLimit() {
// if the search client supports loading more results,
// show 7 out of 10 results at first (arbitrary number),
// so that scroll events are fired and trigger onLoadMore()
return restClient.loadMore ? 7 : null;
}
},
methods: {
/**
* Fetch suggestions when new input is received.
*
* @param {string} value
*/
onInput: function ( value ) {
const query = value.trim();
this.currentSearchQuery = query;
if ( query === '' ) {
this.suggestions = [];
this.searchFooterUrl = '';
return;
}
this.updateUIWithSearchClientResult(
restClient.fetchByTitle( query, 10, this.showDescription ),
true
);
this.$refs.emptyState.innerHTML = '';
},
/**
* Fetch additional suggestions.
*
* This should only be called if visibleItemLimit is non-null,
* i.e. if the search client supports loading more results.
*/
onLoadMore() {
if ( !restClient.loadMore ) {
mw.log.warn( 'onLoadMore() should not have been called for this search client' );
return;
}
this.updateUIWithSearchClientResult(
restClient.loadMore(
this.currentSearchQuery,
this.suggestions.length,
10,
this.showDescription
),
false
);
},
/**
* @param {AbortableSearchFetch} search
* @param {boolean} replaceResults
*/
updateUIWithSearchClientResult( search, replaceResults ) {
const query = this.currentSearchQuery;
instrumentation.listeners.onFetchStart();
search.fetch
.then( ( data ) => {
// Only use these results if they're still relevant
// If currentSearchQuery !== query, these results are for a previous search
// and we shouldn't show them.
if ( this.currentSearchQuery === query ) {
if ( replaceResults ) {
this.suggestions = [];
}
this.suggestions.push(
...instrumentation.addWprovToSearchResultUrls(
data.results, this.suggestions.length
)
);
this.searchFooterUrl = urlGenerator.generateUrl( query );
}
const event = {
numberOfResults: data.results.length,
query: query
};
instrumentation.listeners.onFetchEnd( event );
} )
.catch( () => {
// TODO: error handling
} );
},
/**
* @param {SearchSubmitEvent} event
*/
onSubmit( event ) {
this.wprov = instrumentation.getWprovFromResultIndex( event.index );
instrumentation.listeners.onSubmit( event );
},
onFocus() {
this.isFocused = true;
},
onBlur() {
this.isFocused = false;
}
},
mounted() {
if ( this.autofocusInput ) {
this.$refs.searchForm.focus();
nextTick( () => {
this.disableTransitions = false;
} );
}
if ( this.$refs.emptyState ) {
mw.hook( 'search.display' ).fire( this.$refs.emptyState );
}
}
} );
</script>