@simplysm/solid 13.0.55 → 13.0.56

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.
Files changed (181) hide show
  1. package/README.md +3 -1
  2. package/dist/components/data/crud-detail/CrudDetail.d.ts +14 -0
  3. package/dist/components/data/crud-detail/CrudDetail.d.ts.map +1 -0
  4. package/dist/components/data/crud-detail/CrudDetail.js +348 -0
  5. package/dist/components/data/crud-detail/CrudDetail.js.map +6 -0
  6. package/dist/components/data/crud-detail/CrudDetailAfter.d.ts +7 -0
  7. package/dist/components/data/crud-detail/CrudDetailAfter.d.ts.map +1 -0
  8. package/dist/components/data/crud-detail/CrudDetailAfter.js +14 -0
  9. package/dist/components/data/crud-detail/CrudDetailAfter.js.map +6 -0
  10. package/dist/components/data/crud-detail/CrudDetailBefore.d.ts +7 -0
  11. package/dist/components/data/crud-detail/CrudDetailBefore.d.ts.map +1 -0
  12. package/dist/components/data/crud-detail/CrudDetailBefore.js +14 -0
  13. package/dist/components/data/crud-detail/CrudDetailBefore.js.map +6 -0
  14. package/dist/components/data/crud-detail/CrudDetailTools.d.ts +7 -0
  15. package/dist/components/data/crud-detail/CrudDetailTools.d.ts.map +1 -0
  16. package/dist/components/data/crud-detail/CrudDetailTools.js +14 -0
  17. package/dist/components/data/crud-detail/CrudDetailTools.js.map +6 -0
  18. package/dist/components/data/crud-detail/types.d.ts +45 -0
  19. package/dist/components/data/crud-detail/types.d.ts.map +1 -0
  20. package/dist/components/data/crud-detail/types.js +1 -0
  21. package/dist/components/data/crud-detail/types.js.map +6 -0
  22. package/dist/components/data/crud-sheet/CrudSheet.d.ts +17 -0
  23. package/dist/components/data/crud-sheet/CrudSheet.d.ts.map +1 -0
  24. package/dist/components/data/crud-sheet/CrudSheet.js +679 -0
  25. package/dist/components/data/crud-sheet/CrudSheet.js.map +6 -0
  26. package/dist/components/data/crud-sheet/CrudSheetColumn.d.ts +5 -0
  27. package/dist/components/data/crud-sheet/CrudSheetColumn.d.ts.map +1 -0
  28. package/dist/components/data/crud-sheet/CrudSheetColumn.js +29 -0
  29. package/dist/components/data/crud-sheet/CrudSheetColumn.js.map +6 -0
  30. package/dist/components/data/crud-sheet/CrudSheetFilter.d.ts +7 -0
  31. package/dist/components/data/crud-sheet/CrudSheetFilter.d.ts.map +1 -0
  32. package/dist/components/data/crud-sheet/CrudSheetFilter.js +14 -0
  33. package/dist/components/data/crud-sheet/CrudSheetFilter.js.map +6 -0
  34. package/dist/components/data/crud-sheet/CrudSheetHeader.d.ts +7 -0
  35. package/dist/components/data/crud-sheet/CrudSheetHeader.d.ts.map +1 -0
  36. package/dist/components/data/crud-sheet/CrudSheetHeader.js +14 -0
  37. package/dist/components/data/crud-sheet/CrudSheetHeader.js.map +6 -0
  38. package/dist/components/data/crud-sheet/CrudSheetTools.d.ts +7 -0
  39. package/dist/components/data/crud-sheet/CrudSheetTools.d.ts.map +1 -0
  40. package/dist/components/data/crud-sheet/CrudSheetTools.js +14 -0
  41. package/dist/components/data/crud-sheet/CrudSheetTools.js.map +6 -0
  42. package/dist/components/data/crud-sheet/types.d.ts +109 -0
  43. package/dist/components/data/crud-sheet/types.d.ts.map +1 -0
  44. package/dist/components/data/crud-sheet/types.js +1 -0
  45. package/dist/components/data/crud-sheet/types.js.map +6 -0
  46. package/dist/components/data/kanban/Kanban.d.ts.map +1 -1
  47. package/dist/components/data/kanban/Kanban.js +137 -138
  48. package/dist/components/data/kanban/Kanban.js.map +2 -2
  49. package/dist/components/data/kanban/KanbanContext.d.ts +5 -1
  50. package/dist/components/data/kanban/KanbanContext.d.ts.map +1 -1
  51. package/dist/components/data/kanban/KanbanContext.js.map +1 -1
  52. package/dist/components/data/list/ListItem.d.ts.map +1 -1
  53. package/dist/components/data/list/ListItem.js +109 -99
  54. package/dist/components/data/list/ListItem.js.map +2 -2
  55. package/dist/components/data/sheet/DataSheet.js +1 -1
  56. package/dist/components/data/sheet/DataSheet.js.map +2 -2
  57. package/dist/components/data/sheet/DataSheet.styles.d.ts.map +1 -1
  58. package/dist/components/data/sheet/DataSheet.styles.js +1 -1
  59. package/dist/components/data/sheet/DataSheet.styles.js.map +1 -1
  60. package/dist/components/disclosure/Dialog.d.ts +16 -10
  61. package/dist/components/disclosure/Dialog.d.ts.map +1 -1
  62. package/dist/components/disclosure/Dialog.js +126 -91
  63. package/dist/components/disclosure/Dialog.js.map +2 -2
  64. package/dist/components/disclosure/DialogContext.d.ts +2 -4
  65. package/dist/components/disclosure/DialogContext.d.ts.map +1 -1
  66. package/dist/components/disclosure/DialogContext.js.map +1 -1
  67. package/dist/components/disclosure/DialogProvider.d.ts.map +1 -1
  68. package/dist/components/disclosure/DialogProvider.js +14 -9
  69. package/dist/components/disclosure/DialogProvider.js.map +2 -2
  70. package/dist/components/disclosure/Dropdown.d.ts +46 -22
  71. package/dist/components/disclosure/Dropdown.d.ts.map +1 -1
  72. package/dist/components/disclosure/Dropdown.js +100 -65
  73. package/dist/components/disclosure/Dropdown.js.map +2 -2
  74. package/dist/components/feedback/notification/NotificationBanner.d.ts.map +1 -1
  75. package/dist/components/feedback/notification/NotificationBanner.js +3 -3
  76. package/dist/components/feedback/notification/NotificationBanner.js.map +1 -1
  77. package/dist/components/feedback/notification/NotificationBell.d.ts.map +1 -1
  78. package/dist/components/feedback/notification/NotificationBell.js +84 -84
  79. package/dist/components/feedback/notification/NotificationBell.js.map +2 -2
  80. package/dist/components/form-control/combobox/Combobox.d.ts +6 -3
  81. package/dist/components/form-control/combobox/Combobox.d.ts.map +1 -1
  82. package/dist/components/form-control/combobox/Combobox.js +150 -168
  83. package/dist/components/form-control/combobox/Combobox.js.map +2 -2
  84. package/dist/components/form-control/combobox/ComboboxContext.d.ts +3 -0
  85. package/dist/components/form-control/combobox/ComboboxContext.d.ts.map +1 -1
  86. package/dist/components/form-control/combobox/ComboboxContext.js.map +1 -1
  87. package/dist/components/form-control/date-range-picker/DateRangePicker.d.ts +0 -2
  88. package/dist/components/form-control/date-range-picker/DateRangePicker.d.ts.map +1 -1
  89. package/dist/components/form-control/date-range-picker/DateRangePicker.js +9 -17
  90. package/dist/components/form-control/date-range-picker/DateRangePicker.js.map +2 -2
  91. package/dist/components/form-control/field/Field.styles.d.ts.map +1 -1
  92. package/dist/components/form-control/field/Field.styles.js +2 -1
  93. package/dist/components/form-control/field/Field.styles.js.map +1 -1
  94. package/dist/components/form-control/field/NumberInput.d.ts +15 -5
  95. package/dist/components/form-control/field/NumberInput.d.ts.map +1 -1
  96. package/dist/components/form-control/field/NumberInput.js +181 -141
  97. package/dist/components/form-control/field/NumberInput.js.map +2 -2
  98. package/dist/components/form-control/field/TextInput.d.ts +9 -5
  99. package/dist/components/form-control/field/TextInput.d.ts.map +1 -1
  100. package/dist/components/form-control/field/TextInput.js +199 -154
  101. package/dist/components/form-control/field/TextInput.js.map +2 -2
  102. package/dist/components/form-control/select/Select.d.ts +3 -3
  103. package/dist/components/form-control/select/Select.d.ts.map +1 -1
  104. package/dist/components/form-control/select/Select.js +116 -100
  105. package/dist/components/form-control/select/Select.js.map +2 -2
  106. package/dist/components/form-control/select/SelectContext.d.ts +9 -1
  107. package/dist/components/form-control/select/SelectContext.d.ts.map +1 -1
  108. package/dist/components/form-control/select/SelectContext.js.map +1 -1
  109. package/dist/components/form-control/select/SelectItem.d.ts.map +1 -1
  110. package/dist/components/form-control/select/SelectItem.js +77 -67
  111. package/dist/components/form-control/select/SelectItem.js.map +2 -2
  112. package/dist/components/layout/topbar/TopbarMenu.d.ts.map +1 -1
  113. package/dist/components/layout/topbar/TopbarMenu.js +63 -57
  114. package/dist/components/layout/topbar/TopbarMenu.js.map +2 -2
  115. package/dist/components/layout/topbar/TopbarUser.d.ts.map +1 -1
  116. package/dist/components/layout/topbar/TopbarUser.js +53 -54
  117. package/dist/components/layout/topbar/TopbarUser.js.map +2 -2
  118. package/dist/hooks/createControllableStore.d.ts +29 -0
  119. package/dist/hooks/createControllableStore.d.ts.map +1 -0
  120. package/dist/hooks/createControllableStore.js +19 -0
  121. package/dist/hooks/createControllableStore.js.map +6 -0
  122. package/dist/index.d.ts +5 -1
  123. package/dist/index.d.ts.map +1 -1
  124. package/dist/index.js +6 -2
  125. package/dist/index.js.map +1 -1
  126. package/dist/styles/patterns.styles.d.ts.map +1 -1
  127. package/dist/styles/patterns.styles.js +7 -1
  128. package/dist/styles/patterns.styles.js.map +1 -1
  129. package/docs/data-components.md +428 -0
  130. package/docs/disclosure.md +65 -35
  131. package/docs/form-controls.md +18 -3
  132. package/docs/helpers.md +0 -39
  133. package/docs/hooks.md +39 -0
  134. package/package.json +4 -3
  135. package/src/components/data/crud-detail/CrudDetail.tsx +346 -0
  136. package/src/components/data/crud-detail/CrudDetailAfter.tsx +19 -0
  137. package/src/components/data/crud-detail/CrudDetailBefore.tsx +19 -0
  138. package/src/components/data/crud-detail/CrudDetailTools.tsx +19 -0
  139. package/src/components/data/crud-detail/types.ts +58 -0
  140. package/src/components/data/crud-sheet/CrudSheet.tsx +628 -0
  141. package/src/components/data/crud-sheet/CrudSheetColumn.tsx +34 -0
  142. package/src/components/data/crud-sheet/CrudSheetFilter.tsx +21 -0
  143. package/src/components/data/crud-sheet/CrudSheetHeader.tsx +19 -0
  144. package/src/components/data/crud-sheet/CrudSheetTools.tsx +21 -0
  145. package/src/components/data/crud-sheet/types.ts +133 -0
  146. package/src/components/data/kanban/Kanban.tsx +72 -65
  147. package/src/components/data/kanban/KanbanContext.ts +7 -1
  148. package/src/components/data/list/ListItem.tsx +31 -18
  149. package/src/components/data/sheet/DataSheet.styles.ts +1 -1
  150. package/src/components/data/sheet/DataSheet.tsx +1 -1
  151. package/src/components/disclosure/Dialog.tsx +143 -105
  152. package/src/components/disclosure/DialogContext.ts +2 -4
  153. package/src/components/disclosure/DialogProvider.tsx +4 -2
  154. package/src/components/disclosure/Dropdown.tsx +174 -86
  155. package/src/components/feedback/notification/NotificationBanner.tsx +3 -9
  156. package/src/components/feedback/notification/NotificationBell.tsx +51 -57
  157. package/src/components/form-control/combobox/Combobox.tsx +109 -134
  158. package/src/components/form-control/combobox/ComboboxContext.ts +4 -1
  159. package/src/components/form-control/date-range-picker/DateRangePicker.tsx +6 -16
  160. package/src/components/form-control/field/Field.styles.ts +1 -0
  161. package/src/components/form-control/field/NumberInput.tsx +131 -88
  162. package/src/components/form-control/field/TextInput.tsx +139 -88
  163. package/src/components/form-control/select/Select.tsx +85 -67
  164. package/src/components/form-control/select/SelectContext.ts +12 -1
  165. package/src/components/form-control/select/SelectItem.tsx +39 -18
  166. package/src/components/layout/topbar/TopbarMenu.tsx +52 -55
  167. package/src/components/layout/topbar/TopbarUser.tsx +28 -31
  168. package/src/hooks/createControllableStore.ts +47 -0
  169. package/src/index.ts +5 -1
  170. package/src/styles/patterns.styles.ts +7 -1
  171. package/tailwind.css +4 -0
  172. package/dist/helpers/splitSlots.d.ts +0 -25
  173. package/dist/helpers/splitSlots.d.ts.map +0 -1
  174. package/dist/helpers/splitSlots.js +0 -25
  175. package/dist/helpers/splitSlots.js.map +0 -6
  176. package/dist/hooks/createItemTemplate.d.ts +0 -17
  177. package/dist/hooks/createItemTemplate.d.ts.map +0 -1
  178. package/dist/hooks/createItemTemplate.js +0 -40
  179. package/dist/hooks/createItemTemplate.js.map +0 -6
  180. package/src/helpers/splitSlots.ts +0 -51
  181. package/src/hooks/createItemTemplate.tsx +0 -42
