wiki-formant 0.73.0 → 0.74.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/README.md CHANGED
@@ -467,7 +467,7 @@ are unit-tested without a DOM.
467
467
  `FacetBar` renders the rows `createTaxonomy` already produces. This is why the taxonomy exports a rows model rather than markup: the rows could always cross the boundary and, until this subpath existed, the markup could not, so all three wikis hand-rendered it and two put `aria-pressed` on an `<a>`. `Breadcrumbs` renders the trail and its `BreadcrumbList` JSON-LD together, because a trail whose structured data is written somewhere else is a trail that will one day disagree with its own markup — which is the case Google penalises. It takes a `base` origin: structured-data URLs must be absolute and a package cannot know the site.
468
468
  `FacetBar` takes a `count` class to render each count as its own element, and `alphaFirst` to lead with the A–Z row. A chip row above the grid and a panel beside it are the same markup laid out by different stylesheets, so the class names are what tell them apart: `letterRow`, `letterControl` and `letterControlActive` style the A–Z row apart from the facet rows (each falls back to `row`, `control` or `controlActive`); a `text` class wraps each label in its own element and gives a facet option its value as `title`, so a long value can truncate while its count stays in view; and `letterCounts="title"` keeps each letter a bare glyph with its count in the tooltip. With none of them set the markup is what it was. Its JSON-LD goes out through `JsonLd`, which is exported for every other payload a page emits: it re-encodes each `<` as `\u003c`, because these payloads carry authored titles and an authored `</script>` would otherwise close the tag.
469
469
 
