native-document 1.0.168 → 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 (153) hide show
  1. package/CHANGELOG.md +99 -4
  2. package/components.js +3 -1
  3. package/dist/native-document.components.min.js +197 -171
  4. package/dist/native-document.dev.js +92 -75
  5. package/dist/native-document.dev.js.map +1 -1
  6. package/dist/native-document.min.js +1 -1
  7. package/docs/components/icons.md +321 -0
  8. package/docs/vitepress-conventions.md +2 -2
  9. package/package.json +11 -4
  10. package/src/components/BaseComponent.js +43 -1
  11. package/src/components/accordion/Accordion.js +1 -0
  12. package/src/components/accordion/AccordionItem.js +1 -0
  13. package/src/components/alert/Alert.js +4 -0
  14. package/src/components/avatar/Avatar.js +1 -0
  15. package/src/components/avatar/AvatarGroup.js +1 -0
  16. package/src/components/badge/Badge.js +1 -0
  17. package/src/components/breadcrumb/BreadCrumb.js +4 -0
  18. package/src/components/button/Button.js +1 -0
  19. package/src/components/card/Card.js +1 -0
  20. package/src/components/context-menu/ContextMenu.js +2 -0
  21. package/src/components/context-menu/ContextMenuGroup.js +2 -0
  22. package/src/components/context-menu/ContextMenuItem.js +2 -0
  23. package/src/components/divider/Divider.js +2 -0
  24. package/src/components/dropdown/Dropdown.js +1 -0
  25. package/src/components/dropdown/DropdownDivider.js +1 -0
  26. package/src/components/dropdown/DropdownGroup.js +1 -0
  27. package/src/components/dropdown/DropdownItem.js +1 -0
  28. package/src/components/dropdown/DropdownTrigger.js +1 -0
  29. package/src/components/form/FormControl.js +1 -0
  30. package/src/components/form/field/Field.js +1 -0
  31. package/src/components/form/field/types/FileField.js +3 -3
  32. package/src/components/form/field/types/StringField.js +1 -1
  33. package/src/components/icon/Icon.js +108 -0
  34. package/src/components/icon/icon-getters.js +142 -0
  35. package/src/components/icon/icons.js +171 -0
  36. package/src/components/icon/index.js +17 -0
  37. package/src/components/icon/types/Icon.d.ts +191 -0
  38. package/src/components/index.d.ts +6 -1
  39. package/src/components/list/ListItem.js +1 -0
  40. package/src/components/list/types/List.d.ts +45 -32
  41. package/src/components/list/types/ListDivider.ts +16 -0
  42. package/src/components/list/types/ListGroup.d.ts +51 -29
  43. package/src/components/list/types/ListItem.d.ts +20 -21
  44. package/src/components/menu/Menu.js +3 -0
  45. package/src/components/menu/MenuDivider.js +1 -0
  46. package/src/components/menu/MenuGroup.js +1 -0
  47. package/src/components/menu/MenuItem.js +1 -0
  48. package/src/components/menu/MenuLink.js +1 -0
  49. package/src/components/modal/Modal.js +4 -0
  50. package/src/components/pagination/Pagination.js +1 -0
  51. package/src/components/popover/Popover.js +1 -0
  52. package/src/components/popover/PopoverFooter.js +1 -0
  53. package/src/components/popover/PopoverHeader.js +1 -0
  54. package/src/components/progress/Progress.js +5 -0
  55. package/src/components/skeleton/Skeleton.js +4 -0
  56. package/src/components/slider/Slider.js +1 -0
  57. package/src/components/spacer/Spacer.js +1 -0
  58. package/src/components/spinner/Spinner.js +1 -0
  59. package/src/components/splitter/Splitter.js +1 -0
  60. package/src/components/splitter/SplitterGutter.js +6 -0
  61. package/src/components/splitter/SplitterPanel.js +1 -0
  62. package/src/components/stacks/AbsoluteStack.js +1 -0
  63. package/src/components/stacks/FixedStack.js +1 -0
  64. package/src/components/stacks/HStack.js +1 -0
  65. package/src/components/stacks/PositionStack.js +1 -0
  66. package/src/components/stacks/RelativeStack.js +1 -0
  67. package/src/components/stacks/Stack.js +1 -0
  68. package/src/components/stacks/VStack.js +1 -0
  69. package/src/components/stepper/Stepper.js +1 -0
  70. package/src/components/stepper/StepperStep.js +1 -0
  71. package/src/components/switch/Switch.js +1 -0
  72. package/src/components/table/DataTable.js +1 -0
  73. package/src/components/table/SimpleTable.js +1 -0
  74. package/src/components/tabs/Tabs.js +1 -0
  75. package/src/components/toast/Toast.js +1 -0
  76. package/src/components/tooltip/Tooltip.js +1 -0
  77. package/src/core/data/MemoryManager.js +6 -1
  78. package/src/core/data/ObservableItem.js +28 -10
  79. package/src/core/elements/anchor/anchor.js +4 -3
  80. package/src/core/elements/anchor/one-child-anchor-overwriting.js +4 -4
  81. package/src/core/elements/content-formatter.js +35 -2
  82. package/src/core/elements/control/for-each-array-cache.js +59 -0
  83. package/src/core/elements/control/for-each-array.js +15 -23
  84. package/src/core/elements/form.js +1 -1
  85. package/src/core/elements/img.js +1 -1
  86. package/src/core/elements/medias.js +2 -2
  87. package/src/core/elements/meta-data.js +1 -1
  88. package/src/core/wrappers/AttributesWrapper.js +44 -29
  89. package/src/core/wrappers/ElementCreator.js +38 -22
  90. package/src/core/wrappers/HtmlElementWrapper.js +33 -9
  91. package/src/core/wrappers/NDElement.js +35 -9
  92. package/src/core/wrappers/NdPrototype.js +31 -39
  93. package/src/core/wrappers/constants.js +12 -12
  94. package/src/core/wrappers/prototypes/attributes-extensions.js +24 -24
  95. package/src/core/wrappers/prototypes/nd-element-extensions.js +128 -65
  96. package/src/ui/components/accordion/AccordionItemRender.js +29 -7
  97. package/src/ui/components/alert/AlertRender.js +6 -0
  98. package/src/ui/components/breadcrumb/BreadcrumbRender.js +9 -1
  99. package/src/ui/components/button/ButtonRender.js +3 -0
  100. package/src/ui/components/contextmenu/ContextmenuRender.js +2 -0
  101. package/src/ui/components/dropdown/DropdownRender.js +3 -0
  102. package/src/ui/components/dropdown/item/DropdownItemRender.js +6 -1
  103. package/src/ui/components/form/FieldCollectionRender.js +4 -0
  104. package/src/ui/components/form/fields/CheckboxFieldRender.js +4 -0
  105. package/src/ui/components/form/fields/CheckboxGroupFieldRender.js +4 -0
  106. package/src/ui/components/form/fields/RadioFieldRender.js +4 -0
  107. package/src/ui/components/form/fields/RangeFieldRender.js +4 -0
  108. package/src/ui/components/form/fields/SelectFieldRender.js +5 -0
  109. package/src/ui/components/form/fields/SliderFieldRender.js +3 -0
  110. package/src/ui/components/icon/material/MaterialIconRender.js +71 -0
  111. package/src/ui/components/icon/material/material.css +15 -0
  112. package/src/ui/components/icon/material/material.map.js +170 -0
  113. package/src/ui/components/icon/phosphor/PhosphorIconRender.js +71 -0
  114. package/src/ui/components/icon/phosphor/phosphor.css +17 -0
  115. package/src/ui/components/icon/phosphor/phosphor.map.js +165 -0
  116. package/src/ui/components/icon/tabler/TablerIconRender.js +58 -0
  117. package/src/ui/components/icon/tabler/tabler.css +14 -0
  118. package/src/ui/components/icon/tabler/tabler.map.js +165 -0
  119. package/src/ui/components/list/item/ListItemRender.js +4 -0
  120. package/src/ui/components/menu/MenuItemRender.js +8 -0
  121. package/src/ui/components/menu/MenuLinkRender.js +11 -0
  122. package/src/ui/components/menu/MenuRender.js +5 -0
  123. package/src/ui/components/menu/helpers.js +2 -1
  124. package/src/ui/components/modal/ModalRender.js +8 -2
  125. package/src/ui/components/progress/ProgressRender.js +9 -0
  126. package/src/ui/components/skeleton/SkeletonRender.js +2 -0
  127. package/src/ui/components/spinner/SpinnerRender.js +3 -0
  128. package/src/ui/components/splitter/SplitterGutterRender.js +4 -0
  129. package/src/ui/components/splitter/SplitterPanelRender.js +4 -0
  130. package/src/ui/components/splitter/SplitterRender.js +2 -0
  131. package/src/ui/components/stepper/StepperRender.js +2 -1
  132. package/src/ui/components/stepper/StepperStepRender.js +8 -1
  133. package/src/ui/components/switch/SwitchRender.js +3 -0
  134. package/src/ui/components/table/data-table/DataTableRender.js +4 -0
  135. package/src/ui/components/table/simple-table/SimpleTableRender.js +2 -0
  136. package/src/ui/components/toast/ToastRender.js +4 -0
  137. package/src/ui/index.js +5 -0
  138. package/src/ui/utils/aria.js +19 -0
  139. package/tests/integration/ui/attributes-wrapper.test.js +177 -0
  140. package/tests/integration/ui/for-each-array.test.js +167 -0
  141. package/tests/integration/ui/lifecycle.test.js +146 -0
  142. package/tests/integration/ui/show-if.test.js +168 -0
  143. package/tests/unit/core/observable-array.test.js +404 -0
  144. package/tests/unit/core/observable-item.test.js +428 -0
  145. package/tests/unit/core/observable-object.test.js +239 -0
  146. package/tests/unit/core/observable-resource.test.js +323 -0
  147. package/tests/unit/core/store.test.js +347 -0
  148. package/tests/unit/router/route-matching.test.js +169 -0
  149. package/tests/unit/router/router-guards.test.js +189 -0
  150. package/tests/unit/utils/cache.test.js +120 -0
  151. package/tests/unit/utils/filters.test.js +394 -0
  152. package/types/elements.d.ts +5 -0
  153. package/vitest.config.js +15 -0
