Patterns · Settings
Image action button
The compact per-option image control — an image icon button with empty / loading / thumbnail states and a Change/Edit/Delete menu; it emits intents, the host owns the modals.
Examples
State
Per-option image control — empty · loading · filled (hover the thumbnail for the menu)
<!-- Paste-and-run: save as .html and open in a browser. No build step.
<aha-image-action-button> is the SAME shared custom element React and Vue consume — here in its
native form. Theming comes only from the --aha-* tokens in tokens.css. It EMITS intents
(add/change/edit/delete) — the host runs the upload/crop modals; this control owns none. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/tokens.css">
<script type="module">
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-image-action-button.js'; // registers <aha-image-action-button>
</script>
<div style="display:flex; gap:12px; align-items:center">
<!-- empty: click emits `add` -->
<aha-image-action-button id="img" state="empty"></aha-image-action-button>
<!-- loading: spinner at the same size, no layout jump -->
<aha-image-action-button state="loading"></aha-image-action-button>
<!-- filled: hover for the pencil overlay, click for Change/Edit/Delete -->
<aha-image-action-button state="filled" src="https://picsum.photos/80"></aha-image-action-button>
</div>
<script type="module">
const el = document.getElementById('img');
// The host owns the flow: on `add`, open your uploader, flip to loading, then filled + src.
el.addEventListener('add', () => {
el.state = 'loading';
setTimeout(() => { el.src = 'https://picsum.photos/80'; el.state = 'filled'; }, 800);
});
el.addEventListener('delete', () => { el.removeAttribute('src'); el.state = 'empty'; });
</script>
import '@ahaslides-product/design/aha-image-action-button'; // registers <aha-image-action-button>
import { useRef, useEffect } from 'react';
// React 18 needs a thin wrapper; React 19 can use <aha-image-action-button> directly.
// It emits intents; the host owns the modals — drive state/src from your upload flow.
function AhaImageActionButton({ state, src, label, disabled, onAdd, onChange, onEdit, onDelete }) {
const ref = useRef();
useEffect(() => {
const el = ref.current;
const map = { add: onAdd, change: onChange, edit: onEdit, delete: onDelete };
const on = Object.entries(map).map(([k, fn]) => { const h = () => fn?.(); el.addEventListener(k, h); return [k, h]; });
return () => on.forEach(([k, h]) => el.removeEventListener(k, h));
}, [onAdd, onChange, onEdit, onDelete]);
return (
<aha-image-action-button
ref={ref}
state={state || 'empty'}
{...(src ? { src } : {})}
{...(label ? { label } : {})}
{...(disabled ? { disabled: '' } : {})}
/>
);
}
// usage — the host runs the uploader and flips state
<AhaImageActionButton
state={img ? 'filled' : 'empty'}
src={img}
onAdd={openUploader}
onEdit={openCropper}
onDelete={() => setImg(null)}
/>
// main.ts — register the element + mark aha-* as custom elements
import '@ahaslides-product/design/aha-image-action-button'; // registers <aha-image-action-button>
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('aha-');
// Component.vue
<script setup>
import { ref } from 'vue';
const img = ref(null); // the option's image URL
const state = ref('empty'); // empty | loading | filled
function onAdd() { state.value = 'loading'; upload().then((url) => { img.value = url; state.value = 'filled'; }); }
function onDelete() { img.value = null; state.value = 'empty'; }
</script>
<template>
<!-- emits intents; the host owns the upload/crop modals -->
<aha-image-action-button
:state="state"
:src="img || undefined"
@add="onAdd"
@edit="openCropper"
@delete="onDelete"
/>
</template>
API
| Prop | Type | Default | Notes |
|---|---|---|---|
state | empty | loading | filled | empty | The control's state — drives which affordance shows |
src | string | — | The thumbnail image URL when filled |
label | string | Add image | The empty-state tooltip / aria-label |
disabled | boolean | false | Non-interactive; grey fill |
add / change / edit / delete | CustomEvent | — | Composed intents — the host runs the corresponding modal/flow |
Install
# .npmrc — once: point the @ahaslides-product scope at GitHub Packages
@ahaslides-product:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} # a GitHub token with read:packages
npm i @ahaslides-product/design
import '@ahaslides-product/design/tokens.css'; // once, at the app root
import '@ahaslides-product/design/aha-image-action-button'; // registers <aha-image-action-button>
Agent feed for this component (absolute, fetchable anywhere): image-action-button.agent.json · image-action-button.md · image-action-button.llms.txt
When to use
When to use
- Image action button — a per-OPTION image cell in an option row
- Image dropzone — a standalone MAIN image field where the image IS the field (SETTINGS-44)
Per-option image support is all-or-nothing: present with its FULL state set, or the control is omitted entirely — never a disabled/greyed stub when unsupported (SETTINGS-22). It is a compact icon button, not a text "Image" button (SETTINGS-32). It emits intents; the host owns the upload/crop modals — never a self-built uploader.
Surfaces
editor settings
Spec
Trigger 36×36 icon button, radius 8, 1px #E3E3E3 border · Empty system-image-square glyph + "Add image" tooltip; click emits `add` · Loading spinner at the same 36×36 size — no layout jump; role=status, aria-label "Loading", aria-busy · Filled thumbnail + hover pencil overlay; click opens Change/Edit/Delete · Menu Change / Edit / Delete (Delete in #F5222D); each emits an intent · Intents add · change · edit · delete — host owns the modals, this owns none · Disabled #F1F1F1 fill, #EBEBEB border, not-allowed