@simplysm/solid 13.0.93 → 13.0.96
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 +22 -9
- package/dist/components/features/crud-sheet/CrudSheet.d.ts.map +1 -1
- package/dist/components/features/crud-sheet/CrudSheet.js +14 -9
- package/dist/components/features/crud-sheet/CrudSheet.js.map +2 -2
- package/dist/components/features/crud-sheet/CrudSheet.types.d.ts +2 -0
- package/dist/components/features/crud-sheet/CrudSheet.types.d.ts.map +1 -1
- package/dist/components/layout/sidebar/Sidebar.d.ts +5 -0
- package/dist/components/layout/sidebar/Sidebar.d.ts.map +1 -1
- package/dist/components/layout/sidebar/Sidebar.js +7 -4
- package/dist/components/layout/sidebar/Sidebar.js.map +2 -2
- package/docs/display-feedback.md +142 -17
- package/docs/features.md +270 -3
- package/docs/form-controls.md +226 -17
- package/docs/layout-data.md +6 -0
- package/docs/providers-hooks.md +145 -40
- package/package.json +5 -5
- package/src/components/features/crud-sheet/CrudSheet.tsx +15 -7
- package/src/components/features/crud-sheet/CrudSheet.types.ts +2 -0
- package/src/components/layout/sidebar/Sidebar.tsx +9 -3
- package/tests/components/features/crud-sheet/CrudSheet.spec.tsx +116 -1
package/docs/providers-hooks.md
CHANGED
|
@@ -38,14 +38,23 @@ const theme = useTheme();
|
|
|
38
38
|
theme.mode(); // "light" | "dark" | "system"
|
|
39
39
|
theme.resolvedTheme(); // "light" | "dark" (OS 설정 반영)
|
|
40
40
|
theme.setMode("dark");
|
|
41
|
-
theme.cycleMode(); // light
|
|
41
|
+
theme.cycleMode(); // light -> system -> dark -> light
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
### useTheme 반환 타입
|
|
45
|
+
|
|
46
|
+
| 속성 | 타입 | 설명 |
|
|
47
|
+
|------|------|------|
|
|
48
|
+
| `mode()` | `ThemeMode` | 현재 테마 모드 (`"light" \| "dark" \| "system"`) |
|
|
49
|
+
| `setMode(mode)` | `(mode: ThemeMode) => void` | 테마 모드 설정 |
|
|
50
|
+
| `resolvedTheme()` | `ResolvedTheme` | 실제 적용된 테마 (`"light" \| "dark"`) |
|
|
51
|
+
| `cycleMode()` | `() => void` | 다음 모드로 순환 |
|
|
52
|
+
|
|
44
53
|
---
|
|
45
54
|
|
|
46
55
|
## I18nProvider
|
|
47
56
|
|
|
48
|
-
다국어 지원. 한국어(ko), 영어(en) 내장.
|
|
57
|
+
다국어 지원. 한국어(ko), 영어(en) 내장. 브라우저 언어 자동 감지.
|
|
49
58
|
|
|
50
59
|
```tsx
|
|
51
60
|
import { I18nProvider, useI18n } from "@simplysm/solid";
|
|
@@ -70,6 +79,15 @@ i18n.configure({
|
|
|
70
79
|
});
|
|
71
80
|
```
|
|
72
81
|
|
|
82
|
+
### useI18n 반환 타입
|
|
83
|
+
|
|
84
|
+
| 속성 | 타입 | 설명 |
|
|
85
|
+
|------|------|------|
|
|
86
|
+
| `t(key, params?)` | `(key: string, params?: Record<string, string>) => string` | 번역 조회 |
|
|
87
|
+
| `locale()` | `Accessor<string>` | 현재 로케일 |
|
|
88
|
+
| `setLocale(locale)` | `(locale: string) => void` | 로케일 변경 |
|
|
89
|
+
| `configure(options)` | `(options: I18nConfigureOptions) => void` | 사전 확장/설정 |
|
|
90
|
+
|
|
73
91
|
---
|
|
74
92
|
|
|
75
93
|
## SharedDataProvider
|
|
@@ -159,41 +177,111 @@ await sharedData.wait();
|
|
|
159
177
|
|
|
160
178
|
### createControllableSignal
|
|
161
179
|
|
|
162
|
-
제어/비제어 컴포넌트 패턴 구현.
|
|
180
|
+
제어/비제어 컴포넌트 패턴 구현. `onChange`가 제공되면 제어 모드, 없으면 비제어 모드.
|
|
163
181
|
|
|
164
182
|
```typescript
|
|
165
183
|
import { createControllableSignal } from "@simplysm/solid";
|
|
166
184
|
|
|
167
185
|
const [value, setValue] = createControllableSignal({
|
|
168
|
-
value: () => props.value,
|
|
186
|
+
value: () => props.value ?? "",
|
|
169
187
|
onChange: () => props.onValueChange,
|
|
170
188
|
});
|
|
189
|
+
|
|
190
|
+
// 함수형 setter 지원
|
|
191
|
+
setValue((prev) => prev + "!");
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// 시그니처
|
|
196
|
+
function createControllableSignal<TValue>(options: {
|
|
197
|
+
value: Accessor<TValue>;
|
|
198
|
+
onChange: Accessor<((value: TValue) => void) | undefined>;
|
|
199
|
+
}): [Accessor<TValue>, (newValue: TValue | ((prev: TValue) => TValue)) => TValue];
|
|
171
200
|
```
|
|
172
201
|
|
|
173
202
|
### createControllableStore
|
|
174
203
|
|
|
175
|
-
객체 상태용 제어/비제어 패턴.
|
|
204
|
+
객체 상태용 제어/비제어 패턴. SolidJS store 기반.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { createControllableStore } from "@simplysm/solid";
|
|
208
|
+
|
|
209
|
+
const [items, setItems] = createControllableStore<Item[]>({
|
|
210
|
+
value: () => props.items ?? [],
|
|
211
|
+
onChange: () => props.onItemsChange,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// SetStoreFunction 오버로드 모두 지원
|
|
215
|
+
setItems(0, "name", "Alice");
|
|
216
|
+
setItems(reconcile(newItems));
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// 시그니처
|
|
221
|
+
function createControllableStore<TValue extends object>(options: {
|
|
222
|
+
value: () => TValue;
|
|
223
|
+
onChange: () => ((value: TValue) => void) | undefined;
|
|
224
|
+
}): [TValue, SetStoreFunction<TValue>];
|
|
225
|
+
```
|
|
176
226
|
|
|
177
227
|
### createIMEHandler
|
|
178
228
|
|
|
179
|
-
IME(한글 등) 입력 처리.
|
|
229
|
+
IME(한글 등) 입력 처리. 조합 중 DOM 재생성을 방지한다.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { createIMEHandler } from "@simplysm/solid";
|
|
233
|
+
|
|
234
|
+
const ime = createIMEHandler((value) => setValue(value));
|
|
235
|
+
|
|
236
|
+
// 이벤트 핸들러
|
|
237
|
+
onCompositionStart={ime.handleCompositionStart}
|
|
238
|
+
onInput={(e) => ime.handleInput(e.currentTarget.value, e.isComposing)}
|
|
239
|
+
onCompositionEnd={(e) => ime.handleCompositionEnd(e.currentTarget.value)}
|
|
240
|
+
|
|
241
|
+
// 조합 중 값 (display 용)
|
|
242
|
+
ime.composingValue()
|
|
243
|
+
|
|
244
|
+
// 조합 강제 완료
|
|
245
|
+
ime.flushComposition()
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### createMountTransition
|
|
249
|
+
|
|
250
|
+
마운트/언마운트 시 애니메이션 상태를 관리한다.
|
|
180
251
|
|
|
181
252
|
```typescript
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
253
|
+
import { createMountTransition } from "@simplysm/solid";
|
|
254
|
+
|
|
255
|
+
const { mounted, animating, unmount } = createMountTransition(() => isVisible());
|
|
256
|
+
|
|
257
|
+
// mounted: DOM에 마운트 여부
|
|
258
|
+
// animating: 진입/퇴장 애니메이션 중 여부
|
|
259
|
+
// unmount: 언마운트 트리거
|
|
187
260
|
```
|
|
188
261
|
|
|
189
262
|
### useLocalStorage
|
|
190
263
|
|
|
191
|
-
반응형 localStorage.
|
|
264
|
+
반응형 localStorage. `ConfigProvider`의 `clientName`을 키 접두사로 사용한다.
|
|
192
265
|
|
|
193
266
|
```typescript
|
|
194
267
|
import { useLocalStorage } from "@simplysm/solid";
|
|
195
268
|
|
|
196
|
-
const [
|
|
269
|
+
const [token, setToken] = useLocalStorage<string>("auth-token");
|
|
270
|
+
|
|
271
|
+
setToken("abc123"); // 저장
|
|
272
|
+
token(); // "abc123"
|
|
273
|
+
setToken(undefined); // 삭제
|
|
274
|
+
|
|
275
|
+
// 함수형 setter
|
|
276
|
+
setToken((prev) => prev ? prev + "-updated" : "new-token");
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
// 시그니처
|
|
281
|
+
function useLocalStorage<TValue>(
|
|
282
|
+
key: string,
|
|
283
|
+
initialValue?: TValue,
|
|
284
|
+
): [Accessor<TValue | undefined>, StorageSetter<TValue>];
|
|
197
285
|
```
|
|
198
286
|
|
|
199
287
|
### useSyncConfig
|
|
@@ -201,30 +289,28 @@ const [theme, setTheme] = useLocalStorage("theme", "light");
|
|
|
201
289
|
클라이언트명 접두사 붙은 localStorage 동기화.
|
|
202
290
|
|
|
203
291
|
```typescript
|
|
292
|
+
import { useSyncConfig } from "@simplysm/solid";
|
|
293
|
+
|
|
204
294
|
const [config, setConfig] = useSyncConfig("my-setting", defaultValue);
|
|
205
295
|
```
|
|
206
296
|
|
|
207
297
|
### useLogger
|
|
208
298
|
|
|
209
299
|
```typescript
|
|
300
|
+
import { useLogger } from "@simplysm/solid";
|
|
301
|
+
|
|
210
302
|
const logger = useLogger();
|
|
211
303
|
```
|
|
212
304
|
|
|
213
305
|
### useRouterLink
|
|
214
306
|
|
|
215
307
|
```typescript
|
|
308
|
+
import { useRouterLink } from "@simplysm/solid";
|
|
309
|
+
|
|
216
310
|
const navigate = useRouterLink();
|
|
217
311
|
navigate("/users");
|
|
218
312
|
```
|
|
219
313
|
|
|
220
|
-
### createMountTransition
|
|
221
|
-
|
|
222
|
-
마운트 애니메이션 상태 관리.
|
|
223
|
-
|
|
224
|
-
```typescript
|
|
225
|
-
const { mounted, animating, unmount } = createMountTransition(() => isVisible());
|
|
226
|
-
```
|
|
227
|
-
|
|
228
314
|
---
|
|
229
315
|
|
|
230
316
|
## createAppStructure
|
|
@@ -287,6 +373,33 @@ app.perms; // 타입 안전한 권한 객체 (app.perms.admin.users
|
|
|
287
373
|
app.getTitleChainByHref("/admin/users"); // ["관리", "사용자 관리"]
|
|
288
374
|
```
|
|
289
375
|
|
|
376
|
+
### AppStructureItem 타입
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
// 그룹 항목 (children 보유)
|
|
380
|
+
interface AppStructureGroupItem<TModule> {
|
|
381
|
+
code: string;
|
|
382
|
+
title: string;
|
|
383
|
+
icon?: Component<IconProps>;
|
|
384
|
+
modules?: TModule[];
|
|
385
|
+
requiredModules?: TModule[];
|
|
386
|
+
children: AppStructureItem<TModule>[];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 리프 항목 (페이지 컴포넌트)
|
|
390
|
+
interface AppStructureLeafItem<TModule> {
|
|
391
|
+
code: string;
|
|
392
|
+
title: string;
|
|
393
|
+
icon?: Component<IconProps>;
|
|
394
|
+
modules?: TModule[];
|
|
395
|
+
requiredModules?: TModule[];
|
|
396
|
+
component?: Component;
|
|
397
|
+
perms?: ("use" | "edit")[];
|
|
398
|
+
subPerms?: AppStructureSubPerm<TModule>[];
|
|
399
|
+
isNotMenu?: boolean;
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
290
403
|
### AppStructure 반환 타입
|
|
291
404
|
|
|
292
405
|
| 속성 | 타입 | 설명 |
|
|
@@ -355,14 +468,19 @@ themeTokens.primary.border // border-primary-300 ...
|
|
|
355
468
|
|
|
356
469
|
### ripple
|
|
357
470
|
|
|
358
|
-
Material Design 스타일 리플 효과.
|
|
471
|
+
Material Design 스타일 리플 효과. 포인터 다운 시 클릭 위치에서 원형 리플이 확산된다.
|
|
359
472
|
|
|
360
473
|
```tsx
|
|
361
474
|
import { ripple } from "@simplysm/solid";
|
|
475
|
+
void ripple; // TypeScript directive 등록
|
|
362
476
|
|
|
363
|
-
<button use:ripple>클릭</button>
|
|
477
|
+
<button use:ripple={!props.disabled}>클릭</button>
|
|
364
478
|
```
|
|
365
479
|
|
|
480
|
+
- `prefers-reduced-motion: reduce` 설정 시 리플 비활성화
|
|
481
|
+
- 내부적으로 `overflow: hidden` 컨테이너를 생성하여 부모 요소에 영향 없음
|
|
482
|
+
- `static` 포지션인 경우 자동으로 `relative`로 변경 (cleanup 시 복원)
|
|
483
|
+
|
|
366
484
|
---
|
|
367
485
|
|
|
368
486
|
## 헬퍼
|
|
@@ -384,28 +502,15 @@ mergeStyles("color: red", "font-size: 14px"); // "color: red; font-size: 14px"
|
|
|
384
502
|
```typescript
|
|
385
503
|
import { createSlot } from "@simplysm/solid";
|
|
386
504
|
|
|
387
|
-
// 슬롯 정의
|
|
505
|
+
// 1. 슬롯 정의
|
|
388
506
|
const [MySlot, createMySlotAccessor] = createSlot<{ children: JSX.Element }>();
|
|
389
507
|
|
|
390
|
-
// 컴포넌트 내부에서 슬롯 접근
|
|
508
|
+
// 2. 컴포넌트 내부에서 슬롯 접근
|
|
391
509
|
const [slotValue, SlotProvider] = createMySlotAccessor();
|
|
392
510
|
|
|
393
|
-
// 사용
|
|
511
|
+
// 3. 사용
|
|
394
512
|
<SlotProvider>
|
|
395
513
|
<MySlot><span>슬롯 내용</span></MySlot>
|
|
396
|
-
{/* slotValue()
|
|
514
|
+
{/* slotValue()?.children 으로 접근 */}
|
|
397
515
|
</SlotProvider>
|
|
398
516
|
```
|
|
399
|
-
|
|
400
|
-
### startPointerDrag
|
|
401
|
-
|
|
402
|
-
포인터 드래그 인터랙션 관리. 포인터 캡처를 설정하고 move/end 이벤트를 관리한다.
|
|
403
|
-
|
|
404
|
-
```typescript
|
|
405
|
-
import { startPointerDrag } from "@simplysm/solid";
|
|
406
|
-
|
|
407
|
-
startPointerDrag(element, event.pointerId, {
|
|
408
|
-
onMove: (e) => { /* 드래그 중 */ },
|
|
409
|
-
onEnd: (e) => { /* 드래그 종료 */ },
|
|
410
|
-
});
|
|
411
|
-
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplysm/solid",
|
|
3
|
-
"version": "13.0.
|
|
3
|
+
"version": "13.0.96",
|
|
4
4
|
"description": "Simplysm package - SolidJS library",
|
|
5
5
|
"author": "simplysm",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"tabbable": "^6.4.0",
|
|
53
53
|
"tailwind-merge": "^3.5.0",
|
|
54
54
|
"tailwindcss": "^3.4.19",
|
|
55
|
-
"@simplysm/core-browser": "13.0.
|
|
56
|
-
"@simplysm/core-common": "13.0.
|
|
57
|
-
"@simplysm/service-
|
|
58
|
-
"@simplysm/service-
|
|
55
|
+
"@simplysm/core-browser": "13.0.96",
|
|
56
|
+
"@simplysm/core-common": "13.0.96",
|
|
57
|
+
"@simplysm/service-client": "13.0.96",
|
|
58
|
+
"@simplysm/service-common": "13.0.96"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@solidjs/testing-library": "^0.8.10"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createEffect,
|
|
3
|
+
createMemo,
|
|
3
4
|
createSignal,
|
|
4
5
|
createUniqueId,
|
|
5
6
|
For,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
} from "solid-js";
|
|
11
12
|
import { createStore, produce, reconcile } from "solid-js/store";
|
|
12
13
|
import { createControllableStore } from "../../../hooks/createControllableStore";
|
|
14
|
+
import { createControllableSignal } from "../../../hooks/createControllableSignal";
|
|
13
15
|
import type { DateTime } from "@simplysm/core-common";
|
|
14
16
|
import { obj } from "@simplysm/core-common";
|
|
15
17
|
import "@simplysm/core-common"; // register extensions
|
|
@@ -77,6 +79,8 @@ const CrudSheetBase = <TItem, TFilter extends Record<string, unknown>>(
|
|
|
77
79
|
"dialogEdit",
|
|
78
80
|
"excel",
|
|
79
81
|
"selectionMode",
|
|
82
|
+
"selectedKeys",
|
|
83
|
+
"onSelectedKeysChange",
|
|
80
84
|
"onSelect",
|
|
81
85
|
"hideAutoTools",
|
|
82
86
|
"close",
|
|
@@ -128,7 +132,11 @@ const CrudSheetBase = <TItem, TFilter extends Record<string, unknown>>(
|
|
|
128
132
|
const [ready, setReady] = createSignal(false);
|
|
129
133
|
|
|
130
134
|
const [selection, setSelection] = createSignal<TItem[]>([]);
|
|
131
|
-
const [selectedKeys, setSelectedKeys] =
|
|
135
|
+
const [selectedKeys, setSelectedKeys] = createControllableSignal<(string | number)[]>({
|
|
136
|
+
value: () => local.selectedKeys ?? [],
|
|
137
|
+
onChange: () => local.onSelectedKeysChange,
|
|
138
|
+
});
|
|
139
|
+
const selectedKeysSet = createMemo(() => new Set(selectedKeys()));
|
|
132
140
|
|
|
133
141
|
let formRef: HTMLFormElement | undefined;
|
|
134
142
|
|
|
@@ -146,8 +154,8 @@ const CrudSheetBase = <TItem, TFilter extends Record<string, unknown>>(
|
|
|
146
154
|
// -- Key-based selection: restore selection when items change --
|
|
147
155
|
createEffect(() => {
|
|
148
156
|
const currentItems = items;
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
157
|
+
const keysSet = selectedKeysSet();
|
|
158
|
+
if (keysSet.size === 0) {
|
|
151
159
|
if (selection().length > 0) {
|
|
152
160
|
setSelection([]);
|
|
153
161
|
}
|
|
@@ -155,7 +163,7 @@ const CrudSheetBase = <TItem, TFilter extends Record<string, unknown>>(
|
|
|
155
163
|
}
|
|
156
164
|
const restored = currentItems.filter((item) => {
|
|
157
165
|
const key = local.getItemKey(item);
|
|
158
|
-
return key != null &&
|
|
166
|
+
return key != null && keysSet.has(key);
|
|
159
167
|
});
|
|
160
168
|
setSelection(restored);
|
|
161
169
|
});
|
|
@@ -346,7 +354,7 @@ const CrudSheetBase = <TItem, TFilter extends Record<string, unknown>>(
|
|
|
346
354
|
merged.add(key); // Add current page selection
|
|
347
355
|
}
|
|
348
356
|
|
|
349
|
-
setSelectedKeys(merged);
|
|
357
|
+
setSelectedKeys([...merged]);
|
|
350
358
|
setSelection(newSelection);
|
|
351
359
|
|
|
352
360
|
// Auto-confirm for single selection mode in dialog
|
|
@@ -359,14 +367,14 @@ const CrudSheetBase = <TItem, TFilter extends Record<string, unknown>>(
|
|
|
359
367
|
}
|
|
360
368
|
|
|
361
369
|
function clearSelection() {
|
|
362
|
-
setSelectedKeys(
|
|
370
|
+
setSelectedKeys([]);
|
|
363
371
|
setSelection([]);
|
|
364
372
|
}
|
|
365
373
|
|
|
366
374
|
function handleSelectConfirm() {
|
|
367
375
|
local.onSelect?.({
|
|
368
376
|
items: selection(),
|
|
369
|
-
keys:
|
|
377
|
+
keys: selectedKeys(),
|
|
370
378
|
});
|
|
371
379
|
}
|
|
372
380
|
|
|
@@ -94,6 +94,8 @@ interface CrudSheetBaseProps<TItem, TFilter extends Record<string, unknown>> {
|
|
|
94
94
|
onItemsChange?: (items: TItem[]) => void;
|
|
95
95
|
excel?: ExcelConfig<TItem>;
|
|
96
96
|
selectionMode?: "single" | "multiple";
|
|
97
|
+
selectedKeys?: (string | number)[];
|
|
98
|
+
onSelectedKeysChange?: (keys: (string | number)[]) => void;
|
|
97
99
|
onSelect?: (result: SelectResult<TItem>) => void;
|
|
98
100
|
onSubmitComplete?: () => void;
|
|
99
101
|
hideAutoTools?: boolean;
|
|
@@ -180,11 +180,17 @@ export interface SidebarMenuProps extends Omit<JSX.HTMLAttributes<HTMLDivElement
|
|
|
180
180
|
* Menu items array
|
|
181
181
|
*/
|
|
182
182
|
menus: AppMenu[];
|
|
183
|
+
/**
|
|
184
|
+
* When true, all nested menu lists are expanded on initial render.
|
|
185
|
+
* @default false
|
|
186
|
+
*/
|
|
187
|
+
defaultOpen?: boolean;
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
// Internal Context: share initial open state
|
|
186
191
|
interface MenuContextValue {
|
|
187
192
|
initialOpenItems: Accessor<Set<AppMenu>>;
|
|
193
|
+
defaultOpen: boolean;
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
const MenuContext = createContext<MenuContextValue>();
|
|
@@ -215,7 +221,7 @@ const MenuContext = createContext<MenuContextValue>();
|
|
|
215
221
|
* ```
|
|
216
222
|
*/
|
|
217
223
|
const SidebarMenu: Component<SidebarMenuProps> = (props) => {
|
|
218
|
-
const [local, rest] = splitProps(props, ["menus", "class"]);
|
|
224
|
+
const [local, rest] = splitProps(props, ["menus", "class", "defaultOpen"]);
|
|
219
225
|
|
|
220
226
|
const location = useLocation();
|
|
221
227
|
|
|
@@ -249,7 +255,7 @@ const SidebarMenu: Component<SidebarMenuProps> = (props) => {
|
|
|
249
255
|
const getClassName = () => twMerge("flex-1 overflow-y-auto", local.class);
|
|
250
256
|
|
|
251
257
|
return (
|
|
252
|
-
<MenuContext.Provider value={{ initialOpenItems }}>
|
|
258
|
+
<MenuContext.Provider value={{ initialOpenItems, defaultOpen: local.defaultOpen ?? false }}>
|
|
253
259
|
<div {...rest} data-sidebar-menu class={getClassName()}>
|
|
254
260
|
<div class={clsx("px-3.5 py-1 text-sm font-bold", text.muted, "uppercase tracking-wider")}>MENU</div>
|
|
255
261
|
<List inset>
|
|
@@ -281,7 +287,7 @@ const MenuItem: Component<MenuItemProps> = (props) => {
|
|
|
281
287
|
// Calculate open state (compare by object reference)
|
|
282
288
|
const shouldBeOpen = () => menuContext.initialOpenItems().has(props.menu);
|
|
283
289
|
|
|
284
|
-
const [open, setOpen] = createSignal(
|
|
290
|
+
const [open, setOpen] = createSignal(menuContext.defaultOpen);
|
|
285
291
|
|
|
286
292
|
// Update open state in response to pathname change
|
|
287
293
|
createEffect(() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import type { JSX } from "solid-js";
|
|
3
3
|
import { render } from "@solidjs/testing-library";
|
|
4
4
|
import { CrudSheet } from "../../../../src/components/features/crud-sheet/CrudSheet";
|
|
@@ -460,3 +460,118 @@ describe("CrudSheet dialog mode", () => {
|
|
|
460
460
|
expect(container.textContent).not.toContain("Confirm");
|
|
461
461
|
});
|
|
462
462
|
});
|
|
463
|
+
|
|
464
|
+
describe("CrudSheet selectedKeys controllable", () => {
|
|
465
|
+
beforeEach(() => {
|
|
466
|
+
localStorage.setItem("test.i18n-locale", JSON.stringify("en"));
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
afterEach(() => {
|
|
470
|
+
localStorage.removeItem("test.i18n-locale");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const searchFn = () =>
|
|
474
|
+
Promise.resolve({
|
|
475
|
+
items: [
|
|
476
|
+
{ id: 1, name: "홍길동", isDeleted: false },
|
|
477
|
+
{ id: 2, name: "김철수", isDeleted: false },
|
|
478
|
+
{ id: 3, name: "이영희", isDeleted: false },
|
|
479
|
+
],
|
|
480
|
+
pageCount: 1,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("controlled: selectedKeys prop sets initial selection", async () => {
|
|
484
|
+
const { container } = render(() => (
|
|
485
|
+
<ConfigProvider clientName="test"><I18nProvider>
|
|
486
|
+
<TestWrapper>
|
|
487
|
+
<CrudSheet<TestItem, Record<string, never>>
|
|
488
|
+
search={searchFn}
|
|
489
|
+
getItemKey={(item) => item.id}
|
|
490
|
+
selectionMode="multiple"
|
|
491
|
+
selectedKeys={[1, 3]}
|
|
492
|
+
onSelectedKeysChange={() => {}}
|
|
493
|
+
>
|
|
494
|
+
<CrudSheet.Column<TestItem> key="name" header="이름">
|
|
495
|
+
{(ctx) => <div>{ctx.item.name}</div>}
|
|
496
|
+
</CrudSheet.Column>
|
|
497
|
+
</CrudSheet>
|
|
498
|
+
</TestWrapper>
|
|
499
|
+
</I18nProvider></ConfigProvider>
|
|
500
|
+
));
|
|
501
|
+
|
|
502
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
503
|
+
|
|
504
|
+
const checkboxes = container.querySelectorAll('[role="checkbox"]');
|
|
505
|
+
// Header checkbox + 3 row checkboxes = 4 total
|
|
506
|
+
// Row checkboxes: index 1 (id=1, checked), index 2 (id=2, unchecked), index 3 (id=3, checked)
|
|
507
|
+
const rowCheckboxes = Array.from(checkboxes).slice(1);
|
|
508
|
+
expect(rowCheckboxes[0].getAttribute("aria-checked")).toBe("true");
|
|
509
|
+
expect(rowCheckboxes[1].getAttribute("aria-checked")).toBe("false");
|
|
510
|
+
expect(rowCheckboxes[2].getAttribute("aria-checked")).toBe("true");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("controlled: onSelectedKeysChange is called when row checkbox is clicked", async () => {
|
|
514
|
+
const onKeysChange = vi.fn();
|
|
515
|
+
|
|
516
|
+
const { container } = render(() => (
|
|
517
|
+
<ConfigProvider clientName="test"><I18nProvider>
|
|
518
|
+
<TestWrapper>
|
|
519
|
+
<CrudSheet<TestItem, Record<string, never>>
|
|
520
|
+
search={searchFn}
|
|
521
|
+
getItemKey={(item) => item.id}
|
|
522
|
+
selectionMode="multiple"
|
|
523
|
+
selectedKeys={[]}
|
|
524
|
+
onSelectedKeysChange={onKeysChange}
|
|
525
|
+
>
|
|
526
|
+
<CrudSheet.Column<TestItem> key="name" header="이름">
|
|
527
|
+
{(ctx) => <div>{ctx.item.name}</div>}
|
|
528
|
+
</CrudSheet.Column>
|
|
529
|
+
</CrudSheet>
|
|
530
|
+
</TestWrapper>
|
|
531
|
+
</I18nProvider></ConfigProvider>
|
|
532
|
+
));
|
|
533
|
+
|
|
534
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
535
|
+
|
|
536
|
+
// Click the first row's checkbox cell (the clickable div wrapping the checkbox)
|
|
537
|
+
const checkboxCells = container.querySelectorAll('[role="checkbox"]');
|
|
538
|
+
const firstRowCheckboxCell = checkboxCells[1].parentElement;
|
|
539
|
+
firstRowCheckboxCell?.click();
|
|
540
|
+
|
|
541
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
542
|
+
|
|
543
|
+
expect(onKeysChange).toHaveBeenCalled();
|
|
544
|
+
expect(onKeysChange.mock.calls[0][0]).toContain(1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("uncontrolled: selection works without selectedKeys prop", async () => {
|
|
548
|
+
const { container } = render(() => (
|
|
549
|
+
<ConfigProvider clientName="test"><I18nProvider>
|
|
550
|
+
<TestWrapper>
|
|
551
|
+
<CrudSheet<TestItem, Record<string, never>>
|
|
552
|
+
search={searchFn}
|
|
553
|
+
getItemKey={(item) => item.id}
|
|
554
|
+
selectionMode="multiple"
|
|
555
|
+
onSelect={() => {}}
|
|
556
|
+
>
|
|
557
|
+
<CrudSheet.Column<TestItem> key="name" header="이름">
|
|
558
|
+
{(ctx) => <div>{ctx.item.name}</div>}
|
|
559
|
+
</CrudSheet.Column>
|
|
560
|
+
</CrudSheet>
|
|
561
|
+
</TestWrapper>
|
|
562
|
+
</I18nProvider></ConfigProvider>
|
|
563
|
+
));
|
|
564
|
+
|
|
565
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
566
|
+
|
|
567
|
+
// Click the first row's checkbox cell
|
|
568
|
+
const checkboxCells = container.querySelectorAll('[role="checkbox"]');
|
|
569
|
+
const firstRowCheckboxCell = checkboxCells[1].parentElement;
|
|
570
|
+
firstRowCheckboxCell?.click();
|
|
571
|
+
|
|
572
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
573
|
+
|
|
574
|
+
// Checkbox should be checked (internal state)
|
|
575
|
+
expect(checkboxCells[1].getAttribute("aria-checked")).toBe("true");
|
|
576
|
+
});
|
|
577
|
+
});
|