@warkypublic/svelix 0.1.24 → 0.1.25

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.
@@ -174,6 +174,8 @@
174
174
  let page = $state(0);
175
175
  let reload = $state(0);
176
176
  let lastServerRequestKey = $state<string>('');
177
+ let activeServerRequestId = 0;
178
+ let activeServerRequestKey = '';
177
179
 
178
180
  let gridApi = $state<IApi | null>(null);
179
181
  let searchOpen = $state(false);
@@ -504,6 +506,33 @@
504
506
  let debouncedFilter = $state<SvarkFilterState>({});
505
507
  let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
506
508
 
509
+ function isSameSortState(a: SvarkSortState[], b: SvarkSortState[]): boolean {
510
+ if (a.length !== b.length) return false;
511
+ return a.every((item, index) => item.key === b[index]?.key && item.order === b[index]?.order);
512
+ }
513
+
514
+ function isSameFilterState(a: SvarkFilterState, b: SvarkFilterState): boolean {
515
+ const aKeys = Object.keys(a);
516
+ const bKeys = Object.keys(b);
517
+ if (aKeys.length !== bKeys.length) return false;
518
+
519
+ for (const key of aKeys) {
520
+ const left = a[key];
521
+ const right = b[key];
522
+ if (
523
+ !right ||
524
+ left?.column !== right.column ||
525
+ left?.operator !== right.operator ||
526
+ left?.logic_operator !== right.logic_operator ||
527
+ left?.value !== right.value
528
+ ) {
529
+ return false;
530
+ }
531
+ }
532
+
533
+ return true;
534
+ }
535
+
507
536
  $effect(() => {
508
537
  const s = sortState;
509
538
  const f = effectiveFilterState;
@@ -517,6 +546,9 @@
517
546
  if (_debounceTimer !== null) clearTimeout(_debounceTimer);
518
547
  _debounceTimer = setTimeout(() => {
519
548
  _debounceTimer = null;
549
+ if (page === 0 && isSameSortState(debouncedSort, s) && isSameFilterState(debouncedFilter, f)) {
550
+ return;
551
+ }
520
552
  page = 0; // reset page atomically — prevents a double-fetch
521
553
  debouncedSort = s;
522
554
  debouncedFilter = f;
@@ -626,6 +658,43 @@
626
658
  emitFilters({});
627
659
  }
628
660
 
661
+ function extractRowsFromResponse(response: unknown): Record<string, unknown>[] {
662
+ if (Array.isArray(response)) {
663
+ return response as Record<string, unknown>[];
664
+ }
665
+
666
+ if (
667
+ response &&
668
+ typeof response === 'object' &&
669
+ Array.isArray((response as { data?: unknown }).data)
670
+ ) {
671
+ return (response as { data: Record<string, unknown>[] }).data;
672
+ }
673
+
674
+ return [];
675
+ }
676
+
677
+ function normalizeGridRows(rows: Record<string, unknown>[], uniqueID: string): Record<string, unknown>[] {
678
+ return rows.map((row) => {
679
+ const rowId = row?.id;
680
+ const uniqueValue = row?.[uniqueID];
681
+ if (rowId != null || uniqueValue == null) return row;
682
+ return { ...row, id: uniqueValue };
683
+ });
684
+ }
685
+
686
+ function extractTotalFromResponse(response: unknown, rows: Record<string, unknown>[]): number {
687
+ if (
688
+ response &&
689
+ typeof response === 'object' &&
690
+ typeof (response as { metadata?: { total?: unknown } }).metadata?.total === 'number'
691
+ ) {
692
+ return (response as { metadata: { total: number } }).metadata.total;
693
+ }
694
+
695
+ return rows.length;
696
+ }
697
+
629
698
  $effect(() => {
630
699
  // Keep SVAR's local highlight + server-side search filters in sync with controlled searchValue.
631
700
  const next = searchValueState;
@@ -725,9 +794,10 @@
725
794
  // Paging offset: only applied when server-side paging is active
726
795
  const _offset = showPager ? page * pageSize : 0;
727
796
  void reload;
728
- let cancelled = false;
729
797
 
730
798
  if (!_adapter) {
799
+ activeServerRequestId += 1;
800
+ activeServerRequestKey = '';
731
801
  loading = false;
732
802
  error = resolvedServerSide
733
803
  ? 'Missing server configuration (dataSourceOptions.url/schema/entity)'
@@ -736,11 +806,10 @@
736
806
  return;
737
807
  }
738
808
 
739
- loading = true;
740
- error = null;
741
-
742
809
  // Guard: if something upstream keeps re-triggering the effect without changing the
743
810
  // actual server query parameters, avoid a tight refetch loop.
811
+ let requestId = 0;
812
+ let reqKey = '';
744
813
  if (resolvedServerSide) {
745
814
  const sortKey = JSON.stringify(_sort ?? []);
746
815
  const filterKey = JSON.stringify(
@@ -749,7 +818,7 @@
749
818
  .map((k) => [k, (_filters as any)[k]?.column, (_filters as any)[k]?.operator, (_filters as any)[k]?.value])
750
819
  );
751
820
  const queryKey = JSON.stringify(query ?? {});
752
- const reqKey = JSON.stringify({
821
+ reqKey = JSON.stringify({
753
822
  url: resolvedUrl,
754
823
  schema: resolvedSchema,
755
824
  entity: resolvedEntity,
@@ -763,19 +832,33 @@
763
832
  });
764
833
 
765
834
  if (reqKey === lastServerRequestKey) {
766
- loading = false;
767
835
  return;
768
836
  }
837
+
769
838
  lastServerRequestKey = reqKey;
839
+ activeServerRequestKey = reqKey;
840
+ activeServerRequestId += 1;
841
+ requestId = activeServerRequestId;
770
842
  }
771
843
 
844
+ loading = true;
845
+ error = null;
846
+
772
847
  _adapter
773
848
  .read(_sort, _filters, _limit, _offset, query)
774
849
  .then((response) => {
775
- if (cancelled) return;
776
- if (response.success) {
777
- const incoming = response.data ?? [];
778
- total = (response as any).metadata?.total ?? incoming.length;
850
+ if (
851
+ resolvedServerSide &&
852
+ (requestId !== activeServerRequestId || reqKey !== activeServerRequestKey)
853
+ ) {
854
+ return;
855
+ }
856
+ if ((response as any)?.success !== false) {
857
+ const incoming = normalizeGridRows(
858
+ extractRowsFromResponse(response),
859
+ resolvedUniqueID
860
+ );
861
+ total = extractTotalFromResponse(response, incoming);
779
862
 
780
863
  if (virtualScroll) {
781
864
  data = [];
@@ -796,18 +879,24 @@
796
879
  }
797
880
  })
798
881
  .catch((e) => {
799
- if (cancelled) return;
882
+ if (
883
+ resolvedServerSide &&
884
+ (requestId !== activeServerRequestId || reqKey !== activeServerRequestKey)
885
+ ) {
886
+ return;
887
+ }
800
888
  const msg = e instanceof Error ? e.message : 'Unknown error';
801
889
  error = msg;
802
890
  onerror?.(msg);
803
891
  })
804
892
  .finally(() => {
805
- if (!cancelled) loading = false;
893
+ if (
894
+ !resolvedServerSide ||
895
+ (requestId === activeServerRequestId && reqKey === activeServerRequestKey)
896
+ ) {
897
+ loading = false;
898
+ }
806
899
  });
807
-
808
- return () => {
809
- cancelled = true;
810
- };
811
900
  });
812
901
 
813
902
  async function loadNextPage() {
@@ -841,8 +930,12 @@
841
930
  return;
842
931
  }
843
932
  }
844
- if (response.success) {
845
- data = [...data, ...(response.data ?? [])];
933
+ if ((response as any)?.success !== false) {
934
+ const incoming = normalizeGridRows(
935
+ extractRowsFromResponse(response),
936
+ resolvedUniqueID
937
+ );
938
+ data = [...data, ...incoming];
846
939
  }
847
940
  } catch {
848
941
  // Silent — don't replace existing rows with an error on pagination
@@ -885,8 +978,8 @@
885
978
  start,
886
979
  query
887
980
  );
888
- if (response.success) {
889
- data = response.data ?? [];
981
+ if ((response as any)?.success !== false) {
982
+ data = normalizeGridRows(extractRowsFromResponse(response), resolvedUniqueID);
890
983
  }
891
984
  } catch (e) {
892
985
  const msg = e instanceof Error ? e.message : 'Failed to load data';
@@ -373,6 +373,254 @@ Prefer stories that cover:
373
373
  - interactive behavior
374
374
  - any adapter-driven or async flow that is likely to regress
375
375
 
376
+ ## Implementation Examples From Stories
377
+
378
+ These examples are adapted from the real Storybook stories in this repository. They are better reference points for LLM implementation work than generic placeholder snippets.
379
+
380
+ ### Former: request-driven form workflow
381
+
382
+ Based on:
383
+ - [src/lib/components/Former/Former.stories.ts](/home/hein/hein/dev/svelix/src/lib/components/Former/Former.stories.ts)
384
+
385
+ ```svelte
386
+ <script lang="ts">
387
+ import { Former } from '@warkypublic/svelix';
388
+
389
+ export interface Props {
390
+ request?: 'insert' | 'update' | 'delete' | 'view';
391
+ }
392
+
393
+ let { request = 'insert' }: Props = $props();
394
+
395
+ let values = $state({
396
+ firstName: '',
397
+ lastName: '',
398
+ email: '',
399
+ role: 'viewer'
400
+ });
401
+
402
+ async function onAPICall(_mode: 'mutate' | 'read', req: string, value?: typeof values) {
403
+ if (req === 'select') {
404
+ return {
405
+ firstName: 'Janet',
406
+ lastName: 'Nguyen',
407
+ email: 'janet@example.com',
408
+ role: 'admin'
409
+ };
410
+ }
411
+
412
+ return value;
413
+ }
414
+ </script>
415
+
416
+ <Former {request} {values} {onAPICall}>
417
+ {#snippet children(state)}
418
+ <p>Request: {state.request}</p>
419
+ <p>Dirty: {state.dirty ? 'yes' : 'no'}</p>
420
+ {/snippet}
421
+ </Former>
422
+ ```
423
+
424
+ Story behavior worth preserving:
425
+ - `insert` mode should allow saving immediately once required fields are valid
426
+ - `update` mode should keep save disabled until the form becomes dirty
427
+ - `delete` mode should require explicit confirmation before delete is enabled
428
+ - modal and drawer variants should behave the same way once opened
429
+
430
+ ### Gridler: canvas-backed data grid
431
+
432
+ Based on:
433
+ - [src/lib/components/Gridler/components/Gridler.stories.ts](/home/hein/hein/dev/svelix/src/lib/components/Gridler/components/Gridler.stories.ts)
434
+
435
+ ```svelte
436
+ <script lang="ts">
437
+ import { Gridler } from '@warkypublic/svelix';
438
+ import type { GridlerCell, GridlerColumn, Item } from '@warkypublic/svelix';
439
+
440
+ export interface Props {
441
+ height?: number;
442
+ }
443
+
444
+ let { height = 400 }: Props = $props();
445
+
446
+ const columns: GridlerColumn[] = [
447
+ { id: 'name', title: 'Name', width: 180 },
448
+ { id: 'email', title: 'Email', width: 220 },
449
+ { id: 'age', title: 'Age', width: 80 }
450
+ ];
451
+
452
+ function getCellContent([col, row]: Item): GridlerCell {
453
+ const person = {
454
+ name: `User ${row + 1}`,
455
+ email: `user${row + 1}@example.com`,
456
+ age: 20 + (row % 30)
457
+ };
458
+
459
+ if (col === 0) return { kind: 'text', data: person.name, displayData: person.name, allowOverlay: true };
460
+ if (col === 1) return { kind: 'uri', data: person.email, displayData: person.email, allowOverlay: true };
461
+ return { kind: 'number', data: person.age, displayData: String(person.age), allowOverlay: true };
462
+ }
463
+ </script>
464
+
465
+ <Gridler
466
+ {columns}
467
+ rows={1000}
468
+ {getCellContent}
469
+ {height}
470
+ rowMarkers="number"
471
+ searchValue="User 1"
472
+ />
473
+ ```
474
+
475
+ Story behavior worth preserving:
476
+ - support large row counts without rendering every row into the DOM
477
+ - row markers can switch between `none`, `number`, and `checkbox`
478
+ - search highlighting and fixed columns are first-class usage patterns
479
+ - theme overrides are an expected integration point, not a niche feature
480
+
481
+ ### SvarkGrid: adapter-backed tabular data
482
+
483
+ Based on:
484
+ - [src/lib/components/SvarkGrid/SvarkGrid.stories.ts](/home/hein/hein/dev/svelix/src/lib/components/SvarkGrid/SvarkGrid.stories.ts)
485
+
486
+ ```svelte
487
+ <script lang="ts">
488
+ import { SvarkGrid } from '@warkypublic/svelix';
489
+
490
+ const columns = [
491
+ { id: 'id', title: 'ID', width: 60 },
492
+ { id: 'name', title: 'Name', width: 180 },
493
+ { id: 'email', title: 'Email', width: 220 },
494
+ { id: 'role', title: 'Role', width: 100 }
495
+ ];
496
+ </script>
497
+
498
+ <SvarkGrid
499
+ url="/api/users"
500
+ schema="public"
501
+ entity="users"
502
+ adapterType="ResolveSpec"
503
+ {columns}
504
+ pageSize={25}
505
+ serverSide={true}
506
+ height={480}
507
+ select={true}
508
+ multiselect={true}
509
+ />
510
+ ```
511
+
512
+ Story behavior worth preserving:
513
+ - both `ResolveSpec` and `RestHeaderSpec` style adapters are supported patterns
514
+ - sorting, filtering, search, and selected-items callbacks are part of normal usage
515
+ - mock adapters in stories are a good reference for test doubles and local prototyping
516
+
517
+ ### VTree: local or lazy-loaded tree data
518
+
519
+ Based on:
520
+ - [src/lib/components/VTree/VTree.stories.ts](/home/hein/hein/dev/svelix/src/lib/components/VTree/VTree.stories.ts)
521
+
522
+ ```svelte
523
+ <script lang="ts">
524
+ import { VTree } from '@warkypublic/svelix';
525
+ import type { VTreeRecord, VTreeServerAdapter } from '@warkypublic/svelix';
526
+
527
+ export interface DemoNode extends VTreeRecord {
528
+ id: string;
529
+ label: string;
530
+ description?: string;
531
+ hasChildren?: boolean;
532
+ }
533
+
534
+ class DemoAdapter implements VTreeServerAdapter<DemoNode> {
535
+ async readRoot(): Promise<DemoNode[]> {
536
+ return [{ id: 'root-1', label: 'Root 1', hasChildren: true }];
537
+ }
538
+
539
+ async readChildren(node: DemoNode): Promise<DemoNode[]> {
540
+ return [{ id: `${node.id}-child-1`, label: `Child of ${node.label}` }];
541
+ }
542
+
543
+ async search(query: string): Promise<DemoNode[]> {
544
+ return [{ id: 'match-1', label: `Match: ${query}` }];
545
+ }
546
+ }
547
+
548
+ const adapter = new DemoAdapter();
549
+ </script>
550
+
551
+ <VTree
552
+ {adapter}
553
+ loadOnMount={true}
554
+ searchable={true}
555
+ serverSideSearch={true}
556
+ height={480}
557
+ rowHeight={36}
558
+ />
559
+ ```
560
+
561
+ Story behavior worth preserving:
562
+ - local data and adapter-driven data are equally valid entry points
563
+ - server-side search is an explicit mode, not just a derived optimization
564
+ - virtualization settings such as `rowHeight` and `overscan` matter for large trees
565
+
566
+ ### ContentEditor: file-type driven editor/viewer selection
567
+
568
+ Based on:
569
+ - [src/lib/components/ContentEditor/ContentEditor.stories.ts](/home/hein/hein/dev/svelix/src/lib/components/ContentEditor/ContentEditor.stories.ts)
570
+
571
+ ```svelte
572
+ <script lang="ts">
573
+ import { ContentEditor } from '@warkypublic/svelix';
574
+
575
+ const markdown = new Blob(
576
+ [
577
+ `# Hello\n\nThis file is edited through the markdown flow.\n\n- Item one\n- Item two\n`
578
+ ],
579
+ { type: 'text/markdown' }
580
+ );
581
+
582
+ async function onUpload(file: File): Promise<string | null> {
583
+ return `/uploads/${file.name}`;
584
+ }
585
+ </script>
586
+
587
+ <ContentEditor
588
+ filename="notes.md"
589
+ value={markdown}
590
+ readonly={false}
591
+ {onUpload}
592
+ />
593
+ ```
594
+
595
+ Story behavior worth preserving:
596
+ - filename and content type drive editor selection
597
+ - markdown, Monaco/text, media, and PDF flows are all real supported paths
598
+ - office-document integrations expose `insertText` and `insertHtml` hooks for external automation
599
+
600
+ ### BetterMenu: interaction-first menu behavior
601
+
602
+ Based on:
603
+ - [src/lib/components/BetterMenu/BetterMenu.stories.ts](/home/hein/hein/dev/svelix/src/lib/components/BetterMenu/BetterMenu.stories.ts)
604
+
605
+ ```svelte
606
+ <script lang="ts">
607
+ import { BetterMenu } from '@warkypublic/svelix';
608
+
609
+ export interface Props {
610
+ providerID?: string;
611
+ }
612
+
613
+ let { providerID = 'example-menu' }: Props = $props();
614
+ </script>
615
+
616
+ <BetterMenu {providerID} />
617
+ ```
618
+
619
+ Story behavior worth preserving:
620
+ - opening via an explicit trigger is the default interaction model
621
+ - closing via Escape/backdrop is part of the expected behavior contract
622
+ - interactive stories are a good source for keyboard and focus expectations
623
+
376
624
  ### Story with Actions
377
625
  ```typescript
378
626
  import { action } from '@storybook/addon-actions';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warkypublic/svelix",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Svelte 5 component library with Skeleton UI and Tailwind CSS",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {