shelving 1.271.0 → 1.271.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.271.0",
3
+ "version": "1.271.2",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,13 +9,13 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.5.2",
12
+ "@biomejs/biome": "^2.5.4",
13
13
  "@google-cloud/firestore": "^8.6.0",
14
14
  "@heroicons/react": "^2.2.0",
15
15
  "@types/bun": "^1.3.14",
16
16
  "@types/react": "^19.2.17",
17
17
  "@types/react-dom": "^19.2.3",
18
- "firebase": "^12.15.0",
18
+ "firebase": "^12.16.0",
19
19
  "react": "^19.3.0-canary-fef12a01-20260413",
20
20
  "react-dom": "^19.3.0-canary-fef12a01-20260413",
21
21
  "stylelint": "^17.14.0",
@@ -7,7 +7,7 @@ A continuous horizontal progress bar for the completion of a task. Rendered as a
7
7
  - Use `Progress` for **task completion** — a value heading toward "done" (an upload, a multi-step form, a load). For a static reading within a range that can move up and down (disk usage, a score), a gauge is the right model, not a progress bar.
8
8
  - `value` is filled within the `min`–`max` range (defaults `0`–`100`), matching `getPercent()` and `formatPercent()`. `<progress>` has no `min` attribute, so the range is normalised to `value - min` / `max - min` before it reaches the element.
9
9
  - The browser clamps `value` to the `0`–`max` range, so an out-of-range `value` renders an empty or full bar rather than overspilling. A non-positive range (`min === max`) falls back to an empty bar rather than the indeterminate state.
10
- - Omit `value` (or pass `null`/`undefined`) for an ongoing task whose duration isn't known — exactly like a native `<progress>`, dropping the attribute switches the element into the `:indeterminate` state (no `aria-valuenow`), and a block of fill colour flows across the track on a loop. `color=` / `status=` still recolour it. The animation respects `prefers-reduced-motion` (it holds a centred block instead of looping).
10
+ - Omit `value` (or pass `null`/`undefined`) for an ongoing task whose duration isn't known — exactly like a native `<progress>`, dropping the attribute switches the element into the `:indeterminate` state (no `aria-valuenow`), and a block of fill colour flows across the track on a loop. `color=` / `status=` still recolour it. The animation runs even under `prefers-reduced-motion` movement is the indeterminate bar's only signal, and a frozen block would read as a stalled bar.
11
11
  - `aria-valuetext` carries the formatted percentage, so assistive tech announces e.g. "75%".
12
12
  - Paints from the [tint ladder](/ui/TINT_CLASS): `color=` and `status=` move the tint anchor for the bar, so the fill (and track) re-derive together — `status="success"` gives a green bar, `color="purple"` a purple one. Without either, the bar takes the ambient tint (`--tint-50`, gray by default).
13
13
 
@@ -63,14 +63,7 @@
63
63
  background: transparent;
64
64
  }
65
65
 
66
- /* Honour reduced-motion: hold the block centred rather than looping. */
67
- @media (prefers-reduced-motion: reduce) {
68
- .progress:indeterminate,
69
- .prose progress:indeterminate {
70
- animation: none;
71
- background-position: 50% 0;
72
- }
73
- }
66
+ /* No `prefers-reduced-motion` override — movement is the indeterminate bar's only signal, so freezing it reads as a stalled determinate bar rather than "working". The animation is a small element-sized loop, not a large vestibular-trigger transition. */
74
67
  }
75
68
 
