kreftjs 0.0.20-beta → 0.0.21-beta

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.
Files changed (54) hide show
  1. package/README.md +10 -1
  2. package/bin/backend/http.js +6 -2
  3. package/bin/backend/index.js +4 -0
  4. package/bin/backend/stream.js +74 -0
  5. package/bin/build.js +12 -5
  6. package/bin/kreftjs.js +35 -11
  7. package/book/ar/16-virtual-list.md +131 -0
  8. package/book/ar/17-combobox.md +169 -0
  9. package/book/ar/18-tabs-offline.md +168 -0
  10. package/book/ar/19-wizard.md +196 -0
  11. package/book/ar/20-pipes-match-props.md +75 -0
  12. package/book/ar/README.md +12 -1
  13. package/book/kreft-book-ar.docx +0 -0
  14. package/book/~$eft-book-ar.docx +0 -0
  15. package/docs/README.md +110 -12
  16. package/editors/sublime/Comments.tmPreferences +23 -0
  17. package/editors/sublime/Component.sublime-snippet +20 -0
  18. package/editors/sublime/Kreft.sublime-syntax +10 -0
  19. package/editors/sublime/LICENSE +95 -0
  20. package/editors/sublime/README.md +7 -0
  21. package/editors/sublime/action.sublime-snippet +10 -0
  22. package/editors/sublime/derive.sublime-snippet +8 -0
  23. package/editors/sublime/for.sublime-snippet +12 -0
  24. package/editors/sublime/k-case.sublime-snippet +8 -0
  25. package/editors/sublime/k-if.sublime-snippet +8 -0
  26. package/editors/sublime/k-match.sublime-snippet +12 -0
  27. package/editors/sublime/kreft-wait.sublime-snippet +12 -0
  28. package/editors/sublime/layout.sublime-snippet +8 -0
  29. package/editors/sublime/load.sublime-snippet +8 -0
  30. package/editors/sublime/on.sublime-snippet +10 -0
  31. package/editors/sublime/pipe.sublime-snippet +8 -0
  32. package/editors/sublime/prop-typed.sublime-snippet +8 -0
  33. package/editors/sublime/saved.sublime-snippet +8 -0
  34. package/editors/sublime/section.sublime-snippet +10 -0
  35. package/editors/sublime/state.sublime-snippet +8 -0
  36. package/editors/sublime/watch.sublime-snippet +10 -0
  37. package/editors/sublime/when.sublime-snippet +10 -0
  38. package/editors/sublime/yield.sublime-snippet +8 -0
  39. package/editors/vscode/snippets/kreftjs.json +143 -15
  40. package/editors/zed/LICENSE +95 -0
  41. package/editors/zed/README.md +17 -0
  42. package/editors/zed/extension.toml +11 -0
  43. package/editors/zed/languages/kreft/brackets.scm +4 -0
  44. package/editors/zed/languages/kreft/config.toml +17 -0
  45. package/editors/zed/languages/kreft/highlights.scm +18 -0
  46. package/editors/zed/languages/kreft/indents.scm +5 -0
  47. package/editors/zed/languages/kreft/injections.scm +14 -0
  48. package/editors/zed/languages/kreft/outline.scm +3 -0
  49. package/kreftjs.d.ts +1 -1
  50. package/kreftjs.js +219 -14
  51. package/package.json +2 -2
  52. package/server.d.ts +2 -0
  53. package/templates/app/frontend/kreftjs.d.ts +1 -1
  54. package/templates/app/frontend/kreftjs.js +219 -14