@@ -5,6 +5,7 @@ import {
5
5
  For,
6
6
  type JSX,
7
7
  type ParentComponent,
8
+ onCleanup,
8
9
  Show,
9
10
  splitProps,
10
11
  } from "solid-js";
@@ -15,17 +16,18 @@ import { Icon } from "../../display/Icon";
15
16
  import { Dropdown } from "../../disclosure/Dropdown";
16
17
  import { List } from "../../data/list/List";
17
18
  import { SelectContext, type SelectContextValue } from "./SelectContext";
19
+ import { useSelectContext } from "./SelectContext";
18
20
  import { SelectItem } from "./SelectItem";
19
21
  import { ripple } from "../../../directives/ripple";
20
- import { splitSlots } from "../../../helpers/splitSlots";
21
22
  import { borderDefault, type ComponentSize, textMuted } from "../../../styles/tokens.styles";
22
23
  import { createControllableSignal } from "../../../hooks/createControllableSignal";
23
- import { createItemTemplate } from "../../../hooks/createItemTemplate";
24
24
  import { chevronWrapperClass, getTriggerClass } from "../DropdownTrigger.styles";
25
25
  import { Invalid } from "../Invalid";
26
26
 
27
27
  void ripple;
28
28
 
29
+ type SlotAccessor = (() => JSX.Element) | undefined;
30
+
29
31
  // Select 전용 스타일
