dalila 1.5.13 → 1.7.0
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/README.md +47 -0
- package/dist/componentes/ui/accordion/index.d.ts +2 -0
- package/dist/componentes/ui/accordion/index.js +114 -0
- package/dist/componentes/ui/calendar/index.d.ts +2 -0
- package/dist/componentes/ui/calendar/index.js +132 -0
- package/dist/componentes/ui/combobox/index.d.ts +2 -0
- package/dist/componentes/ui/combobox/index.js +161 -0
- package/dist/componentes/ui/dialog/index.d.ts +10 -0
- package/dist/componentes/ui/dialog/index.js +54 -0
- package/dist/componentes/ui/drawer/index.d.ts +2 -0
- package/dist/componentes/ui/drawer/index.js +41 -0
- package/dist/componentes/ui/dropdown/index.d.ts +2 -0
- package/dist/componentes/ui/dropdown/index.js +48 -0
- package/dist/componentes/ui/dropzone/index.d.ts +2 -0
- package/dist/componentes/ui/dropzone/index.js +92 -0
- package/dist/componentes/ui/env.d.ts +1 -0
- package/dist/componentes/ui/env.js +2 -0
- package/dist/componentes/ui/index.d.ts +13 -0
- package/dist/componentes/ui/index.js +12 -0
- package/dist/componentes/ui/popover/index.d.ts +2 -0
- package/dist/componentes/ui/popover/index.js +156 -0
- package/dist/componentes/ui/runtime.d.ts +20 -0
- package/dist/componentes/ui/runtime.js +421 -0
- package/dist/componentes/ui/tabs/index.d.ts +3 -0
- package/dist/componentes/ui/tabs/index.js +101 -0
- package/dist/componentes/ui/toast/index.d.ts +3 -0
- package/dist/componentes/ui/toast/index.js +115 -0
- package/dist/componentes/ui/ui-types.d.ts +175 -0
- package/dist/componentes/ui/ui-types.js +1 -0
- package/dist/componentes/ui/validate.d.ts +7 -0
- package/dist/componentes/ui/validate.js +71 -0
- package/dist/components/ui/accordion/index.d.ts +2 -0
- package/dist/components/ui/accordion/index.js +114 -0
- package/dist/components/ui/calendar/index.d.ts +2 -0
- package/dist/components/ui/calendar/index.js +132 -0
- package/dist/components/ui/combobox/index.d.ts +2 -0
- package/dist/components/ui/combobox/index.js +161 -0
- package/dist/components/ui/dialog/index.d.ts +10 -0
- package/dist/components/ui/dialog/index.js +54 -0
- package/dist/components/ui/drawer/index.d.ts +2 -0
- package/dist/components/ui/drawer/index.js +41 -0
- package/dist/components/ui/dropdown/index.d.ts +2 -0
- package/dist/components/ui/dropdown/index.js +48 -0
- package/dist/components/ui/dropzone/index.d.ts +2 -0
- package/dist/components/ui/dropzone/index.js +92 -0
- package/dist/components/ui/env.d.ts +1 -0
- package/dist/components/ui/env.js +2 -0
- package/dist/components/ui/index.d.ts +13 -0
- package/dist/components/ui/index.js +12 -0
- package/dist/components/ui/popover/index.d.ts +2 -0
- package/dist/components/ui/popover/index.js +156 -0
- package/dist/components/ui/runtime.d.ts +20 -0
- package/dist/components/ui/runtime.js +421 -0
- package/dist/components/ui/tabs/index.d.ts +3 -0
- package/dist/components/ui/tabs/index.js +101 -0
- package/dist/components/ui/toast/index.d.ts +3 -0
- package/dist/components/ui/toast/index.js +115 -0
- package/dist/components/ui/ui-types.d.ts +175 -0
- package/dist/components/ui/ui-types.js +1 -0
- package/dist/components/ui/validate.d.ts +7 -0
- package/dist/components/ui/validate.js +71 -0
- package/dist/form/form-types.d.ts +181 -0
- package/dist/form/form-types.js +4 -0
- package/dist/form/form.d.ts +71 -0
- package/dist/form/form.js +1073 -0
- package/dist/form/index.d.ts +2 -0
- package/dist/form/index.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runtime/bind.js +567 -9
- package/dist/ui/accordion.d.ts +2 -0
- package/dist/ui/accordion.js +114 -0
- package/dist/ui/calendar.d.ts +2 -0
- package/dist/ui/calendar.js +132 -0
- package/dist/ui/combobox.d.ts +2 -0
- package/dist/ui/combobox.js +161 -0
- package/dist/ui/dialog.d.ts +10 -0
- package/dist/ui/dialog.js +54 -0
- package/dist/ui/drawer.d.ts +2 -0
- package/dist/ui/drawer.js +41 -0
- package/dist/ui/dropdown.d.ts +2 -0
- package/dist/ui/dropdown.js +48 -0
- package/dist/ui/dropzone.d.ts +2 -0
- package/dist/ui/dropzone.js +92 -0
- package/dist/ui/env.d.ts +1 -0
- package/dist/ui/env.js +2 -0
- package/dist/ui/index.d.ts +13 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/popover.d.ts +2 -0
- package/dist/ui/popover.js +156 -0
- package/dist/ui/runtime.d.ts +20 -0
- package/dist/ui/runtime.js +421 -0
- package/dist/ui/tabs.d.ts +3 -0
- package/dist/ui/tabs.js +101 -0
- package/dist/ui/toast.d.ts +3 -0
- package/dist/ui/toast.js +115 -0
- package/dist/ui/ui-types.d.ts +175 -0
- package/dist/ui/ui-types.js +1 -0
- package/dist/ui/validate.d.ts +7 -0
- package/dist/ui/validate.js +71 -0
- package/package.json +60 -2
- package/src/components/ui/accordion/accordion.css +90 -0
- package/src/components/ui/alert/alert.css +78 -0
- package/src/components/ui/avatar/avatar.css +45 -0
- package/src/components/ui/badge/badge.css +71 -0
- package/src/components/ui/breadcrumb/breadcrumb.css +41 -0
- package/src/components/ui/button/button.css +135 -0
- package/src/components/ui/calendar/calendar.css +96 -0
- package/src/components/ui/card/card.css +93 -0
- package/src/components/ui/checkbox/checkbox.css +57 -0
- package/src/components/ui/chip/chip.css +62 -0
- package/src/components/ui/collapsible/collapsible.css +61 -0
- package/src/components/ui/combobox/combobox.css +85 -0
- package/src/components/ui/dalila/dalila.css +42 -0
- package/src/components/ui/dalila-core/dalila-core.css +14 -0
- package/src/components/ui/dialog/dialog.css +125 -0
- package/src/components/ui/drawer/drawer.css +122 -0
- package/src/components/ui/dropdown/dropdown.css +87 -0
- package/src/components/ui/dropzone/dropzone.css +47 -0
- package/src/components/ui/empty-state/empty-state.css +33 -0
- package/src/components/ui/form/form.css +44 -0
- package/src/components/ui/input/input.css +106 -0
- package/src/components/ui/layout/layout.css +62 -0
- package/src/components/ui/pagination/pagination.css +55 -0
- package/src/components/ui/popover/popover.css +55 -0
- package/src/components/ui/radio/radio.css +56 -0
- package/src/components/ui/separator/separator.css +38 -0
- package/src/components/ui/skeleton/skeleton.css +57 -0
- package/src/components/ui/slider/slider.css +60 -0
- package/src/components/ui/spinner/spinner.css +38 -0
- package/src/components/ui/table/table.css +54 -0
- package/src/components/ui/tabs/tabs.css +74 -0
- package/src/components/ui/toast/toast.css +100 -0
- package/src/components/ui/toggle/toggle.css +90 -0
- package/src/components/ui/tokens/tokens.css +161 -0
- package/src/components/ui/tooltip/tooltip.css +53 -0
- package/src/components/ui/typography/typography.css +81 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/runtime/bind.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module dalila/runtime
|
|
8
8
|
*/
|
|
9
|
-
import { effect, createScope, withScope, isInDevMode } from '../core/index.js';
|
|
9
|
+
import { effect, createScope, withScope, isInDevMode, signal } from '../core/index.js';
|
|
10
|
+
import { WRAPPED_HANDLER } from '../form/index.js';
|
|
10
11
|
// ============================================================================
|
|
11
12
|
// Utilities
|
|
12
13
|
// ============================================================================
|
|
@@ -485,6 +486,554 @@ function bindAttrs(root, ctx, cleanups) {
|
|
|
485
486
|
}
|
|
486
487
|
}
|
|
487
488
|
// ============================================================================
|
|
489
|
+
// Form Directives
|
|
490
|
+
// ============================================================================
|
|
491
|
+
/**
|
|
492
|
+
* Bind all [d-form] directives within root.
|
|
493
|
+
* Associates a form element with a Form instance from the context.
|
|
494
|
+
* Also auto-wraps d-on-submit handlers through form.handleSubmit().
|
|
495
|
+
*/
|
|
496
|
+
function bindForm(root, ctx, cleanups) {
|
|
497
|
+
const elements = qsaIncludingRoot(root, '[d-form]');
|
|
498
|
+
for (const el of elements) {
|
|
499
|
+
// Skip forms inside d-each templates
|
|
500
|
+
// They'll be bound when the template is cloned and bound individually
|
|
501
|
+
if (el.closest('[d-each]')) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (!(el instanceof HTMLFormElement)) {
|
|
505
|
+
warn('d-form: must be used on a <form> element');
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const bindingName = normalizeBinding(el.getAttribute('d-form'));
|
|
509
|
+
if (!bindingName)
|
|
510
|
+
continue;
|
|
511
|
+
const form = ctx[bindingName];
|
|
512
|
+
if (!form || typeof form !== 'object' || !('handleSubmit' in form)) {
|
|
513
|
+
warn(`d-form: "${bindingName}" is not a valid Form instance`);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
// Register form element with the Form instance
|
|
517
|
+
if ('_setFormElement' in form && typeof form._setFormElement === 'function') {
|
|
518
|
+
form._setFormElement(el);
|
|
519
|
+
}
|
|
520
|
+
// Auto-wrap d-on-submit handler through form.handleSubmit()
|
|
521
|
+
// Don't mutate shared ctx - add listener directly to this form element
|
|
522
|
+
const submitHandlerName = normalizeBinding(el.getAttribute('d-on-submit'));
|
|
523
|
+
if (submitHandlerName) {
|
|
524
|
+
const originalHandler = ctx[submitHandlerName];
|
|
525
|
+
if (typeof originalHandler === 'function') {
|
|
526
|
+
// Check if handler is already wrapped to avoid double-wrapping
|
|
527
|
+
// If user did: const save = form.handleSubmit(...), don't wrap again
|
|
528
|
+
const isAlreadyWrapped = originalHandler[WRAPPED_HANDLER] === true;
|
|
529
|
+
const finalHandler = isAlreadyWrapped
|
|
530
|
+
? originalHandler
|
|
531
|
+
: form.handleSubmit(originalHandler);
|
|
532
|
+
// Add submit listener directly to form element (not via d-on-submit)
|
|
533
|
+
// This avoids mutating the shared context
|
|
534
|
+
el.addEventListener('submit', finalHandler);
|
|
535
|
+
// Remove d-on-submit to prevent bindEvents from adding duplicate listener
|
|
536
|
+
el.removeAttribute('d-on-submit');
|
|
537
|
+
// Restore attribute on cleanup so dispose()+bind() (HMR) can rediscover it
|
|
538
|
+
cleanups.push(() => {
|
|
539
|
+
el.removeEventListener('submit', finalHandler);
|
|
540
|
+
el.setAttribute('d-on-submit', submitHandlerName);
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Bind all [d-field] directives within root.
|
|
548
|
+
* Registers field elements with their Form instance and sets up a11y attributes.
|
|
549
|
+
*/
|
|
550
|
+
function bindField(root, ctx, cleanups) {
|
|
551
|
+
const elements = qsaIncludingRoot(root, '[d-field]');
|
|
552
|
+
for (const el of elements) {
|
|
553
|
+
// Prefer data-field-path (set by d-array) over d-field for full path
|
|
554
|
+
const dataFieldPath = el.getAttribute('data-field-path');
|
|
555
|
+
const dFieldPath = normalizeBinding(el.getAttribute('d-field'));
|
|
556
|
+
const fieldPath = dataFieldPath || dFieldPath;
|
|
557
|
+
if (!fieldPath)
|
|
558
|
+
continue;
|
|
559
|
+
// Find the form element - use context first (for detached clones), then closest()
|
|
560
|
+
// When bind() runs on d-array clones, the clone is still detached from DOM,
|
|
561
|
+
// so el.closest('form[d-form]') returns null. We pass form refs through context.
|
|
562
|
+
const formEl = ctx._formElement || el.closest('form[d-form]');
|
|
563
|
+
if (!formEl) {
|
|
564
|
+
warn(`d-field: field "${fieldPath}" must be inside a [d-form]`);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const formBinding = ctx._formBinding || normalizeBinding(formEl.getAttribute('d-form'));
|
|
568
|
+
if (!formBinding)
|
|
569
|
+
continue;
|
|
570
|
+
const form = ctx[formBinding];
|
|
571
|
+
if (!form || typeof form !== 'object')
|
|
572
|
+
continue;
|
|
573
|
+
const htmlEl = el;
|
|
574
|
+
// Set name attribute if not already set (use full path)
|
|
575
|
+
if (!htmlEl.getAttribute('name')) {
|
|
576
|
+
htmlEl.setAttribute('name', fieldPath);
|
|
577
|
+
}
|
|
578
|
+
// Register field with form using full path
|
|
579
|
+
if ('_registerField' in form && typeof form._registerField === 'function') {
|
|
580
|
+
const unregister = form._registerField(fieldPath, htmlEl);
|
|
581
|
+
cleanups.push(unregister);
|
|
582
|
+
}
|
|
583
|
+
// Setup reactive aria-invalid based on error state
|
|
584
|
+
if ('error' in form && typeof form.error === 'function') {
|
|
585
|
+
effect(() => {
|
|
586
|
+
// Read current path from DOM attribute inside effect
|
|
587
|
+
// This allows the effect to see updated paths after array reorder
|
|
588
|
+
const currentPath = htmlEl.getAttribute('data-field-path') || htmlEl.getAttribute('name') || fieldPath;
|
|
589
|
+
const errorMsg = form.error(currentPath);
|
|
590
|
+
if (errorMsg) {
|
|
591
|
+
htmlEl.setAttribute('aria-invalid', 'true');
|
|
592
|
+
// Use form prefix for unique IDs across multiple forms
|
|
593
|
+
const errorId = `${formBinding}_${currentPath.replace(/[.\[\]]/g, '_')}_error`;
|
|
594
|
+
htmlEl.setAttribute('aria-describedby', errorId);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
htmlEl.removeAttribute('aria-invalid');
|
|
598
|
+
htmlEl.removeAttribute('aria-describedby');
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Bind all [d-error] directives within root.
|
|
606
|
+
* Displays error messages for specific fields.
|
|
607
|
+
*/
|
|
608
|
+
function bindError(root, ctx, cleanups) {
|
|
609
|
+
const elements = qsaIncludingRoot(root, '[d-error]');
|
|
610
|
+
for (const el of elements) {
|
|
611
|
+
// Prefer data-error-path (set by d-array) over d-error for full path
|
|
612
|
+
const dataErrorPath = el.getAttribute('data-error-path');
|
|
613
|
+
const dErrorPath = normalizeBinding(el.getAttribute('d-error'));
|
|
614
|
+
const fieldPath = dataErrorPath || dErrorPath;
|
|
615
|
+
if (!fieldPath)
|
|
616
|
+
continue;
|
|
617
|
+
// Find the form element - use context first (for detached clones), then closest()
|
|
618
|
+
// When bind() runs on d-array clones, the clone is still detached from DOM,
|
|
619
|
+
// so el.closest('form[d-form]') returns null. We pass form refs through context.
|
|
620
|
+
const formEl = ctx._formElement || el.closest('form[d-form]');
|
|
621
|
+
if (!formEl) {
|
|
622
|
+
warn(`d-error: error for "${fieldPath}" must be inside a [d-form]`);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const formBinding = ctx._formBinding || normalizeBinding(formEl.getAttribute('d-form'));
|
|
626
|
+
if (!formBinding)
|
|
627
|
+
continue;
|
|
628
|
+
const form = ctx[formBinding];
|
|
629
|
+
if (!form || typeof form !== 'object' || !('error' in form))
|
|
630
|
+
continue;
|
|
631
|
+
const htmlEl = el;
|
|
632
|
+
// Generate stable ID with form prefix to avoid duplicate IDs
|
|
633
|
+
// Multiple forms on same page can have fields with same names
|
|
634
|
+
const errorId = `${formBinding}_${fieldPath.replace(/[.\[\]]/g, '_')}_error`;
|
|
635
|
+
htmlEl.id = errorId;
|
|
636
|
+
// Set role for accessibility
|
|
637
|
+
htmlEl.setAttribute('role', 'alert');
|
|
638
|
+
htmlEl.setAttribute('aria-live', 'polite');
|
|
639
|
+
// Reactive error display
|
|
640
|
+
effect(() => {
|
|
641
|
+
// Read current path from DOM attribute inside effect
|
|
642
|
+
// This allows the effect to see updated paths after array reorder
|
|
643
|
+
const currentPath = htmlEl.getAttribute('data-error-path') || fieldPath;
|
|
644
|
+
const errorMsg = form.error(currentPath);
|
|
645
|
+
if (errorMsg) {
|
|
646
|
+
// Update ID to match current path (with form prefix for uniqueness)
|
|
647
|
+
const errorId = `${formBinding}_${currentPath.replace(/[.\[\]]/g, '_')}_error`;
|
|
648
|
+
htmlEl.id = errorId;
|
|
649
|
+
htmlEl.textContent = errorMsg;
|
|
650
|
+
htmlEl.style.display = '';
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
htmlEl.textContent = '';
|
|
654
|
+
htmlEl.style.display = 'none';
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Bind all [d-form-error] directives within root.
|
|
661
|
+
* Displays form-level error messages.
|
|
662
|
+
*/
|
|
663
|
+
function bindFormError(root, ctx, cleanups) {
|
|
664
|
+
const elements = qsaIncludingRoot(root, '[d-form-error]');
|
|
665
|
+
for (const el of elements) {
|
|
666
|
+
// Use the attribute value as explicit form binding name when provided
|
|
667
|
+
const explicitBinding = normalizeBinding(el.getAttribute('d-form-error'));
|
|
668
|
+
// Fall back to finding the form element via context or closest()
|
|
669
|
+
const formEl = ctx._formElement || el.closest('form[d-form]');
|
|
670
|
+
const formBinding = explicitBinding
|
|
671
|
+
|| ctx._formBinding
|
|
672
|
+
|| (formEl ? normalizeBinding(formEl.getAttribute('d-form')) : null);
|
|
673
|
+
if (!formBinding) {
|
|
674
|
+
warn('d-form-error: must specify a form binding or be inside a [d-form]');
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const form = ctx[formBinding];
|
|
678
|
+
if (!form || typeof form !== 'object' || !('formError' in form))
|
|
679
|
+
continue;
|
|
680
|
+
const htmlEl = el;
|
|
681
|
+
// Set role for accessibility
|
|
682
|
+
htmlEl.setAttribute('role', 'alert');
|
|
683
|
+
htmlEl.setAttribute('aria-live', 'polite');
|
|
684
|
+
// Reactive form error display
|
|
685
|
+
effect(() => {
|
|
686
|
+
const errorMsg = form.formError();
|
|
687
|
+
if (errorMsg) {
|
|
688
|
+
htmlEl.textContent = errorMsg;
|
|
689
|
+
htmlEl.style.display = '';
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
htmlEl.textContent = '';
|
|
693
|
+
htmlEl.style.display = 'none';
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Bind all [d-array] directives within root.
|
|
700
|
+
* Renders field arrays with stable keys for reordering.
|
|
701
|
+
* Preserves DOM state by reusing keyed nodes instead of full teardown.
|
|
702
|
+
*/
|
|
703
|
+
function bindArray(root, ctx, cleanups) {
|
|
704
|
+
const elements = qsaIncludingRoot(root, '[d-array]')
|
|
705
|
+
// Skip d-array inside d-each templates
|
|
706
|
+
// They'll be bound when the template is cloned
|
|
707
|
+
// Note: qsaIncludingRoot's boundary logic already prevents duplicate processing,
|
|
708
|
+
// so we don't need an additional filter for nested d-arrays
|
|
709
|
+
.filter(el => !el.closest('[d-each]'));
|
|
710
|
+
for (const el of elements) {
|
|
711
|
+
// Prefer data-array-path (set by parent d-array for nested arrays) over d-array
|
|
712
|
+
const dataArrayPath = el.getAttribute('data-array-path');
|
|
713
|
+
const dArrayAttr = normalizeBinding(el.getAttribute('d-array'));
|
|
714
|
+
const arrayPath = dataArrayPath || dArrayAttr;
|
|
715
|
+
if (!arrayPath)
|
|
716
|
+
continue;
|
|
717
|
+
// Find the form element — use context first (for detached clones), then closest()
|
|
718
|
+
const formEl = ctx._formElement || el.closest('form[d-form]');
|
|
719
|
+
if (!formEl) {
|
|
720
|
+
warn(`d-array: array "${arrayPath}" must be inside a [d-form]`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const formBinding = ctx._formBinding || normalizeBinding(formEl.getAttribute('d-form'));
|
|
724
|
+
if (!formBinding)
|
|
725
|
+
continue;
|
|
726
|
+
const form = ctx[formBinding];
|
|
727
|
+
if (!form || typeof form !== 'object' || !('fieldArray' in form))
|
|
728
|
+
continue;
|
|
729
|
+
// Get or create the field array
|
|
730
|
+
const fieldArray = form.fieldArray(arrayPath);
|
|
731
|
+
// Find the template element (d-each inside d-array)
|
|
732
|
+
const templateElement = el.querySelector('[d-each]');
|
|
733
|
+
if (!templateElement) {
|
|
734
|
+
warn(`d-array: array "${arrayPath}" must contain a [d-each] template`);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
// Store template reference for closure (TypeScript assertion)
|
|
738
|
+
const template = templateElement;
|
|
739
|
+
const comment = document.createComment(`d-array:${arrayPath}`);
|
|
740
|
+
template.parentNode?.replaceChild(comment, template);
|
|
741
|
+
template.removeAttribute('d-each');
|
|
742
|
+
// Track clones by key to preserve DOM state on reorder
|
|
743
|
+
const clonesByKey = new Map();
|
|
744
|
+
const disposesByKey = new Map();
|
|
745
|
+
const metadataByKey = new Map();
|
|
746
|
+
const itemSignalsByKey = new Map();
|
|
747
|
+
function createClone(key, value, index, count) {
|
|
748
|
+
const clone = template.cloneNode(true);
|
|
749
|
+
// Create context for this item
|
|
750
|
+
const itemCtx = Object.create(ctx);
|
|
751
|
+
// Create signal for item so bindings can react to updates
|
|
752
|
+
const itemSignal = signal(value);
|
|
753
|
+
// Create signals for spread properties (if value is an object)
|
|
754
|
+
// This allows {propName} bindings to update when value changes
|
|
755
|
+
const spreadProps = new Map();
|
|
756
|
+
if (typeof value === 'object' && value !== null) {
|
|
757
|
+
for (const [propKey, propValue] of Object.entries(value)) {
|
|
758
|
+
const propSignal = signal(propValue);
|
|
759
|
+
spreadProps.set(propKey, propSignal);
|
|
760
|
+
itemCtx[propKey] = propSignal;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
itemSignalsByKey.set(key, { item: itemSignal, spreadProps });
|
|
764
|
+
// Use signals for metadata so they can be updated on reorder
|
|
765
|
+
const metadata = {
|
|
766
|
+
$index: signal(index),
|
|
767
|
+
$count: signal(count),
|
|
768
|
+
$first: signal(index === 0),
|
|
769
|
+
$last: signal(index === count - 1),
|
|
770
|
+
$odd: signal(index % 2 !== 0),
|
|
771
|
+
$even: signal(index % 2 === 0),
|
|
772
|
+
};
|
|
773
|
+
metadataByKey.set(key, metadata);
|
|
774
|
+
// Expose item signal and metadata to context
|
|
775
|
+
itemCtx.item = itemSignal;
|
|
776
|
+
itemCtx.key = key;
|
|
777
|
+
itemCtx.$index = metadata.$index;
|
|
778
|
+
itemCtx.$count = metadata.$count;
|
|
779
|
+
itemCtx.$first = metadata.$first;
|
|
780
|
+
itemCtx.$last = metadata.$last;
|
|
781
|
+
itemCtx.$odd = metadata.$odd;
|
|
782
|
+
itemCtx.$even = metadata.$even;
|
|
783
|
+
// Pass form reference for bindField/bindError to use
|
|
784
|
+
// When clone is detached, el.closest('form[d-form]') returns null
|
|
785
|
+
itemCtx._formElement = formEl;
|
|
786
|
+
itemCtx._formBinding = formBinding;
|
|
787
|
+
// Expose array operations bound to this item's key (not index)
|
|
788
|
+
itemCtx.$remove = () => fieldArray.remove(key);
|
|
789
|
+
itemCtx.$moveUp = () => {
|
|
790
|
+
const currentIndex = fieldArray._getIndex(key);
|
|
791
|
+
if (currentIndex > 0)
|
|
792
|
+
fieldArray.move(currentIndex, currentIndex - 1);
|
|
793
|
+
};
|
|
794
|
+
itemCtx.$moveDown = () => {
|
|
795
|
+
const currentIndex = fieldArray._getIndex(key);
|
|
796
|
+
if (currentIndex < fieldArray.length() - 1)
|
|
797
|
+
fieldArray.move(currentIndex, currentIndex + 1);
|
|
798
|
+
};
|
|
799
|
+
// Mark and bind clone
|
|
800
|
+
clone.setAttribute('data-dalila-internal-bound', '');
|
|
801
|
+
clone.setAttribute('data-array-key', key);
|
|
802
|
+
// Update field names and d-field to include full path
|
|
803
|
+
// Include clone root itself (for primitive arrays like <input d-each="items" d-field="value">)
|
|
804
|
+
const fields = [];
|
|
805
|
+
if (clone.hasAttribute('d-field'))
|
|
806
|
+
fields.push(clone);
|
|
807
|
+
fields.push(...Array.from(clone.querySelectorAll('[d-field]')));
|
|
808
|
+
for (const field of fields) {
|
|
809
|
+
const relativeFieldPath = field.getAttribute('d-field');
|
|
810
|
+
if (relativeFieldPath) {
|
|
811
|
+
const fullPath = `${arrayPath}[${index}].${relativeFieldPath}`;
|
|
812
|
+
field.setAttribute('name', fullPath);
|
|
813
|
+
// Set data-field-path for bindField to use full path
|
|
814
|
+
field.setAttribute('data-field-path', fullPath);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
// Also update d-error elements to use full path (including root)
|
|
818
|
+
const errors = [];
|
|
819
|
+
if (clone.hasAttribute('d-error'))
|
|
820
|
+
errors.push(clone);
|
|
821
|
+
errors.push(...Array.from(clone.querySelectorAll('[d-error]')));
|
|
822
|
+
for (const errorEl of errors) {
|
|
823
|
+
const relativeErrorPath = errorEl.getAttribute('d-error');
|
|
824
|
+
if (relativeErrorPath) {
|
|
825
|
+
const fullPath = `${arrayPath}[${index}].${relativeErrorPath}`;
|
|
826
|
+
errorEl.setAttribute('data-error-path', fullPath);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Update nested d-array elements to use full path (for nested field arrays)
|
|
830
|
+
const nestedArrays = clone.querySelectorAll('[d-array]');
|
|
831
|
+
for (const nestedArr of nestedArrays) {
|
|
832
|
+
const relativeArrayPath = nestedArr.getAttribute('d-array');
|
|
833
|
+
if (relativeArrayPath) {
|
|
834
|
+
const fullPath = `${arrayPath}[${index}].${relativeArrayPath}`;
|
|
835
|
+
nestedArr.setAttribute('data-array-path', fullPath);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
// Set type="button" on array control buttons to prevent form submit
|
|
839
|
+
// Buttons like d-on-click="$remove" inside templates aren't processed by
|
|
840
|
+
// bindArrayOperations (they don't exist yet), so set it here during clone creation
|
|
841
|
+
const controlButtons = clone.querySelectorAll('button[d-on-click*="$remove"], button[d-on-click*="$moveUp"], button[d-on-click*="$moveDown"], button[d-on-click*="$swap"]');
|
|
842
|
+
for (const btn of controlButtons) {
|
|
843
|
+
if (btn.getAttribute('type') !== 'button') {
|
|
844
|
+
btn.setAttribute('type', 'button');
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const dispose = bind(clone, itemCtx, { _skipLifecycle: true });
|
|
848
|
+
disposesByKey.set(key, dispose);
|
|
849
|
+
clonesByKey.set(key, clone);
|
|
850
|
+
return clone;
|
|
851
|
+
}
|
|
852
|
+
function updateCloneIndex(clone, key, value, index, count) {
|
|
853
|
+
// Update field names with new index (values stay in DOM)
|
|
854
|
+
// Include clone root itself (for primitive arrays)
|
|
855
|
+
const fields = [];
|
|
856
|
+
if (clone.hasAttribute('d-field'))
|
|
857
|
+
fields.push(clone);
|
|
858
|
+
fields.push(...Array.from(clone.querySelectorAll('[d-field]')));
|
|
859
|
+
for (const field of fields) {
|
|
860
|
+
const relativeFieldPath = field.getAttribute('d-field');
|
|
861
|
+
if (relativeFieldPath) {
|
|
862
|
+
const fullPath = `${arrayPath}[${index}].${relativeFieldPath}`;
|
|
863
|
+
field.setAttribute('name', fullPath);
|
|
864
|
+
field.setAttribute('data-field-path', fullPath);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// Update d-error elements (including root)
|
|
868
|
+
const errors = [];
|
|
869
|
+
if (clone.hasAttribute('d-error'))
|
|
870
|
+
errors.push(clone);
|
|
871
|
+
errors.push(...Array.from(clone.querySelectorAll('[d-error]')));
|
|
872
|
+
for (const errorEl of errors) {
|
|
873
|
+
const relativeErrorPath = errorEl.getAttribute('d-error');
|
|
874
|
+
if (relativeErrorPath) {
|
|
875
|
+
const fullPath = `${arrayPath}[${index}].${relativeErrorPath}`;
|
|
876
|
+
errorEl.setAttribute('data-error-path', fullPath);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// Update nested d-array paths
|
|
880
|
+
const nestedArrays = clone.querySelectorAll('[d-array]');
|
|
881
|
+
for (const nestedArr of nestedArrays) {
|
|
882
|
+
const relativeArrayPath = nestedArr.getAttribute('d-array');
|
|
883
|
+
if (relativeArrayPath) {
|
|
884
|
+
const fullPath = `${arrayPath}[${index}].${relativeArrayPath}`;
|
|
885
|
+
nestedArr.setAttribute('data-array-path', fullPath);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// Update metadata signals with new index values
|
|
889
|
+
const metadata = metadataByKey.get(key);
|
|
890
|
+
if (metadata) {
|
|
891
|
+
metadata.$index.set(index);
|
|
892
|
+
metadata.$count.set(count);
|
|
893
|
+
metadata.$first.set(index === 0);
|
|
894
|
+
metadata.$last.set(index === count - 1);
|
|
895
|
+
metadata.$odd.set(index % 2 !== 0);
|
|
896
|
+
metadata.$even.set(index % 2 === 0);
|
|
897
|
+
}
|
|
898
|
+
// Update item signals when value changes via updateAt()
|
|
899
|
+
const itemSignals = itemSignalsByKey.get(key);
|
|
900
|
+
if (itemSignals) {
|
|
901
|
+
// Update the item signal
|
|
902
|
+
itemSignals.item.set(value);
|
|
903
|
+
// Update spread property signals
|
|
904
|
+
if (typeof value === 'object' && value !== null) {
|
|
905
|
+
const newProps = new Set(Object.keys(value));
|
|
906
|
+
// Update existing props and clear removed ones
|
|
907
|
+
for (const [propKey, propSignal] of itemSignals.spreadProps) {
|
|
908
|
+
if (newProps.has(propKey)) {
|
|
909
|
+
propSignal.set(value[propKey]);
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
// Property was removed - clear to undefined
|
|
913
|
+
propSignal.set(undefined);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
// Value is not an object (null, primitive, etc) - clear all spread props
|
|
919
|
+
for (const [, propSignal] of itemSignals.spreadProps) {
|
|
920
|
+
propSignal.set(undefined);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function renderList() {
|
|
926
|
+
const items = fieldArray.fields();
|
|
927
|
+
const newKeys = new Set(items.map((item) => item.key));
|
|
928
|
+
// Remove clones for keys that no longer exist
|
|
929
|
+
for (const [key, clone] of clonesByKey) {
|
|
930
|
+
if (!newKeys.has(key)) {
|
|
931
|
+
clone.remove();
|
|
932
|
+
clonesByKey.delete(key);
|
|
933
|
+
metadataByKey.delete(key);
|
|
934
|
+
itemSignalsByKey.delete(key);
|
|
935
|
+
const dispose = disposesByKey.get(key);
|
|
936
|
+
if (dispose) {
|
|
937
|
+
dispose();
|
|
938
|
+
disposesByKey.delete(key);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// Build new DOM order, reusing existing clones
|
|
943
|
+
const parent = comment.parentNode;
|
|
944
|
+
if (!parent)
|
|
945
|
+
return;
|
|
946
|
+
// Collect all clones in new order
|
|
947
|
+
const orderedClones = [];
|
|
948
|
+
for (let i = 0; i < items.length; i++) {
|
|
949
|
+
const { key, value } = items[i];
|
|
950
|
+
let clone = clonesByKey.get(key);
|
|
951
|
+
if (clone) {
|
|
952
|
+
// Reuse existing clone, update index-based attributes and item value
|
|
953
|
+
updateCloneIndex(clone, key, value, i, items.length);
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
// Create new clone for new key
|
|
957
|
+
clone = createClone(key, value, i, items.length);
|
|
958
|
+
}
|
|
959
|
+
orderedClones.push(clone);
|
|
960
|
+
}
|
|
961
|
+
// Reorder DOM nodes efficiently
|
|
962
|
+
// Remove all clones from current positions
|
|
963
|
+
for (const clone of orderedClones) {
|
|
964
|
+
if (clone.parentNode) {
|
|
965
|
+
clone.parentNode.removeChild(clone);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
// Insert in correct order before the comment
|
|
969
|
+
for (const clone of orderedClones) {
|
|
970
|
+
parent.insertBefore(clone, comment);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
// Reactive rendering
|
|
974
|
+
effect(() => {
|
|
975
|
+
renderList();
|
|
976
|
+
});
|
|
977
|
+
// Bind array operation buttons
|
|
978
|
+
bindArrayOperations(el, fieldArray, cleanups);
|
|
979
|
+
cleanups.push(() => {
|
|
980
|
+
for (const clone of clonesByKey.values()) {
|
|
981
|
+
clone.remove();
|
|
982
|
+
}
|
|
983
|
+
for (const dispose of disposesByKey.values()) {
|
|
984
|
+
dispose();
|
|
985
|
+
}
|
|
986
|
+
clonesByKey.clear();
|
|
987
|
+
disposesByKey.clear();
|
|
988
|
+
metadataByKey.clear();
|
|
989
|
+
itemSignalsByKey.clear();
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Bind array operation buttons: d-append, d-remove, d-insert, d-move-up, d-move-down, d-swap
|
|
995
|
+
*/
|
|
996
|
+
function bindArrayOperations(container, fieldArray, cleanups) {
|
|
997
|
+
// d-append: append new item
|
|
998
|
+
const appendButtons = container.querySelectorAll('[d-append]');
|
|
999
|
+
for (const btn of appendButtons) {
|
|
1000
|
+
// Set type="button" to prevent form submit
|
|
1001
|
+
// Inside <form>, buttons default to type="submit"
|
|
1002
|
+
if (btn.getAttribute('type') !== 'button' && btn.tagName === 'BUTTON') {
|
|
1003
|
+
btn.setAttribute('type', 'button');
|
|
1004
|
+
}
|
|
1005
|
+
const handler = (e) => {
|
|
1006
|
+
e.preventDefault(); // Extra safety
|
|
1007
|
+
const defaultValue = btn.getAttribute('d-append');
|
|
1008
|
+
try {
|
|
1009
|
+
const value = defaultValue ? JSON.parse(defaultValue) : {};
|
|
1010
|
+
fieldArray.append(value);
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
fieldArray.append({});
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
btn.addEventListener('click', handler);
|
|
1017
|
+
cleanups.push(() => btn.removeEventListener('click', handler));
|
|
1018
|
+
}
|
|
1019
|
+
// d-remove: remove item (uses context from bindArray)
|
|
1020
|
+
const removeButtons = container.querySelectorAll('[d-remove]');
|
|
1021
|
+
for (const btn of removeButtons) {
|
|
1022
|
+
// This is handled in the item context during bindArray
|
|
1023
|
+
// Just prevent default if it's a button
|
|
1024
|
+
if (btn.getAttribute('type') !== 'button' && btn.tagName === 'BUTTON') {
|
|
1025
|
+
btn.setAttribute('type', 'button');
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// d-move-up, d-move-down: handled in item context
|
|
1029
|
+
const moveButtons = container.querySelectorAll('[d-move-up], [d-move-down]');
|
|
1030
|
+
for (const btn of moveButtons) {
|
|
1031
|
+
if (btn.getAttribute('type') !== 'button' && btn.tagName === 'BUTTON') {
|
|
1032
|
+
btn.setAttribute('type', 'button');
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// ============================================================================
|
|
488
1037
|
// Main bind() Function
|
|
489
1038
|
// ============================================================================
|
|
490
1039
|
/**
|
|
@@ -524,9 +1073,13 @@ export function bind(root, ctx, options = {}) {
|
|
|
524
1073
|
const cleanups = [];
|
|
525
1074
|
// Run all bindings within the template scope
|
|
526
1075
|
withScope(templateScope, () => {
|
|
527
|
-
// 1.
|
|
1076
|
+
// 1. Form setup — must run very early to register form instances
|
|
1077
|
+
bindForm(root, ctx, cleanups);
|
|
1078
|
+
// 2. d-array — must run before d-each to setup field arrays
|
|
1079
|
+
bindArray(root, ctx, cleanups);
|
|
1080
|
+
// 3. d-each — must run early: removes templates before TreeWalker visits them
|
|
528
1081
|
bindEach(root, ctx, cleanups);
|
|
529
|
-
//
|
|
1082
|
+
// 4. Text interpolation
|
|
530
1083
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
531
1084
|
const textNodes = [];
|
|
532
1085
|
// Same boundary logic as qsaIncludingRoot: only visit text nodes that
|
|
@@ -553,17 +1106,22 @@ export function bind(root, ctx, options = {}) {
|
|
|
553
1106
|
for (const node of textNodes) {
|
|
554
1107
|
bindTextNode(node, ctx, cleanups);
|
|
555
1108
|
}
|
|
556
|
-
//
|
|
1109
|
+
// 5. d-attr bindings
|
|
557
1110
|
bindAttrs(root, ctx, cleanups);
|
|
558
|
-
//
|
|
1111
|
+
// 6. d-html bindings
|
|
559
1112
|
bindHtml(root, ctx, cleanups);
|
|
560
|
-
//
|
|
1113
|
+
// 7. Form fields — register fields with form instances
|
|
1114
|
+
bindField(root, ctx, cleanups);
|
|
1115
|
+
// 8. Event bindings
|
|
561
1116
|
bindEvents(root, ctx, events, cleanups);
|
|
562
|
-
//
|
|
1117
|
+
// 9. d-when directive
|
|
563
1118
|
bindWhen(root, ctx, cleanups);
|
|
564
|
-
//
|
|
1119
|
+
// 10. d-match directive
|
|
565
1120
|
bindMatch(root, ctx, cleanups);
|
|
566
|
-
//
|
|
1121
|
+
// 11. Form error displays — BEFORE d-if to bind errors in conditionally rendered sections
|
|
1122
|
+
bindError(root, ctx, cleanups);
|
|
1123
|
+
bindFormError(root, ctx, cleanups);
|
|
1124
|
+
// 12. d-if — must run last: elements are fully bound before conditional removal
|
|
567
1125
|
bindIf(root, ctx, cleanups);
|
|
568
1126
|
});
|
|
569
1127
|
// Bindings complete: remove loading state and mark as ready.
|