native-document 1.0.169 → 1.0.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/docs/vitepress-conventions.md +2 -2
  3. package/package.json +11 -4
  4. package/src/components/BaseComponent.js +43 -1
  5. package/src/components/accordion/Accordion.js +1 -0
  6. package/src/components/accordion/AccordionItem.js +1 -0
  7. package/src/components/alert/Alert.js +4 -0
  8. package/src/components/avatar/Avatar.js +1 -0
  9. package/src/components/avatar/AvatarGroup.js +1 -0
  10. package/src/components/badge/Badge.js +1 -0
  11. package/src/components/breadcrumb/BreadCrumb.js +4 -0
  12. package/src/components/button/Button.js +1 -0
  13. package/src/components/card/Card.js +1 -0
  14. package/src/components/context-menu/ContextMenu.js +2 -0
  15. package/src/components/context-menu/ContextMenuGroup.js +2 -0
  16. package/src/components/context-menu/ContextMenuItem.js +2 -0
  17. package/src/components/divider/Divider.js +2 -0
  18. package/src/components/dropdown/Dropdown.js +1 -0
  19. package/src/components/dropdown/DropdownDivider.js +1 -0
  20. package/src/components/dropdown/DropdownGroup.js +1 -0
  21. package/src/components/dropdown/DropdownItem.js +1 -0
  22. package/src/components/dropdown/DropdownTrigger.js +1 -0
  23. package/src/components/form/FormControl.js +1 -0
  24. package/src/components/form/field/Field.js +1 -0
  25. package/src/components/form/field/types/StringField.js +1 -1
  26. package/src/components/icon/Icon.js +1 -0
  27. package/src/components/list/ListItem.js +1 -0
  28. package/src/components/menu/Menu.js +3 -0
  29. package/src/components/menu/MenuDivider.js +1 -0
  30. package/src/components/menu/MenuGroup.js +1 -0
  31. package/src/components/menu/MenuItem.js +1 -0
  32. package/src/components/menu/MenuLink.js +1 -0
  33. package/src/components/modal/Modal.js +4 -0
  34. package/src/components/pagination/Pagination.js +1 -0
  35. package/src/components/popover/Popover.js +1 -0
  36. package/src/components/popover/PopoverFooter.js +1 -0
  37. package/src/components/popover/PopoverHeader.js +1 -0
  38. package/src/components/progress/Progress.js +5 -0
  39. package/src/components/skeleton/Skeleton.js +4 -0
  40. package/src/components/slider/Slider.js +1 -0
  41. package/src/components/spacer/Spacer.js +1 -0
  42. package/src/components/spinner/Spinner.js +1 -0
  43. package/src/components/splitter/Splitter.js +1 -0
  44. package/src/components/splitter/SplitterGutter.js +6 -0
  45. package/src/components/splitter/SplitterPanel.js +1 -0
  46. package/src/components/stacks/AbsoluteStack.js +1 -0
  47. package/src/components/stacks/FixedStack.js +1 -0
  48. package/src/components/stacks/HStack.js +1 -0
  49. package/src/components/stacks/PositionStack.js +1 -0
  50. package/src/components/stacks/RelativeStack.js +1 -0
  51. package/src/components/stacks/Stack.js +1 -0
  52. package/src/components/stacks/VStack.js +1 -0
  53. package/src/components/stepper/Stepper.js +1 -0
  54. package/src/components/stepper/StepperStep.js +1 -0
  55. package/src/components/switch/Switch.js +1 -0
  56. package/src/components/table/DataTable.js +1 -0
  57. package/src/components/table/SimpleTable.js +1 -0
  58. package/src/components/tabs/Tabs.js +1 -0
  59. package/src/components/toast/Toast.js +1 -0
  60. package/src/components/tooltip/Tooltip.js +1 -0
  61. package/src/core/data/MemoryManager.js +6 -1
  62. package/src/core/data/ObservableItem.js +28 -10
  63. package/src/core/elements/anchor/anchor.js +4 -3
  64. package/src/core/elements/anchor/one-child-anchor-overwriting.js +4 -4
  65. package/src/core/elements/content-formatter.js +19 -0
  66. package/src/core/elements/control/for-each-array-cache.js +59 -0
  67. package/src/core/elements/control/for-each-array.js +15 -23
  68. package/src/core/wrappers/AttributesWrapper.js +12 -12
  69. package/src/core/wrappers/ElementCreator.js +33 -10
  70. package/src/core/wrappers/HtmlElementWrapper.js +9 -4
  71. package/src/core/wrappers/NDElement.js +23 -8
  72. package/src/core/wrappers/NdPrototype.js +31 -39
  73. package/src/core/wrappers/constants.js +12 -12
  74. package/src/core/wrappers/prototypes/nd-element-extensions.js +127 -57
  75. package/src/ui/components/accordion/AccordionItemRender.js +29 -7
  76. package/src/ui/components/alert/AlertRender.js +6 -0
  77. package/src/ui/components/breadcrumb/BreadcrumbRender.js +9 -1
  78. package/src/ui/components/button/ButtonRender.js +3 -0
  79. package/src/ui/components/contextmenu/ContextmenuRender.js +2 -0
  80. package/src/ui/components/dropdown/DropdownRender.js +3 -0
  81. package/src/ui/components/dropdown/item/DropdownItemRender.js +6 -1
  82. package/src/ui/components/form/FieldCollectionRender.js +4 -0
  83. package/src/ui/components/form/fields/CheckboxFieldRender.js +4 -0
  84. package/src/ui/components/form/fields/CheckboxGroupFieldRender.js +4 -0
  85. package/src/ui/components/form/fields/RadioFieldRender.js +4 -0
  86. package/src/ui/components/form/fields/RangeFieldRender.js +4 -0
  87. package/src/ui/components/form/fields/SelectFieldRender.js +5 -0
  88. package/src/ui/components/form/fields/SliderFieldRender.js +3 -0
  89. package/src/ui/components/icon/tabler/TablerIconRender.js +0 -1
  90. package/src/ui/components/list/item/ListItemRender.js +4 -0
  91. package/src/ui/components/menu/MenuItemRender.js +8 -0
  92. package/src/ui/components/menu/MenuLinkRender.js +11 -0
  93. package/src/ui/components/menu/MenuRender.js +5 -0
  94. package/src/ui/components/menu/helpers.js +2 -1
  95. package/src/ui/components/modal/ModalRender.js +8 -2
  96. package/src/ui/components/progress/ProgressRender.js +9 -0
  97. package/src/ui/components/skeleton/SkeletonRender.js +2 -0
  98. package/src/ui/components/spinner/SpinnerRender.js +3 -0
  99. package/src/ui/components/splitter/SplitterGutterRender.js +4 -0
  100. package/src/ui/components/splitter/SplitterPanelRender.js +4 -0
  101. package/src/ui/components/splitter/SplitterRender.js +2 -0
  102. package/src/ui/components/stepper/StepperRender.js +2 -1
  103. package/src/ui/components/stepper/StepperStepRender.js +8 -1
  104. package/src/ui/components/switch/SwitchRender.js +3 -0
  105. package/src/ui/components/table/data-table/DataTableRender.js +4 -0
  106. package/src/ui/components/table/simple-table/SimpleTableRender.js +2 -0
  107. package/src/ui/components/toast/ToastRender.js +4 -0
  108. package/src/ui/utils/aria.js +19 -0
  109. package/tests/integration/ui/attributes-wrapper.test.js +177 -0
  110. package/tests/integration/ui/for-each-array.test.js +167 -0
  111. package/tests/integration/ui/lifecycle.test.js +146 -0
  112. package/tests/integration/ui/show-if.test.js +168 -0
  113. package/tests/unit/core/observable-array.test.js +404 -0
  114. package/tests/unit/core/observable-item.test.js +428 -0
  115. package/tests/unit/core/observable-object.test.js +239 -0
  116. package/tests/unit/core/observable-resource.test.js +323 -0
  117. package/tests/unit/core/store.test.js +347 -0
  118. package/tests/unit/router/route-matching.test.js +169 -0
  119. package/tests/unit/router/router-guards.test.js +189 -0
  120. package/tests/unit/utils/cache.test.js +120 -0
  121. package/tests/unit/utils/filters.test.js +394 -0
  122. package/types/elements.d.ts +5 -0
  123. package/vitest.config.js +15 -0