30
32
  const multiTagClass = clsx("rounded", "bg-base-200 px-1", "dark:bg-base-600");
31
33
  const selectedValueClass = clsx("flex-1", "whitespace-nowrap");
@@ -37,8 +39,9 @@ interface SelectActionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement>
37
39
 
38
40
  const SelectAction: ParentComponent<SelectActionProps> = (props) => {
39
41
  const [local, rest] = splitProps(props, ["children", "class"]);
42
+ const ctx = useSelectContext();
40
43
 
41
- return (
44
+ ctx.setAction(() => (
42
45
  <button
43
46
  {...rest}
44
47
  type="button"
@@ -63,17 +66,31 @@ const SelectAction: ParentComponent<SelectActionProps> = (props) => {
63
66
  >
64
67
  {local.children}
65
68
  </button>
66
- );
69
+ ));
70
+ onCleanup(() => ctx.setAction(undefined));
71
+ return null;
67
72
  };
68
73
 
69
74
  /**
70
75
  * 드롭다운 상단 커스텀 영역 서브 컴포넌트
71
76
  */
72
- const SelectHeader: ParentComponent = (props) => <div data-select-header>{props.children}</div>;
77
+ const SelectHeader: ParentComponent = (props) => {
78
+ const ctx = useSelectContext();
79
+ // eslint-disable-next-line solid/reactivity -- 슬롯 accessor로 저장, JSX tracked scope에서 호출됨
80
+ ctx.setHeader(() => props.children);
81
+ onCleanup(() => ctx.setHeader(undefined));
82
+ return null;
83
+ };
73
84
 
74
- const { TemplateSlot: SelectItemTemplate, getTemplate: getSelectItemTemplate } = createItemTemplate<
75
- [item: unknown, index: number, depth: number]
76
- >("data-select-item-template");
85
+ const SelectItemTemplate = <TArgs extends unknown[]>(props: {
86
+ children: (...args: TArgs) => JSX.Element;
87
+ }) => {
88
+ const ctx = useSelectContext();
89
+ // eslint-disable-next-line solid/reactivity -- 렌더 함수를 signal에 저장, JSX tracked scope에서 호출됨
90
+ ctx.setItemTemplate(props.children as (...args: unknown[]) => JSX.Element);
91
+ onCleanup(() => ctx.setItemTemplate(undefined));
92
+ return null;
93
+ };
77
94
 
78
95
  // Props 정의
79
96
 
@@ -214,8 +231,6 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
214
231
  "touchMode",
215
232
  ]);
216
233
 
217
- let triggerRef!: HTMLDivElement;
218
-
219
234
  const [open, setOpen] = createSignal(false);
220
235
 
221
236
  // 선택된 값 관리 (controlled/uncontrolled 패턴)
@@ -256,18 +271,26 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
256
271
  setOpen(false);
257
272
  };
258
273
 
274
+ // 슬롯 signals
275
+ const [header, _setHeader] = createSignal<SlotAccessor>();
276
+ const setHeader = (content: SlotAccessor) => _setHeader(() => content);
277
+ const [action, _setAction] = createSignal<SlotAccessor>();
278
+ const setAction = (content: SlotAccessor) => _setAction(() => content);
279
+ const [itemTemplate, _setItemTemplate] = createSignal<
280
+ ((...args: unknown[]) => JSX.Element) | undefined
281
+ >();
282
+ const setItemTemplate = (fn: ((...args: unknown[]) => JSX.Element) | undefined) =>
283
+ _setItemTemplate(() => fn);
284
+
259
285
  // Context 값
260
286
  const contextValue: SelectContextValue<T> = {
261
287
  multiple: () => local.multiple ?? false,
262
288
  isSelected,
263
289
  toggleValue,
264
290
  closeDropdown,
265
- };
266
-
267
- // 트리거 클릭
268
- const handleTriggerClick = () => {
269
- if (local.disabled) return;
270
- setOpen((v) => !v);
291
+ setHeader,
292
+ setAction,
293
+ setItemTemplate,
271
294
  };
272
295
 
273
296
  // 트리거 키보드 처리 (Enter/Space만 처리, ArrowUp/Down은 Dropdown이 처리)
@@ -297,32 +320,26 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
297
320
  class: local.class,
298
321
  });
299
322
 
