Page MenuHomePhabricator

[Spike 60hr] Identify the component baseline for Vue.js search
Closed, ResolvedPublicSpike

Description

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?

Examples

The following examples of a button primitive.

ContentTranslation

Button.vue

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.
//
.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.
  //
  &.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.
  &.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.
  &.mw-ui-button--block {
    display: block;
    width: 100%;
    margin-left: auto;
    margin-right: auto;
  }
}
</style>
WMDE

EventEmittingButton.vue

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

Button.vue

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

Button.vue

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 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

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" role="combobox" aria-expanded="true" aria-haspopup="listbox" aria-owns="wvui-typeahead-search__suggestions">
  <form id="wvui-typeahead-search__search" class="wvui-search-form" action="https://wikipedia.org/search-redirect.php" >
    <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">
      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" aria-label="search suggestions">
    <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>
Minimal CSS
.wvui-typeahead-search__suggestions {
  /* Don't present list numbers to GUI readers. */
  list-style: none 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;
}

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. Add additional ARIA roles and attributes where needed.
  3. Aim for the above to be merged.
  4. Copy properties from OOUI that apply to the minimal template. Aim for this to be 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 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

Create a

  1. new single file component,
  2. accompanying user interface Storybook story, and
  3. Jest boilerplate:
Single file component
<template>
</template>

<script lang="ts">
</script>

<style lang="less">
</style>
  1. 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.
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.

  1. Put the above in code review and get it merged.
  1. Now copy the OOUI styles in – using BEM naming for the interim. Use or add new variables to wikimedia-ui-base (this already be the case for most OOUI styles). Compare the result to these OOUI demos. This part is tricky so we have a dedicated 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.
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">
/* Less variables from WikimediaUI Base. */
.wvui-button {
    /* ... */

    &.wvui-button--framed {
        border-color: @border-color-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.

Event Timeline

Are there any initial thoughts on this? We have too many good options but I particularly liked the approach in the ContentTranslation extension for the "library" primitives. They have Storybook UI stories, tests, and overall seem pretty nicely isolated as a library that happens to be embedded in a larger app. The more sophisticated components like LanguageSelector depend on Vuex but we don't necessarily have to use that. I guess I'm down with whatever is practical but I would prefer not to try to rebuild everything from scratch all at once for #Vue.js-Search specifically.

I am interested to hear differing and shared perspectives as well as any blind spots. I would like to encourage participants to be practical with respect to both short and long-term objectives. Leaning too much towards one or the other will be detrimental to both in my opinion.

Restricted Application changed the subtype of this task from "Task" to "Spike". · View Herald TranscriptMay 29 2020, 5:19 AM

Is there a way to to have mixins/interfaces? Mixins like TabIndexElement which provide accessible attributes and focus/blur methods allow for lots of code de-duplication and enforcing of accessibility standards.

Is there a way to to have mixins/interfaces? Mixins like TabIndexElement which provide accessible attributes and focus/blur methods allow for lots of code de-duplication and enforcing of accessibility standards.

Vue mixins will be required certainly to support these kind of features in consistant way. All of the vue based libraries mentioned in this tickets are written for some early usecases in respective projects. Long way to go.

It's my understanding that the Composition API is generally preferred to mixins. It's part of Vue 3 but requires an extra library in Vue 2 that recommends against production use. There's a summary here (note especially the conclusion). I have no issue with using mixins as needed in the short term but, if so, I think we should plan on migrating to the Composition API as soon as able.

Long way to go.

Understood but which is the best place to start?

I would suggest using Vue mixins to start with, and migrating to the composition API when it's available. The composition API is conceptually not that different from mixins, it's basically just a more controlled version of mixins that avoids a lot of the problems with the current mixin system. Porting a component that uses a mixin to a composition API version of that mixin is usually straightforward.

Also, channeling @egardner for a second: we should aim to use mixins (and, in the future, the composition API) only in reusable/base components (like the ones we're talking about in this task), and we should discourage their use in "end user" components (one-off components that implement a specific UI), as part of the general theme of pushing complexity down into reusable components as much as possible. Not that anyone's necessarily suggested using mixins for end user components, but I think it's worth keeping this in mind so that we design our component library in such a way that people won't feel the need to use mixins outside of reusable components.

ovasileva renamed this task from [Spike] Identify the component baseline for Vue.js search to [Spike 60hr] Identify the component baseline for Vue.js search.Jun 17 2020, 4:12 PM

I would suggest using Vue mixins to start with, and migrating to the composition API when it's available. The composition API is conceptually not that different from mixins, it's basically just a more controlled version of mixins that avoids a lot of the problems with the current mixin system. Porting a component that uses a mixin to a composition API version of that mixin is usually straightforward.

I agree with this approach – we are starting this in Vue 2, so we should pull out shared code into mixins for now; migrating to the composition API later if necessary should not be a big issue.

For example, let's say we need a standardized way to handle focus behavior across components. It would be simple to set up a mixins/focusable.js file; this file can provide focus-related methods, emit consistent events, define consistent properties, etc. for any component that needs them. Perhaps all "focusable" components would be stateful in regards to whether or not the user has used keyboard navigation on a particular element (maybe a special set of styling rules comes into effect in that case, etc). You'd define that property under data in the mixin, and wouldn't need to re-introduce it in each "focusable" component.

Another good use case might be: certain DOM-dependent calculations, like some kind of sticky, floating element that needs to remember its original place in the window when the user scrolls. A "dismissable" mixin could be used for modal dialogs, message boxes, notifications, etc.

API request code *could* be moved into a mixin as well, but only for the generic request/response/error handling cycle; you'd want to define specific behavior at the component level for whatever your actual use case was.

The only caveat I'd say is that less is probably more when it comes to mixins; we should resist the temptation to create a ton of these up front. The more mixins a component includes, the harder it becomes to keep track of where a given bit of data or method actually comes from. Some kind of naming convention might be worth considering here.

Thanks @egardner and @Catrope for your thoughts. For the time being and the primitives in front of us, it seems to me that we can easily live with the mixins at need path and expect only a very small number of possible mixins ahead of us for, if at all, for the Search Case Study.

@Volker_E, the conclusion of this task was kind of "all of the above." The template is largely based on the Portals implementation. Component scripts and structure were greatly influenced by ContentTranslation, MachineVision, WMDE, and NearbyPages. Styles are copied from OOUI as directly as possible (see T253953).

Updated the HTML skeleton to

  • provide better roles and other ARIA attributes;
    • including moving combobox roles from form to encapsulating div and
    • adding aria-label to role="listbox", as it's required for this role
  • remove button type=submit, as it's button's default

Looking good! Several parts have already been reflected in the first WVUI components.