Start a project

Actions

Actions

One accent, spent once per screen. Everything else earns attention through weight and elevation.

AdsButton

Three weights. Accent for the one action the screen exists to produce, default for everything else, quiet for actions that should not compete.

vue
<AdsButton variant="accent" icon="plus">New crawl</AdsButton>
<AdsButton>Export CSV</AdsButton>
<AdsButton variant="quiet">Cancel</AdsButton>
<AdsButton destructive>Delete project</AdsButton>
AdsButton props
PropTypeDefaultDescription
variantdefault | accent | quietdefault
sizesm | mdmd
iconstring—Glyph name from utils/glyphs.
loadingbooleanfalseSpinner in place of the icon; sets aria-busy.
destructivebooleanfalseNever accent: forces the default weight. Label it with the verb (Delete project).
disabledbooleanfalse
Source · ads/app/components/AdsButton.vue
vue
<script setup lang="ts">
// The ADS button. `.btn` is the raised soft surface; the accent variant is the
// one action per screen, so spend it once. A destructive action never gets the
// accent — the accent means "the thing to do", and deleting is not it — so
// `destructive` pins the button to the default weight whatever variant is
// asked for. The confirm that follows it says the verb, not "OK".
const props = withDefaults(
  defineProps<{
    variant?: 'default' | 'accent' | 'quiet'
    size?: 'sm' | 'md'
    icon?: string
    iconSize?: number
    loading?: boolean
    destructive?: boolean
    type?: 'button' | 'submit' | 'reset'
    disabled?: boolean
  }>(),
  { variant: 'default', size: 'md', iconSize: 16, loading: false, destructive: false, type: 'button', disabled: false },
)

const weight = computed(() => (props.destructive && props.variant === 'accent' ? 'default' : props.variant))

if (false && props.destructive && props.variant === 'accent') {
  console.warn('[AdsButton] A destructive action cannot take the accent; rendering the default weight.')
}
</script>

<template>
  <button
    :type="type"
    class="btn"
    :class="{
      'btn-accent': weight === 'accent',
      'btn-quiet': weight === 'quiet',
      'btn-sm': size === 'sm',
    }"
    :disabled="disabled || loading"
    :aria-busy="loading || undefined"
  >
    <span v-if="loading" class="ads-spinner ads-button__spinner" aria-hidden="true" />
    <Icon v-else-if="icon" :name="icon" :size="iconSize" />
    <slot />
  </button>
</template>

<style scoped>
.btn .ads-button__spinner {
  width: 14px;
  height: 14px;
  border-width: 1.5px;
}
</style>

AdsIconButton

A tile-shaped target for an action whose glyph is unambiguous. Always carries an accessible name.

vue
<AdsIconButton icon="grid" label="Grid" />
<AdsIconButton icon="chart" label="Chart" />
AdsIconButton props
PropTypeDefaultDescription
iconstring—
labelstring—Accessible name (required).
roundbooleanfalse
accentbooleanfalse
Source · ads/app/components/AdsIconButton.vue
vue
<script setup lang="ts">
// A button that holds only a glyph. Round or square, accent or soft.
withDefaults(
  defineProps<{
    icon: string
    label: string
    round?: boolean
    accent?: boolean
    size?: number
    type?: 'button' | 'submit'
  }>(),
  { round: false, accent: false, size: 18, type: 'button' },
)
</script>

<template>
  <button
    :type="type"
    class="ads-icon-button"
    :class="{ 'is-round': round, 'is-accent': accent }"
    :aria-label="label"
  >
    <Icon :name="icon" :size="size" />
  </button>
</template>

AdsSegmented

Two to five mutually exclusive views, all visible at once. Use it where a select would hide the options; more than five, or labels that wrap, is a select.

vue
<AdsSegmented v-model="range" label="Range" :options="[
  { label: '24h', value: '24h' },
  { label: '7d', value: '7d' },
]" />
AdsSegmented props
PropTypeDefaultDescription
modelValuestring | number—
options{ label, value }[]—2–5 short, comparable labels.
labelstring— (required)Accessible name of the group.
Source · ads/app/components/AdsSegmented.vue
vue
<script setup lang="ts">
// A segmented control: two to five mutually exclusive views, all visible at
// once, in a single pressed track. More than five, or labels that wrap, is a
// select. The group needs a name because the options alone ("7d", "30d") do
// not say what they choose between.
export type SegOption = { label: string; value: string | number }

