# Virtual list

A list of tens of thousands of rows that scrolls smoothly on a phone. Demo: `/virtual-list`
(https://components.hybriel.worldapi.org/virtual-list).

Only the rows near the viewport exist in the page. Every row has the same fixed height, so the list knows
where row N is without measuring: a spacer as tall as the whole list (`itemCount × rowHeight`) makes the scroll
bar honest, and the few rows near the viewport (about 30 on a phone) are placed inside it. Scrolling a
13,449-row list to the end keeps the page at about 35 rows.

Pure Hybriel: one file, `virtual-list.hl`, with its own `Style`; no JavaScript file. It does not fetch anything:
**the host owns the data** and loads it page by page.

## Use it in an app

Copy `components/virtual-list/virtual-list.hl` (and `components/icons/icons.hl`, which it imports), then:

```
import VirtualList from './virtual-list.hl'

Number total = 13449
Hybrid chunk = []          // the rows the host has loaded
Number chunkFrom = 0       // the number of the first row in `chunk`

loadRange = (first, wanted) => {
	r = emit server myRows(first - 20 < 0 ? 0 : first - 20, 100)    // an app asks its server
	chunk = r.rows
	chunkFrom = r.offset
}
picked = (id, index) => { ... }

View {
	div { class = "my-box"      // gets a height, the list scrolls inside it
		VirtualList { itemCount = total  rowHeight = 56  loaded = chunk  loadedFrom = chunkFrom  onRange = loadRange  onPick = picked  selectedId = playingId }
	}
}
```

## Attributes

| Attribute | Meaning |
|---|---|
| `itemCount` | how many rows the whole list has |
| `rowHeight` | the height of every row in pixels (default 56); the rows are exactly this tall |
| `loaded` | the rows the host has loaded, `[{ id, title, sub, aside, img }]`; `img` is a thumbnail address or `''` (a plain square) and is loaded lazily |
| `loadedFrom` | the number of the first row in `loaded` (0-based) |
| `onRange` | a function the host hands in; called with `(first, count)` when rows near the viewport are not all in `loaded`; the host loads them and hands in new `loaded` / `loadedFrom` |
| `onPick` | called with `(id, index)` when a row is tapped |
| `selectedId` | the `id` of the row to mark (the accent colour on a surface) |
| `thumbs` | `"false"` leaves the thumbnail out |
| `overscan` | how many rows are kept above and below the viewport (default 10) |
| `listLabel`, `emptyText` | the accessible name of the list; the text shown when `itemCount` is 0 |
| content | anything written inside `VirtualList { … }` stands above the rows and scrolls away with them (an album's cover and title) |

`emit vlTop()` scrolls back to the first row; the host calls it after it changed the order or the filter.

## Behaviour

- Rows that the host has not delivered yet are blank rows of the same height, so the list never jumps; the host's
  `loaded` may be only a chunk of 100 rows around the viewport, a jump to the middle of the list asks for that part.
- The list fills the box the host gives `virtual-list` (`height: 100%` of its parent, the scrolling happens inside).
- A row is a button (keyboard Tab and Enter work), exactly `rowHeight` tall: the tap area is the row itself, no padding tricks, so the
  list stays dense. Thumbnail, the text block and the row share one centre line.
- Colours are the semantic tokens with a black/white/grey fallback (the base look); no frames.
- **Use one per page** for `vlTop()`, which locates the scrolling box with a document query. Composed components own their state.

## Not yet

No row measuring (all rows are one height), no sticky section headers, no horizontal lists, no keyboard arrow navigation between
rows (Tab moves along the rows that exist), and a row moved out of the window loses the keyboard focus.

## Initial position and remembering a visit

`VirtualList { centerId = playingId  scrollTop = savedOffset  onScroll = rememberOffset }`
uses ordinary parent member bindings and a parent function `rememberOffset = (top) => { ... }`.
The parent saves that pixel offset in its own persistent settings and supplies it again after reload.
The component does not choose a storage key or save application data itself.

`scrollTop` defaults to null and is applied once at the first mount tick. `centerId` defaults to
an empty string. A matching id in the initial `loaded` chunk is centered once, after restoring
the offset, so centering wins when both are supplied. Include that row in `loaded` and set
`loadedFrom` to the chunk's actual start; an id outside the initial chunk cannot be located
without the parent's data and leaves the saved position alone. Offsets are clamped by the browser.
Later changes to either input or the loaded rows do not recenter or restore the list again.
`selectedId` continues to control the highlighted row independently.

`onScroll(top)` defaults to null. While scrolling it reports the latest offset at most once
per 100 ms, including the final offset after the last event. It receives a number, in pixels.