300
- // 내부 컴포넌트: Provider 안에서 children을 resolve
323
+ // 내부 컴포넌트: Provider 안에서 children을 resolve하여 슬롯 등록을 트리거
301
324
  const SelectInner: ParentComponent = (innerProps) => {
325
+ // children() resolve로 서브 컴포넌트 등록 트리거 (Header, Action, ItemTemplate은 null 반환)
302
326
  const resolved = children(() => innerProps.children);
303
- const [slots, items] = splitSlots(resolved, [
304
- "selectHeader",
305
- "selectAction",
306
- "selectItemTemplate",
307
- ] as const);
308
327
 
309
328
  // itemTemplate 함수 추출
310
329
  const getItemTemplate = ():
311
330
  | ((item: T, index: number, depth: number) => JSX.Element)
312
331
  | undefined => {
313
- return getSelectItemTemplate(slots().selectItemTemplate) as
314
- | ((item: T, index: number, depth: number) => JSX.Element)
315
- | undefined;
332
+ return itemTemplate() as ((item: T, index: number, depth: number) => JSX.Element) | undefined;
316
333
  };
317
334
 
318
335
  // items 재귀 렌더링
319
336
  const renderItems = (itemList: T[], depth: number): JSX.Element => {
320
- const itemTemplate = getItemTemplate();
337
+ const tpl = getItemTemplate();
321
338
  return (
322
339
  <For each={itemList}>
323
340
  {(item, index) => (
324
341
  <SelectItem value={item}>
325
- {itemTemplate ? itemTemplate(item, index(), depth) : String(item)}
342
+ {tpl ? tpl(item, index(), depth) : String(item)}
326
343
  <Show when={local.getChildren?.(item, index(), depth)} keyed>
327
344
  {(itemChildren) => (
328
345
  <Show when={itemChildren.length > 0}>
@@ -343,9 +360,9 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
343
360
  if (local.renderValue) {
344
361
  return local.renderValue(value);
345
362
  }
346
- const itemTemplate = getItemTemplate();
347
- if (itemTemplate) {
348
- return itemTemplate(value, 0, 0);
363
+ const tpl = getItemTemplate();
364
+ if (tpl) {
365
+ return tpl(value, 0, 0);
349
366
  }
350
367
  return <>{String(value)}</>;
351
368
  };
@@ -372,33 +389,43 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
372
389
 
373
390
  return (
374
391
  <div {...rest} data-select class={clsx("group", local.inset ? "flex" : "inline-flex")}>
375
- <div
376
- ref={triggerRef}
377
- use:ripple={!local.disabled}
378
- role="combobox"
379
- aria-haspopup="listbox"
380
- aria-expanded={open()}
381
- aria-disabled={local.disabled || undefined}
382
- aria-required={local.required || undefined}
383
- tabIndex={local.disabled ? -1 : 0}
384
- class={twMerge(
385
- getTriggerClassName(),
386
- slots().selectAction.length > 0 &&
387
- clsx(
388
- "rounded-r-none border-r-0",
389
- "group-focus-within:border-primary-400 dark:group-focus-within:border-primary-400",
390
- ),
391
- )}
392
- style={local.style}
393
- onClick={handleTriggerClick}
394
- onKeyDown={handleTriggerKeyDown}
395
- >
396
- <div class={selectedValueClass}>{renderSelectedValue()}</div>
397
- <div class={chevronWrapperClass}>
398
- <Icon icon={IconChevronDown} size="1em" />
399
- </div>
400
- </div>
401
- <Show when={slots().selectAction.length > 0}>
392
+ <Dropdown disabled={local.disabled} open={open()} onOpenChange={setOpen} keyboardNav>
393
+ <Dropdown.Trigger>
394
+ <div
395
+ use:ripple={!local.disabled}
396
+ role="combobox"
397
+ aria-haspopup="listbox"
398
+ aria-expanded={open()}
399
+ aria-disabled={local.disabled || undefined}
400
+ aria-required={local.required || undefined}
401
+ tabIndex={local.disabled ? -1 : 0}
402
+ class={twMerge(
403
+ getTriggerClassName(),
404
+ action() !== undefined &&
405
+ clsx(
406
+ "rounded-r-none border-r-0",
407
+ "group-focus-within:border-primary-400 dark:group-focus-within:border-primary-400",
408
+ ),
409
+ )}
410
+ style={local.style}
411
+ onKeyDown={handleTriggerKeyDown}
412
+ >
413
+ <div class={selectedValueClass}>{renderSelectedValue()}</div>
414
+ <div class={chevronWrapperClass}>
415
+ <Icon icon={IconChevronDown} size="1em" />
416
+ </div>
417
+ </div>
418
+ </Dropdown.Trigger>
419
+ <Dropdown.Content>
420
+ <Show when={header()}>{header()!()}</Show>
421
+ <List inset role="listbox">
422
+ <Show when={local.items} fallback={resolved()}>
423
+ {renderItems(local.items!, 0)}
424
+ </Show>
425
+ </List>
426
+ </Dropdown.Content>
427
+ </Dropdown>
428
+ <Show when={action()}>
402
429
  <div
403
430
  class={clsx(
404
431
  "contents",
@@ -406,18 +433,9 @@ export const Select: SelectComponent = <T,>(props: SelectProps<T>) => {
406
433
  "[&>[data-select-action]+[data-select-action]]:-ml-px",
407
434
  )}
408
435
  >
409
- {slots().selectAction}
436
+ {action()!()}
410
437
  </div>
411
438
  </Show>
412
-
413
- <Dropdown triggerRef={() => triggerRef} open={open()} onOpenChange={setOpen} keyboardNav>
414
- <Show when={slots().selectHeader.length > 0}>{slots().selectHeader.single()}</Show>
415
- <List inset role="listbox">
416
- <Show when={local.items} fallback={items()}>
417
- {renderItems(local.items!, 0)}
418
- </Show>
419
- </List>
420
- </Dropdown>
421
439
  </div>
422
440
  );
423
441
  };
@@ -1,4 +1,6 @@
1
- import { createContext, useContext, type Accessor } from "solid-js";
1
+ import { createContext, useContext, type Accessor, type JSX } from "solid-js";
2
+
3
+ type SlotAccessor = (() => JSX.Element) | undefined;
2
4
 
3
5
  export interface SelectContextValue<TValue = unknown> {
4
6
  /** 다중 선택 모드 여부 */
@@ -12,6 +14,15 @@ export interface SelectContextValue<TValue = unknown> {
12
14
 
13
15
  /** 드롭다운 닫기 */
14
16
  closeDropdown: () => void;
17
+
18
+ /** 헤더 슬롯 등록 */
19
+ setHeader: (content: SlotAccessor) => void;
20
+
21
+ /** 액션 슬롯 등록 */
22
+ setAction: (content: SlotAccessor) => void;
23
+
24
+ /** 아이템 템플릿 등록 */
25
+ setItemTemplate: (fn: ((...args: unknown[]) => JSX.Element) | undefined) => void;
15
26
  }
16
27
 
17
28
  export const SelectContext = createContext<SelectContextValue>();
@@ -1,4 +1,13 @@
1
- import { children, type JSX, type ParentComponent, Show, splitProps } from "solid-js";
1
+ import {
2
+ createContext,
3
+ createSignal,
4
+ type JSX,
5
+ onCleanup,
6
+ type ParentComponent,
7
+ Show,
8
+ splitProps,
9
+ useContext,
10
+ } from "solid-js";
2
11
  import { twMerge } from "tailwind-merge";
3
12
  import { IconCheck } from "@tabler/icons-solidjs";
4
13
  import { Icon } from "../../display/Icon";
@@ -6,7 +15,6 @@ import { useSelectContext } from "./SelectContext";
6
15
  import { ripple } from "../../../directives/ripple";
7
16
  import { List } from "../../data/list/List";
8
17
  import { Collapse } from "../../disclosure/Collapse";
9
- import { splitSlots } from "../../../helpers/splitSlots";
10
18
  import {
11
19
  listItemBaseClass,
12
20
  listItemSelectedClass,
@@ -18,17 +26,24 @@ import {
18
26
 
19
27
  void ripple;
20
28
 
29
+ type SlotAccessor = (() => JSX.Element) | undefined;
30
+
31
+ interface SelectItemSlotsContextValue {
32
+ setChildren: (content: SlotAccessor) => void;
33
+ }
34
+
35
+ const SelectItemSlotsContext = createContext<SelectItemSlotsContextValue>();
36
+
21
37
  /**
22
38
  * 중첩 아이템을 담는 서브 컴포넌트
23
39
  */
24
- const SelectItemChildren: ParentComponent = (props) => (
25
- <div class="flex" data-select-item-children>
26
- <div class={listItemIndentGuideClass} />
27
- <List inset class="flex-1">
28
- {props.children}
29
- </List>
30
- </div>
31
- );
40
+ const SelectItemChildren: ParentComponent = (props) => {
41
+ const ctx = useContext(SelectItemSlotsContext)!;
42
+ // eslint-disable-next-line solid/reactivity -- slot accessor: children is lazily read at render time
43
+ ctx.setChildren(() => props.children);
44
+ onCleanup(() => ctx.setChildren(undefined));
45
+ return null;
46
+ };
32
47
 
33
48
  export interface SelectItemProps<TValue = unknown> extends Omit<
34
49
  JSX.ButtonHTMLAttributes<HTMLButtonElement>,
@@ -68,10 +83,9 @@ export const SelectItem: SelectItemComponent = <T,>(
68
83
 
69
84
  const context = useSelectContext<T>();
70
85
 
71
- const resolved = children(() => local.children);
72
- const [slots, content] = splitSlots(resolved, ["selectItemChildren"] as const);
73
-
74
- const hasChildren = () => slots().selectItemChildren.length > 0;
86
+ const [childrenSlot, _setChildrenSlot] = createSignal<SlotAccessor>();
87
+ const setChildrenSlot = (content: SlotAccessor) => _setChildrenSlot(() => content);
88
+ const hasChildren = () => childrenSlot() !== undefined;
75
89
  const isSelected = () => context.isSelected(local.value);
76
90
  const useRipple = () => !local.disabled;
77
91
 
@@ -97,7 +111,7 @@ export const SelectItem: SelectItemComponent = <T,>(
97
111
  const getCheckIconClass = () => getListItemSelectedIconClass(isSelected());
98
112
 
99
113
  return (
100
- <>
114
+ <SelectItemSlotsContext.Provider value={{ setChildren: setChildrenSlot }}>
101
115
  <button
102
116
  {...rest}
103
117
  type="button"
@@ -114,12 +128,19 @@ export const SelectItem: SelectItemComponent = <T,>(
114
128
  <Show when={context.multiple() && !hasChildren()}>
115
129
  <Icon icon={IconCheck} class={getCheckIconClass()} />
116
130
  </Show>
117
- <span class={listItemContentClass}>{content()}</span>
131
+ <span class={listItemContentClass}>{local.children}</span>
118
132
  </button>
119
133
  <Show when={hasChildren()}>
120
- <Collapse open={true}>{slots().selectItemChildren.single()}</Collapse>
134
+ <Collapse open={true}>
135
+ <div class="flex">
136
+ <div class={listItemIndentGuideClass} />
137
+ <List inset class="flex-1">
138
+ {childrenSlot()!()}
139
+ </List>
140
+ </div>
141
+ </Collapse>
121
142
  </Show>
122
- </>
143
+ </SelectItemSlotsContext.Provider>
123
144
  );
124
145
  };
125
146
 
@@ -63,7 +63,6 @@ export interface TopbarMenuProps extends Omit<JSX.HTMLAttributes<HTMLElement>, "
63
63
  export const TopbarMenu: Component<TopbarMenuProps> = (props) => {
64
64
  const [local, rest] = splitProps(props, ["menus", "class"]);
65
65
  const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false);
66
- let mobileButtonRef: HTMLButtonElement | undefined;
67
66
 
68
67
  return (
69
68
  <>
@@ -74,28 +73,26 @@ export const TopbarMenu: Component<TopbarMenuProps> = (props) => {
74
73
 
75
74
  {/* 모바일 햄버거 (640px 미만에서만 표시) */}
76
75
  <div class={mobileWrapperClass}>
77
- <Button
78
- ref={mobileButtonRef}
79
- variant="ghost"
80
- onClick={() => setMobileMenuOpen((v) => !v)}
81
- aria-label="메뉴"
82
- aria-haspopup="menu"
83
- aria-expanded={mobileMenuOpen()}
84
- >
85
- <Icon icon={IconDotsVertical} size="1.25em" />
86
- </Button>
87
- <Dropdown
88
- triggerRef={() => mobileButtonRef}
89
- open={mobileMenuOpen()}
90
- onOpenChange={setMobileMenuOpen}
91
- >
92
- <List inset>
93
- <For each={local.menus}>
94
- {(menu) => (
95
- <TopbarMenuDropdownItem menu={menu} onClose={() => setMobileMenuOpen(false)} />
96
- )}
97
- </For>
98
- </List>
76
+ <Dropdown open={mobileMenuOpen()} onOpenChange={setMobileMenuOpen}>
77
+ <Dropdown.Trigger>
78
+ <Button
79
+ variant="ghost"
80
+ aria-label="메뉴"
81
+ aria-haspopup="menu"
82
+ aria-expanded={mobileMenuOpen()}
83
+ >
84
+ <Icon icon={IconDotsVertical} size="1.25em" />
85
+ </Button>
86
+ </Dropdown.Trigger>
87
+ <Dropdown.Content>
88
+ <List inset>
89
+ <For each={local.menus}>
90
+ {(menu) => (
91
+ <TopbarMenuDropdownItem menu={menu} onClose={() => setMobileMenuOpen(false)} />
92
+ )}
93
+ </For>
94
+ </List>
95
+ </Dropdown.Content>
99
96
  </Dropdown>
100
97
  </div>
101
98
  </>
@@ -111,7 +108,6 @@ const TopbarMenuButton: Component<TopbarMenuButtonProps> = (props) => {
111
108
  const navigate = useNavigate();
112
109
 
113
110
  const [open, setOpen] = createSignal(false);
114
- let buttonRef: HTMLButtonElement | undefined;
115
111
 
116
112
  const hasChildren = () => props.menu.children !== undefined && props.menu.children.length > 0;
117
113
  const isExternalLink = () => props.menu.href?.includes("://") ?? false;
@@ -133,10 +129,8 @@ const TopbarMenuButton: Component<TopbarMenuButtonProps> = (props) => {
133
129
  return false;
134
130
  });
135
131
 
136
- const handleClick = () => {
137
- if (hasChildren()) {
138
- setOpen((v) => !v);
139
- } else if (props.menu.href !== undefined) {
132
+ const handleNavigate = () => {
133
+ if (props.menu.href !== undefined) {
140
134
  if (isExternalLink()) {
141
135
  window.open(props.menu.href, "_blank", "noopener,noreferrer");
142
136
  } else {
@@ -145,39 +139,42 @@ const TopbarMenuButton: Component<TopbarMenuButtonProps> = (props) => {
145
139
  }
146
140
  };
147
141
 
148
- return (
149
- <>
150
- <Button
151
- ref={buttonRef}
152
- variant={isSelected() ? "solid" : "ghost"}
153
- theme={isSelected() ? "primary" : "base"}
154
- onClick={handleClick}
155
- class={menuButtonContentClass}
156
- aria-haspopup={hasChildren() ? "menu" : undefined}
157
- aria-expanded={hasChildren() ? open() : undefined}
158
- >
159
- <Show when={props.menu.icon}>
160
- <Icon icon={props.menu.icon!} />
161
- </Show>
162
- <span>{props.menu.title}</span>
163
- <Show when={hasChildren()}>
164
- <Icon
165
- icon={IconChevronDown}
166
- size="1em"
167
- class={clsx("transition-transform", open() && "rotate-180")}
168
- />
169
- </Show>
170
- </Button>
142
+ const buttonContent = () => (
143
+ <Button
144
+ variant={isSelected() ? "solid" : "ghost"}
145
+ theme={isSelected() ? "primary" : "base"}
146
+ class={menuButtonContentClass}
147
+ aria-haspopup={hasChildren() ? "menu" : undefined}
148
+ aria-expanded={hasChildren() ? open() : undefined}
149
+ onClick={hasChildren() ? undefined : handleNavigate}
150
+ >
151
+ <Show when={props.menu.icon}>
152
+ <Icon icon={props.menu.icon!} />
153
+ </Show>
154
+ <span>{props.menu.title}</span>
171
155
  <Show when={hasChildren()}>
172
- <Dropdown triggerRef={() => buttonRef} open={open()} onOpenChange={setOpen}>
156
+ <Icon
157
+ icon={IconChevronDown}
158
+ size="1em"
159
+ class={clsx("transition-transform", open() && "rotate-180")}
160
+ />
161
+ </Show>
162
+ </Button>
163
+ );
164
+
165
+ return (
166
+ <Show when={hasChildren()} fallback={buttonContent()}>
167
+ <Dropdown open={open()} onOpenChange={setOpen}>
168
+ <Dropdown.Trigger>{buttonContent()}</Dropdown.Trigger>
169
+ <Dropdown.Content>
173
170
  <List inset>
174
171
  <For each={props.menu.children}>
175
172
  {(child) => <TopbarMenuDropdownItem menu={child} onClose={() => setOpen(false)} />}
176
173
  </For>
177
174
  </List>
178
- </Dropdown>
179
- </Show>
180
- </>
175
+ </Dropdown.Content>
176
+ </Dropdown>
177
+ </Show>
181
178
  );
182
179
  };
183
180
 
@@ -50,16 +50,9 @@ export const TopbarUser: ParentComponent<TopbarUserProps> = (props) => {
50
50
  const [local, rest] = splitProps(props, ["children", "class", "menus"]);
51
51
 
52
52
  const [open, setOpen] = createSignal(false);
53
- let buttonRef: HTMLButtonElement | undefined;
54
53
 
55
54
  const hasMenus = () => local.menus !== undefined && local.menus.length > 0;
56
55
 
57
- const handleClick = () => {
58
- if (hasMenus()) {
59
- setOpen((v) => !v);
60
- }
61
- };
62
-
63
56
  const handleMenuClick = (menu: TopbarUserMenu) => {
64
57
  setOpen(false);
65
58
  menu.onClick();
@@ -67,32 +60,36 @@ export const TopbarUser: ParentComponent<TopbarUserProps> = (props) => {
67
60
 
68
61
  const getClassName = () => twMerge(wrapperBaseClass, local.class);
69
62
 
63
+ const buttonContent = () => (
64
+ <Button
65
+ variant="ghost"
66
+ class={buttonContentClass}
67
+ aria-haspopup={hasMenus() ? "menu" : undefined}
68
+ aria-expanded={hasMenus() ? open() : undefined}
69
+ >
70
+ {local.children}
71
+ <Show when={hasMenus()}>
72
+ <Icon
73
+ icon={IconChevronDown}
74
+ size="1em"
75
+ class={clsx("transition-transform", open() && "rotate-180")}
76
+ />
77
+ </Show>
78
+ </Button>
79
+ );
80
+
70
81
  return (
71
82
  <div {...rest} data-topbar-user class={getClassName()}>
72
- <Button
73
- ref={buttonRef}
74
- variant="ghost"
75
- onClick={handleClick}
76
- class={buttonContentClass}
77
- aria-haspopup={hasMenus() ? "menu" : undefined}
78
- aria-expanded={hasMenus() ? open() : undefined}
79
- >
80
- {local.children}
81
- <Show when={hasMenus()}>
82
- <Icon
83
- icon={IconChevronDown}
84
- size="1em"
85
- class={clsx("transition-transform", open() && "rotate-180")}
86
- />
87
- </Show>
88
- </Button>
89
- <Show when={hasMenus()}>
90
- <Dropdown triggerRef={() => buttonRef} open={open()} onOpenChange={setOpen}>
91
- <List inset>
92
- <For each={local.menus}>
93
- {(menu) => <ListItem onClick={() => handleMenuClick(menu)}>{menu.title}</ListItem>}
94
- </For>
95
- </List>
83
+ <Show when={hasMenus()} fallback={buttonContent()}>
84
+ <Dropdown open={open()} onOpenChange={setOpen}>
85
+ <Dropdown.Trigger>{buttonContent()}</Dropdown.Trigger>
86
+ <Dropdown.Content>
87
+ <List inset>
88
+ <For each={local.menus}>
89
+ {(menu) => <ListItem onClick={() => handleMenuClick(menu)}>{menu.title}</ListItem>}
90
+ </For>
91
+ </List>
92
+ </Dropdown.Content>
96
93
  </Dropdown>
97
94
  </Show>
98
95
  </div>
@@ -0,0 +1,47 @@
1
+ import { createEffect } from "solid-js";
2
+ import { createStore, reconcile, unwrap } from "solid-js/store";
3
+ import type { SetStoreFunction } from "solid-js/store";
4
+ import { objClone } from "@simplysm/core-common";
5
+
6
+ /**
7
+ * Controlled/Uncontrolled 패턴을 지원하는 store hook
8
+ *
9
+ * @remarks
10
+ * - `onChange`가 제공되면 controlled 모드: setter 호출 시 onChange로 변경 알림
11
+ * - `onChange`가 없으면 uncontrolled 모드: 내부 store만 사용
12
+ * - SetStoreFunction의 모든 overload 지원 (path 기반, produce, reconcile 등)
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * // Controlled 모드 (onItemsChange 제공)
17
+ * const [items, setItems] = createControllableStore<Item[]>({
18
+ * value: () => props.items ?? [],
19
+ * onChange: () => props.onItemsChange,
20
+ * });
21
+ *
22
+ * // Uncontrolled 모드 (onItemsChange 미제공)
23
+ * const [items, setItems] = createControllableStore<Item[]>({
24
+ * value: () => [],
25
+ * onChange: () => undefined,
26
+ * });
27
+ * ```
28
+ */
29
+ export function createControllableStore<TValue extends object>(options: {
30
+ value: () => TValue;
31
+ onChange: () => ((value: TValue) => void) | undefined;
32
+ }): [TValue, SetStoreFunction<TValue>] {
33
+ const [store, rawSet] = createStore<TValue>(objClone(options.value()));
34
+
35
+ // 외부 value 변경 → 내부 store 동기화
36
+ createEffect(() => {
37
+ rawSet(reconcile(options.value()) as any);
38
+ });
39
+
40
+ // 함수 래퍼로 setter 감싸서 onChange 알림 추가
41
+ const wrappedSet = ((...args: any[]) => {
42
+ (rawSet as any)(...args);
43
+ options.onChange()?.(objClone(unwrap(store)));
44
+ }) as SetStoreFunction<TValue>;
45
+
46
+ return [store, wrappedSet];
47
+ }