Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Fetch:** add the `historyMode` option and keep the `src` separation on popstate ([#656](https://github.com/studiometa/ui/pull/656))
- **Fetch:** report `fetch.file-not-uploaded` when a file control cannot be sent as a file ([#656](https://github.com/studiometa/ui/pull/656))

### Fixed

- **Fetch:** send a form submission's submitter and its `formaction`, `formmethod` and `formenctype` ([#656](https://github.com/studiometa/ui/pull/656))

## [v2.0.0-alpha.0](https://github.com/studiometa/ui/compare/1.11.1..2.0.0-alpha.0) (2026-09-03)

This is the first release of the v2 line. It moves every package onto [`@studiometa/js-toolkit` v4](https://js-toolkit-v4.studiometa.dev/), removes six component families that a newer component already covers, renames three components, merges `LargeText` and `CircularMarquee` into `Marquee`, rewrites `Tabs` on the WAI-ARIA Tabs pattern, redesigns `Cursor` around published CSS hooks, completes the `Carousel` family and gives it an accessibility contract, and changes the shape of every event payload. There is no compatibility layer.
Expand Down
8 changes: 8 additions & 0 deletions packages/docs/.vitepress/reference/public-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,14 @@ export const publicContractSymbols = [
href: '/reference/items/Fetch/js-api',
status: 'stable',
},
{
name: 'FetchRequestContext',
kind: 'type',
package: 'npm:@studiometa/ui',
importPath: '@studiometa/ui',
href: '/reference/items/Fetch/js-api',
status: 'stable',
},
{
name: 'FetchShopifyPartialProps',
kind: 'type',
Expand Down
64 changes: 63 additions & 1 deletion packages/docs/reference/items/Fetch/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Intercepting clicks on links, displaying a loader and updating the targets' cont

## Fetch from any element

`Fetch` normally reads its URL from an `<a href>` or `<form action>`, but the [`src` option](./js-api.md#src) lets it be driven from **any** element and triggered programmatically. In the following example the panel is a `<div>`: it combines `Fetch` with the [`InViewOnce`](../InViewOnce/index.md) and [`Action`](../Action/index.md) components so that its content is lazy-loaded the first time it scrolls into view, with a bare [`Fetch.fetch()`](./js-api.md#fetch-url-url-string-requestinit-requestinit) call that resolves the `src` URL on its own.
`Fetch` normally reads its URL from an `<a href>` or `<form action>`, but the [`src` option](./js-api.md#src) lets it be driven from **any** element and triggered programmatically. In the following example the panel is a `<div>`: it combines `Fetch` with the [`InViewOnce`](../InViewOnce/index.md) and [`Action`](../Action/index.md) components so that its content is lazy-loaded the first time it scrolls into view, with a bare [`Fetch.fetch()`](./js-api.md#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) call that resolves the `src` URL on its own.

<llm-exclude>
<PreviewPlayground
Expand Down Expand Up @@ -69,6 +69,68 @@ In the following example, we intercept a form submission, display a loader and u

</llm-only>

## Pagination with submit buttons

An intercepted submission sends the button that caused it, so a set of `<button type="submit" name="page">` controls is a paginator with no script of its own. Each button carries its own `page` value, and the hidden `orderby` field travels with every submission. See [form submissions](./js-api.md#form-submissions).

<llm-exclude>
<PreviewPlayground
:html="() => import('./stories/pagination/app.twig')"
:script="() => import('./stories/pagination/app.ts?raw')"
/>
</llm-exclude>
<llm-only>

:::code-group

<<< ./stories/pagination/app.twig
<<< ./stories/pagination/app.ts

:::

</llm-only>

## Live search with a separate source

A live search displays one URL and requests another. The form's `action` is the full results page a visitor can copy and a no-JS submission reaches; the [`src` option](./js-api.md#src) points the enhanced request at a fragment endpoint. [`historyMode`](./js-api.md#historymode) set to `replace` keeps the address bar in step without spending one history entry per keystroke.

::: code-group

```html [index.html]
<form
id="search"
action="/help"
method="get"
data-component="Fetch"
data-option-src="/apps/search?view=fragment"
data-option-history
data-option-history-mode="replace">
<input
type="search"
name="q"
data-component="Action"
data-on:input.debounce300="Fetch(#search)->target.fetch()" />
</form>

<div id="search-results">…</div>
```

```js twoslash [app.ts]
import { registerComponent } from '@studiometa/js-toolkit';
import { Action, Fetch } from '@studiometa/ui';

registerComponent(Action);
registerComponent(Fetch);
```

:::

Typing `shipping` requests `/apps/search?view=fragment&q=shipping` and displays `/help?q=shipping`. Pressing back restores `/help`, and the request is rebuilt against the source: `/apps/search?view=fragment`, with the restored entry's parameters rather than the text still sitting in the input.

::: tip
`Fetch` aborts the request in flight when a new one starts, so a fast typist never sees an older response land. The `debounce300` modifier of the [`Action`](../Action/js-api.md#on) component spares the endpoint the keystrokes in between.
:::

## Modes

Modes are configured with the [`data-option-mode` attribute](./js-api.md#mode).
Expand Down
39 changes: 38 additions & 1 deletion packages/docs/reference/items/Fetch/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ We use `id` attributes to detect which content from the response should be used

### From any element

The `Fetch` component is not limited to `<a>` and `<form>` elements. Set the [`src` option](./js-api.md#src) to a URL and the component can be mounted on any element, then triggered programmatically with the [`fetch()` method](./js-api.md#fetch-url-url-string-requestinit-requestinit) — for example from an event via the [`Action`](../Action/index.md) component.
The `Fetch` component is not limited to `<a>` and `<form>` elements. Set the [`src` option](./js-api.md#src) to a URL and the component can be mounted on any element, then triggered programmatically with the [`fetch()` method](./js-api.md#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) — for example from an event via the [`Action`](../Action/index.md) component.

::: code-group

Expand All @@ -81,6 +81,43 @@ registerComponent(Fetch);

Calling `fetch()` without an argument uses the `src` option (or the element's `href` / `action` when it is a link or a form). You can also pass an explicit URL or a relative string, e.g. `Fetch.fetch('/other-content')`.

### Submit buttons

A submission sends the button that caused it, so pagination and alternate actions are markup:

```html
<form action="/projects" method="get" data-component="Fetch">
<input type="hidden" name="orderby" value="title" />
<button type="submit" name="page" value="1">1</button>
<button type="submit" name="page" value="2">2</button>
</form>

<div id="projects">…</div>
```

The second button requests `/projects?orderby=title&page=2` and swaps `#projects` with the same region from the response. The submitter's `formaction`, `formmethod` and `formenctype` are honoured too — see [form submissions](./js-api.md#form-submissions).

### History

Set the [`history` option](./js-api.md#history) to write each update to the browser history, and the [`historyMode` option](./js-api.md#historymode) to choose whether that costs an entry:

```html
<!-- One entry per page, so back returns to the previous one. -->
<a href="/projects/page/2" data-component="Fetch" data-option-history>2</a>

<!-- No entry per keystroke, so back leaves the search. -->
<form
action="/help"
method="get"
data-component="Fetch"
data-option-history
data-option-history-mode="replace">
<input type="search" name="q" />
</form>
```

With `history` on, a back or forward navigation re-fetches the restored entry. When the request URL and the displayed URL differ — the [`src` option](./js-api.md#src) — the request is rebuilt against `src`, so its fixed parameters survive the replay. See [`historyUrl`](./js-api.md#historyurl).

### With a loader

Use the [`Action`](../Action/index.md) and [`Transition`](../Transition/index.md) components to display a loader while the fetch request is happening.
Expand Down
100 changes: 95 additions & 5 deletions packages/docs/reference/items/Fetch/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,35 @@ This option can be used to extract specific content from the response, but the m
- Type: `boolean`
- Default: `false`

Updates the browser's history when performing a request. The `historyPush` utility from [`@studiometa/js-toolkit`](https://js-toolkit-v4.studiometa.dev) will be used in the background.
Updates the browser's history when performing a request. The [`historyMode` option](#historymode) picks between the `historyPush` and `historyReplace` utilities from [`@studiometa/js-toolkit`](https://js-toolkit-v4.studiometa.dev), which write the [`historyUrl`](#historyurl).

The component also listens for `popstate` while this option is on, so a back or forward navigation re-fetches the restored entry and updates the same regions.

### `historyMode`

- Type: `'push' | 'replace'`
- Default: `'push'`

Picks the history writer, when the [`history` option](#history) is on.

- `push` adds one entry per update, so every update is one back press away.
- `replace` overwrites the current entry, so no update adds one.

Use `replace` for a control that fires often — a live search, a facet list, a map — where one entry per keystroke buries the page the visitor came from.

```html
<form
action="/help"
method="get"
data-component="Fetch"
data-option-src="/apps/search?view=fragment"
data-option-history
data-option-history-mode="replace">
<input type="search" name="q" />
</form>
```

Neither writer runs for an update `popstate` triggered: the entry being restored is already the current one.

### `requestInit`

Expand Down Expand Up @@ -129,6 +157,8 @@ This is handy for progressive enhancement, where the element's native `action`/`
</form>
```

The separation holds on a back or forward navigation too: the request is rebuilt against `src` rather than aimed at the displayed page. See [`historyUrl`](#historyurl).

## Getters

### `client`
Expand Down Expand Up @@ -177,17 +207,68 @@ Without `src` the two are identical. With it, history follows the element's own

Clicking that requests the `sections=listing` URL and pushes `/projects/page/2?orderby=title`.

A URL passed explicitly to [`fetch(url)`](#fetch-url-url-string-requestinit-requestinit) is pushed as given: a caller that named a URL meant that URL.
A URL passed explicitly to [`fetch(url)`](#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) is pushed as given: a caller that named a URL meant that URL.

The `historyMode` option picks how that URL is written — one entry per update, or none. See [`historyMode`](#historymode).

#### On back and forward navigation

A `popstate` rebuilds the request the same way, from the entry being restored instead of from the live controls:

- the destination is the restored entry, which the address bar already shows;
- the request URL is still the [`src`](#src) when one is set, with its fixed parameters intact;
- the restored entry's search parameters replace the form fields, which still hold whatever the visitor last typed.

So the entry `/help?q=shipping`, restored on the form of the [`historyMode` option](#historymode), requests `/apps/search?view=fragment&q=shipping`. The response is what brings the controls back in line.

::: tip
On a back or forward navigation the component re-fetches `window.location.href`, which is now the pushed URL rather than the `src` one. Keep the [`selector`](#selector) matching elements that exist in **both** responses — the full page and the lighter endpoint — or the two directions will not update the same regions.
Keep the [`selector`](#selector) matching elements that exist in **both** responses — the full page and the lighter endpoint — or the two directions will not update the same regions.
:::

### `requestInit`

- Return: [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit)

Returns the [`requestInit` option](#requestinit) with additionnal headers from the [`headers` option](#headers) [`headers[]` refs](./js-api.md#headers-1) and if the root element is a form with a `method="post"` attribute, its data as body
Returns the [`requestInit` option](#requestinit) with the headers of the [`headers` option](#headers) and the [`headers[]` refs](#headers-1), and, when the root element is a form, its method — plus its data as the body when that method is `post`.

The body follows the form's `enctype`, as a native submission does: `application/x-www-form-urlencoded` by default, a `FormData` for `multipart/form-data`, plain text for `text/plain`.

This getter describes a request with no submission behind it. A `submit` event builds the same parts from its submitter as well; see [form submissions](#form-submissions).

## Form submissions

An intercepted submission sends what a native one would send, the button that caused it included.

- The submitter is a successful control: `<button type="submit" name="page" value="2">` puts `page=2` in the request, and two buttons of the same name each send their own value.
- `formaction` overrides the form's `action` for that submission, so it overrides the destination and the URL written to history.
- `formmethod` overrides the form's `method`, moving the fields between the URL and the body.
- `formenctype` overrides the form's `enctype`, choosing how the body is encoded.
- Repeated names keep every value, and the usual successful-control rules apply.

```html
<form action="/projects" method="get" data-component="Fetch">
<input type="hidden" name="orderby" value="title" />
<button type="submit" name="page" value="1">1</button>
<button type="submit" name="page" value="2">2</button>
</form>
```

Pressing the second button requests `/projects?orderby=title&page=2`, with no script of its own.

The submitter belongs to the submission that carried it: a later [`fetch()`](#fetch-url-url-string-requestinit-requestinit-context-fetchrequestcontext) call, and a back or forward navigation, build their request without it.

### File controls

The effective enctype decides what a file control sends. `multipart/form-data` sends the file. Every other encoding sends the file's name, as a native submission does, and no GET submission uploads a file whatever the form declares.

A form that declares no `enctype` sends `application/x-www-form-urlencoded`, so a file control in it sends a name and not a file. `Fetch` reports that case on the diagnostic channel under the `fetch.file-not-uploaded` code. Declare `enctype="multipart/form-data"` on the form, or `formenctype="multipart/form-data"` on the submitter, to send the file itself.

```html
<form action="/upload" method="post" enctype="multipart/form-data" data-component="Fetch">
<input type="file" name="photo" />
<button type="submit">Upload</button>
</form>
```

## Refs

Expand All @@ -213,7 +294,7 @@ The example above will add a `x-my-token: some-not-sensible-token` header to the

## Methods

### `fetch(url?: URL | string, requestInit?: RequestInit)`
### `fetch(url?: URL | string, requestInit?: RequestInit, context?: FetchRequestContext)`

Performs the fetch request and updates the DOM with the response.

Expand All @@ -223,6 +304,7 @@ The declarative click, submit and popstate flows call this method for you, but i

- `url` (`URL | string`, optional): the URL to fetch. Defaults to the [`url` getter](#url), so a bare `fetch()` call uses the element's `href`, `action` or [`src` option](#src). A `string` is coerced into a `URL` resolved against the current location.
- `requestInit` ([`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit), optional): extra options merged into the [`requestInit` getter](#requestinit-1) for this call.
- `context` (`FetchRequestContext`, optional): what this one request overrides on the element it is built from — a `submitter` for [a form submission](#form-submissions), a `restoredUrl` for a back or forward navigation. The declarative flows fill it in; it is never kept on the instance, so nothing leaks into the next request.

```html
<div data-component="Action InView Fetch" data-option-src="/path" data-on:in-view="Fetch.fetch()">
Expand All @@ -242,6 +324,14 @@ Abort the current request.
Using an `Error` instance as the `reason` parameter of the `abort(reason?: any)` method will trigger the [`fetch-error` event](#fetch-error) along the [`fetch-abort` event](#fetch-abort).
:::

## Diagnostics

Every one is a development-only warning on the [toolkit diagnostic channel](https://js-toolkit-v4.studiometa.dev/).

| Code | Meaning |
| ------------------------- | -------------------------------------------------------------------------------------- |
| `fetch.file-not-uploaded` | A file control is sent as its filename because the effective enctype is not multipart. |

## Events

All events from the `Fetch` component bubble up the DOM tree, so they can be listened to from any parent element.
Expand Down
4 changes: 4 additions & 0 deletions packages/docs/reference/items/Fetch/stories/pagination/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { registerComponent } from '@studiometa/js-toolkit';
import { Fetch } from '@studiometa/ui';

registerComponent(Fetch);
48 changes: 48 additions & 0 deletions packages/docs/reference/items/Fetch/stories/pagination/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{#
The response is rendered by the API from this template, with the request's
query parameters as variables — so `page` is the value of the button that
was pressed, and `orderby` the hidden field that travels with every
submission. `verbatim` keeps both placeholders for the API to fill in.
#}
{%- set results -%}
{% verbatim %}
<div id="pagination-results" class="rounded border border-current/20 p-6">
<p>
Showing page <strong>{{ page|default(1) }}</strong>,
ordered by <strong>{{ orderby|default('title') }}</strong>.
</p>
</div>
{% endverbatim %}
{%- endset -%}

<div class="flex max-w-xl flex-col gap-6">
<div id="pagination-results" class="rounded border border-current/20 p-6">
<p>
Showing page <strong>1</strong>,
ordered by <strong>title</strong>.
</p>
</div>

{#
A plain GET form. Each button is a successful control of its own
submission, so the request carries the `page` value of the button that was
pressed, alongside the hidden `orderby` field.
#}
<form
data-component="Fetch"
method="get"
action="{{ twig_toolkit_url('/api/').withQueryParameter('content', results) }}"
class="flex items-center gap-2">
<input type="hidden" name="orderby" value="title">

{% for page in 1..3 %}
<button
type="submit"
name="page"
value="{{ page }}"
class="rounded border border-current/20 px-4 py-2 hover:bg-current/10">
{{ page }}
</button>
{% endfor %}
</form>
</div>
Loading
Loading