@@ -25,6 +25,7 @@ export default function CheckboxFieldRender($desc, instance) {
25
25
  }
26
26
 
27
27
  const buildCheckbox = ($desc, instance) => {
28
+ const isRequired = $desc.rules?.some(rule => rule.fn?.name === 'required');
28
29
  const input = Input({
29
30
  class: 'field-checkbox',
30
31
  type: 'checkbox',
@@ -32,6 +33,9 @@ const buildCheckbox = ($desc, instance) => {
32
33
  id: $desc.id || $desc.name,
33
34
  disabled: $desc.disabled,
34
35
  checked: $desc.checked,
36
+ // [a11y] aria-required, aria-invalid
37
+ ...(isRequired ? { 'aria-required': 'true' } : {}),
38
+ ...($desc.hasErrors ? { 'aria-invalid': $desc.hasErrors } : {}),
35
39
  ...($desc.elementsProps.input || {}),
36
40
  });
37
41
 
@@ -85,8 +85,12 @@ const buildOptions = ($desc, instance) => {
85
85
  ]);
86
86
  };
87
87
 
88
+ const isRequired = $desc.rules?.some(rule => rule.fn?.name === 'required');
88
89
  return Div({
89
90
  class: 'field-checkbox-group is-' + ($desc.layout || 'vertical'),
91
+ // [a11y] role=group, aria-required
92
+ role: 'group',
93
+ ...( isRequired ? { 'aria-required': 'true' } : {} ),
90
94
  ...($desc.elementsProps.wrapper || {}),
91
95
  }, ForEachArray($desc.options, buildItem));
