Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions .specs/chip.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ spec_version: 1
figma:
url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=476-948
node_id: 476:948
checksum: 7ad0a091cf8128450ec31f287c8ca500d949583d475198d1a113775b24879375
checksum: e72ea0f7d1667d07fcf47f47426ebff36d92cf86724ed0f89fc16f8e4ccd24c2
created: 2026-06-23
last_updated: 2026-06-26
last_updated: 2026-07-02
---

# Chip — Component Spec
Expand All @@ -26,7 +26,7 @@ import Chip from '@aziontech/webkit/chip'
</script>

<template>
<Chip label="Active" size="medium" removable @remove="onRemove" />
<Chip label="Active" size="medium" clickable removable @click="onEdit" @remove="onRemove" />
</template>
```

Expand All @@ -35,14 +35,16 @@ import Chip from '@aziontech/webkit/chip'
| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
| `label` | `string` | `''` | no | Fallback text when the default slot is empty. |
| `size` | `'small' \| 'medium'` | `'medium'` | no | Size token; `medium` is 24px tall, `small` is 20px. |
| `size` | `'small' \| 'medium'` | `'medium'` | no | Size token; `small` is a fixed 20px, `medium`'s height is driven by its vertical padding (~30px). |
| `removable` | `boolean` | `false` | no | When true, renders a trailing remove button that emits `remove`. |
| `clickable` | `boolean` | `false` | no | When true, the chip body becomes interactive (`role="button"`, focusable) and emits `click` on activation (click / Enter / Space). |

## Events

| Event | Payload | Notes |
|---|---|---|
| `remove` | `MouseEvent` | Fires when `removable` is true and the remove button is activated (click / Enter / Space), after the chip's exit (fade-out) animation completes, so the parent removes the chip once it has animated out. |
| `click` | `MouseEvent \| KeyboardEvent` | Fires only when `clickable` is true and the chip body is activated — by pointer, or `Enter` / `Space` while the root is focused. The trailing remove button stops propagation, so activating it emits `remove` only, never `click`. |

## Slots

Expand All @@ -52,17 +54,20 @@ import Chip from '@aziontech/webkit/chip'

## States

- Visual states: `default`, `hover`, `focus-visible` (on the remove button), `active`
- Visual states: `default`, `hover`, `focus-visible`, `active`
- `data-size` mirrors the `size` prop (`small` | `medium`)
- `data-removable` is present when the `removable` prop is true
- The root is a non-interactive container; only the remove button is focusable, and it shows a visible focus ring on `focus-visible`.
- `data-clickable` is present when the `clickable` prop is true
- When `clickable`, the root is an interactive `role="button"` with `tabindex="0"`: it shows `cursor-pointer`, a `::before` ghost-layer darkening overlay (`--bg-hover`) on `hover` and `active`, a `--border-strong` border on `active` (the pressed state, per Figma), and a visible focus ring on `focus-visible`. When not clickable, the root stays a non-interactive container.
- The remove button (when `removable`) is always independently focusable and shows its own focus ring on `focus-visible`.

## Motion & Animations

| Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback |
|---|---|---|---|
| remove (chip dismiss) | inline `opacity` transition (fade-out), matching the `Message` dismiss | `duration['fast-02']` · `curve['productive-exit']` (animations.js) | `motion-reduce:transition-none` |
| remove button hover/focus state change | `transition-colors duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none` |
| clickable hover / active (chip body) | `::before` ghost-layer `opacity` overlay (`--bg-hover`) shown on `hover` and `active`, only when `clickable`; `active` also flips the border to `--border-strong` | `before:duration-fast-02 before:ease-productive-entrance` (DESIGN.md § Interactive states) | `motion-reduce:before:transition-none` |

## Tokens

Expand All @@ -79,6 +84,8 @@ import Chip from '@aziontech/webkit/chip'
| spacing (small padding) | `var(--spacing-xs)` / `var(--spacing-xxs)` |
| spacing (label↔icon gap) | `var(--spacing-xxs)` |
| ring | `var(--ring-color)` |
| interactive overlay (clickable hover / active) | `var(--bg-hover)` (ghost layer) |
| border (clickable active) | `var(--border-strong)` |

## Theme gaps

Expand All @@ -90,18 +97,19 @@ import Chip from '@aziontech/webkit/chip'

## Accessibility (WCAG 2.1 AA)

- Visible focus: the remove button uses `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]`.
- Keyboard map: the root is not focusable; `Tab` focuses the remove button (when `removable`), `Enter`/`Space` activates it and emits `remove`.
- ARIA: the root is a non-interactive `<span>` container; the remove control is a real `<button type="button">` with `aria-label="Remove"`; the `pi pi-times` glyph is `aria-hidden="true"`.
- Visible focus: both the clickable root (when `clickable`) and the remove button use `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]`.
- Keyboard map: when `clickable`, the root is focusable (`tabindex="0"`) and `Enter` / `Space` emit `click` (`Space` is `preventDefault`ed to avoid scrolling). When `removable`, `Tab` reaches the remove button and `Enter` / `Space` activate it and emit `remove`. Keystrokes that originate on the remove button do not bubble to the root activation (the handler ignores events whose `target` is not the root itself).
- ARIA: when not `clickable`, the root is a non-interactive `<span>` container; when `clickable`, it is `role="button"` with `tabindex="0"`. The remove control is a real `<button type="button">` with `aria-label="Remove"`; the `pi pi-times` glyph is `aria-hidden="true"`.
- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including the remove icon.
- `motion-reduce:transition-none` on the remove button's color transition and on the chip's dismiss fade-out (the exit transition collapses to instant under reduced motion).
- Touch target: justified deviation — the chip height is 20–24px, so the remove button's touch target is below 40×40 px. This matches the design (the Chip is a compact inline token, not a primary action), and the larger surrounding chip remains the visible affordance.
- Touch target: justified deviation — the chip height is 20–30px, so the remove button's touch target is below 40×40 px. This matches the design (the Chip is a compact inline token, not a primary action), and the larger surrounding chip remains the visible affordance.

## Stories (Storybook)

- Default — the baseline Chip with `label`.
- Sizes — composite story rendering `small` and `medium` side by side, justified because `size` is a real axis with two values.
- Removable — args delta `removable: true`, wiring the `remove` event to the Actions panel; justified because `removable` is a real boolean state that changes the rendered anatomy (adds the trailing remove button) and the emitted event.
- Clickable — args delta `clickable: true`, wiring the `click` event to the Actions panel; justified because `clickable` is a real boolean state that turns the chip body into an interactive `role="button"` and emits a distinct event.

Types/Sizes canonical note: there is no `kind`/`severity` axis on Chip, so the `Types` story is omitted.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const meta = {
docs: {
description: {
component:
'A compact, dismissible token that labels a user-applied value, such as a removable filter on a data view. When `removable` is set, it renders a trailing button that emits the `remove` event.'
'A compact, dismissible token that labels a user-applied value, such as a removable filter on a data view. When `removable` is set, it renders a trailing button that emits the `remove` event; when `clickable` is set, the chip body becomes interactive and emits the `click` event.'
},
canvas: { sourceState: 'shown' }
}
Expand All @@ -38,9 +38,15 @@ const meta = {
control: 'boolean',
description: 'When true, renders a trailing remove button that emits remove.',
table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
},
clickable: {
control: 'boolean',
description:
'When true, the chip body becomes interactive (role=button, focusable) and emits click on activation (click / Enter / Space).',
table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }
}
},
args: { label: 'Label', size: 'medium', removable: false }
args: { label: 'Label', size: 'medium', removable: false, clickable: false }
}

export default meta
Expand Down Expand Up @@ -97,3 +103,20 @@ export const Removable = {
}
}
}

const CLICKABLE_MARKUP = '<Chip label="Label" size="medium" clickable />'

export const Clickable = {
args: { clickable: true },
render: Template,
argTypes: { onClick: { action: 'click' } },
parameters: {
docs: {
description: {
story:
'Clickable chip; the interactive body emits `click` on pointer or keyboard (Enter / Space).'
},
source: { code: toSfc(IMPORT, CLICKABLE_MARKUP) }
}
}
}
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default [
HTMLSelectElement: 'readonly',
HTMLInputElement: 'readonly',
MouseEvent: 'readonly',
KeyboardEvent: 'readonly',
Event: 'readonly',
Node: 'readonly',
// Node globals
Expand Down
79 changes: 55 additions & 24 deletions packages/webkit/src/components/inputs/chip/chip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,24 @@
interface Props {
/** Fallback text when the default slot is empty. */
label?: string
/** Size token; `medium` is 24px tall, `small` is 20px. */
/** Size token; `small` is a fixed 20px, `medium`'s height is driven by its vertical padding (~30px). */
size?: ChipSize
/** When true, renders a trailing remove button that emits remove. */
removable?: boolean
/** When true, the chip body becomes interactive and emits click on activation (click / Enter / Space). */
clickable?: boolean
}

withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
label: '',
size: 'medium',
removable: false
removable: false,
clickable: false
})

const emit = defineEmits<{
remove: [event: MouseEvent]
click: [event: MouseEvent | KeyboardEvent]
}>()

defineSlots<{
Expand Down Expand Up @@ -60,6 +64,26 @@
pendingRemoveEvent = undefined
}
}

function onClick(event: MouseEvent) {
if (!props.clickable) {
return
}

emit('click', event)
}

function onKeydown(event: KeyboardEvent) {
// Only the chip root drives activation; keys originating in the remove button are ignored.
if (!props.clickable || event.target !== event.currentTarget) {
return
}

if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('click', event)
}
}
</script>

<template>
Expand All @@ -76,31 +100,38 @@
:data-testid="testId"
:data-size="size"
:data-removable="removable || null"
:data-clickable="clickable || null"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
:style="removeTransitionStyle"
:class="attrs.class"
class="inline-flex items-center justify-center overflow-hidden border border-[var(--border-default)] border-[length:var(--border-width-default)] bg-[var(--bg-surface-raised)] text-[var(--text-default)] shadow-[var(--shadow-sm)] leading-none rounded-[var(--shape-elements)] gap-[var(--spacing-xxs)] data-[size=medium]:text-label-md data-[size=small]:text-label-sm data-[size=medium]:h-6 data-[size=small]:h-5 data-[size=medium]:py-[var(--spacing-xs)] data-[size=medium]:px-[var(--spacing-sm)] data-[size=small]:p-[var(--spacing-xs)] data-[size=medium]:data-[removable]:pr-[var(--spacing-xs)] data-[size=small]:data-[removable]:pr-[var(--spacing-xxs)]"
class="relative inline-flex items-center justify-center overflow-hidden border border-[var(--border-default)] border-[length:var(--border-width-default)] bg-[var(--bg-surface-raised)] text-[var(--text-default)] shadow-[var(--shadow-sm)] leading-none rounded-[var(--shape-elements)] before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[var(--bg-hover)] before:opacity-0 before:content-[''] before:transition-opacity before:duration-fast-02 before:ease-productive-entrance motion-reduce:before:transition-none data-[size=medium]:text-label-md data-[size=small]:text-label-sm data-[size=medium]:leading-none data-[size=small]:leading-none data-[size=small]:h-5 data-[size=medium]:py-[var(--spacing-xs)] data-[size=medium]:px-[var(--spacing-sm)] data-[size=small]:p-[var(--spacing-xs)] data-[size=medium]:data-[removable]:pr-[var(--spacing-xs)] data-[size=small]:data-[removable]:pr-[var(--spacing-xxs)] data-[clickable]:cursor-pointer data-[clickable]:hover:before:opacity-100 data-[clickable]:active:before:opacity-100 data-[clickable]:active:border-[var(--border-strong)] data-[clickable]:focus-visible:outline-none data-[clickable]:focus-visible:ring-2 data-[clickable]:focus-visible:ring-[var(--ring-color)] data-[clickable]:focus-visible:ring-offset-2 data-[clickable]:focus-visible:ring-offset-[var(--bg-canvas)]"
@click="onClick"
@keydown="onKeydown"
>
<slot v-if="$slots['default']" />
<span
v-else-if="label"
:data-testid="`${testId}__label`"
>
{{ label }}
<span class="relative z-[1] inline-flex items-center justify-center gap-[var(--spacing-xxs)]">
<slot v-if="$slots['default']" />
<span
v-else-if="label"
:data-testid="`${testId}__label`"
>
{{ label }}
</span>
<button
v-if="removable"
type="button"
aria-label="Remove"
:data-testid="`${testId}__remove`"
class="inline-flex shrink-0 items-center justify-center rounded-[var(--shape-elements)] text-[var(--text-default)] transition-colors duration-150 ease-out motion-reduce:transition-none hover:bg-[var(--bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]"
@click.stop="onRemove"
>
<i
class="pi pi-times flex shrink-0 items-center size-[14px]"
aria-hidden="true"
:data-testid="`${testId}__remove-icon`"
/>
</button>
</span>
<button
v-if="removable"
type="button"
aria-label="Remove"
:data-testid="`${testId}__remove`"
class="inline-flex shrink-0 items-center justify-center rounded-[var(--shape-elements)] text-[var(--text-default)] transition-colors duration-150 ease-out motion-reduce:transition-none hover:bg-[var(--bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]"
@click="onRemove"
>
<i
class="pi pi-times flex shrink-0 items-center size-[14px]"
aria-hidden="true"
:data-testid="`${testId}__remove-icon`"
/>
</button>
</span>
</Transition>
</template>
Loading