ngx-devextreme-zoneless 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ricardo Gross
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# ngx-devextreme-zoneless
|
|
2
|
+
|
|
3
|
+
Strongly-typed **signal & zoneless change-detection adapters** for
|
|
4
|
+
[DevExtreme Angular](https://js.devexpress.com/Angular/) components.
|
|
5
|
+
|
|
6
|
+
DevExtreme widgets mutate their own state (user typing, row selection, popup
|
|
7
|
+
closing, chart point clicks, …) through their internal option system. Under
|
|
8
|
+
zoneless change detection nothing tells Angular about those mutations. This
|
|
9
|
+
library bridges the gap with **signals**: every adapter writes widget changes
|
|
10
|
+
into a signal (which schedules zoneless CD natively) and pushes signal changes
|
|
11
|
+
back into the widget — with echo suppression, structural equality, optional
|
|
12
|
+
debouncing and automatic teardown.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Angular signal ──effect──▶ widget option / method
|
|
16
|
+
Angular signal ◀──set──── widget event (optionChanged, selectionChanged, …)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **Angular** ≥ 20 (built and verified against Angular 22, zoneless by default)
|
|
20
|
+
- **DevExtreme / devextreme-angular** ≥ 25.1 (built and verified against 26.1)
|
|
21
|
+
- **TypeScript** 6.0, `strict`, zero `any` in the public surface
|
|
22
|
+
|
|
23
|
+
Everything is *derived from DevExtreme's own `Properties` declarations* via
|
|
24
|
+
template-literal and conditional types — option names, option value types and
|
|
25
|
+
event payload types are string-literal unions that automatically follow the
|
|
26
|
+
installed DevExtreme version. A typo'd option name is a compile error.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm i ngx-devextreme-zoneless
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { Component, signal } from '@angular/core';
|
|
38
|
+
import { DxTextBoxModule } from 'devextreme-angular/ui/text-box';
|
|
39
|
+
import { DxDataGridModule } from 'devextreme-angular/ui/data-grid';
|
|
40
|
+
import {
|
|
41
|
+
DxTextBoxValueDirective,
|
|
42
|
+
DxDataGridSelectedRowKeysDirective,
|
|
43
|
+
} from 'ngx-devextreme-zoneless';
|
|
44
|
+
|
|
45
|
+
@Component({
|
|
46
|
+
imports: [DxTextBoxModule, DxDataGridModule,
|
|
47
|
+
DxTextBoxValueDirective, DxDataGridSelectedRowKeysDirective],
|
|
48
|
+
template: `
|
|
49
|
+
<dx-text-box [(dxValue)]="search" [dxValueDebounce]="300" />
|
|
50
|
+
<dx-data-grid [(dxSelectedRowKeys)]="selected" [dataSource]="orders" keyExpr="id">
|
|
51
|
+
<dxo-selection mode="multiple" />
|
|
52
|
+
</dx-data-grid>
|
|
53
|
+
`,
|
|
54
|
+
})
|
|
55
|
+
export class OrdersComponent {
|
|
56
|
+
readonly search = signal('');
|
|
57
|
+
readonly selected = signal<readonly number[]>([]); // TKey inferred as number
|
|
58
|
+
readonly orders = [{ id: 1 }, { id: 2 }];
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or import everything at once with `DX_SIGNAL_DIRECTIVES` (also available per
|
|
63
|
+
family: `DX_VALUE_DIRECTIVES`, `DX_NAVIGATION_DIRECTIVES`,
|
|
64
|
+
`DX_VISIBLE_DIRECTIVES`, `DX_DATA_DIRECTIVES`, `DX_VISUALIZATION_DIRECTIVES`).
|
|
65
|
+
|
|
66
|
+
Components without a dedicated directive (PivotGrid, Sortable, Menu, …) are
|
|
67
|
+
covered too — see [Composition API](#composition-api): the same engine works
|
|
68
|
+
with **any** option and event of **any** DevExtreme component, fully typed.
|
|
69
|
+
|
|
70
|
+
## Directive catalog
|
|
71
|
+
|
|
72
|
+
### Editors — `[(dxValue)]`
|
|
73
|
+
|
|
74
|
+
Every value editor gets a `[(dxValue)]` two-way model plus `dxValueDebounce`
|
|
75
|
+
(ms, widget → signal direction) and `dxValueEqual` (custom comparer) inputs.
|
|
76
|
+
|
|
77
|
+
| Component | Model type |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `dx-text-box`, `dx-text-area`, `dx-html-editor` | `string` |
|
|
80
|
+
| `dx-number-box` | `number \| null` |
|
|
81
|
+
| `dx-check-box` | `boolean \| null` |
|
|
82
|
+
| `dx-switch` | `boolean` |
|
|
83
|
+
| `dx-date-box` | `Date \| number \| string \| null` |
|
|
84
|
+
| `dx-date-range-box` | `readonly [start: DxDateValue \| null, end: DxDateValue \| null]` |
|
|
85
|
+
| `dx-calendar` | `DxDateValue \| readonly DxDateValue[] \| null` |
|
|
86
|
+
| `dx-color-box`, `dx-autocomplete` | `string \| null` |
|
|
87
|
+
| `dx-slider` | `number` |
|
|
88
|
+
| `dx-range-slider` | `readonly [start: number, end: number]` |
|
|
89
|
+
| `dx-select-box`, `dx-lookup`, `dx-drop-down-box`, `dx-radio-group` | generic `TValue` — inferred from your signal |
|
|
90
|
+
| `dx-tag-box` | generic `readonly TKey[]` |
|
|
91
|
+
| `dx-filter-builder` | `DxFilterExpression \| null` |
|
|
92
|
+
| `dx-range-selector` | DevExtreme's declared scale range type |
|
|
93
|
+
|
|
94
|
+
Where DevExtreme declares `value?: any` (SelectBox & friends) the directive is
|
|
95
|
+
**generic** instead, so `[(dxValue)]="status"` with a
|
|
96
|
+
`signal<OrderStatus | null>` is checked end-to-end.
|
|
97
|
+
|
|
98
|
+
### Selection & data
|
|
99
|
+
|
|
100
|
+
| Selector | Model |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `dx-data-grid[dxSelectedRowKeys]`, `dx-tree-list[dxSelectedRowKeys]` | `readonly TKey[]` |
|
|
103
|
+
| `dx-list[dxSelectedItemKeys]` | `readonly TKey[]` |
|
|
104
|
+
| `dx-tree-view[dxSelectedNodeKeys]` | `readonly TKey[]` (method-based: `getSelectedNodeKeys` / `selectItem`) |
|
|
105
|
+
| `dx-gantt[dxSelectedRowKey]` | `TKey \| null` |
|
|
106
|
+
| `dx-scheduler[dxCurrentDate]` | `Date \| number \| string` |
|
|
107
|
+
| `dx-scheduler[dxCurrentView]` | DevExtreme's view union |
|
|
108
|
+
| `dx-form[dxFormData]` | generic `TFormData` — emits a fresh shallow copy per field edit |
|
|
109
|
+
|
|
110
|
+
### Navigation & overlays
|
|
111
|
+
|
|
112
|
+
| Selector | Model |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `dx-tabs`, `dx-tab-panel`, `dx-accordion`, `dx-gallery` + `[dxSelectedIndex]` | `number` |
|
|
115
|
+
| `dx-drawer[dxOpened]` | `boolean` |
|
|
116
|
+
| `dx-popup`, `dx-popover`, `dx-tooltip`, `dx-toast`, `dx-load-panel`, `dx-action-sheet` + `[dxVisible]` | `boolean` |
|
|
117
|
+
|
|
118
|
+
`[(dxVisible)]` tracks *every* way an overlay can close (shading click, escape,
|
|
119
|
+
close button, API) — the classic zoneless pain point.
|
|
120
|
+
|
|
121
|
+
### Visualization
|
|
122
|
+
|
|
123
|
+
| Selector | Model |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `dx-chart[dxSelectedPoints]`, `dx-pie-chart[dxSelectedPoints]` | `readonly DxChartSelectedPoint[]` |
|
|
126
|
+
|
|
127
|
+
Chart selection is method-based; selected points are exposed as serializable
|
|
128
|
+
`{ seriesName, argument, value }` descriptors that can be stored and restored.
|
|
129
|
+
DevExtreme charts do not select points on click by themselves (their demos
|
|
130
|
+
wire `onPointClick: e => e.target.select()` manually), so these directives
|
|
131
|
+
toggle the clicked point's selection by default — opt out with
|
|
132
|
+
`[dxPointClickToggle]="false"` if you want to handle `onPointClick` yourself.
|
|
133
|
+
|
|
134
|
+
## Composition API
|
|
135
|
+
|
|
136
|
+
The directives are sugar. The same engine is available as injection-context
|
|
137
|
+
functions and covers **every option and event of every DevExtreme component**,
|
|
138
|
+
fully typed:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import {
|
|
142
|
+
bindDxOption, dxOptionSignal, dxEventSignal, injectDxInstance,
|
|
143
|
+
} from 'ngx-devextreme-zoneless';
|
|
144
|
+
|
|
145
|
+
@Component({ /* ... */ })
|
|
146
|
+
export class MyComponent {
|
|
147
|
+
// On the same element (inside a directive):
|
|
148
|
+
private readonly textBox = inject(DxTextBoxComponent, { self: true });
|
|
149
|
+
|
|
150
|
+
// Two-way signal for any option — names & types from DevExtreme Properties:
|
|
151
|
+
readonly placeholder = dxOptionSignal(this.textBox, 'placeholder');
|
|
152
|
+
readonly mode = dxOptionSignal(this.textBox, 'mode', { initialValue: 'search' });
|
|
153
|
+
|
|
154
|
+
// Latest payload of any widget event:
|
|
155
|
+
readonly focusOut = dxEventSignal(this.textBox, 'focusOut');
|
|
156
|
+
|
|
157
|
+
// Works with view children too (pass the viewChild signal itself):
|
|
158
|
+
private readonly grid = viewChild(DxDataGridComponent<Order, number>);
|
|
159
|
+
readonly filter = dxOptionSignal(this.grid, 'filterValue');
|
|
160
|
+
|
|
161
|
+
// Bind an existing signal to an option:
|
|
162
|
+
readonly keys = signal<number[] | undefined>([]);
|
|
163
|
+
constructor() {
|
|
164
|
+
bindDxOption(this.grid, 'selectedRowKeys', this.keys);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The raw widget instance as a signal (undefined until created):
|
|
168
|
+
readonly widget = injectDxInstance(DxTextBoxComponent);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Components without a dedicated directive
|
|
173
|
+
|
|
174
|
+
The coverage model is simple: **a directive exists wherever a widget has
|
|
175
|
+
genuine user-mutable two-way state** (value, selection, visibility, index,
|
|
176
|
+
current date/view, form data, chart points). Every other component — and any
|
|
177
|
+
component DevExtreme ships in the future — is still fully supported through
|
|
178
|
+
the composition API, because `dxOptionSignal`, `dxEventSignal` and
|
|
179
|
+
`bindDxOption` are not written against a component list: their option names,
|
|
180
|
+
option value types and event payloads are computed from the `Properties`
|
|
181
|
+
declaration of whatever host component you hand them. Support doesn't
|
|
182
|
+
cliff-edge at the directive catalog; it just gets one notch less sugary.
|
|
183
|
+
|
|
184
|
+
A few real-world cases:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// PivotGrid — no two-way options (its mutable state lives in the
|
|
188
|
+
// PivotGridDataSource), but every event and option is reachable, typed:
|
|
189
|
+
private readonly pivot = inject(DxPivotGridComponent, { self: true });
|
|
190
|
+
readonly cellClick = dxEventSignal(this.pivot, 'cellClick');
|
|
191
|
+
readonly contextMenu = dxEventSignal(this.pivot, 'contextMenuPreparing');
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
// Scheduler beyond [(dxCurrentDate)] / [(dxCurrentView)] — appointment
|
|
196
|
+
// lifecycle as signals:
|
|
197
|
+
private readonly scheduler = inject(DxSchedulerComponent, { self: true });
|
|
198
|
+
readonly appointmentAdded = dxEventSignal(this.scheduler, 'appointmentAdded');
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// Sortable (e.g. a Kanban board built from dx-sortable + lists) — purely
|
|
203
|
+
// event-driven, so state stays in your signals and events drive mutations:
|
|
204
|
+
private readonly sortable = viewChild(DxSortableComponent);
|
|
205
|
+
readonly reorder = dxEventSignal(this.sortable, 'reorder');
|
|
206
|
+
constructor() {
|
|
207
|
+
effect(() => {
|
|
208
|
+
const e = this.reorder();
|
|
209
|
+
if (e) this.cards.update((cards) => moveItem(cards, e.fromIndex, e.toIndex));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
// Any option of any widget, two-way — e.g. Menu, ButtonGroup, Toolbar, ...:
|
|
216
|
+
readonly disabled = dxOptionSignal(this.menu, 'disabled');
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
For state that is neither an option nor a single event (method-based APIs),
|
|
220
|
+
implement a `DxBindingAdapter` and pass it to `bindDxState` — that is exactly
|
|
221
|
+
how the chart-selection and tree-view directives are built. And if you want
|
|
222
|
+
template sugar for a component we don't cover, a custom directive is ~10
|
|
223
|
+
lines by extending `DxValueDirectiveBase`, `DxVisibleDirectiveBase`, etc.
|
|
224
|
+
|
|
225
|
+
## Semantics
|
|
226
|
+
|
|
227
|
+
- **Initial sync** (`initialSync`, default `'auto'`): the signal's value is
|
|
228
|
+
pushed into the widget on attach; if it is `undefined`, the widget's current
|
|
229
|
+
value is pulled into the signal instead.
|
|
230
|
+
- **Echo suppression**: updates only propagate when the value *structurally*
|
|
231
|
+
changed (`Date`-aware, deep for arrays/plain objects). Override per binding
|
|
232
|
+
via `equal` / `dxValueEqual`.
|
|
233
|
+
- **Debounce** applies to the widget → signal direction only, so typing into a
|
|
234
|
+
`dx-text-box` doesn't storm your `computed()` graph.
|
|
235
|
+
- **Teardown** is automatic via `DestroyRef` — event handlers are detached and
|
|
236
|
+
effects destroyed when the directive/component dies.
|
|
237
|
+
- **Zoneless**: no `NgZone`, no `markForCheck` — signal writes are the change
|
|
238
|
+
notification.
|
|
239
|
+
|
|
240
|
+
## Type helpers
|
|
241
|
+
|
|
242
|
+
All building blocks are exported, e.g.:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
DxHostOptionName<DxTextBoxComponent> // 'value' | 'placeholder' | 'mode' | ...
|
|
246
|
+
DxHostOptionValue<DxTextBoxComponent, 'mode'> // 'email' | 'password' | ... | undefined
|
|
247
|
+
DxHostEventName<DxDataGridComponent> // 'rowClick' | 'selectionChanged' | ...
|
|
248
|
+
DxHostEventArg<DxTextBoxComponent, 'valueChanged'>
|
|
249
|
+
DxHostValue<DxCheckBoxComponent> // boolean | null
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
npm run build # ng-packagr build → dist/
|
|
256
|
+
npm run typecheck:examples # strict-template check of examples/ + type contract tests
|
|
257
|
+
npm run verify # both
|
|
258
|
+
npm run smoke # runtime smoke test app on http://localhost:4299
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The smoke app ([smoke/src/smoke.component.ts](smoke/src/smoke.component.ts))
|
|
262
|
+
boots with `provideZonelessChangeDetection()` — zone.js is not even installed
|
|
263
|
+
in this workspace — and exercises text box, select box, check box, slider,
|
|
264
|
+
tabs, grid selection, popup visibility, chart point selection and the
|
|
265
|
+
composition API in a real browser, with `data-testid` readouts for scripted
|
|
266
|
+
verification of both binding directions.
|
|
267
|
+
|
|
268
|
+
See [examples/order-dashboard.component.ts](examples/order-dashboard.component.ts)
|
|
269
|
+
for a complete signal-driven dashboard and
|
|
270
|
+
[examples/type-tests.ts](examples/type-tests.ts) for the compile-time contract
|
|
271
|
+
tests.
|