92
96
  };
@@ -70,8 +70,12 @@ const buildOptions = ($desc, instance) => {
70
70
  ]);
71
71
  };
72
72
 
73
+ const isRequired = $desc.rules?.some(rule => rule.fn?.name === 'required');
73
74
  return Div({
74
75
  class: 'field-radio-group is-' + ($desc.layout || 'vertical'),
76
+ // [a11y] role=radiogroup, aria-required
77
+ role: 'radiogroup',
78
+ ...( isRequired ? { 'aria-required': 'true' } : {} ),
75
79
  ...($desc.elementsProps.wrapper || {}),
76
80
  }, ForEachArray($options, buildOption));
77
81
  };
@@ -66,6 +66,10 @@ const buildInput = ($desc, instance) => {
66
66
  step: $desc.step ?? 1,
67
67
  disabled: $desc.disabled,
68
68
  value: $desc.value,
69
+ // [a11y] aria-valuemin, aria-valuemax, aria-valuenow
70
+ 'aria-valuemin': String($desc.min ?? 0),
71
+ 'aria-valuemax': String($desc.max ?? 100),
72
+ ...($desc.value?.__$Observable ? { 'aria-valuenow': $desc.value.transform((v) => String(v)) } : { 'aria-valuenow': String($desc.value ?? 0) }),
69
73
  ...($desc.elementsProps.input || {}),
70
74
  });
71
75
 
@@ -61,8 +61,13 @@ const buildSelectWrapper = ($desc, instance) => {
61
61
  }
62
62
  triggerOptions.push(Span({class: 'select-field-chevron'}, '▾'));
63
63
 
