GrChip

Package: @feugene/granularitycoreGroup: data

An interactive tag: removable, selectable, with an icon. Where GrBadge only shows a status, a chip answers to a click.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • a dismissible label on an entity — the tags of a task, the recipients of a letter, the selected files: a cross on each, the list kept by the application;
  • a filter that is switched on and offselectable gives a toggle with aria-pressed, and a set of such filters is collected by GrChipGroup;
  • a quick choice among short options — a period, a status, a priority: chips take up a line rather than a dropdown list;
  • a row of tags of your own beside a field — when the input and the storage are already written and all that is needed is a tile with a cross.

When to take something else

NeedTake
A status or a tag without interactionGrBadge
A field that collects tags from typing itselfGrInputTag
A counter on top of a button or an iconGrBadgeWrap
A single choice among options in one lineGrSegmented
Many options that are searched by typingGrSelect / GrAutocomplete

The cross is not always a button

The markup of a chip depends on whether the chip itself is clicked.

The chip is not a widget (just a dismissible label) — the root is a <span>, and the cross is a real <button> with a name of its own and a Tab stop of its own. That is how the chips inside GrInputTag are built.

The chip is a widget (selectable or inside a group) — the root is a <button>, and the cross inside it cannot be a button: the role declares its descendants presentational (axe catches that as nested-interactive), and a <button> inside a <button> is invalid by the HTML content model. The cross therefore becomes an aria-hidden <span>, dismissal moves to Delete/Backspace, and the chip announces that through aria-keyshortcuts. The closable tabs of GrTabs live by the same device.

The practical consequence: a link must not be put inside a selectable chip. If a link is needed, the chip is not selectable.

The chip does not perform the dismissal

remove is a request, not a fact: the contents of the set belong to the consumer, and only the consumer can remove an item from the array. The chip does not hide itself and announces nothing into a live region — announcing “removed” before it has happened would be a lie.

The label is needed separately from the slot

The label prop duplicates the content of the slot not by an oversight: the name of the dismiss button is built from it. Twenty buttons named “Remove” in a row make it impossible to pick the right one — the name has to name the tag itself, so by default it is “Remove {label}”. If the content is more complex than a string, set removeLabel by hand.

The selection is seen by the fill, not by the outline

A selected chip takes the solid variant of its tone — the same one dark gives. The hue does not change in the process: a set of filters where the selected one is recoloured into a neighbouring colour reads as a rainbow rather than as a state.

An outline and a slight thickening of the type do not carry that job: in a row of five filters the selected one is not found at a glance by them. A difference in lightness is found — and it survives monochrome and colour blindness, unlike “became slightly darker”.

The non-colour channel is kept nonetheless: a selected chip has font-weight: 600. It is that channel that carries the case where dark is set for the whole set and the fill of the selected and the unselected coincide.

The tones are shared with the badge, the sizes are its own

The palette of eight tones and the three radii are taken from GrBadge unchanged: a chip and a badge often stand in the same row and have to match in colour.

The size scale of a chip, however, is different. A badge is set one step smaller (--gr-control-text-2xs…md), because a label is not a target for a press; a chip is clicked, and its type size matches the neighbouring control (--gr-control-text-xs…lg). Hence, too, the explicit height a badge does not have at all.

The height is still below the control one — 24 / 28 / 32 / 36 px against 28 / 32 / 40 / 44 for an input field and a button: a chip is denser, because it travels in sets and several to a line. The practical consequence is worth knowing in advance: the ceiling of the scale is 36px, that is, a chip does not reach the 44×44 required by WCAG 2.5.5 and the Apple HIG for a target under a finger on any step. If exactly such a target is needed, take GrButton with size="lg", not a chip.

Limits

  • there is no virtualisation. A thousand chips in a row is not a row of chips but a list: cut the set yourself or take GrSelect with virtual;
  • chips are not reordered by dragging. The order is set by the consumer’s array; if a manual order is needed — GrSortableList;
  • a chip has no input of its own. It shows a ready value; collecting a set from typing is GrInputTag.

Playground 11

Loading…

Code
<GrChip />

Install

npm i @feugene/granularity

Import

import { GrChip } from '@feugene/granularity/components/GrChip'

API

Props

