Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .changeset/reativa-components-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@xpecs/reativa": minor
---

Add `@xpecs/reativa` — the ReasonML/OCaml (reativa + Melange) sibling of
`@xpecs/xote`, as its own package.

- Implements the same set `@xpecs/xote` covers — 22 elements, 8 components, and
7 blocks — in `.mlx` (JSX-for-OCaml) over `Reativa.View`, styled against
`@xpecs/tokens`, with the same behaviour and accessibility semantics as the
Xote components.
- Enum prop types (`variant`, `size`, …) are generated from the specs' `## API`
contracts into `src/Contracts.ml` by `npm run contracts`, so the OCaml
compiler enforces that the implementation can't drift from the spec — the same
guarantee `@xpecs/xote` has.
- `src/Registry.mlx` renders one live example per spec and exports the JS surface
the website's **Reativa** preview consumes (`mount_example`, `example_ids`,
`built`). The website's example block now has two tab strips: one picks the
view (Preview / Playground / Code) and one picks the implementation rendered in
the preview (**Xote** or **Reativa**).
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/reativa/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.build/
dist/
_build/
node_modules/
65 changes: 65 additions & 0 deletions packages/reativa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# @xpecs/reativa

Accessible UI components for **[reativa](https://github.com/brnrdog/reativa)**
(OCaml + Melange) that implement the [Xpecs](https://github.com/brnrdog/ux-archetypes)
contracts — the ReasonML/OCaml sibling of
[`@xpecs/xote`](../xote). Every element, component, and block is written in
`.mlx` (JSX-for-OCaml) over `Reativa.View`, styled against
[`@xpecs/tokens`](../tokens), so a re-theme cascades through these exactly like
it does through the Xote components.

There is no virtual DOM: `View.mount` builds real DOM nodes once and only the
reactive regions (driven by signals) update in place.

## What's implemented

The same set `@xpecs/xote` covers — 22 elements, 8 components, and 7 blocks:

| Layer | Specs |
| ----- | ----- |
| element | `aspect-ratio` `avatar` `badge` `button` `checkbox` `icon` `icon-button` `input` `input-otp` `kbd` `link` `progress` `radio-group` `scroll-area` `separator` `skeleton` `slider` `spinner` `switch` `textarea` `toggle` `toggle-group` |
| component | `accordion` `alert` `collapsible` `dialog` `field` `select` `tabs` `tooltip` |
| block | `announcement-bar` `contact-section` `logo-cloud` `newsletter` `page-header` `stat-grid` `steps` |

Each component is a plain function — `Button.make ~variant:\`primary ~children ()`
— and composes the others. Enum prop types (`variant`, `size`, …) are generated
from the specs' `## API` contracts into [`src/Contracts.ml`](src/Contracts.ml)
by `npm run contracts`, so the OCaml compiler enforces that the implementation
can't drift from the spec's allowed values.

[`src/Registry.mlx`](src/Registry.mlx) renders one live example per spec (the
same demos as the website's Xote examples) and exports the JS surface the
website consumes: `mount_example(specId, containerId)`, `example_ids`, and
`built`.

## Building

reativa's core library has **no `public_name`**, so it is a *private* dune
library that can't be consumed as an installed opam package — its own demo
builds only because it lives inside the reativa dune project. So we do the same:
[`scripts/build.mjs`](scripts/build.mjs) clones reativa (pinned to a commit),
drops [`dune`](dune) + `src/*` into a subdirectory of the clone where the
private `reativa` library and the `reativa.mlx_ppx` ppx are in scope, compiles
to ES modules with Melange, and bundles the emitted `Registry.js` into
`dist/reativa.bundle.js` with esbuild.

Requires the OCaml toolchain (an opam switch on OCaml **5.1+**, since reativa
needs `melange >= 3`). One-time setup:

```bash
opam switch create . 5.2.1 # or reuse an existing 5.1+ switch
opam install dune melange mlx
```

Then:

```bash
npm run build --workspace @xpecs/reativa # contracts → melange → esbuild bundle
```

To move to a newer reativa, bump `REATIVA_REF` in `scripts/build.mjs`.

> This build is intentionally **not** part of `npm run build:packages` (it needs
> opam/melange, which the rest of the build does not) — but CI runs it, and the
> website's `npm run reativa` builds this package and copies the bundle into the
> site so the **Reativa** preview ships for real.
19 changes: 19 additions & 0 deletions packages/reativa/dune
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
; Compiles the reativa component sources (this directory's *.mlx / *.ml) to
; JavaScript ES modules under output/. The emitted entry (output/Registry.js)
; re-exports mount_example / example_ids / built and is bundled for the browser
; with esbuild by `node scripts/build.mjs`; see README.md.
;
; This dune file is copied, together with the src/ modules, into a subdirectory
; of a pinned clone of the reativa repo (which ships the private `reativa`
; library and the reativa.mlx_ppx ppx) — reativa's core has no public_name, so
; it can't be consumed as an installed opam library and must be built in-tree.
;
; Each source module carries a `[@@@warning "-a"]` floating attribute so a stray
; unused binding never fails the build under dune's dev profile; genuine type
; errors are still errors.
(melange.emit
(target output)
(libraries reativa)
(preprocess
(pps reativa.mlx_ppx melange.ppx))
(module_systems es6))
40 changes: 40 additions & 0 deletions packages/reativa/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@xpecs/reativa",
"version": "0.1.0",
"description": "Accessible UI components for Reativa (OCaml + Melange) that implement the Xpecs contracts — the ReasonML/OCaml sibling of @xpecs/xote. Styled against @xpecs/tokens; prop types are generated from the spec API contracts, so the implementation can't drift from the spec.",
"license": "MIT",
"author": "brnrdog",
"keywords": [
"ocaml",
"reason",
"melange",
"reativa",
"components",
"ui",
"design-system",
"ux-archetypes",
"accessibility"
],
"type": "module",
"files": [
"src",
"dune",
"README.md"
],
"scripts": {
"contracts": "node scripts/generate-contracts.mjs",
"build": "npm run contracts && node scripts/build.mjs",
"prepack": "npm run contracts"
},
"dependencies": {
"@xpecs/tokens": "^0.1.0"
},
"repository": {
"type": "git",
"url": "https://github.com/brnrdog/ux-archetypes",
"directory": "packages/reativa"
},
"publishConfig": {
"access": "public"
}
}
91 changes: 91 additions & 0 deletions packages/reativa/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Build the @xpecs/reativa component bundle (consumed by the website's Reativa
// tab). reativa's core library is a *private* dune library (no public_name), so
// it can't be consumed as an installed opam library — its own demo works only
// because it lives inside the reativa dune project. We do the same: clone
// reativa (pinned), drop this package's `dune` + src/ modules into a
// subdirectory of that project so the private `reativa` library and the
// `reativa.mlx_ppx` ppx resolve, compile to JS with Melange, then bundle the
// emitted entry (Registry.js) into dist/reativa.bundle.js with esbuild.
//
// Requires: an opam switch with `dune`, `melange`, `mlx` installed, plus git and
// esbuild. Not part of `npm run build:packages`/the website's default build
// (it needs opam/melange, which the rest of the build does not) — invoked
// explicitly by `npm run build --workspace @xpecs/reativa`, and by CI so PRs
// compile the components and Pages ships the real bundle.

import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
copyFileSync,
readdirSync,
} from "node:fs";
import { resolve, join } from "node:path";
import { fileURLToPath } from "node:url";

const REATIVA_REPO = "https://github.com/brnrdog/reativa.git";
// Pin a known-good commit so the build is reproducible. Bump deliberately.
const REATIVA_REF = "c94697df4bcd16fae59f900a01f7c60964606492";

const pkgDir = resolve(fileURLToPath(import.meta.url), "../..");
const srcDir = resolve(pkgDir, "src");
const buildDir = resolve(pkgDir, ".build");
const clone = resolve(buildDir, "reativa");
// The subdirectory inside the reativa dune project we drop our sources into.
const exDir = resolve(clone, "xpecs_reativa");
const distDir = resolve(pkgDir, "dist");

const sh = (file, args, cwd) => execFileSync(file, args, { cwd, stdio: "inherit" });

// 1. Clone reativa (or reuse a previous clone) and check out the pinned commit.
mkdirSync(buildDir, { recursive: true });
if (!existsSync(resolve(clone, ".git"))) {
sh("git", ["clone", REATIVA_REPO, "reativa"], buildDir);
}
sh("git", ["fetch", "--quiet", "origin"], clone);
sh("git", ["checkout", "--quiet", REATIVA_REF], clone);

// 2. Drop this package's dune + every src module into the reativa project (same
// dune-project ⇒ the private `reativa` library and reativa.mlx_ppx are in
// scope, and the `.mlx` dialect is available).
mkdirSync(exDir, { recursive: true });
copyFileSync(resolve(pkgDir, "dune"), resolve(exDir, "dune"));
for (const name of readdirSync(srcDir)) {
if (name.endsWith(".mlx") || name.endsWith(".ml") || name.endsWith(".mli")) {
copyFileSync(resolve(srcDir, name), resolve(exDir, name));
}
}

// 3. Compile the components (and reativa itself) to ES modules via Melange.
sh("opam", ["exec", "--", "dune", "build", "@melange"], clone);

// 4. Bundle the emitted entry (Registry.js) into a single ES module. melange.emit
// mirrors the source path under the target dir, so the entry is normally
// <target>/<emit-dir>/output/Registry.js — but locate it defensively in case
// the nesting differs across dune/melange versions.
const outputRoot = resolve(clone, "_build/default/xpecs_reativa/output");
const findEntry = (dir) => {
for (const name of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, name.name);
if (name.isDirectory()) {
const hit = findEntry(p);
if (hit) return hit;
} else if (name.name === "Registry.js") {
return p;
}
}
return null;
};
const entry = findEntry(outputRoot);
if (!entry) {
throw new Error(`could not find emitted Registry.js under ${outputRoot}`);
}
mkdirSync(distDir, { recursive: true });
const outfile = resolve(distDir, "reativa.bundle.js");
sh(
"npx",
["--yes", "esbuild", entry, "--bundle", "--format=esm", `--outfile=${outfile}`],
pkgDir,
);

console.log(`\n✓ @xpecs/reativa bundle written to ${outfile}`);
77 changes: 77 additions & 0 deletions packages/reativa/scripts/generate-contracts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Generates src/Contracts.ml from the `## API` blocks in the spec markdown.
// For every enum prop it emits an OCaml polymorphic-variant type, grouped in a
// module named after the spec. Components annotate their props with these
// types, so the OCaml compiler itself enforces that the implementation stays in
// sync with the contract's allowed values.
//
// This is the OCaml counterpart of packages/xote/scripts/generate-contracts.mjs
// (which emits ReScript polymorphic variants for @xpecs/xote); both read the
// same specs so the two implementations expose the same contract types.
import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs";
import { join, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const specsDir = join(here, "..", "..", "..", "specs");
const outFile = join(here, "..", "src", "Contracts.ml");
const layers = ["elements", "components", "blocks", "pages", "flows"];

// OCaml keywords that can't be bare type names — suffixed with `_` if hit.
const reserved = new Set([
"and", "as", "assert", "begin", "class", "constraint", "do", "done", "downto",
"else", "end", "exception", "external", "false", "for", "fun", "function",
"functor", "if", "in", "include", "inherit", "initializer", "lazy", "let",
"match", "method", "module", "mutable", "new", "nonrec", "object", "of", "open",
"or", "private", "rec", "sig", "struct", "then", "to", "true", "try", "type",
"val", "virtual", "when", "while", "with",
]);
const typeName = (n) => (reserved.has(n) ? `${n}_` : n);
const pascal = (id) =>
id
.split("-")
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join("");

function apiOf(body) {
const m = body.match(/^##\s+API\s*$/m);
if (!m) return null;
const fence = body.slice(m.index).match(/```json\s*([\s\S]*?)```/);
if (!fence) return null;
return JSON.parse(fence[1]);
}

const modules = [];
for (const layerDir of layers) {
let files;
try {
files = readdirSync(join(specsDir, layerDir)).filter((f) => f.endsWith(".md"));
} catch {
continue;
}
for (const file of files.sort()) {
const raw = readFileSync(join(specsDir, layerDir, file), "utf8");
const api = apiOf(raw);
if (!api || !Array.isArray(api.props)) continue;
const enums = api.props.filter((p) => p.type === "enum" && p.values?.length);
if (enums.length === 0) continue;
const id = basename(file, ".md");
const types = enums
.map(
(p) =>
` type ${typeName(p.name)} = [ ${p.values.map((v) => `\`${v}`).join(" | ")} ]`,
)
.join("\n");
modules.push(`module ${pascal(id)} = struct\n${types}\nend`);
}
}

const out = `(* GENERATED FILE — do not edit by hand.
Run \`npm run contracts\` (scripts/generate-contracts.mjs) to regenerate.
Types are derived from the \`## API\` contracts in the spec markdown. *)

${modules.join("\n\n")}
`;

mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, out);
console.log(`Wrote ${modules.length} contract module(s) to ${outFile}`);
35 changes: 35 additions & 0 deletions packages/reativa/src/Accordion.mlx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[@@@warning "-a"]

(* Accordion — a stack of headers that expand/collapse their panels. [type_]
(from the contract) selects single- vs multiple-open behavior; [collapsible]
controls whether the open panel can be closed again. Open state is the list of
open ids held in the [value] signal. *)

open Reativa
open Reativa.View.Mlx

let make ?(type_ : Contracts.Accordion.type_ = `single) ?(collapsible = true) ~value ~items () =
let toggle id =
Signal.update value (fun cur ->
let is_open = List.mem id cur in
match (type_, is_open) with
| _, true -> if collapsible then List.filter (fun x -> x <> id) cur else cur
| `single, false -> [ id ]
| `multiple, false -> cur @ [ id ])
in
let render (id, header, panel) =
let is_open () = List.mem id (Signal.get value) in
let mark () = if List.mem id (Signal.get value) then "\xe2\x88\x92" else "+" in
<div>
<button
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-neutral-800 hover:bg-neutral-50"
onClick=(fun _ -> toggle id)>
(header)
<span className="text-neutral-400">(View.dyn_text mark)</span>
</button>
(View.show is_open (<p className="px-4 pb-3 text-sm text-neutral-500">(panel)</p>))
</div>
in
<div className="w-96 divide-y divide-neutral-200 rounded-lg border border-neutral-200">
(View.fragment (List.map render items))
</div>
Loading
Loading