@apliteni/apliteni-ui 0.7.2 β†’ 0.8.1

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,39 @@ 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. When 6007 is taken Storybook moves to the next
109
+ free port, so the root Storybook does not trust the number: it probes the range
110
+ 6007 can drift into and composes the first port that proves it is this workspace's
111
+ Storybook. A stranger on the port is never composed β€” the "React components"
112
+ section is absent instead, and the terminal says why. Details in
113
+ [`react/README.md`](./react/README.md).
114
+
70
115
  ## Theming
71
116
 
72
117
  Theme is a `data-theme="dark|light"` attribute on `<html>`; accent is an orthogonal
@@ -98,21 +143,32 @@ src/
98
143
  assets/ # brand mark (seedling) + line-icon set
99
144
  components/ # HTML-string factories: button(), card(), badge(), topbar()…
100
145
  stories/ # Storybook: Foundations, Components, Apps
101
- site/ # ui.apli.tech landing page + static server
146
+ site/ # ui.apli.tech landing page (static site build)
147
+ react/ # React components β€” private workspace, built to react/dist/
148
+ dist/ # tsup output; shipped as …/react and …/react/css
102
149
  ```
103
150
 
104
151
  ## Develop
105
152
 
106
153
  ```bash
107
154
  npm install
108
- npm run storybook # http://localhost:6006
155
+ npm run storybook # http://localhost:6006, or the next free port
109
156
  npm run build-storybook # -> storybook-static/
110
- node site/build.mjs # -> site/public/ (landing + kit.css)
157
+ node site/build.mjs # -> site/public/ (landing + kit.css + /storybook)
158
+ ```
159
+
160
+ The React components build and test through the workspace (`npm install` at the repo
161
+ root covers them β€” there is no second install):
162
+
163
+ ```bash
164
+ npm run storybook -w react # http://localhost:6007, or the next free port
165
+ npm test -w react # vitest
166
+ npm run build # tsup -> react/dist/ (also runs on prepare)
111
167
  ```
112
168
 
113
169
  ## Publish (public npm)
114
170
 
115
- Versioned publish runs from CI on a GitHub Release (needs the `NPM_TOKEN` secret):
171
+ Versioned publish runs from CI on a GitHub Release:
116
172
 
117
173
  ```bash
118
174
  npm version patch # or minor / major β€” bumps package.json + tags
@@ -120,17 +176,34 @@ git push --follow-tags
120
176
  gh release create v$(node -p "require('./package.json').version") --generate-notes
121
177
  ```
122
178
 
123
- The **Release** workflow (`.github/workflows/release.yml`) then publishes with the
124
- built-in `GITHUB_TOKEN` (`packages: write`). No manual `npm publish` needed.
179
+ The **Release** workflow (`.github/workflows/release.yml`) then publishes to the public
180
+ npm registry over npm Trusted Publishing (OIDC) β€” there is no long-lived token. No
181
+ manual `npm publish` needed. It runs in two jobs: `build` installs and runs `npm pack`,
182
+ whose `prepare` rebuilds `react/dist` from the tagged commit, and `publish` β€” the only
183
+ job that can mint an OIDC credential β€” just publishes that tarball, so no dependency
184
+ or build script ever runs beside the credential. The packaging guard
185
+ (`scripts/packaging.test.js`) fails CI if the React subpath isn't in the tarball, and
186
+ the release itself re-checks the tarball before publishing it.
125
187
 
126
188
  ## Deploy (ui.apli.tech)
127
189
 
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:
190
+ The site is **100% static** (landing + hosted Storybook) β€” no container, no registry.
191
+ It's served by **Lessly static hosting**, built straight from this repo. The `site`
192
+ service builds from `main` with:
193
+
194
+ ```
195
+ npm ci && npm run build-storybook && node site/build.mjs
196
+ ```
197
+
198
+ and serves `site/public/`, which includes the landing page, `/changelog`, `kit.css`,
199
+ and the Storybook folded in at `/storybook`. Push to `main` and redeploy the `site`
200
+ service to roll it out.
201
+
202
+ To reproduce the exact static bundle locally:
131
203
 
132
204
  ```bash
133
- docker buildx build --platform linux/amd64 -t <registry>/ui-apli-tech:latest --push .
205
+ npm ci && npm run build-storybook && node site/build.mjs
206
+ # -> site/public/ (landing + /changelog + /storybook + kit.css)
134
207
  ```
135
208
 
136
209
  ## Adopting into the strategy portal
@@ -143,4 +216,5 @@ stays self-contained for the claude.ai Artifact CSP, baking tokens in via its bu
143
216
 
144
217
  ## License
145
218
 
146
- [MIT](./LICENSE) Β© Apliteni.
219
+ [MIT](./LICENSE) Β© Apliteni β€” for the **code**. The Apliteni name, logos, and brand
220
+ 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.1",
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,58 @@
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
+ 6007 is a request, not a promise: if something else already holds it, Storybook
45
+ walks upward to the next free port. The root Storybook composes these components
46
+ by following that drift β€” it probes 6007 through 6016 (the window Storybook's own
47
+ port-finder searches) and takes the first one whose `index.json` lists every story
48
+ file this workspace has on disk. A stranger on the port fails that check and is
49
+ never shown under "React components"; the section is simply absent, and the root
50
+ Storybook's terminal says which ports it tried and what it found.
51
+
52
+ The probe runs once, while the root Storybook boots. Start this one afterwards and
53
+ you have to restart the root Storybook before it appears.
54
+
55
+ The bare `@apliteni/apliteni-ui` specifier in this source resolves to the kit itself
56
+ once installed. In the repo there is no copy to resolve to, so `kit-alias.ts` points
57
+ vitest and Storybook straight at `../src/` β€” which is why the class-name parity tests
58
+ 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
+ };