Skip to content
ts
import Button from '~/ui/Button.vue'

Button

Triggers an action on click

  • To control a boolean such as isPlaying, use Toggle or Switch instead
  • When the action changes the user's context or position, use Link instead
ts
const {
  uiBlockSize = useContext().value.uiBlockSize ?? '48',
  uiOvershootNone = useContext().value.uiOvershootNone,
  uiRaised = useContext().value.uiRaised ?? undefined,
  ...props
} = defineProps<WithAttributes<{
  class: {
    align?: 'align-start' | 'align-stretch'
    justify?: 'justify-start' | 'justify-stretch'
    color?: `${'color-primary' | 'color-destructive' | 'color-default' | 'color-yellow' | 'color-green'}${'-solid' | '-ghost'}` | 'color-accent' | 'color-default'
    shape?: 'shape-circle' | 'shape-square' | 'shape-field-grow' | 'shape-field-minimal' | 'shape-field-full'
  }
  /**
   * Indicates whether an element is being updated in the background and will not respond to user interactions.
   * Note: Use string 'false' to convey 'aria-busy may happen', otherwise use booleans for developer convenience.
   * @see https://www.w3.org/TR/wai-aria-1.2/#aria-busy
   */
  ariaBusy?: 'true' | 'false' | boolean

  /**
   * Indicates element that is visually and semantically current when element is part of a set of related elements
   * - `true`: Current.
   * - `'step'`: Current step in a multi-step process.
   * - `'location'`: Current location within an environment or context.
   * - `'date'`: Current date.
   * - `'time'`: Current time.
   * @see https://www.w3.org/TR/wai-aria-1.2/#aria-current
   */
  ariaCurrent?: boolean | 'step' | 'location' | 'date' | 'time'

  /**
   * Move focus to this component when it is mounted. Move focus back to previous when it is unmounted.
   */
  autofocus?: boolean

  /**
   * Use for buttons that the user expects at a certain position, for example in a toolbar or in a row of actions
   * Otherwise, remove the button instead.
   */
  disabled?: boolean

  ariaDisabled?: 'true' | 'false' | boolean

  uiBlockSize?: '24' | '40' | '48'

  /**
   * Use in a context where you want to prevent the default overlapping of round edges
   */
  uiOvershootNone?: boolean

  uiRaised?: boolean

  /**
   * Default to `submit` in the absence of `@click`
   * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#type
   */
  onClick?: (e: MouseEvent) => void
} & Labeled & Activated>>()

Actions

The default action of buttons is submit [mdn]. Specify an @click to trigger a custom effect.

template
<Button
  :disabled="alerts.length === 0"
  @click="alerts = []"
>
  Clear all
  <Badge v-if="alerts.length > 0">
    {{ alerts.length }}
  </Badge>
</Button>

Long-running actions are indicated by a loader and an accessible aria-busy attribute.

TS
/**
 * Makes component busy while async function is running
 * @param onClick
 * @returns A reactive object - use with v-bind on buttons
 */
const useBusyClick = (onClick: (e: MouseEvent) => Promise<void>) => {
  const props = reactive<{
    ariaBusy?: 'true' | 'false'
    onClick: (e: MouseEvent) => void
  }>({
    ariaBusy: undefined,
    onClick: async (e: MouseEvent) => {
      const result = onClick(e)
      const makeBusy = setTimeout(() => {
        props.ariaBusy = 'true'
      }, 200)

      try {
        await result
      } finally {
        props.ariaBusy = 'false'
        clearTimeout(makeBusy)
      }
    }

  })

  return props
}

const useAddAlert = useBusyClick(async () => {
  await delay((Math.random() + 1) * 1000)
  alerts.value.unshift(Date.now())
})
template
<Button
  aria-current="time"
  aria-label="Add alert"
  class="color-primary-solid shape-circle"
  title="Add alert"
  v-bind="useAddAlert"
>
  <Icon
    icon="bi:plus"
    style="font-size: 24px;"
  />
</Button>

Focus a button

If the user is likely to choose a certain button in a given situation, give it the autofocus attribute. This will put the focus on it so that the user can confirm it by pressing SPACE and doesn't need to navigate to it. The button will be emphasized with a contrasting outline. Primary buttons will draw the outline outside their shape for increased emphasis and visibility (A11y).

Make sure to not steal focus from a more important control, and to to visibly return focus to whare it was before after the button is gone. See the Modal component (docs) for a complete example.

Note that submit buttons (no @click) trigger when the user hits ENTER while focusing anywhere within its associated form.

Using this component

A11y Checklist

Accessible button

template
<script setup lang="ts">
import { ref, reactive } from 'vue'

import Button from '~/ui/Button.vue'
import Alert from '~/ui/Alert.vue'
import Toolbar from '~/ui/Toolbar.vue'
import Badge from '~/ui/Badge.vue'
import { Icon } from '@iconify/vue'

const alerts = ref<number[]>([])

const delay = (ms: number) => new Promise(res => setTimeout(res, ms));

// #region useBusyClick
/**
 * Makes component busy while async function is running
 * @param onClick
 * @returns A reactive object - use with v-bind on buttons
 */
const useBusyClick = (onClick: (e: MouseEvent) => Promise<void>) => {
  const props = reactive<{
    ariaBusy?: 'true' | 'false'
    onClick: (e: MouseEvent) => void
  }>({
    ariaBusy: undefined,
    onClick: async (e: MouseEvent) => {
      const result = onClick(e)
      const makeBusy = setTimeout(() => {
        props.ariaBusy = 'true'
      }, 200)

      try {
        await result
      } finally {
        props.ariaBusy = 'false'
        clearTimeout(makeBusy)
      }
    }

  })

  return props
}

const useAddAlert = useBusyClick(async () => {
  await delay((Math.random() + 1) * 1000)
  alerts.value.unshift(Date.now())
})
// #endregion useBusyClick
</script>

<template>
  <Toolbar>
    <!-- #region snippet -->
    <!-- #region immediate -->
    <Button
      :disabled="alerts.length === 0"
      @click="alerts = []"
    >
      Clear all
      <Badge v-if="alerts.length > 0">
        {{ alerts.length }}
      </Badge>
    </Button>
    <!-- #endregion immediate -->
    <!-- #region delayed -->
    <Button
      aria-current="time"
      aria-label="Add alert"
      class="color-primary-solid shape-circle"
      title="Add alert"
      v-bind="useAddAlert"
    >
      <Icon
        icon="bi:plus"
        style="font-size: 24px;"
      />
    </Button>
    <!-- #endregion delayed -->
    <!-- #endregion snippet -->
    <div
      class="layout-stack"
      style="position: fixed; bottom: 0; right: 0;"
    >
      <Alert
        v-for="(date, index) in alerts"
        :key="index" class="color-green-solid"
        aria-label="Dismiss"
        @click="alerts.splice(index, 1)"
      >
        {{ new Date(date).toLocaleString(undefined, { timeStyle: 'medium' }) }}
      </Alert>
    </div>
  </Toolbar>
</template>

Test this component in isolation against WCAG2 criteria