native-document 1.0.169 → 1.0.173
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/CHANGELOG.md +100 -2
- package/docs/vitepress-conventions.md +2 -2
- package/index.d.ts +1 -1
- package/package.json +11 -4
- package/src/components/BaseComponent.js +43 -1
- package/src/components/accordion/Accordion.js +1 -0
- package/src/components/accordion/AccordionItem.js +1 -0
- package/src/components/alert/Alert.js +4 -0
- package/src/components/avatar/Avatar.js +1 -0
- package/src/components/avatar/AvatarGroup.js +1 -0
- package/src/components/badge/Badge.js +1 -0
- package/src/components/breadcrumb/BreadCrumb.js +4 -0
- package/src/components/button/Button.js +1 -0
- package/src/components/card/Card.js +1 -0
- package/src/components/context-menu/ContextMenu.js +2 -0
- package/src/components/context-menu/ContextMenuGroup.js +2 -0
- package/src/components/context-menu/ContextMenuItem.js +2 -0
- package/src/components/divider/Divider.js +2 -0
- package/src/components/dropdown/Dropdown.js +1 -0
- package/src/components/dropdown/DropdownDivider.js +1 -0
- package/src/components/dropdown/DropdownGroup.js +1 -0
- package/src/components/dropdown/DropdownItem.js +1 -0
- package/src/components/dropdown/DropdownTrigger.js +1 -0
- package/src/components/form/FormControl.js +1 -0
- package/src/components/form/field/Field.js +1 -0
- package/src/components/form/field/types/StringField.js +1 -1
- package/src/components/icon/Icon.js +1 -0
- package/src/components/list/ListItem.js +1 -0
- package/src/components/menu/Menu.js +3 -0
- package/src/components/menu/MenuDivider.js +1 -0
- package/src/components/menu/MenuGroup.js +1 -0
- package/src/components/menu/MenuItem.js +1 -0
- package/src/components/menu/MenuLink.js +1 -0
- package/src/components/modal/Modal.js +4 -0
- package/src/components/pagination/Pagination.js +1 -0
- package/src/components/popover/Popover.js +1 -0
- package/src/components/popover/PopoverFooter.js +1 -0
- package/src/components/popover/PopoverHeader.js +1 -0
- package/src/components/progress/Progress.js +5 -0
- package/src/components/skeleton/Skeleton.js +4 -0
- package/src/components/slider/Slider.js +1 -0
- package/src/components/spacer/Spacer.js +1 -0
- package/src/components/spinner/Spinner.js +1 -0
- package/src/components/splitter/Splitter.js +1 -0
- package/src/components/splitter/SplitterGutter.js +6 -0
- package/src/components/splitter/SplitterPanel.js +1 -0
- package/src/components/stacks/AbsoluteStack.js +1 -0
- package/src/components/stacks/FixedStack.js +1 -0
- package/src/components/stacks/HStack.js +1 -0
- package/src/components/stacks/PositionStack.js +1 -0
- package/src/components/stacks/RelativeStack.js +1 -0
- package/src/components/stacks/Stack.js +1 -0
- package/src/components/stacks/VStack.js +1 -0
- package/src/components/stepper/Stepper.js +1 -0
- package/src/components/stepper/StepperStep.js +1 -0
- package/src/components/switch/Switch.js +1 -0
- package/src/components/table/DataTable.js +1 -0
- package/src/components/table/SimpleTable.js +1 -0
- package/src/components/tabs/Tabs.js +1 -0
- package/src/components/toast/Toast.js +1 -0
- package/src/components/tooltip/Tooltip.js +1 -0
- package/src/core/data/MemoryManager.js +6 -1
- package/src/core/data/ObservableItem.js +33 -15
- package/src/core/data/ObservableObject.js +1 -2
- package/src/core/elements/anchor/anchor.js +4 -3
- package/src/core/elements/anchor/one-child-anchor-overwriting.js +4 -4
- package/src/core/elements/content-formatter.js +19 -0
- package/src/core/elements/control/for-each-array-cache.js +59 -0
- package/src/core/elements/control/for-each-array.js +15 -23
- package/src/core/wrappers/AttributesWrapper.js +12 -12
- package/src/core/wrappers/ElementCreator.js +33 -10
- package/src/core/wrappers/HtmlElementWrapper.js +17 -13
- package/src/core/wrappers/NDElement.js +23 -8
- package/src/core/wrappers/NdPrototype.js +64 -39
- package/src/core/wrappers/constants.js +12 -12
- package/src/core/wrappers/prototypes/nd-element-extensions.js +127 -57
- package/src/core/wrappers/template-cloner/NodeCloner.js +10 -6
- package/src/core/wrappers/template-cloner/TemplateCloner.js +7 -4
- package/src/fetch/NativeFetch.js +1 -0
- package/src/ui/components/accordion/AccordionItemRender.js +29 -7
- package/src/ui/components/alert/AlertRender.js +6 -0
- package/src/ui/components/breadcrumb/BreadcrumbRender.js +9 -1
- package/src/ui/components/button/ButtonRender.js +3 -0
- package/src/ui/components/contextmenu/ContextmenuRender.js +2 -0
- package/src/ui/components/dropdown/DropdownRender.js +3 -0
- package/src/ui/components/dropdown/item/DropdownItemRender.js +6 -1
- package/src/ui/components/form/FieldCollectionRender.js +4 -0
- package/src/ui/components/form/fields/CheckboxFieldRender.js +4 -0
- package/src/ui/components/form/fields/CheckboxGroupFieldRender.js +4 -0
- package/src/ui/components/form/fields/FieldRender.js +1 -1
- package/src/ui/components/form/fields/RadioFieldRender.js +4 -0
- package/src/ui/components/form/fields/RangeFieldRender.js +4 -0
- package/src/ui/components/form/fields/SelectFieldRender.js +5 -0
- package/src/ui/components/form/fields/SliderFieldRender.js +3 -0
- package/src/ui/components/icon/tabler/TablerIconRender.js +0 -1
- package/src/ui/components/list/item/ListItemRender.js +4 -0
- package/src/ui/components/menu/MenuItemRender.js +8 -0
- package/src/ui/components/menu/MenuLinkRender.js +11 -0
- package/src/ui/components/menu/MenuRender.js +5 -0
- package/src/ui/components/menu/helpers.js +2 -1
- package/src/ui/components/modal/ModalRender.js +8 -2
- package/src/ui/components/progress/ProgressRender.js +9 -0
- package/src/ui/components/skeleton/SkeletonRender.js +2 -0
- package/src/ui/components/spinner/SpinnerRender.js +3 -0
- package/src/ui/components/splitter/SplitterGutterRender.js +4 -0
- package/src/ui/components/splitter/SplitterPanelRender.js +4 -0
- package/src/ui/components/splitter/SplitterRender.js +2 -0
- package/src/ui/components/stepper/StepperRender.js +2 -1
- package/src/ui/components/stepper/StepperStepRender.js +8 -1
- package/src/ui/components/switch/SwitchRender.js +3 -0
- package/src/ui/components/table/data-table/DataTableRender.js +4 -0
- package/src/ui/components/table/simple-table/SimpleTableRender.js +2 -0
- package/src/ui/components/toast/ToastRender.js +4 -0
- package/src/ui/utils/aria.js +19 -0
- package/tests/integration/ui/attributes-wrapper.test.js +177 -0
- package/tests/integration/ui/for-each-array.test.js +167 -0
- package/tests/integration/ui/lifecycle.test.js +146 -0
- package/tests/integration/ui/show-if.test.js +168 -0
- package/tests/unit/core/observable-array.test.js +404 -0
- package/tests/unit/core/observable-item.test.js +428 -0
- package/tests/unit/core/observable-object.test.js +239 -0
- package/tests/unit/core/observable-resource.test.js +323 -0
- package/tests/unit/core/store.test.js +347 -0
- package/tests/unit/router/route-matching.test.js +169 -0
- package/tests/unit/router/router-guards.test.js +189 -0
- package/tests/unit/utils/cache.test.js +120 -0
- package/tests/unit/utils/filters.test.js +394 -0
- package/types/elements.d.ts +5 -0
- package/types/native-fetch.d.ts +1 -1
- package/types/observable-resource.d.ts +2 -2
- package/types/observable.d.ts +137 -21
- package/types/service.d.ts +2 -1
- package/utils.js +7 -4
- 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', (
|
|
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
|
-
|
|
70
|
+
|
|
71
|
+
const buildHeader = ($desc, instance, dialog) => {
|
|
70
72
|
const content = [];
|
|
71
73
|
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|