Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F2754272
typeahead.js
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
14 KB
Referenced Files
None
Subscribers
None
typeahead.js
View Options
const
SEARCH_LOADING_CLASS
=
'citizen-loading'
;
// Config object from getCitizenSearchResourceLoaderConfig()
const
config
=
require
(
'./config.json'
);
const
searchPresults
=
require
(
'./searchPresults.js'
)();
const
searchClient
=
require
(
'./searchClient.js'
)(
config
);
const
searchHistory
=
require
(
'./searchHistory.js'
)(
config
);
const
searchResults
=
require
(
'./searchResults.js'
)();
const
searchQuery
=
require
(
'./searchQuery.js'
)();
const
templateTypeaheadElement
=
require
(
'./templates/TypeaheadElement.mustache'
);
const
templateTypeaheadPlaceholder
=
require
(
'./templates/TypeaheadPlaceholder.mustache'
);
const
templateTypeaheadList
=
require
(
'./templates/TypeaheadList.mustache'
);
const
templateTypeaheadListItem
=
require
(
'./templates/TypeaheadListItem.mustache'
);
const
compiledTemplates
=
{};
const
typeahead
=
{
/** @type {HTMLElement | undefined} */
element
:
undefined
,
form
:
{
/** @type {HTMLFormElement | undefined} */
element
:
undefined
,
isLoading
:
false
,
init
:
function
(
formEl
)
{
const
typeaheadFormElement
=
formEl
;
this
.
element
=
typeaheadFormElement
;
typeaheadFormElement
.
setAttribute
(
'aria-owns'
,
typeahead
.
element
.
id
);
},
setLoadingState
:
function
(
state
)
{
this
.
element
.
classList
.
toggle
(
SEARCH_LOADING_CLASS
,
state
);
this
.
isLoading
=
state
;
}
},
input
:
{
/** @type {HTMLInputElement | undefined} */
element
:
undefined
,
displayElement
:
undefined
,
// Trigger update only when character is composed (e.g. CJK IME)
isComposing
:
false
,
init
:
function
(
inputEl
)
{
const
typeaheadInputElement
=
inputEl
;
this
.
element
=
typeaheadInputElement
;
const
wrapper
=
document
.
createElement
(
'div'
);
wrapper
.
classList
.
add
(
'citizen-typeahead-input-group'
);
typeaheadInputElement
.
parentNode
.
insertBefore
(
wrapper
,
typeaheadInputElement
);
const
overlay
=
document
.
createElement
(
'div'
);
overlay
.
classList
.
add
(
'citizen-typeahead-input-overlay'
);
this
.
displayElement
=
document
.
createElement
(
'span'
);
this
.
displayElement
.
textContent
=
typeaheadInputElement
.
value
;
this
.
displayElement
.
classList
.
add
(
'citizen-typeahead-input-overlay-query'
);
overlay
.
append
(
this
.
displayElement
);
typeaheadInputElement
.
classList
.
add
(
'citizen-typeahead-input'
);
typeaheadInputElement
.
setAttribute
(
'aria-autocomplete'
,
'list'
);
typeaheadInputElement
.
setAttribute
(
'aria-controls'
,
typeahead
.
element
.
id
);
wrapper
.
append
(
overlay
,
typeaheadInputElement
);
typeaheadInputElement
.
addEventListener
(
'focus'
,
this
.
onFocus
);
const
isVisible
=
typeaheadInputElement
.
offsetWidth
>
0
||
typeaheadInputElement
.
offsetHeight
>
0
;
const
isFocusable
=
!
typeaheadInputElement
.
disabled
&&
!
typeaheadInputElement
.
readOnly
;
if
(
isVisible
&&
isFocusable
)
{
requestAnimationFrame
(
()
=>
{
typeaheadInputElement
.
focus
();
}
);
}
},
onCompositionstart
:
function
()
{
typeahead
.
input
.
element
.
addEventListener
(
'compositionend'
,
typeahead
.
input
.
onCompositionend
);
typeahead
.
input
.
isComposing
=
true
;
},
onCompositionend
:
function
()
{
typeahead
.
input
.
isComposing
=
false
;
typeahead
.
input
.
element
.
dispatchEvent
(
new
Event
(
'input'
)
);
},
onFocus
:
function
()
{
const
typeaheadInputElement
=
typeahead
.
input
.
element
;
// Refresh the typeahead since the query will be emptied when blurred
typeahead
.
afterSearchQueryInput
();
typeahead
.
form
.
element
.
parentElement
.
classList
.
add
(
'citizen-search__card--expanded'
);
// FIXME: Should probably clean up this somehow
typeahead
.
element
.
addEventListener
(
'click'
,
typeahead
.
onClick
);
typeaheadInputElement
.
addEventListener
(
'keydown'
,
typeahead
.
input
.
onKeydown
);
typeaheadInputElement
.
addEventListener
(
'input'
,
typeahead
.
input
.
onInput
);
typeaheadInputElement
.
addEventListener
(
'blur'
,
typeahead
.
onBlur
);
},
onInput
:
function
()
{
const
typeaheadInputElement
=
typeahead
.
input
.
element
;
typeahead
.
input
.
displayElement
.
textContent
=
typeaheadInputElement
.
value
;
typeaheadInputElement
.
addEventListener
(
'compositionstart'
,
typeahead
.
input
.
onCompositionstart
);
if
(
typeahead
.
input
.
isComposing
!==
true
)
{
mw
.
util
.
debounce
(
typeahead
.
afterSearchQueryInput
(),
100
);
}
},
onKeydown
:
function
(
event
)
{
if
(
event
.
defaultPrevented
)
{
return
;
// Do nothing if the event was already processed
}
/* Moves the active item up and down */
if
(
event
.
key
===
'ArrowDown'
||
event
.
key
===
'ArrowUp'
)
{
event
.
preventDefault
();
if
(
event
.
key
===
'ArrowDown'
)
{
typeahead
.
items
.
increment
(
1
);
}
else
{
typeahead
.
items
.
increment
(
-
1
);
}
typeahead
.
items
.
toggle
(
typeahead
.
items
.
elements
[
typeahead
.
items
.
index
]
);
}
/* Enter to click on the active item */
const
link
=
typeahead
.
items
.
elements
[
typeahead
.
items
.
index
];
if
(
event
.
key
===
'Enter'
&&
link
&&
link
instanceof
HTMLAnchorElement
)
{
event
.
preventDefault
();
link
.
click
();
}
}
},
items
:
{
/** @type {NodeList | undefined} */
elements
:
undefined
,
index
:
-
1
,
max
:
0
,
setMax
:
function
(
x
)
{
this
.
max
=
x
;
},
increment
:
function
(
i
)
{
this
.
index
+=
i
;
if
(
this
.
index
<
0
)
{
this
.
setIndex
(
this
.
max
-
1
);
}
// Index reaches top
if
(
this
.
index
===
this
.
max
)
{
this
.
setIndex
(
0
);
}
// Index reaches bottom
return
this
.
index
;
},
setIndex
:
function
(
i
)
{
if
(
i
<=
this
.
max
-
1
)
{
this
.
index
=
i
;
}
return
this
.
index
;
},
clearIndex
:
function
()
{
this
.
setIndex
(
-
1
);
},
/**
* Sets 'citizen-typeahead__item--active' class on the element
*
* @param {HTMLElement} item
*/
toggle
:
function
(
item
)
{
this
.
elements
.
forEach
(
(
element
,
index
)
=>
{
if
(
item
!==
element
)
{
delete
element
.
dataset
.
mwTypeaheadSelected
;
}
else
{
if
(
item
.
dataset
.
mwTypeaheadSelected
)
{
delete
item
.
dataset
.
mwTypeaheadSelected
;
}
else
{
item
.
dataset
.
mwTypeaheadSelected
=
''
;
typeahead
.
input
.
element
.
setAttribute
(
'aria-activedescendant'
,
item
.
id
);
this
.
setIndex
(
index
);
}
}
}
);
},
// So that mouse hover events are the same as keyboard hover events
bindMouseHoverEvent
:
function
()
{
this
.
elements
.
forEach
(
(
element
)
=>
{
element
.
addEventListener
(
'mouseenter'
,
(
event
)
=>
{
this
.
toggle
(
event
.
currentTarget
);
}
);
element
.
addEventListener
(
'mouseleave'
,
(
event
)
=>
{
this
.
toggle
(
event
.
currentTarget
);
}
);
}
);
},
set
:
function
()
{
const
typeaheadElement
=
typeahead
.
element
;
this
.
elements
=
typeaheadElement
.
querySelectorAll
(
'.citizen-typeahead-group[data-mw-typeahead-keyboard-navigation] .citizen-typeahead-list-item-link'
);
this
.
bindMouseHoverEvent
();
this
.
setMax
(
this
.
elements
.
length
);
}
},
onBlur
:
function
(
event
)
{
const
typeaheadElement
=
typeahead
.
element
;
const
typeaheadInputElement
=
typeahead
.
input
.
element
;
if
(
!
typeaheadElement
.
contains
(
event
.
relatedTarget
)
)
{
// HACK: On Safari, users are unable to click any links because the blur
// event dismiss the links before it is clicked. This should fix it.
setTimeout
(
()
=>
{
typeahead
.
form
.
element
.
parentElement
.
classList
.
remove
(
'citizen-search__card--expanded'
);
typeaheadInputElement
.
setAttribute
(
'aria-activedescendant'
,
''
);
typeaheadElement
.
removeEventListener
(
'click'
,
typeahead
.
onClick
);
typeaheadInputElement
.
removeEventListener
(
'keydown'
,
typeahead
.
input
.
onKeydown
);
// input listener need to stay on to make clear button works
// typeaheadInputElement.removeEventListener( 'input', typeahead.input.onInput );
typeaheadInputElement
.
removeEventListener
(
'compositionstart'
,
typeahead
.
input
.
onCompositionstart
);
typeaheadInputElement
.
removeEventListener
(
'compositionend'
,
typeahead
.
input
.
onCompositionend
);
typeaheadInputElement
.
removeEventListener
(
'blur'
,
this
.
onBlur
);
},
10
);
}
},
onClick
:
function
(
event
)
{
// Extra safety so closest won't tranverse out of the typeahead
if
(
typeahead
.
element
.
contains
(
event
.
target
)
)
{
const
link
=
event
.
target
.
closest
(
'.citizen-typeahead-list-item-link'
);
// Early exit if target is not a link
if
(
!
link
)
{
return
;
}
const
group
=
event
.
target
.
closest
(
'.citizen-typeahead-group'
);
// Save to history on click
const
historyType
=
group
.
dataset
.
mwTypeaheadHistoryValue
;
if
(
historyType
)
{
let
historyText
;
if
(
historyType
===
'query'
)
{
historyText
=
searchQuery
.
value
;
}
else
{
const
historyTextEl
=
link
.
querySelector
(
`.citizen-typeahead-list-item-
${
historyType
}
`
);
if
(
historyTextEl
&&
historyTextEl
.
innerText
)
{
historyText
=
historyTextEl
.
innerText
;
}
}
if
(
historyText
)
{
searchHistory
.
add
(
historyText
);
}
}
}
},
updateSearchClient
:
function
()
{
const
typeaheadInputElement
=
typeahead
.
input
.
element
;
searchClient
.
setActive
(
config
.
wgCitizenSearchGateway
);
// Search command experiement
if
(
typeaheadInputElement
.
value
.
startsWith
(
'/'
)
)
{
const
command
=
typeaheadInputElement
.
value
.
split
(
' '
)[
0
].
slice
(
1
);
if
(
command
.
length
>
0
)
{
const
searchClientData
=
searchClient
.
getData
(
'command'
,
command
);
// Multi-search clients experiment
if
(
searchClientData
)
{
searchClient
.
setActive
(
searchClientData
.
id
);
searchQuery
.
remove
(
`/
${
command
}
`
);
}
}
}
return
Promise
.
resolve
(
`Search client updated to
${
searchClient
.
active
.
id
}
.`
);
},
updateSearchQuery
:
function
()
{
const
currentQuery
=
typeahead
.
input
.
element
.
value
;
if
(
searchQuery
.
value
===
currentQuery
)
{
return
Promise
.
reject
(
`Search query has not changed:
${
searchQuery
.
value
}
.`
);
}
searchQuery
.
setValue
(
currentQuery
);
typeahead
.
updateSearchClient
();
// TODO: Merge this with the search client command and put this somewhere else
const
replaceRules
=
[
{
startWith
:
'{{'
,
pattern
:
/{{(.[^}]*)}?}?/
,
replace
:
'Template:$1'
,
clients
:
[
'mwActionApi'
,
'mwRestApi'
]
},
{
startWith
:
'[['
,
pattern
:
/\[\[(.[^\]]*)\]?\]?/
,
replace
:
'$1'
,
clients
:
[
'mwActionApi'
,
'mwRestApi'
]
}
];
replaceRules
.
forEach
(
(
rule
)
=>
{
if
(
rule
.
clients
.
includes
(
searchClient
.
active
.
id
)
&&
searchQuery
.
value
.
startsWith
(
rule
.
startWith
)
)
{
searchQuery
.
replace
(
rule
.
pattern
,
rule
.
replace
);
}
}
);
return
Promise
.
resolve
(
`Search query updated to
${
searchQuery
.
value
}
.`
);
},
afterSearchQueryInput
:
function
()
{
typeahead
.
updateSearchQuery
().
then
(
updateTypeaheadItems
)
.
catch
(
()
=>
{
// Don't do anything if search query has not changed.
}
);
},
init
:
function
(
formEl
,
inputEl
)
{
// Compile Mustache templates
// TODO: Find better way to handle this
this
.
mustacheCompiler
=
mw
.
template
.
getCompiler
(
'mustache'
);
Object
.
assign
(
compiledTemplates
,
{
TypeaheadElement
:
this
.
mustacheCompiler
.
compile
(
templateTypeaheadElement
),
TypeaheadPlaceholder
:
this
.
mustacheCompiler
.
compile
(
templateTypeaheadPlaceholder
),
TypeaheadList
:
this
.
mustacheCompiler
.
compile
(
templateTypeaheadList
),
TypeaheadListItem
:
this
.
mustacheCompiler
.
compile
(
templateTypeaheadListItem
)
}
);
const
data
=
{
'data-placeholder'
:
{
hidden
:
true
},
'array-lists'
:
[
{
type
:
'action'
,
class
:
'citizen-typeahead-group--chips'
,
hidden
:
true
,
historyValue
:
'query'
},
{
type
:
'history'
,
hidden
:
true
,
keyboardNavigation
:
true
},
{
type
:
'page'
,
hidden
:
true
,
keyboardNavigation
:
true
,
historyValue
:
'title'
}
]
};
const
partials
=
{
TypeaheadPlaceholder
:
compiledTemplates
.
TypeaheadPlaceholder
,
TypeaheadList
:
compiledTemplates
.
TypeaheadList
};
this
.
element
=
compiledTemplates
.
TypeaheadElement
.
render
(
data
,
partials
).
get
()[
0
];
formEl
.
after
(
this
.
element
);
this
.
form
.
init
(
formEl
);
this
.
input
.
init
(
inputEl
);
searchHistory
.
init
();
searchResults
.
init
();
searchPresults
.
render
(
compiledTemplates
);
// Init the value in case of undef error
typeahead
.
items
.
set
();
// Run once in case there is searchQuery before eventlistener is attached
if
(
this
.
input
.
element
.
value
.
length
>
0
)
{
this
.
afterSearchQueryInput
();
}
}
};
/**
* Fetch suggestions from API and render the suggetions in HTML
*
*/
// eslint-disable-next-line es-x/no-async-functions
async
function
getSuggestions
()
{
const
typeaheadInputElement
=
typeahead
.
input
.
element
;
const
renderSuggestions
=
(
results
)
=>
{
const
groupEl
=
document
.
getElementById
(
'citizen-typeahead-group-page'
);
const
listEl
=
document
.
getElementById
(
'citizen-typeahead-list-page'
);
const
placeholderEl
=
document
.
getElementById
(
'citizen-typeahead-placeholder'
);
if
(
results
.
length
>
0
)
{
// TODO: This should be a generic method
listEl
.
outerHTML
=
searchResults
.
getResultsHTML
(
results
,
searchQuery
.
valueHtml
,
compiledTemplates
);
groupEl
.
hidden
=
false
;
placeholderEl
.
innerHTML
=
''
;
placeholderEl
.
hidden
=
true
;
}
else
{
// Update placeholder with no result content
listEl
.
innerHTML
=
''
;
groupEl
.
hidden
=
true
;
placeholderEl
.
innerHTML
=
searchResults
.
getPlaceholderHTML
(
searchQuery
.
valueHtml
,
compiledTemplates
);
placeholderEl
.
hidden
=
false
;
}
typeahead
.
form
.
setLoadingState
(
false
);
typeahead
.
items
.
set
();
};
// Add loading animation
typeahead
.
form
.
setLoadingState
(
true
);
const
{
abort
,
fetch
}
=
searchResults
.
fetch
(
searchQuery
.
value
,
searchClient
.
active
.
client
);
const
inputEventListener
=
()
=>
{
abort
();
typeaheadInputElement
.
removeEventListener
(
'input'
,
inputEventListener
);
};
typeaheadInputElement
.
addEventListener
(
'input'
,
inputEventListener
,
{
once
:
true
}
);
try
{
const
response
=
await
fetch
;
renderSuggestions
(
response
.
results
);
}
catch
(
error
)
{
typeahead
.
form
.
setLoadingState
(
false
);
// User can trigger the abort when the fetch event is pending
// There is no need for an error
if
(
error
.
name
!==
'AbortError'
)
{
const
message
=
`Uh oh, a wild error appears!
${
error
}
`
;
throw
new
Error
(
message
);
}
}
}
/**
* Update the typeahead element
*
*/
function
updateTypeaheadItems
()
{
typeahead
.
input
.
element
.
setAttribute
(
'aria-activedescendant'
,
''
);
typeahead
.
items
.
clearIndex
();
if
(
searchQuery
.
isValid
)
{
searchPresults
.
clear
();
searchResults
.
render
(
searchQuery
,
compiledTemplates
);
getSuggestions
();
}
else
{
searchResults
.
clear
();
searchPresults
.
render
(
compiledTemplates
);
}
typeahead
.
items
.
set
();
}
/**
* @param {HTMLFormElement} formEl
* @param {HTMLInputElement} inputEl
*/
function
initTypeahead
(
formEl
,
inputEl
)
{
typeahead
.
init
(
formEl
,
inputEl
);
}
module
.
exports
=
{
init
:
initTypeahead
};
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Jul 3, 22:23 (1 m, 2 s ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
58/f9/e91730fe82b748459f627953f765
Default Alt Text
typeahead.js (14 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment