@rs-x/cli 2.0.0-next.16 → 2.0.0-next.18
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/bin/rsx.cjs +226 -6
- package/package.json +1 -1
- package/{rs-x-vscode-extension-2.0.0-next.16.vsix → rs-x-vscode-extension-2.0.0-next.18.vsix} +0 -0
- package/templates/next-demo/components/demo-app.tsx +4 -0
- package/templates/vue-demo/README.md +27 -0
- package/templates/vue-demo/src/App.vue +89 -0
- package/templates/vue-demo/src/components/VirtualTableRow.vue +33 -0
- package/templates/vue-demo/src/components/VirtualTableShell.vue +58 -0
- package/templates/vue-demo/src/composables/use-virtual-table-controller.ts +33 -0
- package/templates/vue-demo/src/composables/use-virtual-table-viewport.ts +40 -0
- package/templates/vue-demo/src/env.d.ts +6 -0
- package/templates/vue-demo/src/lib/row-data.ts +35 -0
- package/templates/vue-demo/src/lib/row-model.ts +45 -0
- package/templates/vue-demo/src/lib/rsx-bootstrap.ts +46 -0
- package/templates/vue-demo/src/lib/virtual-table-controller.ts +247 -0
- package/templates/vue-demo/src/lib/virtual-table-data.service.ts +126 -0
- package/templates/vue-demo/src/main.ts +12 -0
- package/templates/vue-demo/src/style.css +440 -0
package/bin/rsx.cjs
CHANGED
|
@@ -27,6 +27,12 @@ const REACT_DEMO_TEMPLATE_DIR = path.join(
|
|
|
27
27
|
'templates',
|
|
28
28
|
'react-demo',
|
|
29
29
|
);
|
|
30
|
+
const VUE_DEMO_TEMPLATE_DIR = path.join(
|
|
31
|
+
__dirname,
|
|
32
|
+
'..',
|
|
33
|
+
'templates',
|
|
34
|
+
'vue-demo',
|
|
35
|
+
);
|
|
30
36
|
const NEXT_DEMO_TEMPLATE_DIR = path.join(
|
|
31
37
|
__dirname,
|
|
32
38
|
'..',
|
|
@@ -669,6 +675,77 @@ function writeFileWithDryRun(filePath, content, dryRun) {
|
|
|
669
675
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
670
676
|
}
|
|
671
677
|
|
|
678
|
+
function stripJsonComments(content) {
|
|
679
|
+
let result = '';
|
|
680
|
+
let inString = false;
|
|
681
|
+
let stringDelimiter = '"';
|
|
682
|
+
let inLineComment = false;
|
|
683
|
+
let inBlockComment = false;
|
|
684
|
+
|
|
685
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
686
|
+
const current = content[index];
|
|
687
|
+
const next = content[index + 1];
|
|
688
|
+
|
|
689
|
+
if (inLineComment) {
|
|
690
|
+
if (current === '\n') {
|
|
691
|
+
inLineComment = false;
|
|
692
|
+
result += current;
|
|
693
|
+
}
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (inBlockComment) {
|
|
698
|
+
if (current === '*' && next === '/') {
|
|
699
|
+
inBlockComment = false;
|
|
700
|
+
index += 1;
|
|
701
|
+
}
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (inString) {
|
|
706
|
+
result += current;
|
|
707
|
+
if (current === '\\') {
|
|
708
|
+
index += 1;
|
|
709
|
+
if (index < content.length) {
|
|
710
|
+
result += content[index];
|
|
711
|
+
}
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (current === stringDelimiter) {
|
|
715
|
+
inString = false;
|
|
716
|
+
}
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (current === '"' || current === "'") {
|
|
721
|
+
inString = true;
|
|
722
|
+
stringDelimiter = current;
|
|
723
|
+
result += current;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (current === '/' && next === '/') {
|
|
728
|
+
inLineComment = true;
|
|
729
|
+
index += 1;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (current === '/' && next === '*') {
|
|
734
|
+
inBlockComment = true;
|
|
735
|
+
index += 1;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
result += current;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return result;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function parseJsonc(content) {
|
|
746
|
+
return JSON.parse(stripJsonComments(content.replace(/^\uFEFF/u, '')));
|
|
747
|
+
}
|
|
748
|
+
|
|
672
749
|
function copyPathWithDryRun(sourcePath, targetPath, dryRun) {
|
|
673
750
|
if (dryRun) {
|
|
674
751
|
logInfo(`[dry-run] copy ${sourcePath} -> ${targetPath}`);
|
|
@@ -720,7 +797,7 @@ function upsertTypescriptPluginInTsConfig(configPath, dryRun) {
|
|
|
720
797
|
return;
|
|
721
798
|
}
|
|
722
799
|
|
|
723
|
-
const tsConfig =
|
|
800
|
+
const tsConfig = parseJsonc(fs.readFileSync(configPath, 'utf8'));
|
|
724
801
|
const compilerOptions = tsConfig.compilerOptions ?? {};
|
|
725
802
|
const plugins = Array.isArray(compilerOptions.plugins)
|
|
726
803
|
? compilerOptions.plugins
|
|
@@ -748,6 +825,26 @@ function upsertTypescriptPluginInTsConfig(configPath, dryRun) {
|
|
|
748
825
|
fs.writeFileSync(configPath, `${JSON.stringify(tsConfig, null, 2)}\n`, 'utf8');
|
|
749
826
|
}
|
|
750
827
|
|
|
828
|
+
function ensureTsConfigIncludePattern(configPath, pattern, dryRun) {
|
|
829
|
+
if (!fs.existsSync(configPath)) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const tsConfig = parseJsonc(fs.readFileSync(configPath, 'utf8'));
|
|
834
|
+
const include = Array.isArray(tsConfig.include) ? tsConfig.include : [];
|
|
835
|
+
if (!include.includes(pattern)) {
|
|
836
|
+
include.push(pattern);
|
|
837
|
+
}
|
|
838
|
+
tsConfig.include = include;
|
|
839
|
+
|
|
840
|
+
if (dryRun) {
|
|
841
|
+
logInfo(`[dry-run] patch ${configPath}`);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
fs.writeFileSync(configPath, `${JSON.stringify(tsConfig, null, 2)}\n`, 'utf8');
|
|
846
|
+
}
|
|
847
|
+
|
|
751
848
|
function toFileDependencySpec(fromDir, targetPath) {
|
|
752
849
|
const relative = path.relative(fromDir, targetPath).replace(/\\/gu, '/');
|
|
753
850
|
const normalized = relative.startsWith('.') ? relative : `./${relative}`;
|
|
@@ -798,6 +895,7 @@ function resolveProjectRsxSpecs(
|
|
|
798
895
|
) {
|
|
799
896
|
const includeAngularPackage = Boolean(options.includeAngularPackage);
|
|
800
897
|
const includeReactPackage = Boolean(options.includeReactPackage);
|
|
898
|
+
const includeVuePackage = Boolean(options.includeVuePackage);
|
|
801
899
|
const versionSpec = options.tag ? options.tag : RSX_PACKAGE_VERSION;
|
|
802
900
|
const defaults = {
|
|
803
901
|
'@rs-x/core': versionSpec,
|
|
@@ -807,6 +905,7 @@ function resolveProjectRsxSpecs(
|
|
|
807
905
|
'@rs-x/typescript-plugin': versionSpec,
|
|
808
906
|
...(includeAngularPackage ? { '@rs-x/angular': versionSpec } : {}),
|
|
809
907
|
...(includeReactPackage ? { '@rs-x/react': versionSpec } : {}),
|
|
908
|
+
...(includeVuePackage ? { '@rs-x/vue': versionSpec } : {}),
|
|
810
909
|
'@rs-x/cli': versionSpec,
|
|
811
910
|
};
|
|
812
911
|
|
|
@@ -818,6 +917,7 @@ function resolveProjectRsxSpecs(
|
|
|
818
917
|
'@rs-x/typescript-plugin': 'rs-x-typescript-plugin',
|
|
819
918
|
...(includeAngularPackage ? { '@rs-x/angular': 'rs-x-angular' } : {}),
|
|
820
919
|
...(includeReactPackage ? { '@rs-x/react': 'rs-x-react' } : {}),
|
|
920
|
+
...(includeVuePackage ? { '@rs-x/vue': 'rs-x-vue' } : {}),
|
|
821
921
|
'@rs-x/cli': 'rs-x-cli',
|
|
822
922
|
};
|
|
823
923
|
|
|
@@ -845,6 +945,11 @@ function resolveProjectRsxSpecs(
|
|
|
845
945
|
'rs-x-react': path.join(tarballsDir, 'rs-x-react'),
|
|
846
946
|
}
|
|
847
947
|
: {}),
|
|
948
|
+
...(includeVuePackage
|
|
949
|
+
? {
|
|
950
|
+
'rs-x-vue': path.join(tarballsDir, 'rs-x-vue'),
|
|
951
|
+
}
|
|
952
|
+
: {}),
|
|
848
953
|
'rs-x-cli': path.join(tarballsDir, 'rs-x-cli'),
|
|
849
954
|
};
|
|
850
955
|
|
|
@@ -893,6 +998,11 @@ function resolveProjectRsxSpecs(
|
|
|
893
998
|
'@rs-x/react': path.join(workspaceRoot, 'rs-x-react'),
|
|
894
999
|
}
|
|
895
1000
|
: {}),
|
|
1001
|
+
...(includeVuePackage
|
|
1002
|
+
? {
|
|
1003
|
+
'@rs-x/vue': path.join(workspaceRoot, 'rs-x-vue'),
|
|
1004
|
+
}
|
|
1005
|
+
: {}),
|
|
896
1006
|
'@rs-x/cli': path.join(workspaceRoot, 'rs-x-cli'),
|
|
897
1007
|
};
|
|
898
1008
|
|
|
@@ -1687,6 +1797,120 @@ function applyReactDemoStarter(projectRoot, projectName, pm, flags) {
|
|
|
1687
1797
|
}
|
|
1688
1798
|
}
|
|
1689
1799
|
|
|
1800
|
+
function applyVueDemoStarter(projectRoot, projectName, pm, flags) {
|
|
1801
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
1802
|
+
const tag = resolveInstallTag(flags);
|
|
1803
|
+
const tarballsDir =
|
|
1804
|
+
typeof flags['tarballs-dir'] === 'string'
|
|
1805
|
+
? path.resolve(process.cwd(), flags['tarballs-dir'])
|
|
1806
|
+
: typeof process.env.RSX_TARBALLS_DIR === 'string' &&
|
|
1807
|
+
process.env.RSX_TARBALLS_DIR.trim().length > 0
|
|
1808
|
+
? path.resolve(process.cwd(), process.env.RSX_TARBALLS_DIR)
|
|
1809
|
+
: null;
|
|
1810
|
+
const workspaceRoot = findRepoRoot(projectRoot);
|
|
1811
|
+
const rsxSpecs = resolveProjectRsxSpecs(
|
|
1812
|
+
projectRoot,
|
|
1813
|
+
workspaceRoot,
|
|
1814
|
+
tarballsDir,
|
|
1815
|
+
{ tag, includeVuePackage: true },
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
const templateFiles = ['README.md', 'src'];
|
|
1819
|
+
for (const entry of templateFiles) {
|
|
1820
|
+
copyPathWithDryRun(
|
|
1821
|
+
path.join(VUE_DEMO_TEMPLATE_DIR, entry),
|
|
1822
|
+
path.join(projectRoot, entry),
|
|
1823
|
+
dryRun,
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
const staleVueFiles = [
|
|
1828
|
+
path.join(projectRoot, 'public'),
|
|
1829
|
+
path.join(projectRoot, 'src/components/HelloWorld.vue'),
|
|
1830
|
+
path.join(projectRoot, 'src/assets'),
|
|
1831
|
+
];
|
|
1832
|
+
for (const stalePath of staleVueFiles) {
|
|
1833
|
+
removeFileOrDirectoryWithDryRun(stalePath, dryRun);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const readmePath = path.join(projectRoot, 'README.md');
|
|
1837
|
+
if (fs.existsSync(readmePath)) {
|
|
1838
|
+
const readmeSource = fs.readFileSync(readmePath, 'utf8');
|
|
1839
|
+
const nextReadme = readmeSource.replace(
|
|
1840
|
+
/^#\s+rsx-vue-example/mu,
|
|
1841
|
+
`# ${projectName}`,
|
|
1842
|
+
);
|
|
1843
|
+
if (dryRun) {
|
|
1844
|
+
logInfo(`[dry-run] patch ${readmePath}`);
|
|
1845
|
+
} else {
|
|
1846
|
+
fs.writeFileSync(readmePath, nextReadme, 'utf8');
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
const packageJsonPath = path.join(projectRoot, 'package.json');
|
|
1851
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
1852
|
+
logError(`package.json not found in generated Vue app: ${packageJsonPath}`);
|
|
1853
|
+
process.exit(1);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
1857
|
+
packageJson.name = projectName;
|
|
1858
|
+
packageJson.private = true;
|
|
1859
|
+
packageJson.version = '0.1.0';
|
|
1860
|
+
packageJson.type = 'module';
|
|
1861
|
+
packageJson.scripts = {
|
|
1862
|
+
'build:rsx': 'rsx build --project tsconfig.app.json --no-emit --prod',
|
|
1863
|
+
'typecheck:rsx': 'rsx typecheck --project tsconfig.app.json',
|
|
1864
|
+
dev: 'npm run build:rsx && vite',
|
|
1865
|
+
build: 'npm run build:rsx && vue-tsc -b && vite build',
|
|
1866
|
+
preview: 'vite preview',
|
|
1867
|
+
};
|
|
1868
|
+
packageJson.rsx = {
|
|
1869
|
+
build: {
|
|
1870
|
+
preparse: true,
|
|
1871
|
+
preparseFile: 'src/rsx-generated/rsx-aot-preparsed.generated.ts',
|
|
1872
|
+
compiled: true,
|
|
1873
|
+
compiledFile: 'src/rsx-generated/rsx-aot-compiled.generated.ts',
|
|
1874
|
+
compiledResolvedEvaluator: false,
|
|
1875
|
+
},
|
|
1876
|
+
};
|
|
1877
|
+
packageJson.dependencies = {
|
|
1878
|
+
vue: packageJson.dependencies?.vue ?? '^3.5.30',
|
|
1879
|
+
'@rs-x/core': rsxSpecs['@rs-x/core'],
|
|
1880
|
+
'@rs-x/state-manager': rsxSpecs['@rs-x/state-manager'],
|
|
1881
|
+
'@rs-x/expression-parser': rsxSpecs['@rs-x/expression-parser'],
|
|
1882
|
+
'@rs-x/vue': rsxSpecs['@rs-x/vue'],
|
|
1883
|
+
};
|
|
1884
|
+
packageJson.devDependencies = {
|
|
1885
|
+
...(packageJson.devDependencies ?? {}),
|
|
1886
|
+
'@rs-x/cli': rsxSpecs['@rs-x/cli'],
|
|
1887
|
+
'@rs-x/compiler': rsxSpecs['@rs-x/compiler'],
|
|
1888
|
+
'@rs-x/typescript-plugin': rsxSpecs['@rs-x/typescript-plugin'],
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1891
|
+
if (dryRun) {
|
|
1892
|
+
logInfo(`[dry-run] patch ${packageJsonPath}`);
|
|
1893
|
+
} else {
|
|
1894
|
+
fs.writeFileSync(
|
|
1895
|
+
packageJsonPath,
|
|
1896
|
+
`${JSON.stringify(packageJson, null, 2)}\n`,
|
|
1897
|
+
'utf8',
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const tsConfigAppPath = path.join(projectRoot, 'tsconfig.app.json');
|
|
1902
|
+
if (fs.existsSync(tsConfigAppPath)) {
|
|
1903
|
+
upsertTypescriptPluginInTsConfig(tsConfigAppPath, dryRun);
|
|
1904
|
+
ensureTsConfigIncludePattern(tsConfigAppPath, 'src/**/*.d.ts', dryRun);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
if (!Boolean(flags['skip-install'])) {
|
|
1908
|
+
logInfo(`Refreshing ${pm} dependencies for the RS-X Vue starter...`);
|
|
1909
|
+
run(pm, ['install'], { dryRun });
|
|
1910
|
+
logOk('Vue starter dependencies are up to date.');
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1690
1914
|
function applyNextDemoStarter(projectRoot, projectName, pm, flags) {
|
|
1691
1915
|
const dryRun = Boolean(flags['dry-run']);
|
|
1692
1916
|
const tag = resolveInstallTag(flags);
|
|
@@ -1836,11 +2060,7 @@ async function runProjectWithTemplate(template, flags) {
|
|
|
1836
2060
|
return;
|
|
1837
2061
|
}
|
|
1838
2062
|
if (normalizedTemplate === 'vuejs') {
|
|
1839
|
-
|
|
1840
|
-
...flags,
|
|
1841
|
-
entry: flags.entry ?? 'src/main.ts',
|
|
1842
|
-
});
|
|
1843
|
-
applyVueRsxTemplate(projectRoot, dryRun);
|
|
2063
|
+
applyVueDemoStarter(projectRoot, projectName, pm, flags);
|
|
1844
2064
|
}
|
|
1845
2065
|
});
|
|
1846
2066
|
|
package/package.json
CHANGED
package/{rs-x-vscode-extension-2.0.0-next.16.vsix → rs-x-vscode-extension-2.0.0-next.18.vsix}
RENAMED
|
Binary file
|
|
@@ -9,6 +9,10 @@ import { VirtualTableShell } from './virtual-table-shell';
|
|
|
9
9
|
type ThemeMode = 'light' | 'dark';
|
|
10
10
|
|
|
11
11
|
function getInitialTheme(): ThemeMode {
|
|
12
|
+
if (typeof window === 'undefined') {
|
|
13
|
+
return 'dark';
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
const storedTheme = window.localStorage.getItem('rsx-theme');
|
|
13
17
|
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
14
18
|
return storedTheme;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rsx-vue-example
|
|
2
|
+
|
|
3
|
+
Website & docs: https://www.rsxjs.com/
|
|
4
|
+
|
|
5
|
+
This starter shows how to use RS-X in a Vue 3 application with a million-row
|
|
6
|
+
virtual table that keeps rendering and expression memory bounded.
|
|
7
|
+
|
|
8
|
+
## Scripts
|
|
9
|
+
|
|
10
|
+
- `npm run dev` runs the RS-X build step and starts Vite
|
|
11
|
+
- `npm run build` generates RS-X artifacts and builds the production app
|
|
12
|
+
- `npm run preview` previews the production build
|
|
13
|
+
|
|
14
|
+
## Structure
|
|
15
|
+
|
|
16
|
+
- `src/App.vue` contains the app shell and theme toggle
|
|
17
|
+
- `src/components/` contains UI components
|
|
18
|
+
- `src/composables/` contains reusable Vue composables
|
|
19
|
+
- `src/lib/` contains RS-X bootstrap and virtual-table state/data utilities
|
|
20
|
+
- `src/env.d.ts` declares Vue SFC modules for the RS-X build/typecheck pass
|
|
21
|
+
|
|
22
|
+
## Notes
|
|
23
|
+
|
|
24
|
+
- The demo defaults to dark mode.
|
|
25
|
+
- It uses the `useRsxExpression` composable from `@rs-x/vue`.
|
|
26
|
+
- The generated RS-X cache files in `src/rsx-generated` are created by
|
|
27
|
+
`npm run build:rsx`; they are not checked into the starter template.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref, watch } from 'vue';
|
|
3
|
+
|
|
4
|
+
import VirtualTableShell from './components/VirtualTableShell.vue';
|
|
5
|
+
|
|
6
|
+
type ThemeMode = 'light' | 'dark';
|
|
7
|
+
|
|
8
|
+
const theme = ref<ThemeMode>('dark');
|
|
9
|
+
|
|
10
|
+
onMounted(() => {
|
|
11
|
+
const storedTheme = window.localStorage.getItem('rsx-theme');
|
|
12
|
+
if (storedTheme === 'light' || storedTheme === 'dark') {
|
|
13
|
+
theme.value = storedTheme;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
watch(
|
|
18
|
+
theme,
|
|
19
|
+
(nextTheme) => {
|
|
20
|
+
document.documentElement.setAttribute('data-theme', nextTheme);
|
|
21
|
+
document.body.setAttribute('data-theme', nextTheme);
|
|
22
|
+
window.localStorage.setItem('rsx-theme', nextTheme);
|
|
23
|
+
},
|
|
24
|
+
{ immediate: true },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
function toggleTheme(): void {
|
|
28
|
+
theme.value = theme.value === 'dark' ? 'light' : 'dark';
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<main class="app-shell">
|
|
34
|
+
<section class="hero">
|
|
35
|
+
<div class="container">
|
|
36
|
+
<div class="heroGrid">
|
|
37
|
+
<div class="heroLeft">
|
|
38
|
+
<p class="app-eyebrow">RS-X Vue Demo</p>
|
|
39
|
+
<h1 class="hTitle">Virtual Table</h1>
|
|
40
|
+
<p class="hSubhead">
|
|
41
|
+
Million-row scrolling with a fixed RS-X expression pool.
|
|
42
|
+
</p>
|
|
43
|
+
<p class="hSub">
|
|
44
|
+
This demo keeps rendering bounded while streaming pages on demand,
|
|
45
|
+
so scrolling stays smooth without growing expression memory with the
|
|
46
|
+
dataset.
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
<div class="heroActions">
|
|
50
|
+
<a
|
|
51
|
+
class="btn btnGhost"
|
|
52
|
+
href="https://www.rsxjs.com/"
|
|
53
|
+
target="_blank"
|
|
54
|
+
rel="noreferrer"
|
|
55
|
+
>
|
|
56
|
+
rs-x
|
|
57
|
+
</a>
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
class="btn btnGhost theme-toggle"
|
|
61
|
+
:aria-label="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`"
|
|
62
|
+
@click="toggleTheme"
|
|
63
|
+
>
|
|
64
|
+
{{ theme === 'dark' ? 'Light mode' : 'Dark mode' }}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<aside class="card heroNote">
|
|
70
|
+
<h2 class="cardTitle">What This Shows</h2>
|
|
71
|
+
<p class="cardText">
|
|
72
|
+
Only a small row-model pool stays alive while pages stream in around
|
|
73
|
+
the viewport. That means one million logical rows without one million
|
|
74
|
+
live bindings.
|
|
75
|
+
</p>
|
|
76
|
+
</aside>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
<section class="section">
|
|
82
|
+
<div class="container">
|
|
83
|
+
<section class="app-panel card">
|
|
84
|
+
<VirtualTableShell />
|
|
85
|
+
</section>
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
88
|
+
</main>
|
|
89
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { useRsxExpression } from '@rs-x/vue';
|
|
5
|
+
|
|
6
|
+
import type { RowView } from '../lib/virtual-table-controller';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ item: RowView }>();
|
|
9
|
+
|
|
10
|
+
const style = computed(() => ({
|
|
11
|
+
transform: `translateY(${props.item.top}px)`,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const id = useRsxExpression(props.item.row.idExpr);
|
|
15
|
+
const name = useRsxExpression(props.item.row.nameExpr);
|
|
16
|
+
const category = useRsxExpression(props.item.row.categoryExpr);
|
|
17
|
+
const price = useRsxExpression(props.item.row.priceExpr);
|
|
18
|
+
const quantity = useRsxExpression(props.item.row.quantityExpr);
|
|
19
|
+
const total = useRsxExpression(props.item.row.totalExpr);
|
|
20
|
+
const updatedAt = useRsxExpression(props.item.row.updatedAtExpr);
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="table-row" :style="style">
|
|
25
|
+
<span data-label="ID">#{{ id ?? 0 }}</span>
|
|
26
|
+
<span data-label="Name">{{ name ?? '' }}</span>
|
|
27
|
+
<span data-label="Category">{{ category ?? '' }}</span>
|
|
28
|
+
<span data-label="Price">€{{ price ?? 0 }}</span>
|
|
29
|
+
<span data-label="Qty">{{ quantity ?? 0 }}</span>
|
|
30
|
+
<span data-label="Total" class="total">€{{ total ?? 0 }}</span>
|
|
31
|
+
<span data-label="Updated">{{ updatedAt ?? '--' }}</span>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useTemplateRef } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { useVirtualTableController } from '../composables/use-virtual-table-controller';
|
|
5
|
+
import { useVirtualTableViewport } from '../composables/use-virtual-table-viewport';
|
|
6
|
+
import VirtualTableRow from './VirtualTableRow.vue';
|
|
7
|
+
|
|
8
|
+
const { controller, snapshot } = useVirtualTableController();
|
|
9
|
+
const viewport = useTemplateRef<HTMLDivElement>('viewport');
|
|
10
|
+
useVirtualTableViewport(controller, viewport);
|
|
11
|
+
|
|
12
|
+
const visibleRows = computed(() => snapshot.value.visibleRows);
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<section class="table-toolbar">
|
|
17
|
+
<div class="toolbar-left">
|
|
18
|
+
<h2>Inventory Snapshot</h2>
|
|
19
|
+
<p>{{ snapshot.totalRows }} rows • {{ snapshot.poolSize }} pre-wired models</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="toolbar-right">
|
|
22
|
+
<button type="button" @click="controller.toggleSort('price')">Sort by price</button>
|
|
23
|
+
<button type="button" @click="controller.toggleSort('quantity')">Sort by stock</button>
|
|
24
|
+
<button type="button" @click="controller.toggleSort('name')">Sort by name</button>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<div class="table-header">
|
|
29
|
+
<span>ID</span>
|
|
30
|
+
<span>Name</span>
|
|
31
|
+
<span>Category</span>
|
|
32
|
+
<span>Price</span>
|
|
33
|
+
<span>Qty</span>
|
|
34
|
+
<span>Total</span>
|
|
35
|
+
<span>Updated</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div
|
|
39
|
+
ref="viewport"
|
|
40
|
+
class="table-viewport"
|
|
41
|
+
@scroll="controller.setScrollTop(($event.target as HTMLDivElement).scrollTop)"
|
|
42
|
+
>
|
|
43
|
+
<div class="table-spacer" :style="{ height: `${snapshot.spacerHeight}px` }" />
|
|
44
|
+
<VirtualTableRow
|
|
45
|
+
v-for="item in visibleRows"
|
|
46
|
+
:key="item.index"
|
|
47
|
+
:item="item"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div class="table-footer">
|
|
52
|
+
<div>
|
|
53
|
+
Rows in view: {{ snapshot.rowsInView }} • Loaded pages:
|
|
54
|
+
{{ snapshot.loadedPageCount }}
|
|
55
|
+
</div>
|
|
56
|
+
<div>Scroll to stream pages from a 1,000,000-row virtual dataset.</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCurrentScope,
|
|
3
|
+
onScopeDispose,
|
|
4
|
+
shallowRef,
|
|
5
|
+
type ShallowRef,
|
|
6
|
+
} from 'vue';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
VirtualTableController,
|
|
10
|
+
type VirtualTableSnapshot,
|
|
11
|
+
} from '../lib/virtual-table-controller';
|
|
12
|
+
|
|
13
|
+
export function useVirtualTableController(): {
|
|
14
|
+
controller: VirtualTableController;
|
|
15
|
+
snapshot: ShallowRef<VirtualTableSnapshot>;
|
|
16
|
+
} {
|
|
17
|
+
const controller = new VirtualTableController();
|
|
18
|
+
const snapshot = shallowRef(controller.getSnapshot());
|
|
19
|
+
const unsubscribe = controller.subscribe(() => {
|
|
20
|
+
snapshot.value = controller.getSnapshot();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (getCurrentScope()) {
|
|
24
|
+
onScopeDispose(() => {
|
|
25
|
+
unsubscribe();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
controller,
|
|
31
|
+
snapshot,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getCurrentScope, onMounted, onScopeDispose, type Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
import type { VirtualTableController } from '../lib/virtual-table-controller';
|
|
4
|
+
|
|
5
|
+
const COMPACT_BREAKPOINT_PX = 720;
|
|
6
|
+
const DEFAULT_ROW_HEIGHT = 36;
|
|
7
|
+
const COMPACT_ROW_HEIGHT = 168;
|
|
8
|
+
|
|
9
|
+
export function useVirtualTableViewport(
|
|
10
|
+
controller: VirtualTableController,
|
|
11
|
+
viewportRef: Ref<HTMLDivElement | null>,
|
|
12
|
+
): void {
|
|
13
|
+
let observer: ResizeObserver | undefined;
|
|
14
|
+
|
|
15
|
+
onMounted(() => {
|
|
16
|
+
const viewport = viewportRef.value;
|
|
17
|
+
if (!viewport) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const syncMetrics = (): void => {
|
|
22
|
+
controller.setViewportHeight(viewport.clientHeight);
|
|
23
|
+
controller.setRowHeight(
|
|
24
|
+
viewport.clientWidth <= COMPACT_BREAKPOINT_PX
|
|
25
|
+
? COMPACT_ROW_HEIGHT
|
|
26
|
+
: DEFAULT_ROW_HEIGHT,
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
syncMetrics();
|
|
31
|
+
observer = new ResizeObserver(syncMetrics);
|
|
32
|
+
observer.observe(viewport);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (getCurrentScope()) {
|
|
36
|
+
onScopeDispose(() => {
|
|
37
|
+
observer?.disconnect();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type SortKey = 'id' | 'name' | 'price' | 'quantity' | 'category';
|
|
2
|
+
export type SortDirection = 'asc' | 'desc';
|
|
3
|
+
|
|
4
|
+
export type RowData = {
|
|
5
|
+
id: number;
|
|
6
|
+
name: string;
|
|
7
|
+
price: number;
|
|
8
|
+
quantity: number;
|
|
9
|
+
category: string;
|
|
10
|
+
updatedAt: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const categories = ['Hardware', 'Software', 'Design', 'Ops'];
|
|
14
|
+
|
|
15
|
+
function pad(value: number): string {
|
|
16
|
+
return value.toString().padStart(2, '0');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createRowData(id: number): RowData {
|
|
20
|
+
const zeroBasedId = id - 1;
|
|
21
|
+
const price = 25 + (zeroBasedId % 1000) / 10;
|
|
22
|
+
const quantity = 1 + (Math.floor(zeroBasedId / 1000) % 100);
|
|
23
|
+
const category = categories[zeroBasedId % categories.length] ?? 'General';
|
|
24
|
+
const month = pad(((zeroBasedId * 7) % 12) + 1);
|
|
25
|
+
const day = pad(((zeroBasedId * 11) % 28) + 1);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
name: `Product ${id.toString().padStart(7, '0')}`,
|
|
30
|
+
price,
|
|
31
|
+
quantity,
|
|
32
|
+
category,
|
|
33
|
+
updatedAt: `2026-${month}-${day}`,
|
|
34
|
+
};
|
|
35
|
+
}
|