MediaWiki:Gadget-tagautocomplete.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary |
Previous Version did not work - hopefully this one does. |
||
| Line 1: | Line 1: | ||
/* ============================================================================= | /* ============================================================================= | ||
Gadget-tagautocomplete.js | Gadget-tagautocomplete.js | ||
Tokenized tag input with autocomplete on Submit Project / Tutorial forms. | |||
Pulls existing tags from the Projects and Tutorials Cargo tables. | |||
============================================================================= */ | ============================================================================= */ | ||
( function () { | ( function () { | ||
// Only run on Special:FormEdit/* pages | |||
if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'FormEdit' ) { return; } | |||
$( function () { | $( function () { | ||
// ── Find the tags input | // ── Find the tags input by name attribute ──────────────────────────── | ||
// Page Forms | // Page Forms names inputs as TemplateName[fieldname] — target [tags] | ||
var $raw = $( 'input | // specifically. We use .filter() because jQuery CSS attribute selectors | ||
var | // require the attribute to be explicitly present in the HTML; Page Forms | ||
return | // does not write type="text" on its inputs, so [type="text"] never matches. | ||
var $raw = $( 'input' ).filter( function () { | |||
var name = $( this ).attr( 'name' ) || ''; | |||
return name === 'ProjectMeta[tags]' || name === 'TutorialMeta[tags]'; | |||
} ); | } ); | ||
if ( !$raw.length ) { return; } | if ( !$raw.length ) { return; } | ||
| Line 23: | Line 26: | ||
var allTags = []; | var allTags = []; | ||
function fetchTags( table | function fetchTags( table ) { | ||
return $.getJSON( mw.util.wikiScript( 'api' ), { | return $.getJSON( mw.util.wikiScript( 'api' ), { | ||
action | action : 'cargoquery', | ||
tables | tables : table, | ||
fields | fields : 'tags', | ||
limit | limit : 500, | ||
format | format : 'json' | ||
} ).then( function ( data ) { | } ).then( function ( data ) { | ||
( data.cargoquery || [] ).forEach( function ( r ) { | ( data.cargoquery || [] ).forEach( function ( r ) { | ||
( r.title | ( r.title.tags || '' ).split( ',' ).forEach( function ( t ) { | ||
t = t.trim().toLowerCase(); | t = t.trim().toLowerCase(); | ||
if ( t && allTags.indexOf( t ) === -1 ) { | if ( t && allTags.indexOf( t ) === -1 ) { | ||
| Line 43: | Line 46: | ||
$.when( | $.when( | ||
fetchTags( 'Projects | fetchTags( 'Projects' ), | ||
fetchTags( 'Tutorials | fetchTags( 'Tutorials' ) | ||
).then( function () { | ).then( function () { | ||
allTags.sort(); | allTags.sort(); | ||
| Line 54: | Line 57: | ||
// ── Tokenizer ──────────────────────────────────────────────────────── | // ── Tokenizer ──────────────────────────────────────────────────────── | ||
function initTokenizer( $input ) { | function initTokenizer( $input ) { | ||
$input.hide(); | $input.hide(); | ||
var $wrap = $( '<div>' ).addClass( 'tag-tokenizer' ).insertAfter( $input ); | var $wrap = $( '<div>' ).addClass( 'tag-tokenizer' ).insertAfter( $input ); | ||
var $box | var $box = $( '<div>' ).addClass( 'tag-tokenizer-box' ).appendTo( $wrap ); | ||
var $entry = $( '<input>' ).attr( { | var $entry = $( '<input>' ).attr( { | ||
type : 'text', | type : 'text', | ||
placeholder : 'Type a tag and press Enter or comma\u2026', | placeholder : 'Type a tag and press Enter or comma\u2026', | ||
| Line 66: | Line 68: | ||
var $dropdown = $( '<ul>' ).addClass( 'tag-tokenizer-dropdown' ).hide().appendTo( $wrap ); | var $dropdown = $( '<ul>' ).addClass( 'tag-tokenizer-dropdown' ).hide().appendTo( $wrap ); | ||
// Seed from | // Seed from existing hidden input value (edit mode) | ||
var existing = ( $input.val() || '' ).split( ',' ).map( function ( t ) { return t.trim(); } ).filter( Boolean ); | var existing = ( $input.val() || '' ).split( ',' ) | ||
existing.forEach( | .map( function ( t ) { return t.trim(); } ) | ||
.filter( Boolean ); | |||
existing.forEach( addToken ); | |||
syncHidden(); | syncHidden(); | ||
function addToken( tag ) { | function addToken( tag ) { | ||
tag = tag.trim().toLowerCase().replace( /\s+/g, '-' ); | tag = tag.trim().toLowerCase().replace( /\s+/g, '-' ); | ||
if ( !tag ) { return; } | if ( !tag ) { return; } | ||
if ( $box.find( '.tag-token' ).filter( function () { | if ( $box.find( '.tag-token' ).filter( function () { | ||
return $( this ).data( 'tag' ) === tag; | return $( this ).data( 'tag' ) === tag; | ||
| Line 81: | Line 83: | ||
var $token = $( '<span>' ).addClass( 'tag-token' ).text( tag ).attr( 'data-tag', tag ); | var $token = $( '<span>' ).addClass( 'tag-token' ).text( tag ).attr( 'data-tag', tag ); | ||
var $x = $( '<button>' ).attr( 'type', 'button' ).addClass( 'tag-token-remove' ).text( '\u00d7' ) | var $x = $( '<button>' ) | ||
.attr( 'type', 'button' ) | |||
.addClass( 'tag-token-remove' ) | |||
.text( '\u00d7' ) | |||
.on( 'click', function () { | |||
$token.remove(); | |||
$token.insertBefore( $entry ); | syncHidden(); | ||
} ); | |||
$token.append( $x ).insertBefore( $entry ); | |||
syncHidden(); | syncHidden(); | ||
} | } | ||
| Line 99: | Line 103: | ||
} | } | ||
function showDropdown( query ) { | function showDropdown( query ) { | ||
var | var active = []; | ||
$box.find( '.tag-token' ).each( function () { | $box.find( '.tag-token' ).each( function () { | ||
active.push( $( this ).data( 'tag' ) ); | |||
} ); | } ); | ||
var matches = allTags.filter( function ( t ) { | var matches = allTags.filter( function ( t ) { | ||
return t.indexOf( query ) === 0 && | return t.indexOf( query ) === 0 && active.indexOf( t ) === -1; | ||
} ).slice( 0, 8 ); | } ).slice( 0, 8 ); | ||
| Line 115: | Line 118: | ||
matches.forEach( function ( tag ) { | matches.forEach( function ( tag ) { | ||
$( '<li>' ).text( tag ).on( 'mousedown', function ( e ) { | $( '<li>' ).text( tag ).on( 'mousedown', function ( e ) { | ||
e.preventDefault(); | e.preventDefault(); | ||
addToken( tag ); | addToken( tag ); | ||
$entry.val( '' ); | $entry.val( '' ); | ||
| Line 124: | Line 127: | ||
} | } | ||
$entry.on( 'input', function () { | $entry.on( 'input', function () { | ||
var val = $entry.val(); | var val = $entry.val(); | ||
if ( /,$/.test( val ) ) { | |||
if ( / | |||
addToken( val.replace( /,$/, '' ) ); | addToken( val.replace( /,$/, '' ) ); | ||
$entry.val( '' ); | $entry.val( '' ); | ||
| Line 139: | Line 140: | ||
$entry.on( 'keydown', function ( e ) { | $entry.on( 'keydown', function ( e ) { | ||
if ( e.key === 'Enter' ) { | if ( e.key === 'Enter' ) { | ||
e.preventDefault(); | e.preventDefault(); | ||
var first = $dropdown.find( 'li' | var first = $dropdown.find( 'li:first' ).text(); | ||
addToken( first || $entry.val() ); | addToken( first || $entry.val() ); | ||
$entry.val( '' ); | $entry.val( '' ); | ||
$dropdown.hide(); | $dropdown.hide(); | ||
} | } | ||
if ( e.key === 'Backspace' && $entry.val() === '' ) { | if ( e.key === 'Backspace' && $entry.val() === '' ) { | ||
$box.find( '.tag-token' ).last().remove(); | $box.find( '.tag-token' ).last().remove(); | ||
| Line 156: | Line 155: | ||
$entry.on( 'blur', function () { | $entry.on( 'blur', function () { | ||
setTimeout( function () { $dropdown.hide(); }, 150 ); | setTimeout( function () { $dropdown.hide(); }, 150 ); | ||
var val = $entry.val().trim(); | var val = $entry.val().trim(); | ||
if ( val ) { addToken( val ); $entry.val( '' ); } | if ( val ) { addToken( val ); $entry.val( '' ); } | ||
Latest revision as of 09:22, 5 April 2026
/* =============================================================================
Gadget-tagautocomplete.js
Tokenized tag input with autocomplete on Submit Project / Tutorial forms.
Pulls existing tags from the Projects and Tutorials Cargo tables.
============================================================================= */
( function () {
// Only run on Special:FormEdit/* pages
if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'FormEdit' ) { return; }
$( function () {
// ── Find the tags input by name attribute ────────────────────────────
// Page Forms names inputs as TemplateName[fieldname] — target [tags]
// specifically. We use .filter() because jQuery CSS attribute selectors
// require the attribute to be explicitly present in the HTML; Page Forms
// does not write type="text" on its inputs, so [type="text"] never matches.
var $raw = $( 'input' ).filter( function () {
var name = $( this ).attr( 'name' ) || '';
return name === 'ProjectMeta[tags]' || name === 'TutorialMeta[tags]';
} );
if ( !$raw.length ) { return; }
// ── Load all existing tags from Cargo ────────────────────────────────
var allTags = [];
function fetchTags( table ) {
return $.getJSON( mw.util.wikiScript( 'api' ), {
action : 'cargoquery',
tables : table,
fields : 'tags',
limit : 500,
format : 'json'
} ).then( function ( data ) {
( data.cargoquery || [] ).forEach( function ( r ) {
( r.title.tags || '' ).split( ',' ).forEach( function ( t ) {
t = t.trim().toLowerCase();
if ( t && allTags.indexOf( t ) === -1 ) {
allTags.push( t );
}
} );
} );
} );
}
$.when(
fetchTags( 'Projects' ),
fetchTags( 'Tutorials' )
).then( function () {
allTags.sort();
$raw.each( function () {
initTokenizer( $( this ) );
} );
} );
// ── Tokenizer ────────────────────────────────────────────────────────
function initTokenizer( $input ) {
$input.hide();
var $wrap = $( '<div>' ).addClass( 'tag-tokenizer' ).insertAfter( $input );
var $box = $( '<div>' ).addClass( 'tag-tokenizer-box' ).appendTo( $wrap );
var $entry = $( '<input>' ).attr( {
type : 'text',
placeholder : 'Type a tag and press Enter or comma\u2026',
autocomplete: 'off'
} ).addClass( 'tag-tokenizer-entry' ).appendTo( $box );
var $dropdown = $( '<ul>' ).addClass( 'tag-tokenizer-dropdown' ).hide().appendTo( $wrap );
// Seed from existing hidden input value (edit mode)
var existing = ( $input.val() || '' ).split( ',' )
.map( function ( t ) { return t.trim(); } )
.filter( Boolean );
existing.forEach( addToken );
syncHidden();
function addToken( tag ) {
tag = tag.trim().toLowerCase().replace( /\s+/g, '-' );
if ( !tag ) { return; }
if ( $box.find( '.tag-token' ).filter( function () {
return $( this ).data( 'tag' ) === tag;
} ).length ) { return; }
var $token = $( '<span>' ).addClass( 'tag-token' ).text( tag ).attr( 'data-tag', tag );
var $x = $( '<button>' )
.attr( 'type', 'button' )
.addClass( 'tag-token-remove' )
.text( '\u00d7' )
.on( 'click', function () {
$token.remove();
syncHidden();
} );
$token.append( $x ).insertBefore( $entry );
syncHidden();
}
function syncHidden() {
var tags = [];
$box.find( '.tag-token' ).each( function () {
tags.push( $( this ).data( 'tag' ) );
} );
$input.val( tags.join( ', ' ) );
}
function showDropdown( query ) {
var active = [];
$box.find( '.tag-token' ).each( function () {
active.push( $( this ).data( 'tag' ) );
} );
var matches = allTags.filter( function ( t ) {
return t.indexOf( query ) === 0 && active.indexOf( t ) === -1;
} ).slice( 0, 8 );
$dropdown.empty();
if ( !matches.length ) { $dropdown.hide(); return; }
matches.forEach( function ( tag ) {
$( '<li>' ).text( tag ).on( 'mousedown', function ( e ) {
e.preventDefault();
addToken( tag );
$entry.val( '' );
$dropdown.hide();
} ).appendTo( $dropdown );
} );
$dropdown.show();
}
$entry.on( 'input', function () {
var val = $entry.val();
if ( /,$/.test( val ) ) {
addToken( val.replace( /,$/, '' ) );
$entry.val( '' );
$dropdown.hide();
return;
}
var q = val.trim().toLowerCase();
if ( q.length >= 1 ) { showDropdown( q ); } else { $dropdown.hide(); }
} );
$entry.on( 'keydown', function ( e ) {
if ( e.key === 'Enter' ) {
e.preventDefault();
var first = $dropdown.find( 'li:first' ).text();
addToken( first || $entry.val() );
$entry.val( '' );
$dropdown.hide();
}
if ( e.key === 'Backspace' && $entry.val() === '' ) {
$box.find( '.tag-token' ).last().remove();
syncHidden();
}
} );
$entry.on( 'blur', function () {
setTimeout( function () { $dropdown.hide(); }, 150 );
var val = $entry.val().trim();
if ( val ) { addToken( val ); $entry.val( '' ); }
} );
$box.on( 'click', function () { $entry.trigger( 'focus' ); } );
}
} );
}() );