PropTypedefaultDescription
tone"primary" | "neutral" | "success" | "warning" | "danger" | "info" | "slate" | "azure" | undefinedundefined
closableboolean | undefinedfalseA cross. Inside a widget it is not a button — see the section on roles below.
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedAn accessible name for when the label does not explain the purpose.
darkboolean | undefinedundefined
radiusGrBadgeRadius | undefinedundefined
valueGrChipValue | undefinedundefinedThe value of the chip in a group. Outside `GrChipGroup` it is not used.
labelstring | undefinedundefinedThe label. It is needed separately from the slot: the name of the dismiss button is built from it — "Remove" on twenty buttons in a row makes it impossible to pick the right one.
selectableboolean | undefinedfalseThe chip becomes a toggle: `aria-pressed`, `Enter`/`Space`.
selectedboolean | undefinedfalseThe state of the toggle outside a group. Inside a group the value is run by the group.
removeLabelstring | undefinedundefinedThe name of the dismiss button as a whole. It overrides the one built from `label`.
removeTabindexnumber | undefinedundefinedThe `tabindex` of the dismiss button — for a chip inside an input field. There the tab stop belongs to the `<input>`, and the row of chips is walked with the arrows: in `GrInputTag` the ring leaves exactly one `0`, in `GrAutocomplete` there are none at all. The prop is needed because the ring is run by the parent — it knows both the order of the chips and what lies beyond the edge of the row.

Slots

SlotTypeDescription
defaultanyThe label of the chip. Inside a selectable chip — phrasing content only.
iconanyAn icon before the label.

Events

EventTypeDescription
remove[]
update:selected[value: boolean]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void | undefined
blur() => void | undefined
removeElHTMLButtonElement | nullThe dismiss button — the target of the parent’s roving focus. It is given away as an element rather than as a `focusRemove` method: the ring needs the node itself, and it decides for itself when and where to move the focus.

Examples 3

Tags

designfrontendнужен ревьюv0.25

Tags
<script setup lang="ts">
import { ref } from 'vue'

import { GrCard, GrChip } from '@feugene/granularity'

// Метки задачи: состав ведёт приложение, чип только просит его убрать.
const tags = ref(['design', 'frontend', 'нужен ревью', 'v0.25'])

function drop(tag: string): void {
  tags.value = tags.value.filter(item => item !== tag)
}
</script>

<template>
  <GrCard class="p-4">
    <div class="flex flex-wrap items-center gap-2">
      <GrChip
        v-for="tag in tags"
        :key="tag"
        :label="tag"
        tone="slate"
        closable
        @remove="drop(tag)"
      />
      <span v-if="!tags.length" class="text-sm text-[var(--gr-muted-fg)]">
        Меток не осталось
      </span>
    </div>
  </GrCard>
</template>

Icon

Icondepends on the showcase environment
<script setup lang="ts">
import { ref } from 'vue'

import IconCheck from '~icons/lucide/check'
import IconUser from '~icons/lucide/user'

import { GrChip } from '@feugene/granularity'

const subscribed = ref(false)
</script>

<template>
  <div class="flex flex-wrap items-center gap-2">
    <GrChip label="Анна Петрова" tone="azure" closable>
      <template #icon>
        <IconUser class="h-full w-full" />
      </template>
    </GrChip>

    <GrChip
      v-model:selected="subscribed"
      label="Подписаться на изменения"
      tone="success"
      selectable
    >
      <template v-if="subscribed" #icon>
        <IconCheck class="h-full w-full" />
      </template>
    </GrChip>

    <GrChip label="Архивная метка" tone="neutral" closable disabled />
  </div>
</template>

Tones

neutralprimarysuccesswarningdangerinfoslateazure
neutralprimarysuccesswarningdangerinfoslateazure
xssmmdlg

Tones
<script setup lang="ts">
import { GrChip } from '@feugene/granularity'

const tones = ['neutral', 'primary', 'success', 'warning', 'danger', 'info', 'slate', 'azure'] as const
const sizes = ['xs', 'sm', 'md', 'lg'] as const
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-2">
      <GrChip v-for="tone in tones" :key="tone" :tone="tone" :label="tone" />
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <GrChip v-for="tone in tones" :key="tone" :tone="tone" :label="tone" dark />
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <GrChip v-for="size in sizes" :key="size" :size="size" tone="info" :label="size" closable />
    </div>
  </div>
</template>

Accessibility

APG pattern
toggle button / option

Full keyboard contract of the package

Component documentationAll components