@reblu/site-ui
v0.7.1
Published
Public presentational primitives for Reblu SITEs (Button, Link, Input, Label, Form, Tabs, Pagination, cn).
Maintainers
Readme
@reblu/site-ui
Public presentational primitives for Reblu SITEs. The distributable, tsup-built
counterpart of the private @reblu/ui — @reblu/ui re-exports these so the
internal and public components can never diverge (REB-306, design D5/D6).
Entry points
| Import | Exports | Peer deps |
| --- | --- | --- |
| @reblu/site-ui | Button, buttonVariants, Input, Toast, buildPageHref, cn | react >=19 |
| @reblu/site-ui/utils | cn | — |
| @reblu/site-ui/button-variants | buttonVariants | — |
| @reblu/site-ui/pagination-href | buildPageHref | — |
| @reblu/site-ui/next | Link | react >=19, next >=16 |
| @reblu/site-ui/pagination | Pagination | react >=19, next >=16 |
| @reblu/site-ui/form | Form, Label, … | react >=19, react-hook-form >=7.57 |
| @reblu/site-ui/tabs | Tabs, … | react >=19 |
| @reblu/site-ui/label | Label | react >=19 |
| @reblu/site-ui/phone | normalizePhoneToE164, isSupportedRegion, getPhoneDialCode, … | — |
| @reblu/site-ui/phone-input | PhoneInput | react >=19 |
The root entry is framework-agnostic and safe inside React Server Components. The
Link wrapper carries a 'use client' boundary and wraps next/link, so it lives
under the ./next subpath where next is a required peer — and Pagination lives
under ./pagination for the same reason. next is declared as an OPTIONAL peer, so a
consumer that never touches those two subpaths is not asked to install it.
The three dependency-free subpaths (./utils, ./button-variants,
./pagination-href) exist so a Server Component can reach a class merger, the Button's
class list, or the pagination URL rule without entering a React or Radix module graph
(REB-647). All three are re-exported from the root as well, so the subpaths are
additive, never moves.
import { Button, cn } from '@reblu/site-ui'
import Link from '@reblu/site-ui/next'
import { Pagination } from '@reblu/site-ui/pagination'Phone input
PhoneInput is a controlled, form-library-independent field, exported from
./phone-input rather than the root so importing Button does not load the
phone metadata. Supply the tenant's known two-letter region to resolve national
numbers and show its calling prefix. It never guesses a region from the browser.
Valid international numbers are honored regardless of this region. Incomplete
numbers remain editable; a valid number is emitted as E.164. An empty optional
field emits ''. Pass error for an associated accessible field message.
The visible controlled value changes to E.164 as soon as valid; a SITE needing
different editing ergonomics can use the pure ./phone helper at its own submit
boundary instead.
import { PhoneInput } from '@reblu/site-ui/phone-input'
<PhoneInput id='customer-phone' label='Teléfono' region='ES'
value={phone} onChange={setPhone} error={phoneError} />normalizePhoneToE164 is now defined in this distributable package; the private
@reblu/ui/phone path re-exports the same implementation for existing callers.
An unresolved value is not safe to serialize as E.164: keep server validation.
Toast
A minimal, themeable toast CONTENT shell (REB-837) — deliberately NOT a toast
manager: no portal, no positioning, no auto-dismiss timer, no aria-live
region. site-ui does not take a sonner (or any toaster) dependency for
this on purpose — a toaster is a runtime concern with its own positioning/
stacking/dismissal opinions, and pulling one into a primitives package would
force it on every consumer. Render Toast AS the content of whichever
toaster the consuming SITE already mounts:
import { Toast } from '@reblu/site-ui'
import { toast } from 'sonner'
toast.custom(() => (
<Toast
title='Serum'
description='Añadido al carrito'
variant='success'
action={<a href='/shop/cart'>Ver carrito</a>}
/>
))That host toaster owns the live region — do NOT wrap Toast in a second
aria-live region, or the announcement doubles. @reblu/site-blocks's
AddedToCartToast is the cart-specific, ready-made version of this pattern
(product name, thumbnail, "Ver carrito" link, status copy) — reach for
Toast directly only for a non-cart toast.
| Prop | Default | Notes |
| --- | --- | --- |
| title | — | required |
| description | — | optional second line |
| icon | — | leading slot, e.g. a thumbnail |
| action | — | trailing slot, e.g. a link |
| variant | 'default' | 'default' \| 'success' \| 'error', exposed via data-variant for theming |
Pagination
import { Pagination } from '@reblu/site-ui/pagination'
<Pagination
currentPage={page}
totalPages={result.meta?.pages ?? 1}
basePath='/blog'
searchParams={{ category, tag }}
/>Renders nothing when totalPages <= 1. totalPages should come from the list
envelope's meta.pages, never from data.length — a full LAST page is
indistinguishable from "there is more", so deriving it offers a next page that renders
the empty state.
| Prop | Default | Notes |
| --- | --- | --- |
| currentPage | — | 1-based; clamped into [1, totalPages] before rendering |
| totalPages | — | from meta.pages |
| basePath | — | e.g. /blog |
| searchParams | {} | preserved in every link; an inherited page is ignored |
| siblingCount | 1 | numbered links kept on each side of the current page |
| variant | 'numbers' | 'compact' = prev/next around a status text |
| labels | Spanish (below) | every string and aria-label is overridable |
| className | — | merged onto the <nav> |
Numbered links are the default form. With prev/next only, a keyboard user cannot
reach page 4 without three full server navigations, each one resetting focus, and
nothing on screen says how far the end is. variant='compact' exists for rows too
narrow for numbers; it is a fallback, not the shape to reach for.
aria-current='page' goes on the current numbered link and on nothing else. The
attribute marks which member of a set of navigation links is current. On a standalone
status <span> reading "Página 1 de 5" there is no set, so it informs nobody — and axe
does not flag it, which is what makes that mistake durable. variant='compact' renders
no aria-current at all.
Two more details worth knowing, because they are easy to "simplify" away:
- At the first/last page the prev/next control is disabled in place
(
<span aria-disabled='true'>), not removed. A control that unmounts at the boundary re-centres the row, so the target slides out from under the pointer on the very click that reaches the last page. - The arrow glyphs are
aria-hidden; the accessible name comes fromaria-label. A screen reader announces←as anything from "leftwards arrow" to silence.
rel='prev' / rel='next' are set on the prev/next links.
Labels — the defaults are Spanish
| key | default |
| --- | --- |
| nav | Paginación (the <nav> aria-label) |
| previous / next | Anterior / Siguiente (visible text) |
| previousAriaLabel / nextAriaLabel | Página anterior / Página siguiente |
| previousGlyph / nextGlyph | ← / → (always aria-hidden) |
| page | (n) => 'Página ' + n (a numbered link's accessible name) |
| status | (n, m) => 'Página ' + n + ' de ' + m — variant='compact' only |
Nothing in the render path is unreachable from labels, and a partial object merges
over the defaults:
<Pagination
currentPage={page}
totalPages={pages}
basePath='/blog'
labels={{
nav: 'Pagination',
previous: 'Previous',
next: 'Next',
previousAriaLabel: 'Previous page',
nextAriaLabel: 'Next page',
page: (n) => `Page ${n}`,
}}
/>buildPageHref — and why filter links need it
import { buildPageHref } from '@reblu/site-ui/pagination-href'
// also: import { buildPageHref } from '@reblu/site-ui'
buildPageHref('/blog', 2, { category: 'ia' }) // '/blog?category=ia&page=2'
buildPageHref('/blog', 1, { category: 'ia' }) // '/blog?category=ia' (no ?page=1)
buildPageHref('/blog', 1, { category: 'ia', page: '7' })// '/blog?category=ia' (page discarded)Pagination uses this internally; it is exported because filter links need it too.
A filter link built from a spread of the current request's params carries page along
with it, and a different filter is a different result set with a different length — so
preserving the offset drops the reader on a page that no longer exists. Page reset
therefore lives in the URL construction, not in each caller's memory: page is deleted
unconditionally and only re-set when it is > 1. A filter link is just
buildPageHref(basePath, 1, nextParams).
Page 1 also drops the param entirely rather than emitting ?page=1, so the first
page has exactly one URL — two URLs for one page split ISR/CDN cache entries and hand
search engines a duplicate.
This module imports nothing (no React, no next), so a Server Component can call it.
Do not import it from @reblu/site-ui/pagination: that entry is 'use client', and a
function imported across that boundary comes back as a client reference.
Page-size params differ per endpoint — pageSize vs limit
When you wire Pagination to @reblu/site-client, note that the request param is not
the same word on every route:
| SDK call | request param | response key |
| --- | --- | --- |
| client.blog.list({ limit }) | limit | meta.limit |
| client.academy.courses({ limit }) | limit | meta.limit |
| client.shop.products({ limit }) | limit | meta.limit |
| client.pages.list({ pageSize }) | pageSize | meta.limit |
/api/site/pages reads pageSize; /api/site/blog reads limit. Both answer with
meta.limit. Porting a listing from blog to pages by changing only the route therefore
silently ignores the page size — you get the server default of 20 items, meta.limit
reports 20, and nothing errors. The asymmetry is real and typed, not a wrapper bug; see
PagesListParams in @reblu/site-client (src/types.ts).
Build
tsup emits dual ESM + CJS + .d.ts with source maps. Two guarantees are locked by
test/use-client-smoke.test.ts against the real build output:
- Every client entry (
./next,./pagination,./form,./tabs,./label) keeps its'use client'directive prologue (ESM leading, CJS after esbuild's'use strict'). tsup'streeshakeis deliberately off — its post-esbuild rollup pass strips the directive and silently collapses the RSC boundary.splittingis off for the same reason: a shared chunk would move code out from under the directive. - The root entry, and the dependency-free subpaths, are never forced into a client module.
test/esm-next-specifiers.test.ts locks a third: the ESM build imports next/link.js, never
the bare next/link. Next ships no exports map, so plain Node ESM (a SITE's vitest) cannot
resolve the bare subpath. The CJS build keeps require('next/link').
test/rsc-safety.test.ts adds the stronger claim: it loads each RSC-safe entry in a child
process under --conditions=react-server and renders it there. ./pagination is
deliberately NOT in that set — next/link runs React.createContext at module scope (via
next/dist/shared/lib/router-context.shared-runtime), which the react-server build of
React does not export. That is why the component is a client entry and its href builder is
a separate, import-free one.
pnpm --filter @reblu/site-ui build # emit dist/
pnpm --filter @reblu/site-ui test # unit + build smoke