@ultimat3/ui 22.2.2 → 22.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CATALOG.md +13 -9
- package/README.md +12 -10
- package/package.json +5 -5
- package/src/components/Image.module.scss +6 -0
- package/src/components/Image.tsx +52 -16
- package/src/components/LocaleSwitcher.tsx +24 -3
- package/src/components/ThemeToggle.tsx +9 -2
- package/src/components/image-source.ts +65 -0
- package/src/index.ts +10 -3
package/CATALOG.md
CHANGED
|
@@ -478,17 +478,19 @@ A button whose only content is an icon, so `label` is mandatory: it becomes the
|
|
|
478
478
|
|
|
479
479
|
### Image
|
|
480
480
|
|
|
481
|
-
Zero-CLS image primitive: a plain <img>,
|
|
481
|
+
Zero-CLS image primitive: a plain <img>, or a <picture> around one when AVIF/WebP renditions are handed in. No JS, no fetch, no client state. The encoding half of the pipeline — AVIF/WebP renditions, measured dimensions — is the app's build step; the URLs come from `asset('assets/…')` in the page. This component emits exactly what it is handed and fabricates nothing, except the one thing it insists on: a reserved box.
|
|
482
482
|
|
|
483
483
|
| Prop | Type | Required | Notes |
|
|
484
484
|
|---|---|---|---|
|
|
485
|
-
| `src` | `string` | yes |
|
|
485
|
+
| `src` | `string` | yes | The fallback every browser can decode — usually the JPEG/PNG rendition. |
|
|
486
486
|
| `alt` | `string` | yes | Required, with no default: an <Image> whose meaning is undescribed is a type error at the call site. Pass `alt=""` for a decorative image, deliberately. |
|
|
487
|
-
| `variants` | `readonly ImageVariant[]` | — | Renditions
|
|
488
|
-
| `
|
|
489
|
-
| `
|
|
490
|
-
| `
|
|
487
|
+
| `variants` | `readonly ImageVariant[]` | — | Renditions of `src`'s own encoding; the descriptors and their order are derived. |
|
|
488
|
+
| `sources` | `ImageSources` | — | AVIF and WebP renditions, each a width list: `{ avif: [{ src: asset('…'), width: 640 }] }`. |
|
|
489
|
+
| `sizes` | `string` | — | Layout width of the box, e.g. `(max-width: 700px) 100vw, 620px`. Applies to every set. |
|
|
490
|
+
| `priority` | `boolean` | — | The LCP image, at most one per route: eager, `fetchpriority="high"`, never lazy. |
|
|
491
|
+
| `width` | `number` | — | Intrinsic size in CSS pixels, from the step that encoded it. Both, or `aspectRatio`. |
|
|
491
492
|
| `height` | `number` | — | |
|
|
493
|
+
| `aspectRatio` | `ImageAspectRatio` | — | `'16 / 9'` — the reservation when the intrinsic size is not known. Never beside width/height. |
|
|
492
494
|
| `class` | `string` | — | |
|
|
493
495
|
|
|
494
496
|
### InfiniteScroll
|
|
@@ -565,10 +567,12 @@ Locale picker. Option labels come from `Intl.DisplayNames` in each locale's own
|
|
|
565
567
|
|
|
566
568
|
| Prop | Type | Required | Notes |
|
|
567
569
|
|---|---|---|---|
|
|
568
|
-
| `locales` | `readonly Locale[]` | yes |
|
|
570
|
+
| `locales` | `readonly Locale[]` | yes | Every locale to offer, the DEFAULT first — `routedLocales()` from `@ultimat3/i18n`. |
|
|
569
571
|
| `value` | `Locale` | — | Defaults to the context locale. |
|
|
570
572
|
| `onLocaleChange` | `((locale: Locale) => void)` | — | |
|
|
571
|
-
| `hrefFor` | `((locale: Locale) => string)` | — | Render as links instead of a select, for a 0kb-JS `site/` route. |
|
|
573
|
+
| `hrefFor` | `((locale: Locale) => string)` | — | Render as links instead of a select, for a 0kb-JS `site/` route. Defaults, when `path` is given, to that page in each locale — `localizedPath`: unprefixed for the default, `/en/…` otherwise — so a site's switcher needs no href of its own. |
|
|
574
|
+
| `path` | `string` | — | The page's own path, prefixed or not — `new URL(props.url).pathname`. Turns on links mode. |
|
|
575
|
+
| `defaultLocale` | `Locale` | — | The unprefixed locale. Defaults to `locales[0]`, which is where `routedLocales()` puts it. |
|
|
572
576
|
| `class` | `string` | — | |
|
|
573
577
|
|
|
574
578
|
### Menu
|
|
@@ -879,7 +883,7 @@ Theme control. `toggle` flips light/dark; `select` also offers "system", which c
|
|
|
879
883
|
| Prop | Type | Required | Notes |
|
|
880
884
|
|---|---|---|---|
|
|
881
885
|
| `mode` | `'toggle' \| 'select'` | — | |
|
|
882
|
-
| `initial` | `Theme` | — |
|
|
886
|
+
| `initial` | `Theme` | — | The theme in force at server render — the toggle's glyph. Never the select's choice: nothing on the server knows whether the visitor chose it, so the select shows "system" until the effect reads storage. |
|
|
883
887
|
| `env` | `ThemeEnv` | — | Injectable for tests and for non-DOM hosts. |
|
|
884
888
|
| `class` | `string` | — | |
|
|
885
889
|
|
package/README.md
CHANGED
|
@@ -401,21 +401,23 @@ transparent inside a heading or a caption.
|
|
|
401
401
|
| `weight` | keys of `fontWeightTokens`: `normal` `medium` `semibold` `bold` |
|
|
402
402
|
| `as` | `span` (default) `p` `div` `strong` `em` |
|
|
403
403
|
|
|
404
|
-
`<Image>` is one `<img
|
|
405
|
-
|
|
404
|
+
`<Image>` is one `<img>`, or a `<picture>` around one when `sources` is given — no
|
|
405
|
+
JS, no fetch, no client state. `alt` is a required prop, so a missing description
|
|
406
|
+
is a type error rather than a review comment.
|
|
406
407
|
|
|
407
408
|
| Prop | Emitted |
|
|
408
409
|
|---|---|
|
|
409
410
|
| `variants` | `srcset`, descriptors derived and ordered ascending (`srcsetFor`) |
|
|
410
|
-
| `
|
|
411
|
+
| `sources` | `{ avif?, webp? }` width lists → `<source type="image/avif">`, then `image/webp`, before the `<img>` (`sourceSetsFor`) |
|
|
412
|
+
| `sizes` | `sizes`, verbatim, on the `<img>` and every `<source>` |
|
|
411
413
|
| `priority` | `loading="eager"` + `fetchpriority="high"`; otherwise `lazy` + `auto`, always `decoding="async"` |
|
|
412
|
-
| `width` + `height` |
|
|
414
|
+
| `width` + `height`, or `aspectRatio` | required — one of the two. Inlined attributes plus `--image-ratio` (`aspect-ratio`), so the box is always reserved (`reservedRatio`) |
|
|
413
415
|
|
|
414
|
-
Shipped here: the element. Measuring intrinsic dimensions
|
|
415
|
-
renditions
|
|
416
|
-
([
|
|
417
|
-
|
|
418
|
-
|
|
416
|
+
Shipped here: the element. Measuring intrinsic dimensions and encoding AVIF/WebP
|
|
417
|
+
renditions are build-pipeline steps; the URLs come from `asset('assets/…')` in
|
|
418
|
+
`@ultimat3/render` ([Static Assets](../../wiki/Static-Assets.md)). The component
|
|
419
|
+
emits what it is handed and fabricates nothing — no variants it was not given, no
|
|
420
|
+
dimensions it did not measure.
|
|
419
421
|
|
|
420
422
|
## Keyboard groups
|
|
421
423
|
|
|
@@ -543,7 +545,7 @@ of truth for the one thing the server decides.
|
|
|
543
545
|
| `X_UI_FORM_PATH_INVALID` | a form field or control name the path grammar cannot read (`items.0.price`, `items[]`, `__proto__`), or two control names describing different shapes for one path (`user` beside `user.name`) |
|
|
544
546
|
| `X_UI_CONTRAST_INSUFFICIENT` | a `defineTheme()` palette whose resolved channels put a pairing in `CONTRAST_PAIRS` below WCAG 2.2 AA — 4.5:1 for text, 3:1 for the focus ring. Only pairings the brand changed are measured; the cause names the measured ratio and the required one |
|
|
545
547
|
| `X_UI_QR_CAPACITY` | a `<QrCode>` value over version 3's 42-byte ceiling (byte mode, error-correction level M). The encoder draws versions 1–3 only; the cause names the byte count and the ceiling |
|
|
546
|
-
| `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors
|
|
548
|
+
| `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors, one dimension without the other, or no reserved box at all, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
|
|
547
549
|
|
|
548
550
|
### Error classes
|
|
549
551
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.3.0",
|
|
4
4
|
"description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"icons": "bun run src/icons/build-icons.ts"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@ultimat3/core": "22.
|
|
52
|
-
"@ultimat3/i18n": "22.
|
|
53
|
-
"@ultimat3/money": "22.
|
|
54
|
-
"@ultimat3/time": "22.
|
|
51
|
+
"@ultimat3/core": "22.3.0",
|
|
52
|
+
"@ultimat3/i18n": "22.3.0",
|
|
53
|
+
"@ultimat3/money": "22.3.0",
|
|
54
|
+
"@ultimat3/time": "22.3.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"solid-js": "^1.9.0"
|
|
@@ -17,3 +17,9 @@
|
|
|
17
17
|
color: t.role('fg-muted');
|
|
18
18
|
font-size: t.text(sm);
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
// A <picture> only chooses a source; it must not become a box of its own, or the <img>'s
|
|
22
|
+
// reservation above would sit inside an inline wrapper with a line box of its own.
|
|
23
|
+
.picture {
|
|
24
|
+
display: contents;
|
|
25
|
+
}
|
package/src/components/Image.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Zero-CLS image primitive: a plain <img>,
|
|
1
|
+
// Zero-CLS image primitive: a plain <img>, or a <picture> around one when AVIF/WebP renditions
|
|
2
|
+
// are handed in. No JS, no fetch, no client state.
|
|
2
3
|
//
|
|
3
|
-
// The
|
|
4
|
-
//
|
|
5
|
-
// is
|
|
6
|
-
// nothing: no variants it was not given, no dimensions it did not measure.
|
|
4
|
+
// The encoding half of the pipeline — AVIF/WebP renditions, measured dimensions — is the app's
|
|
5
|
+
// build step; the URLs come from `asset('assets/…')` in the page. This component emits exactly
|
|
6
|
+
// what it is handed and fabricates nothing, except the one thing it insists on: a reserved box.
|
|
7
7
|
|
|
8
8
|
import type { JSX } from 'solid-js';
|
|
9
9
|
import { cx } from '../cx';
|
|
@@ -11,44 +11,70 @@ import styles from './Image.module.scss';
|
|
|
11
11
|
import {
|
|
12
12
|
assertNonEmptySrc,
|
|
13
13
|
boxFor,
|
|
14
|
+
type ImageAspectRatio,
|
|
14
15
|
type ImageBox,
|
|
15
16
|
type ImageLoadingHints,
|
|
17
|
+
type ImageSourceSet,
|
|
18
|
+
type ImageSources,
|
|
16
19
|
type ImageVariant,
|
|
17
20
|
loadingHints,
|
|
18
|
-
|
|
21
|
+
reservedRatio,
|
|
22
|
+
sourceSetsFor,
|
|
19
23
|
srcsetFor,
|
|
20
24
|
} from './image-source';
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The box is ALWAYS reserved: the intrinsic size, or an explicit ratio when the size is not known
|
|
28
|
+
* (a CSS-sized hero). Neither is a type error here and `X_UI_INVALID_VALUE` at render.
|
|
29
|
+
*/
|
|
30
|
+
export type ImageDimensions =
|
|
31
|
+
| { width: number; height: number; aspectRatio?: undefined }
|
|
32
|
+
| { aspectRatio: ImageAspectRatio; width?: undefined; height?: undefined };
|
|
33
|
+
|
|
34
|
+
export interface ImageBaseProps {
|
|
35
|
+
/** The fallback every browser can decode — usually the JPEG/PNG rendition. */
|
|
23
36
|
src: string;
|
|
24
37
|
/**
|
|
25
38
|
* Required, with no default: an <Image> whose meaning is undescribed is a type
|
|
26
39
|
* error at the call site. Pass `alt=""` for a decorative image, deliberately.
|
|
27
40
|
*/
|
|
28
41
|
alt: string;
|
|
29
|
-
/** Renditions
|
|
42
|
+
/** Renditions of `src`'s own encoding; the descriptors and their order are derived. */
|
|
30
43
|
variants?: readonly ImageVariant[] | undefined;
|
|
31
|
-
/**
|
|
44
|
+
/** AVIF and WebP renditions, each a width list: `{ avif: [{ src: asset('…'), width: 640 }] }`. */
|
|
45
|
+
sources?: ImageSources | undefined;
|
|
46
|
+
/** Layout width of the box, e.g. `(max-width: 700px) 100vw, 620px`. Applies to every set. */
|
|
32
47
|
sizes?: string | undefined;
|
|
33
|
-
/** The LCP image, at most one per route: eager, high
|
|
48
|
+
/** The LCP image, at most one per route: eager, `fetchpriority="high"`, never lazy. */
|
|
34
49
|
priority?: boolean | undefined;
|
|
35
|
-
/** Intrinsic
|
|
50
|
+
/** Intrinsic size in CSS pixels, from the step that encoded it. Both, or `aspectRatio`. */
|
|
36
51
|
width?: number | undefined;
|
|
37
52
|
height?: number | undefined;
|
|
53
|
+
/** `'16 / 9'` — the reservation when the intrinsic size is not known. Never beside width/height. */
|
|
54
|
+
aspectRatio?: ImageAspectRatio | undefined;
|
|
38
55
|
class?: string | undefined;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
|
|
58
|
+
/** What a call site passes: the base props, with exactly one of the two box forms required. */
|
|
59
|
+
export type ImageProps = ImageBaseProps & ImageDimensions;
|
|
60
|
+
|
|
61
|
+
export function Image(props: ImageBaseProps & ImageDimensions): JSX.Element {
|
|
42
62
|
const src = (): string => assertNonEmptySrc('Image', props.src, props.src);
|
|
43
63
|
const hints = (): ImageLoadingHints => loadingHints(props.priority);
|
|
64
|
+
// Validated first: `reservedRatio` refuses a missing box before anything is emitted.
|
|
65
|
+
const ratio = (): string => reservedRatio(props.width, props.height, props.aspectRatio);
|
|
44
66
|
const box = (): ImageBox | undefined => boxFor(props.width, props.height);
|
|
67
|
+
const sets = (): readonly ImageSourceSet[] => sourceSetsFor(props.sources);
|
|
68
|
+
// Once, eagerly: a compiled `style` binding may be read inside an effect, and a refusal thrown
|
|
69
|
+
// there reaches no caller. Here it fails the render that wrote the bad props.
|
|
70
|
+
ratio();
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
const img = (): JSX.Element => (
|
|
47
73
|
<img
|
|
48
74
|
class={cx(styles['image'], props.class)}
|
|
49
|
-
// A custom property, because that is the one thing an inline `style` may carry here.
|
|
50
|
-
//
|
|
51
|
-
style={
|
|
75
|
+
// A custom property, because that is the one thing an inline `style` may carry here. The
|
|
76
|
+
// stylesheet turns it into `aspect-ratio`, the half of the reservation that survives styling.
|
|
77
|
+
style={{ '--image-ratio': ratio() }}
|
|
52
78
|
src={src()}
|
|
53
79
|
alt={props.alt}
|
|
54
80
|
srcset={srcsetFor(props.variants)}
|
|
@@ -60,4 +86,14 @@ export function Image(props: ImageProps): JSX.Element {
|
|
|
60
86
|
decoding={hints().decoding}
|
|
61
87
|
/>
|
|
62
88
|
);
|
|
89
|
+
|
|
90
|
+
if (sets().length === 0) return img();
|
|
91
|
+
return (
|
|
92
|
+
<picture class={styles['picture']}>
|
|
93
|
+
{sets().map((set) => (
|
|
94
|
+
<source type={set.type} srcset={set.srcset} sizes={props.sizes} />
|
|
95
|
+
))}
|
|
96
|
+
{img()}
|
|
97
|
+
</picture>
|
|
98
|
+
);
|
|
63
99
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Locale picker. Option labels come from `Intl.DisplayNames` in each locale's own
|
|
2
2
|
// language (endonyms), so the list needs no translation catalog of its own.
|
|
3
3
|
|
|
4
|
+
// Core's, never the i18n barrel's: that barrel installs the framework catalog at import, and this
|
|
5
|
+
// component ships in browser chunks (issue #490). `localizePath` is what i18n's `localizedPath` is.
|
|
6
|
+
import { localizePath } from '@ultimat3/core';
|
|
4
7
|
import type { Locale } from '@ultimat3/i18n';
|
|
5
8
|
import type { JSX } from 'solid-js';
|
|
6
9
|
import { cx } from '../cx';
|
|
@@ -10,12 +13,21 @@ import styles from './LocaleSwitcher.module.scss';
|
|
|
10
13
|
import { Select, type SelectOption } from './Select';
|
|
11
14
|
|
|
12
15
|
export interface LocaleSwitcherProps {
|
|
16
|
+
/** Every locale to offer, the DEFAULT first — `routedLocales()` from `@ultimat3/i18n`. */
|
|
13
17
|
locales: readonly Locale[];
|
|
14
18
|
/** Defaults to the context locale. */
|
|
15
19
|
value?: Locale | undefined;
|
|
16
20
|
onLocaleChange?: ((locale: Locale) => void) | undefined;
|
|
17
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Render as links instead of a select, for a 0kb-JS `site/` route. Defaults, when `path` is
|
|
23
|
+
* given, to that page in each locale — `localizedPath`: unprefixed for the default, `/en/…`
|
|
24
|
+
* otherwise — so a site's switcher needs no href of its own.
|
|
25
|
+
*/
|
|
18
26
|
hrefFor?: ((locale: Locale) => string) | undefined;
|
|
27
|
+
/** The page's own path, prefixed or not — `new URL(props.url).pathname`. Turns on links mode. */
|
|
28
|
+
path?: string | undefined;
|
|
29
|
+
/** The unprefixed locale. Defaults to `locales[0]`, which is where `routedLocales()` puts it. */
|
|
30
|
+
defaultLocale?: Locale | undefined;
|
|
19
31
|
class?: string | undefined;
|
|
20
32
|
}
|
|
21
33
|
|
|
@@ -31,13 +43,22 @@ export function LocaleSwitcher(props: LocaleSwitcherProps): JSX.Element {
|
|
|
31
43
|
const options = (): SelectOption[] =>
|
|
32
44
|
props.locales.map((tag) => ({ value: tag, label: localeLabel(tag) }));
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
const hrefFor = (): ((locale: Locale) => string) | undefined => {
|
|
47
|
+
if (props.hrefFor !== undefined) return props.hrefFor;
|
|
48
|
+
const path = props.path;
|
|
49
|
+
if (path === undefined) return undefined;
|
|
50
|
+
const fallback = props.defaultLocale ?? props.locales[0] ?? current();
|
|
51
|
+
return (locale) => localizePath(path, locale, props.locales, fallback);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const linkFor = hrefFor();
|
|
55
|
+
if (linkFor !== undefined) {
|
|
35
56
|
return (
|
|
36
57
|
<nav class={cx(styles['links'], props.class)} aria-label={ui.t(UI_KEYS.language)}>
|
|
37
58
|
{props.locales.map((tag) => (
|
|
38
59
|
<a
|
|
39
60
|
class={styles['link']}
|
|
40
|
-
href={
|
|
61
|
+
href={linkFor(tag)}
|
|
41
62
|
hreflang={tag}
|
|
42
63
|
lang={tag}
|
|
43
64
|
aria-current={tag === current() ? 'true' : undefined}
|
|
@@ -26,7 +26,11 @@ export type ThemeChoice = Theme | 'system';
|
|
|
26
26
|
|
|
27
27
|
export interface ThemeToggleProps {
|
|
28
28
|
mode?: 'toggle' | 'select' | undefined;
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The theme in force at server render — the toggle's glyph. Never the select's choice: nothing
|
|
31
|
+
* on the server knows whether the visitor chose it, so the select shows "system" until the
|
|
32
|
+
* effect reads storage.
|
|
33
|
+
*/
|
|
30
34
|
initial?: Theme | undefined;
|
|
31
35
|
/** Injectable for tests and for non-DOM hosts. */
|
|
32
36
|
env?: ThemeEnv | undefined;
|
|
@@ -36,7 +40,10 @@ export interface ThemeToggleProps {
|
|
|
36
40
|
export function ThemeToggle(props: ThemeToggleProps): JSX.Element {
|
|
37
41
|
const ui = useUi();
|
|
38
42
|
const rt = solid();
|
|
39
|
-
|
|
43
|
+
// "system" until the effect reads storage, whatever `initial` says: `initial` is the RESOLVED
|
|
44
|
+
// theme the boot stamped on <html>, and seeding the choice with it made the select claim the
|
|
45
|
+
// visitor had picked it — so picking "system" looked like a no-op on every first paint.
|
|
46
|
+
const [choice, setChoice] = rt.createSignal<ThemeChoice>('system');
|
|
40
47
|
const [resolved, setResolved] = rt.createSignal<Theme>(props.initial ?? 'light');
|
|
41
48
|
|
|
42
49
|
const env = (): ThemeEnv => props.env ?? browserThemeEnv();
|
|
@@ -180,3 +180,68 @@ function assertPixels(value: number): void {
|
|
|
180
180
|
throw invalidValueError('Image', value, 'a positive whole number of CSS pixels');
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/** `16 / 9`, as CSS writes it — the reservation when the intrinsic size is not known. */
|
|
185
|
+
export type ImageAspectRatio = `${number} / ${number}`;
|
|
186
|
+
|
|
187
|
+
const RATIO = /^\s*(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s*$/;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The box an image reserves, which it ALWAYS reserves: both intrinsic dimensions, or an explicit
|
|
191
|
+
* `aspectRatio` — never neither, which is a layout shift waiting for the decode, and never both,
|
|
192
|
+
* which is two answers to one question. The returned ratio is what `--image-ratio` carries.
|
|
193
|
+
*/
|
|
194
|
+
export function reservedRatio(
|
|
195
|
+
width: number | undefined,
|
|
196
|
+
height: number | undefined,
|
|
197
|
+
aspectRatio: string | undefined,
|
|
198
|
+
): string {
|
|
199
|
+
if (aspectRatio !== undefined) {
|
|
200
|
+
if (width !== undefined || height !== undefined) {
|
|
201
|
+
throw invalidValueError(
|
|
202
|
+
'Image',
|
|
203
|
+
{ width, height, aspectRatio },
|
|
204
|
+
'width and height, or aspectRatio — not both, which would be two ratios for one box',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const match = RATIO.exec(aspectRatio);
|
|
208
|
+
if (match === null || Number(match[1]) <= 0 || Number(match[2]) <= 0) {
|
|
209
|
+
throw invalidValueError('Image', aspectRatio, 'an aspectRatio such as "16 / 9"');
|
|
210
|
+
}
|
|
211
|
+
return `${match[1]} / ${match[2]}`;
|
|
212
|
+
}
|
|
213
|
+
const box = boxFor(width, height);
|
|
214
|
+
if (box === undefined) {
|
|
215
|
+
throw invalidValueError(
|
|
216
|
+
'Image',
|
|
217
|
+
{ width, height },
|
|
218
|
+
'width and height (the intrinsic size) or an aspectRatio — an image with neither reserves no box and shifts the layout when it decodes',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return ratioFor(box) as string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Modern encodings, offered in this order ahead of the `<img>`'s own `src`. */
|
|
225
|
+
export interface ImageSources {
|
|
226
|
+
readonly avif?: readonly ImageVariant[] | undefined;
|
|
227
|
+
readonly webp?: readonly ImageVariant[] | undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface ImageSourceSet {
|
|
231
|
+
readonly type: 'image/avif' | 'image/webp';
|
|
232
|
+
readonly srcset: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One `<source>` per encoding handed in, AVIF first: the browser takes the FIRST type it can
|
|
237
|
+
* decode, so the smaller encoding has to be asked about before the larger one.
|
|
238
|
+
*/
|
|
239
|
+
export function sourceSetsFor(sources: ImageSources | undefined): readonly ImageSourceSet[] {
|
|
240
|
+
if (sources === undefined) return [];
|
|
241
|
+
const sets: ImageSourceSet[] = [];
|
|
242
|
+
const avif = srcsetFor(sources.avif);
|
|
243
|
+
if (avif !== undefined) sets.push({ type: 'image/avif', srcset: avif });
|
|
244
|
+
const webp = srcsetFor(sources.webp);
|
|
245
|
+
if (webp !== undefined) sets.push({ type: 'image/webp', srcset: webp });
|
|
246
|
+
return sets;
|
|
247
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -140,7 +140,7 @@ export type { IconProps } from './components/Icon';
|
|
|
140
140
|
export { Icon } from './components/Icon';
|
|
141
141
|
export type { IconButtonProps } from './components/IconButton';
|
|
142
142
|
export { IconButton } from './components/IconButton';
|
|
143
|
-
export type { ImageProps } from './components/Image';
|
|
143
|
+
export type { ImageBaseProps, ImageDimensions, ImageProps } from './components/Image';
|
|
144
144
|
export { Image } from './components/Image';
|
|
145
145
|
export type { InfiniteScrollProps } from './components/InfiniteScroll';
|
|
146
146
|
export { InfiniteScroll } from './components/InfiniteScroll';
|
|
@@ -148,8 +148,15 @@ export type { InputProps, InputType } from './components/Input';
|
|
|
148
148
|
export { Input } from './components/Input';
|
|
149
149
|
export type { IconElement, IconGlyph, IconTag } from './components/icon-glyph';
|
|
150
150
|
export { ICON_TAGS, iconElements } from './components/icon-glyph';
|
|
151
|
-
export type {
|
|
152
|
-
|
|
151
|
+
export type {
|
|
152
|
+
ImageAspectRatio,
|
|
153
|
+
ImageBox,
|
|
154
|
+
ImageLoadingHints,
|
|
155
|
+
ImageSourceSet,
|
|
156
|
+
ImageSources,
|
|
157
|
+
ImageVariant,
|
|
158
|
+
} from './components/image-source';
|
|
159
|
+
export { boxFor, reservedRatio, sourceSetsFor, srcsetFor } from './components/image-source';
|
|
153
160
|
export type { LoadMoreInput, LoadMoreState } from './components/infinite-scroll-view';
|
|
154
161
|
export type { KbdProps } from './components/Kbd';
|
|
155
162
|
export { Kbd } from './components/Kbd';
|