76
69
  /* The `progress-flow` keyframes referenced above live in the sibling non-module `Progress.css` — see the note there for why they can't be defined in this module file. */
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { createMeta, Head, MetaContext } from "shelving/ui";
4
+
5
+ describe("Head", () => {
6
+ test("emits a <link> element when links are set in context", () => {
7
+ const html = renderToStaticMarkup(
8
+ <MetaContext value={createMeta({ root: "http://x.com/", url: "./", links: { icon: "/img/favicon.png" } })}>
9
+ <Head />
10
+ </MetaContext>,
11
+ );
12
+ expect(html).toContain('rel="icon"');
13
+ expect(html).toContain('href="http://x.com/img/favicon.png"');
14
+ });
15
+ });
package/ui/util/meta.js CHANGED
@@ -115,7 +115,7 @@ export function mergeMetaTags(current, next) {
115
115
  * @see https://shelving.cc/ui/mergeMetaLinks
116
116
  */
117
117
  export function mergeMetaLinks(current, next, url, root, caller = mergeMetaLinks) {
118
- return next ? { ...current, ..._yieldMetaLinkEntries(next, url, root, caller) } : current;
118
+ return next ? { ...current, ...Object.fromEntries(_yieldMetaLinkEntries(next, url, root, caller)) } : current;
119
119
  }
120
120
  function* _yieldMetaLinkEntries(links, url, root, caller) {
121
121
  for (const [k, link] of getDictionaryItems(links))
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createMeta, mergeMeta, mergeMetaAssets, mergeMetaLinks } from "shelving/ui";
3
+ import { requireURL } from "shelving/util/url";
4
+
5
+ const ROOT = requireURL("http://x.com/app/");
6
+ const URL = requireURL("http://x.com/app/sub/page");
7
+
8
+ describe("mergeMetaLinks", () => {
9
+ test("returns resolved entries when current is unset", () => {
10
+ const links = mergeMetaLinks(undefined, { icon: "/img/favicon.png" }, ROOT, ROOT);
11
+ expect(links?.icon?.href).toBe("http://x.com/app/img/favicon.png");
12
+ });
13
+
14
+ test("merges next over current — new keys override, untouched keys survive", () => {
15
+ const current = mergeMetaLinks(undefined, { icon: "/old.png", canonical: "/page" }, ROOT, ROOT);
16
+ const links = mergeMetaLinks(current, { icon: "/new.png" }, ROOT, ROOT);
17
+ expect(links?.icon?.href).toBe("http://x.com/app/new.png");
18
+ expect(links?.canonical?.href).toBe("http://x.com/app/page");
19
+ });
20
+
21
+ test("skips nullish link values", () => {
22
+ const links = mergeMetaLinks(undefined, { icon: undefined, canonical: null }, ROOT, ROOT);
23
+ expect(links).toEqual({});
24
+ });
25
+
26
+ test("resolves relative hrefs against url and absolute hrefs against root", () => {
27
+ const links = mergeMetaLinks(undefined, { canonical: "./other", icon: "/favicon.png" }, URL, ROOT);
28
+ expect(links?.canonical?.href).toBe("http://x.com/app/sub/page/other");
29
+ expect(links?.icon?.href).toBe("http://x.com/app/favicon.png");
30
+ });
31
+
32
+ test("returns current unchanged when next is unset", () => {
33
+ const current = mergeMetaLinks(undefined, { icon: "/favicon.png" }, ROOT, ROOT);
34
+ expect(mergeMetaLinks(current, undefined, ROOT, ROOT)).toBe(current);
35
+ });
36
+ });
37
+
38
+ describe("mergeMetaAssets", () => {
39
+ test("resolves relative hrefs against url and absolute hrefs against root", () => {
40
+ const assets = mergeMetaAssets(undefined, ["./style.css", "/base.css"], URL, ROOT);
41
+ expect(assets?.map(({ href }) => href)).toEqual(["http://x.com/app/sub/page/style.css", "http://x.com/app/base.css"]);
42
+ });
43
+
44
+ test("appends next after current and skips nullish values", () => {
45
+ const current = mergeMetaAssets(undefined, ["/base.css"], ROOT, ROOT);
46
+ const assets = mergeMetaAssets(current, [undefined, "/extra.css", null], ROOT, ROOT);
47
+ expect(assets?.map(({ href }) => href)).toEqual(["http://x.com/app/base.css", "http://x.com/app/extra.css"]);
48
+ });
49
+ });
50
+
51
+ describe("mergeMeta", () => {
52
+ test("meta with links set produces non-empty resolved links", () => {
53
+ const meta = createMeta({ root: "http://x.com/", url: "./", links: { icon: "/img/favicon.png" } });
54
+ expect(meta.links?.icon?.href).toBe("http://x.com/img/favicon.png");
55
+ });
56
+
57
+ test("merges new links over existing resolved links", () => {
58
+ const meta1 = createMeta({ root: "http://x.com/", url: "./", links: { icon: "/favicon.png" } });
59
+ const meta2 = mergeMeta(meta1, { url: "./page", links: { canonical: "/page" } });
60
+ expect(meta2.links?.icon?.href).toBe("http://x.com/favicon.png");
61
+ expect(meta2.links?.canonical?.href).toBe("http://x.com/page");
62
+ });
63
+ });
package/ui/util/meta.ts CHANGED
@@ -239,7 +239,7 @@ export function mergeMetaLinks(
239
239
  root: ImmutableURL | undefined,
240
240
  caller: AnyCaller = mergeMetaLinks,
241
241
  ): MetaLinks | undefined {
242
- return next ? { ...current, ..._yieldMetaLinkEntries(next, url, root, caller) } : current;
242
+ return next ? { ...current, ...Object.fromEntries(_yieldMetaLinkEntries(next, url, root, caller)) } : current;
243
243
  }
244
244
  function* _yieldMetaLinkEntries(
245
245
  links: PossibleMetaLinks,