const props = withDefaults(defineProps<{ modelValue?: string | number; options: SegOption[]; label: string }>(), {
  modelValue: undefined,
})
const emit = defineEmits<{ (e: 'update:modelValue', v: string | number): void }>()

if (false && (props.options.length < 2 || props.options.length > 5)) {
  console.warn(`[AdsSegmented] "${props.label}" has ${props.options.length} options; a segmented control holds 2–5. Use a select.`)
}
</script>

<template>
  <div class="ads-segmented" role="group" :aria-label="label">
    <button
      v-for="o in options"
      :key="o.value"
      type="button"
      :aria-pressed="props.modelValue === o.value"
      :class="{ 'is-on': props.modelValue === o.value }"
      @click="emit('update:modelValue', o.value)"
    >
      {{ o.label }}
    </button>
  </div>
</template>

AdsMenu

Row actions and overflow. Everything a reader can do to one record, in one place. Destructive items go last, below a rule; shortcuts show beside their item.

vue
<AdsMenu label="Row actions" :items="[
  { label: 'Exclude from crawl', icon: 'alert', destructive: true },
  { label: 'Open detail', icon: 'arrow', shortcut: '↵' },
]" />
<!-- Destructive items are always moved last, below a rule. -->
AdsMenu props
PropTypeDefaultDescription
items{ label, icon?, shortcut?, destructive?, action? }[]—destructive items are moved last, below a rule. Use label --- for a divider.
labelstring— (required)Accessible name of the menu.
Source · ads/app/components/AdsMenu.vue
vue
<script setup lang="ts">
// A menu: everything a reader can do to one record, in one place. Destructive
// items always go last, below a rule — the menu sorts them there itself, so a
// "Delete" can never land first under the pointer. Shortcuts sit beside their
// item in the data face, because a menu is how a reader learns them.
export type MenuItem = {
  label: string
  icon?: string
  shortcut?: string
  destructive?: boolean
  action?: () => void
}

const props = defineProps<{ items: MenuItem[]; label: string }>()
const root = ref<HTMLElement>()

const isRule = (item: MenuItem) => item.label === '---'

// Ordinary items keep their order and their `---` rules; destructive ones are
// lifted out and set after a single rule of their own.
const groups = computed(() => {
  const safe = props.items.filter((i) => !i.destructive)
  while (safe.length && isRule(safe[safe.length - 1]!)) safe.pop()
  const danger = props.items.filter((i) => i.destructive && !isRule(i))
  return { safe, danger }
})

// Arrow keys walk the items, as they do in every native menu.
function onKeydown(e: KeyboardEvent) {
  const buttons = [...(root.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])]
  if (!buttons.length) return
  const at = buttons.indexOf(document.activeElement as HTMLButtonElement)
  const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: buttons.length - 1 }[e.key]
  if (next === undefined) return
  e.preventDefault()
  buttons[(next + buttons.length) % buttons.length]!.focus()
}
</script>

<template>
  <div ref="root" class="ads-menu" role="menu" :aria-label="label" @keydown="onKeydown">
    <template v-for="(item, i) in groups.safe" :key="`s${i}`">
      <hr v-if="isRule(item)" />
      <button v-else type="button" role="menuitem" @click="item.action?.()">
        <Icon v-if="item.icon" :name="item.icon" :size="15" />
        {{ item.label }}
        <span v-if="item.shortcut" class="shortcut">{{ item.shortcut }}</span>
      </button>
    </template>
    <template v-if="groups.danger.length">
      <hr v-if="groups.safe.length" />
      <button v-for="(item, i) in groups.danger" :key="`d${i}`" type="button" role="menuitem" @click="item.action?.()">
        <Icon v-if="item.icon" :name="item.icon" :size="15" />
        {{ item.label }}
        <span v-if="item.shortcut" class="shortcut">{{ item.shortcut }}</span>
      </button>
    </template>
  </div>
</template>