diff --git a/.github/workflows/ci_cli.yml b/.github/workflows/ci_apps_cli.yml
similarity index 93%
rename from .github/workflows/ci_cli.yml
rename to .github/workflows/ci_apps_cli.yml
index 7de8eb4..94f737b 100644
--- a/.github/workflows/ci_cli.yml
+++ b/.github/workflows/ci_apps_cli.yml
@@ -1,10 +1,10 @@
-name: CI in the cli app
+name: apps/cli
on:
push:
paths:
- 'packages/components/**'
- 'apps/cli/**'
- - '.github/workflows/ci_cli.yml'
+ - '.github/workflows/ci_apps_cli.yml'
defaults:
run:
diff --git a/.github/workflows/ci_apps_hightable_demo.yml b/.github/workflows/ci_apps_hightable_demo.yml
new file mode 100644
index 0000000..39f52cc
--- /dev/null
+++ b/.github/workflows/ci_apps_hightable_demo.yml
@@ -0,0 +1,32 @@
+name: apps/hightable-demo
+on:
+ push:
+ paths:
+ - 'apps/hightable-demo/**'
+ - '.github/workflows/ci_apps_hightable_demo.yml'
+
+defaults:
+ run:
+ working-directory: ./apps/hightable-demo
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm i
+ - run: npm run lint
+
+ typecheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm i
+ - run: tsc
+
+ buildcheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: npm i
+ - run: npm run build
\ No newline at end of file
diff --git a/.github/workflows/ci_components.yml b/.github/workflows/ci_packages_components.yml
similarity index 88%
rename from .github/workflows/ci_components.yml
rename to .github/workflows/ci_packages_components.yml
index 279bc65..64289ed 100644
--- a/.github/workflows/ci_components.yml
+++ b/.github/workflows/ci_packages_components.yml
@@ -1,9 +1,9 @@
-name: CI in the components package
+name: packages/components
on:
push:
paths:
- 'packages/components/**'
- - '.github/workflows/ci_components.yml'
+ - '.github/workflows/ci_packages_components.yml'
defaults:
run:
diff --git a/apps/hightable-demo/.gitignore b/apps/hightable-demo/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/apps/hightable-demo/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/apps/hightable-demo/README.md b/apps/hightable-demo/README.md
new file mode 100644
index 0000000..02a4747
--- /dev/null
+++ b/apps/hightable-demo/README.md
@@ -0,0 +1,18 @@
+# HighTable demo
+
+This is an example project showing how to use [hightable](https://github.com/hyparam/hightable).
+
+## Build
+
+```bash
+cd apps/hightable-demo
+npm i
+npm run build
+```
+
+The build artifacts will be stored in the `dist/` directory and can be served using any static server, eg. `http-server`:
+
+```bash
+npm i -g http-server
+http-server dist/
+```
diff --git a/apps/hightable-demo/eslint.config.js b/apps/hightable-demo/eslint.config.js
new file mode 100644
index 0000000..f39d22c
--- /dev/null
+++ b/apps/hightable-demo/eslint.config.js
@@ -0,0 +1,40 @@
+import js from '@eslint/js'
+import react from 'eslint-plugin-react'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked],
+ // Set the react version
+ settings: { react: { version: '18.3' } },
+ files: ['src/**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ project: './tsconfig.json',
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ plugins: {
+ react,
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ ...react.configs.recommended.rules,
+ ...react.configs['jsx-runtime'].rules,
+
+ '@typescript-eslint/restrict-template-expressions': 'off',
+ },
+ },
+)
diff --git a/apps/hightable-demo/index.html b/apps/hightable-demo/index.html
new file mode 100644
index 0000000..d2d5102
--- /dev/null
+++ b/apps/hightable-demo/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+ HighTable Viewer Demo
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/hightable-demo/package.json b/apps/hightable-demo/package.json
new file mode 100644
index 0000000..3ba0fad
--- /dev/null
+++ b/apps/hightable-demo/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "hightable-demo",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "hightable": "0.7.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.13.0",
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.3",
+ "eslint": "^9.13.0",
+ "eslint-plugin-react": "^7.37.2",
+ "eslint-plugin-react-hooks": "^5.0.0",
+ "eslint-plugin-react-refresh": "^0.4.14",
+ "globals": "^15.11.0",
+ "typescript": "~5.6.2",
+ "typescript-eslint": "^8.11.0",
+ "vite": "^5.4.10"
+ }
+}
diff --git a/apps/hightable-demo/public/favicon.png b/apps/hightable-demo/public/favicon.png
new file mode 100644
index 0000000..cd162fd
Binary files /dev/null and b/apps/hightable-demo/public/favicon.png differ
diff --git a/apps/hightable-demo/src/HighTable.css b/apps/hightable-demo/src/HighTable.css
new file mode 100644
index 0000000..aa54220
--- /dev/null
+++ b/apps/hightable-demo/src/HighTable.css
@@ -0,0 +1,215 @@
+.table-container {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ position: relative;
+}
+
+.table-container * {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+.table-scroll {
+ flex: 1;
+ overflow: auto;
+}
+.table-scroll > div {
+ position: relative;
+}
+.table-scroll .table {
+ position: absolute;
+}
+
+.table {
+ border-collapse: separate;
+ border-spacing: 0;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ max-width: 100%;
+ overflow-x: auto;
+}
+.table:focus-visible {
+ outline: none;
+}
+
+/* header */
+.table thead th {
+ background-color: #eaeaeb;
+ border: none;
+ border-bottom: 2px solid #c9c9c9;
+ box-sizing: content-box;
+ color: #444;
+ height: 20px;
+ padding-top: 8px;
+ position: sticky;
+ top: -1px; /* fix 1px gap above thead */
+ user-select: none;
+ z-index: 10;
+}
+.table thead th:first-child {
+ border: none;
+}
+.table thead th:first-child span {
+ cursor: default;
+ width: 0;
+}
+.table tbody tr:first-child td {
+ border-top: 1px solid transparent;
+}
+
+/* sortable */
+.table.sortable thead th {
+ cursor: pointer;
+}
+.table thead th.orderby ::after {
+ position: absolute;
+ right: 8px;
+ top: 8px;
+ padding-left: 2px;
+ background-color: #eaeaeb;
+ content: "▾";
+}
+
+/* column resize */
+.table thead span {
+ position: absolute;
+ border-right: 1px solid #ddd;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: 8px;
+ cursor: col-resize;
+ transition: background-color 0.2s ease;
+}
+.table thead span:hover {
+ background-color: #aab;
+}
+
+/* row numbers */
+.table td:first-child {
+ background-color: #eaeaeb;
+ border-right: 1px solid #ddd;
+ color: #888;
+ font-size: 10px;
+ padding: 0 2px;
+ position: sticky;
+ left: 0;
+ text-align: center;
+ user-select: none;
+ min-width: 32px;
+ max-width: none;
+ width: 32px;
+}
+
+/* cells */
+.table th,
+.table td {
+ border-bottom: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+ height: 32px;
+ max-width: 2000px; /* prevent columns expanding */
+ padding: 4px 12px;
+ text-align: left;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+/* pending cell state */
+.table td.pending {
+ position: relative;
+}
+.table td.pending::after {
+ content: '';
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ right: 8px;
+ bottom: 8px;
+ border-radius: 4px;
+ background: linear-gradient(
+ 60deg,
+ rgba(0, 0, 0, 0.05) 25%,
+ rgba(0, 0, 0, 0.08) 50%,
+ rgba(0, 0, 0, 0.05) 75%
+ );
+ background-size: 120px 100%;
+ animation: textshimmer 3s infinite linear;
+}
+/* stagger row shimmering */
+.table tr:nth-child(2n) td.pending::after { animation-delay: -1s; }
+.table tr:nth-child(2n+1) td.pending::after { animation-delay: -3s; }
+.table tr:nth-child(3n) td.pending::after { animation-delay: -2s; }
+.table tr:nth-child(5n) td.pending::after { animation-delay: -4s; }
+.table tr:nth-child(7n) td.pending::after { animation-delay: -1.5s; }
+@keyframes textshimmer {
+ 0% {
+ background-position: -120px 0;
+ }
+ 100% {
+ background-position: 120px 0;
+ }
+}
+
+/* pending table state */
+.table th::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 4px;
+ background-color: #706fb1;
+ z-index: 100;
+}
+.pending .table th::before {
+ animation: shimmer 2s infinite linear;
+}
+@keyframes shimmer {
+ 0%, 100% { background-color: #6fb176; }
+ 50% { background-color: #adc6b0; }
+}
+
+/* don't hover on mobile */
+@media (hover: hover) {
+ .table tbody tr:hover {
+ background-color: #dbdbe5;
+ }
+ .table tbody tr:hover td {
+ border-right-color: #bbb;
+ }
+ .table tbody tr:hover td:first-child {
+ background-color: #ccd;
+ }
+}
+
+/* row error */
+.table tr[title] {
+ color: #a11;
+}
+
+/* table corner */
+.table-corner {
+ background-color: #e4e4e6;
+ border-right: 1px solid #ccc;
+ position: absolute;
+ height: 33px;
+ width: 32px;
+ top: 0;
+ left: 0;
+ z-index: 15;
+ box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.2);
+}
+/* mock row numbers */
+.mock-row-label {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ background: #eaeaeb;
+ z-index: -10;
+}
diff --git a/apps/hightable-demo/src/data.tsx b/apps/hightable-demo/src/data.tsx
new file mode 100644
index 0000000..496f6fa
--- /dev/null
+++ b/apps/hightable-demo/src/data.tsx
@@ -0,0 +1,41 @@
+import { rowCache, sortableDataFrame, wrapPromise } from 'hightable'
+
+function lorem(rand: number, length: number): string {
+ const words = 'lorem ipsum dolor sit amet consectetur adipiscing elit'.split(' ')
+ const str = Array.from({ length }, (_, i) => words[Math.floor(i + rand * 8) % 8]).join(' ')
+ return str[0].toUpperCase() + str.slice(1)
+}
+
+function delay(value: T, ms: number): Promise {
+ return new Promise(resolve => setTimeout(() => { resolve(value);}, ms))
+}
+
+const header = ['ID', 'Name', 'Age', 'UUID', 'Text', 'JSON']
+const mockData = {
+ header,
+ numRows: 10000,
+ rows(start: number, end: number) {
+ const arr: Record>>[] = []
+ for (let i = start; i < end; i++) {
+ const rand = Math.abs(Math.sin(i + 1))
+ const uuid = rand.toString(16).substring(2)
+ const partial = {
+ ID: i + 1,
+ Name: `Name${i}`,
+ Age: 20 + i % 80,
+ UUID: uuid,
+ Text: lorem(rand, 100),
+ }
+ const row = { ...partial, JSON: JSON.stringify(partial) }
+ // Map to randomly delayed promises
+ const promised = Object.fromEntries(Object.entries(row).map(([key, value]) =>
+ // discrete time delay for each cell to simulate async data loading
+ [key, wrapPromise(delay(value, 100 * Math.floor(10 * Math.random())))],
+ ))
+ arr.push(promised)
+ }
+ return arr
+ },
+}
+
+export const data = rowCache(sortableDataFrame(mockData))
diff --git a/apps/hightable-demo/src/hightable.svg b/apps/hightable-demo/src/hightable.svg
new file mode 100644
index 0000000..927446a
--- /dev/null
+++ b/apps/hightable-demo/src/hightable.svg
@@ -0,0 +1,5 @@
+
diff --git a/apps/hightable-demo/src/index.css b/apps/hightable-demo/src/index.css
new file mode 100644
index 0000000..1e9c39a
--- /dev/null
+++ b/apps/hightable-demo/src/index.css
@@ -0,0 +1,56 @@
+body {
+ display: flex;
+ font-family: 'Mulish', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ margin: 0;
+ padding: 0;
+}
+
+/* sidebar */
+nav {
+ height: 100vh;
+ min-width: 48px;
+ background-image: linear-gradient(to bottom, #667, #585669);
+ box-shadow: 0 0 4px rgba(10, 10, 10, 0.5);
+ height: 100vh;
+}
+
+/* brand logo */
+.brand {
+ color: #fff;
+ display: flex;
+ align-items: center;
+ filter: drop-shadow(0 0 2px #444);
+ font-family: 'Century Gothic', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-size: 1.1em;
+ font-weight: bold;
+ text-orientation: mixed;
+ opacity: 0.85;
+ padding: 10px 12px;
+ user-select: none;
+ writing-mode: vertical-rl;
+ text-decoration: none;
+}
+.brand:hover {
+ color: #fff;
+ filter: drop-shadow(0 0 2px #333);
+ opacity: 0.9;
+ text-decoration: none;
+}
+.brand::before {
+ content: '';
+ background: url(logo.svg) no-repeat 0 center;
+ background-size: 26px;
+ height: 26px;
+ width: 26px;
+ margin-bottom: 10px;
+}
+
+#app {
+ height: 100vh;
+ display: flex;
+ flex: 1;
+}
+
+.table-corner {
+ background: url('hightable.svg') #e4e4e6 no-repeat center 6px;
+}
diff --git a/apps/hightable-demo/src/logo.svg b/apps/hightable-demo/src/logo.svg
new file mode 100644
index 0000000..90903e2
--- /dev/null
+++ b/apps/hightable-demo/src/logo.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/hightable-demo/src/main.tsx b/apps/hightable-demo/src/main.tsx
new file mode 100644
index 0000000..e90e4d7
--- /dev/null
+++ b/apps/hightable-demo/src/main.tsx
@@ -0,0 +1,13 @@
+import { HighTable } from 'hightable'
+import { StrictMode } from 'react'
+import ReactDOM from 'react-dom/client'
+import { data } from './data'
+import './HighTable.css'
+import './index.css'
+
+const app = document.getElementById('app')
+if (!app) throw new Error('missing app element')
+
+ReactDOM.createRoot(app).render(
+
+)
diff --git a/apps/hightable-demo/src/vite-env.d.ts b/apps/hightable-demo/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/hightable-demo/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/hightable-demo/tsconfig.json b/apps/hightable-demo/tsconfig.json
new file mode 100644
index 0000000..243bf41
--- /dev/null
+++ b/apps/hightable-demo/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/apps/hightable-demo/vite.config.ts b/apps/hightable-demo/vite.config.ts
new file mode 100644
index 0000000..8b0f57b
--- /dev/null
+++ b/apps/hightable-demo/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/package.json b/package.json
index 03a38a9..ec931f1 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"license": "MIT",
"workspaces": [
"apps/cli",
+ "apps/hightable-demo",
"packages/components"
]
}