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.
@@ -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(prop.new() |> prop.class("my-widget"), [
73
- html.h1_([react.text(greeting)]),
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
- Props // React props 객체
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 파이프라인 API
130
+ ### 2.4 Attribute 리스트 API
131
131
 
132
- Props는 Gleam의 파이프 연산자(`|>`) 활용한 빌더 패턴으로 구성합니다:
132
+ HTML 속성은 `[attribute.xxx(), event.xxx()]` 선언적 리스트 패턴으로 구성합니다:
133
133
 
134
134
  ```gleam
135
- let my_props =
136
- prop.new()
137
- |> prop.class("card card-primary")
138
- |> prop.string("id", "main-card")
139
- |> prop.bool("hidden", False)
140
- |> prop.on_click(fn(_event) { Nil })
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/prop
175
+ import glendix/react/attribute
158
176
 
159
- // Props가 있는 엘리먼트
160
- react.el("div", prop.new() |> prop.class("container"), [
177
+ // Attribute 리스트가 있는 엘리먼트
178
+ react.element("div", [attribute.class("container")], [
161
179
  react.text("Hello"),
162
180
  ])
163
181
 
164
- // Props 없이 간단하게
165
- react.el_("div", [
182
+ // 속성 없이 간단하게
183
+ react.element_("div", [
166
184
  react.text("Hello"),
167
185
  ])
168
186
 
169
187
  // Self-closing 엘리먼트 (input, img, br 등)
170
- react.void("input", prop.new() |> prop.string("type", "text"))
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.h1_([react.text("제목")]),
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 Component}
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
- pub fn pie_chart() -> Component { binding.resolve(m(), "PieChart") }
242
- pub fn pie() -> Component { binding.resolve(m(), "Pie") }
243
- pub fn cell() -> Component { binding.resolve(m(), "Cell") }
244
- pub fn tooltip() -> Component { binding.resolve(m(), "Tooltip") }
245
- pub fn legend() -> Component { binding.resolve(m(), "Legend") }
246
- pub fn responsive_container() -> Component {
247
- binding.resolve(m(), "ResponsiveContainer")
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/prop
296
+ import glendix/react/attribute
257
297
 
258
298
  pub fn my_pie_chart(data) -> react.ReactElement {
259
- react.component(recharts.responsive_container(),
260
- prop.new() |> prop.int("width", 400) |> prop.int("height", 300),
299
+ recharts.responsive_container(
300
+ [attribute.attribute("width", 400), attribute.attribute("height", 300)],
261
301
  [
262
- react.component(recharts.pie_chart(), prop.new(), [
263
- react.component(recharts.pie(),
264
- prop.new()
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
- react.component(recharts.tooltip(), prop.new(), []),
270
- react.component(recharts.legend(), prop.new(), []),
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.component(binding.resolve(rc, "PieChart"), props, children)
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
- ### 3.2 Props 빌더
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/react/prop` 모듈은 타입별 props 설정 함수를 제공합니다.
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/react/prop
370
+ import glendix/mendix
371
+ import glendix/widget
372
+ import glendix/react
373
+ import glendix/react/attribute
294
374
 
295
- let props =
296
- prop.new()
297
- |> prop.string("placeholder", "입력하세요") // String
298
- |> prop.int("tabIndex", 0) // Int
299
- |> prop.float("opacity", 0.5) // Float
300
- |> prop.bool("disabled", True) // Bool
301
- |> prop.any("data", some_value) // 임의 타입
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
- let props =
308
- prop.new()
309
- |> prop.class("btn btn-primary") // className 설정
310
- |> prop.classes(["btn", "btn-large"]) // 여러 클래스 공백으로 결합
311
- |> prop.key("item-1") // React key (리스트 렌더링)
312
- |> prop.ref(my_ref) // React ref
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
- let props =
319
- prop.new()
320
- |> prop.on_click(fn(event) { handle_click(event) })
321
- |> prop.on_change(fn(event) { handle_change(event) })
322
- |> prop.on_submit(fn(event) { handle_submit(event) })
323
- |> prop.on_key_down(fn(event) { handle_key(event) })
324
- |> prop.on_focus(fn(event) { handle_focus(event) })
325
- |> prop.on_blur(fn(event) { handle_blur(event) })
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
- |> prop.on("onMouseEnter", fn(event) { handle_mouse(event) })
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
- ### 3.3 HTML 태그 함수
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
- `glendix/react/html` 모듈은 30개 이상의 HTML 태그를 위한 편의 함수를 제공합니다. 순수 Gleam으로 구현되어 FFI가 없습니다.
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/prop
675
+ import glendix/react/attribute
338
676
 
339
- // Props가 있는 버전
340
- html.div(prop.new() |> prop.class("container"), children)
341
- html.button(prop.new() |> prop.on_click(handler), children)
342
- html.input(prop.new() |> prop.string("type", "text")) // void 엘리먼트
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
- // Props 없는 버전 (언더스코어 접미사)
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`, `h2`, `h3`, `h4`, `h5`, `h6` |
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
- ### 3.4 React Hooks
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
- `glendix/react/hook` 모듈은 주요 React Hooks를 제공합니다.
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
- prop.new() |> prop.on_click(fn(_) { set_count(count + 1) }),
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
- }, #(count)) // 의존성을 튜플로 전달
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
- }, #(dependency))
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
- }, #(data))
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
- }, #(count))
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(prop.new() |> prop.ref(input_ref))
897
+ html.input([attribute.ref(input_ref)])
460
898
  ```
461
899
 
462
- ### 3.5 이벤트 처리
900
+ #### useReducer 리듀서 기반 상태
463
901
 
464
- `glendix/react/event` 모듈은 이벤트 타입과 유틸리티 함수를 제공합니다.
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
- import glendix/react/event
925
+ let id = hook.use_id() // SSR-safe 고유 ID
926
+ ```
481
927
 
482
- // input 가져오기
483
- prop.on_change(fn(e) {
484
- let value = event.target_value(e)
485
- set_name(value)
486
- })
928
+ #### useTransition 비긴급 업데이트
487
929
 
488
- // 키보드 이벤트
489
- prop.on_key_down(fn(e) {
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
- prop.on_submit(fn(e) {
499
- event.prevent_default(e)
500
- handle_submit()
933
+ start_transition(fn() {
934
+ // 비긴급 상태 업데이트
935
+ set_search_results(filter(data, query))
501
936
  })
937
+ ```
502
938
 
503
- // 이벤트 전파 중지
504
- prop.on_click(fn(e) {
505
- event.stop_propagation(e)
506
- handle_click()
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
- ### 3.6 조건부 렌더링
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.7 리스트 렌더링
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(prop.new() |> prop.key(item), [
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(prop.new() |> prop.key(int.to_string(idx)), [
1023
+ html.li([attribute.key(int.to_string(idx))], [
552
1024
  react.text(item),
553
1025
  ])
554
1026
  })
555
1027
  ```
556
1028
 
557
- > 리스트 렌더링 시 항상 `prop.key()`를 설정하세요. React의 reconciliation에 필요합니다.
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
- ### 3.8 스타일 지정
1069
+ ```gleam
1070
+ react.strict_mode([
1071
+ // 개발 모드 이중 렌더링 감지
1072
+ my_widget(props),
1073
+ ])
1074
+ ```
1075
+
1076
+ #### Suspense
560
1077
 
561
1078
  ```gleam
562
- import glendix/react/prop
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
- html.div(prop.new() |> prop.style(my_style), children)
1087
+ ```gleam
1088
+ // 위젯 DOM 외부에 렌더링 (모달, 팝업)
1089
+ react.portal(modal_element, document_body)
572
1090
  ```
573
1091
 
574
- > CSS 속성명은 JavaScript camelCase 표기법을 사용합니다 (예: `backgroundColor`, `fontSize`).
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
- prop.new()
645
- |> prop.string("value", display)
646
- |> prop.bool("readOnly", !is_editable)
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(prop.new() |> prop.class("text-danger"), [
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
- prop.new()
709
- |> prop.class("btn")
710
- |> prop.bool("disabled", case on_click {
711
- Some(a) -> !action.can_execute(a)
712
- None -> True
713
- })
714
- |> prop.on_click(fn(_) {
715
- // Option(ActionValue) 안전하게 실행
716
- action.execute_action(on_click)
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(prop.new() |> prop.key(id), [
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
- [html.tbody_(
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(prop.new() |> prop.key(id), [
1425
+ html.tr([attribute.key(id)], [
863
1426
  html.td_([react.text(display)]),
864
1427
  html.td_([
865
1428
  html.button(
866
- prop.new()
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(prop.new() |> prop.key(mendix.object_id(item)), [
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`로 Mendix 연관 관계(Association)를 다룹니다.
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 = ref.multi_value(my_ref_set) // Option(List(a))
951
- ref.set_multi_value(my_ref_set, Some([item1, item2]))
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
- prop.new()
1110
- |> prop.string("src", src)
1111
- |> prop.string("alt", option.unwrap(alt, "")),
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(prop.new() |> prop.class(icon.icon_class(my_icon)), [])
1684
+ html.span([attribute.class(icon.icon_class(my_icon))], [])
1123
1685
  icon.Image ->
1124
- html.img(prop.new() |> prop.string("src", icon.icon_url(my_icon)))
1686
+ html.img([attribute.src(icon.icon_url(my_icon))])
1125
1687
  icon.IconFont ->
1126
- html.span(prop.new() |> prop.class(icon.icon_class(my_icon)), [])
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(prop.new() |> prop.class("form-group"), [
1176
- html.input(
1177
- prop.new()
1178
- |> prop.class("form-control")
1179
- |> prop.string("value", display)
1180
- |> prop.string("placeholder", placeholder)
1181
- |> prop.bool("readOnly", !editable)
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
- |> prop.on_key_down(fn(e) {
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(prop.new() |> prop.class("alert alert-danger"), [
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(prop.new() |> prop.class("table-responsive"), [
1225
- html.table(prop.new() |> prop.class("table table-striped"), [
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(prop.new() |> prop.key(id), [
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
- prop.new() |> prop.string("colSpan", "2"),
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(prop.new() |> prop.class("pagination"), [
1830
+ html.div([attribute.class("pagination")], [
1269
1831
  html.button(
1270
- prop.new()
1271
- |> prop.bool("disabled", offset == 0)
1272
- |> prop.on_click(fn(_) {
1273
- lv.set_offset(ds, int.max(0, offset - limit))
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
- prop.new()
1279
- |> prop.bool("disabled", has_more == Some(False))
1280
- |> prop.on_click(fn(_) {
1281
- lv.set_offset(ds, offset + limit)
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
- }, #(query))
1884
+ }, [query])
1321
1885
 
1322
1886
  html.div_([
1323
1887
  // 검색 입력
1324
- html.input(
1325
- prop.new()
1326
- |> prop.class("form-control")
1327
- |> prop.string("type", "search")
1328
- |> prop.string("placeholder", "검색...")
1329
- |> prop.string("value", query)
1330
- |> prop.on_change(fn(e) { set_query(event.target_value(e)) }),
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(prop.new() |> prop.class("card"), [
1350
- html.div(prop.new() |> prop.class("card-header"), [
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(prop.new() |> prop.class("card-body"), children),
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(prop.new() |> prop.class("empty-state"), [
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(prop.new() |> prop.class("dashboard"), [
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(prop.new() |> prop.key(mendix.object_id(item)), [
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
- let pie = binding.resolve(rc, "PieChart")
1456
- react.component(pie, props, children)
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
- **5. `binding.resolve()`에서 컴포넌트 이름을 snake_case로 바꾸지 마세요:**
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
+ ---