470
- `PageNav` is the previous/next pair at the foot of an article — the sequential read the infobox rail's lateral links do not cover. Ordering is the caller's, because it is the one part that is never portable: a wiki's sequence is its section's configured sort, a knowledge base's is a taxonomy walk. Pair it with `adjacentPages` from `wiki-formant/pagination` over a list you already hold — neither wiki needs a query for it, and the two indexed lookups the neighbours used to cost were the reason one of them dropped the control.
470
+ `Pager` is the "← Previous · Page 2 of 9 · Next →" line, for paged results and for a carousel. Pass `href` and the steps are links (pair it with `paginatedResponse`'s `page` and `totalPages`); pass `onPage` and they are buttons, `wrap` stepping round from either end as a snap row does. A step with nowhere to go stays in place, inert, so the count never jumps sideways, and `spread` puts the steps at the ends of the line. `noun` names what a page is ("Point 2 of 21", "Previous point"). `SnapDots` is a snap row's dots: with `onGo` (`useSnapRow`'s `go`) each is a named button, without it they only count and are hidden from assistive technology. Three sites had written the pager by hand, each stopping at the ends differently, and two had drawn their own dots around the shared hook. `base.css` lays both out; the colours are the site's, the dots' through `--snap-dot` and `--snap-dot-on`.
471
471
 
472
472
  `RailShell` stays in `wiki-formant/react`, because it calls `useSidebar` and genuinely is a client component. It renders the rail's landmark, its scroll wrapper and the three collapse states — including the `--instant` class that keeps a remembered-closed rail from animating shut on first paint, and the close-on-navigate that a mobile drawer needs. Only a followed link closes it: a search field or a table-of-contents entry inside the drawer leaves it open. Compose your own rail inside it and mark the active link with `isRailLinkActive`; all three wikis do, and the pre-composed component that used to sit here had one consumer and had already lost both of those behaviours.
473
473
 
package/dist/base.css CHANGED
@@ -201,6 +201,44 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
201
201
  }
202
202
  .breadcrumbs .separator { user-select: none; }
203
203
 
204
+ /* ---- Pager and SnapDots -----------------------------------------------------
205
+ One line that never wraps, the count in tabular figures so stepping does not
206
+ shift it. A step is a bare control: its colour, size and hit area are the
207
+ site's, set on `.pager-step`. The dots' colours are `--snap-dot` and
208
+ `--snap-dot-on`, defaulting to the text colour faint and full. */
209
+ .pager {
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ gap: var(--pager-gap, 1rem);
214
+ white-space: nowrap;
215
+ font-variant-numeric: tabular-nums;
216
+ }
217
+ .pager[data-spread] { justify-content: space-between; gap: 0; }
218
+ .pager-step { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
219
+ .pager-step:is(:disabled, [aria-disabled='true']) { opacity: 0.4; cursor: default; }
220
+
221
+ .snap-dots { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--snap-dot-gap, 0); }
222
+ .snap-dots button {
223
+ display: grid;
224
+ place-items: center;
225
+ inline-size: var(--snap-dot-hit, 1.5rem);
226
+ block-size: var(--snap-dot-hit, 1.5rem);
227
+ padding: 0;
228
+ border: 0;
229
+ background: none;
230
+ cursor: pointer;
231
+ }
232
+ .snap-dots i {
233
+ display: block;
234
+ inline-size: var(--snap-dot-size, 0.5rem);
235
+ block-size: var(--snap-dot-size, 0.5rem);
236
+ border-radius: 9999px;
237
+ background: var(--snap-dot, color-mix(in srgb, currentColor 30%, transparent));
238
+ transition: background-color 150ms;
239
+ }
240
+ .snap-dots :is(i[data-on], [data-on] > i) { background: var(--snap-dot-on, currentColor); }
241
+
204
242
  /* ---- spinner, skeleton, empty state ----------------------------------------
205
243
  The shape and the motion; colour, size and surface are the site's. The
206
244
  spinner is a ring in currentColor sized by `--spinner-size`, the skeleton a
@@ -195,6 +195,58 @@ export declare function Breadcrumbs({ items, base, className, link: Link, lastAs
195
195
  export declare function BreadcrumbsRow({ actions, className, ...props }: BreadcrumbsProps & {
196
196
  actions?: ReactNode;
197
197
  }): import("react").JSX.Element;
198
+ export interface PagerProps {
199
+ /** The page showing, from 1. */
200
+ page: number;
201
+ total: number;
202
+ /** Each page's address: the steps are links and the pager ships no JavaScript. */
203
+ href?: (page: number) => string;
204
+ /** Or what a step does: the steps are buttons. A carousel passes `go`. */
205
+ onPage?: (page: number) => void;
206
+ /** Step round from either end, as a carousel does, instead of stopping. */
207
+ wrap?: boolean;
208
+ /** Both steps inert, as while the next page loads. */
209
+ disabled?: boolean;
210
+ /** What one page is: the middle reads "<noun> 2 of 9", the steps "Previous <noun>". */
211
+ noun?: string;
212
+ prev?: ReactNode;
213
+ next?: ReactNode;
214
+ /** The steps at the ends of the line rather than beside the count. */
215
+ spread?: boolean;
216
+ /** The landmark's name, when the page has more than one pager. */
217
+ label?: string;
218
+ link?: WikiLinkComponent;
219
+ className?: string;
220
+ }
221
+ /**
222
+ * "← Previous · Page 2 of 9 · Next →", for search results and for a carousel.
223
+ *
224
+ * Three sites wrote this line by hand, two of them for paged results (one with
225
+ * links, one with buttons) and one for a snap row of points, and each stopped
226
+ * at the ends differently: a disabled button, an empty span, a wrap. Here a
227
+ * step at an end with nowhere to go stays in place, inert, so the count does
228
+ * not jump sideways on the first and last page.
229
+ */
230
+ export declare function Pager({ page, total, href, onPage, wrap, disabled, noun, prev, next, spread, label, link: Link, className, }: PagerProps): import("react").JSX.Element | null;
231
+ export interface SnapDotsProps {
232
+ count: number;
233
+ /** The item showing, from 0: `useSnapRow`'s `at`. */
234
+ at: number;
235
+ /** `useSnapRow`'s `go`: each dot becomes a button. Omit it for dots that only count. */
236
+ onGo?: (i: number) => void;
237
+ /** What one item is, for each button's name: "Go to <noun> 3". */
238
+ noun?: string;
239
+ className?: string;
240
+ }
241
+ /**
242
+ * A snap row's dots, one per item, the one in view marked.
243
+ *
244
+ * With `onGo` each is a button, named, its 0.5rem mark in a 1.5rem target; on a
245
+ * card whose whole face is already a link it is only a count, `aria-hidden`.
246
+ * The mark is the `<i>` either way, so one rule draws both, and `data-on`
247
+ * marks the current one in both.
248
+ */
249
+ export declare function SnapDots({ count, at, onGo, noun, className }: SnapDotsProps): import("react").JSX.Element | null;
198
250
  export interface InfoboxAsideProps {
199
251
  /** The landmark's accessible name, e.g. `Key facts about ${title}`. */
200
252
  label: string;
@@ -148,6 +148,49 @@ export function Breadcrumbs({ items, base, className = '', link: Link = Anchor,
148
148
  export function BreadcrumbsRow({ actions, className = '', ...props }) {
149
149
  return (_jsxs("div", { className: `breadcrumbs-row ${actions ? 'spread' : ''} ${className}`.trim(), children: [_jsx(Breadcrumbs, { ...props }), actions] }));
150
150
  }
151
+ const Arrow = ({ children }) => _jsx("span", { "aria-hidden": "true", children: children });
152
+ /**
153
+ * "← Previous · Page 2 of 9 · Next →", for search results and for a carousel.
154
+ *
155
+ * Three sites wrote this line by hand, two of them for paged results (one with
156
+ * links, one with buttons) and one for a snap row of points, and each stopped
157
+ * at the ends differently: a disabled button, an empty span, a wrap. Here a
158
+ * step at an end with nowhere to go stays in place, inert, so the count does
159
+ * not jump sideways on the first and last page.
160
+ */
161
+ export function Pager({ page, total, href, onPage, wrap = false, disabled = false, noun = 'Page', prev = _jsxs(_Fragment, { children: [_jsx(Arrow, { children: "\u2190" }), " Previous"] }), next = _jsxs(_Fragment, { children: ["Next ", _jsx(Arrow, { children: "\u2192" })] }), spread = false, label = `${noun}s`, link: Link = Anchor, className = '', }) {
162
+ if (total < 2)
163
+ return null;
164
+ const to = (dir) => {
165
+ const p = page + dir;
166
+ return p >= 1 && p <= total ? p : wrap ? (p < 1 ? total : 1) : null;
167
+ };
168
+ const name = noun.toLowerCase();
169
+ const step = (dir, content) => {
170
+ const p = disabled ? null : to(dir);
171
+ const aria = `${dir < 0 ? 'Previous' : 'Next'} ${name}`;
172
+ if (onPage) {
173
+ return (_jsx("button", { type: "button", className: "pager-step", "aria-label": aria, disabled: p === null, onClick: () => p !== null && onPage(p), children: content }));
174
+ }
175
+ return p !== null && href ? (_jsx(Link, { href: href(p), className: "pager-step", rel: dir < 0 ? 'prev' : 'next', children: content })) : (_jsx("span", { className: "pager-step", "aria-disabled": "true", children: content }));
176
+ };
177
+ return (_jsxs("nav", { "aria-label": label, className: `pager ${className}`.trim(), "data-spread": spread || undefined, children: [step(-1, prev), _jsxs("span", { className: "pager-count", "aria-live": onPage ? 'polite' : undefined, children: [noun, " ", page, " of ", total] }), step(1, next)] }));
178
+ }
179
+ /**
180
+ * A snap row's dots, one per item, the one in view marked.
181
+ *
182
+ * With `onGo` each is a button, named, its 0.5rem mark in a 1.5rem target; on a
183
+ * card whose whole face is already a link it is only a count, `aria-hidden`.
184
+ * The mark is the `<i>` either way, so one rule draws both, and `data-on`
185
+ * marks the current one in both.
186
+ */
187
+ export function SnapDots({ count, at, onGo, noun = 'item', className = '' }) {
188
+ if (count < 2)
189
+ return null;
190
+ const cls = `snap-dots ${className}`.trim();
191
+ const dots = Array.from({ length: count }, (_, i) => i);
192
+ return onGo ? (_jsx("span", { className: cls, children: dots.map(i => (_jsx("button", { type: "button", "aria-label": `Go to ${noun} ${i + 1}`, "aria-current": i === at || undefined, "data-on": i === at || undefined, onClick: () => onGo(i), children: _jsx("i", {}) }, i))) })) : (_jsx("span", { className: cls, "aria-hidden": "true", children: dots.map(i => _jsx("i", { "data-on": i === at || undefined }, i)) }));
193
+ }
151
194
  /**
152
195
  * The facts panel beside an article. Named, because two of the three asides
153
196
  * this replaced had no accessible name, and a complementary landmark without
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.73.0",
3
+ "version": "0.74.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",