64
+ const isRequired = $desc.rules?.some(rule => rule.fn?.name === 'required');
64
65
  const trigger = Div({
65
66
  class: {'field-input select-field-trigger': true, 'has-tags': isTags},
67
+ // [a11y] aria-haspopup, aria-expanded, aria-required
68
+ 'aria-haspopup': 'listbox',
69
+ 'aria-expanded': 'false',
70
+ ...( isRequired ? { 'aria-required': 'true' } : {} ),
66
71
  ...($desc.elementsProps.input || {}),
67
72
  }, [
68
73
  valueDisplay,
@@ -7,6 +7,9 @@ export default function SliderRender($desc, instance) {
7
7
  const props = instance.getEditableProps();
8
8
 
9
9
  props.class.add('slider');
10
+ // [a11y] aria-valuemin, aria-valuemax
11
+ props['aria-valuemin'] = String($desc.min ?? 0);
12
+ props['aria-valuemax'] = String($desc.max ?? 100);
10
13
  props.class.add({
11
14
  'is-vertical': $desc.vertical,
12
15
  'is-reverse': $desc.reverse,
@@ -49,7 +49,6 @@ export default function TablerIconRender($desc) {
49
49
  if($desc.color) {
50
50
  style.color = $desc.color;
51
51
  }
52
- console.log(style);
53
52
 
54
53
  return Italic({
55
54
  'aria-hidden': 'true',
@@ -13,9 +13,13 @@ export default function ListItemRender($desc, instance) {
13
13
 
14
14
  if ($desc.disabled) {
15
15
  props.class.add('is-disabled', $desc.disabled);
16
+ // [a11y] aria-disabled
17
+ props['aria-disabled'] = $desc.disabled;
16
18
  }
17
19
  if ($desc.selected) {
18
20
  props.class.add('is-selected', $desc.selected);
21
+ // [a11y] aria-selected
22
+ props['aria-selected'] = $desc.selected;
19
23
  }
20
24
  if ($desc.visibility) {
21
25
  props.class.add('is-hidden', $desc.visibility.isFalsy());
@@ -24,6 +24,14 @@ export default function MenuItemRender($desc, instance) {
24
24
 
25
25
  if(hasSubmenu) {
26
26
  props.class.add('has-submenu');
27
+ // [a11y] aria-haspopup for submenu items
28
+ props['aria-haspopup'] = 'menu';
29
+ props['aria-expanded'] = 'false';
30
+ }
31
+
32
+ // [a11y] aria-disabled
33
+ if($desc.disabled) {
34
+ props['aria-disabled'] = $desc.disabled;
27
35
  }
28
36
 
29
37
  const content = [
@@ -24,6 +24,17 @@ export default function MenuLinkRender($desc, instance) {
24
24
 
25
25
  if(hasSubmenu) {
26
26
  props.class.add('has-submenu');
27
+ // [a11y] aria-haspopup for submenu links
28
+ props['aria-haspopup'] = 'menu';
29
+ props['aria-expanded'] = 'false';
30
+ }
31
+
32
+ // [a11y] aria-disabled, aria-current
33
+ if($desc.disabled) {
34
+ props['aria-disabled'] = $desc.disabled;
35
+ }
36
+ if($desc.selected) {
37
+ props['aria-current'] = $desc.selected.transform ? $desc.selected.transform((s) => s ? 'page' : undefined) : ($desc.selected ? 'page' : undefined);
27
38
  }
28
39
 
29
40
  const content = [
@@ -8,6 +8,11 @@ export default function MenuRender($desc, instance) {
8
8
  props.class.add('menu');
9
9
  props.class.add('is-' + ($desc.orientation || 'horizontal'));
10
10
 
11
+ // [a11y] aria-label if provided
12
+ if($desc.label) {
13
+ props['aria-label'] = $desc.label;
14
+ }
15
+
11
16
  const nav = Nav(instance.resolveProps(), ForEachArray($desc.items));
12
17
 
13
18
  if($desc.compactThreshold) {
@@ -113,9 +113,10 @@ export const setupInteraction = (listItem, el, submenuEl, orientation, interacti
113
113
  });
114
114
  }
115
115
 
116
- document.addEventListener('click', (e) => {
116
+ document.addEventListener('click', () => {
117
117
  $isMenuActivated?.set(false);
118
118
  $isOpen.set(false);
119
119
  $activeItem?.set(null);
120
120
  });
121
+
121
122
  };
@@ -5,6 +5,7 @@ import {createPortal} from '../../../core/elements/anchor/anchor';
5
5
 
6
6
  import './modal.css';
7
7
 
8
+ let _modalIdCounter = 0;
8
9
  export default function ModalRender($desc, instance) {
9
10
  const editableProps = instance.getEditableProps();
10
11
 
@@ -66,10 +67,15 @@ export default function ModalRender($desc, instance) {
66
67
  return createPortal(dialog);
67
68
  }
68
69
 
69
- const buildHeader = ($desc, instance) => {
70
+
71
+ const buildHeader = ($desc, instance, dialog) => {
70
72
  const content = [];
71
73
 
72
- content.push(Div({class: 'modal-title'}, $desc.title));
74
+ if($desc.title) {
75
+ const titleId = `modal-title-${++_modalIdCounter}`;
76
+ dialog.setAttribute('aria-labelledby', titleId);
77
+ content.push(Div({class: 'modal-title', id: titleId }, $desc.title));
78
+ }
73
79
 
74
80
  if($desc.closable) {
75
81
  content.push(
@@ -46,6 +46,15 @@ const buildBar = ($desc, instance) => {
46
46
 
47
47
  props.class.add('progress');
48
48
 
49
+ // [a11y] aria-valuenow and aria-valuemax
50
+ const ariaMax = $desc.max || 100;
51
+ props['aria-valuemax'] = String(ariaMax);
52
+ if($desc.value?.__$Observable) {
53
+ props['aria-valuenow'] = $desc.value.transform((v) => String(Math.min(v, ariaMax)));
54
+ } else if($desc.value !== null && $desc.value !== undefined) {
55
+ props['aria-valuenow'] = String($desc.value);
56
+ }
57
+
49
58
  const fillClass = ['progress-fill', 'is-' + variant];
50
59
 
51
60
  if($desc.striped) {
@@ -26,6 +26,8 @@ export default function SkeletonRender($desc, instance) {
26
26
 
27
27
  props.class.add('skeleton');
28
28
  props.class.add('is-' + variant);
29
+ // [a11y] aria-label for screen readers
30
+ props['aria-label'] = $desc.label || 'Loading content';
29
31
  props.class.add('is-' + ($desc.borderRadiusType || 'rounded'));
30
32
 
31
33
  if(type === 'circle' || type === 'avatar') {
@@ -37,6 +37,9 @@ export default function SpinnerRender($desc, instance) {
37
37
 
38
38
  const icon = Div({ class: 'spinner-icon', style: iconStyle });
39
39
 
40
+ // [a11y] aria-label: use label if provided, fallback to 'Loading'
41
+ props['aria-label'] = $desc.label || 'Loading';
42
+
40
43
  const content = [icon];
41
44
 
42
45
  if($desc.label) {
@@ -7,6 +7,10 @@ export default function SplitterGutterRender($desc, instance) {
7
7
  props.class.add('is-' + ($desc.orientation || 'horizontal'));
8
8
  props.class.add({'is-dragging': $desc.isDragging});
9
9
 
10
+ // [a11y] aria-orientation and aria-valuenow for resizable separator
11
+ props['aria-orientation'] = $desc.orientation === 'vertical' ? 'horizontal' : 'vertical';
12
+ props['tabindex'] = props['tabindex'] ?? '0';
13
+
10
14
  // props.style.add({
11
15
  // [($desc.orientation === 'vertical') ? 'height' : 'width']: ($desc.size || 8) + 'px',
12
16
  // });
@@ -3,6 +3,10 @@ import {Div} from '../../../core/elements';
3
3
  export default function SplitterPanelRender($desc, instance) {
4
4
  const props = instance.getEditableProps();
5
5
  props.class.add('splitter-panel');
6
+ // [a11y] aria-label if provided
7
+ if($desc.label) {
8
+ props['aria-label'] = $desc.label;
9
+ }
6
10
 
7
11
  if($desc.collapsed) {
8
12
  props.class.add('is-collapsed');
@@ -9,6 +9,8 @@ export default function SplitterRender($desc, instance) {
9
9
 
10
10
  props.class.add('splitter');
11
11
  props.class.add('is-' + ($desc.orientation || 'horizontal'));
12
+ // [a11y] aria-orientation
13
+ props['aria-orientation'] = $desc.orientation || 'horizontal';
12
14
 
13
15
  const container = Div(instance.resolveProps());
14
16
 
@@ -24,7 +24,8 @@ export default function StepperRender($desc, instance) {
24
24
  }
25
25
 
26
26
  const buildNav = ($desc, instance) => {
27
- return Div({class: 'stepper-nav'},
27
+ // [a11y] aria-label on stepper nav
28
+ return Div({ class: 'stepper-nav', 'aria-label': $desc.label || 'Steps' },
28
29
  ForEachArray($desc.visibleSteps, (step, index) => {
29
30
  step.$setStepper(instance);
30
31
  const items = [];
@@ -48,7 +48,14 @@ export default function StepperStepRender($desc, instance) {
48
48
  labelContent.push(Span({class: 'step-optional'}, 'Optional'));
49
49
  }
50
50
 
51
- const stepEl = Div(instance.resolveProps(), [
51
+ // [a11y] aria-current, aria-disabled on step
52
+ const resolvedProps = instance.resolveProps();
53
+ resolvedProps['aria-current'] = isActive.transform((a) => a ? 'step' : undefined);
54
+ if($desc.disabled?.val()) {
55
+ resolvedProps['aria-disabled'] = 'true';
56
+ }
57
+
58
+ const stepEl = Div(resolvedProps, [
52
59
  Div({class: indicatorClass}, [
53
60
  indicatorContent,
54
61
  Span({class: 'step-state-indicator'}, stateIndicator),
@@ -25,6 +25,9 @@ export default function SwitchRender($desc, instance) {
25
25
  type: 'checkbox',
26
26
  checked: $desc.value,
27
27
  disabled: $desc.disabled,
28
+ // [a11y] aria-checked and aria-disabled
29
+ 'aria-checked': $desc.value,
30
+ ...($desc.disabled ? { 'aria-disabled': $desc.disabled } : {}),
28
31
  });
29
32
 
30
33
  $desc.value.subscribe(() => {
@@ -9,6 +9,10 @@ import {buildPageInfo, buildPageNav, buildPageSize, buildPagination} from './pag
9
9
  export default function DataTableRender($desc, instance) {
10
10
  const props = instance.getEditableProps();
11
11
  props.class.add('data-table');
12
+ // [a11y] aria-label on data table
13
+ if($desc.label) {
14
+ props['aria-label'] = $desc.label;
15
+ }
12
16
 
13
17
  const visibleColumns = $desc.columns.filter(col =>
14
18
  col.$description.visible !== false,
@@ -46,6 +46,8 @@ const buildHeaderRow = ($desc, visibleColumns) => {
46
46
  class: col.$description.align ? `is-${col.$description.align}` : null,
47
47
  colspan: col.$description.colspan || null,
48
48
  rowspan: col.$description.rowspan || null,
49
+ // [a11y] scope=col on header cells
50
+ scope: 'col',
49
51
  ...extraProps,
50
52
  }, col.$description.header);
51
53
  }),
@@ -26,6 +26,10 @@ export default function ToastRender($desc, instance) {
26
26
  props.class.add('toast');
27
27
  props.class.add('is-' + type);
28
28
 
29
+ // [a11y] aria-live: assertive for error, polite for others
30
+ props['aria-live'] = (type === 'error' || type === 'danger') ? 'assertive' : 'polite';
31
+ props['aria-atomic'] = 'true';
32
+
29
33
  const content = [];
30
34
 
31
35
  if($desc.showIcon && $desc.icon) {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Applies aria attributes on a trigger element for overlay components.
3
+ * Sets aria-haspopup and keeps aria-expanded in sync with the isOpen observable.
4
+ *
5
+ * @param {HTMLElement} triggerSource - The trigger element
6
+ * @param {ObservableItem<boolean>} isOpen - Observable controlling the open state
7
+ * @param {'menu'|'listbox'|'dialog'|'tree'|'grid'} [type='menu'] - aria-haspopup value
8
+ */
9
+ export const ariaTrigger = (triggerSource, isOpen, type = 'menu') => {
10
+ const triggerEl = triggerSource?.toNdElement?.() ?? triggerSource;
11
+ if(!triggerEl) {
12
+ return;
13
+ }
14
+ triggerEl.setAttribute('aria-haspopup', type);
15
+ triggerEl.setAttribute('aria-expanded', 'false');
16
+ isOpen.subscribe((value) => {
17
+ triggerEl.setAttribute('aria-expanded', String(value));
18
+ });
19
+ };
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Observable } from '../../../src/core/data/Observable';
3
+
4
+ vi.mock('../../../src/core/utils/debug-manager', () => ({
5
+ default: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), disable: vi.fn() },
6
+ }));
7
+
8
+ import { Div, Input, Span } from '../../../elements';
9
+
10
+ describe('AttributesWrapper', () => {
11
+
12
+ // -- string attributes ----------------------------------------------------
13
+
14
+ describe('string attributes', () => {
15
+ it('sets a string attribute', () => {
16
+ const el = Div({ id: 'main', 'data-test': 'value' });
17
+ expect(el.getAttribute('id')).toBe('main');
18
+ expect(el.getAttribute('data-test')).toBe('value');
19
+ });
20
+
21
+ it('sets a number attribute as string', () => {
22
+ const el = Div({ tabindex: 0 });
23
+ expect(el.getAttribute('tabindex')).toBe('0');
24
+ });
25
+
26
+ it('ignores null values', () => {
27
+ const el = Div({ id: null });
28
+ expect(el.hasAttribute('id')).toBe(false);
29
+ });
30
+
31
+ it('ignores undefined values', () => {
32
+ const el = Div({ id: undefined });
33
+ expect(el.hasAttribute('id')).toBe(false);
34
+ });
35
+ });
36
+
37
+ // -- class ----------------------------------------------------------------
38
+
39
+ describe('class', () => {
40
+ it('applies a class string', () => {
41
+ const el = Div({ class: 'foo bar' });
42
+ expect(el.className).toBe('foo bar');
43
+ });
44
+
45
+ it('applies a class map object', () => {
46
+ const el = Div({ class: { active: true, disabled: false } });
47
+ expect(el.classList.contains('active')).toBe(true);
48
+ expect(el.classList.contains('disabled')).toBe(false);
49
+ });
50
+
51
+ it('toggles class reactively with Observable', () => {
52
+ const isActive = Observable(false);
53
+ const el = Div({ class: { active: isActive } });
54
+ expect(el.classList.contains('active')).toBe(false);
55
+ isActive.set(true);
56
+ expect(el.classList.contains('active')).toBe(true);
57
+ });
58
+
59
+ it('adds multiple reactive classes', () => {
60
+ const a = Observable(true);
61
+ const b = Observable(false);
62
+ const el = Div({ class: { foo: a, bar: b } });
63
+ expect(el.classList.contains('foo')).toBe(true);
64
+ expect(el.classList.contains('bar')).toBe(false);
65
+ b.set(true);
66
+ expect(el.classList.contains('bar')).toBe(true);
67
+ });
68
+ });
69
+
70
+ // -- style ----------------------------------------------------------------
71
+
72
+ describe('style', () => {
73
+ it('applies a static style map', () => {
74
+ const el = Div({ style: { color: 'red', fontSize: '16px' } });
75
+ expect(el.style.color).toBe('red');
76
+ expect(el.style.fontSize).toBe('16px');
77
+ });
78
+
79
+ it('updates style reactively with Observable', () => {
80
+ const color = Observable('red');
81
+ const el = Div({ style: { color } });
82
+ expect(el.style.color).toBe('red');
83
+ color.set('blue');
84
+ expect(el.style.color).toBe('blue');
85
+ });
86
+
87
+ it('sets CSS custom property via --var syntax', () => {
88
+ const el = Div({ style: { '--primary': '#fff' } });
89
+ expect(el.style.getPropertyValue('--primary')).toBe('#fff');
90
+ });
91
+
92
+ it('updates CSS custom property reactively', () => {
93
+ const primary = Observable('#fff');
94
+ const el = Div({ style: { '--primary': primary } });
95
+ expect(el.style.getPropertyValue('--primary')).toBe('#fff');
96
+ primary.set('#000');
97
+ expect(el.style.getPropertyValue('--primary')).toBe('#000');
98
+ });
99
+ });
100
+
101
+ // -- Observable attribute -------------------------------------------------
102
+
103
+ describe('Observable attribute', () => {
104
+ it('sets attribute from Observable initial value', () => {
105
+ const label = Observable('hello');
106
+ const el = Div({ 'aria-label': label });
107
+ expect(el.getAttribute('aria-label')).toBe('hello');
108
+ });
109
+
110
+ it('updates attribute when Observable changes', () => {
111
+ const label = Observable('hello');
112
+ const el = Div({ 'aria-label': label });
113
+ label.set('world');
114
+ expect(el.getAttribute('aria-label')).toBe('world');
115
+ });
116
+
117
+ it('handles multiple Observable attributes', () => {
118
+ const a = Observable('val-a');
119
+ const b = Observable('val-b');
120
+ const el = Div({ 'data-a': a, 'data-b': b });
121
+ b.set('changed');
122
+ expect(el.getAttribute('data-a')).toBe('val-a');
123
+ expect(el.getAttribute('data-b')).toBe('changed');
124
+ });
125
+ });
126
+
127
+ // -- boolean attributes ---------------------------------------------------
128
+
129
+ describe('boolean attributes', () => {
130
+ it('sets disabled=true on input', () => {
131
+ const el = Input({ type: 'text', disabled: true });
132
+ expect(el.disabled).toBe(true);
133
+ });
134
+
135
+ it('sets disabled=false on input', () => {
136
+ const el = Input({ type: 'text', disabled: false });
137
+ expect(el.disabled).toBe(false);
138
+ });
139
+
140
+ it('toggles aria-disabled reactively via Observable', () => {
141
+ // Use a standard string attribute for reliable reactive testing
142
+ const isDisabled = Observable('false');
143
+ const el = Input({ type: 'text', 'aria-disabled': isDisabled });
144
+ expect(el.getAttribute('aria-disabled')).toBe('false');
145
+ isDisabled.set('true');
146
+ expect(el.getAttribute('aria-disabled')).toBe('true');
147
+ });
148
+
149
+ it('sets checked on checkbox', () => {
150
+ const el = Input({ type: 'checkbox', checked: true });
151
+ expect(el.checked).toBe(true);
152
+ });
153
+ });
154
+
155
+ // -- aria attributes ------------------------------------------------------
156
+
157
+ describe('aria attributes (via this.aria + resolveAriaKey)', () => {
158
+ it('sets aria-label directly', () => {
159
+ const el = Div({ 'aria-label': 'My label' });
160
+ expect(el.getAttribute('aria-label')).toBe('My label');
161
+ });
162
+
163
+ it('sets role attribute', () => {
164
+ const el = Div({ role: 'dialog' });
165
+ expect(el.getAttribute('role')).toBe('dialog');
166
+ });
167
+
168
+ it('sets aria-expanded reactively', () => {
169
+ const expanded = Observable('false');
170
+ const el = Div({ 'aria-expanded': expanded });
171
+ expect(el.getAttribute('aria-expanded')).toBe('false');
172
+ expanded.set('true');
173
+ expect(el.getAttribute('aria-expanded')).toBe('true');
174
+ });
175
+ });
176
+
177
+ });