# SpeedDial

A floating main button that expands a list of sub-actions, commonly used for quick actions like "New/Share"; `aria-expanded` stays in sync, clicking outside / Esc collapses it, with no orphan popups.

> The demos add `style="position: static"` to avoid fixed positioning affecting the page layout; in real use it is fixed to the bottom-right by default. The expansion direction of the sub-actions is controlled by `direction`.

## Expansion direction

`direction` supports `up` (default) / `down` / `left` / `right`; the first sub-action is always closest to the main button.

<DemoBlock title="Directions: up / down / left / right">
  <div style="display: flex; gap: var(--oas-space-5); align-items: center; min-height: 200px; width: 100%">
    <div style="width: 96px; height: 160px">
      <oas-speed-dial style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
    <div style="width: 96px; height: 160px">
      <oas-speed-dial direction="down" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
    <div style="width: 220px; height: 80px; display: flex; align-items: center">
      <oas-speed-dial direction="right" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
    <div style="width: 220px; height: 80px; display: flex; align-items: center; justify-content: flex-end">
      <oas-speed-dial direction="left" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
  </div>
</DemoBlock>

## Cascade animation

On expansion, the sub-actions fade in one after another (`transition-delay = index × 30ms` per item) and disappear simultaneously on collapse. The animation is built in with no configuration needed; under `prefers-reduced-motion` the cascade delay is zeroed and transitions are disabled (everything appears at once, for accessibility). The demos above already use it when expanded.

## Arc geometry expansion

`geometry` controls how the sub-actions are laid out: `linear` (default, chained layout) / `circle` (full circle, evenly spaced starting from the top) / `semi-circle` (a 180° half circle around the `direction` axis) / `quarter-circle` (a 90° arc whose starting quadrant follows `direction`: up=top-left, down=bottom-right, left=bottom-left, right=top-right). `radius` sets the arc radius in px (default 96, invalid values fall back to 96).

Angle rule: `circle` starts at the top and spaces evenly clockwise; `semi-circle` sweeps from "main direction − 90°" to "main direction + 90°" (up=upper half, down=lower half, left=left half, right=right half); `quarter-circle` is a 90° arc starting at the main direction (the first action stays closest to the main button). In arc mode the sub-actions no longer participate in the chained layout; the `--cascade-i` cascade and `hide-label` bubbles are unaffected, and under `prefers-reduced-motion` the arc transition is skipped (actions appear in place).

<DemoBlock title="circle: full circle spaced from the top">
  <div style="display: flex; align-items: center; justify-content: center; height: 280px; width: 100%">
    <oas-speed-dial geometry="circle" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"},{"label":"Upload","icon":"upload"}]'></oas-speed-dial>
  </div>
</DemoBlock>

<DemoBlock title="semi-circle: half circle around the direction axis">
  <div style="display: flex; gap: var(--oas-space-5); align-items: center; justify-content: center; height: 220px; width: 100%">
    <div style="width: 220px; height: 200px; position: relative">
      <oas-speed-dial style="position: absolute; bottom: 0; left: 50%; margin-left: -24px" geometry="semi-circle" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
  </div>
</DemoBlock>

<DemoBlock title="quarter-circle: 90° quadrant follows direction (up=top-left / right=top-right)">
  <div style="display: flex; gap: var(--oas-space-5); align-items: flex-end; height: 220px; width: 100%">
    <div style="width: 200px; height: 180px; position: relative">
      <oas-speed-dial style="position: absolute; right: 0; top: 0" geometry="quarter-circle" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
    <div style="width: 200px; height: 180px; position: relative">
      <oas-speed-dial style="position: absolute; left: 0; top: 0" geometry="quarter-circle" direction="right" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
    </div>
  </div>
</DemoBlock>

<DemoBlock title="radius: adjust the arc radius (circle)">
  <div style="display: flex; align-items: center; justify-content: center; height: 320px; width: 100%">
    <oas-speed-dial id="sd-arc" geometry="circle" radius="96" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"},{"label":"Upload","icon":"upload"}]'></oas-speed-dial>
  </div>
  <oas-button-group>
    <oas-button size="small" type="primary" onclick="event.stopPropagation(); sdArc(96)">radius 96</oas-button>
    <oas-button size="small" onclick="event.stopPropagation(); sdArc(128)">radius 128</oas-button>
    <oas-button size="small" onclick="event.stopPropagation(); sdArc(160)">radius 160</oas-button>
  </oas-button-group>
</DemoBlock>

## Text-only actions

`icon` can be omitted to show only the label; for icon-only sub-actions use `hide-label` (see the next section).

<DemoBlock title="Text only / icon only">
  <div style="width: 120px; height: 160px">
    <oas-speed-dial style="position: static" actions='[{"label":"Share"},{"label":"Favorite"},{"label":"Report"}]'></oas-speed-dial>
  </div>
</DemoBlock>

## Icon-only sub-actions (label on hover)

With `hide-label: true` a sub-action renders only its icon (a circular small button) and the label is visually hidden; hovering / keyboard focus reveals a text bubble (pure CSS transition, no JS overlay). The bubble position adapts to the expansion direction: `up` places it to the left of the action, `down` to the right, `left`/`right` above — always on the outer side of the action so it never covers neighboring actions. An action with `hide-label` but no renderable icon falls back to showing the label (a one-time `console.warn` in dev). Arrow-key roving navigation and the `oas-select` event are unaffected.

<DemoBlock title="Icon-only sub-actions (label on hover)">
  <div style="width: 260px; height: 220px">
    <oas-speed-dial style="position: static" actions='[{"label":"Copy","icon":"copy","hide-label":true},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash","hide-label":true},{"label":"Upload","icon":"upload","hide-label":true}]'></oas-speed-dial>
  </div>
</DemoBlock>

## Events

Clicking the main button to expand/collapse fires `oas-open` (`detail: { open, reason }`); clicking a sub-action fires `oas-select` (`detail: { index, label }`) and collapses automatically. `reason` marks the source of the open/collapse: `toggle` (main button click) / `outside` (outside click) / `escape` (Esc) / `select` (action selection) / `hover` (hover trigger).

<DemoBlock title="Event feedback">
  <div style="width: 200px; height: 160px">
    <oas-speed-dial id="sd-event" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
  </div>
  <span id="sd-out" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 200px"></span>
</DemoBlock>

## Hover trigger

With `trigger="hover"`, hovering the main button expands and moving away collapses after a 120ms grace period (moving back into the panel during the grace period keeps it open); touch devices automatically fall back to `click` behavior.

<DemoBlock title="Hover trigger">
  <div style="width: 200px; height: 160px">
    <oas-speed-dial trigger="hover" id="sd-hover" style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
  </div>
  <span id="sd-hover-out" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 200px"></span>
</DemoBlock>

## Custom main button icon

The main button shows a default "＋"; pass a custom icon through the default slot (it still rotates 45° when expanded).

<DemoBlock title="Custom main button icon">
  <div style="width: 200px; height: 160px">
    <oas-speed-dial style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"}]'>
      <svg viewBox="0 0 16 16" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="M8 3v10M3 8h10"/>
      </svg>
    </oas-speed-dial>
  </div>
</DemoBlock>

## Keyboard navigation

When expanded, the first sub-action is focused automatically (hover-triggered expansion does not steal focus); arrow keys move between actions (vertical expansion uses `ArrowUp`/`ArrowDown`, horizontal expansion uses `ArrowLeft`/`ArrowRight`, wrapping around), `Home`/`End` jump to the first/last, and `Esc` collapses and returns focus to the main button.

<DemoBlock title="Keyboard navigation">
  <div style="width: 200px; height: 160px">
    <oas-speed-dial style="position: static" actions='[{"label":"Copy","icon":"copy"},{"label":"Edit","icon":"edit"},{"label":"Delete","icon":"trash"}]'></oas-speed-dial>
  </div>
</DemoBlock>

## Controlled open

The `open` attribute is controlled: setting/removing it externally expands/collapses, and the component's own clicks also sync the attribute and fire `oas-open` (clicking outside / Esc still collapses).

<DemoBlock title="Controlled expansion">
  <div style="width: 200px; height: 160px">
    <oas-speed-dial id="sd-ctrl" style="position: static" actions='[{"label":"New","icon":"plus"},{"label":"Upload","icon":"upload"},{"label":"Download","icon":"download"}]'></oas-speed-dial>
  </div>
  <oas-button-group>
    <oas-button type="primary" size="small" onclick="event.stopPropagation(); sdCtrl(true)">Expand</oas-button>
    <oas-button size="small" onclick="event.stopPropagation(); sdCtrl(false)">Collapse</oas-button>
    <oas-tag id="sd-status" type="info">open: false</oas-tag>
  </oas-button-group>
</DemoBlock>

