@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.
@@ -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 system dark 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
- const ime = createIMEHandler();
183
- // ime.handleCompositionStart()
184
- // ime.handleCompositionEnd()
185
- // ime.handleInput()
186
- // ime.composingValue()
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 [theme, setTheme] = useLocalStorage("theme", "light");
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.93",
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.93",
56
- "@simplysm/core-common": "13.0.93",
57
- "@simplysm/service-common": "13.0.93",
58
- "@simplysm/service-client": "13.0.93"
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] = createSignal<Set<string | number>>(new Set());
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 keys = selectedKeys();
150
- if (keys.size === 0) {
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 && keys.has(key);
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(new Set<string | number>());
370
+ setSelectedKeys([]);
363
371
  setSelection([]);
364
372
  }
365
373
 
366
374
  function handleSelectConfirm() {
367
375
  local.onSelect?.({
368
376
  items: selection(),
369
- keys: [...selectedKeys()],
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(false);
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
+ });