The new #Vue.js-Search experience will be a composition of several components. Most probably including something like the following:
- **Button**
- **Input**
- **Icon**
- **Thumbnail**
- **Card**
- **Spinner**
- **Divider**
- List
- ListItem
- Language switcher
- Typeahead input
- Typeahead search
//At least// the emphasized ones seem so commonplace that they surely have been written in some form already at Wikimedia. This task seeks to identify the baseline chosen to start work on those universal usefully components.
#Vue.js-Search has both a tight window for development and a responsibility to inform future work. The project must strike the right balance between shipping for the specific use case, which will help prove out the technical integration with MediaWiki for stakeholders and unlock additional capacities for migration, and architectural polish. What can we do now to set ourselves up for success in both the near and long terms?
Component baseline choice does impact styling but this task is //focused// on component structure and scripts. The styling could be ripped out of the component baseline and class names adjusted. Identifying the styling baseline is T253953.
=== Motivations
- Unblock contribution paths for Vue.js search and other contributors.
- Build on what we have or at least the lessons learned.
- Try not to needlessly repeat efforts.
- Focus on the specific use cases needed for search but allow for polished "ideal" components to be replaced as other migrations contribute. For example, if #Vue.js-Search is using the OkShippableButton for the next few weeks and MachineVision builds the PerfectButton in the course of their work, it would be excellent to be able to replace the former with the latter.
- Ship on time.
- Inform future work.
=== Approach
We can choose any of the following approaches:
- Verbatim: file copy from the chosen baseline to the library.
- "Inspired by": copy as much as possible to the new library.
- Blank slate: start from nothing.
This task is bold enough to posit that a middle-ground is probably most appropriate for the motivations identified. For example, if ContentTranslation's button was copied into the new library, EventEmittingButton from WMDE or Button from MachineVision could have significant influence on subsequent improvements.
== Options
What options are we considering?
- [[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/ContentTranslation/+/master/app/src/lib/mediawiki.ui/components | ContentTranslation ]]
- WMDE ([[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/Wikibase/+/master/client/data-bridge/src/presentation/components | Wikidata Bridge ]], [[ https://gerrit.wikimedia.org/r/plugins/gitiles/wikibase/vuejs-components/+/master/src/components | vuejs-components ]], [[ https://github.com/wikimedia/wikibase-termbox/tree/master/src/components | wikibase-termbox ]], [[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/Wikibase/+/master/view/lib/wikibase-tainted-ref/src/presentation/components | Tainted references ]])
- [[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/MachineVision/+/master/resources/components/ | MachineVision ]]
- [[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/NearbyPages/+/master/resources/ext.nearby.scripts/ | NearbyPages ]]
- ...?
=== Examples
The following examples of a button primitive.
==== ContentTranslation
[[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/ContentTranslation/+/master/app/src/lib/mediawiki.ui/components/MWButton.vue | Button.vue ]]
```lines=15, lang=html, name=ContentTranslation
<template>
<component
:class="classes"
:is="component"
:id="id"
:href="href"
:disabled="disabled"
@click="handleClick"
>
<mw-icon
v-if="icon"
:icon="icon"
:size="large ? 28 : iconSize"
class="mw-ui-button__icon me-2"
></mw-icon>
<slot>
<span
v-if="type !== icon && label"
v-text="label"
class="mw-ui-button__label"
/>
</slot>
<mw-icon
v-if="indicator"
:icon="indicator"
:size="large ? 28 : indicatorSize || iconSize"
class="mw-ui-button__indicator ms-2"
></mw-icon>
</component>
</template>
<script>
import MwIcon from "./MWIcon";
export default {
name: "mw-button",
components: {
MwIcon
},
props: {
id: String,
label: String,
disabled: Boolean,
depressed: Boolean,
block: Boolean,
large: Boolean,
icon: String,
iconSize: {
type: [Number, String]
},
indicatorSize: {
type: [Number, String]
},
indicator: String,
href: String,
accessKey: String,
outlined: Boolean,
progressive: Boolean,
destructive: Boolean,
type: {
type: String,
default: "button",
validator: value => {
// The value must match one of these strings
return ["button", "toggle", "icon", "text"].indexOf(value) !== -1;
}
}
},
computed: {
component() {
if (this.href) {
return "a";
} else {
return "button";
}
},
classes() {
return {
"mw-ui-button": true,
"mw-ui-button--block": this.block,
"mw-ui-button--depressed": this.depressed || this.outlined,
"mw-ui-button--disabled": this.disabled,
"mw-ui-button--fab": this.fab,
"mw-ui-button--large": this.large,
"mw-ui-button--progressive": this.progressive,
"mw-ui-button--destructive": this.destructive,
"mw-ui-button--icon": this.type === "icon",
"mw-ui-button--outlined": this.outlined,
"mw-ui-button--text": this.type === "text"
};
}
},
methods: {
handleClick(e) {
this.$emit("click", e);
}
}
};
</script>
<style lang="less">
@import "../mixins/buttons.less";
@import "../mixins/common.less";
@import "../variables/wikimedia-ui-base.less";
// Neutral button styling
//
// These are the main actions on the page/workflow. The page should have only one of progressive and destructive buttons, the rest being quiet.
//
// Styleguide 2.1.
.mw-ui-button {
background-color: @background-color-framed;
color: @color-base;
.mw-ui-button();
.mw-ui-button-states();
// Progressive buttons
//
// Use progressive buttons for actions which lead to a next step in the process.
//
// Styleguide 2.1.2.
&.mw-ui-button--progressive {
.mw-ui-button-colors-primary(
@color-primary,
@color-primary--hover,
@color-primary--active
);
}
.mw-ui-button__icon + .mw-ui-button__label,
.mw-ui-button__label + .mw-ui-button__indicator {
padding-left: 8px;
}
// Do not break words in buttons.
.mw-ui-button__label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Destructive buttons
//
// Use destructive buttons for actions that remove or limit, such as deleting a page or blocking a user.
// This should not be used for cancel buttons.
// Styleguide 2.1.3.
&.mw-ui-button--destructive {
.mw-ui-button-colors-primary(
@color-destructive,
@color-destructive--hover,
@color-destructive--active
);
}
&.mw-ui-button--icon {
font-size: 0;
}
// Buttons that act like links
&.mw-ui-button--icon,
&.mw-ui-button--text {
color: @color-base;
border-color: transparent;
background-color: transparent;
min-width: 0;
&:hover {
background-color: transparent;
color: @color-primary--hover;
}
&:active {
color: @color-primary--active;
}
&:focus {
background-color: transparent;
color: @color-primary--focus;
}
}
&.mw-ui-button--active {
color: @color-primary--active;
}
&.mw-ui-button--depressed {
color: @color-primary--active;
}
// Big buttons
// Styleguide 2.1.4.
&.mw-ui-button--large {
font-size: 1.3em;
}
// Block buttons
//
// Some buttons might need to be stacked.
// Styleguide 2.1.5.
&.mw-ui-button--block {
display: block;
width: 100%;
margin-left: auto;
margin-right: auto;
}
}
</style>
```
==== WMDE
[[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/Wikibase/+/master/client/data-bridge/src/presentation/components/EventEmittingButton.vue | EventEmittingButton.vue ]]
```lines=15, lang=html, name=Wikidata Bridge
<template>
<a
class="wb-ui-event-emitting-button"
:class="[
`wb-ui-event-emitting-button--${this.type}`,
`wb-ui-event-emitting-button--size-${this.size}`,
{
'wb-ui-event-emitting-button--squary': squary,
'wb-ui-event-emitting-button--pressed': isPressed,
'wb-ui-event-emitting-button--iconOnly': isIconOnly,
'wb-ui-event-emitting-button--frameless': isFrameless,
'wb-ui-event-emitting-button--disabled': disabled,
},
]"
:href="href"
:tabindex="tabindex"
:role="href ? 'link' : 'button'"
:aria-disabled="disabled ? 'true' : null"
:title="message"
:target="opensInNewTab ? '_blank' : null"
:rel="opensInNewTab ? 'noreferrer noopener' : null"
@click="click"
@keydown.enter="handleEnterPress"
@keydown.space="handleSpacePress"
@keyup.enter="unpress"
@keyup.space="unpress"
>
<span
class="wb-ui-event-emitting-button__text"
>{{ message }}</span>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import { Prop } from 'vue-property-decorator';
const validTypes = [
'primaryProgressive',
'close',
'neutral',
'back',
'link',
];
const framelessTypes = [
'close',
'back',
'link',
];
const imageOnlyTypes = [
'close',
'back',
];
const validSizes = [
'M',
'L',
'XL',
];
@Component
export default class EventEmittingButton extends Vue {
@Prop( {
required: true,
validator: ( type ) => validTypes.indexOf( type ) !== -1,
} )
public type!: string;
@Prop( {
required: true,
validator: ( size ) => validSizes.includes( size ),
} )
public size!: string;
@Prop( { required: true, type: String } )
public message!: string;
@Prop( { required: false, default: null, type: String } )
public href!: string|null;
@Prop( { required: false, default: true, type: Boolean } )
public preventDefault!: boolean;
@Prop( { required: false, default: false, type: Boolean } )
public disabled!: boolean;
@Prop( { required: false, default: false, type: Boolean } )
public squary!: boolean;
/**
* Whether this link should open in a new tab or not.
* Only effective if `href` is set.
* `preventDefault` should usually be set to `false` as well.
*/
@Prop( { required: false, default: false, type: Boolean } )
public newTab!: boolean;
public isPressed = false;
public get isIconOnly(): boolean {
return imageOnlyTypes.includes( this.type );
}
public get isFrameless(): boolean {
return framelessTypes.includes( this.type );
}
public get opensInNewTab(): boolean {
return this.href !== null && this.newTab;
}
public handleSpacePress( event: UIEvent ): void {
if ( !this.simulateSpaceOnButton() ) {
return;
}
this.preventScrollingDown( event );
this.isPressed = true;
this.click( event );
}
public handleEnterPress( event: UIEvent ): void {
this.isPressed = true;
if ( this.thereIsNoSeparateClickEvent() ) {
this.click( event );
}
}
public unpress(): void {
this.isPressed = false;
}
public click( event: UIEvent ): void {
if ( this.preventDefault ) {
this.preventOpeningLink( event );
}
if ( this.disabled ) {
return;
}
this.$emit( 'click', event );
}
public get tabindex(): number|null {
if ( this.disabled ) {
return -1;
}
if ( this.href ) {
return null;
}
return 0;
}
private preventOpeningLink( event: UIEvent ): void {
event.preventDefault();
}
private preventScrollingDown( event: UIEvent ): void {
event.preventDefault();
}
private thereIsNoSeparateClickEvent(): boolean {
return this.href === null;
}
private simulateSpaceOnButton(): boolean {
return this.href === null;
}
}
</script>
<style lang="scss">
.wb-ui-event-emitting-button {
font-family: $font-family-sans;
cursor: pointer;
white-space: nowrap;
text-decoration: none;
font-weight: bold;
line-height: $line-height-text;
align-items: center;
justify-content: center;
display: inline-flex;
border-width: 1px;
border-radius: 2px;
border-style: solid;
box-sizing: border-box;
outline: 0;
transition: background-color 100ms, color 100ms, border-color 100ms, box-shadow 100ms, filter 100ms;
@mixin size-M {
font-size: $font-size-bodyS;
padding: px-to-rem( 4px ) px-to-rem( 12px ) px-to-rem( 5px );
}
@mixin size-L {
font-size: $font-size-normal;
padding: px-to-rem( 7px ) px-to-rem( 16px );
}
@mixin size-XL {
font-size: $font-size-normal;
padding: px-to-rem( 11px ) px-to-rem( 16px );
}
&--size-M {
@include size-M;
}
&--size-L {
@include size-L;
}
&--size-XL {
@include size-XL;
}
@media ( max-width: $breakpoint ) {
&--size-M {
@include size-L;
}
&--size-L {
@include size-XL;
}
}
&--primaryProgressive {
background-color: $color-primary;
color: $color-base--inverted;
border-color: $color-primary;
&:hover {
background-color: $color-primary--hover;
border-color: $color-primary--hover;
}
&:active {
background-color: $color-primary--active;
border-color: $color-primary--active;
}
&:focus {
box-shadow: $box-shadow-primary--focus;
}
&:active:focus {
box-shadow: none;
}
}
&--neutral {
background-color: $background-color-framed;
color: $color-base;
border-color: $border-color-base;
&:hover {
background-color: $background-color-base;
}
&:active {
background-color: $background-color-framed--active;
color: $color-base--active;
border-color: $border-color-base--active;
}
&:focus {
background-color: $background-color-base;
border-color: $color-primary--focus;
}
}
&--disabled {
pointer-events: none;
cursor: default;
background-color: $background-color-filled--disabled;
color: $color-filled--disabled;
border-color: $border-color-base--disabled;
}
&--close {
background-image: $svg-close;
}
&--back {
background-image: $svg-back;
@at-root :root[ dir='rtl' ] & { // references dir attribute of the <html> tag
transform: scaleX( -1 );
}
}
&--link {
color: $color-primary;
&:hover {
color: $color-primary--hover;
}
&:active {
color: $color-primary--active;
}
&:focus {
color: $color-primary--hover;
}
}
&--frameless {
border-color: transparent;
background-color: $wmui-color-base100;
&:hover,
&:active,
:not( &:hover:focus ) {
box-shadow: none;
}
&:hover {
background-color: $wmui-color-base90;
}
&:active {
background-color: $wmui-color-base80;
}
&:focus {
border-color: $color-primary;
box-shadow: $box-shadow-base--focus;
}
&:active:focus {
box-shadow: none;
border-color: transparent;
}
}
@mixin iconOnly-size-M {
width: calc( #{ px-to-rem( 30px ) } + 2px );
height: calc( #{ px-to-rem( 30px ) } + 2px );
}
@mixin iconOnly-size-L {
width: $header-content-size--desktop;
height: $header-content-size--desktop;
}
@mixin iconOnly-size-XL {
width: $header-content-size--mobile;
height: $header-content-size--mobile;
}
&--iconOnly {
background-position: center;
background-size: $button-icon-size;
background-repeat: no-repeat;
cursor: pointer;
display: block;
}
&--iconOnly#{&}--size-M {
@include iconOnly-size-M;
}
&--iconOnly#{&}--size-L {
@include iconOnly-size-L;
}
&--iconOnly#{&}--size-XL {
@include iconOnly-size-XL;
}
@media ( max-width: $breakpoint ) {
&--iconOnly#{&}--size-M {
@include iconOnly-size-L;
}
&--iconOnly#{&}--size-L {
@include iconOnly-size-XL;
}
}
&--iconOnly > #{&}__text {
@include sr-only();
}
&--iconOnly#{&}--disabled#{&}--frameless {
opacity: $opacity-base--disabled;
}
// no styles for non-frameless disabled icon button yet (currently no such type)
&--primaryProgressive#{&}--pressed {
background-color: $color-primary--active;
}
&--squary {
border-radius: 0;
}
&--link#{&}--disabled {
color: $color-base--disabled;
}
}
</style>
```
==== MachineVision
[[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/MachineVision/+/master/resources/components/base/Button.vue | Button.vue ]]
```lines=15, lang=html, name=MachineVision
<template>
<button
class="mw-button"
v-bind:class="builtInClasses"
v-bind:disabled="disabled"
v-on:click="$emit( 'click' )"
>
<icon
v-if="icon"
v-bind:icon="icon"
v-bind:invert="invert"
/>
<div class="mw-button__content">
<slot />
</div>
</button>
</template>
<script>
var Icon = require( './Icon.vue' );
/**
* Button with optional icon.
*
* See ImageCard.vue for usage examples.
*/
// @vue/component
module.exports = {
name: 'Button',
components: {
icon: Icon
},
props: {
disabled: {
type: Boolean
},
frameless: {
type: Boolean
},
icon: {
type: String,
default: null
},
// Set to true to hide text node.
invisibletext: {
type: Boolean
},
// In OOUI, flags are passed in as an array (or a string or an object)
// and are handled by a separate mixin. Passing them in individually is
// a bit more readable and intuitive, plus it makes the code in this
// component simpler.
progressive: {
type: Boolean
},
destructive: {
type: Boolean
},
primary: {
type: Boolean
}
},
computed: {
builtInClasses: function () {
return {
'mw-button--framed': !this.frameless,
'mw-button--icon': this.icon,
'mw-button--invisible-text': this.invisibletext,
'mw-button--progressive': this.progressive,
'mw-button--destructive': this.destructive,
'mw-button--primary': this.primary
};
},
invert: function () {
return ( this.primary || this.disabled ) && !this.frameless;
}
}
};
</script>
<style lang="less">
@import 'mediawiki.mixins';
@import '../../../lib/wikimedia-ui-base.less';
.mw-button {
.transition( ~'background-color 100ms, color 100ms, border-color 100ms, box-shadow 100ms' );
background-color: transparent;
border: @border-width-base @border-style-base transparent;
border-radius: 2px;
color: @color-base;
cursor: pointer;
font-size: inherit;
font-weight: bold;
padding: 6px;
user-select: none;
&:hover {
background-color: rgba( 0, 24, 73, 7/255 );
color: @color-base--emphasized;
}
&:focus {
border-color: @color-primary;
box-shadow: @box-shadow-base--focus;
outline: 0;
}
.mw-icon {
height: 100%;
left: 5/14em;
position: absolute;
top: 0;
transition: opacity 100ms;
/* stylelint-disable-next-line selector-class-pattern */
&:not( .oo-ui-icon-invert ) {
opacity: @opacity-icon-base;
}
}
// Variants.
&--icon {
padding-left: 30/14em;
position: relative;
}
&--framed {
background-color: @background-color-framed;
border-color: @border-color-base;
padding: 6px 12px;
&:hover {
background-color: @background-color-framed--hover;
color: @color-base--hover;
}
&.mw-button--icon {
padding-left: 38/14em;
position: relative;
}
/* stylelint-disable-next-line no-descending-specificity */
.mw-icon {
left: 11/14em;
}
}
&--progressive {
color: @color-primary;
&:hover {
color: @color-primary--hover;
}
&.mw-button--framed {
&:hover {
border-color: @color-primary--hover;
}
}
}
&--destructive {
color: @color-destructive;
&:hover {
color: @color-destructive--hover;
}
&:focus {
border-color: @color-destructive;
box-shadow: inset 0 0 0 1px @color-destructive;
}
&.mw-button--framed {
&:hover {
border-color: @color-destructive--hover;
}
&:focus {
box-shadow: inset 0 0 0 1px @color-destructive,
inset 0 0 0 2px @color-base--inverted;
}
}
}
&--primary {
&.mw-button--framed {
// Default to progressive.
background-color: @color-primary;
border-color: @color-primary;
color: @color-base--inverted;
&:hover {
background-color: @color-primary--hover;
border-color: @color-primary--hover;
}
&:focus {
box-shadow: @box-shadow-primary--focus;
}
&.mw-button--destructive {
background-color: @color-destructive;
border-color: @color-destructive;
&:hover {
background-color: @color-destructive--hover;
border-color: @color-destructive--hover;
}
&:focus {
box-shadow: inset 0 0 0 1px @color-destructive,
inset 0 0 0 2px @color-base--inverted;
}
}
}
}
&:disabled {
color: @color-base--disabled;
cursor: auto;
&:hover,
&:focus {
background-color: @background-color-base;
}
&.mw-button--framed {
background-color: @background-color-filled--disabled;
border-color: @border-color-base--disabled;
color: @color-base--inverted;
&:hover,
&:focus {
background-color: @background-color-filled--disabled;
border-color: @border-color-base--disabled;
box-shadow: none;
}
}
&:not( .mw-button--framed ) .mw-icon {
opacity: @opacity-base--disabled;
}
}
&--invisible-text {
padding-right: 0;
.mw-button__content {
border: 0;
clip: rect( 1px, 1px, 1px, 1px );
display: block;
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
}
}
</style>
```
==== NearbyPages
[[ https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/NearbyPages/+/master/resources/ext.nearby.scripts/Button.vue | Button.vue ]]
```lines=15, lang=html, name=NearbyPages
<template>
<button class="mw-ui-button"
v-bind:class="additionalClassNames"
v-on:click="$emit('click')">
<slot />
</button>
</template>
<script>
/**
* A good old fashioned mediawiki ui button
* @module Button
* @param {boolean} primary whether the button should be considered primary
*/
module.exports = {
name: 'mw-button',
computed: {
/**
* @return {Object} representing mapping of classes. Keys are classes
* and their values are where to apply them
*/
additionalClassNames: function () {
return {
'mw-ui-progressive': this.primary
};
}
},
props: [ 'primary' ]
};
</script>
```
== Conclusion
The goal of this task is to identify a general procedure for starting new components for #Vue.js-Search that pretty much any dev can follow and feel confident doing so. The immediate outcome will be a collection of component tasks ready to work on.
The conclusion is to start with a semantic HTML5 template based on the [[https://gerrit.wikimedia.org/g/wikimedia/portals/ | Portals]] search implementation which leverages an extremely successful and simple production implementation that can be styled or scripted as needed. Styles and behavior will then be added to meet the search use case specifically. This approach would unlock developers looking for their next task while allowing for needed longer term discussions to occur in parallel. Because we wish to build on what we have learned, in some respects this marks the beginning of a migration guide for “the good parts.”
=== Strategy
```lang=html,lines=15,name=HTML skeleton
<!--:
References
https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html
https://alphagov.github.io/accessible-autocomplete/examples/
-->
<div class="wvui-card wvui-typeahead-search">
<form id="wvui-typeahead-search__search" class="wvui-search-form" action="//wikipedia.org/search-redirect.php" role="combobox" aria-expanded="true" aria-haspopup="listbox" aria-owns="wvui-typeahead-search__suggestions">
<input type="hidden" name="family" value="wikipedia">
<input type="hidden" name="language" value="en">
<label class="wvui-cue" for="wvui-typeahead-search__input">
Search Wikipedia
</label>
<input
id="wvui-typeahead-search__input"
class="wvui-input"
name="search"
type="search"
size="48"
autofocus="autofocus"
accesskey="f"
title="Search Wikipedia [Alt+Shift+f]"
dir="auto"
autocomplete="off"
aria-describedby="wvui-typeahead-search__cue"
value="ba"
aria-autocomplete="list"
aria-controls="wvui-typeahead-search__suggestions"
>
<!-- Additional instructions can be presented to screen readers when search suggestions
will be presented. -->
<div id="wvui-typeahead-search__cue" class="wvui-cue">
Input your query and press enter or tap the search button to search within pages.
Suggestions may be presented on query change. Use up and down arrow keys to
navigate the suggestions and press enter or tap to select.
</div>
<!--
This button will not be presented in T244392 but may be wanted conditionally within
the component for other use cases.
<button class="wvui-button" type="submit">
Search
</button>
-->
</form>
<!-- A data list is not used because of limited styling options. An ordered list is used over
an unordered list because the contents are ordered by relevance which may be useful
semantically and to screen readers. -->
<ol id="wvui-typeahead-search__suggestions" class="wvui-typeahead-search__suggestions" role="listbox">
<li role="option">
<a href="/wiki/Banana" class="wvui-typeahead-suggestion">
<img
class="wvui-typeahead-suggestion__thumbnail"
src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Bananavarieties.jpg/160px-Bananavarieties.jpg"
width="160"
height="91"
alt="Banana"
loading="lazy"
>
<h3 class="wvui-typeahead-suggestion__title">
<em class="wvui-typeahead-suggestion__matching-title">Ba</em>nana
</h3>
<p class="wvui-typeahead-suggestion__description">
Edible fruit
</p>
</a>
</li>
<!-- More results here. -->
</ol>
<button class="wvui-button wvui-typeahead-search__submit" form="wvui-typeahead-search__search" type="submit">
Search pages containing <em>ba</em>
</button>
</div>
```
```lang=css,lines=15,name=Minimal CSS
.wvui-typeahead-search__suggestions {
/* Don't present list numbers to GUI readers. */
list-style: none;
}
.wvui-typeahead-suggestion {
/* Fill the available width fully so the whole suggestion box is clickable. */
display: block;
width: 100%;
}
.wvui-typeahead-suggestion::after {
/* Add an empty pseudo-element that clears the left and right so that the element can wrap
its contents fully even if they're floated. */
content: "";
clear: both;
/* Make the pseudo-element dimensionless, forbid scrollbars, and hide. */
display: block;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
}
.wvui-typeahead-suggestion__thumbnail {
/* Thumbnails appear to the starting side of title and description. */
float: left;
}
/* Informational cues intended for presentation to screen readers only. */
.wvui-cue {
/* Make the element dimensionless, forbid scrollbars, and hide visually. */
display: block;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
}
```
[[ https://codepen.io/stephen/pen/YzwGrjd | The above as a CodePen ]]
The above example proposes the approximate HTML wanted to render search with one result but has minimal CSS and no JavaScript. It’s largely based on the Portals search implementation. The structure and HTML of the initial components built would approximate it.
The HTML structure and CSS class names are most important as they describe an initial implementation and hint at approximate component separations including: wvui-card, wvui-button, wvui-input, wvui-search-form, wvui-typeahead-suggestion, and wvui-typeahead-search. This HTML will be split along these perforations and made into simple Vue.js components with test and Storybook story boilerplate tasks. Each of these tasks would include the relevant snippet as a practical but imperfect starting point. JavaScript and OOUI styles will then be added and iterated upon until at least the search use case is implemented.
Components can be further divided as wanted. For example, the typeahead suggestion image could be extracted to a new thumbnail component if useful. T244392 represents one use case. When a concrete second use case is needed, it may be easier to perform the extraction at that point for some components. Other components may become cumbersomely large so a division is wanted for readability. There are many interested contributors so it's expected that many components will be built by many different people.
==== Building a component
For each component, the following approach would be taken.
As an author:
1. Create a a) new single file component file, b) UI Storybook story, and c) Jest test.
2. Add the minimal HTML5 template and JavaScript needed for the search use case.
3. Try to get the above merged.
4. Copy properties from OOUI that apply to the minimal template. Try to get this merged.
5. Change as needed. When changing the structure, copy any relevant styles from OOUI. For changes that require extensive discussion and are optional for search, consider forking the component within WVUI to a new directory.
6. Update [[ https://www.mediawiki.org/wiki/Vue.js/OOUI_migration_guide | the migration guide ]] with lessons learned.
As a reviewer:
- The search use case is the focus. Contentious or lengthy discussions will be necessary but should be moved to new tickets for long term component design. Great discourse is needed to build great generic components but the search use case is more focused with a tighter deadline.
===== Example
1. Create a a) new single file component, b) accompanying user interface Storybook story, and c) Jest boilerplate:
```lang=html,name=Single file component
<template>
</template>
<script lang="ts">
</script>
<style lang="less">
</style>
```
2. Create the template using the bare minimum semantic HTML5 markup from the skeleton. For example, the template for a WVUI button component would literally be a `button`. Less is better (and small patches get merged) at this stage so avoid adding functionality until it is needed.
```lang=html,name=Populate template and script
<template>
<button @click="onClick">
<slot />
</button>
</template>
<script lang=”ts”>
export default Vue.extend( {
name: ‘wvui-button',
methods: {
onClick( event: MouseEvent ) {
this.$emit( 'click', event );
}
}
} );
</script>
<style lang="less">
</style>
```
In other words, you take the template from the HTML5 skeleton and the minimum template compositions and JavaScript from any of the existing implementations such as ContentTranslation, WMDE, MachineVision, NearbyPages, or a hybrid. Please see the codepen for the proposed HTML5 skeleton and component.
With this skeleton, it’s a mostly functional button without much extra. It can wrap things like a label. It can be composed into other components and listen for click events.
3. Put the above in code review and get it merged.
4. Now copy the [[ https://gerrit.wikimedia.org/g/oojs/ui/+/master | OOUI styles ]] in – using BEM naming for the interim. Use or add new variables to [[ https://github.com/wikimedia/wikimedia-ui-base | wikimedia-ui-base ]] (this already be the case for most OOUI styles). Compare the result to these [[ https://doc.wikimedia.org/oojs-ui/master/demos/ | OOUI demos ]]. This part is tricky so we have a dedicated [[ https://www.mediawiki.org/wiki/Vue.js/OOUI_migration_guide | migration guide ]]. If this stage takes too long, consider if it makes sense for the work to be broken up into a second patch or ask for help in identifying the work remaining. We need excellent guidance to make this part a success.
```lang=html,name=Populate styles
<template>
<button :class=”classes” @click="onClick">
<slot />
</button>
</template>
<script lang=”ts”>
export default Vue.extend( {
name: ‘wvui-button',
props: {
progress: {
type: String as PropType<Progress>,
default: Progress.Default,
validator( val: string ): val is Progress {
return val in Progress;
// return [ 'Default', 'Progressive', 'Destructive' ].includes( val );
}
},
framed: Boolean
},
computed: {
classes() {
return {
'wvui-button': true,
'wvui-button--progressive': this.progress === Progress.Progressive,
'wvui-button--framed': this.framed,
'wvui-button--quiet': !this.framed
};
}
},
methods: {
onClick( event: MouseEvent ) {
this.$emit( 'click', event );
}
}
} );
</script>
<style lang="less">
.wvui-button {
/* ... */
&.wvui-button--framed {
border-color: @border-color-base; /* Variable from wikimedia-ui-base. */
&:hover {
border-color: @border-color-base--hover;
}
&:focus {
border-color: @border-color-base--focus;
}
&[ disabled ] {
border-color: @border-color-base--disabled;
}
}
}
</style>
```
Put the above in code review and get it merged.
== Acceptance criteria
- [] A baseline option is chosen.
- [] A task or tasks are made for creating or copying any of the existing emphasized components over to the library.