@@ -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) {
package/src/ui/index.js CHANGED
@@ -43,5 +43,10 @@ export { default as ListDividerRender } from './components/list/divider/ListDivi
43
43
 
44
44
  export { default as CardRender } from './components/card/CardRender';
45
45
 
46
+
47
+ export { default as TablerIconRender } from './components/icon/tabler/TablerIconRender';
48
+ export { default as MaterialIconRender } from './components/icon/material/MaterialIconRender';
49
+ export { default as PhosphorIconRender } from './components/icon/phosphor/PhosphorIconRender';
50
+
46
51
  export * from './components/stacks';
47
52
  export * from './components/form';
@@ -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
+ });
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Observable } from '../../../src/core/data/Observable';
3
+ import ObservableArray from '../../../src/core/data/ObservableArray';
4
+
5
+ vi.mock('../../../src/core/utils/debug-manager', () => ({
6
+ default: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), disable: vi.fn() },
7
+ }));
8
+
9
+ import { Div, Span, ForEachArray } from '../../../elements';
10
+
11
+ const mount = (anchor) => {
12
+ document.body.innerHTML = '';
13
+ document.body.appendChild(anchor);
14
+ return document.body;
15
+ };
16
+
17
+ const items = (arr = []) => new ObservableArray(arr);
18
+
19
+ describe('ForEachArray', () => {
20
+
21
+ // -- static array ---------------------------------------------------------
22
+
23
+ describe('static array', () => {
24
+ it('renders one element per item', () => {
25
+ const anchor = ForEachArray(['a', 'b', 'c'], (item) => Div({}, item));
26
+ const body = mount(anchor);
27
+ expect(body.querySelectorAll('div').length).toBe(3);
28
+ });
29
+
30
+ it('renders items in order', () => {
31
+ const anchor = ForEachArray(['x', 'y', 'z'], (item) => Div({}, item));
32
+ const body = mount(anchor);
33
+ const divs = body.querySelectorAll('div');
34
+ expect(divs[0].textContent).toBe('x');
35
+ expect(divs[1].textContent).toBe('y');
36
+ expect(divs[2].textContent).toBe('z');
37
+ });
38
+
39
+ it('renders empty array without error', () => {
40
+ const anchor = ForEachArray([], (item) => Div({}, item));
41
+ const body = mount(anchor);
42
+ expect(body.querySelectorAll('div').length).toBe(0);
43
+ });
44
+ });
45
+
46
+ // -- ObservableArray initial render ---------------------------------------
47
+
48
+ describe('ObservableArray initial render', () => {
49
+ it('renders items from initial ObservableArray', () => {
50
+ const obs = items(['a', 'b', 'c']);
51
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
52
+ const body = mount(anchor);
53
+ expect(body.querySelectorAll('div').length).toBe(3);
54
+ });
55
+
56
+ it('renders empty ObservableArray without error', () => {
57
+ const obs = items([]);
58
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
59
+ const body = mount(anchor);
60
+ expect(body.querySelectorAll('div').length).toBe(0);
61
+ });
62
+ });
63
+
64
+ // -- push() ---------------------------------------------------------------
65
+
66
+ describe('push()', () => {
67
+ it('adds a rendered element to the DOM', () => {
68
+ const obs = items(['a', 'b']);
69
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
70
+ const body = mount(anchor);
71
+ obs.push('c');
72
+ expect(body.querySelectorAll('div').length).toBe(3);
73
+ });
74
+
75
+ it('appended item appears last', () => {
76
+ const obs = items(['a', 'b']);
77
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
78
+ const body = mount(anchor);
79
+ obs.push('c');
80
+ const divs = body.querySelectorAll('div');
81
+ expect(divs[divs.length - 1].textContent).toBe('c');
82
+ });
83
+ });
84
+
85
+ // -- removeItem() ---------------------------------------------------------
86
+
87
+ describe('removeItem()', () => {
88
+ it('removes the corresponding element', () => {
89
+ const obs = items(['a', 'b', 'c']);
90
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
91
+ const body = mount(anchor);
92
+ obs.removeItem('b');
93
+ expect(body.querySelectorAll('div').length).toBe(2);
94
+ expect(body.textContent).not.toContain('b');
95
+ });
96
+ });
97
+
98
+ // -- clear() --------------------------------------------------------------
99
+
100
+ describe('clear()', () => {
101
+ it('removes all elements', () => {
102
+ const obs = items(['a', 'b', 'c']);
103
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
104
+ const body = mount(anchor);
105
+ obs.clear();
106
+ expect(body.querySelectorAll('div').length).toBe(0);
107
+ });
108
+ });
109
+
110
+ // -- set() ----------------------------------------------------------------
111
+
112
+ describe('set()', () => {
113
+ it('replaces all elements', () => {
114
+ const obs = items(['a', 'b']);
115
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
116
+ const body = mount(anchor);
117
+ obs.set(['x', 'y', 'z']);
118
+ const divs = body.querySelectorAll('div');
119
+ expect(divs.length).toBe(3);
120
+ expect(divs[0].textContent).toBe('x');
121
+ });
122
+ });
123
+
124
+ // -- swap() ---------------------------------------------------------------
125
+
126
+ describe('swap()', () => {
127
+ it('swaps two items in the DOM', () => {
128
+ const obs = items(['a', 'b', 'c']);
129
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
130
+ const body = mount(anchor);
131
+ obs.swap(0, 2);
132
+ const divs = body.querySelectorAll('div');
133
+ expect(divs[0].textContent).toBe('c');
134
+ expect(divs[2].textContent).toBe('a');
135
+ });
136
+ });
137
+
138
+ // -- index observable -----------------------------------------------------
139
+
140
+ describe('index observable', () => {
141
+ it('passes index observable when callback has 2 params', () => {
142
+ const obs = items(['a', 'b', 'c']);
143
+ const indices = [];
144
+ const anchor = ForEachArray(obs, (item, index) => {
145
+ indices.push(index);
146
+ return Div({}, item);
147
+ });
148
+ mount(anchor);
149
+ expect(indices.length).toBe(3);
150
+ });
151
+ });
152
+
153
+ // -- order preservation ---------------------------------------------------
154
+
155
+ describe('order preservation', () => {
156
+ it('preserves order after multiple mutations', () => {
157
+ const obs = items(['a', 'b', 'c']);
158
+ const anchor = ForEachArray(obs, (item) => Div({}, item));
159
+ const body = mount(anchor);
160
+ obs.push('d');
161
+ obs.removeItem('b');
162
+ const divs = body.querySelectorAll('div');
163
+ expect([...divs].map((d) => d.textContent)).toEqual(['a', 'c', 'd']);
164
+ });
165
+ });
166
+
167
+ });