package/README.md CHANGED
@@ -24,7 +24,7 @@ action add() { count++ }
24
24
  You need [Node.js](https://nodejs.org) 22.5 or newer.
25
25
 
26
26
  ```bash
27
- npm i -g kreftjs # once: makes the `kreft` command available everywhere
27
+ npm install -g kreftjs@latest # once: makes the `kreft` command available everywhere
28
28
  kreft new my-app # asks: JavaScript or TypeScript?
29
29
  cd my-app
30
30
  npm run dev
@@ -42,6 +42,15 @@ Open the address in the terminal (normally `http://localhost:8000`). A new proje
42
42
  | **Authentication** | Cookie sessions with CSRF protection, API tokens with permissions, device list, two-factor codes, email verification, account lockout. |
43
43
  | **Tooling** | `kreft dev` with hot reload, DevTools, an error monitor, `kreft build` (CSP-safe), prerendering, TypeScript, Vite and Electron support, a safer `kreft add`, an MCP server for AI agents. |
44
44
 
45
+ ## Put it online
46
+
47
+ ```bash
48
+ npm install kreftjs@latest # the newest version
49
+ npm run build # the finished site, ready to upload
50
+ ```
51
+
52
+ Upload what is inside the output folder (`dist/`; for this repository's own website, `dist/kreftjs-com`) to your host. The kreftjs.com steps, and releasing a new package version with `npm publish`, are in [Publish the website](docs/README.md#publish-the-website-kreftjscom).
53
+
45
54
  ## Desktop apps (Electron)
46
55
 
47
56
  ```bash
@@ -96,6 +96,8 @@ const cookieSecure = () => {
96
96
  function response(body, status = 200, headers = {}) { return new Response(body, status, headers); }
97
97
  response.json = (body, status = 200, headers = {}) => new Response(body, status, headers);
98
98
  response.noContent = () => new Response(undefined, 204);
99
+ // response.stream('/path/video.mp4') - sends a file with byte-range support (seeking, Safari); { type, download, headers }
100
+ response.stream = (file, opts = {}) => { const r = new Response(undefined, 200); r.streamFile = { file, ...opts }; return r; };
99
101
  const noContent = () => new Response(undefined, 204);
100
102
  const toResponse = (v) => (v instanceof Response ? v : v === undefined ? new Response(undefined, 204) : new Response(v, 200));
101
103
 
@@ -313,8 +315,10 @@ class Request {
313
315
  if (kind === 'unique' && ignore && ignore !== 'NULL') { sql += ` AND ${conn.q(idColumn)} != ?`; params.push(ignore); }
314
316
  const found = (await conn.query(sql + ' LIMIT 1', params)).length > 0;
315
317
  const label = (names && names[field]) || field.replace(/_/g, ' ');
316
- if (kind === 'unique' && found) (errors[field] = errors[field] || []).push(`The ${label} has already been taken.`);
317
- if (kind === 'exists' && !found) (errors[field] = errors[field] || []).push(`The selected ${label} is invalid.`);
318
+ // your own message for the rule: { 'title.unique': 'That title is already used.' }
319
+ const own = messages && (messages[`${field}.${kind}`] || messages[kind]);
320
+ if (kind === 'unique' && found) (errors[field] = errors[field] || []).push(own || `The ${label} has already been taken.`);
321
+ if (kind === 'exists' && !found) (errors[field] = errors[field] || []).push(own || `The selected ${label} is invalid.`);
318
322
  }
319
323
  }
320
324
  if (Object.keys(errors).length) throw new ValidationError(errors);
@@ -273,6 +273,10 @@ const SECURITY_HEADERS = {
273
273
  });
274
274
  const send = (res, r) => {
275
275
  if (res.headersSent || res.writableEnded) return;
276
+ if (r.streamFile) { // response.stream(file): byte ranges for video and audio
277
+ const { file, ...o } = r.streamFile;
278
+ return require('./stream.js').sendFile(res.req, res, file, { ...o, headers: { 'Cache-Control': 'no-store', ...SECURITY_HEADERS, ...r.headers, ...(o.headers || {}) } });
279
+ }
276
280
  const status = r.statusCode || 200;
277
281
  const noBody = status === 204 || status === 304 || r.body === undefined;
278
282
  const isHtml = typeof r.body === 'string' && (r.headers && r.headers['Content-Type'] && r.headers['Content-Type'].includes('text/html'));
@@ -0,0 +1,74 @@
1
+ // Sending a file the way a video player needs it: byte ranges (206), so the browser can seek and Safari plays at all.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const MEDIA_TYPES = {
6
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.ogv': 'video/ogg', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska',
7
+ '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.wav': 'audio/wav', '.opus': 'audio/ogg', '.flac': 'audio/flac',
8
+ '.m3u8': 'application/vnd.apple.mpegurl', '.ts': 'video/mp2t', '.m4s': 'video/iso.segment', '.mpd': 'application/dash+xml', '.vtt': 'text/vtt',
9
+ };
10
+
11
+ // "bytes=0-99", "bytes=100-", "bytes=-500" -> { start, end } | null (no range) | false (cannot be satisfied)
12
+ function parseRange(header, size) {
13
+ if (!header) return null;
14
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim()); // one range only; several ranges are answered with the whole file
15
+ if (!m || (m[1] === '' && m[2] === '')) return null;
16
+ let start, end;
17
+ if (m[1] === '') { // the last N bytes
18
+ const n = Number(m[2]);
19
+ if (n === 0) return false;
20
+ start = Math.max(0, size - n); end = size - 1;
21
+ } else {
22
+ start = Number(m[1]);
23
+ end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
24
+ }
25
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return false;
26
+ return { start, end };
27
+ }
28
+
29
+ // sendFile(req, res, file, { type, headers, status, download })
30
+ function sendFile(req, res, file, opts = {}) {
31
+ req = req || res.req;
32
+ let stat;
33
+ try { stat = fs.statSync(file); if (!stat.isFile()) throw new Error('not a file'); } catch (_) {
34
+ if (!res.headersSent) res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
35
+ return res.end('Not found');
36
+ }
37
+ const size = stat.size;
38
+ const type = opts.type || MEDIA_TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream';
39
+ const etag = `W/"${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
40
+ const headers = {
41
+ 'Content-Type': type,
42
+ 'Accept-Ranges': 'bytes',
43
+ 'Last-Modified': stat.mtime.toUTCString(),
44
+ ETag: etag,
45
+ 'X-Content-Type-Options': 'nosniff',
46
+ ...(opts.download ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.basename(file))}` } : {}),
47
+ ...(opts.headers || {}),
48
+ };
49
+ const method = (req && req.method) || 'GET';
50
+ const h = (req && req.headers) || {};
51
+
52
+ if (h['if-none-match'] === etag && !h.range) { res.writeHead(304, headers); return res.end(); }
53
+
54
+ let range = parseRange(h.range, size);
55
+ // If-Range: only honor the range when the file is still the one the client started with
56
+ if (range && h['if-range'] && h['if-range'] !== etag && h['if-range'] !== headers['Last-Modified']) range = null;
57
+ if (range === false) {
58
+ res.writeHead(416, { 'Content-Range': `bytes */${size}`, 'Content-Type': 'text/plain; charset=utf-8' });
59
+ return res.end('Range not satisfiable');
60
+ }
61
+
62
+ let status = opts.status || 200, start = 0, end = size - 1;
63
+ if (range) { status = 206; ({ start, end } = range); headers['Content-Range'] = `bytes ${start}-${end}/${size}`; }
64
+ headers['Content-Length'] = size === 0 ? 0 : end - start + 1;
65
+ res.writeHead(status, headers);
66
+ if (method === 'HEAD' || size === 0) return res.end();
67
+
68
+ const stream = fs.createReadStream(file, { start, end });
69
+ stream.on('error', () => res.destroy());
70
+ res.on('close', () => stream.destroy()); // the viewer seeked or left: stop reading
71
+ stream.pipe(res);
72
+ }
73
+
74
+ module.exports = { sendFile, parseRange, MEDIA_TYPES };
package/bin/build.js CHANGED
@@ -25,7 +25,7 @@ const path = require('path');
25
25
  const Kreftjs = require('../kreftjs.js');
26
26
 
27
27
  const { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing,
28
- scopeCss, hashString, propType, simplePathRE, fnExpRE, forAliasRE, stripTypeScript } = Kreftjs.__internal;
28
+ scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, stripTypeScript } = Kreftjs.__internal;
29
29
 
30
30
  const deps = require('./deps.js');
31
31
  // component files: .kreft, or .kreftjs in a project from the 0.0.x betas
@@ -329,12 +329,16 @@ function compileFile(file, rel, root) {
329
329
 
330
330
  // props: keep the default as a function so every instance gets its own copy
331
331
  const props = Object.entries(sp.props).map(([p, expr]) => {
332
- if (expr == null) return `${JSON.stringify(p)}: {}`;
332
+ // `prop size: 'sm' | 'lg'`: the type text is kept, so the built app warns about a wrong value too
333
+ const ti = sp.propTypes && sp.propTypes[p];
334
+ const simple = ti && { string: 'String', number: 'Number', boolean: 'Boolean' }[ti.type.trim()];
335
+ const typed = ti ? `, typeText: ${JSON.stringify(ti.type)}, check: Kreftjs.__typeCheck(${JSON.stringify(ti.type)})${ti.required ? ', required: true' : ''}` : '';
336
+ if (expr == null) return `${JSON.stringify(p)}: { ${simple ? `type: ${simple}` : ''}${typed} }`.replace('{ , ', '{ ').replace('{ }', '{}');
333
337
  let sample;
334
338
  try { sample = new Function(`return (${expr})`)(); } catch (_) { /* uses something only known at runtime */ }
335
- if (typeof sample === 'function') return `${JSON.stringify(p)}: { type: Function, default: (${expr}) }`;
339
+ if (typeof sample === 'function') return `${JSON.stringify(p)}: { type: Function, default: (${expr})${typed} }`;
336
340
  const type = propType(sample);
337
- return `${JSON.stringify(p)}: { ${type ? `type: ${type.name}, ` : ''}default: function () { return (${expr}); } }`;
341
+ return `${JSON.stringify(p)}: { ${type ? `type: ${type.name}, ` : ''}default: function () { return (${expr}); }${typed} }`;
338
342
  });
339
343
 
340
344
  // imports: other .kreft files become component references; .js modules load with import()
@@ -439,12 +443,15 @@ function addRouterTemplates(fns) {
439
443
  addHandler(fns, 'navigate($event)');
440
444
  }
441
445
 
442
- const STRUCTURAL = new Set(['if', 'elif', 'else', 'each', 'key', 'ref', 'skip', 'cloak', 'slot', 'empty', 'once', 'animate', 'waiting', 'failed']);
446
+ const STRUCTURAL = new Set(['match', 'case', 'default', 'if', 'elif', 'else', 'each', 'key', 'ref', 'skip', 'cloak', 'slot', 'empty', 'once', 'animate', 'waiting', 'failed']);
443
447
 
444
448
  function collectExpressions(template, fns) {
445
449
  const html = prepareTemplate(template);
450
+ const subjects = []; // k-match subjects seen so far: a k-case is compared with one of them
446
451
  scanHtml(html, {
447
452
  attr(name, value) {
453
+ if (name === 'k-match') { const v = stripBrace(value); subjects.push(v); return addEval(fns, v); }
454
+ if (name === 'k-case') { const raw = stripBrace(value); for (const sub of subjects) addEval(fns, caseCond(sub, raw)); return; }
448
455
  if (name[0] === ':' || name === 'k-bind' || name.startsWith('k-bind:')) return addEval(fns, value);
449
456
  if (name[0] === '@' || name.startsWith('k-on:')) return addHandler(fns, stripBrace(value));
450
457
  if (name.startsWith('k-')) {
package/bin/kreftjs.js CHANGED
@@ -583,6 +583,30 @@ function copyFolder(from, to) {
583
583
  }
584
584
 
585
585
  /* ------------------------------------------------------------------ kreft vscode */
586
+ /* VS Code only loads extensions listed in extensions.json, and after "Uninstall" it keeps the folder in .obsolete and
587
+ deletes it on the next start. Copying the folder is not enough after an uninstall: list it again and clear .obsolete. */
588
+ function registerExtension(dir, folder, version) {
589
+ const id = 'kreft.kreftjs';
590
+ const location = path.join(dir, folder);
591
+ try {
592
+ const file = path.join(dir, 'extensions.json');
593
+ let list = [];
594
+ if (fs.existsSync(file)) { try { list = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { list = []; } }
595
+ if (!Array.isArray(list)) list = [];
596
+ list = list.filter((e) => !(e && e.identifier && /^kreft\.kreft(js)?$/.test(e.identifier.id)));
597
+ list.push({ identifier: { id }, version, location: { $mid: 1, fsPath: location, external: 'file://' + location, path: location, scheme: 'file' }, relativeLocation: folder, metadata: { installedTimestamp: Date.now(), pinned: false, source: 'resource', isApplicationScoped: false, isMachineScoped: false, isBuiltin: false, targetPlatform: 'undefined' } });
598
+ fs.writeFileSync(file, JSON.stringify(list));
599
+ } catch {}
600
+ try {
601
+ const file = path.join(dir, '.obsolete');
602
+ if (fs.existsSync(file)) {
603
+ const o = JSON.parse(fs.readFileSync(file, 'utf8'));
604
+ for (const k of Object.keys(o)) if (k.startsWith('kreft.kreftjs-') || k.startsWith('kreft.kreft-')) delete o[k];
605
+ fs.writeFileSync(file, JSON.stringify(o));
606
+ }
607
+ } catch {}
608
+ }
609
+
586
610
  function installVscode() {
587
611
  const os = require('os');
588
612
  const from = path.join(__dirname, '..', 'editors', 'vscode');
@@ -595,6 +619,7 @@ function installVscode() {
595
619
  fs.mkdirSync(dir, { recursive: true });
596
620
  fs.readdirSync(dir).filter((n) => n.startsWith('kreft.kreftjs-') || n.startsWith('kreft.kreft-')) /* the older extensions go too */.forEach((n) => fs.rmSync(path.join(dir, n), { recursive: true, force: true }));
597
621
  copyFolder(from, path.join(dir, `kreft.kreftjs-${ext.version}`));
622
+ registerExtension(dir, `kreft.kreftjs-${ext.version}`, ext.version);
598
623
  console.log(` Installed in ${path.join(dir, `kreft.kreftjs-${ext.version}`)}`);
599
624
  }
600
625
  // Ensure current project .vscode/settings.json maps .kreft files to Kreft and configures formatter
@@ -736,7 +761,7 @@ async function devServer(args, { production = false } = {}) {
736
761
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.kreft': 'text/plain', '.kreftjs': 'text/plain',
737
762
  '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
738
763
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
739
- '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain', '.md': 'text/markdown',
764
+ '.ico': 'image/x-icon', ...(({ '.ts': _ts, ...media }) => media)(require('./backend/stream.js').MEDIA_TYPES), '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain', '.md': 'text/markdown',
740
765
  };
741
766
  const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch (_) { return false; } };
742
767
 
@@ -754,12 +779,15 @@ async function devServer(args, { production = false } = {}) {
754
779
  }
755
780
  }
756
781
  const type = types[path.extname(file).toLowerCase()] || 'application/octet-stream';
757
- res.writeHead(opts.status || 200, {
782
+ const fileHeaders = {
758
783
  'Content-Type': type + (type.startsWith('text/') || type.endsWith('json') ? '; charset=utf-8' : ''),
759
784
  'Cache-Control': isProd ? 'no-cache' : 'no-store', // dev: always pick up your latest changes
760
785
  'X-Content-Type-Options': 'nosniff',
761
786
  ...prodHeaders,
762
- });
787
+ };
788
+ // everything but pages is sent with byte ranges (video and audio seek, Safari plays)
789
+ if (type !== 'text/html') return require('./backend/stream.js').sendFile(res.req, res, file, { status: opts.status, type: fileHeaders['Content-Type'], headers: fileHeaders });
790
+ res.writeHead(opts.status || 200, fileHeaders);
763
791
  if (!production && type === 'text/html') {
764
792
  let html = fs.readFileSync(file, 'utf8');
765
793
  // PUBLIC_ values from .env, as Kreftjs.env (kreft build puts them in app.js)
@@ -794,12 +822,9 @@ async function devServer(args, { production = false } = {}) {
794
822
  return res.end(opts.seo ? require('./backend/seo-pages.js').inject(html, opts.seo) : html);
795
823
  }
796
824
  if (type === 'text/html' && opts.seo) return res.end(require('./backend/seo-pages.js').inject(fs.readFileSync(file, 'utf8'), opts.seo));
797
- const stream = fs.createReadStream(file);
798
- stream.on('error', () => {
799
- if (!res.headersSent) res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
800
- res.end('Not found');
801
- });
802
- stream.pipe(res);
825
+ const page = fs.createReadStream(file); // a page in production: headers are written, send it whole
826
+ page.on('error', () => res.end('Not found'));
827
+ page.pipe(res);
803
828
  }
804
829
 
805
830
  // ----- hot reload: watch the folder, tell open pages what changed -----
@@ -1109,8 +1134,7 @@ async function devServer(args, { production = false } = {}) {
1109
1134
  const realPub = real(pub), realF = real(f);
1110
1135
  if ((f.startsWith(pub + path.sep)) && !secret(urlPath) && isFile(f) && realPub && realF && realF.startsWith(realPub + path.sep)) {
1111
1136
  const type = types[path.extname(f).toLowerCase()] || 'application/octet-stream';
1112
- res.writeHead(200, { 'Content-Type': type, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox", 'Cache-Control': 'public, max-age=3600' });
1113
- return fs.createReadStream(f).pipe(res);
1137
+ return require('./backend/stream.js').sendFile(req, res, f, { type, headers: { 'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox", 'Cache-Control': /\.(m3u8|mpd)$/i.test(f) ? 'no-cache' : 'public, max-age=3600' } }); // a live playlist changes every few seconds
1114
1138
  }
1115
1139
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1116
1140
  return res.end('Not found: ' + urlPath);
@@ -0,0 +1,131 @@
1
+ # الفصل السادس عشر (خبير): قوائم ضخمة بلا تباطؤ — النافذة الافتراضية
2
+
3
+ عرض 1,000 صف سهل. عرض 100,000 صف في جدول واحد يغرق المتصفح: بناء عشرات آلاف العقد في الـ DOM يكلف وقتًا وذاكرة. الحل الذي تستعمله كل الجداول الاحترافية هو **النافذة الافتراضية (Virtualization)**: نرسم الصفوف المرئية فقط، وعند التمرير نستبدل الصفوف التي خرجت من الشاشة.
4
+
5
+ سنبني هنا مكوّن `VirtualList` عامًا يتحمل أي عدد من العناصر ويبقى التمرير سلسًا. **كل الشيفرة جرِّبت في متصفح** مع 100,000 عنصر.
6
+
7
+ ## 1) الفكرة
8
+
9
+ بدل أن نرسم القائمة كلها:
10
+
11
+ 1. نعرف ارتفاع الصف الواحد `itemHeight` (يجب أن يكون ثابتًا).
12
+ 2. من موضع التمرير `scrollTop` وارتفاع الحاوية نعرف **أي نطاق من العناصر ظاهر**.
13
+ 3. نرسم ذلك النطاق فقط، فوق نطاق صغير إضافي (`overscan`) كي لا يظهر فراغ عند التمرير السريع.
14
+ 4. نضبط ارتفاع "العمود الفارغ" على `items.length * itemHeight` ليبقى شريط التمرير صادقًا، ونزيح النافذة المرئية بـ `translateY`.
15
+
16
+ ## 2) المكوّن
17
+
18
+ ```html
19
+ <!-- components/VirtualList.kreft -->
20
+ prop items = [] // كل العناصر (قد تكون ملايين)
21
+ prop itemHeight = 48 // ارتفاع الصف الواحد بالبكسل
22
+ prop overscan = 6 // صفوف إضافية فوق وتحت النافذة المرئية
23
+
24
+ state viewport = null // يملؤه k-ref
25
+ state scrollTop = 0
26
+ state viewH = 0
27
+
28
+ // ارتفاع العمود الكامل (شريط التمرير يعتمد عليه)
29
+ derive total = items.length * itemHeight
30
+
31
+ // النطاق المرئي + هامش الأمان
32
+ derive start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
33
+ derive end = Math.min(items.length, Math.ceil((scrollTop + viewH) / itemHeight) + overscan)
34
+ derive visible = items.slice(start, end)
35
+ derive offset = start * itemHeight
36
+
37
+ action onScroll() {
38
+ scrollTop = viewport.scrollTop
39
+ viewH = viewport.clientHeight
40
+ }
41
+
42
+ // نراقب حجم الحاوية (نافذة صغرت أو كبرت) وننظف حين يزول المكوّن
43
+ let ro
44
+ on ready {
45
+ viewH = viewport.clientHeight
46
+ ro = new ResizeObserver(() => { viewH = viewport.clientHeight })
47
+ ro.observe(viewport)
48
+ }
49
+ on destroy { ro && ro.disconnect() }
50
+
51
+ <view>
52
+ <div class="vlist" k-ref="viewport" @scroll="onScroll">
53
+ <div class="vlist__sizer" style="height: {total}px">
54
+ <div class="vlist__window" style="transform: translateY({offset}px)">
55
+ {for (item, i) in visible key item.id}
56
+ <div class="vlist__row" style="height: {itemHeight}px">
57
+ <slot item={item} index={start + i}>{item}</slot>
58
+ </div>
59
+ {/for}
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </view>
64
+
65
+ <style scoped>
66
+ .vlist { overflow-y: auto; height: 400px; border: 1px solid var(--border, #ddd); }
67
+ .vlist__sizer { position: relative; }
68
+ .vlist__window { position: absolute; inset-block-start: 0; inset-inline: 0; }
69
+ .vlist__row { box-sizing: border-box; }
70
+ </style>
71
+ ```
72
+
73
+ لماذا يعمل هذا؟
74
+
75
+ - **`derive visible`** يُعاد حسابه وحده كلما تغيّر `scrollTop` أو `viewH` أو `items`، فيرسم Kreft الصفوف الجديدة ويزيل الخارجة (وهو يعرف كيف يطابقها بمفتاح `key item.id`).
76
+ - **`translateY`** على نافذة الصفوف أرخص من `padding-top` كبير: لا نعيد حساب التخطيط.
77
+ - **`k-ref`** يضع عنصر التمرير في `state viewport`، فنقرؤه في `onScroll` بلا بحث في الـ DOM كل مرة.
78
+ - **`ResizeObserver`** يلتقط تغيّر حجم الحاوية، و`on destroy` يقطع المراقبة.
79
+
80
+ ## 3) الاستعمال: 100,000 صف
81
+
82
+ ```html
83
+ <!-- pages/BigTable.kreft -->
84
+ import VirtualList from '../components/VirtualList.kreft'
85
+
86
+ state rows = Array.from({ length: 100000 }, (_, i) => ({
87
+ id: i + 1,
88
+ name: 'الاسم ' + (i + 1),
89
+ price: (i % 100) + 1,
90
+ }))
91
+
92
+ <view>
93
+ <h1>100,000 صف، ورسم فوري</h1>
94
+ <virtual-list items={rows} itemHeight={44}>
95
+ <template k-slot="{ item, index }">
96
+ <div style="display:flex; gap:12px; align-items:center; height:100%; padding-inline:12px">
97
+ <b>{index + 1}</b>
98
+ <span>{item.name}</span>
99
+ <span style="margin-inline-start:auto">{item.price} $</span>
100
+ </div>
101
+ </template>
102
+ </virtual-list>
103
+ </view>
104
+ ```
105
+
106
+ - الفتحة الافتراضية هنا **بمعاملات**: `k-slot="{ item, index }"` يفكّك ما مرّره `<slot item={item} index={start + i}>`.
107
+ - `index` هو الرقم الحقيقي في القائمة (لأننا أضفنا `start`) لا رقم الصف المرسوم.
108
+
109
+ ## 4) لمسات أداء تكميلية
110
+
111
+ النافذة الافتراضية وحدها تكفي غالبًا. هذه أدوات إضافية للصفوف الثقيلة:
112
+
113
+ | الأداة | متى تستعملها | مثال |
114
+ |---|---|---|
115
+ | `k-once` | صف/خلية لا تتغير أبدًا بعد أول رسم | `<td k-once>{row.id}</td>` |
116
+ | `k-memo` | خلية تعتمد على قيمتين فقط | `<tr k-memo="[row.id === selected]">` |
117
+ | `key item.id` | دائمًا مع الحلقات | `{for item in visible key item.id}` |
118
+ | `derive` للقيم الغالية | احسب مرة لا في كل خلية | `derive priceLabel = (p) => p.toFixed(2) + ' $'` |
119
+ | سكربت عادي لما لا تحتاجه الصفحة | ثوابت ودوال مساعدة | `const clamp = (n, a, b) => …` |
120
+
121
+ تذكّر فخ الفصل الثالث عشر: ما تكتبه بـ `const`/`let` لا تراه الصفحة. كل ما يظهر في `view` يجب أن يكون `state` أو `derive` أو `action` أو `prop`.
122
+
123
+ ## 5) القياس
124
+
125
+ جرّب الفرق بنفسك: اعرض 100,000 عنصر بدون النافذة الافتراضية (حلقة عادية) ثم معها، وراقب زمن أول رسم وذاكرة التبويب في أدوات المطوّر. في تجربتنا كانت الحلقة العادية تُرسم في مئات المللي ثانية وتشبع الذاكرة، بينما النافذة الافتراضية تُبقي في الـ DOM نحو 30 صفًا فقط مهما كان حجم القائمة.
126
+
127
+ ## تمارين
128
+
129
+ 1. أضف `prop itemKey` ليستعمل المفتاح `item[itemKey]` بدل `item.id` دائمًا.
130
+ 2. اجعل `itemHeight` متغيّرًا: صفوف بارتفاعات مختلفة تُجمَع في "أقسام" (Variable size virtualization).
131
+ 3. أضف تمريرًا إلى عنصر معيّن: دالة `scrollToIndex(i)` تضبط `viewport.scrollTop = i * itemHeight`.
@@ -0,0 +1,169 @@
1
+ # الفصل السابع عشر (خبير): مكوّن مركّب — قائمة اختيار ذكية (Combobox)
2
+
3
+ المكوّنات البسيطة تأخذ `props` وتعرض HTML. المكوّنات **المركّبة** تحزم عدة قطع تتعاون معًا: زناد وفتحة منبثقة وحقل بحث وقائمة خيارات ولوحة مفاتيح. هنا نبني `Combobox` كاملًا قابلًا لإعادة الاستخدام: يختار من قائمة، يبحث، يتنقّل بالأسهم، يغلق بـ Esc أو بالنقر خارجه، ويعمل بـ `k-model`.
4
+
5
+ **جرِّب هذه الشيفرة في المتصفح** كما هي.
6
+
7
+ ## 1) المكوّن
8
+
9
+ ```html
10
+ <!-- components/Combobox.kreft -->
11
+ prop modelValue = null // القيمة المختارة (id)
12
+ prop options = [] // [{ id, label }, …]
13
+ prop placeholder = 'اختر…'
14
+ prop searchable = false
15
+ event update:modelValue
16
+
17
+ state open = false
18
+ state query = ''
19
+ state active = 0 // فهرس الخيار المظلَّل
20
+ state trigger = null // يملؤه k-ref
21
+ state rect = { top: 0, left: 0, width: 0 }
22
+
23
+ derive selected = options.find((o) => o.id === modelValue) || null
24
+ derive filtered = !searchable || !query ? options : options.filter((o) => o.label.includes(query))
25
+ derive shown = filtered.slice(0, 50) // لا نرسم أكثر من 50
26
+
27
+ action toggle() {
28
+ if (!open && trigger) {
29
+ const r = trigger.getBoundingClientRect()
30
+ rect = { top: r.bottom, left: r.left, width: r.width }
31
+ }
32
+ open = !open
33
+ active = 0
34
+ }
35
+
36
+ action pick(o) {
37
+ send('update:modelValue', o.id)
38
+ open = false
39
+ query = ''
40
+ }
41
+
42
+ action onKey(e) {
43
+ if (e.key === 'Escape') { open = false; return }
44
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) { toggle(); return }
45
+ if (!open) return
46
+ if (e.key === 'ArrowDown') active = Math.min(active + 1, shown.length - 1)
47
+ else if (e.key === 'ArrowUp') active = Math.max(active - 1, 0)
48
+ else if (e.key === 'Enter') { if (shown[active]) pick(shown[active]) }
49
+ }
50
+
51
+ <view>
52
+ <div class="combobox" tabindex="0" @keydown="onKey($event)" k-click-outside={() => open = false}>
53
+ <button type="button" class="combobox__trigger" k-ref="trigger" @click="toggle" aria-haspopup="listbox">
54
+ <span>{selected ? selected.label : placeholder}</span> <span>▾</span>
55
+ </button>
56
+
57
+ <kreft-portal to="body">
58
+ <div k-if="open" class="combobox__menu" role="listbox" k-animate="scale"
59
+ style="position:fixed; top:{rect.top}px; left:{rect.left}px; width:{rect.width}px">
60
+ <input k-if="searchable" class="combobox__search" k-model="query" placeholder="ابحث…">
61
+ <ul>
62
+ {for (o, i) in shown key o.id}
63
+ <li role="option" class={i === active ? 'active' : ''}
64
+ @mouseenter="active = i" @click="pick(o)">
65
+ <slot name="option" option={o}>{o.label}</slot>
66
+ </li>
67
+ {empty}
68
+ <li class="combobox__empty">لا نتائج</li>
69
+ {/for}
70
+ </ul>
71
+ </div>
72
+ </kreft-portal>
73
+ </div>
74
+ </view>
75
+
76
+ <style scoped>
77
+ .combobox { position: relative; }
78
+ .combobox__trigger { width: 100%; display: flex; justify-content: space-between; gap: 8px; }
79
+ .combobox__menu { background: var(--background, #fff); color: var(--foreground, #111);
80
+ border: 1px solid var(--border, #ddd); max-height: 240px; overflow-y: auto; z-index: 200; }
81
+ .combobox__search { width: 100%; border: 0; border-block-end: 1px solid var(--border, #ddd); padding: 8px; }
82
+ .combobox__menu ul { list-style: none; margin: 0; padding: 4px; }
83
+ .combobox__menu li { padding: 6px 8px; cursor: pointer; border-radius: 4px; }
84
+ .combobox__menu li.active { background: var(--accent, #8883); }
85
+ .combobox__empty { padding: 8px; opacity: .6; }
86
+ </style>
87
+ ```
88
+
89
+ النقاط المهمة:
90
+
91
+ - **`k-model` على مكوّن** (كما في الفصل الثالث): المكوّن يملك `prop modelValue` ويرسل `update:modelValue`، فيكتب الأب `<combobox k-model="city">`.
92
+ - **`k-click-outside`** توجيه مخصص من الفصل الثالث عشر يغلق القائمة عند النقر خارجها.
93
+ - **`kreft-portal`** يرسم القائمة في `<body>` فلا يقتطعها `overflow` لآب؛ ولذلك نحسب موضعها يدويًا من `trigger.getBoundingClientRect()`.
94
+ - **فتحة `option` بمعاملات**: من يستعمل المكوّن يقرر شكل كل خيار.
95
+ - **`shown = filtered.slice(0, 50)`**: حد أقصى للرسم مهما كثرت النتائج (تكمّله البحث).
96
+ - **`k-animate="scale"`** لحركة دخول وخروج خفيفة.
97
+
98
+ ## 2) الاستعمال
99
+
100
+ ```html
101
+ <!-- pages/City.kreft -->
102
+ import Combobox from '../components/Combobox.kreft'
103
+
104
+ state cities = [
105
+ { id: 'mo', label: 'مقديشو' },
106
+ { id: 'hr', label: 'هرجيسا' },
107
+ { id: 'bos', label: 'بوصاصو' },
108
+ { id: 'ki', label: 'كيسمايو' },
109
+ ]
110
+
111
+ state city = null
112
+
113
+ <view>
114
+ <p>المدينة المختارة: {cities.find((c) => c.id === city)?.label || '—'}</p>
115
+ <combobox k-model="city" options={cities} searchable placeholder="اختر مدينة">
116
+ <template k-slot:option="{ option }">
117
+ <b>{option.label}</b> <small style="opacity:.6">{option.id.toUpperCase()}</small>
118
+ </template>
119
+ </combobox>
120
+ </view>
121
+ ```
122
+
123
+ ## 3) خيارات تُحمَّل من الخادم (غير متزامن)
124
+
125
+ لن تفيدك القائمة الثابتة في تطبيق حقيقي. حوّل البحث إلى طلب خادم بتأخير بسيط، كما فعلنا في بحث المدونة:
126
+
127
+ ```html
128
+ <!-- components/AsyncCombobox.kreft -->
129
+ prop modelValue = null
130
+ event update:modelValue
131
+
132
+ state open = false
133
+ state text = ''
134
+ state query = '' // ما نبحث به فعلًا (بعد التأخير)
135
+
136
+ let timer
137
+ watch text {
138
+ clearTimeout(timer)
139
+ timer = setTimeout(() => { query = value }, 300)
140
+ }
141
+ on destroy { clearTimeout(timer) }
142
+
143
+ // يعيد الجلب وحده كلما تغيّر query
144
+ load results = api.get('/api/cities', { query: { q: query || undefined } })
145
+
146
+ action pick(c) { send('update:modelValue', c.id); open = false; text = c.label }
147
+
148
+ <view>
149
+ <input k-model="text" @focus="open = true" placeholder="ابحث عن مدينة…"
150
+ k-click-outside={() => open = false}>
151
+ <div k-if="open" class="menu">
152
+ <p k-if="results.loading">جارٍ البحث…</p>
153
+ {for c in results.data || [] key c.id}
154
+ <button @click="pick(c)">{c.label}</button>
155
+ {empty}
156
+ <p>لا نتائج لـ "{query}".</p>
157
+ {/for}
158
+ </div>
159
+ </view>
160
+ ```
161
+
162
+ - **`text` و`query`**: نفصل ما يكتبه المستخدم عمّا نطلبه، فيتجمع الإدخال السريع في طلب واحد.
163
+ - **`load results`** يقرأ `query` فيجلب من جديد عند تغيّرها، ويعطيك `loading` و`error` و`reload()`.
164
+
165
+ ## تمارين
166
+
167
+ 1. أضف إلى `Combobox` خاصية `prop clearable` مع زر ✕ يفرغ الاختيار.
168
+ 2. اجعل القائمة تفتح للأعلى إذا لم يتسع أسفلها (قارن `rect.top` بارتفاع النافذة).
169
+ 3. حوّل `AsyncCombobox` ليدعم اختيارًا متعددًا: `modelValue` مصفوفة و`pick` يبدّل العنصر بدل اختياره وحيدًا.