@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.
- package/dist/components/SvarkGrid/SvarkGrid.svelte +113 -20
- package/llm/COMPONENT_GUIDE.md +248 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 (
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
845
|
-
|
|
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
|
|
889
|
-
data = response
|
|
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';
|
package/llm/COMPONENT_GUIDE.md
CHANGED
|
@@ -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';
|