# Progress

> A bar for work with a known end, a spinner for work without one.

Use a determinate bar whenever you know how much is left — it is the only one that tells the reader anything. Omit `value` and the bar animates instead, which says "still working" and nothing more.

## Determinate

```javascript
stack({ gap: 'lg' },
  progress({ value: 25 }),
  progress({ value: 60 }),
  progress({ value: 100 }),
)
```

## Indeterminate

```javascript
progress()
```

A bar with no `label` is marked `aria-hidden` — a progressbar role with no accessible name tells a screen reader nothing, so an unlabelled bar is treated as decoration. Label anything a reader is meant to follow.

## Labels

A label names what is happening; `showValue` adds the percentage on the right.

```javascript
stack({ gap: 'lg' },
  progress({ value: 72, label: 'Rendering pages', showValue: true }),
  progress({ value: 30, max: 60, label: 'Optimising images', showValue: true }),
  progress({ label: 'Waiting for the deploy' }),
)
```

## Colors and height

```javascript
stack({ gap: 'lg' },
  progress({ value: 80, color: 'success', label: 'Passed', showValue: true }),
  progress({ value: 45, color: 'warning', label: 'Degraded', showValue: true }),
  progress({ value: 20, color: 'danger', label: 'Failing', showValue: true }),
  progress({ value: 60, color: 'neutral', height: 'xs' }),
  progress({ value: 60, color: 'primary', height: '1rem' }),
)
```

## A scale other than 100

`max` lets you pass the raw numbers — pages built out of pages total — instead of working out a percentage first.

```javascript
progress({ value: 118, max: 169, label: '118 of 169 pages', showValue: true })
```

## Moving it from the browser

A bar is server-rendered HTML: the percentage is a custom property on the fill and a number in `aria-valuenow`, and nothing on the page changes either on its own. Give the bar an `id` and `setProgress` moves both together — the fill, the announced value, and the percentage beside the label.

`src/main.js`

```javascript
import { setProgress } from 'sitelo/ui/client'

const request = new XMLHttpRequest()

request.upload.addEventListener('progress', (event) => {
  setProgress('upload', event.loaded, { max: event.total })
})
```

The maximum is remembered, so later calls are just a value. Or reach the module the way the components reach theirs, and skip the bundle entirely:

`Anywhere`

```javascript
button({ onclick: "import('/su/progress.js').then(m=>m.set('upload',100))" }, 'Finish')
```

Passing `null` — or anything that is not a finite number — hands the bar back to the indeterminate animation, so work that stops reporting numbers does not have to be special-cased. `getProgress()` reads the current value back, on the bar’s own scale.

## Try it

This page loads the runtime, so the buttons below really do move the bar.

```javascript
stack({ gap: 'md' },
  progress({ id: 'demo-progress', value: 0, label: 'Uploading', showValue: true }),
  stack({ direction: 'row', gap: 'sm', wrap: true },
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',0))" }, 'Reset'),
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',35))" }, '35%'),
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',80))" }, '80%'),
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',100))" }, 'Done'),
    button({ size: 'sm', variant: 'ghost', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',null))" }, 'Unknown'),
  ),
)
```

An unlabelled bar is moved too, but it stays `aria-hidden` — it was rendered without a name on purpose, and announcing a value on it now would put a nameless progressbar in the accessibility tree.

## Spinner

There is no spinner component — the spinner is an icon, and `spin` is what turns it. Like every icon it is sized in `em`, so it matches whatever text it sits beside without being told a size.

```javascript
stack({ direction: 'row', gap: 'lg', align: 'center' },
  icon('spinner', { spin: true, size: 'sm' }),
  icon('spinner', { spin: true }),
  icon('spinner', { spin: true, size: 'lg' }),
)
```

## Spinner in context

Give a standalone spinner a `label` so it is announced. One inside a button does not need one — the button already says what it is doing.

```javascript
stack({ gap: 'md' },
  stack({ direction: 'row', gap: 'sm', align: 'center' },
    icon('spinner', { spin: true, label: 'Loading' }),
    text({ variant: 'small', tone: 'muted' }, 'Fetching the latest build…'),
  ),
  stack({ direction: 'row', gap: 'sm' },
    button({ loading: true }, 'Deploying'),
    button({ variant: 'outline', loading: true }, 'Checking links'),
  ),
)
```

## Props

`progress()` — exported as `progressBar` too:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | How far along. Omit for the indeterminate animation. |
| `max` | `number` | `100` | What value counts as complete. |
| `color` | `'primary' \| 'neutral' \| 'success' \| 'warning' \| 'danger'` | `'primary'` | Fill colour. |
| `label` | `Child` | — | Text above the bar; also its accessible name. |
| `showValue` | `boolean` | `false` | Show the percentage beside the label. |
| `height` | `Space` | `'0.5rem'` | Bar thickness. |

`setProgress()` from `sitelo/ui/client`:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `target` | `Element \| string` | — | The bar, or the id of one. A selector is tried if no element has that id. |
| `value` | `number \| null` | — | Where to move it. null returns it to the indeterminate animation. |
| `options.max` | `number` | `100` | What counts as complete. Remembered for the calls after it. |

The spinner has no props of its own — it is `icon('spinner', { spin: true })`, and takes whatever `icon()` takes.
