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?
=== 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>
```
== 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.