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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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]

### Fixed

- **@studiometa/ui-mapbox:** autoload map children declared on a `hidden` element ([#655](https://github.com/studiometa/ui/pull/655))

## [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
32 changes: 22 additions & 10 deletions packages/docs/guide/autoloading/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ Pin an exact version in production. An exact-version URL is immutable and stays

Each manifest entry carries a default mount strategy. Override it per element with `data-mount`. There are six:

| Strategy | The component mounts when… |
| --------------- | ------------------------------------------------------------------------- |
| `eager` | the runtime starts. The default for every `@studiometa/ui` component. |
| `visible` | the element crosses into the viewport, once. |
| `in-view` | the element is in the viewport, and unmounts when it leaves. Reversible. |
| `idle` | the browser is idle. |
| `interaction` | the first `pointerenter`, `pointerdown` or `focusin` on the element. |
| `media:<query>` | the media query matches, and unmounts when it stops matching. Reversible. |
| Strategy | The component mounts when… |
| --------------- | ------------------------------------------------------------------------------------ |
| `eager` | the element exists. No other condition. See [What `eager` means](#what-eager-means). |
| `visible` | the element crosses into the viewport, once. |
| `in-view` | the element is in the viewport, and unmounts when it leaves. Reversible. |
| `idle` | the browser is idle. |
| `interaction` | the first `pointerenter`, `pointerdown` or `focusin` on the element. |
| `media:<query>` | the media query matches, and unmounts when it stops matching. Reversible. |

```html
<div data-component="Dialog" data-mount="interaction">Mounts on hover, touch, or focus</div>
Expand All @@ -86,7 +86,19 @@ Each manifest entry carries a default mount strategy. Override it per element wi

The element's `data-mount` always wins over the manifest default. An invalid value reports a `component.invalid-mount-strategy` diagnostic and the entry's default is used.

The `@studiometa/ui` manifest ships every component as `eager`; `@studiometa/ui-mapbox` and `@studiometa/ui-motion` ship theirs as `visible`, so their heavy dependencies stay off the critical path.
### What `eager` means

`eager` reads like "load at page load". It is not that. The runtime never walks the manifest: it reads the `data-component` tokens declared on the elements of the page, and consults a manifest entry only for a token it finds there. Nothing loads for a token absent from the markup.

`eager` means the entry has no condition left to wait for beyond that element existing — no viewport crossing, no media query, no interaction. The import then runs on a background scheduler task, off the frame that discovered the element. Every element declaring the same token shares one import.

Use it for an element that renders nothing. An element carrying `hidden` is never rendered, so it never intersects the viewport and never receives a pointer or focus event: `visible`, `in-view` and `interaction` wait on it for a signal that cannot arrive.

### Package defaults

The `@studiometa/ui` manifest ships every component as `eager`. `@studiometa/ui-motion` ships every component as `visible`, so the Motion library stays off the critical path.

`@studiometa/ui-mapbox` splits. `MapboxMap` and `StoreLocator` render, so they are `visible` and the heavy `mapbox-gl` import waits for a map to approach the viewport. The twelve map children — clusters, cluster items, controls, the geocoder, images, layers, markers, popups and sources — configure the map from markup that renders nothing and is marked `hidden`, so they are `eager`. Their own modules are small; `mapbox-gl` still waits for `MapboxMap`.

## Component discovery

Expand Down Expand Up @@ -160,7 +172,7 @@ Declare an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Elemen
</script>
```

Load the Mapbox stylesheet yourself, and the geocoder stylesheet only when you use `MapboxGeocoder`. Give each map a valid access token through its `data-option-access-token`, and any other Mapbox `Map` option through `data-option-map-options`. Mapbox components default to the `visible` mount strategy, so the map code loads when a map crosses into the viewport.
Load the Mapbox stylesheet yourself, and the geocoder stylesheet only when you use `MapboxGeocoder`. Give each map a valid access token through its `data-option-access-token`, and any other Mapbox `Map` option through `data-option-map-options`. `MapboxMap` and `StoreLocator` default to the `visible` mount strategy, so the map code loads when a map crosses into the viewport. The map children default to `eager`, because their markup renders nothing and carries `hidden` — see [Package defaults](#package-defaults).

You own the `mapbox-gl` module, so its Web Worker is same-origin and a strict Content Security Policy works. When you load `mapbox-gl` from a CDN that builds its worker from a `blob:` URL, allow it: `Content-Security-Policy: worker-src blob:;`.

Expand Down
6 changes: 4 additions & 2 deletions packages/docs/migration-guides/vue-mapbox-gl/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,18 @@ registerManifest({
load: () => import('@studiometa/ui-mapbox/MapboxMap'),
},
MapboxMarker: {
mountStrategy: 'visible',
mountStrategy: 'eager',
load: () => import('@studiometa/ui-mapbox/MapboxMarker'),
},
MapboxPopup: {
mountStrategy: 'visible',
mountStrategy: 'eager',
load: () => import('@studiometa/ui-mapbox/MapboxPopup'),
},
});
```

The map renders, so `visible` keeps `mapbox-gl` off the critical path. The children are wrapped in a `hidden` element, which never intersects the viewport, so they take `eager` — see [Package defaults](/guide/autoloading/#package-defaults).

## Component mapping

Most Vue components have a same-named js-toolkit equivalent, which you author as `data-component` elements nested inside a `MapboxMap` instead of Vue templates. The clustering and store-locator components are the exception — they were re-architected around a new `MapboxClusterItem`, detailed below the table.
Expand Down
6 changes: 4 additions & 2 deletions packages/docs/reference/items/MapboxMap/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,16 @@ registerManifest({
load: () => import('@studiometa/ui-mapbox/MapboxMap'),
},
MapboxMarker: {
mountStrategy: 'visible',
mountStrategy: 'eager',
load: () => import('@studiometa/ui-mapbox/MapboxMarker'),
},
MapboxPopup: {
mountStrategy: 'visible',
mountStrategy: 'eager',
load: () => import('@studiometa/ui-mapbox/MapboxPopup'),
},
});
```

The map renders, so `visible` holds the `mapbox-gl` import back until a map approaches the viewport. A marker or a popup renders nothing and its element carries `hidden`, so it never intersects the viewport: give it `eager`, which waits for nothing beyond the element being on the page. This is what the package manifest already does — see [Package defaults](/guide/autoloading/#package-defaults).

Reach for a different strategy when it fits better — `idle`, `interaction`, `media:<query>` — and override any of them per element with `data-mount`. The [Autoloading](/guide/autoloading/) guide lists all six.
78 changes: 78 additions & 0 deletions packages/tests/autoload/hidden-elements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Base, getInstance, registerComponent, registerManifest } from '@studiometa/js-toolkit';
import { mount, settle, waitFor } from '@studiometa/js-toolkit/test';
import { MapboxMap } from '@studiometa/ui-mapbox';
import { manifest as mapboxManifest } from '@studiometa/ui-mapbox/manifest';
import { describe, expect, it } from 'vitest';
import { mountMap } from '../MapboxMap/harness.js';

/**
* The `hidden` attribute is the pattern `@studiometa/ui-mapbox` documents for
* the map children that render nothing. A `hidden` element is never rendered,
* so it never intersects the viewport: a `visible` manifest entry on it waits
* for a signal that can never arrive, and the dynamic `import()` never runs.
*
* The map children therefore declare `eager`, which has no condition left to
* wait for beyond the element existing.
*/

// `MapboxMap` renders and is the ancestor every child resolves through, so it
// is registered as a class here: the test is about the children's entries, and
// a `visible` root would make the whole map wait on the viewport.
const { MapboxMap: _root, StoreLocator: _storeLocator, ...childEntries } = mapboxManifest;

registerComponent(MapboxMap);
registerManifest(childEntries);

/** A component whose only job is to record that its lazy entry was imported. */
class HiddenProbe extends Base {
static config = { name: 'HiddenProbe' };
}

let probeImports = 0;
registerManifest({
HiddenProbe: {
mountStrategy: 'visible',
load() {
probeImports += 1;
return HiddenProbe;
},
},
});

describe('autoloading an element carrying the hidden attribute', () => {
it('loads and mounts an eager map child', async () => {
const { mapEl } = await mountMap(`
<div hidden data-component="MapboxNavigationControl" data-option-position="top-right"></div>
`);
const el = mapEl.querySelector<HTMLElement>('[data-component="MapboxNavigationControl"]')!;

const instance = await waitFor(() => getInstance(el, 'MapboxNavigationControl'));

expect(instance.$isMounted).toBe(true);
});

it('loads and mounts every hidden child of one map', async () => {
const { mapEl } = await mountMap(`
<div hidden data-component="MapboxFullscreenControl"></div>
<div hidden data-component="MapboxGeolocateControl"></div>
<div hidden data-component="MapboxMarker" data-option-lng-lat="[2, 48]"></div>
`);

for (const token of ['MapboxFullscreenControl', 'MapboxGeolocateControl', 'MapboxMarker']) {
const el = mapEl.querySelector<HTMLElement>(`[data-component="${token}"]`)!;
const instance = await waitFor(() => getInstance(el, token));

expect(instance.$isMounted).toBe(true);
}
});

it('never loads a visible entry, because a hidden element cannot intersect', async () => {
const root = await mount(`
<div hidden data-component="HiddenProbe"></div>
`);
await settle();

expect(probeImports).toBe(0);
expect(getInstance(root.firstElementChild as HTMLElement, 'HiddenProbe')).toBeUndefined();
});
});
85 changes: 76 additions & 9 deletions packages/tests/autoload/manifest.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Base, type BaseConstructor, type ComponentManifestEntry } from '@studiometa/js-toolkit';
import {
Base,
type BaseConstructor,
type ComponentManifest,
type ComponentManifestEntry,
type MountStrategy,
} from '@studiometa/js-toolkit';
import { manifest as uiManifest } from '@studiometa/ui/manifest';
import { manifest as mapboxManifest } from '@studiometa/ui-mapbox/manifest';
import { manifest as motionManifest } from '@studiometa/ui-motion/manifest';
Expand All @@ -11,21 +17,65 @@ function isBaseConstructor(value: unknown): value is BaseConstructor {
return typeof value === 'function' && value.prototype instanceof Base;
}

/**
* The two `@studiometa/ui-mapbox` components that render. They keep the package
* default `visible`; every other entry configures the map from markup that
* renders nothing and is therefore `eager`.
*/
const MAPBOX_VISIBLE_TOKENS: readonly string[] = ['MapboxMap', 'StoreLocator'];

const MAPBOX_EAGER_TOKENS: readonly string[] = [
'MapboxCluster',
'MapboxClusterItem',
'MapboxFullscreenControl',
'MapboxGeocoder',
'MapboxGeolocateControl',
'MapboxImage',
'MapboxImages',
'MapboxLayer',
'MapboxMarker',
'MapboxNavigationControl',
'MapboxPopup',
'MapboxSource',
];

type ManifestCase = readonly [
packageName: string,
manifest: ComponentManifest,
exports: Record<string, unknown>,
strategyFor: (token: string) => MountStrategy,
];

// A package declares one default strategy, and a component overrides it when
// that default cannot answer for it — so the expectation is per token, not per
// package.
const cases: readonly ManifestCase[] = [
['@studiometa/ui', uiManifest, uiExports as Record<string, unknown>, () => 'eager'],
[
'@studiometa/ui-mapbox',
mapboxManifest,
mapboxExports as Record<string, unknown>,
(token) => (MAPBOX_VISIBLE_TOKENS.includes(token) ? 'visible' : 'eager'),
],
[
'@studiometa/ui-motion',
motionManifest,
motionExports as Record<string, unknown>,
() => 'visible',
],
];

// A generated manifest entry holds only what the registry reads before the
// module is loaded: `{ load, mountStrategy }`. Everything else stays in the
// authoring catalog the generator reads from. The token is not restated inside
// the entry — it *is* the key — which is why the assertions below read the key
// and then check that the loaded class agrees with it.
describe.each([
['@studiometa/ui', uiManifest, uiExports as Record<string, unknown>, 'eager'],
['@studiometa/ui-mapbox', mapboxManifest, mapboxExports as Record<string, unknown>, 'visible'],
['@studiometa/ui-motion', motionManifest, motionExports as Record<string, unknown>, 'visible'],
] as const)('%s ./manifest export', (_packageName, manifest, exports, strategy) => {
it('declares the package mount strategy on every entry', () => {
describe.each(cases)('%s ./manifest export', (_packageName, manifest, exports, strategyFor) => {
it('declares the expected mount strategy on every entry', () => {
expect(Object.keys(manifest).length).toBeGreaterThan(0);

for (const entry of Object.values(manifest) as ComponentManifestEntry[]) {
expect(entry.mountStrategy).toBe(strategy);
for (const [token, entry] of Object.entries(manifest) as [string, ComponentManifestEntry][]) {
expect(entry.mountStrategy).toBe(strategyFor(token));
}
});

Expand All @@ -43,3 +93,20 @@ describe.each([
}
});
});

// Listing both halves keeps a new component from inheriting a strategy by
// accident: adding one to the catalog fails here until it is classified.
describe('@studiometa/ui-mapbox mount strategies', () => {
it('splits every entry between the rendered roots and the declarative children', () => {
const eager = Object.entries(mapboxManifest)
.filter(([, entry]) => (entry as ComponentManifestEntry).mountStrategy === 'eager')
.map(([token]) => token);
const visible = Object.entries(mapboxManifest)
.filter(([, entry]) => (entry as ComponentManifestEntry).mountStrategy === 'visible')
.map(([token]) => token);

expect(visible).toEqual([...MAPBOX_VISIBLE_TOKENS]);
expect(eager).toEqual([...MAPBOX_EAGER_TOKENS]);
expect(eager.length + visible.length).toBe(Object.keys(mapboxManifest).length);
});
});
28 changes: 12 additions & 16 deletions packages/ui-mapbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,23 @@ npm install @mapbox/mapbox-gl-geocoder

## Usage

Register each component your page uses: each one registers independently and resolves its parent map on its own. `mapbox-gl` is heavy (~230&nbsp;kB gzipped), so the recommended default is to lazy-register each component with js-toolkit's `importWhen*` helpers and the per-component subpaths (each subpath's default export is the component class), keeping the dependency out of your main bundle until a map is actually on the page:
Register each component your page uses: each one registers independently and resolves its parent map on its own.

```js
import { registerComponents, importWhenVisible } from '@studiometa/js-toolkit';
import { registerComponents } from '@studiometa/js-toolkit';
import { MapboxMap, MapboxMarker, MapboxPopup } from '@studiometa/ui-mapbox';

// Register only the components your page uses; order doesn't matter.
registerComponents(
importWhenVisible(() => import('@studiometa/ui-mapbox/MapboxMap'), 'MapboxMap'),
importWhenVisible(() => import('@studiometa/ui-mapbox/MapboxMarker'), 'MapboxMarker'),
importWhenVisible(() => import('@studiometa/ui-mapbox/MapboxPopup'), 'MapboxPopup'),
);
registerComponents(MapboxMap, MapboxMarker, MapboxPopup);
```

Other triggers are available too — `importWhenIdle`, `importOnInteraction` and `importOnMediaQuery` — see the [`importWhen*` helper docs](https://js-toolkit.studiometa.dev/api/helpers/importWhenVisible.html). Then author the map declaratively in your markup:
`mapbox-gl` is heavy (~230&nbsp;kB gzipped), so import the autoload entry instead to keep it out of your main bundle. It registers a lazy entry for every component of the package, and imports a module only when an element on the page declares its token:

```js
import '@studiometa/ui-mapbox/autoload';
```

Then author the map declaratively in your markup:

```html
<div
Expand All @@ -48,14 +51,7 @@ Other triggers are available too — `importWhenIdle`, `importOnInteraction` and
</div>
```

If you do not need code-splitting, register eagerly instead — each component is exported by name from the package:

```js
import { registerComponent } from '@studiometa/js-toolkit';
import { MapboxMap } from '@studiometa/ui-mapbox';

registerComponent(MapboxMap);
```
`MapboxMarker` renders nothing of its own — it configures the map from its attributes — so its element carries `hidden`. Every map child works that way, and the autoload manifest gives them the `eager` mount strategy for that reason: a `hidden` element is never rendered and never intersects the viewport, so a strategy waiting for a viewport crossing would never load them. `MapboxMap` and `StoreLocator` do render and keep `visible`, which is what holds the `mapbox-gl` import back until a map approaches the viewport. Override any of it per element with `data-mount`.

Do not forget to include the `mapbox-gl` stylesheet so the map renders correctly.

Expand Down
Loading
Loading