@helfy/helfy 0.0.6 → 0.0.8
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/README.md +341 -414
- package/package.json +2 -3
- package/dist/ts-plugin/index.js +0 -12
package/README.md
CHANGED
|
@@ -1,52 +1,72 @@
|
|
|
1
1
|
# Helfy
|
|
2
2
|
|
|
3
|
-
Helfy
|
|
3
|
+
Helfy — a TypeScript UI framework with a decorator-oriented API, custom JSX, and fine-grained reactivity.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Components are defined as classes with the `@View` decorator, state is managed via `@state` and `@observable`, and templates use extended JSX with `@if`, `@for`, and `@else` directives. Reactivity is built on signals (similar to SolidJS): `render()` runs once, and subsequent DOM updates are granular — only nodes that read the changed signal are updated.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Table of contents
|
|
8
8
|
|
|
9
|
-
- [
|
|
10
|
-
- [
|
|
11
|
-
- [
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Components](#components)
|
|
11
|
+
- [Basic component](#basic-component)
|
|
12
12
|
- [Props](#props)
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
- [
|
|
16
|
-
- [
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
13
|
+
- [Local state (@state)](#local-state-state)
|
|
14
|
+
- [Nested components](#nested-components)
|
|
15
|
+
- [DOM mounting](#dom-mounting)
|
|
16
|
+
- [DOM and component refs (@ref)](#dom-and-component-refs-ref)
|
|
17
|
+
- [Two-way binding (@bind)](#two-way-binding-bind)
|
|
18
|
+
- [Forms (@Form, @field, @field in JSX)](#forms-form-field-field-in-jsx)
|
|
19
|
+
- [Template directives](#template-directives)
|
|
20
20
|
- [@if / @elseif / @else](#if--elseif--else)
|
|
21
21
|
- [@for](#for)
|
|
22
22
|
- [@empty](#empty)
|
|
23
23
|
- [State Management](#state-management)
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [
|
|
24
|
+
- [Creating a Store](#creating-a-store)
|
|
25
|
+
- [Subscribing with @observe](#subscribing-with-observe)
|
|
26
|
+
- [Store context](#store-context)
|
|
27
|
+
- [Logging (@logger)](#logging-logger)
|
|
28
28
|
- [Context & DI](#context--di)
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
31
|
-
- [
|
|
32
|
-
- [
|
|
33
|
-
- [DI Container](#di-container
|
|
29
|
+
- [Provider (@Context, @provide)](#provider-context-provide)
|
|
30
|
+
- [Consumer (@ctx)](#consumer-ctx)
|
|
31
|
+
- [Reactive fields](#reactive-fields)
|
|
32
|
+
- [Optional injection](#optional-injection)
|
|
33
|
+
- [DI Container](#di-container-constructor-injection)
|
|
34
34
|
- [JSX](#jsx)
|
|
35
|
-
- [
|
|
36
|
-
- [
|
|
37
|
-
- [
|
|
38
|
-
- [CSS
|
|
35
|
+
- [Attributes](#attributes)
|
|
36
|
+
- [Events](#events)
|
|
37
|
+
- [Styles](#styles)
|
|
38
|
+
- [CSS classes](#css-classes)
|
|
39
39
|
- [API](#api)
|
|
40
40
|
|
|
41
41
|
---
|
|
42
42
|
|
|
43
|
-
##
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
### Creating a new project (helfy-create)
|
|
46
|
+
|
|
47
|
+
Quick start — create a project with a single command:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx helfy-create my-app
|
|
51
|
+
cd my-app
|
|
52
|
+
npm run dev
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Without a project name (creates `helfy-app`):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx helfy-create
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `--skip-install` option — create without running `npm install` (e.g. for offline use or custom registry).
|
|
62
|
+
|
|
63
|
+
### Adding to an existing project
|
|
44
64
|
|
|
45
65
|
```bash
|
|
46
66
|
npm install @helfy/helfy
|
|
47
67
|
```
|
|
48
68
|
|
|
49
|
-
|
|
69
|
+
Or in `package.json`:
|
|
50
70
|
|
|
51
71
|
```json
|
|
52
72
|
{
|
|
@@ -58,11 +78,11 @@ npm install @helfy/helfy
|
|
|
58
78
|
|
|
59
79
|
### Build setup
|
|
60
80
|
|
|
61
|
-
Babel
|
|
81
|
+
The Babel plugin runs the DI scanner before transformation (no pre-scripts in `package.json` needed). The scanner generates `.helfy/di-tokens.ts` and `.helfy/di-registry.ts`. Add `.helfy` to `.gitignore`.
|
|
62
82
|
|
|
63
83
|
### Babel
|
|
64
84
|
|
|
65
|
-
|
|
85
|
+
A single preset in `.babelrc` is enough:
|
|
66
86
|
|
|
67
87
|
```json
|
|
68
88
|
{
|
|
@@ -70,11 +90,11 @@ Babel-плагин автоматически запускает DI-сканер
|
|
|
70
90
|
}
|
|
71
91
|
```
|
|
72
92
|
|
|
73
|
-
|
|
93
|
+
The preset includes: JSX runtime, TypeScript, legacy decorators, class properties, babel-plugin-transform-typescript-metadata, **helfy-di** (compile-time DI for `@Injectable<IX>()`, auto-registration at `createApp`, `@logger()` → `@logger("<ClassName>")` transformation).
|
|
74
94
|
|
|
75
95
|
### Webpack
|
|
76
96
|
|
|
77
|
-
|
|
97
|
+
To process directives (`@if`, `@for`, `@ref`, `@bind`, `@field`) in `.tsx` files, add the loader:
|
|
78
98
|
|
|
79
99
|
```javascript
|
|
80
100
|
{
|
|
@@ -88,7 +108,7 @@ Babel-плагин автоматически запускает DI-сканер
|
|
|
88
108
|
|
|
89
109
|
### TypeScript
|
|
90
110
|
|
|
91
|
-
|
|
111
|
+
In `tsconfig.json` set:
|
|
92
112
|
|
|
93
113
|
```json
|
|
94
114
|
{
|
|
@@ -96,20 +116,20 @@ Babel-плагин автоматически запускает DI-сканер
|
|
|
96
116
|
"jsx": "preserve",
|
|
97
117
|
"experimentalDecorators": true,
|
|
98
118
|
"emitDecoratorMetadata": true,
|
|
99
|
-
"plugins": [{ "name": "@helfy/helfy
|
|
119
|
+
"plugins": [{ "name": "@helfy/helfy-ts-plugin" }]
|
|
100
120
|
}
|
|
101
121
|
}
|
|
102
122
|
```
|
|
103
123
|
|
|
104
|
-
|
|
124
|
+
The `@helfy/helfy-ts-plugin` plugin provides IDE support for `@if`, `@for`, `@ref`, `@bind`, `@field` directives (autocomplete, typing, go-to-definition). Install separately: `npm install @helfy/helfy-ts-plugin`.
|
|
105
125
|
|
|
106
126
|
---
|
|
107
127
|
|
|
108
|
-
##
|
|
128
|
+
## Components
|
|
109
129
|
|
|
110
|
-
###
|
|
130
|
+
### Basic component
|
|
111
131
|
|
|
112
|
-
|
|
132
|
+
A component is a class with the `@View` decorator and a `render()` method that returns JSX:
|
|
113
133
|
|
|
114
134
|
```tsx
|
|
115
135
|
import { View } from "@helfy/helfy";
|
|
@@ -126,19 +146,19 @@ class Hello {
|
|
|
126
146
|
}
|
|
127
147
|
```
|
|
128
148
|
|
|
129
|
-
|
|
130
|
-
-
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
149
|
+
The `@View` decorator automatically:
|
|
150
|
+
- calls `render()` **once** and mounts the result into a DOM fragment
|
|
151
|
+
- adds a `view` property (reference to the root DOM element)
|
|
152
|
+
- wraps `this.props` in a reactive Proxy (each prop is a signal)
|
|
153
|
+
- configures fine-grained effects: when a signal changes, only the specific DOM node that reads it is updated, not the entire component
|
|
134
154
|
|
|
135
|
-
>
|
|
155
|
+
> **Important:** When inheriting from `@View`, apply the decorator on the child class as well. Otherwise `@ctx`, `scheduleUpdate`, and DOM updates will not work.
|
|
136
156
|
|
|
137
157
|
### Props
|
|
138
158
|
|
|
139
|
-
Props
|
|
159
|
+
Props are passed via the constructor. The `@View` decorator wraps `this.props` in a reactive Proxy — each prop becomes a signal. This means that when the parent updates props, only the DOM nodes that read them are updated, without a full re-render.
|
|
140
160
|
|
|
141
|
-
>
|
|
161
|
+
> **Important:** Do not destructure `this.props` in `render()`. Access fields directly via `this.props.field` — this ensures correct signal subscription.
|
|
142
162
|
|
|
143
163
|
```tsx
|
|
144
164
|
import { View } from "@helfy/helfy";
|
|
@@ -166,18 +186,17 @@ class Button {
|
|
|
166
186
|
}
|
|
167
187
|
```
|
|
168
188
|
|
|
169
|
-
|
|
189
|
+
Usage:
|
|
170
190
|
|
|
171
191
|
```tsx
|
|
172
|
-
<Button label="
|
|
192
|
+
<Button label="Click" onClick={() => console.log('clicked')} />
|
|
173
193
|
```
|
|
174
194
|
|
|
175
|
-
|
|
195
|
+
When the parent updates props, only the DOM nodes that read the changed props are updated.
|
|
176
196
|
|
|
177
|
-
###
|
|
197
|
+
### Local state (@state)
|
|
178
198
|
|
|
179
|
-
|
|
180
|
-
При изменении значения обновляются только те DOM-узлы и эффекты, которые читают это поле (fine-grained). Полный перерендер компонента не происходит:
|
|
199
|
+
The `@state` decorator makes a field reactive. When its value changes, only the DOM nodes and effects that read it are updated (fine-grained). The component is not fully re-rendered:
|
|
181
200
|
|
|
182
201
|
```tsx
|
|
183
202
|
import { View, state } from "@helfy/helfy";
|
|
@@ -187,7 +206,7 @@ class Counter {
|
|
|
187
206
|
@state private count = 0;
|
|
188
207
|
|
|
189
208
|
increment() {
|
|
190
|
-
this.count++; //
|
|
209
|
+
this.count++; // updates only nodes that read count
|
|
191
210
|
}
|
|
192
211
|
|
|
193
212
|
decrement() {
|
|
@@ -206,13 +225,13 @@ class Counter {
|
|
|
206
225
|
}
|
|
207
226
|
```
|
|
208
227
|
|
|
209
|
-
|
|
228
|
+
You can declare multiple `@state` fields. Each independently triggers updates only in the DOM nodes that read it.
|
|
210
229
|
|
|
211
|
-
|
|
230
|
+
Signal primitives (`createSignal`, `createEffect`, `createComputed`, `batch`, `onCleanup`) are also exported from `helfy` and can be used directly, but in most cases `@state`, `@computed`, and `@effect` are sufficient.
|
|
212
231
|
|
|
213
|
-
###
|
|
232
|
+
### Nested components
|
|
214
233
|
|
|
215
|
-
|
|
234
|
+
Components are used in JSX as tags. Props are passed as attributes:
|
|
216
235
|
|
|
217
236
|
```tsx
|
|
218
237
|
import { View } from "@helfy/helfy";
|
|
@@ -230,20 +249,31 @@ class App {
|
|
|
230
249
|
}
|
|
231
250
|
```
|
|
232
251
|
|
|
233
|
-
###
|
|
252
|
+
### DOM mounting
|
|
253
|
+
|
|
254
|
+
Typical bootstrap is via `createApp()`: an instance is created, `attach(root)` is called internally, and the `onAttached()` hook runs after insertion into the document.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import { createApp } from "@helfy/helfy";
|
|
258
|
+
|
|
259
|
+
createApp({ root: document.getElementById("root")! })
|
|
260
|
+
.router({ routes })
|
|
261
|
+
.mount(App);
|
|
262
|
+
```
|
|
234
263
|
|
|
235
|
-
|
|
264
|
+
For scenarios without routing:
|
|
236
265
|
|
|
237
266
|
```typescript
|
|
238
|
-
|
|
239
|
-
app.attach(document.getElementById('root'));
|
|
267
|
+
createApp({ root: document.getElementById("root")! }).mount(App);
|
|
240
268
|
```
|
|
241
269
|
|
|
242
|
-
|
|
270
|
+
Manual mounting (without `createApp`): `const app = new App(); app.attach(root)`.
|
|
243
271
|
|
|
244
|
-
|
|
272
|
+
### DOM and component refs (@ref)
|
|
245
273
|
|
|
246
|
-
|
|
274
|
+
The `@ref` decorator marks a field to receive a reference to a DOM element or component instance. In JSX use the `@ref(this.fieldName)` directive. The reference is available after `onMount()`. For operations like `focus()` that require the element in the document, use `onAttached()`.
|
|
275
|
+
|
|
276
|
+
**For components** — the parent receives a proxy with access only to methods marked with `@expose`. This lets you explicitly define the component's public API.
|
|
247
277
|
|
|
248
278
|
```tsx
|
|
249
279
|
import { View, ref, expose } from "@helfy/helfy";
|
|
@@ -266,7 +296,7 @@ class Form {
|
|
|
266
296
|
@ref private input!: Input;
|
|
267
297
|
|
|
268
298
|
onAttached() {
|
|
269
|
-
this.input.focus(); //
|
|
299
|
+
this.input.focus(); // only the exposed method is available
|
|
270
300
|
}
|
|
271
301
|
|
|
272
302
|
render() {
|
|
@@ -275,15 +305,15 @@ class Form {
|
|
|
275
305
|
}
|
|
276
306
|
```
|
|
277
307
|
|
|
278
|
-
- DOM
|
|
279
|
-
-
|
|
280
|
-
-
|
|
308
|
+
- DOM elements via `@ref` are passed as-is
|
|
309
|
+
- Components with `@expose` — parent sees only the exposed methods
|
|
310
|
+
- Components without `@expose` — the full instance is passed (backward compatibility)
|
|
281
311
|
|
|
282
|
-
|
|
312
|
+
With conditional rendering (`@if`), the ref is cleared when the element is removed. Do not use `@ref` and `@state` on the same field.
|
|
283
313
|
|
|
284
|
-
###
|
|
314
|
+
### Two-way binding (@bind)
|
|
285
315
|
|
|
286
|
-
|
|
316
|
+
The `@bind` directive is syntactic sugar for two-way binding of a `@state` field to a form element. Syntax: `@bind(expr)` — parentheses, like `@ref`. The compiler generates the `value`/`checked` pair and event handler.
|
|
287
317
|
|
|
288
318
|
```tsx
|
|
289
319
|
import { View, state } from "@helfy/helfy";
|
|
@@ -301,8 +331,8 @@ class LoginForm {
|
|
|
301
331
|
<input @bind(this.email) type="email" />
|
|
302
332
|
<input @bind(this.isActive) type="checkbox" />
|
|
303
333
|
<select @bind(this.priority)>
|
|
304
|
-
<option value="low"
|
|
305
|
-
<option value="high"
|
|
334
|
+
<option value="low">Low</option>
|
|
335
|
+
<option value="high">High</option>
|
|
306
336
|
</select>
|
|
307
337
|
</form>
|
|
308
338
|
);
|
|
@@ -310,24 +340,24 @@ class LoginForm {
|
|
|
310
340
|
}
|
|
311
341
|
```
|
|
312
342
|
|
|
313
|
-
|
|
343
|
+
Transformation by element type:
|
|
314
344
|
|
|
315
|
-
|
|
|
316
|
-
|
|
345
|
+
| Element | Attribute | Event |
|
|
346
|
+
|---------|-----------|-------|
|
|
317
347
|
| `input[text|email|password|...]` | `value` | `oninput` |
|
|
318
348
|
| `input[checkbox|radio]` | `checked` | `onchange` |
|
|
319
349
|
| `select` | `value` | `onchange` |
|
|
320
350
|
| `textarea` | `value` | `oninput` |
|
|
321
351
|
|
|
322
|
-
|
|
352
|
+
The field must be marked with `@state` for reactive UI updates. For union types (e.g. `TodoPriority`), use `as typeof expr` — type safety is preserved.
|
|
323
353
|
|
|
324
|
-
|
|
354
|
+
**Custom components:** for binding to a component with a `value` prop, use named binding `@bind:value(expr)`:
|
|
325
355
|
|
|
326
356
|
```tsx
|
|
327
|
-
//
|
|
328
|
-
<Input @bind:value(this.title) placeholder="
|
|
357
|
+
// Parent
|
|
358
|
+
<Input @bind:value(this.title) placeholder="Title" />
|
|
329
359
|
|
|
330
|
-
//
|
|
360
|
+
// Input component with @binded("value")
|
|
331
361
|
@View
|
|
332
362
|
class Input {
|
|
333
363
|
@binded("value") private bindedVal!: string;
|
|
@@ -338,11 +368,11 @@ class Input {
|
|
|
338
368
|
}
|
|
339
369
|
```
|
|
340
370
|
|
|
341
|
-
###
|
|
371
|
+
### Forms (@Form, @field, @field in JSX)
|
|
342
372
|
|
|
343
|
-
|
|
373
|
+
Centralized form handling with validation via context: `@Form` — form context class, `@field` — field decorator (creates `FieldState`), `@useForm` — injects the form into a component. The JSX directive `@field(expr)` connects an input to `FieldState` in one line.
|
|
344
374
|
|
|
345
|
-
**FormContext** —
|
|
375
|
+
**FormContext** — a class with `@Form` and `@field` fields:
|
|
346
376
|
|
|
347
377
|
```tsx
|
|
348
378
|
import { Form, field, logger, type FieldState, type ILogger } from "@helfy/helfy";
|
|
@@ -361,7 +391,7 @@ export class LoginFormContext {
|
|
|
361
391
|
rememberMe!: FieldState<boolean>;
|
|
362
392
|
|
|
363
393
|
validateAll(): boolean {
|
|
364
|
-
//
|
|
394
|
+
// validate fields, set field.error
|
|
365
395
|
return true;
|
|
366
396
|
}
|
|
367
397
|
|
|
@@ -375,9 +405,25 @@ export class LoginFormContext {
|
|
|
375
405
|
}
|
|
376
406
|
```
|
|
377
407
|
|
|
378
|
-
**FieldState**
|
|
408
|
+
**FieldState** has `value`, `isDirty`, `isTouched`, `error`, `isValid`. When `value`/`error`/`isTouched` changes, components with `@useForm` re-render.
|
|
409
|
+
|
|
410
|
+
**Form provider** — the parent component wraps the form in context:
|
|
411
|
+
|
|
412
|
+
```tsx
|
|
413
|
+
// LoginPage.tsx
|
|
414
|
+
@View
|
|
415
|
+
export class LoginPage {
|
|
416
|
+
render() {
|
|
417
|
+
return (
|
|
418
|
+
<LoginFormContext>
|
|
419
|
+
<LoginForm />
|
|
420
|
+
</LoginFormContext>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
```
|
|
379
425
|
|
|
380
|
-
|
|
426
|
+
**Form component** — inject via `@useForm`, access fields via `this.form`:
|
|
381
427
|
|
|
382
428
|
```tsx
|
|
383
429
|
import { View, useForm } from "@helfy/helfy";
|
|
@@ -389,79 +435,79 @@ export class LoginForm {
|
|
|
389
435
|
|
|
390
436
|
render() {
|
|
391
437
|
return (
|
|
392
|
-
<
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
{this.form.email.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
</form>
|
|
403
|
-
</LoginFormContext>
|
|
438
|
+
<form onsubmit={(e: Event) => { e.preventDefault(); this.form.submit(); }}>
|
|
439
|
+
<input @field(this.form.email) type="email" class="input" />
|
|
440
|
+
{this.form.email.isTouched && this.form.email.error && (
|
|
441
|
+
<span class="error">{this.form.email.error}</span>
|
|
442
|
+
)}
|
|
443
|
+
<input @field(this.form.password) type="password" class="input" />
|
|
444
|
+
<input @field(this.form.rememberMe) type="checkbox" id="remember" />
|
|
445
|
+
<label for="remember">Remember me</label>
|
|
446
|
+
<button type="submit">Sign in</button>
|
|
447
|
+
</form>
|
|
404
448
|
);
|
|
405
449
|
}
|
|
406
450
|
}
|
|
407
451
|
```
|
|
408
452
|
|
|
409
|
-
|
|
453
|
+
Wrapper components (TextField, CheckboxField, etc.) accept `field` as a prop: `<TextField field={this.form.email} label="Email" />` and use `<input @field(this.props.field) />` internally.
|
|
454
|
+
|
|
455
|
+
**JSX directive `@field(expr)`** — one directive replaces `@bind` + `onblur` + error `class` + `aria-invalid`. The compiler generates:
|
|
410
456
|
|
|
411
|
-
- `value`/`checked`
|
|
457
|
+
- `value`/`checked` and `oninput`/`onchange`
|
|
412
458
|
- `onblur` → `expr.isTouched = true`
|
|
413
|
-
- `class` —
|
|
459
|
+
- `class` — merged with existing; when `expr.isTouched && expr.error` adds `input-error`
|
|
414
460
|
- `aria-invalid={expr.isTouched && expr.error ? "true" : "false"}`
|
|
415
461
|
|
|
416
|
-
|
|
462
|
+
Supports `input` (text, email, password, checkbox, radio), `select`, `textarea`. The `.input-error` class can be defined in global styles (e.g. `@apply border-red-500` in Tailwind).
|
|
417
463
|
|
|
418
464
|
---
|
|
419
465
|
|
|
420
|
-
##
|
|
466
|
+
## Template directives
|
|
421
467
|
|
|
422
|
-
Helfy
|
|
468
|
+
Helfy extends JSX with `@if`, `@elseif`, `@else`, `@for`, and `@empty` directives.
|
|
423
469
|
|
|
424
470
|
### @if / @elseif / @else
|
|
425
471
|
|
|
426
|
-
|
|
472
|
+
Conditional rendering:
|
|
427
473
|
|
|
428
474
|
```tsx
|
|
429
475
|
render() {
|
|
430
476
|
return (
|
|
431
477
|
<div>
|
|
432
478
|
@if (this.count > 0) {
|
|
433
|
-
<span
|
|
479
|
+
<span>Positive: {this.count}</span>
|
|
434
480
|
}
|
|
435
481
|
</div>
|
|
436
482
|
);
|
|
437
483
|
}
|
|
438
484
|
```
|
|
439
485
|
|
|
440
|
-
|
|
486
|
+
Condition chains:
|
|
441
487
|
|
|
442
488
|
```tsx
|
|
443
489
|
render() {
|
|
444
490
|
return (
|
|
445
491
|
<div>
|
|
446
492
|
@if (this.status === 'loading') {
|
|
447
|
-
<span
|
|
493
|
+
<span>Loading...</span>
|
|
448
494
|
} @elseif (this.status === 'error') {
|
|
449
|
-
<span
|
|
495
|
+
<span>Error!</span>
|
|
450
496
|
} @else {
|
|
451
|
-
<span
|
|
497
|
+
<span>Data loaded</span>
|
|
452
498
|
}
|
|
453
499
|
</div>
|
|
454
500
|
);
|
|
455
501
|
}
|
|
456
502
|
```
|
|
457
503
|
|
|
458
|
-
|
|
504
|
+
Nested conditions:
|
|
459
505
|
|
|
460
506
|
```tsx
|
|
461
507
|
@if (this.isVisible) {
|
|
462
508
|
<div>
|
|
463
509
|
@if (this.count > 10) {
|
|
464
|
-
<span
|
|
510
|
+
<span>More than ten</span>
|
|
465
511
|
}
|
|
466
512
|
</div>
|
|
467
513
|
}
|
|
@@ -469,7 +515,7 @@ render() {
|
|
|
469
515
|
|
|
470
516
|
### @for
|
|
471
517
|
|
|
472
|
-
|
|
518
|
+
Array iteration. Syntax: `@for (item of array)` or `@for (item, index of array)`.
|
|
473
519
|
|
|
474
520
|
```tsx
|
|
475
521
|
@state private items = ['apple', 'banana', 'cherry'];
|
|
@@ -485,7 +531,7 @@ render() {
|
|
|
485
531
|
}
|
|
486
532
|
```
|
|
487
533
|
|
|
488
|
-
`track`
|
|
534
|
+
`track` sets the key for DOM diffing optimization (like `key` in React):
|
|
489
535
|
|
|
490
536
|
```tsx
|
|
491
537
|
@for (user of this.users; track user.id) {
|
|
@@ -495,13 +541,13 @@ render() {
|
|
|
495
541
|
|
|
496
542
|
### @empty
|
|
497
543
|
|
|
498
|
-
|
|
544
|
+
The `@empty` block after `@for` renders when the array is empty:
|
|
499
545
|
|
|
500
546
|
```tsx
|
|
501
547
|
@for (item of this.items; track item) {
|
|
502
548
|
<div>{item}</div>
|
|
503
549
|
} @empty {
|
|
504
|
-
<span
|
|
550
|
+
<span>List is empty</span>
|
|
505
551
|
}
|
|
506
552
|
```
|
|
507
553
|
|
|
@@ -509,9 +555,9 @@ render() {
|
|
|
509
555
|
|
|
510
556
|
## State Management
|
|
511
557
|
|
|
512
|
-
###
|
|
558
|
+
### Creating a Store
|
|
513
559
|
|
|
514
|
-
Store
|
|
560
|
+
A Store is a global reactive store. Create a class with the `@Store` decorator; reactive fields use `@observable`:
|
|
515
561
|
|
|
516
562
|
```typescript
|
|
517
563
|
import { Store, observable } from "@helfy/helfy";
|
|
@@ -523,7 +569,7 @@ class UserStore {
|
|
|
523
569
|
|
|
524
570
|
login(name: string) {
|
|
525
571
|
this.name = name;
|
|
526
|
-
this.isLoggedIn = true; //
|
|
572
|
+
this.isLoggedIn = true; // subscribers of name and isLoggedIn are notified
|
|
527
573
|
}
|
|
528
574
|
|
|
529
575
|
logout() {
|
|
@@ -535,15 +581,15 @@ class UserStore {
|
|
|
535
581
|
export default UserStore;
|
|
536
582
|
```
|
|
537
583
|
|
|
538
|
-
|
|
539
|
-
- `subscribe(field, callback)`
|
|
540
|
-
- `unsubscribe(field, callback)`
|
|
584
|
+
The `@Store` decorator adds:
|
|
585
|
+
- `subscribe(field, callback)` — subscribe to field changes, returns unsubscribe function
|
|
586
|
+
- `unsubscribe(field, callback)` — unsubscribe
|
|
541
587
|
|
|
542
|
-
|
|
588
|
+
The `@observable` decorator turns a field into a reactive property with getter/setter. On write, all subscribers are notified.
|
|
543
589
|
|
|
544
|
-
###
|
|
590
|
+
### Subscribing with @observe
|
|
545
591
|
|
|
546
|
-
|
|
592
|
+
The `@observe` decorator binds a component field to a store field. When the store value changes, the component re-renders:
|
|
547
593
|
|
|
548
594
|
```tsx
|
|
549
595
|
import { View, observe } from "@helfy/helfy";
|
|
@@ -562,9 +608,9 @@ class Header {
|
|
|
562
608
|
return (
|
|
563
609
|
<header>
|
|
564
610
|
@if (this.isLoggedIn) {
|
|
565
|
-
<span
|
|
611
|
+
<span>Hello, {this.userName}!</span>
|
|
566
612
|
} @else {
|
|
567
|
-
<span
|
|
613
|
+
<span>Sign in</span>
|
|
568
614
|
}
|
|
569
615
|
</header>
|
|
570
616
|
);
|
|
@@ -572,11 +618,11 @@ class Header {
|
|
|
572
618
|
}
|
|
573
619
|
```
|
|
574
620
|
|
|
575
|
-
|
|
621
|
+
The field type is inferred from the store via lookup type (`UserStore['name']`), giving full type safety.
|
|
576
622
|
|
|
577
|
-
###
|
|
623
|
+
### Store context
|
|
578
624
|
|
|
579
|
-
|
|
625
|
+
Create a single context for all stores:
|
|
580
626
|
|
|
581
627
|
```typescript
|
|
582
628
|
import UserStore from "./UserStore";
|
|
@@ -590,26 +636,26 @@ class Stores {
|
|
|
590
636
|
export default Stores;
|
|
591
637
|
```
|
|
592
638
|
|
|
593
|
-
|
|
639
|
+
Usage in components:
|
|
594
640
|
|
|
595
641
|
```tsx
|
|
596
642
|
import Stores from "./StoreContext";
|
|
597
643
|
|
|
598
|
-
//
|
|
644
|
+
// subscribe via decorator
|
|
599
645
|
@observe(Stores.userStore, 'name')
|
|
600
646
|
private userName: string;
|
|
601
647
|
|
|
602
|
-
//
|
|
648
|
+
// direct store method call
|
|
603
649
|
Stores.userStore.login("Alice");
|
|
604
650
|
```
|
|
605
651
|
|
|
606
652
|
---
|
|
607
653
|
|
|
608
|
-
##
|
|
654
|
+
## Logging (@logger)
|
|
609
655
|
|
|
610
|
-
|
|
656
|
+
The `@logger` decorator injects a logger into View, Context, Store, and Injectable classes. The class name is set at compile time (Babel plugin), keeping readable names even after minification.
|
|
611
657
|
|
|
612
|
-
###
|
|
658
|
+
### Usage
|
|
613
659
|
|
|
614
660
|
```tsx
|
|
615
661
|
import { View, logger, type ILogger } from "@helfy/helfy";
|
|
@@ -626,22 +672,22 @@ class TodoInput {
|
|
|
626
672
|
}
|
|
627
673
|
```
|
|
628
674
|
|
|
629
|
-
|
|
630
|
-
- `@logger()` —
|
|
631
|
-
- `@logger("my-tag")` —
|
|
675
|
+
**Variants:**
|
|
676
|
+
- `@logger()` — class name is set at compile-time as `<ClassName>`, format and color depend on class type
|
|
677
|
+
- `@logger("my-tag")` — custom tag, gray color
|
|
632
678
|
|
|
633
|
-
###
|
|
679
|
+
### Tag format and color by class type
|
|
634
680
|
|
|
635
|
-
|
|
|
636
|
-
|
|
637
|
-
| View (
|
|
638
|
-
| Injectable (
|
|
681
|
+
| Type | Format | Color |
|
|
682
|
+
|------|--------|-------|
|
|
683
|
+
| View (components) | `<TodoInput>` | skyblue |
|
|
684
|
+
| Injectable (services) | `TodoValidateService()` | pink |
|
|
639
685
|
| Context | `{TodoContext}` | khaki |
|
|
640
686
|
| Form | `[LoginFormContext]` | bright blue |
|
|
641
687
|
| Store | `TodoStore[]` | lime green |
|
|
642
|
-
|
|
|
688
|
+
| Custom `@logger("...")` | as specified | gray |
|
|
643
689
|
|
|
644
|
-
### API
|
|
690
|
+
### Logger API
|
|
645
691
|
|
|
646
692
|
```typescript
|
|
647
693
|
interface ILogger {
|
|
@@ -649,13 +695,13 @@ interface ILogger {
|
|
|
649
695
|
info(message: string, meta?: Record<string, unknown>): void;
|
|
650
696
|
warn(message: string, meta?: Record<string, unknown>): void;
|
|
651
697
|
error(messageOrError: string | Error, meta?: Record<string, unknown>): void;
|
|
652
|
-
withContext(ctx: string): ILogger; //
|
|
698
|
+
withContext(ctx: string): ILogger; // child logger with extra prefix
|
|
653
699
|
}
|
|
654
700
|
```
|
|
655
701
|
|
|
656
|
-
### DI
|
|
702
|
+
### DI and transports
|
|
657
703
|
|
|
658
|
-
`LoggerService`
|
|
704
|
+
`LoggerService` is registered under `ILoggerToken` via `registerAllServices`. Custom transports (Sentry, file, etc.) implementing `ILogTransport` can be wired up:
|
|
659
705
|
|
|
660
706
|
```typescript
|
|
661
707
|
import { LoggerService, ConsoleTransport, ILoggerToken } from "@helfy/helfy";
|
|
@@ -668,17 +714,17 @@ createApp({ root })
|
|
|
668
714
|
.mount(App);
|
|
669
715
|
```
|
|
670
716
|
|
|
671
|
-
|
|
717
|
+
Without a registered logger in the container, a fallback that logs to `console` is used.
|
|
672
718
|
|
|
673
719
|
---
|
|
674
720
|
|
|
675
721
|
## Context & DI
|
|
676
722
|
|
|
677
|
-
Helfy
|
|
723
|
+
Helfy supports hierarchical Context and Dependency Injection: a provider wraps a subtree in JSX, and child components receive values via `@ctx`. Useful for theme, forms, routing, and other shared dependencies.
|
|
678
724
|
|
|
679
|
-
###
|
|
725
|
+
### Provider (@Context, @provide)
|
|
680
726
|
|
|
681
|
-
|
|
727
|
+
A class with the `@Context` decorator is a non-rendering provider: it has no `render()` and only renders its children. Fields with `@provide` are available to consumers down the tree.
|
|
682
728
|
|
|
683
729
|
```tsx
|
|
684
730
|
import { Context, provide } from "@helfy/helfy";
|
|
@@ -697,7 +743,7 @@ export class ThemeContext {
|
|
|
697
743
|
}
|
|
698
744
|
```
|
|
699
745
|
|
|
700
|
-
|
|
746
|
+
Usage in JSX — wrap a subtree:
|
|
701
747
|
|
|
702
748
|
```tsx
|
|
703
749
|
<ThemeContext>
|
|
@@ -706,11 +752,11 @@ export class ThemeContext {
|
|
|
706
752
|
</ThemeContext>
|
|
707
753
|
```
|
|
708
754
|
|
|
709
|
-
|
|
755
|
+
The framework only renders the children of `ThemeContext`; the provider itself does not create DOM nodes.
|
|
710
756
|
|
|
711
|
-
###
|
|
757
|
+
### Consumer (@ctx)
|
|
712
758
|
|
|
713
|
-
|
|
759
|
+
A component with `@View` can receive context values via `@ctx`:
|
|
714
760
|
|
|
715
761
|
```tsx
|
|
716
762
|
import { View, ctx } from "@helfy/helfy";
|
|
@@ -724,14 +770,14 @@ class ThemeToggle {
|
|
|
724
770
|
render() {
|
|
725
771
|
return (
|
|
726
772
|
<button onclick={this.theme.toggle}>
|
|
727
|
-
{this.theme.mode === "dark" ? "
|
|
773
|
+
{this.theme.mode === "dark" ? "Light theme" : "Dark theme"}
|
|
728
774
|
</button>
|
|
729
775
|
);
|
|
730
776
|
}
|
|
731
777
|
}
|
|
732
778
|
```
|
|
733
779
|
|
|
734
|
-
|
|
780
|
+
You can inject only a context field:
|
|
735
781
|
|
|
736
782
|
```tsx
|
|
737
783
|
@View
|
|
@@ -740,20 +786,31 @@ class ModeDisplay {
|
|
|
740
786
|
private mode!: TThemeMode;
|
|
741
787
|
|
|
742
788
|
render() {
|
|
743
|
-
return <span
|
|
789
|
+
return <span>Current theme: {this.mode}</span>;
|
|
744
790
|
}
|
|
745
791
|
}
|
|
746
792
|
```
|
|
747
793
|
|
|
748
|
-
|
|
794
|
+
Provider lookup goes up the tree (`_parentView`); the nearest provider with the matching key is used.
|
|
749
795
|
|
|
750
|
-
###
|
|
796
|
+
### Reactive fields
|
|
751
797
|
|
|
752
|
-
`@provide({ reactive: true })`
|
|
798
|
+
`@provide({ reactive: true })` makes a field reactive: when it changes, all consumers re-render. For methods, `@provide()` without `reactive` is enough.
|
|
753
799
|
|
|
754
|
-
|
|
800
|
+
**Computed fields** — getters with `@provide({ computed: true, deps: ["field1", "field2"] })` recompute when dependencies change and trigger consumer re-renders:
|
|
755
801
|
|
|
756
|
-
|
|
802
|
+
```tsx
|
|
803
|
+
@provide({ computed: true, deps: ["todos", "filter"] })
|
|
804
|
+
get filteredTodos(): Todo[] {
|
|
805
|
+
return this.filter === "active"
|
|
806
|
+
? this.todos.filter(t => !t.completed)
|
|
807
|
+
: this.todos;
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
### Optional injection
|
|
812
|
+
|
|
813
|
+
The third argument of `@ctx` — options `{ optional?, defaultValue? }`:
|
|
757
814
|
|
|
758
815
|
```tsx
|
|
759
816
|
@ctx(ThemeContext, { optional: true })
|
|
@@ -763,15 +820,15 @@ private theme?: ThemeContext;
|
|
|
763
820
|
private mode = "light";
|
|
764
821
|
```
|
|
765
822
|
|
|
766
|
-
|
|
823
|
+
With no provider in the tree, `optional: true` yields `undefined`; `defaultValue` is used when the provider is absent.
|
|
767
824
|
|
|
768
|
-
### DI Container (
|
|
825
|
+
### DI Container (constructor injection)
|
|
769
826
|
|
|
770
|
-
Helfy
|
|
827
|
+
Helfy supports a DI container for global services with **compile-time** magic: services are marked with `@Injectable<IX>()`, consumers only specify the type in the constructor — no `@inject` or explicit tokens.
|
|
771
828
|
|
|
772
|
-
|
|
829
|
+
**Convention:** the last constructor parameter is props from the parent; preceding parameters are resolved from the container or from @Context up the tree.
|
|
773
830
|
|
|
774
|
-
|
|
831
|
+
**Service** — must use `@Injectable<IX>()` (interface in generic):
|
|
775
832
|
|
|
776
833
|
```typescript
|
|
777
834
|
export interface ITodoValidateService {
|
|
@@ -784,7 +841,7 @@ export class TodoValidateService implements ITodoValidateService {
|
|
|
784
841
|
}
|
|
785
842
|
```
|
|
786
843
|
|
|
787
|
-
|
|
844
|
+
**Consumer** — only the type in the constructor (plugin injects the token):
|
|
788
845
|
|
|
789
846
|
```tsx
|
|
790
847
|
@View
|
|
@@ -796,7 +853,7 @@ class TodoInput {
|
|
|
796
853
|
}
|
|
797
854
|
```
|
|
798
855
|
|
|
799
|
-
**Bootstrap** —
|
|
856
|
+
**Bootstrap** — DI registration is attached automatically at `createApp` (plugin injects `.useDI(registerAllServices)` in the chain):
|
|
800
857
|
|
|
801
858
|
```typescript
|
|
802
859
|
createApp({ root: document.getElementById("root")! })
|
|
@@ -804,9 +861,9 @@ createApp({ root: document.getElementById("root")! })
|
|
|
804
861
|
.mount(App);
|
|
805
862
|
```
|
|
806
863
|
|
|
807
|
-
|
|
864
|
+
The scanner is run by the Babel plugin before transformation, finds all `@Injectable<IX>()`, and generates `.helfy/di-tokens.ts` and `.helfy/di-registry.ts`.
|
|
808
865
|
|
|
809
|
-
**Fallback** —
|
|
866
|
+
**Fallback** — for manual setup: `.configureContainer()`, `@inject(token)`, `@Injectable(token)`:
|
|
810
867
|
|
|
811
868
|
```typescript
|
|
812
869
|
createApp({ root })
|
|
@@ -818,38 +875,38 @@ createApp({ root })
|
|
|
818
875
|
.mount(App);
|
|
819
876
|
```
|
|
820
877
|
|
|
821
|
-
|
|
878
|
+
Optionally: pass a custom container via `.container(myContainer)` or `createApp({ root, container })`.
|
|
822
879
|
|
|
823
|
-
`@Context`
|
|
880
|
+
`@Context` and the container coexist: Context is for tree-scoped values (theme, router), Container for global services.
|
|
824
881
|
|
|
825
882
|
---
|
|
826
883
|
|
|
827
884
|
## JSX
|
|
828
885
|
|
|
829
|
-
Helfy
|
|
886
|
+
Helfy uses a custom JSX runtime (`@helfy/helfy/jsx-runtime`). JSX is translated to `jsx()` / `jsxs()` calls that build a virtual DOM representation.
|
|
830
887
|
|
|
831
|
-
###
|
|
888
|
+
### Attributes
|
|
832
889
|
|
|
833
|
-
|
|
890
|
+
Standard HTML attributes are passed through:
|
|
834
891
|
|
|
835
892
|
```tsx
|
|
836
|
-
<input type="text" placeholder="
|
|
893
|
+
<input type="text" placeholder="Enter name" />
|
|
837
894
|
<img src="/logo.png" alt="Logo" />
|
|
838
895
|
<div id="container" class="wrapper"></div>
|
|
839
896
|
```
|
|
840
897
|
|
|
841
|
-
###
|
|
898
|
+
### Events
|
|
842
899
|
|
|
843
|
-
|
|
900
|
+
Event handlers use **lowercase** attributes (`onclick`, `oninput`, not `onClick`):
|
|
844
901
|
|
|
845
902
|
```tsx
|
|
846
903
|
<button onclick={() => this.increment()}>+</button>
|
|
847
904
|
<input oninput={(e) => this.handleInput(e)} />
|
|
848
905
|
```
|
|
849
906
|
|
|
850
|
-
###
|
|
907
|
+
### Styles
|
|
851
908
|
|
|
852
|
-
|
|
909
|
+
Inline styles use an object with camelCase keys:
|
|
853
910
|
|
|
854
911
|
```tsx
|
|
855
912
|
<div style={{
|
|
@@ -858,26 +915,26 @@ Helfy использует кастомный JSX runtime (`@helfy/helfy/jsx-run
|
|
|
858
915
|
padding: '8px 16px',
|
|
859
916
|
borderRadius: '4px'
|
|
860
917
|
}}>
|
|
861
|
-
|
|
918
|
+
Styled block
|
|
862
919
|
</div>
|
|
863
920
|
```
|
|
864
921
|
|
|
865
|
-
### CSS
|
|
922
|
+
### CSS classes
|
|
866
923
|
|
|
867
|
-
|
|
924
|
+
The `class` attribute accepts a string or an array with conditional classes:
|
|
868
925
|
|
|
869
926
|
```tsx
|
|
870
|
-
//
|
|
927
|
+
// string
|
|
871
928
|
<div class="container">...</div>
|
|
872
929
|
|
|
873
930
|
// CSS Modules
|
|
874
931
|
import styles from './App.module.css';
|
|
875
932
|
<div class={styles.wrapper}>...</div>
|
|
876
933
|
|
|
877
|
-
//
|
|
934
|
+
// conditional classes via array
|
|
878
935
|
<div class={[
|
|
879
936
|
styles.cell,
|
|
880
|
-
[styles.active, this.isActive], //
|
|
937
|
+
[styles.active, this.isActive], // applied when this.isActive === true
|
|
881
938
|
[styles.disabled, this.isDisabled],
|
|
882
939
|
]}>...</div>
|
|
883
940
|
```
|
|
@@ -886,116 +943,44 @@ import styles from './App.module.css';
|
|
|
886
943
|
|
|
887
944
|
## API
|
|
888
945
|
|
|
889
|
-
###
|
|
890
|
-
|
|
891
|
-
|
|
|
892
|
-
|
|
893
|
-
| `@View` |
|
|
894
|
-
| `@state` |
|
|
895
|
-
| `@Store` |
|
|
896
|
-
| `@observable` |
|
|
897
|
-
| `@observe(store, field)` |
|
|
898
|
-
| `@Context` |
|
|
899
|
-
| `@provide()` / `@provide({ reactive: true })` |
|
|
900
|
-
| `@ctx(ContextClass)` / `@ctx(ContextClass, field)` |
|
|
901
|
-
| `@Injectable<IX>()` / `@Injectable<IX>('singleton' \| 'transient' \| 'scoped')` / `@Injectable(token)` |
|
|
902
|
-
| `@inject(token)` |
|
|
903
|
-
| `@logger()` / `@logger("tag")` |
|
|
904
|
-
| `@ref` |
|
|
905
|
-
| `@
|
|
906
|
-
| `@
|
|
907
|
-
| `@
|
|
908
|
-
| `@
|
|
909
|
-
| `@
|
|
910
|
-
| `@
|
|
911
|
-
|
|
912
|
-
|
|
946
|
+
### Decorators
|
|
947
|
+
|
|
948
|
+
| Decorator | Scope | Description |
|
|
949
|
+
|-----------|-------|-------------|
|
|
950
|
+
| `@View` | Class | Turns a class into a component with `render()`, `view`, `update()` |
|
|
951
|
+
| `@state` | Field | Component local state on signals (write updates only this component) |
|
|
952
|
+
| `@Store` | Class | Adds `subscribe`/`unsubscribe` and `@observable` field reactivity |
|
|
953
|
+
| `@observable` | Field | Makes a store field reactive — write notifies subscribers |
|
|
954
|
+
| `@observe(store, field)` | Field | Binds a component field to a store field |
|
|
955
|
+
| `@Context` | Class | Non-rendering context provider; renders only children |
|
|
956
|
+
| `@provide()` / `@provide({ reactive: true })` / `@provide({ computed: true, deps })` | Field | Marks a field as available to `@ctx`. `reactive` — consumers re-render on change; `computed` + `deps` — for getters |
|
|
957
|
+
| `@ctx(ContextClass)` / `@ctx(ContextClass, field)` | Field | Injects context or a context field from the nearest provider up the tree |
|
|
958
|
+
| `@Injectable<IX>()` / `@Injectable<IX>('singleton' \| 'transient' \| 'scoped')` / `@Injectable(token)` | Class | Marks a class as injectable. Scope in (): `'singleton'` (default), `'transient'`, `'scoped'`. Fallback: `@Injectable(token)` with Symbol/string |
|
|
959
|
+
| `@inject(token)` | Constructor param | Sets the token for the parameter (fallback; with `@Injectable<IX>()` the plugin injects it automatically) |
|
|
960
|
+
| `@logger()` / `@logger("tag")` | Field | Injects ILogger. No arg — compile-time class name and color by type (View/Context/Store/Injectable). With arg — custom tag (gray) |
|
|
961
|
+
| `@ref` | Field | Marks a field to receive a DOM or component reference (use with `@ref(this.fieldName)` in JSX) |
|
|
962
|
+
| `@expose` | Method | Makes a method available to the parent when using `@ref` on a component (without `@expose` the parent gets the full instance) |
|
|
963
|
+
| `@binded(name)` | Field | Binds a field to `@bind:name` from the parent (for custom components) |
|
|
964
|
+
| `@bind(expr)` / `@bind:name(expr)` | JSX | Two-way binding with `@state` field (value/checked + oninput/onchange). For components: `@bind:value(expr)` |
|
|
965
|
+
| `@Form` | Class | Form context with `@field` fields |
|
|
966
|
+
| `@field(options)` | FormContext field | Creates FieldState for a form field (value, isTouched, error, isDirty) |
|
|
967
|
+
| `@useForm(FormContext)` | Field | Injects the form into a component with subscription to field changes |
|
|
968
|
+
| `@field(expr)` | JSX | Connects an input to FieldState: value/checked + onblur + error class + aria-invalid |
|
|
969
|
+
|
|
970
|
+
### Routing (SPA)
|
|
971
|
+
|
|
972
|
+
Helfy includes a lightweight SPA router. When you call `createApp().router({ routes }).mount(App)`, the framework automatically wraps the app in `RouterContext`, so you don't need to wrap your App manually:
|
|
913
973
|
|
|
914
|
-
```
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
Store, // декоратор @Store
|
|
920
|
-
observable, // декоратор @observable
|
|
921
|
-
observe, // декоратор @observe
|
|
922
|
-
computed, // @computed для Store/@Context/@View (getter-based)
|
|
923
|
-
effect, // @effect для реактивных сайд-эффектов во View
|
|
924
|
-
Context, // декоратор @Context
|
|
925
|
-
provide, // декоратор @provide
|
|
926
|
-
ctx, // декоратор @ctx (поле, Context)
|
|
927
|
-
Container, // DI-контейнер
|
|
928
|
-
Injectable, // декоратор @Injectable
|
|
929
|
-
inject, // декоратор @inject (параметр конструктора)
|
|
930
|
-
createViewInstance,
|
|
931
|
-
setRootContainer,
|
|
932
|
-
getRootContainer,
|
|
933
|
-
ref, // декоратор @ref
|
|
934
|
-
binded, // декоратор @binded (для компонентов с @bind:value)
|
|
935
|
-
Form, // декоратор @Form (контекст формы)
|
|
936
|
-
field, // декоратор @field (поле FormContext)
|
|
937
|
-
useForm, // декоратор @useForm (инжект формы)
|
|
938
|
-
type FieldState,
|
|
939
|
-
type FieldOptions,
|
|
940
|
-
StoreBase, // базовый класс Store (для расширения)
|
|
941
|
-
Subscriber, // тип callback-подписчика
|
|
942
|
-
Router, // ядро роутера (Router)
|
|
943
|
-
RouterContext, // контекст роутера для дерева компонентов
|
|
944
|
-
RouterView, // компонент, рендерящий текущий маршрут
|
|
945
|
-
Link, // SPA-ссылка
|
|
946
|
-
DefaultNotFound, // fallback-компонент 404 (используется RouterView при отсутствии слота)
|
|
947
|
-
path, // @path() — декоратор для pathname
|
|
948
|
-
search, // @search() — декоратор для query-параметров
|
|
949
|
-
params, // @params() — декоратор для route-параметров
|
|
950
|
-
router, // @router() — декоратор для API роутера
|
|
951
|
-
logger, // @logger() — декоратор для ILogger
|
|
952
|
-
LoggerService, // реализация ILogger (DI)
|
|
953
|
-
ConsoleTransport,
|
|
954
|
-
ILoggerToken,
|
|
955
|
-
// primitives для fine-grained reactivity
|
|
956
|
-
createSignal,
|
|
957
|
-
createEffect,
|
|
958
|
-
createComputed,
|
|
959
|
-
batch,
|
|
960
|
-
onCleanup,
|
|
961
|
-
type Signal,
|
|
962
|
-
type SignalGetter,
|
|
963
|
-
type SignalSetter,
|
|
964
|
-
type EffectDisposer,
|
|
965
|
-
type RouteConfig,
|
|
966
|
-
type RouterLocation,
|
|
967
|
-
type ILogger,
|
|
968
|
-
type ILogTransport,
|
|
969
|
-
type LogLevel,
|
|
970
|
-
} from "@helfy/helfy";
|
|
974
|
+
```tsx
|
|
975
|
+
// index.ts
|
|
976
|
+
createApp({ root: document.getElementById("root")! })
|
|
977
|
+
.router({ routes })
|
|
978
|
+
.mount(App);
|
|
971
979
|
```
|
|
972
980
|
|
|
973
|
-
### Subpath-экспорты
|
|
974
|
-
|
|
975
|
-
| Путь | Назначение |
|
|
976
|
-
|------|------------|
|
|
977
|
-
| `helfy` | Основной API (декораторы, $, типы) |
|
|
978
|
-
| `@helfy/helfy/router` | Тот же API роутера, но отдельным entry (для tree-shaking) |
|
|
979
|
-
| `@helfy/helfy/jsx-runtime` | JSX runtime (`jsx`, `jsxs`) |
|
|
980
|
-
| `@helfy/helfy/compiler/helfy-loader` | Webpack-loader для директив `@if`/`@for`/`@ref`/`@bind`/`@field` |
|
|
981
|
-
| `@helfy/helfy/babel-preset` | Babel-пресет (JSX, TypeScript, декораторы) |
|
|
982
|
-
|
|
983
|
-
### Роутинг (SPA)
|
|
984
|
-
|
|
985
|
-
Helfy включает лёгкий SPA‑роутер, построенный на `Context & DI`:
|
|
986
|
-
|
|
987
981
|
```tsx
|
|
988
|
-
import {
|
|
989
|
-
|
|
990
|
-
RouterContext,
|
|
991
|
-
RouterView,
|
|
992
|
-
Link,
|
|
993
|
-
type RouteConfig,
|
|
994
|
-
type RouterLocation,
|
|
995
|
-
ctx,
|
|
996
|
-
} from "@helfy/helfy";
|
|
997
|
-
|
|
998
|
-
// Конфиг маршрутов
|
|
982
|
+
import { View, RouterView, Link, path, type RouteConfig } from "@helfy/helfy";
|
|
983
|
+
|
|
999
984
|
const routes: RouteConfig[] = [
|
|
1000
985
|
{ path: "/", component: HomePage },
|
|
1001
986
|
{ path: "/analytics", component: AnalyticsPage },
|
|
@@ -1007,45 +992,34 @@ const routes: RouteConfig[] = [
|
|
|
1007
992
|
class App {
|
|
1008
993
|
render() {
|
|
1009
994
|
return (
|
|
1010
|
-
<
|
|
1011
|
-
<
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
</Layout>
|
|
1015
|
-
</RouterContext>
|
|
995
|
+
<Layout>
|
|
996
|
+
@slot.sidebar() {<Sidebar />}
|
|
997
|
+
@slot.content() {<RouterView />}
|
|
998
|
+
</Layout>
|
|
1016
999
|
);
|
|
1017
1000
|
}
|
|
1018
1001
|
}
|
|
1019
1002
|
|
|
1020
1003
|
@View
|
|
1021
1004
|
class Sidebar {
|
|
1022
|
-
@
|
|
1023
|
-
private
|
|
1005
|
+
@path()
|
|
1006
|
+
private pathname!: string;
|
|
1024
1007
|
|
|
1025
1008
|
render() {
|
|
1026
|
-
const
|
|
1027
|
-
const
|
|
1028
|
-
const isAnalytics = pathname.startsWith("/analytics");
|
|
1009
|
+
const isHome = this.pathname === "/";
|
|
1010
|
+
const isAnalytics = this.pathname.startsWith("/analytics");
|
|
1029
1011
|
|
|
1030
1012
|
return (
|
|
1031
1013
|
<nav>
|
|
1032
|
-
<Link
|
|
1033
|
-
|
|
1034
|
-
label="Главная"
|
|
1035
|
-
class={isHome ? "font-bold" : ""}
|
|
1036
|
-
/>
|
|
1037
|
-
<Link
|
|
1038
|
-
to="/analytics"
|
|
1039
|
-
label="Аналитика"
|
|
1040
|
-
class={isAnalytics ? "font-bold" : ""}
|
|
1041
|
-
/>
|
|
1014
|
+
<Link to="/" label="Home" class={isHome ? "font-bold" : ""} />
|
|
1015
|
+
<Link to="/analytics" label="Analytics" class={isAnalytics ? "font-bold" : ""} />
|
|
1042
1016
|
</nav>
|
|
1043
1017
|
);
|
|
1044
1018
|
}
|
|
1045
1019
|
}
|
|
1046
1020
|
```
|
|
1047
1021
|
|
|
1048
|
-
|
|
1022
|
+
A typical page component can use router decorators:
|
|
1049
1023
|
|
|
1050
1024
|
```tsx
|
|
1051
1025
|
import { View, path, search, params, router, type RouterAPI } from "@helfy/helfy";
|
|
@@ -1072,7 +1046,7 @@ class DebugPage {
|
|
|
1072
1046
|
<pre>params: {JSON.stringify(this.routeParams)}</pre>
|
|
1073
1047
|
<pre>query: {JSON.stringify(this.query)}</pre>
|
|
1074
1048
|
<button onclick={() => this.rtr.push("/analytics")}>
|
|
1075
|
-
|
|
1049
|
+
Go to analytics
|
|
1076
1050
|
</button>
|
|
1077
1051
|
</section>
|
|
1078
1052
|
);
|
|
@@ -1080,14 +1054,7 @@ class DebugPage {
|
|
|
1080
1054
|
}
|
|
1081
1055
|
```
|
|
1082
1056
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
- `Router` использует `history.pushState/replaceState` и `popstate` для синхронизации с URL.
|
|
1086
|
-
- `RouterContext` хранит реактивное `location` (`@provide({ reactive: true })`), так что `@ctx(RouterContext, "location")` и декораторы `@path/@search/@params/@router` автоматически триггерят перерендер компонентов при смене маршрута.
|
|
1087
|
-
- `RouterView` сопоставляет текущий `pathname` с `RouteConfig` (в том числе с параметрами `:id` и вложенными маршрутами) и рендерит нужный `@View`‑класс. При отсутствии совпадения показывается `DefaultNotFound` или содержимое слота `@slot.notFound`.
|
|
1088
|
-
- `Link` перехватывает `click`, не перезагружает страницу и вызывает `router.push/replace`, сохраняя поведение обычных ссылок (`href` остаётся).
|
|
1089
|
-
|
|
1090
|
-
**Кастомная страница 404.** Оберните `RouterView` и переопределите слот `notFound`:
|
|
1057
|
+
**Custom 404 page.** Wrap `RouterView` and override the `notFound` slot:
|
|
1091
1058
|
|
|
1092
1059
|
```tsx
|
|
1093
1060
|
@View
|
|
@@ -1102,35 +1069,35 @@ class AppRouter {
|
|
|
1102
1069
|
);
|
|
1103
1070
|
}
|
|
1104
1071
|
}
|
|
1105
|
-
```
|
|
1072
|
+
```
|
|
1106
1073
|
|
|
1107
|
-
###
|
|
1074
|
+
### Component lifecycle
|
|
1108
1075
|
|
|
1109
|
-
1. `constructor(props)`
|
|
1110
|
-
2. `render()`
|
|
1111
|
-
3. `mount()`
|
|
1112
|
-
4. `onMount()`
|
|
1113
|
-
5. `onAttached()`
|
|
1114
|
-
6. `update()`
|
|
1115
|
-
7. `updateProps(newProps)`
|
|
1076
|
+
1. `constructor(props)` — instance creation, `this.props` wrapped in reactive Proxy
|
|
1077
|
+
2. `render()` — returns JSX (**called once** on mount)
|
|
1078
|
+
3. `mount()` — initial JSX render to DOM fragment
|
|
1079
|
+
4. `onMount()` — hook after first mount (optional)
|
|
1080
|
+
5. `onAttached()` — hook after insertion into document (optional). Called on `attach(parent)`.
|
|
1081
|
+
6. `update()` — structural update (e.g. on route change). For the same child component, only signals are updated via `updateProps`
|
|
1082
|
+
7. `updateProps(newProps)` — updates prop signals (fine-grained, no full re-render)
|
|
1116
1083
|
|
|
1117
1084
|
#### onMount vs onAttached
|
|
1118
1085
|
|
|
1119
1086
|
| | `onMount()` | `onAttached()` |
|
|
1120
1087
|
|---|---|---|
|
|
1121
|
-
|
|
|
1122
|
-
|
|
|
1123
|
-
|
|
|
1088
|
+
| **When** | Right after `mount()`, tree built, refs assigned | After `attach(parent)`, element in document |
|
|
1089
|
+
| **Element in document** | May not be yet (root in fragment) | Yes |
|
|
1090
|
+
| **Refs** | Available | Available |
|
|
1124
1091
|
|
|
1125
|
-
**`onMount()`** —
|
|
1126
|
-
-
|
|
1127
|
-
-
|
|
1128
|
-
-
|
|
1092
|
+
**`onMount()`** — for initialization that doesn't need the document:
|
|
1093
|
+
- Subscriptions to store/observable/services
|
|
1094
|
+
- Internal state setup
|
|
1095
|
+
- Adding handlers (works on detached nodes too)
|
|
1129
1096
|
|
|
1130
|
-
**`onAttached()`** —
|
|
1097
|
+
**`onAttached()`** — for operations that require the element in the document:
|
|
1131
1098
|
- `focus()`, `scrollIntoView()`
|
|
1132
|
-
- `getBoundingClientRect()`,
|
|
1133
|
-
-
|
|
1099
|
+
- `getBoundingClientRect()`, layout measurement
|
|
1100
|
+
- Any DOM API that only works on attached nodes
|
|
1134
1101
|
|
|
1135
1102
|
```tsx
|
|
1136
1103
|
@View
|
|
@@ -1138,62 +1105,22 @@ class SearchInput {
|
|
|
1138
1105
|
@ref private input!: HTMLInputElement;
|
|
1139
1106
|
|
|
1140
1107
|
onMount() {
|
|
1141
|
-
this.store.subscribe(this.handleChange); //
|
|
1108
|
+
this.store.subscribe(this.handleChange); // subscription — document not needed
|
|
1142
1109
|
}
|
|
1143
1110
|
|
|
1144
1111
|
onAttached() {
|
|
1145
|
-
this.input.focus(); //
|
|
1112
|
+
this.input.focus(); // focus — needs document
|
|
1146
1113
|
}
|
|
1147
1114
|
}
|
|
1148
1115
|
```
|
|
1149
1116
|
|
|
1150
|
-
###
|
|
1151
|
-
|
|
1152
|
-
Компилятор трансформирует директивы в вызовы `$`, но их можно использовать и напрямую.
|
|
1153
|
-
|
|
1154
|
-
**Статические** (условие вычисляется один раз):
|
|
1155
|
-
|
|
1156
|
-
```tsx
|
|
1157
|
-
import { $ } from "@helfy/helfy";
|
|
1158
|
-
|
|
1159
|
-
render() {
|
|
1160
|
-
return (
|
|
1161
|
-
<div>
|
|
1162
|
-
{$._if(this.visible, <span>Видно</span>)}
|
|
1163
|
-
{$._ifelse(this.count > 0,
|
|
1164
|
-
<span>Положительное</span>,
|
|
1165
|
-
<span>Отрицательное или ноль</span>
|
|
1166
|
-
)}
|
|
1167
|
-
{$._forin(this.items, (item, index) => (
|
|
1168
|
-
<div $_key={item.id}>{index}: {item.name}</div>
|
|
1169
|
-
))}
|
|
1170
|
-
</div>
|
|
1171
|
-
);
|
|
1172
|
-
}
|
|
1173
|
-
```
|
|
1174
|
-
|
|
1175
|
-
**Реактивные** (условие и ветки — thunks, пересчитываются при изменении signals):
|
|
1176
|
-
|
|
1177
|
-
```tsx
|
|
1178
|
-
{$._rIf(() => this.visible, () => <span>Видно</span>)}
|
|
1179
|
-
{$._rIfElse(() => this.count > 0,
|
|
1180
|
-
() => <span>Положительное</span>,
|
|
1181
|
-
() => <span>Отрицательное или ноль</span>
|
|
1182
|
-
)}
|
|
1183
|
-
{$._rForin(() => this.items, (item, index) => (
|
|
1184
|
-
<div $_key={item.id}>{index}: {item.name}</div>
|
|
1185
|
-
))}
|
|
1186
|
-
```
|
|
1187
|
-
|
|
1188
|
-
Компилятор автоматически выбирает реактивный вариант (`_rIf`, `_rIfElse`, `_rForin`) при генерации кода из директив `@if` / `@for`.
|
|
1189
|
-
|
|
1190
|
-
### Слоты (content projection)
|
|
1117
|
+
### Slots (content projection)
|
|
1191
1118
|
|
|
1192
|
-
Helfy
|
|
1119
|
+
Helfy supports named slots with fallback content and override in child components.
|
|
1193
1120
|
|
|
1194
|
-
####
|
|
1121
|
+
#### Slot provider (`@View` component)
|
|
1195
1122
|
|
|
1196
|
-
|
|
1123
|
+
Slots are declared in JSX via the `@slot:name(...)` directive inside `render()`:
|
|
1197
1124
|
|
|
1198
1125
|
```tsx
|
|
1199
1126
|
import { View } from "@helfy/helfy";
|
|
@@ -1203,24 +1130,24 @@ class AppLayout {
|
|
|
1203
1130
|
render() {
|
|
1204
1131
|
return (
|
|
1205
1132
|
<section class="layout">
|
|
1206
|
-
{/*
|
|
1207
|
-
@slot:header({ title: "
|
|
1133
|
+
{/* Named slot header with fallback markup */}
|
|
1134
|
+
@slot:header({ title: "Task list" }) fallback {
|
|
1208
1135
|
<header class="mb-4">
|
|
1209
1136
|
<h1 class="text-2xl font-bold text-gray-900">
|
|
1210
|
-
|
|
1137
|
+
Task list
|
|
1211
1138
|
</h1>
|
|
1212
1139
|
</header>
|
|
1213
1140
|
}
|
|
1214
1141
|
|
|
1215
|
-
{/*
|
|
1142
|
+
{/* Named slot content with fallback and @if inside */}
|
|
1216
1143
|
@slot:content({ store: this.props.store, filtered: this.props.filtered, hasTodos: this.props.hasTodos }) fallback {
|
|
1217
1144
|
@if (this.props.hasTodos) {
|
|
1218
1145
|
<section class="pt-3 border-t border-gray-200 text-sm text-gray-600">
|
|
1219
1146
|
<p class="mb-1">
|
|
1220
|
-
|
|
1221
|
-
|
|
1147
|
+
Total: {this.props.store.todos.length}, active: {this.props.store.activeCount},
|
|
1148
|
+
completed: {this.props.store.completedCount}
|
|
1222
1149
|
</p>
|
|
1223
|
-
<p
|
|
1150
|
+
<p>Filtered: {this.props.filtered.length}</p>
|
|
1224
1151
|
</section>
|
|
1225
1152
|
}
|
|
1226
1153
|
}
|
|
@@ -1230,15 +1157,15 @@ class AppLayout {
|
|
|
1230
1157
|
}
|
|
1231
1158
|
```
|
|
1232
1159
|
|
|
1233
|
-
|
|
1160
|
+
Provider rules:
|
|
1234
1161
|
|
|
1235
|
-
- `@slot:header({ ... })` —
|
|
1236
|
-
-
|
|
1237
|
-
-
|
|
1162
|
+
- `@slot:header({ ... })` — declares the named slot `header` and invokes it.
|
|
1163
|
+
- The `fallback { ... }` block (optional) defines default markup when the slot is not overridden.
|
|
1164
|
+
- Inside `fallback` you can use `@if`, `@for`, and regular JSX.
|
|
1238
1165
|
|
|
1239
|
-
####
|
|
1166
|
+
#### Slot consumer (override in JSX)
|
|
1240
1167
|
|
|
1241
|
-
|
|
1168
|
+
Override a slot in a child component via `@slot.name(...) { ... }` inside JSX children:
|
|
1242
1169
|
|
|
1243
1170
|
```tsx
|
|
1244
1171
|
@View
|
|
@@ -1254,20 +1181,20 @@ class TodoApp {
|
|
|
1254
1181
|
filtered={filtered}
|
|
1255
1182
|
hasTodos={this.hasTodos}
|
|
1256
1183
|
>
|
|
1257
|
-
{/*
|
|
1184
|
+
{/* Override header slot */}
|
|
1258
1185
|
@slot.header({ title }) {
|
|
1259
1186
|
<header class="mb-5">
|
|
1260
1187
|
<h1 class="mb-4 text-2xl font-bold text-gray-900">
|
|
1261
|
-
|
|
1188
|
+
Tasks ({title})
|
|
1262
1189
|
</h1>
|
|
1263
1190
|
<TodoInput
|
|
1264
|
-
placeholder="
|
|
1191
|
+
placeholder="Add task…"
|
|
1265
1192
|
onSubmit={(text) => store.add(text)}
|
|
1266
1193
|
/>
|
|
1267
1194
|
</header>
|
|
1268
1195
|
}
|
|
1269
1196
|
|
|
1270
|
-
{/*
|
|
1197
|
+
{/* Override content slot, @if/@for allowed inside */}
|
|
1271
1198
|
@slot.content({ store, filtered, hasTodos }) {
|
|
1272
1199
|
@if (hasTodos) {
|
|
1273
1200
|
<section class="pt-3 border-t border-gray-200">
|
|
@@ -1293,8 +1220,8 @@ class TodoApp {
|
|
|
1293
1220
|
}
|
|
1294
1221
|
```
|
|
1295
1222
|
|
|
1296
|
-
|
|
1223
|
+
Syntax summary:
|
|
1297
1224
|
|
|
1298
|
-
- `@slot
|
|
1299
|
-
- `@slot
|
|
1300
|
-
-
|
|
1225
|
+
- `@slot:name({ props }) fallback { FallbackJSX }` — declare and invoke the slot in the provider.
|
|
1226
|
+
- `@slot.name({ ctx }) { OverrideJSX }` — override the slot in the consumer.
|
|
1227
|
+
- All directives (`@if`, `@for`, `@empty`) and regular JSX work inside `FallbackJSX` and `OverrideJSX`.
|