vanilla-jui 1.5.4 → 1.5.6

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.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  For,
3
+ type ElementProps,
3
4
  createDeepStore,
4
5
  createEffect,
5
6
  createMemo,
@@ -13,7 +14,7 @@ import {
13
14
  defineComponent,
14
15
  } from '../core/component.ts';
15
16
  import { joinClasses } from '../utilities/class-name.ts';
16
- import { type RenderableContent, all } from '../utilities/dom.ts';
17
+ import { asRenderable, type RenderableContent, all } from '../utilities/dom.ts';
17
18
  import { randomId } from '../utilities/id.ts';
18
19
  import { isPlainObject } from '../utilities/object.ts';
19
20
  import {
@@ -23,6 +24,8 @@ import {
23
24
  } from '../utilities/types.ts';
24
25
  import { createValidator } from '../validation/validator.ts';
25
26
  import { createLoading } from '../primitives/loading.ts';
27
+ import { t } from 'vanilla-signal-i18n';
28
+ import locales from '../locales/index.ts';
26
29
 
27
30
  type FormValue = string | number | boolean;
28
31
  export type FieldOption = FormValue | FormOption;
@@ -99,7 +102,10 @@ export interface FormField {
99
102
  label?: RenderableContent<Form> | false;
100
103
  name?: string;
101
104
  options?: readonly FieldOption[];
102
- value?: FormDataEntryValue | boolean | readonly FormDataEntryValue[];
105
+ value?:
106
+ | FormDataEntryValue
107
+ | FormValue
108
+ | readonly (FormDataEntryValue | FormValue)[];
103
109
  checked?: boolean;
104
110
  required?: boolean;
105
111
  placeholder?: string;
@@ -230,8 +236,18 @@ const DEFAULT_CLASS_NAMES: FormClassNames = {
230
236
  };
231
237
 
232
238
  const DEFAULT_BUTTONS: FormButton[] = [
233
- { type: 'submit', text: 'Submit', theme: 'primary', action: 'submit' },
234
- { type: 'reset', text: 'Reset', theme: 'ghost', action: 'reset' },
239
+ {
240
+ type: 'submit',
241
+ text: t('Submit', locales),
242
+ theme: 'primary',
243
+ action: 'submit',
244
+ },
245
+ {
246
+ type: 'reset',
247
+ text: t('Reset', locales),
248
+ theme: 'ghost',
249
+ action: 'reset',
250
+ },
235
251
  ];
236
252
 
237
253
  // Guard against malformed dynamic chains that never terminate.
@@ -365,7 +381,21 @@ function stringifyFormValue(
365
381
  if (typeof value === 'number' || typeof value === 'boolean') {
366
382
  return String(value);
367
383
  }
368
- return value.name;
384
+ if (value instanceof File) return value.name;
385
+ return '';
386
+ }
387
+
388
+ function stringifyControlValue(value: FormField['value'] | undefined): string {
389
+ if (value == null || Array.isArray(value)) return '';
390
+ if (
391
+ typeof value === 'string' ||
392
+ typeof value === 'number' ||
393
+ typeof value === 'boolean'
394
+ ) {
395
+ return String(value);
396
+ }
397
+ if (value instanceof File) return value.name;
398
+ return '';
369
399
  }
370
400
 
371
401
  function fieldIsRequired(
@@ -687,97 +717,112 @@ export function createForm(input: FormProps = {}): Form {
687
717
  setChainVersion((version: number) => version + 1);
688
718
  };
689
719
 
690
- const controlProps = <TExtra extends Record<string, unknown>>(
691
- field: FormField,
720
+ const controlProps = <TElement extends Element>(
721
+ fieldAccessor: () => FormField,
692
722
  id: string,
693
- extra: TExtra
694
- ): TExtra & Record<string, unknown> => ({
723
+ extra: ElementProps<TElement>
724
+ ): ElementProps<TElement> => ({
695
725
  ...extra,
696
- name: field.name,
726
+ name: () => fieldAccessor().name,
697
727
  id,
698
- placeholder: field.placeholder || '',
699
- required: !!field.required,
700
- disabled: () => state.submitting || !!field.disabled,
701
- readonly: !!field.readonly,
702
- 'data-form-field': field.name || id,
728
+ placeholder: () => fieldAccessor().placeholder || '',
729
+ required: () => !!fieldAccessor().required,
730
+ disabled: () => state.submitting || !!fieldAccessor().disabled,
731
+ readonly: () => !!fieldAccessor().readonly,
732
+ 'data-form-field': () => fieldAccessor().name || id,
703
733
  });
704
734
 
705
735
  const choiceGroupView = (
706
- field: FormField,
736
+ fieldAccessor: () => FormField,
707
737
  id: string,
708
738
  type: 'checkbox' | 'radio'
709
739
  ): HTMLElement => {
710
- const direction = field.vertical ? 'vertical' : 'horizontal';
711
- const names = state.className;
712
740
  return jsx('div', {
713
- className: joinClasses(
714
- type === 'radio' ? names.radio : names.checkbox,
715
- direction === 'vertical'
716
- ? names.choiceVertical
717
- : names.choiceHorizontal,
718
- field.group ? names.choiceGroup : '',
719
- field.size ? `is-${field.size}` : ''
720
- ),
721
- 'data-form-choice-group': field.name || id,
741
+ className: () => {
742
+ const field = fieldAccessor();
743
+ const direction = field.vertical ? 'vertical' : 'horizontal';
744
+ const names = state.className;
745
+ return joinClasses(
746
+ type === 'radio' ? names.radio : names.checkbox,
747
+ direction === 'vertical'
748
+ ? names.choiceVertical
749
+ : names.choiceHorizontal,
750
+ field.group ? names.choiceGroup : '',
751
+ field.size ? `is-${field.size}` : ''
752
+ );
753
+ },
754
+ 'data-form-choice-group': () => fieldAccessor().name || id,
722
755
  'data-choice-type': type,
723
- 'data-choice-layout': direction,
724
- children: (field.options || []).map((option, optionIndex) => {
725
- const item = normalizeOption(option);
726
- const optionId = `${id}_${optionIndex}`;
727
- return jsx('label', {
728
- className: type === 'radio' ? names.radioLabel : '',
729
- 'data-form-choice': field.name || id,
730
- for: optionId,
731
- children: [
732
- jsx('input', {
733
- type,
734
- id: optionId,
735
- name: field.name,
736
- value: item.value ?? '',
737
- checked:
738
- type === 'radio'
739
- ? isSelected(field.value, item.value)
740
- : isChecked(field.value, item.value, item.checked),
741
- disabled: () =>
742
- state.submitting || !!item.disabled || !!field.disabled,
743
- required: !!field.required,
744
- onChange: (event: Event) => {
745
- const target = event.currentTarget;
746
- if (target instanceof HTMLInputElement) {
747
- syncFieldValue(field, target, type);
748
- }
749
- },
750
- }),
751
- jsx('span', {
752
- className: type === 'radio' ? names.radioText : '',
753
- 'data-form-choice-text': '',
754
- children: item.text ?? item.label ?? item.value ?? '',
755
- }),
756
- ],
757
- });
758
- }),
756
+ 'data-choice-layout': () =>
757
+ fieldAccessor().vertical ? 'vertical' : 'horizontal',
758
+ children: () =>
759
+ (fieldAccessor().options || []).map((option, optionIndex) => {
760
+ const item = normalizeOption(option);
761
+ const optionId = `${id}_${optionIndex}`;
762
+ return jsx('label', {
763
+ className: () =>
764
+ type === 'radio' ? state.className.radioLabel : '',
765
+ 'data-form-choice': () => fieldAccessor().name || id,
766
+ for: optionId,
767
+ children: [
768
+ jsx('input', {
769
+ type,
770
+ id: optionId,
771
+ name: () => fieldAccessor().name,
772
+ value: item.value ?? '',
773
+ checked: () =>
774
+ type === 'radio'
775
+ ? isSelected(fieldAccessor().value, item.value)
776
+ : isChecked(
777
+ fieldAccessor().value,
778
+ item.value,
779
+ item.checked
780
+ ),
781
+ disabled: () =>
782
+ state.submitting ||
783
+ !!item.disabled ||
784
+ !!fieldAccessor().disabled,
785
+ required: () => !!fieldAccessor().required,
786
+ onChange: (event: Event) => {
787
+ const target = event.currentTarget;
788
+ if (target instanceof HTMLInputElement) {
789
+ syncFieldValue(fieldAccessor(), target, type);
790
+ }
791
+ },
792
+ }),
793
+ jsx('span', {
794
+ className: () =>
795
+ type === 'radio' ? state.className.radioText : '',
796
+ 'data-form-choice-text': '',
797
+ children: asRenderable(
798
+ item.text ?? item.label ?? stringifyFormValue(item.value)
799
+ ),
800
+ }),
801
+ ],
802
+ });
803
+ }),
759
804
  }) as HTMLElement;
760
805
  };
761
806
 
762
807
  const controlView = (
763
- type: FormItemType,
764
- field: FormField,
808
+ itemAccessor: () => FormItem<FormField>,
809
+ fieldAccessor: () => FormField,
765
810
  id: string,
766
- index: number,
767
- item: FormItem<FormField>
811
+ indexAccessor: () => number
768
812
  ): RenderableContent<FormControlContext> => {
769
- const names = state.className;
813
+ const type = itemAccessor().type;
770
814
  if (type === 'textarea') {
771
815
  return jsx(
772
816
  'textarea',
773
- controlProps(field, id, {
774
- className: field.className || names.textarea,
775
- autocomplete: field.autocomplete,
776
- value: field.value ?? '',
817
+ controlProps<HTMLTextAreaElement>(fieldAccessor, id, {
818
+ className: () =>
819
+ fieldAccessor().className || state.className.textarea,
820
+ autocomplete: () => fieldAccessor().autocomplete,
821
+ value: () => stringifyControlValue(fieldAccessor().value),
777
822
  onInput: (event: Event) => {
778
823
  const target = event.currentTarget;
779
824
  if (target instanceof HTMLTextAreaElement) {
780
- syncFieldValue(field, target, 'textarea');
825
+ syncFieldValue(fieldAccessor(), target, 'textarea');
781
826
  }
782
827
  },
783
828
  })
@@ -786,84 +831,105 @@ export function createForm(input: FormProps = {}): Form {
786
831
  if (type === 'select') {
787
832
  return jsx(
788
833
  'select',
789
- controlProps(field, id, {
790
- className: field.className || names.select,
791
- autocomplete: field.autocomplete || 'off',
792
- multiple: !!field.multiple,
834
+ controlProps<HTMLSelectElement>(fieldAccessor, id, {
835
+ className: () => fieldAccessor().className || state.className.select,
836
+ autocomplete: () => fieldAccessor().autocomplete || 'off',
837
+ multiple: () => !!fieldAccessor().multiple,
838
+ value: () =>
839
+ fieldAccessor().multiple
840
+ ? undefined
841
+ : stringifyControlValue(fieldAccessor().value),
793
842
  onChange: (event: Event) => {
794
843
  const target = event.currentTarget;
795
844
  if (target instanceof HTMLSelectElement) {
796
- syncFieldValue(field, target, 'select');
845
+ syncFieldValue(fieldAccessor(), target, 'select');
797
846
  }
798
847
  },
799
- children: (field.options || []).map((option) => {
800
- const item = normalizeOption(option);
801
- return jsx('option', {
802
- value: item.value ?? '',
803
- disabled: !!item.disabled,
804
- selected: isSelected(field.value, item.value),
805
- children: item.text ?? item.label ?? item.value ?? '',
806
- });
807
- }),
848
+ children: () =>
849
+ (fieldAccessor().options || []).map((option) => {
850
+ const item = normalizeOption(option);
851
+ return jsx('option', {
852
+ value: item.value ?? '',
853
+ disabled: !!item.disabled,
854
+ selected: () => isSelected(fieldAccessor().value, item.value),
855
+ children: asRenderable(
856
+ item.text ?? item.label ?? stringifyFormValue(item.value)
857
+ ),
858
+ });
859
+ }),
808
860
  })
809
861
  ) as HTMLSelectElement;
810
862
  }
811
- if (type === 'radio') return choiceGroupView(field, id, 'radio');
812
- if (type === 'checkbox' && Array.isArray(field.options)) {
813
- return choiceGroupView(field, id, 'checkbox');
863
+ if (type === 'radio') return choiceGroupView(fieldAccessor, id, 'radio');
864
+ if (type === 'checkbox' && Array.isArray(fieldAccessor().options)) {
865
+ return choiceGroupView(fieldAccessor, id, 'checkbox');
814
866
  }
815
867
  if (type === 'switch') {
816
868
  return jsx('label', {
817
- className: joinClasses(
818
- names.switch,
819
- field.variant ? `is-${field.variant}` : names.switchDefault,
820
- field.size ? `is-${field.size}` : names.switchSizeMd
821
- ),
822
- 'data-form-switch': field.name || id,
869
+ className: () => {
870
+ const field = fieldAccessor();
871
+ const names = state.className;
872
+ return joinClasses(
873
+ names.switch,
874
+ field.variant ? `is-${field.variant}` : names.switchDefault,
875
+ field.size ? `is-${field.size}` : names.switchSizeMd
876
+ );
877
+ },
878
+ 'data-form-switch': () => fieldAccessor().name || id,
823
879
  for: id,
824
880
  children: [
825
881
  jsx('input', {
826
882
  type: 'checkbox',
827
883
  id,
828
- name: field.name,
829
- value: field.value ?? '1',
830
- checked: !!field.checked,
831
- disabled: () => state.submitting || !!field.disabled,
832
- required: !!field.required,
884
+ name: () => fieldAccessor().name,
885
+ value: () => fieldAccessor().value ?? '1',
886
+ checked: () => !!fieldAccessor().checked,
887
+ disabled: () => state.submitting || !!fieldAccessor().disabled,
888
+ required: () => !!fieldAccessor().required,
833
889
  onChange: (event: Event) => {
834
890
  const target = event.currentTarget;
835
891
  if (target instanceof HTMLInputElement) {
836
- syncFieldValue(field, target, 'switch');
892
+ syncFieldValue(fieldAccessor(), target, 'switch');
837
893
  }
838
894
  },
839
895
  }),
840
896
  jsx('span', {
841
- className: names.switchSlider,
897
+ className: () => state.className.switchSlider,
842
898
  'data-form-switch-slider': '',
843
899
  }),
844
900
  ],
845
901
  }) as HTMLLabelElement;
846
902
  }
847
903
  if (type === 'custom') {
904
+ const field = fieldAccessor();
848
905
  return typeof field.content === 'function'
849
- ? field.content({ form, field, index, item })
906
+ ? field.content({
907
+ form,
908
+ field,
909
+ index: indexAccessor(),
910
+ item: itemAccessor(),
911
+ })
850
912
  : field.content;
851
913
  }
852
914
  const inputType = type || 'text';
853
915
  return jsx('input', {
854
- ...controlProps(field, id, {
916
+ ...controlProps<HTMLInputElement>(fieldAccessor, id, {
855
917
  type: inputType,
856
- className: field.className || names.input,
857
- autocomplete: field.autocomplete || autoComplete(inputType),
858
- value: field.value ?? '',
918
+ className: () => fieldAccessor().className || state.className.input,
919
+ autocomplete: () =>
920
+ fieldAccessor().autocomplete || autoComplete(inputType),
921
+ value: () => fieldAccessor().value ?? '',
859
922
  onInput: (event: Event) => {
860
923
  const target = event.currentTarget;
861
924
  if (target instanceof HTMLInputElement) {
862
- syncFieldValue(field, target, inputType);
925
+ syncFieldValue(fieldAccessor(), target, inputType);
863
926
  }
864
927
  },
865
928
  }),
866
- checked: field.checked === undefined ? undefined : !!field.checked,
929
+ checked: () => {
930
+ const checked = fieldAccessor().checked;
931
+ return checked === undefined ? undefined : !!checked;
932
+ },
867
933
  }) as HTMLInputElement;
868
934
  };
869
935
 
@@ -871,11 +937,8 @@ export function createForm(input: FormProps = {}): Form {
871
937
  itemAccessor: () => FormItem<FormField>,
872
938
  indexAccessor: () => number
873
939
  ): HTMLElement => {
874
- const item = itemAccessor();
875
- const field = item.payload;
876
940
  const fieldAccessor = (): FormField => itemAccessor().payload;
877
- const index = indexAccessor();
878
- const id = resolveFieldId(field, index);
941
+ const id = resolveFieldId(fieldAccessor(), indexAccessor());
879
942
  return jsx('div', {
880
943
  className: () => state.className.item,
881
944
  'data-form-item': () => fieldAccessor().name || String(indexAccessor()),
@@ -898,7 +961,7 @@ export function createForm(input: FormProps = {}): Form {
898
961
  ),
899
962
  'data-form-label': () => fieldAccessor().name || id,
900
963
  for: id,
901
- children: () => fieldAccessor().label,
964
+ children: () => asRenderable(fieldAccessor().label),
902
965
  });
903
966
  },
904
967
  jsx('div', {
@@ -906,7 +969,9 @@ export function createForm(input: FormProps = {}): Form {
906
969
  'data-form-control': () =>
907
970
  fieldAccessor().name || String(indexAccessor()),
908
971
  children: [
909
- controlView(item.type, field, id, index, item),
972
+ asRenderable(
973
+ controlView(itemAccessor, fieldAccessor, id, indexAccessor)
974
+ ),
910
975
  () => {
911
976
  const current = fieldAccessor();
912
977
  return current.help
@@ -914,7 +979,7 @@ export function createForm(input: FormProps = {}): Form {
914
979
  className: 'help-block',
915
980
  'data-form-help': () =>
916
981
  fieldAccessor().name || String(indexAccessor()),
917
- children: () => fieldAccessor().help,
982
+ children: () => asRenderable(fieldAccessor().help),
918
983
  })
919
984
  : null;
920
985
  },
@@ -959,7 +1024,7 @@ export function createForm(input: FormProps = {}): Form {
959
1024
  disabled: () => state.submitting || !!buttonAccessor().disabled,
960
1025
  children: () => {
961
1026
  const button = buttonAccessor();
962
- const content = button.text ?? button.label ?? '';
1027
+ const content = asRenderable(button.text ?? button.label ?? '');
963
1028
  const submitting =
964
1029
  state.submitting &&
965
1030
  (button.type === 'submit' || button.action === 'submit');
@@ -17,7 +17,12 @@ import { icon } from '../primitives/icons.ts';
17
17
  import { createLoading } from '../primitives/loading.ts';
18
18
  import { createPopup } from '../primitives/popup.ts';
19
19
  import { joinClasses } from '../utilities/class-name.ts';
20
- import { all, q, type RenderableContent } from '../utilities/dom.ts';
20
+ import {
21
+ all,
22
+ asRenderable,
23
+ q,
24
+ type RenderableContent,
25
+ } from '../utilities/dom.ts';
21
26
  import { randomId } from '../utilities/id.ts';
22
27
  import { createMotionGroup, createTransition } from '../core/motion.ts';
23
28
  import { isPlainObject } from '../utilities/object.ts';
@@ -685,72 +690,74 @@ export function createModal(input: ModalProps = {}): Modal {
685
690
  ...(props.style ? { style: props.style } : {}),
686
691
  'data-mount': () => (motionVisible() ? 'true' : 'false'),
687
692
  children: [
688
- jsx('div', {
689
- className: props.className.header,
690
- hidden: () => !props.header || state.loading,
691
- children: () =>
692
- props.header && !state.loading
693
- ? [
694
- jsx('div', {
695
- className: props.className.title,
696
- id: `${props.id}_title`,
697
- 'aria-label': props.text.title,
698
- children: props.text.title,
699
- }),
700
- props.showClose
701
- ? jsx('button', {
702
- type: 'button',
703
- className: joinClasses(
704
- props.className.button,
705
- props.className.closeBtn
706
- ),
707
- 'data-action': 'close',
708
- 'aria-label': t('Close', locales),
709
- disabled: isBusy,
710
- children: icon('close'),
711
- })
712
- : null,
713
- ]
714
- : null,
715
- }),
693
+ props.header
694
+ ? jsx('div', {
695
+ className: props.className.header,
696
+ children: () =>
697
+ state.loading
698
+ ? null
699
+ : [
700
+ jsx('div', {
701
+ className: props.className.title,
702
+ id: `${props.id}_title`,
703
+ 'aria-label': props.text.title,
704
+ children: props.text.title,
705
+ }),
706
+ props.showClose
707
+ ? jsx('button', {
708
+ type: 'button',
709
+ className: joinClasses(
710
+ props.className.button,
711
+ props.className.closeBtn
712
+ ),
713
+ 'data-action': 'close',
714
+ 'aria-label': t('Close', locales),
715
+ disabled: isBusy,
716
+ children: icon('close'),
717
+ })
718
+ : null,
719
+ ],
720
+ })
721
+ : null,
716
722
  jsx('div', {
717
723
  className: props.className.body,
718
724
  'data-modal-body': '',
719
725
  children: () => {
720
- return resolvedContent().value;
726
+ return asRenderable(resolvedContent().value);
721
727
  },
722
728
  }),
723
- jsx('div', {
724
- className: props.className.footer,
725
- hidden: () => !props.footer || state.loading,
726
- children: () =>
727
- props.footer && !state.loading
728
- ? [
729
- props.showCancel
730
- ? jsx('button', {
729
+ props.footer
730
+ ? jsx('div', {
731
+ className: props.className.footer,
732
+ children: () =>
733
+ state.loading
734
+ ? null
735
+ : [
736
+ props.showCancel
737
+ ? jsx('button', {
738
+ type: 'button',
739
+ className: joinClasses(
740
+ props.className.button,
741
+ props.className.cancelBtn
742
+ ),
743
+ 'data-action': 'cancel',
744
+ disabled: isBusy,
745
+ children: props.text.cancel,
746
+ })
747
+ : null,
748
+ jsx('button', {
731
749
  type: 'button',
732
750
  className: joinClasses(
733
751
  props.className.button,
734
- props.className.cancelBtn
752
+ props.className.confirmBtn
735
753
  ),
736
- 'data-action': 'cancel',
754
+ 'data-action': 'confirm',
737
755
  disabled: isBusy,
738
- children: props.text.cancel,
739
- })
740
- : null,
741
- jsx('button', {
742
- type: 'button',
743
- className: joinClasses(
744
- props.className.button,
745
- props.className.confirmBtn
746
- ),
747
- 'data-action': 'confirm',
748
- disabled: isBusy,
749
- children: props.text.confirm,
750
- }),
751
- ]
752
- : null,
753
- }),
756
+ children: props.text.confirm,
757
+ }),
758
+ ],
759
+ })
760
+ : null,
754
761
  ],
755
762
  }) as HTMLElement;
756
763
  const root = createPopup({
@@ -12,7 +12,7 @@ import {
12
12
  defineComponent,
13
13
  } from '../core/component.ts';
14
14
  import { createLoading } from '../primitives/loading.ts';
15
- import { type RenderableContent } from '../utilities/dom.ts';
15
+ import { asRenderable, type RenderableContent } from '../utilities/dom.ts';
16
16
  import { randomId } from '../utilities/id.ts';
17
17
  import { createEventManager } from '../utilities/events.ts';
18
18
  import { createMotionGroup, createTransition } from '../core/motion.ts';
@@ -448,11 +448,13 @@ export function createOffcanvas(input: OffcanvasProps = {}): Offcanvas {
448
448
  className: props.className.content,
449
449
  'data-offcanvas-content': props.id,
450
450
  children: () =>
451
- state.loading
452
- ? createLoading()
453
- : typeof state.resolvedContent === 'function'
454
- ? state.resolvedContent(offcanvas)
455
- : state.resolvedContent,
451
+ asRenderable(
452
+ state.loading
453
+ ? createLoading()
454
+ : typeof state.resolvedContent === 'function'
455
+ ? state.resolvedContent(offcanvas)
456
+ : state.resolvedContent
457
+ ),
456
458
  }),
457
459
  }) as HTMLElement;
458
460
  },