<script setup>
import { onMounted } from 'vue'
onMounted(() => {
  const el = document.getElementById('sd-event')
  const out = document.getElementById('sd-out')
  el?.addEventListener('oas-open', (e) => {
    out.textContent = `oas-open: { open: ${e.detail.open}, reason: "${e.detail.reason}" }`
  })
  el?.addEventListener('oas-select', (e) => {
    out.textContent = `oas-select: { index: ${e.detail.index}, label: "${e.detail.label}" }`
  })

  const hover = document.getElementById('sd-hover')
  const hoverOut = document.getElementById('sd-hover-out')
  hover?.addEventListener('oas-open', (e) => {
    hoverOut.textContent = `oas-open: { open: ${e.detail.open}, reason: "${e.detail.reason}" }`
  })

  const ctrl = document.getElementById('sd-ctrl')
  const status = document.getElementById('sd-status')
  if (ctrl && status) {
    const sync = () => {
      status.textContent = `open: ${ctrl.hasAttribute('open')}`
    }
    window.sdCtrl = (open) => {
      if (open) ctrl.setAttribute('open', '')
      else ctrl.removeAttribute('open')
    }
    sync()
    // Component clicks, outside clicks and Esc all change `open`; keep the status in sync with a MutationObserver
    new MutationObserver(sync).observe(ctrl, { attributes: true, attributeFilter: ['open'] })
  }

  const arc = document.getElementById('sd-arc')
  window.sdArc = (r) => {
    arc?.setAttribute('radius', String(r))
  }
})
</script>

## API

### oas-speed-dial

#### Attributes

| Attribute | Description | Type | Default |
| --- | --- | --- | --- |
| `actions` | Sub-action JSON (`[{ label, icon?, 'hide-label'? }]`; `hide-label: true` renders the sub-action as a circular icon-only button with the label visually hidden, shown as a text bubble on hover/keyboard focus; falls back to showing the label when no renderable icon is provided) | `string` | `[]` |
| `direction` | Expansion direction | `string` | `up` |
| `geometry` | Expansion geometry: `linear` (default, chained layout) / `circle` (full circle, evenly spaced from the top) / `semi-circle` (half circle around the `direction` axis) / `quarter-circle` (90° arc whose starting quadrant follows `direction`: up=top-left, down=bottom-right, left=bottom-left, right=top-right) | `string` | `linear` |
| `open` | Expanded state (controlled) | `boolean` | — |
| `radius` | Arc radius in px (default 96; only effective when `geometry` is not `linear`; invalid values fall back to 96) | — | — |
| `trigger` | Trigger mode: `click` (default) \| `hover` (open on hover, collapse on leave with a 120ms grace period; falls back to click on touch devices) | `string` | `click` |

#### Events

| Event | Description |
| --- | --- |
| `oas-open` | Expanded/collapsed, `detail: { open, reason }`; reason is `toggle` / `outside` / `escape` / `select` / `hover` (source marker, backward compatible with the open field) |
| `oas-select` | A sub-action was selected, `detail: { index, label }`, then it collapses automatically |

#### Slots

| Name | Description |
| --- | --- |
| default | Custom icon for the main button; when provided it replaces the default ＋ (45° rotation on expansion is preserved) |

#### CSS Variables

| CSS Variable | Default |
| --- | --- |
| `--oas-tooltip-bg` | `var(--oas-color-text-primary)` |
| `--oas-tooltip-color` | `var(--oas-color-bg)` |

`SpeedDialAction` fields:

| Field   | Description                                          | Type     |
| ------- | ---------------------------------------------------- | -------- |
| `label` | Action text                                          | `string` |
| `icon`  | Icon name (a key of `@oas-ui/icons` built-in icon set)  | `string` |
| `hide-label` | When `true`, renders only the icon (a circular small button); the label is visually hidden and appears as a text bubble on hover / keyboard focus. Falls back to showing the label when no renderable icon is provided | `boolean` |

Behavior: clicking the main button toggles expansion (`aria-expanded` synced); `trigger="hover"` switches to hover trigger (touch devices fall back to click); clicking outside or pressing Esc collapses (after Esc, focus returns to the main button); clicking a sub-action collapses and fires `oas-select`; when expanded, the first sub-action is focused automatically (except for hover-triggered expansion) and arrow keys / Home / End navigate between actions; `hide-label` sub-actions keep an accessible name via `aria-label`. The default position is `position: fixed; bottom/right`, overridable. The document-level listener is only attached while expanded and disconnected during cleanup — no orphan popups.
