@apliteni/apliteni-ui 0.7.2 β†’ 0.8.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
@@ -1,23 +1,35 @@
1
1
  # @apliteni/apliteni-ui
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@apliteni/apliteni-ui?color=cb3837&logo=npm&logoColor=white)](https://www.npmjs.com/package/@apliteni/apliteni-ui)
4
+ [![license: MIT](https://img.shields.io/npm/l/@apliteni/apliteni-ui?color=3b9dff)](./LICENSE)
5
+ [![live: ui.apli.tech](https://img.shields.io/badge/live-ui.apli.tech-9b5dff)](https://ui.apli.tech)
6
+
3
7
  The Apliteni design system & UI kit β€” one source of UI for every product surface
4
8
  (the strategy deck, the text portal, `/account`, the operating model, and whatever
5
9
  ships next).
6
10
 
7
- Framework-agnostic **HTML + CSS**, driven entirely by design tokens, themeable dark
8
- and light with **accent sub-themes**. Showcased and reviewed in **Storybook**, and
9
- published on **ui.apli.tech**.
11
+ Two layers over one set of design tokens: framework-agnostic **HTML + CSS**, and
12
+ **React components** for stateful surfaces. Themeable dark and light with **accent
13
+ sub-themes**. Showcased and reviewed in **Storybook**, and published on **ui.apli.tech**.
10
14
 
11
15
  - 🎨 **Live site + Storybook** β†’ [ui.apli.tech](https://ui.apli.tech)
12
16
  - πŸ“¦ **Package** β†’ [`@apliteni/apliteni-ui`](https://www.npmjs.com/package/@apliteni/apliteni-ui) (public npm)
17
+ - βš›οΈ **React components** β†’ `@apliteni/apliteni-ui/react` β€” a subpath of the same package, source in [`react/`](./react)
18
+
19
+ ## HTML + CSS *and* React
20
+
21
+ The core is HTML + CSS because the strategy portal (`apliteni/strategy`, `viz/`)
22
+ server-renders HTML strings (`.mjs` modules), not a component framework. So the kit
23
+ ships the same shape: token CSS + component CSS + tiny HTML-string factories. That
24
+ makes it a *true* single source of truth β€” the portal imports it with no rewrite and
25
+ no framework drift. Storybook (`@storybook/html-vite`) renders exactly what ships.
13
26
 
14
- ## Why HTML + CSS (not React)
27
+ React sits on top for surfaces that hold real client state β€” dashboards, tables,
28
+ filters, forms. Those components render the same `.ui-*` classes and the same tokens
29
+ as the vanilla kit, so the two layers can't drift.
15
30
 
16
- The strategy portal (`apliteni/strategy`, `viz/`) server-renders HTML strings
17
- (`.mjs` modules), not a component framework. So the kit ships the same shape: token
18
- CSS + component CSS + tiny HTML-string factories. That makes it a *true* single source
19
- of truth β€” the portal imports it with no rewrite and no framework drift. Storybook
20
- (`@storybook/html-vite`) renders exactly what ships.
31
+ **Which one:** does the surface hold meaningful client state? No β†’ the HTML-string
32
+ factories below. Yes β†’ the [React components](#react-components-stateful-surfaces).
21
33
 
22
34
  ## Install
23
35
 
@@ -67,6 +79,34 @@ import { tokensCss, topbarCss, cssText } from '@apliteni/apliteni-ui/inline';
67
79
  // …inline tokensCss + topbarCss into the <style> you serve.
68
80
  ```
69
81
 
82
+ ## React components (stateful surfaces)
83
+
84
+ `DataTable`, `Modal`, `Button`, `Badge`, `Card` and `Icon` β€” same `.ui-*` classes,
85
+ same tokens, TypeScript types included. They ship as a **subpath of this package**,
86
+ not as a package of their own: one install, one version, one pin.
87
+
88
+ **You install React yourself.** The kit declares no dependency on it β€” not a
89
+ dependency, not a peer β€” so npm will not warn you and will not pull it in. Bring your
90
+ own `react` and `react-dom`, version 18 or newer:
91
+
92
+ ```bash
93
+ npm install @apliteni/apliteni-ui react react-dom
94
+ ```
95
+
96
+ Miss them and the import of `@apliteni/apliteni-ui/react` fails at build or at
97
+ runtime with a module-not-found error for `react`. Everything else in the kit is
98
+ unaffected, so a plain HTML consumer installs the kit alone and never gets React in
99
+ its tree.
100
+
101
+ ```tsx
102
+ import '@apliteni/apliteni-ui/css'; // kit tokens + .ui-* classes
103
+ import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (modal, pager)
104
+ import { DataTable, Modal } from '@apliteni/apliteni-ui/react';
105
+ ```
106
+
107
+ The source lives in [`react/`](./react) β€” a private workspace with its own build
108
+ (tsup) and Storybook on port 6007. Details in [`react/README.md`](./react/README.md).
109
+
70
110
  ## Theming
71
111
 
72
112
  Theme is a `data-theme="dark|light"` attribute on `<html>`; accent is an orthogonal
@@ -98,7 +138,9 @@ src/
98
138
  assets/ # brand mark (seedling) + line-icon set
99
139
  components/ # HTML-string factories: button(), card(), badge(), topbar()…
100
140
  stories/ # Storybook: Foundations, Components, Apps
101
- site/ # ui.apli.tech landing page + static server
141
+ site/ # ui.apli.tech landing page (static site build)
142
+ react/ # React components β€” private workspace, built to react/dist/
143
+ dist/ # tsup output; shipped as …/react and …/react/css
102
144
  ```
103
145
 
104
146
  ## Develop
@@ -107,12 +149,21 @@ site/ # ui.apli.tech landing page + static server
107
149
  npm install
108
150
  npm run storybook # http://localhost:6006
109
151
  npm run build-storybook # -> storybook-static/
110
- node site/build.mjs # -> site/public/ (landing + kit.css)
152
+ node site/build.mjs # -> site/public/ (landing + kit.css + /storybook)
153
+ ```
154
+
155
+ The React components build and test through the workspace (`npm install` at the repo
156
+ root covers them β€” there is no second install):
157
+
158
+ ```bash
159
+ npm run storybook -w react # http://localhost:6007
160
+ npm test -w react # vitest
161
+ npm run build # tsup -> react/dist/ (also runs on prepare)
111
162
  ```
112
163
 
113
164
  ## Publish (public npm)
114
165
 
115
- Versioned publish runs from CI on a GitHub Release (needs the `NPM_TOKEN` secret):
166
+ Versioned publish runs from CI on a GitHub Release:
116
167
 
117
168
  ```bash
118
169
  npm version patch # or minor / major β€” bumps package.json + tags
@@ -120,17 +171,34 @@ git push --follow-tags
120
171
  gh release create v$(node -p "require('./package.json').version") --generate-notes
121
172
  ```
122
173
 
123
- The **Release** workflow (`.github/workflows/release.yml`) then publishes with the
124
- built-in `GITHUB_TOKEN` (`packages: write`). No manual `npm publish` needed.
174
+ The **Release** workflow (`.github/workflows/release.yml`) then publishes to the public
175
+ npm registry over npm Trusted Publishing (OIDC) β€” there is no long-lived token. No
176
+ manual `npm publish` needed. It runs in two jobs: `build` installs and runs `npm pack`,
177
+ whose `prepare` rebuilds `react/dist` from the tagged commit, and `publish` β€” the only
178
+ job that can mint an OIDC credential β€” just publishes that tarball, so no dependency
179
+ or build script ever runs beside the credential. The packaging guard
180
+ (`scripts/packaging.test.js`) fails CI if the React subpath isn't in the tarball, and
181
+ the release itself re-checks the tarball before publishing it.
125
182
 
126
183
  ## Deploy (ui.apli.tech)
127
184
 
128
- `Dockerfile` builds the landing page + Storybook and serves both (`/` and `/storybook`)
129
- via a zero-dependency static server. Deployed on **Lessly** as its own product, on
130
- `linux/amd64` (arm64 images fail there). Rebuild + redeploy:
185
+ The site is **100% static** (landing + hosted Storybook) β€” no container, no registry.
186
+ It's served by **Lessly static hosting**, built straight from this repo. The `site`
187
+ service builds from `main` with:
188
+
189
+ ```
190
+ npm ci && npm run build-storybook && node site/build.mjs
191
+ ```
192
+
193
+ and serves `site/public/`, which includes the landing page, `/changelog`, `kit.css`,
194
+ and the Storybook folded in at `/storybook`. Push to `main` and redeploy the `site`
195
+ service to roll it out.
196
+
197
+ To reproduce the exact static bundle locally:
131
198
 
132
199
  ```bash
133
- docker buildx build --platform linux/amd64 -t <registry>/ui-apli-tech:latest --push .
200
+ npm ci && npm run build-storybook && node site/build.mjs
201
+ # -> site/public/ (landing + /changelog + /storybook + kit.css)
134
202
  ```
135
203
 
136
204
  ## Adopting into the strategy portal
@@ -143,4 +211,5 @@ stays self-contained for the claude.ai Artifact CSP, baking tokens in via its bu
143
211
 
144
212
  ## License
145
213
 
146
- [MIT](./LICENSE) Β© Apliteni.
214
+ [MIT](./LICENSE) Β© Apliteni β€” for the **code**. The Apliteni name, logos, and brand
215
+ marks are trademarks and are **not** covered by the MIT license; see [TRADEMARK.md](./TRADEMARK.md).
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@apliteni/apliteni-ui",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
+ "workspaces": [
5
+ "react"
6
+ ],
4
7
  "description": "Apliteni shared design system & UI kit β€” tokens, components and the deck theme, framework-agnostic (HTML + CSS).",
5
8
  "type": "module",
6
9
  "license": "MIT",
@@ -21,11 +24,18 @@
21
24
  "./css": "./src/index.css",
22
25
  "./tokens": "./src/tokens/tokens.css",
23
26
  "./accents": "./src/tokens/accents.css",
24
- "./brand": "./src/tokens/brand.generated.css"
27
+ "./brand": "./src/tokens/brand.generated.css",
28
+ "./react": {
29
+ "types": "./react/dist/index.d.ts",
30
+ "import": "./react/dist/index.js",
31
+ "default": "./react/dist/index.js"
32
+ },
33
+ "./react/css": "./react/dist/index.css"
25
34
  },
26
35
  "files": [
27
36
  "src",
28
37
  "!src/**/*.test.js",
38
+ "react/dist",
29
39
  "README.md"
30
40
  ],
31
41
  "sideEffects": [
@@ -34,7 +44,9 @@
34
44
  "scripts": {
35
45
  "storybook": "storybook dev -p 6006 --no-open",
36
46
  "build-storybook": "storybook build -o storybook-static",
37
- "test": "node --test",
47
+ "build": "npm run build --workspace react",
48
+ "prepare": "npm run build",
49
+ "test": "for d in src stories site scripts; do [ -d \"$d\" ] || { echo \"npm test: no such directory: $d\" >&2; exit 1; }; done; node --test 'src/**/*.test.js' 'stories/**/*.test.js' 'site/**/*.test.js' 'scripts/**/*.test.js'",
38
50
  "tokens:sync": "node scripts/sync-brand-tokens.mjs",
39
51
  "tokens:check": "node scripts/sync-brand-tokens.mjs --check",
40
52
  "tokens:drift": "node scripts/brand-drift.mjs"
@@ -43,14 +55,11 @@
43
55
  "node": ">=20"
44
56
  },
45
57
  "devDependencies": {
46
- "@storybook/addon-a11y": "^8.6.14",
47
- "@storybook/addon-essentials": "^8.6.14",
48
- "@storybook/blocks": "^8.6.14",
49
- "@storybook/html": "^8.6.14",
50
- "@storybook/html-vite": "^8.6.14",
58
+ "@storybook/addon-a11y": "^10.5.5",
59
+ "@storybook/html-vite": "^10.5.5",
51
60
  "axe-core": "^4.12.1",
52
- "jsdom": "^29.1.1",
53
- "storybook": "^8.6.14",
54
- "vite": "^5.4.11"
61
+ "jsdom": "^30.0.1",
62
+ "storybook": "^10.5.5",
63
+ "vite": "^8.2.0"
55
64
  }
56
65
  }
@@ -0,0 +1,47 @@
1
+ # React components
2
+
3
+ Source for the kit's React layer β€” for surfaces that hold real client state
4
+ (dashboards, tables, filters, forms). They render the same `.ui-*` classes and
5
+ tokens as the vanilla kit, so the two layers can't drift.
6
+
7
+ This directory is a **private workspace**, not a package. It builds to `react/dist/`
8
+ and ships as the `@apliteni/apliteni-ui/react` subpath of the kit β€” one package, one
9
+ version, one pin. There is no `@apliteni/apliteni-ui-react` on npm.
10
+
11
+ **Decision rule:** does this surface hold meaningful client state?
12
+ No β†’ use the vanilla factories from `@apliteni/apliteni-ui`.
13
+ Yes β†’ use these React components.
14
+
15
+ ## Install (as a consumer)
16
+
17
+ ```bash
18
+ npm install @apliteni/apliteni-ui react react-dom
19
+ ```
20
+
21
+ The kit declares no dependency on `react` or `react-dom`, so install them yourself
22
+ (18 or newer). Miss them and the import fails with a module-not-found error.
23
+
24
+ ## Use
25
+
26
+ ```tsx
27
+ import '@apliteni/apliteni-ui/css'; // kit tokens + .ui-* classes
28
+ import '@apliteni/apliteni-ui/react/css'; // React components' shell styles (modal, pager)
29
+ import { DataTable, Modal, Button } from '@apliteni/apliteni-ui/react';
30
+ ```
31
+
32
+ Components: `DataTable`, `Modal`, `Button`, `Badge`, `Card`, `Icon`.
33
+
34
+ ## Work on them
35
+
36
+ From the repo root β€” one `npm install` covers the workspace:
37
+
38
+ ```bash
39
+ npm test -w react # vitest
40
+ npm run storybook -w react # http://localhost:6007
41
+ npm run build # tsup -> react/dist/
42
+ ```
43
+
44
+ The bare `@apliteni/apliteni-ui` specifier in this source resolves to the kit itself
45
+ once installed. In the repo there is no copy to resolve to, so `kit-alias.ts` points
46
+ vitest and Storybook straight at `../src/` β€” which is why the class-name parity tests
47
+ now compare against the working tree rather than the last published release.
@@ -0,0 +1,76 @@
1
+ /* src/Modal.css */
2
+ .rx-scrim {
3
+ position: fixed;
4
+ inset: 0;
5
+ background: rgba(0, 0, 0, .55);
6
+ backdrop-filter: blur(2px);
7
+ display: grid;
8
+ place-items: center;
9
+ z-index: 50;
10
+ }
11
+ .rx-modal {
12
+ width: min(480px, 92vw);
13
+ background: var(--surface);
14
+ border: 1px solid var(--border);
15
+ border-radius: var(--radius-xl);
16
+ box-shadow: var(--shadow-lg);
17
+ overflow: hidden;
18
+ }
19
+ .rx-modal__head {
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ padding: 16px 20px;
24
+ border-bottom: 1px solid var(--border);
25
+ }
26
+ .rx-modal__title {
27
+ font-weight: 600;
28
+ font-size: var(--text-lg);
29
+ }
30
+ .rx-modal__body {
31
+ padding: 20px;
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 16px;
35
+ }
36
+ .rx-modal__foot {
37
+ display: flex;
38
+ justify-content: flex-end;
39
+ gap: 12px;
40
+ padding: 16px 20px;
41
+ border-top: 1px solid var(--border);
42
+ }
43
+
44
+ /* src/DataTable.css */
45
+ .rx-sortable {
46
+ user-select: none;
47
+ }
48
+ .rx-sort {
49
+ all: unset;
50
+ cursor: pointer;
51
+ display: inline-flex;
52
+ align-items: center;
53
+ font: inherit;
54
+ color: inherit;
55
+ }
56
+ .rx-sort:focus-visible {
57
+ outline: 2px solid var(--accent);
58
+ outline-offset: 2px;
59
+ border-radius: 4px;
60
+ }
61
+ .rx-caret {
62
+ opacity: .5;
63
+ margin-left: 2px;
64
+ }
65
+ .rx-pager {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 8px;
69
+ justify-content: flex-end;
70
+ margin-top: 16px;
71
+ }
72
+ .rx-pager__info {
73
+ color: var(--muted);
74
+ font-size: var(--text-sm);
75
+ margin-right: auto;
76
+ }
@@ -0,0 +1,58 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ButtonHTMLAttributes } from 'react';
3
+
4
+ declare function Icon({ name }: {
5
+ name: string;
6
+ }): react.JSX.Element;
7
+
8
+ type ButtonProps = {
9
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
10
+ size?: 'sm' | 'md' | 'lg';
11
+ icon?: string;
12
+ iconRight?: string;
13
+ iconOnly?: boolean;
14
+ block?: boolean;
15
+ children?: ReactNode;
16
+ } & ButtonHTMLAttributes<HTMLButtonElement>;
17
+ declare function Button({ variant, size, icon, iconRight, iconOnly, block, children, type, ...rest }: ButtonProps): react.JSX.Element;
18
+
19
+ declare function Badge({ variant, children }: {
20
+ variant?: string;
21
+ children: ReactNode;
22
+ }): react.JSX.Element;
23
+
24
+ declare function Card({ title, sub, children }: {
25
+ title?: ReactNode;
26
+ sub?: ReactNode;
27
+ children?: ReactNode;
28
+ }): react.JSX.Element;
29
+
30
+ type ModalProps = {
31
+ open: boolean;
32
+ title: string;
33
+ onClose: () => void;
34
+ footer?: ReactNode;
35
+ children?: ReactNode;
36
+ };
37
+ declare function Modal({ open, title, onClose, footer, children }: ModalProps): react.ReactPortal | null;
38
+
39
+ type Column<T> = {
40
+ key: keyof T & string;
41
+ label: string;
42
+ num?: boolean;
43
+ sortable?: boolean;
44
+ render?: (row: T) => ReactNode;
45
+ };
46
+ type DataTableProps<T> = {
47
+ columns: Column<T>[];
48
+ rows: T[];
49
+ pageSize?: number;
50
+ selected: Set<string>;
51
+ onToggle: (name: string) => void;
52
+ onTogglePage: (names: string[]) => void;
53
+ };
54
+ declare function DataTable<T extends {
55
+ name: string;
56
+ }>({ columns, rows, pageSize, selected, onToggle, onTogglePage, }: DataTableProps<T>): react.JSX.Element;
57
+
58
+ export { Badge, Button, type ButtonProps, Card, type Column, DataTable, type DataTableProps, Icon, Modal, type ModalProps };
@@ -0,0 +1,254 @@
1
+ "use client";
2
+
3
+ // src/primitives/Icon.tsx
4
+ import { icon } from "@apliteni/apliteni-ui";
5
+ import { jsx } from "react/jsx-runtime";
6
+ function Icon({ name }) {
7
+ return /* @__PURE__ */ jsx(
8
+ "span",
9
+ {
10
+ "aria-hidden": "true",
11
+ style: { display: "inline-flex" },
12
+ dangerouslySetInnerHTML: { __html: icon(name) }
13
+ }
14
+ );
15
+ }
16
+
17
+ // src/primitives/Button.tsx
18
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
19
+ var cx = (...a) => a.filter(Boolean).join(" ");
20
+ function Button({
21
+ variant = "secondary",
22
+ size = "md",
23
+ icon: icon2,
24
+ iconRight,
25
+ iconOnly,
26
+ block,
27
+ children,
28
+ type = "button",
29
+ ...rest
30
+ }) {
31
+ const cls = cx(
32
+ "ui-btn",
33
+ variant && `ui-btn--${variant}`,
34
+ size !== "md" && `ui-btn--${size}`,
35
+ block && "ui-btn--block",
36
+ iconOnly && "ui-btn--icon"
37
+ );
38
+ const labelled = rest["aria-label"] != null || rest["aria-labelledby"] != null;
39
+ const fallback = typeof children === "string" && children.trim() ? children.trim() : icon2;
40
+ const named = iconOnly && !labelled && fallback ? { "aria-label": fallback, title: rest.title ?? fallback } : {};
41
+ return /* @__PURE__ */ jsxs("button", { type, className: cls, ...rest, ...named, children: [
42
+ icon2 && /* @__PURE__ */ jsx2(Icon, { name: icon2 }),
43
+ !iconOnly && children != null && /* @__PURE__ */ jsx2("span", { children }),
44
+ iconRight && /* @__PURE__ */ jsx2(Icon, { name: iconRight })
45
+ ] });
46
+ }
47
+
48
+ // src/primitives/Badge.tsx
49
+ import { jsx as jsx3 } from "react/jsx-runtime";
50
+ function Badge({ variant = "neutral", children }) {
51
+ const cls = ["ui-badge", variant !== "neutral" && `ui-badge--${variant}`].filter(Boolean).join(" ");
52
+ return /* @__PURE__ */ jsx3("span", { className: cls, children });
53
+ }
54
+
55
+ // src/primitives/Card.tsx
56
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
57
+ function Card({ title, sub, children }) {
58
+ return /* @__PURE__ */ jsxs2("div", { className: "ui-card", children: [
59
+ title != null && /* @__PURE__ */ jsx4("div", { className: "ui-card__title", children: title }),
60
+ sub != null && /* @__PURE__ */ jsx4("div", { className: "ui-card__sub", children: sub }),
61
+ children
62
+ ] });
63
+ }
64
+
65
+ // src/Modal.tsx
66
+ import { useEffect, useRef } from "react";
67
+ import { createPortal } from "react-dom";
68
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
69
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
70
+ function Modal({ open, title, onClose, footer, children }) {
71
+ const panel = useRef(null);
72
+ useEffect(() => {
73
+ if (!open) return;
74
+ const onKey = (e) => {
75
+ if (e.key === "Escape") {
76
+ onClose();
77
+ return;
78
+ }
79
+ if (e.key !== "Tab" || !panel.current) return;
80
+ const items = Array.from(panel.current.querySelectorAll(FOCUSABLE));
81
+ if (items.length === 0) {
82
+ e.preventDefault();
83
+ panel.current.focus();
84
+ return;
85
+ }
86
+ const first = items[0];
87
+ const last = items[items.length - 1];
88
+ const active = document.activeElement;
89
+ if (!panel.current.contains(active)) {
90
+ e.preventDefault();
91
+ first.focus();
92
+ return;
93
+ }
94
+ if (!e.shiftKey && active === last) {
95
+ e.preventDefault();
96
+ first.focus();
97
+ } else if (e.shiftKey && active === first) {
98
+ e.preventDefault();
99
+ last.focus();
100
+ }
101
+ };
102
+ document.addEventListener("keydown", onKey);
103
+ return () => document.removeEventListener("keydown", onKey);
104
+ }, [open, onClose]);
105
+ useEffect(() => {
106
+ if (!open) return;
107
+ const opener = document.activeElement;
108
+ const portalRoot = panel.current?.closest(".rx-scrim");
109
+ const muted = Array.from(document.body.children).filter((el) => el !== portalRoot && !el.hasAttribute("inert"));
110
+ muted.forEach((el) => el.setAttribute("inert", ""));
111
+ return () => {
112
+ muted.forEach((el) => el.removeAttribute("inert"));
113
+ if (opener && document.contains(opener)) opener.focus();
114
+ };
115
+ }, [open]);
116
+ useEffect(() => {
117
+ if (!open) return;
118
+ const target = panel.current?.querySelector(
119
+ ".rx-modal__body input, .rx-modal__body select, .rx-modal__body textarea, .rx-modal__body button"
120
+ ) || panel.current;
121
+ target?.focus();
122
+ }, [open]);
123
+ if (!open) return null;
124
+ return createPortal(
125
+ /* @__PURE__ */ jsx5("div", { className: "rx-scrim", onMouseDown: (e) => {
126
+ if (e.target === e.currentTarget) onClose();
127
+ }, children: /* @__PURE__ */ jsxs3("div", { className: "rx-modal", role: "dialog", "aria-modal": "true", "aria-label": title, tabIndex: -1, ref: panel, children: [
128
+ /* @__PURE__ */ jsxs3("div", { className: "rx-modal__head", children: [
129
+ /* @__PURE__ */ jsx5("div", { className: "rx-modal__title", children: title }),
130
+ /* @__PURE__ */ jsx5(Button, { variant: "ghost", size: "sm", iconOnly: true, icon: "x", "aria-label": "Close", onClick: onClose })
131
+ ] }),
132
+ /* @__PURE__ */ jsx5("div", { className: "rx-modal__body", children }),
133
+ footer && /* @__PURE__ */ jsx5("div", { className: "rx-modal__foot", children: footer })
134
+ ] }) }),
135
+ document.body
136
+ );
137
+ }
138
+
139
+ // src/DataTable.tsx
140
+ import { useMemo, useState } from "react";
141
+ import { Fragment, jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
142
+ function DataTable({
143
+ columns,
144
+ rows,
145
+ pageSize = 4,
146
+ selected,
147
+ onToggle,
148
+ onTogglePage
149
+ }) {
150
+ const [sort, setSort] = useState(
151
+ { key: columns.find((c) => c.sortable)?.key, dir: -1 }
152
+ );
153
+ const [page, setPage] = useState(0);
154
+ const sorted = useMemo(() => {
155
+ if (!sort.key) return rows;
156
+ const k = sort.key;
157
+ return [...rows].sort((a, b) => (a[k] > b[k] ? 1 : a[k] < b[k] ? -1 : 0) * sort.dir);
158
+ }, [rows, sort]);
159
+ const pages = Math.max(1, Math.ceil(sorted.length / pageSize));
160
+ const safePage = Math.min(page, pages - 1);
161
+ const slice = sorted.slice(safePage * pageSize, safePage * pageSize + pageSize);
162
+ const onSort = (k) => {
163
+ setSort((s) => s.key === k ? { key: k, dir: s.dir === 1 ? -1 : 1 } : { key: k, dir: -1 });
164
+ setPage(0);
165
+ };
166
+ const caret = (k) => sort.key === k ? sort.dir === 1 ? " \u25B2" : " \u25BC" : " \u2195";
167
+ const pageAllOn = slice.length > 0 && slice.every((r) => selected.has(r.name));
168
+ return /* @__PURE__ */ jsxs4(Fragment, { children: [
169
+ /* @__PURE__ */ jsxs4("table", { className: "ui-table ui-table--hover ui-table--zebra", children: [
170
+ /* @__PURE__ */ jsx6("thead", { children: /* @__PURE__ */ jsxs4("tr", { children: [
171
+ /* @__PURE__ */ jsx6("th", { scope: "col", children: /* @__PURE__ */ jsx6(
172
+ "input",
173
+ {
174
+ type: "checkbox",
175
+ checked: pageAllOn,
176
+ "aria-label": "Select all rows on this page",
177
+ onChange: () => onTogglePage(slice.map((r) => r.name))
178
+ }
179
+ ) }),
180
+ columns.map((c) => (
181
+ // The sort control is a real <button> inside the header cell. It used to be
182
+ // role="button" ON the <th>, which threw away the columnheader role and put
183
+ // aria-sort on a role that forbids it.
184
+ /* @__PURE__ */ jsx6(
185
+ "th",
186
+ {
187
+ scope: "col",
188
+ className: [c.num && "ui-table__num", c.sortable && "rx-sortable"].filter(Boolean).join(" "),
189
+ "aria-sort": c.sortable ? sort.key === c.key ? sort.dir === 1 ? "ascending" : "descending" : "none" : void 0,
190
+ children: c.sortable ? /* @__PURE__ */ jsxs4("button", { type: "button", className: "rx-sort", onClick: () => onSort(c.key), children: [
191
+ c.label,
192
+ /* @__PURE__ */ jsx6("span", { className: "rx-caret", "aria-hidden": "true", children: caret(c.key) })
193
+ ] }) : c.label
194
+ },
195
+ c.key
196
+ )
197
+ ))
198
+ ] }) }),
199
+ /* @__PURE__ */ jsx6("tbody", { children: slice.map((r) => /* @__PURE__ */ jsxs4("tr", { children: [
200
+ /* @__PURE__ */ jsx6("td", { children: /* @__PURE__ */ jsx6(
201
+ "input",
202
+ {
203
+ type: "checkbox",
204
+ checked: selected.has(r.name),
205
+ "aria-label": `Select ${r.name}`,
206
+ onChange: () => onToggle(r.name)
207
+ }
208
+ ) }),
209
+ columns.map((c) => /* @__PURE__ */ jsx6("td", { className: c.num ? "ui-table__num" : void 0, children: c.render ? c.render(r) : String(r[c.key]) }, c.key))
210
+ ] }, r.name)) })
211
+ ] }),
212
+ /* @__PURE__ */ jsxs4("div", { className: "rx-pager", children: [
213
+ /* @__PURE__ */ jsxs4("span", { className: "rx-pager__info", children: [
214
+ "Page ",
215
+ safePage + 1,
216
+ " of ",
217
+ pages,
218
+ " \xB7 ",
219
+ sorted.length,
220
+ " rows"
221
+ ] }),
222
+ /* @__PURE__ */ jsx6(
223
+ Button,
224
+ {
225
+ variant: "ghost",
226
+ size: "sm",
227
+ icon: "chevronLeft",
228
+ disabled: safePage === 0,
229
+ onClick: () => setPage(safePage - 1),
230
+ children: "Prev"
231
+ }
232
+ ),
233
+ /* @__PURE__ */ jsx6(
234
+ Button,
235
+ {
236
+ variant: "ghost",
237
+ size: "sm",
238
+ iconRight: "chevronRight",
239
+ disabled: safePage >= pages - 1,
240
+ onClick: () => setPage(safePage + 1),
241
+ children: "Next"
242
+ }
243
+ )
244
+ ] })
245
+ ] });
246
+ }
247
+ export {
248
+ Badge,
249
+ Button,
250
+ Card,
251
+ DataTable,
252
+ Icon,
253
+ Modal
254
+ };