create-mendix-widget-gleam 1.1.4 → 2.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/README.md +6 -3
- package/package.json +1 -1
- package/src/index.mjs +152 -8
- package/template/docs/glendix_guide.md +839 -228
- package/template/gleam.toml +1 -1
- package/template/src/components/hello_world.gleam +2 -2
|
@@ -48,7 +48,7 @@ glendix = { path = "../glendix" }
|
|
|
48
48
|
gleam run -m glendix/install
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
> `glendix/install`은 패키지 매니저를 자동 감지하여 의존성을 설치하고, `bindings.json`이 있으면 외부 React 컴포넌트 바인딩도 자동 생성합니다.
|
|
51
|
+
> `glendix/install`은 패키지 매니저를 자동 감지하여 의존성을 설치하고, `bindings.json`이 있으면 외부 React 컴포넌트 바인딩을, `widgets/` 디렉토리에 `.mpk` 파일이 있으면 위젯 바인딩도 자동 생성합니다.
|
|
52
52
|
|
|
53
53
|
#### 4) 빌드 확인
|
|
54
54
|
|
|
@@ -63,14 +63,14 @@ gleam build
|
|
|
63
63
|
```gleam
|
|
64
64
|
import glendix/mendix
|
|
65
65
|
import glendix/react.{type JsProps, type ReactElement}
|
|
66
|
+
import glendix/react/attribute
|
|
66
67
|
import glendix/react/html
|
|
67
|
-
import glendix/react/prop
|
|
68
68
|
|
|
69
69
|
pub fn widget(props: JsProps) -> ReactElement {
|
|
70
70
|
let greeting = mendix.get_string_prop(props, "greetingText")
|
|
71
71
|
|
|
72
|
-
html.div(
|
|
73
|
-
html.
|
|
72
|
+
html.div([attribute.class("my-widget")], [
|
|
73
|
+
html.h1([attribute.class("title")], [react.text(greeting)]),
|
|
74
74
|
html.p_([react.text("glendix로 만든 첫 번째 위젯입니다!")]),
|
|
75
75
|
])
|
|
76
76
|
}
|
|
@@ -101,7 +101,7 @@ glendix의 핵심 설계 원칙은 **opaque 타입을 통한 타입 안전성**
|
|
|
101
101
|
// 이 타입들은 내부 구현이 숨겨져 있어 잘못된 접근을 컴파일 타임에 차단합니다
|
|
102
102
|
ReactElement // React 요소
|
|
103
103
|
JsProps // Mendix 프로퍼티 객체
|
|
104
|
-
|
|
104
|
+
Attribute // HTML/React 속성
|
|
105
105
|
EditableValue // Mendix 편집 가능한 값
|
|
106
106
|
ActionValue // Mendix 액션
|
|
107
107
|
ListValue // Mendix 리스트 데이터
|
|
@@ -127,20 +127,38 @@ case mendix.get_prop(props, "myAttr") {
|
|
|
127
127
|
}
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
### 2.4
|
|
130
|
+
### 2.4 Attribute 리스트 API
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
HTML 속성은 `[attribute.xxx(), event.xxx()]` 선언적 리스트 패턴으로 구성합니다:
|
|
133
133
|
|
|
134
134
|
```gleam
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
import glendix/react/attribute
|
|
136
|
+
import glendix/react/event
|
|
137
|
+
|
|
138
|
+
html.button(
|
|
139
|
+
[
|
|
140
|
+
attribute.class("btn btn-primary"),
|
|
141
|
+
attribute.type_("submit"),
|
|
142
|
+
attribute.disabled(False),
|
|
143
|
+
event.on_click(fn(_event) { Nil }),
|
|
144
|
+
],
|
|
145
|
+
[react.text("Submit")],
|
|
146
|
+
)
|
|
141
147
|
```
|
|
142
148
|
|
|
143
|
-
|
|
149
|
+
조건부 속성은 `attribute.none()`으로 처리합니다:
|
|
150
|
+
|
|
151
|
+
```gleam
|
|
152
|
+
html.input([
|
|
153
|
+
attribute.class("input"),
|
|
154
|
+
case is_error {
|
|
155
|
+
True -> attribute.class("input-error")
|
|
156
|
+
False -> attribute.none()
|
|
157
|
+
},
|
|
158
|
+
])
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
여러 `attribute.class()` 호출 시 자동으로 공백 구분 병합됩니다.
|
|
144
162
|
|
|
145
163
|
---
|
|
146
164
|
|
|
@@ -154,20 +172,20 @@ let my_props =
|
|
|
154
172
|
|
|
155
173
|
```gleam
|
|
156
174
|
import glendix/react
|
|
157
|
-
import glendix/react/
|
|
175
|
+
import glendix/react/attribute
|
|
158
176
|
|
|
159
|
-
//
|
|
160
|
-
react.
|
|
177
|
+
// Attribute 리스트가 있는 엘리먼트
|
|
178
|
+
react.element("div", [attribute.class("container")], [
|
|
161
179
|
react.text("Hello"),
|
|
162
180
|
])
|
|
163
181
|
|
|
164
|
-
//
|
|
165
|
-
react.
|
|
182
|
+
// 속성 없이 간단하게
|
|
183
|
+
react.element_("div", [
|
|
166
184
|
react.text("Hello"),
|
|
167
185
|
])
|
|
168
186
|
|
|
169
187
|
// Self-closing 엘리먼트 (input, img, br 등)
|
|
170
|
-
react.
|
|
188
|
+
react.void_element("input", [attribute.type_("text")])
|
|
171
189
|
```
|
|
172
190
|
|
|
173
191
|
#### 텍스트 노드
|
|
@@ -182,7 +200,7 @@ react.text("Count: " <> int.to_string(count))
|
|
|
182
200
|
```gleam
|
|
183
201
|
// 기본 Fragment
|
|
184
202
|
react.fragment([
|
|
185
|
-
html.
|
|
203
|
+
html.h1([attribute.class("title")], [react.text("제목")]),
|
|
186
204
|
html.p_([react.text("내용")]),
|
|
187
205
|
])
|
|
188
206
|
|
|
@@ -231,43 +249,63 @@ bun add recharts
|
|
|
231
249
|
|
|
232
250
|
**4단계: 순수 Gleam 래퍼 모듈 작성** (편의용, 선택사항)
|
|
233
251
|
|
|
252
|
+
html.gleam과 동일한 호출 패턴으로 작성하면 일관된 API를 제공할 수 있습니다:
|
|
253
|
+
|
|
234
254
|
```gleam
|
|
235
255
|
// src/chart/recharts.gleam — 순수 Gleam, FFI 없음!
|
|
236
256
|
import glendix/binding
|
|
237
|
-
import glendix/react.{type
|
|
257
|
+
import glendix/react.{type ReactElement}
|
|
258
|
+
import glendix/react/attribute.{type Attribute}
|
|
238
259
|
|
|
239
260
|
fn m() { binding.module("recharts") }
|
|
240
261
|
|
|
241
|
-
|
|
242
|
-
pub fn
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
pub fn
|
|
247
|
-
binding.resolve(m(), "
|
|
262
|
+
// attrs + children 컴포넌트 (html.div 패턴)
|
|
263
|
+
pub fn pie_chart(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
|
|
264
|
+
react.component_el(binding.resolve(m(), "PieChart"), attrs, children)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
pub fn pie(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
|
|
268
|
+
react.component_el(binding.resolve(m(), "Pie"), attrs, children)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
pub fn responsive_container(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
|
|
272
|
+
react.component_el(binding.resolve(m(), "ResponsiveContainer"), attrs, children)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// children 없는 컴포넌트 (html.input 패턴)
|
|
276
|
+
pub fn cell(attrs: List(Attribute)) -> ReactElement {
|
|
277
|
+
react.void_component_el(binding.resolve(m(), "Cell"), attrs)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
pub fn tooltip(attrs: List(Attribute)) -> ReactElement {
|
|
281
|
+
react.void_component_el(binding.resolve(m(), "Tooltip"), attrs)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
pub fn legend(attrs: List(Attribute)) -> ReactElement {
|
|
285
|
+
react.void_component_el(binding.resolve(m(), "Legend"), attrs)
|
|
248
286
|
}
|
|
249
287
|
```
|
|
250
288
|
|
|
251
289
|
**5단계: 위젯에서 사용**
|
|
252
290
|
|
|
291
|
+
html.gleam과 동일한 호출 구조입니다:
|
|
292
|
+
|
|
253
293
|
```gleam
|
|
254
294
|
import chart/recharts
|
|
255
295
|
import glendix/react
|
|
256
|
-
import glendix/react/
|
|
296
|
+
import glendix/react/attribute
|
|
257
297
|
|
|
258
298
|
pub fn my_pie_chart(data) -> react.ReactElement {
|
|
259
|
-
|
|
260
|
-
|
|
299
|
+
recharts.responsive_container(
|
|
300
|
+
[attribute.attribute("width", 400), attribute.attribute("height", 300)],
|
|
261
301
|
[
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|> prop.any("data", data)
|
|
266
|
-
|> prop.string("dataKey", "value"),
|
|
302
|
+
recharts.pie_chart([], [
|
|
303
|
+
recharts.pie(
|
|
304
|
+
[attribute.attribute("data", data), attribute.attribute("dataKey", "value")],
|
|
267
305
|
[],
|
|
268
306
|
),
|
|
269
|
-
|
|
270
|
-
|
|
307
|
+
recharts.tooltip([]),
|
|
308
|
+
recharts.legend([]),
|
|
271
309
|
]),
|
|
272
310
|
],
|
|
273
311
|
)
|
|
@@ -280,68 +318,368 @@ pub fn my_pie_chart(data) -> react.ReactElement {
|
|
|
280
318
|
import glendix/binding
|
|
281
319
|
|
|
282
320
|
let rc = binding.module("recharts")
|
|
283
|
-
react.
|
|
321
|
+
react.component_el(binding.resolve(rc, "PieChart"), attrs, children)
|
|
322
|
+
react.void_component_el(binding.resolve(rc, "Tooltip"), attrs)
|
|
284
323
|
```
|
|
285
324
|
|
|
286
|
-
|
|
325
|
+
#### .mpk 위젯 컴포넌트 사용
|
|
326
|
+
|
|
327
|
+
`glendix/widget` 모듈로 `widgets/` 디렉토리의 `.mpk` 파일(Mendix 위젯 빌드 결과물)을 React 컴포넌트로 사용합니다. 다른 위젯 안에서 기존 Mendix 위젯을 렌더링할 때 유용합니다.
|
|
328
|
+
|
|
329
|
+
**1단계: `.mpk` 파일 배치**
|
|
330
|
+
|
|
331
|
+
프로젝트 루트에 `widgets/` 디렉토리를 만들고 `.mpk` 파일을 복사합니다:
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
my_widget/
|
|
335
|
+
├── widgets/
|
|
336
|
+
│ ├── Switch.mpk
|
|
337
|
+
│ └── Badge.mpk
|
|
338
|
+
├── src/
|
|
339
|
+
├── gleam.toml
|
|
340
|
+
└── ...
|
|
341
|
+
```
|
|
287
342
|
|
|
288
|
-
`glendix/
|
|
343
|
+
**2단계: `gleam run -m glendix/install`** 실행 (위젯 바인딩 자동 생성)
|
|
289
344
|
|
|
290
|
-
|
|
345
|
+
install 시 두 가지가 자동 수행됩니다:
|
|
346
|
+
- `.mpk` 내부의 `.mjs`와 `.css`가 추출되고, `widget_ffi.mjs`가 생성됩니다
|
|
347
|
+
- `.mpk` XML의 `<property>` 정의가 부모 위젯 XML(`src/{WidgetName}.xml`)에 `<propertyGroup caption="{위젯명}">` 으로 자동 주입됩니다 (동일 caption이 이미 있으면 건너뜀)
|
|
348
|
+
|
|
349
|
+
예를 들어 `Switch.mpk`를 설치하면, 부모 위젯 XML에 다음이 자동 추가됩니다:
|
|
350
|
+
|
|
351
|
+
```xml
|
|
352
|
+
<propertyGroup caption="Switch">
|
|
353
|
+
<property key="booleanAttribute" type="attribute">
|
|
354
|
+
<caption>Boolean attribute</caption>
|
|
355
|
+
<description>Attribute to toggle</description>
|
|
356
|
+
<attributeTypes>
|
|
357
|
+
<attributeType name="Boolean" />
|
|
358
|
+
</attributeTypes>
|
|
359
|
+
</property>
|
|
360
|
+
<property key="action" type="action" required="false">
|
|
361
|
+
<caption>On change</caption>
|
|
362
|
+
<description>Action to be performed when the switch is toggled</description>
|
|
363
|
+
</property>
|
|
364
|
+
</propertyGroup>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**3단계: Gleam 코드에서 사용**
|
|
291
368
|
|
|
292
369
|
```gleam
|
|
293
|
-
import glendix/
|
|
370
|
+
import glendix/mendix
|
|
371
|
+
import glendix/widget
|
|
372
|
+
import glendix/react
|
|
373
|
+
import glendix/react/attribute
|
|
294
374
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
375
|
+
// props에서 자동 주입된 속성을 읽어 위젯에 전달
|
|
376
|
+
let boolean_attr = mendix.get_prop_required(props, "booleanAttribute")
|
|
377
|
+
let action = mendix.get_prop_required(props, "action")
|
|
378
|
+
|
|
379
|
+
// widgets/Switch.mpk의 Switch 컴포넌트 사용
|
|
380
|
+
let switch_comp = widget.component("Switch")
|
|
381
|
+
react.component_el(switch_comp, [
|
|
382
|
+
attribute.attribute("booleanAttribute", boolean_attr),
|
|
383
|
+
attribute.attribute("action", action),
|
|
384
|
+
], [])
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
위젯의 Props는 기존 `attribute.attribute(key, value)` 범용 함수로 전달합니다. 위젯 이름은 `.mpk` 내부 XML의 `<name>` 태그 값(PascalCase)을, property key는 `.mpk` XML의 원본 key를 그대로 사용합니다.
|
|
388
|
+
|
|
389
|
+
> `binding` 모듈과 달리 `widget` 모듈은 1 mpk = 1 컴포넌트이므로 `module` + `resolve` 2단계 없이 `component("Name")` 한 번에 가져옵니다.
|
|
390
|
+
|
|
391
|
+
### 3.2 HTML 속성
|
|
392
|
+
|
|
393
|
+
`glendix/react/attribute` 모듈은 90+ HTML 속성 함수를 제공합니다.
|
|
394
|
+
|
|
395
|
+
#### 범용 속성
|
|
396
|
+
|
|
397
|
+
```gleam
|
|
398
|
+
import glendix/react/attribute
|
|
399
|
+
|
|
400
|
+
// escape hatch — 임의의 속성 설정
|
|
401
|
+
attribute.attribute("data-custom", "value")
|
|
402
|
+
|
|
403
|
+
// aria-* 속성
|
|
404
|
+
attribute.aria("label", "닫기 버튼")
|
|
405
|
+
|
|
406
|
+
// data-* 속성
|
|
407
|
+
attribute.data("testid", "my-element")
|
|
302
408
|
```
|
|
303
409
|
|
|
304
410
|
#### 자주 쓰는 속성
|
|
305
411
|
|
|
306
412
|
```gleam
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
413
|
+
attribute.class("btn btn-primary") // className (자동 병합)
|
|
414
|
+
attribute.classes(["btn", "large"]) // 여러 클래스 공백 결합
|
|
415
|
+
attribute.id("main-card")
|
|
416
|
+
attribute.key("item-1") // React key
|
|
417
|
+
attribute.ref(my_ref) // React ref
|
|
418
|
+
attribute.ref_(fn(el) { Nil }) // callback ref
|
|
313
419
|
```
|
|
314
420
|
|
|
315
|
-
####
|
|
421
|
+
#### 폼 속성
|
|
422
|
+
|
|
423
|
+
```gleam
|
|
424
|
+
attribute.type_("text")
|
|
425
|
+
attribute.value("입력값")
|
|
426
|
+
attribute.placeholder("입력하세요")
|
|
427
|
+
attribute.name("username")
|
|
428
|
+
attribute.disabled(True)
|
|
429
|
+
attribute.checked(True)
|
|
430
|
+
attribute.readonly(True)
|
|
431
|
+
attribute.required(True)
|
|
432
|
+
attribute.max_length(100)
|
|
433
|
+
attribute.min_length(3)
|
|
434
|
+
attribute.pattern("[0-9]+")
|
|
435
|
+
attribute.autocomplete("email")
|
|
436
|
+
attribute.autofocus(True)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
#### 인라인 스타일
|
|
316
440
|
|
|
317
441
|
```gleam
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
442
|
+
// CSS 속성명 자동 camelCase 변환
|
|
443
|
+
attribute.style([
|
|
444
|
+
#("background-color", "#f0f0f0"),
|
|
445
|
+
#("padding", "16px"),
|
|
446
|
+
#("border-radius", "8px"),
|
|
447
|
+
])
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
#### 미디어/리소스 속성
|
|
326
451
|
|
|
327
|
-
|
|
328
|
-
|
|
452
|
+
```gleam
|
|
453
|
+
attribute.src("image.png")
|
|
454
|
+
attribute.alt("설명")
|
|
455
|
+
attribute.loading("lazy") // 지연 로딩
|
|
456
|
+
attribute.fetch_priority("high") // 로딩 우선순위
|
|
457
|
+
attribute.cross_origin("anonymous")
|
|
458
|
+
attribute.srcset("img-2x.png 2x")
|
|
459
|
+
attribute.sizes("(max-width: 600px) 100vw")
|
|
329
460
|
```
|
|
330
461
|
|
|
331
|
-
|
|
462
|
+
#### 모바일/국제화 속성 (Round 3)
|
|
463
|
+
|
|
464
|
+
```gleam
|
|
465
|
+
attribute.input_mode("numeric") // 가상 키보드
|
|
466
|
+
attribute.enter_key_hint("search") // Enter 키 동작
|
|
467
|
+
attribute.auto_capitalize("words") // 대문자 변환
|
|
468
|
+
attribute.capture_("environment") // 카메라 선택
|
|
469
|
+
```
|
|
332
470
|
|
|
333
|
-
|
|
471
|
+
#### Popover API (Round 3)
|
|
472
|
+
|
|
473
|
+
```gleam
|
|
474
|
+
html.button(
|
|
475
|
+
[
|
|
476
|
+
attribute.popover_target("my-popover"),
|
|
477
|
+
attribute.popover_target_action("toggle"),
|
|
478
|
+
],
|
|
479
|
+
[react.text("열기")],
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
html.div(
|
|
483
|
+
[attribute.id("my-popover"), attribute.popover("auto")],
|
|
484
|
+
[react.text("팝오버 내용")],
|
|
485
|
+
)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### 3.3 이벤트 핸들러
|
|
489
|
+
|
|
490
|
+
`glendix/react/event` 모듈은 15개 이벤트 타입과 148+ 핸들러를 제공합니다.
|
|
491
|
+
|
|
492
|
+
#### 이벤트 타입
|
|
493
|
+
|
|
494
|
+
| 타입 | 용도 |
|
|
495
|
+
|---|---|
|
|
496
|
+
| `Event` | 기본/미디어/UI 이벤트 |
|
|
497
|
+
| `MouseEvent` | 클릭, 마우스 이벤트 |
|
|
498
|
+
| `ChangeEvent` | input 변경 이벤트 |
|
|
499
|
+
| `KeyboardEvent` | 키보드 이벤트 |
|
|
500
|
+
| `FormEvent` | 폼 제출 이벤트 |
|
|
501
|
+
| `FocusEvent` | 포커스/블러 이벤트 |
|
|
502
|
+
| `InputEvent` | 입력/beforeinput 이벤트 |
|
|
503
|
+
| `PointerEvent` | 포인터 이벤트 |
|
|
504
|
+
| `DragEvent` | 드래그 앤 드롭 이벤트 |
|
|
505
|
+
| `ClipboardEvent` | 복사/잘라내기/붙여넣기 |
|
|
506
|
+
| `TouchEvent` | 터치 이벤트 |
|
|
507
|
+
| `WheelEvent` | 휠 스크롤 이벤트 |
|
|
508
|
+
| `AnimationEvent` | CSS 애니메이션 이벤트 |
|
|
509
|
+
| `TransitionEvent` | CSS 트랜지션 이벤트 |
|
|
510
|
+
| `CompositionEvent` | CJK/IME 입력 이벤트 |
|
|
511
|
+
|
|
512
|
+
#### 핸들러 사용
|
|
513
|
+
|
|
514
|
+
```gleam
|
|
515
|
+
import glendix/react/event
|
|
516
|
+
|
|
517
|
+
// 마우스 이벤트
|
|
518
|
+
event.on_click(fn(e) { handle_click(e) })
|
|
519
|
+
event.on_double_click(fn(e) { Nil })
|
|
520
|
+
event.on_context_menu(fn(e) { Nil })
|
|
521
|
+
event.on_mouse_enter(fn(e) { Nil })
|
|
522
|
+
|
|
523
|
+
// 키보드 이벤트
|
|
524
|
+
event.on_key_down(fn(e) {
|
|
525
|
+
case event.key(e) {
|
|
526
|
+
"Enter" -> submit()
|
|
527
|
+
"Escape" -> cancel()
|
|
528
|
+
_ -> Nil
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// 폼/입력 이벤트
|
|
533
|
+
event.on_change(fn(e) { set_name(event.target_value(e)) })
|
|
534
|
+
event.on_input(fn(e) { Nil })
|
|
535
|
+
event.on_submit(fn(e) {
|
|
536
|
+
event.prevent_default(e)
|
|
537
|
+
handle_submit()
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
// 포커스 이벤트
|
|
541
|
+
event.on_focus(fn(e) { Nil })
|
|
542
|
+
event.on_blur(fn(e) { Nil })
|
|
543
|
+
|
|
544
|
+
// 로드/에러 이벤트 (Round 3)
|
|
545
|
+
event.on_load(fn(e) { Nil }) // img/iframe/script 로드 완료
|
|
546
|
+
event.on_error(fn(e) { Nil }) // 리소스 로드 실패
|
|
547
|
+
|
|
548
|
+
// 입력 전 이벤트 (Round 3)
|
|
549
|
+
event.on_before_input(fn(e) { Nil }) // 입력 값 변경 전 필터링
|
|
550
|
+
|
|
551
|
+
// 미디어 이벤트
|
|
552
|
+
event.on_play(fn(e) { Nil })
|
|
553
|
+
event.on_pause(fn(e) { Nil })
|
|
554
|
+
event.on_time_update(fn(e) { Nil })
|
|
555
|
+
event.on_load_start(fn(e) { Nil }) // 미디어 로드 시작 (Round 3)
|
|
556
|
+
|
|
557
|
+
// 드래그 이벤트
|
|
558
|
+
event.on_drag_start(fn(e) { Nil })
|
|
559
|
+
event.on_drop(fn(e) { Nil })
|
|
560
|
+
|
|
561
|
+
// 컴포지션 이벤트 (한국어 입력 필수)
|
|
562
|
+
event.on_composition_start(fn(e) { Nil })
|
|
563
|
+
event.on_composition_end(fn(e) { Nil })
|
|
564
|
+
|
|
565
|
+
// 캡처 단계 (모든 핸들러에 _capture 접미사)
|
|
566
|
+
event.on_click_capture(fn(e) { Nil })
|
|
567
|
+
event.on_key_down_capture(fn(e) { Nil })
|
|
568
|
+
|
|
569
|
+
// 범용 이벤트 핸들러 (escape hatch)
|
|
570
|
+
event.on("onCustomEvent", fn(e) { Nil })
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
#### 이벤트 접근자
|
|
574
|
+
|
|
575
|
+
```gleam
|
|
576
|
+
// 공통
|
|
577
|
+
event.target(e) // 이벤트 대상 요소 (Dynamic)
|
|
578
|
+
event.current_target(e) // 핸들러가 등록된 요소 (Dynamic)
|
|
579
|
+
event.target_value(e) // input/textarea 값 (String)
|
|
580
|
+
event.prevent_default(e) // 기본 동작 방지
|
|
581
|
+
event.stop_propagation(e) // 전파 중지
|
|
582
|
+
event.bubbles(e) // 버블링 여부 (Bool)
|
|
583
|
+
event.cancelable(e) // 취소 가능 여부 (Bool)
|
|
584
|
+
event.is_trusted(e) // 사용자 발생 여부 (Bool)
|
|
585
|
+
event.time_stamp(e) // 타임스탬프 (Float)
|
|
586
|
+
event.native_event(e) // 네이티브 브라우저 이벤트 (Dynamic)
|
|
587
|
+
event.is_default_prevented(e)
|
|
588
|
+
event.is_propagation_stopped(e)
|
|
589
|
+
|
|
590
|
+
// 이벤트 유틸리티 (Round 3)
|
|
591
|
+
event.persist(e) // 이벤트 풀링 방지 (React 17+ 호환)
|
|
592
|
+
event.is_persistent(e) // 영속적 여부 (Bool)
|
|
593
|
+
|
|
594
|
+
// 마우스
|
|
595
|
+
event.client_x(e) // Float
|
|
596
|
+
event.client_y(e) // Float
|
|
597
|
+
event.page_x(e)
|
|
598
|
+
event.page_y(e)
|
|
599
|
+
event.offset_x(e)
|
|
600
|
+
event.offset_y(e)
|
|
601
|
+
event.screen_x(e)
|
|
602
|
+
event.screen_y(e)
|
|
603
|
+
event.movement_x(e)
|
|
604
|
+
event.movement_y(e)
|
|
605
|
+
event.button(e) // Int (0=좌, 1=중, 2=우)
|
|
606
|
+
event.buttons(e)
|
|
607
|
+
event.mouse_ctrl_key(e)
|
|
608
|
+
event.mouse_shift_key(e)
|
|
609
|
+
event.mouse_alt_key(e)
|
|
610
|
+
event.mouse_meta_key(e)
|
|
611
|
+
event.get_modifier_state(e, "Control")
|
|
612
|
+
|
|
613
|
+
// 키보드
|
|
614
|
+
event.key(e) // "Enter", "Escape" 등
|
|
615
|
+
event.code(e) // "KeyA", "Space" 등
|
|
616
|
+
event.ctrl_key(e)
|
|
617
|
+
event.shift_key(e)
|
|
618
|
+
event.alt_key(e)
|
|
619
|
+
event.meta_key(e)
|
|
620
|
+
event.repeat(e)
|
|
621
|
+
|
|
622
|
+
// 휠
|
|
623
|
+
event.delta_x(e)
|
|
624
|
+
event.delta_y(e)
|
|
625
|
+
event.delta_z(e)
|
|
626
|
+
event.delta_mode(e)
|
|
627
|
+
|
|
628
|
+
// 터치
|
|
629
|
+
event.touches(e) // Dynamic
|
|
630
|
+
event.changed_touches(e)
|
|
631
|
+
event.target_touches(e)
|
|
632
|
+
|
|
633
|
+
// 포인터
|
|
634
|
+
event.pointer_id(e)
|
|
635
|
+
event.pointer_type(e) // "mouse", "pen", "touch"
|
|
636
|
+
event.pressure(e)
|
|
637
|
+
event.tilt_x(e)
|
|
638
|
+
event.tilt_y(e)
|
|
639
|
+
event.pointer_width(e)
|
|
640
|
+
event.pointer_height(e)
|
|
641
|
+
event.is_primary(e)
|
|
642
|
+
|
|
643
|
+
// 애니메이션
|
|
644
|
+
event.animation_name(e)
|
|
645
|
+
event.animation_elapsed_time(e)
|
|
646
|
+
event.animation_pseudo_element(e)
|
|
647
|
+
|
|
648
|
+
// 트랜지션
|
|
649
|
+
event.property_name(e)
|
|
650
|
+
event.transition_elapsed_time(e)
|
|
651
|
+
event.transition_pseudo_element(e)
|
|
652
|
+
|
|
653
|
+
// 드래그
|
|
654
|
+
event.data_transfer(e) // Dynamic
|
|
655
|
+
|
|
656
|
+
// 포커스
|
|
657
|
+
event.focus_related_target(e) // Dynamic
|
|
658
|
+
|
|
659
|
+
// 컴포지션
|
|
660
|
+
event.composition_data(e) // String
|
|
661
|
+
|
|
662
|
+
// 입력
|
|
663
|
+
event.input_data(e) // String
|
|
664
|
+
|
|
665
|
+
// 클립보드
|
|
666
|
+
event.clipboard_data(e) // Dynamic
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
### 3.4 HTML 태그 함수
|
|
670
|
+
|
|
671
|
+
`glendix/react/html` 모듈은 75+ HTML 태그를 위한 편의 함수를 제공합니다. 순수 Gleam으로 구현되어 FFI가 없습니다.
|
|
334
672
|
|
|
335
673
|
```gleam
|
|
336
674
|
import glendix/react/html
|
|
337
|
-
import glendix/react/
|
|
675
|
+
import glendix/react/attribute
|
|
338
676
|
|
|
339
|
-
//
|
|
340
|
-
html.div(
|
|
341
|
-
html.button(
|
|
342
|
-
html.input(
|
|
677
|
+
// Attribute 리스트가 있는 버전
|
|
678
|
+
html.div([attribute.class("container")], children)
|
|
679
|
+
html.button([attribute.type_("submit"), event.on_click(handler)], children)
|
|
680
|
+
html.input([attribute.type_("text"), attribute.value(val)]) // void 엘리먼트
|
|
343
681
|
|
|
344
|
-
//
|
|
682
|
+
// Attribute 없는 버전 (언더스코어 접미사)
|
|
345
683
|
html.div_(children)
|
|
346
684
|
html.span_([react.text("텍스트")])
|
|
347
685
|
html.p_([react.text("문단")])
|
|
@@ -352,16 +690,82 @@ html.p_([react.text("문단")])
|
|
|
352
690
|
| 카테고리 | 태그 |
|
|
353
691
|
|---|---|
|
|
354
692
|
| **컨테이너** | `div`, `span`, `section`, `main`, `header`, `footer`, `nav`, `aside`, `article` |
|
|
355
|
-
| **텍스트** | `p`, `h1`, `
|
|
356
|
-
| **리스트** | `ul`, `ol`, `li` |
|
|
357
|
-
| **폼** | `form`, `button`, `label`, `select`, `option`, `textarea` |
|
|
358
|
-
| **입력** | `input` (void
|
|
359
|
-
| **테이블** | `table`, `thead`, `tbody`, `tr`, `td`, `th` |
|
|
360
|
-
| **링크/미디어** | `a`, `img` (void), `br` (void), `hr` (void) |
|
|
693
|
+
| **텍스트** | `p`, `h1`~`h6`, `strong`, `em`, `small`, `pre`, `code`, `kbd`, `samp`, `var_` |
|
|
694
|
+
| **리스트** | `ul`, `ol`, `li`, `dl`, `dt`, `dd` |
|
|
695
|
+
| **폼** | `form`, `button`, `label`, `select`, `option`, `textarea`, `fieldset`, `legend`, `datalist`, `optgroup`, `output` |
|
|
696
|
+
| **입력** | `input` (void) |
|
|
697
|
+
| **테이블** | `table`, `thead`, `tbody`, `tfoot`, `tr`, `td`, `th`, `colgroup`, `col` (void), `caption` |
|
|
698
|
+
| **링크/미디어** | `a`, `img` (void), `br` (void), `hr` (void), `video`, `audio`, `source` (void), `track` (void), `picture`, `canvas`, `iframe` (void) |
|
|
699
|
+
| **시맨틱** | `details`, `summary`, `dialog`, `figure`, `figcaption`, `blockquote`, `cite`, `abbr`, `mark`, `del`, `ins`, `sub`, `sup`, `time`, `address`, `meter`, `progress` (void), `search`, `hgroup` |
|
|
700
|
+
| **루비 주석** | `ruby`, `rt`, `rp` |
|
|
701
|
+
| **양방향 텍스트** | `bdi`, `bdo` |
|
|
702
|
+
| **기타** | `data_`, `map_`, `wbr` (void), `embed` (void), `area` (void) |
|
|
703
|
+
|
|
704
|
+
### 3.5 SVG 요소
|
|
705
|
+
|
|
706
|
+
`glendix/react/svg` 모듈은 57개 SVG 요소를 제공합니다. 순수 Gleam, FFI 없음.
|
|
361
707
|
|
|
362
|
-
|
|
708
|
+
```gleam
|
|
709
|
+
import glendix/react/svg
|
|
710
|
+
import glendix/react/svg_attribute as sa
|
|
711
|
+
|
|
712
|
+
svg.svg([sa.view_box("0 0 100 100"), sa.xmlns("http://www.w3.org/2000/svg")], [
|
|
713
|
+
svg.circle([sa.cx("50"), sa.cy("50"), sa.r("40"), sa.fill("blue")], []),
|
|
714
|
+
svg.text([sa.x("50"), sa.y("55"), sa.text_anchor("middle"), sa.fill("white")], [
|
|
715
|
+
react.text("Hello"),
|
|
716
|
+
]),
|
|
717
|
+
])
|
|
718
|
+
```
|
|
363
719
|
|
|
364
|
-
|
|
720
|
+
#### SVG 요소 목록
|
|
721
|
+
|
|
722
|
+
| 카테고리 | 요소 |
|
|
723
|
+
|---|---|
|
|
724
|
+
| **컨테이너** | `svg`, `g`, `defs`, `symbol`, `use_`, `marker` |
|
|
725
|
+
| **도형** | `circle`, `ellipse`, `line`, `path`, `polygon`, `polyline`, `rect` |
|
|
726
|
+
| **텍스트** | `text`, `tspan`, `text_path` |
|
|
727
|
+
| **그래디언트/패턴** | `linear_gradient`, `radial_gradient`, `stop` (void), `pattern` |
|
|
728
|
+
| **필터** | `filter`, `fe_color_matrix`, `fe_composite`, `fe_flood` (void), `fe_gaussian_blur` (void), `fe_merge`, `fe_merge_node` (void), `fe_offset` (void), `fe_blend` (void), `fe_drop_shadow` (void) |
|
|
729
|
+
| **필터 프리미티브** | `fe_convolve_matrix`, `fe_diffuse_lighting`, `fe_displacement_map` (void), `fe_distant_light` (void), `fe_image` (void), `fe_morphology` (void), `fe_point_light` (void), `fe_specular_lighting`, `fe_spot_light` (void), `fe_tile`, `fe_turbulence` (void), `fe_func_r/g/b/a` (void), `fe_component_transfer` |
|
|
730
|
+
| **클리핑/마스킹** | `clip_path`, `mask` |
|
|
731
|
+
| **애니메이션** | `animate` (void), `animate_transform` (void), `set` (void), `mpath` (void) |
|
|
732
|
+
| **기타** | `foreign_object`, `image` (void), `title`, `desc`, `switch_` |
|
|
733
|
+
|
|
734
|
+
### 3.6 SVG 속성
|
|
735
|
+
|
|
736
|
+
`glendix/react/svg_attribute` 모듈은 97+ SVG 전용 속성을 제공합니다. 순수 Gleam, FFI 없음.
|
|
737
|
+
|
|
738
|
+
```gleam
|
|
739
|
+
import glendix/react/svg_attribute as sa
|
|
740
|
+
|
|
741
|
+
sa.view_box("0 0 100 100")
|
|
742
|
+
sa.fill("red")
|
|
743
|
+
sa.stroke("black")
|
|
744
|
+
sa.stroke_width("2")
|
|
745
|
+
sa.transform("rotate(45)")
|
|
746
|
+
sa.d("M10 10 L90 90") // path 데이터
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
#### SVG 속성 목록
|
|
750
|
+
|
|
751
|
+
| 카테고리 | 속성 |
|
|
752
|
+
|---|---|
|
|
753
|
+
| **공통** | `view_box`, `xmlns`, `fill`, `stroke`, `stroke_width`, `stroke_linecap`, `stroke_linejoin`, `stroke_dasharray`, `stroke_dashoffset`, `stroke_opacity`, `fill_opacity`, `fill_rule`, `clip_rule`, `opacity`, `transform` |
|
|
754
|
+
| **좌표** | `x`, `y`, `x1`, `y1`, `x2`, `y2`, `cx`, `cy`, `r`, `rx`, `ry`, `dx`, `dy` |
|
|
755
|
+
| **도형** | `d`, `points`, `path_length` |
|
|
756
|
+
| **그래디언트** | `offset`, `stop_color`, `stop_opacity`, `gradient_units`, `gradient_transform`, `spread_method`, `fx`, `fy` |
|
|
757
|
+
| **텍스트** | `text_anchor`, `dominant_baseline`, `font_size`, `font_family`, `font_weight`, `letter_spacing`, `text_decoration`, `alignment_baseline`, `baseline_shift`, `writing_mode`, `text_rendering` |
|
|
758
|
+
| **참조** | `href`, `xlink_href` |
|
|
759
|
+
| **필터** | `filter_attr`, `in_`, `in2`, `result`, `std_deviation`, `flood_color`, `flood_opacity`, `values`, `mode`, `operator_`, `k1`~`k4`, `scale`, `x_channel_selector`, `y_channel_selector` |
|
|
760
|
+
| **마커** | `marker_start`, `marker_mid`, `marker_end`, `marker_height`, `marker_width`, `ref_x`, `ref_y`, `orient` |
|
|
761
|
+
| **패턴** | `pattern_units`, `pattern_transform`, `pattern_content_units` |
|
|
762
|
+
| **클리핑/마스킹** | `clip_path_attr`, `mask_attr`, `clip_path_units`, `mask_units`, `mask_content_units` |
|
|
763
|
+
| **렌더링** | `image_rendering`, `shape_rendering`, `color_interpolation`, `color_interpolation_filters` |
|
|
764
|
+
| **기타** | `preserve_aspect_ratio`, `overflow`, `cursor`, `visibility`, `pointer_events`, `color`, `display`, `enable_background`, `lighting_color` |
|
|
765
|
+
|
|
766
|
+
### 3.7 React Hooks
|
|
767
|
+
|
|
768
|
+
`glendix/react/hook` 모듈은 37개 React Hooks를 제공합니다.
|
|
365
769
|
|
|
366
770
|
> Gleam의 튜플 `#(a, b)`은 JavaScript 배열 `[a, b]`과 동일하므로 React Hooks의 반환값과 직접 호환됩니다.
|
|
367
771
|
|
|
@@ -377,11 +781,20 @@ pub fn counter(_props) -> ReactElement {
|
|
|
377
781
|
html.div_([
|
|
378
782
|
html.p_([react.text("Count: " <> int.to_string(count))]),
|
|
379
783
|
html.button(
|
|
380
|
-
|
|
784
|
+
[event.on_click(fn(_) { set_count(count + 1) })],
|
|
381
785
|
[react.text("+1")],
|
|
382
786
|
),
|
|
383
787
|
])
|
|
384
788
|
}
|
|
789
|
+
|
|
790
|
+
// 업데이터 함수 변형 (stale closure 방지)
|
|
791
|
+
let #(count, update_count) = hook.use_state_(0)
|
|
792
|
+
update_count(fn(prev) { prev + 1 })
|
|
793
|
+
|
|
794
|
+
// 지연 초기화 (비싼 초기값 계산 방지)
|
|
795
|
+
let #(data, set_data) = hook.use_lazy_state(fn() {
|
|
796
|
+
compute_expensive_initial_value()
|
|
797
|
+
})
|
|
385
798
|
```
|
|
386
799
|
|
|
387
800
|
#### useEffect — 사이드 이펙트
|
|
@@ -391,7 +804,7 @@ pub fn counter(_props) -> ReactElement {
|
|
|
391
804
|
hook.use_effect(fn() {
|
|
392
805
|
// count가 변경될 때마다 실행
|
|
393
806
|
Nil
|
|
394
|
-
},
|
|
807
|
+
}, [count])
|
|
395
808
|
|
|
396
809
|
// 마운트 시 한 번만 실행
|
|
397
810
|
hook.use_effect_once(fn() {
|
|
@@ -419,20 +832,45 @@ hook.use_effect_once_cleanup(fn() {
|
|
|
419
832
|
hook.use_effect_cleanup(fn() {
|
|
420
833
|
// effect 실행
|
|
421
834
|
fn() { /* 클린업 */ }
|
|
422
|
-
},
|
|
835
|
+
}, [dependency])
|
|
423
836
|
|
|
424
837
|
hook.use_effect_always_cleanup(fn() {
|
|
425
838
|
fn() { /* 매 렌더 후 클린업 */ }
|
|
426
839
|
})
|
|
427
840
|
```
|
|
428
841
|
|
|
842
|
+
#### useLayoutEffect — 동기 레이아웃 이펙트
|
|
843
|
+
|
|
844
|
+
```gleam
|
|
845
|
+
// DOM 변경 후 브라우저 페인트 전 동기 실행
|
|
846
|
+
hook.use_layout_effect(fn() {
|
|
847
|
+
// DOM 측정 로직
|
|
848
|
+
Nil
|
|
849
|
+
}, [some_dep])
|
|
850
|
+
|
|
851
|
+
// cleanup 변형도 동일 패턴
|
|
852
|
+
hook.use_layout_effect_once_cleanup(fn() {
|
|
853
|
+
fn() { Nil }
|
|
854
|
+
})
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
#### useInsertionEffect — CSS-in-JS용
|
|
858
|
+
|
|
859
|
+
```gleam
|
|
860
|
+
// DOM 변경 전 실행 (CSS-in-JS 라이브러리용)
|
|
861
|
+
hook.use_insertion_effect(fn() {
|
|
862
|
+
// 스타일 삽입
|
|
863
|
+
Nil
|
|
864
|
+
}, [theme])
|
|
865
|
+
```
|
|
866
|
+
|
|
429
867
|
#### useMemo — 메모이제이션
|
|
430
868
|
|
|
431
869
|
```gleam
|
|
432
870
|
// 값이 비용이 클 때 메모이제이션
|
|
433
871
|
let expensive_result = hook.use_memo(fn() {
|
|
434
872
|
compute_expensive_value(data)
|
|
435
|
-
},
|
|
873
|
+
}, [data])
|
|
436
874
|
```
|
|
437
875
|
|
|
438
876
|
#### useCallback — 콜백 메모이제이션
|
|
@@ -441,7 +879,7 @@ let expensive_result = hook.use_memo(fn() {
|
|
|
441
879
|
// 콜백 함수 메모이제이션 (자식 컴포넌트에 전달할 때 유용)
|
|
442
880
|
let handle_click = hook.use_callback(fn(event) {
|
|
443
881
|
set_count(count + 1)
|
|
444
|
-
},
|
|
882
|
+
}, [count])
|
|
445
883
|
```
|
|
446
884
|
|
|
447
885
|
#### useRef — 참조
|
|
@@ -456,58 +894,92 @@ let current = hook.get_ref(input_ref)
|
|
|
456
894
|
hook.set_ref(input_ref, new_value)
|
|
457
895
|
|
|
458
896
|
// DOM 요소에 연결
|
|
459
|
-
html.input(
|
|
897
|
+
html.input([attribute.ref(input_ref)])
|
|
460
898
|
```
|
|
461
899
|
|
|
462
|
-
|
|
900
|
+
#### useReducer — 리듀서 기반 상태
|
|
463
901
|
|
|
464
|
-
|
|
902
|
+
```gleam
|
|
903
|
+
let #(state, dispatch) = hook.use_reducer(
|
|
904
|
+
fn(state, action) {
|
|
905
|
+
case action {
|
|
906
|
+
Increment -> State(..state, count: state.count + 1)
|
|
907
|
+
Decrement -> State(..state, count: state.count - 1)
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
initial_state,
|
|
911
|
+
)
|
|
465
912
|
|
|
466
|
-
|
|
913
|
+
dispatch(Increment)
|
|
914
|
+
```
|
|
467
915
|
|
|
468
|
-
|
|
469
|
-
|---|---|
|
|
470
|
-
| `Event` | 기본 이벤트 |
|
|
471
|
-
| `MouseEvent` | 클릭, 마우스 이벤트 |
|
|
472
|
-
| `ChangeEvent` | input 변경 이벤트 |
|
|
473
|
-
| `KeyboardEvent` | 키보드 이벤트 |
|
|
474
|
-
| `FormEvent` | 폼 제출 이벤트 |
|
|
475
|
-
| `FocusEvent` | 포커스/블러 이벤트 |
|
|
916
|
+
#### useContext — Context 값 읽기
|
|
476
917
|
|
|
477
|
-
|
|
918
|
+
```gleam
|
|
919
|
+
let theme = hook.use_context(theme_context)
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
#### useId — 고유 ID 생성
|
|
478
923
|
|
|
479
924
|
```gleam
|
|
480
|
-
|
|
925
|
+
let id = hook.use_id() // SSR-safe 고유 ID
|
|
926
|
+
```
|
|
481
927
|
|
|
482
|
-
|
|
483
|
-
prop.on_change(fn(e) {
|
|
484
|
-
let value = event.target_value(e)
|
|
485
|
-
set_name(value)
|
|
486
|
-
})
|
|
928
|
+
#### useTransition — 비긴급 업데이트
|
|
487
929
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
case event.key(e) {
|
|
491
|
-
"Enter" -> submit()
|
|
492
|
-
"Escape" -> cancel()
|
|
493
|
-
_ -> Nil
|
|
494
|
-
}
|
|
495
|
-
})
|
|
930
|
+
```gleam
|
|
931
|
+
let #(is_pending, start_transition) = hook.use_transition()
|
|
496
932
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
handle_submit()
|
|
933
|
+
start_transition(fn() {
|
|
934
|
+
// 비긴급 상태 업데이트
|
|
935
|
+
set_search_results(filter(data, query))
|
|
501
936
|
})
|
|
937
|
+
```
|
|
502
938
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
939
|
+
#### useDeferredValue — 값 지연
|
|
940
|
+
|
|
941
|
+
```gleam
|
|
942
|
+
let deferred_query = hook.use_deferred_value(query)
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
#### useOptimistic — 낙관적 UI (React 19)
|
|
946
|
+
|
|
947
|
+
```gleam
|
|
948
|
+
// 간단한 형태
|
|
949
|
+
let #(optimistic_items, add_optimistic) = hook.use_optimistic(items)
|
|
950
|
+
add_optimistic(new_item)
|
|
951
|
+
|
|
952
|
+
// 리듀서 변형 — 업데이트 함수로 병합 로직 지정 (Round 3)
|
|
953
|
+
let #(optimistic_items, add_optimistic) = hook.use_optimistic_(
|
|
954
|
+
items,
|
|
955
|
+
fn(current, new_item) { list.append(current, [new_item]) },
|
|
956
|
+
)
|
|
957
|
+
add_optimistic(new_item)
|
|
508
958
|
```
|
|
509
959
|
|
|
510
|
-
|
|
960
|
+
#### useImperativeHandle — ref 커스터마이징
|
|
961
|
+
|
|
962
|
+
```gleam
|
|
963
|
+
hook.use_imperative_handle(ref, fn() {
|
|
964
|
+
// 부모에게 노출할 인터페이스
|
|
965
|
+
my_interface
|
|
966
|
+
}, [dep])
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
#### useSyncExternalStore — 외부 스토어
|
|
970
|
+
|
|
971
|
+
```gleam
|
|
972
|
+
let value = hook.use_sync_external_store(subscribe, get_snapshot)
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
#### useDebugValue — DevTools 디버그
|
|
976
|
+
|
|
977
|
+
```gleam
|
|
978
|
+
hook.use_debug_value(state)
|
|
979
|
+
hook.use_debug_value_(state, fn(s) { "State: " <> string.inspect(s) })
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### 3.8 조건부 렌더링
|
|
511
983
|
|
|
512
984
|
```gleam
|
|
513
985
|
import glendix/react
|
|
@@ -530,7 +1002,7 @@ case status {
|
|
|
530
1002
|
}
|
|
531
1003
|
```
|
|
532
1004
|
|
|
533
|
-
### 3.
|
|
1005
|
+
### 3.9 리스트 렌더링
|
|
534
1006
|
|
|
535
1007
|
```gleam
|
|
536
1008
|
import gleam/list
|
|
@@ -540,7 +1012,7 @@ let items = ["사과", "바나나", "체리"]
|
|
|
540
1012
|
|
|
541
1013
|
html.ul_(
|
|
542
1014
|
list.map(items, fn(item) {
|
|
543
|
-
html.li(
|
|
1015
|
+
html.li([attribute.key(item)], [
|
|
544
1016
|
react.text(item),
|
|
545
1017
|
])
|
|
546
1018
|
}),
|
|
@@ -548,30 +1020,121 @@ html.ul_(
|
|
|
548
1020
|
|
|
549
1021
|
// 인덱스가 필요한 경우
|
|
550
1022
|
list.index_map(items, fn(item, idx) {
|
|
551
|
-
html.li(
|
|
1023
|
+
html.li([attribute.key(int.to_string(idx))], [
|
|
552
1024
|
react.text(item),
|
|
553
1025
|
])
|
|
554
1026
|
})
|
|
555
1027
|
```
|
|
556
1028
|
|
|
557
|
-
> 리스트 렌더링 시 항상 `
|
|
1029
|
+
> 리스트 렌더링 시 항상 `attribute.key()`를 설정하세요. React의 reconciliation에 필요합니다.
|
|
1030
|
+
|
|
1031
|
+
### 3.10 인라인 스타일
|
|
1032
|
+
|
|
1033
|
+
```gleam
|
|
1034
|
+
import glendix/react/attribute
|
|
1035
|
+
|
|
1036
|
+
// 튜플 리스트로 스타일 지정 (CSS 속성명은 자동 camelCase 변환)
|
|
1037
|
+
html.div(
|
|
1038
|
+
[attribute.style([
|
|
1039
|
+
#("background-color", "#f0f0f0"),
|
|
1040
|
+
#("padding", "16px"),
|
|
1041
|
+
#("border-radius", "8px"),
|
|
1042
|
+
])],
|
|
1043
|
+
children,
|
|
1044
|
+
)
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
### 3.11 고급 컴포넌트
|
|
1048
|
+
|
|
1049
|
+
#### 컴포넌트 정의
|
|
1050
|
+
|
|
1051
|
+
```gleam
|
|
1052
|
+
// 이름 있는 컴포넌트 (DevTools에 표시)
|
|
1053
|
+
let my_component = react.define_component("MyComponent", fn(props) {
|
|
1054
|
+
html.div_([react.text("Hello")])
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
// React.memo (props 동일 시 리렌더 방지)
|
|
1058
|
+
let memoized = react.memo(my_component)
|
|
1059
|
+
|
|
1060
|
+
// 커스텀 비교 함수
|
|
1061
|
+
let memoized = react.memo_(my_component, fn(prev, next) {
|
|
1062
|
+
// True면 리렌더 건너뜀
|
|
1063
|
+
prev == next
|
|
1064
|
+
})
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
#### StrictMode
|
|
558
1068
|
|
|
559
|
-
|
|
1069
|
+
```gleam
|
|
1070
|
+
react.strict_mode([
|
|
1071
|
+
// 개발 모드 이중 렌더링 감지
|
|
1072
|
+
my_widget(props),
|
|
1073
|
+
])
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
#### Suspense
|
|
560
1077
|
|
|
561
1078
|
```gleam
|
|
562
|
-
|
|
1079
|
+
react.suspense(
|
|
1080
|
+
html.div_([react.text("로딩 중...")]), // fallback
|
|
1081
|
+
[lazy_component], // children
|
|
1082
|
+
)
|
|
1083
|
+
```
|
|
563
1084
|
|
|
564
|
-
|
|
565
|
-
let my_style =
|
|
566
|
-
prop.new_style()
|
|
567
|
-
|> prop.set("backgroundColor", "#f0f0f0")
|
|
568
|
-
|> prop.set("padding", "16px")
|
|
569
|
-
|> prop.set("borderRadius", "8px")
|
|
1085
|
+
#### Portal
|
|
570
1086
|
|
|
571
|
-
|
|
1087
|
+
```gleam
|
|
1088
|
+
// 위젯 DOM 외부에 렌더링 (모달, 팝업)
|
|
1089
|
+
react.portal(modal_element, document_body)
|
|
572
1090
|
```
|
|
573
1091
|
|
|
574
|
-
|
|
1092
|
+
#### forwardRef
|
|
1093
|
+
|
|
1094
|
+
```gleam
|
|
1095
|
+
let fancy_input = react.forward_ref(fn(props, ref) {
|
|
1096
|
+
html.input([attribute.ref(ref), attribute.class("fancy")])
|
|
1097
|
+
})
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
#### startTransition / flushSync
|
|
1101
|
+
|
|
1102
|
+
```gleam
|
|
1103
|
+
// 훅 없이 비긴급 업데이트 표시
|
|
1104
|
+
react.start_transition(fn() {
|
|
1105
|
+
set_data(new_data)
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
// 동기 DOM 업데이트 강제 (상태 변경 후 DOM 측정 시 필요) (Round 3)
|
|
1109
|
+
react.flush_sync(fn() {
|
|
1110
|
+
set_count(count + 1)
|
|
1111
|
+
})
|
|
1112
|
+
// 이 시점에 DOM이 이미 업데이트되어 있음
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
#### Profiler
|
|
1116
|
+
|
|
1117
|
+
```gleam
|
|
1118
|
+
react.profiler("MyWidget", fn(id, phase, actual, base, start, commit) {
|
|
1119
|
+
// 렌더링 성능 측정
|
|
1120
|
+
Nil
|
|
1121
|
+
}, [my_widget(props)])
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
#### Context API
|
|
1125
|
+
|
|
1126
|
+
```gleam
|
|
1127
|
+
// Context 생성
|
|
1128
|
+
let theme_ctx = react.create_context("light")
|
|
1129
|
+
|
|
1130
|
+
// Provider로 값 공급
|
|
1131
|
+
react.provider(theme_ctx, "dark", [
|
|
1132
|
+
child_component,
|
|
1133
|
+
])
|
|
1134
|
+
|
|
1135
|
+
// 소비 (hook)
|
|
1136
|
+
let theme = hook.use_context(theme_ctx)
|
|
1137
|
+
```
|
|
575
1138
|
|
|
576
1139
|
---
|
|
577
1140
|
|
|
@@ -640,18 +1203,17 @@ fn render_input(attr) -> ReactElement {
|
|
|
640
1203
|
let validation_msg = ev.validation(attr) // Option(String)
|
|
641
1204
|
|
|
642
1205
|
html.div_([
|
|
643
|
-
html.input(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|> prop.on_change(fn(e) {
|
|
1206
|
+
html.input([
|
|
1207
|
+
attribute.value(display),
|
|
1208
|
+
attribute.readonly(!is_editable),
|
|
1209
|
+
event.on_change(fn(e) {
|
|
648
1210
|
// 텍스트로 값 설정 (Mendix가 파싱)
|
|
649
1211
|
ev.set_text_value(attr, event.target_value(e))
|
|
650
1212
|
}),
|
|
651
|
-
),
|
|
1213
|
+
]),
|
|
652
1214
|
// 유효성 검사 에러 표시
|
|
653
1215
|
react.when_some(validation_msg, fn(msg) {
|
|
654
|
-
html.span(
|
|
1216
|
+
html.span([attribute.class("text-danger")], [
|
|
655
1217
|
react.text(msg),
|
|
656
1218
|
])
|
|
657
1219
|
}),
|
|
@@ -705,16 +1267,17 @@ pub fn action_button(props: JsProps) -> ReactElement {
|
|
|
705
1267
|
let on_click = mendix.get_prop(props, "onClick") // Option(ActionValue)
|
|
706
1268
|
|
|
707
1269
|
html.button(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1270
|
+
[
|
|
1271
|
+
attribute.class("btn"),
|
|
1272
|
+
attribute.disabled(case on_click {
|
|
1273
|
+
Some(a) -> !action.can_execute(a)
|
|
1274
|
+
None -> True
|
|
1275
|
+
}),
|
|
1276
|
+
event.on_click(fn(_) {
|
|
1277
|
+
// Option(ActionValue) 안전하게 실행
|
|
1278
|
+
action.execute_action(on_click)
|
|
1279
|
+
}),
|
|
1280
|
+
],
|
|
718
1281
|
[react.text("실행")],
|
|
719
1282
|
)
|
|
720
1283
|
}
|
|
@@ -781,7 +1344,7 @@ fn render_list(list_val, props) -> ReactElement {
|
|
|
781
1344
|
html.ul_(
|
|
782
1345
|
list.map(items, fn(item) {
|
|
783
1346
|
let id = mendix.object_id(item)
|
|
784
|
-
html.li(
|
|
1347
|
+
html.li([attribute.key(id)], [
|
|
785
1348
|
react.text("Item: " <> id),
|
|
786
1349
|
])
|
|
787
1350
|
}),
|
|
@@ -844,8 +1407,8 @@ pub fn render_table(props: JsProps) -> ReactElement {
|
|
|
844
1407
|
|
|
845
1408
|
case lv.items(list_val) {
|
|
846
1409
|
Some(items) ->
|
|
847
|
-
html.table_(
|
|
848
|
-
|
|
1410
|
+
html.table_([
|
|
1411
|
+
html.tbody_(
|
|
849
1412
|
list.map(items, fn(item) {
|
|
850
1413
|
let id = mendix.object_id(item)
|
|
851
1414
|
|
|
@@ -859,21 +1422,20 @@ pub fn render_table(props: JsProps) -> ReactElement {
|
|
|
859
1422
|
None -> None
|
|
860
1423
|
}
|
|
861
1424
|
|
|
862
|
-
html.tr(
|
|
1425
|
+
html.tr([attribute.key(id)], [
|
|
863
1426
|
html.td_([react.text(display)]),
|
|
864
1427
|
html.td_([
|
|
865
1428
|
html.button(
|
|
866
|
-
|
|
867
|
-
|> prop.on_click(fn(_) {
|
|
1429
|
+
[event.on_click(fn(_) {
|
|
868
1430
|
action.execute_action(action_opt)
|
|
869
|
-
}),
|
|
1431
|
+
})],
|
|
870
1432
|
[react.text("편집")],
|
|
871
1433
|
),
|
|
872
1434
|
]),
|
|
873
1435
|
])
|
|
874
1436
|
}),
|
|
875
|
-
)
|
|
876
|
-
)
|
|
1437
|
+
),
|
|
1438
|
+
])
|
|
877
1439
|
None -> html.div_([react.text("로딩 중...")])
|
|
878
1440
|
}
|
|
879
1441
|
}
|
|
@@ -898,7 +1460,7 @@ let content_widget = mendix.get_prop_required(props, "content")
|
|
|
898
1460
|
|
|
899
1461
|
list.map(items, fn(item) {
|
|
900
1462
|
let widget_element = la.get_widget(content_widget, item)
|
|
901
|
-
html.div(
|
|
1463
|
+
html.div([attribute.key(mendix.object_id(item))], [
|
|
902
1464
|
widget_element, // ReactElement로 직접 사용
|
|
903
1465
|
])
|
|
904
1466
|
})
|
|
@@ -933,10 +1495,11 @@ selection.set_selections(multi_sel, [item1, item2])
|
|
|
933
1495
|
|
|
934
1496
|
### 4.8 Reference — 연관 관계
|
|
935
1497
|
|
|
936
|
-
`glendix/mendix/reference`로
|
|
1498
|
+
`glendix/mendix/reference`로 단일 연관 관계, `glendix/mendix/reference_set`으로 다중 연관 관계를 다룹니다.
|
|
937
1499
|
|
|
938
1500
|
```gleam
|
|
939
1501
|
import glendix/mendix/reference as ref
|
|
1502
|
+
import glendix/mendix/reference_set as ref_set
|
|
940
1503
|
|
|
941
1504
|
// 단일 참조 (1:1, N:1)
|
|
942
1505
|
let referenced = ref.value(my_ref) // Option(a)
|
|
@@ -947,8 +1510,8 @@ ref.set_value(my_ref, Some(new_item)) // 참조 설정
|
|
|
947
1510
|
ref.set_value(my_ref, None) // 참조 해제
|
|
948
1511
|
|
|
949
1512
|
// 다중 참조 (M:N)
|
|
950
|
-
let items =
|
|
951
|
-
|
|
1513
|
+
let items = ref_set.value(my_ref_set) // Option(List(a))
|
|
1514
|
+
ref_set.set_value(my_ref_set, Some([item1, item2]))
|
|
952
1515
|
```
|
|
953
1516
|
|
|
954
1517
|
### 4.9 Filter — 필터 조건 빌더
|
|
@@ -1105,11 +1668,10 @@ let name = file.name(file_val) // Option(String) - 파일명
|
|
|
1105
1668
|
let src = file.image_uri(img) // String
|
|
1106
1669
|
let alt = file.alt_text(img) // Option(String)
|
|
1107
1670
|
|
|
1108
|
-
html.img(
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
)
|
|
1671
|
+
html.img([
|
|
1672
|
+
attribute.src(src),
|
|
1673
|
+
attribute.alt(option.unwrap(alt, "")),
|
|
1674
|
+
])
|
|
1113
1675
|
```
|
|
1114
1676
|
|
|
1115
1677
|
#### WebIcon
|
|
@@ -1119,11 +1681,11 @@ import glendix/mendix/icon
|
|
|
1119
1681
|
|
|
1120
1682
|
case icon.icon_type(my_icon) {
|
|
1121
1683
|
icon.Glyph ->
|
|
1122
|
-
html.span(
|
|
1684
|
+
html.span([attribute.class(icon.icon_class(my_icon))], [])
|
|
1123
1685
|
icon.Image ->
|
|
1124
|
-
html.img(
|
|
1686
|
+
html.img([attribute.src(icon.icon_url(my_icon))])
|
|
1125
1687
|
icon.IconFont ->
|
|
1126
|
-
html.span(
|
|
1688
|
+
html.span([attribute.class(icon.icon_class(my_icon))], [])
|
|
1127
1689
|
}
|
|
1128
1690
|
```
|
|
1129
1691
|
|
|
@@ -1156,10 +1718,10 @@ import glendix/mendix
|
|
|
1156
1718
|
import glendix/mendix/action
|
|
1157
1719
|
import glendix/mendix/editable_value as ev
|
|
1158
1720
|
import glendix/react.{type JsProps, type ReactElement}
|
|
1721
|
+
import glendix/react/attribute
|
|
1159
1722
|
import glendix/react/event
|
|
1160
1723
|
import glendix/react/hook
|
|
1161
1724
|
import glendix/react/html
|
|
1162
|
-
import glendix/react/prop
|
|
1163
1725
|
|
|
1164
1726
|
pub fn text_input_widget(props: JsProps) -> ReactElement {
|
|
1165
1727
|
let attr = mendix.get_prop(props, "textAttribute")
|
|
@@ -1172,26 +1734,25 @@ pub fn text_input_widget(props: JsProps) -> ReactElement {
|
|
|
1172
1734
|
let editable = ev.is_editable(text_attr)
|
|
1173
1735
|
let validation = ev.validation(text_attr)
|
|
1174
1736
|
|
|
1175
|
-
html.div(
|
|
1176
|
-
html.input(
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|> prop.on_change(fn(e) {
|
|
1737
|
+
html.div([attribute.class("form-group")], [
|
|
1738
|
+
html.input([
|
|
1739
|
+
attribute.class("form-control"),
|
|
1740
|
+
attribute.value(display),
|
|
1741
|
+
attribute.placeholder(placeholder),
|
|
1742
|
+
attribute.readonly(!editable),
|
|
1743
|
+
event.on_change(fn(e) {
|
|
1183
1744
|
ev.set_text_value(text_attr, event.target_value(e))
|
|
1184
|
-
})
|
|
1185
|
-
|
|
1745
|
+
}),
|
|
1746
|
+
event.on_key_down(fn(e) {
|
|
1186
1747
|
case event.key(e) {
|
|
1187
1748
|
"Enter" -> action.execute_action(on_enter)
|
|
1188
1749
|
_ -> Nil
|
|
1189
1750
|
}
|
|
1190
1751
|
}),
|
|
1191
|
-
),
|
|
1752
|
+
]),
|
|
1192
1753
|
// 유효성 검사 메시지
|
|
1193
1754
|
react.when_some(validation, fn(msg) {
|
|
1194
|
-
html.div(
|
|
1755
|
+
html.div([attribute.class("alert alert-danger")], [
|
|
1195
1756
|
react.text(msg),
|
|
1196
1757
|
])
|
|
1197
1758
|
}),
|
|
@@ -1213,16 +1774,17 @@ import glendix/mendix/editable_value as ev
|
|
|
1213
1774
|
import glendix/mendix/list_attribute as la
|
|
1214
1775
|
import glendix/mendix/list_value as lv
|
|
1215
1776
|
import glendix/react.{type JsProps, type ReactElement}
|
|
1777
|
+
import glendix/react/attribute
|
|
1778
|
+
import glendix/react/event
|
|
1216
1779
|
import glendix/react/html
|
|
1217
|
-
import glendix/react/prop
|
|
1218
1780
|
|
|
1219
1781
|
pub fn data_table(props: JsProps) -> ReactElement {
|
|
1220
1782
|
let ds = mendix.get_prop_required(props, "dataSource")
|
|
1221
1783
|
let col_name = mendix.get_prop_required(props, "nameColumn")
|
|
1222
1784
|
let col_status = mendix.get_prop_required(props, "statusColumn")
|
|
1223
1785
|
|
|
1224
|
-
html.div(
|
|
1225
|
-
html.table(
|
|
1786
|
+
html.div([attribute.class("table-responsive")], [
|
|
1787
|
+
html.table([attribute.class("table table-striped")], [
|
|
1226
1788
|
// 헤더
|
|
1227
1789
|
html.thead_([
|
|
1228
1790
|
html.tr_([
|
|
@@ -1239,7 +1801,7 @@ pub fn data_table(props: JsProps) -> ReactElement {
|
|
|
1239
1801
|
let name = ev.display_value(la.get_attribute(col_name, item))
|
|
1240
1802
|
let status = ev.display_value(la.get_attribute(col_status, item))
|
|
1241
1803
|
|
|
1242
|
-
html.tr(
|
|
1804
|
+
html.tr([attribute.key(id)], [
|
|
1243
1805
|
html.td_([react.text(name)]),
|
|
1244
1806
|
html.td_([react.text(status)]),
|
|
1245
1807
|
])
|
|
@@ -1247,7 +1809,7 @@ pub fn data_table(props: JsProps) -> ReactElement {
|
|
|
1247
1809
|
None -> [
|
|
1248
1810
|
html.tr_([
|
|
1249
1811
|
html.td(
|
|
1250
|
-
|
|
1812
|
+
[attribute.col_span(2)],
|
|
1251
1813
|
[react.text("로딩 중...")],
|
|
1252
1814
|
),
|
|
1253
1815
|
]),
|
|
@@ -1265,21 +1827,23 @@ fn render_pagination(ds) -> ReactElement {
|
|
|
1265
1827
|
let limit = lv.limit(ds)
|
|
1266
1828
|
let has_more = lv.has_more_items(ds)
|
|
1267
1829
|
|
|
1268
|
-
html.div(
|
|
1830
|
+
html.div([attribute.class("pagination")], [
|
|
1269
1831
|
html.button(
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1832
|
+
[
|
|
1833
|
+
attribute.disabled(offset == 0),
|
|
1834
|
+
event.on_click(fn(_) {
|
|
1835
|
+
lv.set_offset(ds, int.max(0, offset - limit))
|
|
1836
|
+
}),
|
|
1837
|
+
],
|
|
1275
1838
|
[react.text("이전")],
|
|
1276
1839
|
),
|
|
1277
1840
|
html.button(
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1841
|
+
[
|
|
1842
|
+
attribute.disabled(has_more == Some(False)),
|
|
1843
|
+
event.on_click(fn(_) {
|
|
1844
|
+
lv.set_offset(ds, offset + limit)
|
|
1845
|
+
}),
|
|
1846
|
+
],
|
|
1283
1847
|
[react.text("다음")],
|
|
1284
1848
|
),
|
|
1285
1849
|
])
|
|
@@ -1294,10 +1858,10 @@ import glendix/mendix
|
|
|
1294
1858
|
import glendix/mendix/filter
|
|
1295
1859
|
import glendix/mendix/list_value as lv
|
|
1296
1860
|
import glendix/react.{type JsProps, type ReactElement}
|
|
1861
|
+
import glendix/react/attribute
|
|
1297
1862
|
import glendix/react/event
|
|
1298
1863
|
import glendix/react/hook
|
|
1299
1864
|
import glendix/react/html
|
|
1300
|
-
import glendix/react/prop
|
|
1301
1865
|
|
|
1302
1866
|
pub fn searchable_list(props: JsProps) -> ReactElement {
|
|
1303
1867
|
let ds = mendix.get_prop_required(props, "dataSource")
|
|
@@ -1317,18 +1881,17 @@ pub fn searchable_list(props: JsProps) -> ReactElement {
|
|
|
1317
1881
|
))
|
|
1318
1882
|
}
|
|
1319
1883
|
Nil
|
|
1320
|
-
},
|
|
1884
|
+
}, [query])
|
|
1321
1885
|
|
|
1322
1886
|
html.div_([
|
|
1323
1887
|
// 검색 입력
|
|
1324
|
-
html.input(
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
),
|
|
1888
|
+
html.input([
|
|
1889
|
+
attribute.class("form-control"),
|
|
1890
|
+
attribute.type_("search"),
|
|
1891
|
+
attribute.placeholder("검색..."),
|
|
1892
|
+
attribute.value(query),
|
|
1893
|
+
event.on_change(fn(e) { set_query(event.target_value(e)) }),
|
|
1894
|
+
]),
|
|
1332
1895
|
// 결과 리스트 렌더링
|
|
1333
1896
|
render_results(ds),
|
|
1334
1897
|
])
|
|
@@ -1341,29 +1904,29 @@ Gleam 함수를 컴포넌트처럼 활용하여 UI를 분리합니다:
|
|
|
1341
1904
|
|
|
1342
1905
|
```gleam
|
|
1343
1906
|
import glendix/react.{type ReactElement}
|
|
1907
|
+
import glendix/react/attribute
|
|
1344
1908
|
import glendix/react/html
|
|
1345
|
-
import glendix/react/prop
|
|
1346
1909
|
|
|
1347
1910
|
// 재사용 가능한 카드 컴포넌트
|
|
1348
1911
|
fn card(title: String, children: List(ReactElement)) -> ReactElement {
|
|
1349
|
-
html.div(
|
|
1350
|
-
html.div(
|
|
1912
|
+
html.div([attribute.class("card")], [
|
|
1913
|
+
html.div([attribute.class("card-header")], [
|
|
1351
1914
|
html.h3_([react.text(title)]),
|
|
1352
1915
|
]),
|
|
1353
|
-
html.div(
|
|
1916
|
+
html.div([attribute.class("card-body")], children),
|
|
1354
1917
|
])
|
|
1355
1918
|
}
|
|
1356
1919
|
|
|
1357
1920
|
// 재사용 가능한 빈 상태 컴포넌트
|
|
1358
1921
|
fn empty_state(message: String) -> ReactElement {
|
|
1359
|
-
html.div(
|
|
1922
|
+
html.div([attribute.class("empty-state")], [
|
|
1360
1923
|
html.p_([react.text(message)]),
|
|
1361
1924
|
])
|
|
1362
1925
|
}
|
|
1363
1926
|
|
|
1364
1927
|
// 조합하여 사용
|
|
1365
1928
|
pub fn dashboard(props) -> ReactElement {
|
|
1366
|
-
html.div(
|
|
1929
|
+
html.div([attribute.class("dashboard")], [
|
|
1367
1930
|
card("사용자 목록", [
|
|
1368
1931
|
// 리스트 내용...
|
|
1369
1932
|
]),
|
|
@@ -1374,6 +1937,31 @@ pub fn dashboard(props) -> ReactElement {
|
|
|
1374
1937
|
}
|
|
1375
1938
|
```
|
|
1376
1939
|
|
|
1940
|
+
### 5.5 SVG 아이콘 컴포넌트
|
|
1941
|
+
|
|
1942
|
+
```gleam
|
|
1943
|
+
import glendix/react.{type ReactElement}
|
|
1944
|
+
import glendix/react/attribute
|
|
1945
|
+
import glendix/react/svg
|
|
1946
|
+
import glendix/react/svg_attribute as sa
|
|
1947
|
+
|
|
1948
|
+
fn check_icon(size: String) -> ReactElement {
|
|
1949
|
+
svg.svg(
|
|
1950
|
+
[
|
|
1951
|
+
sa.view_box("0 0 24 24"),
|
|
1952
|
+
attribute.width(size),
|
|
1953
|
+
attribute.height(size),
|
|
1954
|
+
sa.fill("none"),
|
|
1955
|
+
sa.stroke("currentColor"),
|
|
1956
|
+
sa.stroke_width("2"),
|
|
1957
|
+
sa.stroke_linecap("round"),
|
|
1958
|
+
sa.stroke_linejoin("round"),
|
|
1959
|
+
],
|
|
1960
|
+
[svg.path([sa.d("M20 6L9 17l-5-5")], [])],
|
|
1961
|
+
)
|
|
1962
|
+
}
|
|
1963
|
+
```
|
|
1964
|
+
|
|
1377
1965
|
---
|
|
1378
1966
|
|
|
1379
1967
|
## 6. 트러블슈팅
|
|
@@ -1394,6 +1982,8 @@ pub fn dashboard(props) -> ReactElement {
|
|
|
1394
1982
|
| `set_value` 호출 시 에러 | read_only 상태에서 값 설정 | `ev.is_editable(attr)` 확인 후 설정 |
|
|
1395
1983
|
| Hook 순서 에러 | 조건부로 Hook 호출 | Hook은 항상 동일한 순서로 호출해야 함 (React Rules of Hooks) |
|
|
1396
1984
|
| `바인딩이 생성되지 않았습니다` | `binding_ffi.mjs`가 스텁 상태 | `gleam run -m glendix/install` 실행 |
|
|
1985
|
+
| `위젯 바인딩이 생성되지 않았습니다` | `widget_ffi.mjs`가 스텁 상태 | `widgets/` 디렉토리에 `.mpk` 배치 후 `gleam run -m glendix/install` 실행 |
|
|
1986
|
+
| `위젯 바인딩에 등록되지 않은 위젯` | 해당 `.mpk`가 `widgets/`에 없음 | `.mpk` 파일 배치 후 재설치 |
|
|
1397
1987
|
| `could not be resolved – treating it as an external dependency` | `bindings.json`에 등록한 패키지가 `node_modules`에 없음 | `npm install <패키지명>` 등으로 설치 후 재빌드 |
|
|
1398
1988
|
| `바인딩에 등록되지 않은 모듈` | `bindings.json`에 해당 패키지 미등록 | `bindings.json`에 패키지와 컴포넌트 추가 후 재설치 |
|
|
1399
1989
|
| `모듈에 없는 컴포넌트` | `bindings.json`의 `components`에 해당 컴포넌트 미등록 | `components` 배열에 추가 후 재설치 |
|
|
@@ -1430,7 +2020,7 @@ pub fn widget(props) {
|
|
|
1430
2020
|
```gleam
|
|
1431
2021
|
// key가 있어야 React가 효율적으로 업데이트합니다
|
|
1432
2022
|
list.map(items, fn(item) {
|
|
1433
|
-
html.div(
|
|
2023
|
+
html.div([attribute.key(mendix.object_id(item))], [
|
|
1434
2024
|
// ...
|
|
1435
2025
|
])
|
|
1436
2026
|
})
|
|
@@ -1452,11 +2042,22 @@ let month = date.month(my_date) // 1~12 (Gleam 기준, 변환 불필요)
|
|
|
1452
2042
|
// 올바른 방법 — bindings.json + glendix/binding 사용
|
|
1453
2043
|
import glendix/binding
|
|
1454
2044
|
let rc = binding.module("recharts")
|
|
1455
|
-
|
|
1456
|
-
react.
|
|
2045
|
+
react.component_el(binding.resolve(rc, "PieChart"), attrs, children)
|
|
2046
|
+
react.void_component_el(binding.resolve(rc, "Tooltip"), attrs)
|
|
2047
|
+
```
|
|
2048
|
+
|
|
2049
|
+
**5. `.mpk` 위젯용 `.mjs` 파일을 직접 작성하지 마세요:**
|
|
2050
|
+
|
|
2051
|
+
```gleam
|
|
2052
|
+
// 잘못된 방법 — 수동 FFI 작성
|
|
2053
|
+
|
|
2054
|
+
// 올바른 방법 — widgets/ 디렉토리 + glendix/widget 사용
|
|
2055
|
+
import glendix/widget
|
|
2056
|
+
let switch_comp = widget.component("Switch")
|
|
2057
|
+
react.component_el(switch_comp, attrs, children)
|
|
1457
2058
|
```
|
|
1458
2059
|
|
|
1459
|
-
**
|
|
2060
|
+
**6. `binding.resolve()`에서 컴포넌트 이름을 snake_case로 바꾸지 마세요:**
|
|
1460
2061
|
|
|
1461
2062
|
```gleam
|
|
1462
2063
|
// 잘못된 예
|
|
@@ -1466,5 +2067,15 @@ binding.resolve(m(), "pie_chart")
|
|
|
1466
2067
|
binding.resolve(m(), "PieChart")
|
|
1467
2068
|
```
|
|
1468
2069
|
|
|
1469
|
-
|
|
2070
|
+
**7. `react.none()` 대신 빈 문자열이나 빈 리스트를 사용하지 마세요:**
|
|
2071
|
+
|
|
2072
|
+
```gleam
|
|
2073
|
+
// 잘못된 예
|
|
2074
|
+
react.text("") // 빈 텍스트 노드 생성
|
|
2075
|
+
react.fragment([]) // 빈 Fragment 생성
|
|
1470
2076
|
|
|
2077
|
+
// 올바른 예
|
|
2078
|
+
react.none() // React null 반환
|
|
2079
|
+
```
|
|
2080
|
+
|
|
2081
|
+
---
|