@svgrid/grid 2.1.21 → 2.1.23

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 (219) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/GridMenus.svelte +83 -3
  7. package/dist/SvAutoComplete.svelte +5 -3
  8. package/dist/SvCarousel.svelte +20 -42
  9. package/dist/SvColorInput.svelte +4 -3
  10. package/dist/SvColorInput.svelte.d.ts +1 -1
  11. package/dist/SvComboBox.svelte +7 -3
  12. package/dist/SvCommand.svelte +28 -85
  13. package/dist/SvCountryInput.svelte +1 -1
  14. package/dist/SvDateRangeInput.svelte +1 -1
  15. package/dist/SvDateTimePicker.svelte +9 -3
  16. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  17. package/dist/SvDockLayout.svelte +244 -0
  18. package/dist/SvDockLayout.svelte.d.ts +77 -0
  19. package/dist/SvDockManager.svelte +922 -0
  20. package/dist/SvDockManager.svelte.d.ts +106 -0
  21. package/dist/SvDrawer.svelte +69 -47
  22. package/dist/SvDrawer.svelte.d.ts +4 -0
  23. package/dist/SvDropDownList.svelte +85 -13
  24. package/dist/SvDropDownList.svelte.d.ts +3 -0
  25. package/dist/SvField.svelte +21 -4
  26. package/dist/SvField.svelte.d.ts +1 -0
  27. package/dist/SvForm.svelte +201 -91
  28. package/dist/SvForm.svelte.d.ts +15 -22
  29. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  30. package/dist/SvGrid.controller.svelte.js +156 -27
  31. package/dist/SvGrid.css +193 -5
  32. package/dist/SvGrid.svelte +357 -12
  33. package/dist/SvGrid.types.d.ts +452 -1
  34. package/dist/SvGridDropdown.svelte +8 -1
  35. package/dist/SvGridSelect.svelte +1 -0
  36. package/dist/SvListBox.svelte +36 -14
  37. package/dist/SvMaskedInput.svelte +4 -3
  38. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  39. package/dist/SvMenu.svelte +1 -1
  40. package/dist/SvMenuList.svelte +23 -71
  41. package/dist/SvMenuList.svelte.d.ts +2 -13
  42. package/dist/SvModal.svelte +12 -29
  43. package/dist/SvMultiSelect.svelte +1 -0
  44. package/dist/SvNumberInput.svelte +4 -3
  45. package/dist/SvNumberInput.svelte.d.ts +1 -1
  46. package/dist/SvPagination.svelte +18 -18
  47. package/dist/SvPasswordInput.svelte +3 -2
  48. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  49. package/dist/SvPhoneInput.svelte +3 -2
  50. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  51. package/dist/SvPopover.svelte +1 -1
  52. package/dist/SvStepper.svelte +6 -9
  53. package/dist/SvTagsInput.svelte +3 -2
  54. package/dist/SvTagsInput.svelte.d.ts +1 -1
  55. package/dist/SvTooltip.svelte +12 -23
  56. package/dist/SvTour.svelte +32 -4
  57. package/dist/SvTreeSelect.svelte +1 -0
  58. package/dist/build-api.js +76 -54
  59. package/dist/cdn/svgrid.js +10907 -6444
  60. package/dist/cdn/svgrid.svelte-external.js +9622 -5475
  61. package/dist/columns.js +6 -0
  62. package/dist/conditional-formatting.d.ts +67 -3
  63. package/dist/conditional-formatting.js +124 -17
  64. package/dist/createCarousel.svelte.d.ts +72 -0
  65. package/dist/createCarousel.svelte.js +80 -0
  66. package/dist/createCommand.svelte.d.ts +62 -0
  67. package/dist/createCommand.svelte.js +109 -0
  68. package/dist/createForm.svelte.d.ts +62 -0
  69. package/dist/createForm.svelte.js +374 -0
  70. package/dist/createMenu.svelte.d.ts +88 -0
  71. package/dist/createMenu.svelte.js +153 -0
  72. package/dist/createOverlay.svelte.d.ts +38 -0
  73. package/dist/createOverlay.svelte.js +68 -0
  74. package/dist/createPagination.d.ts +74 -0
  75. package/dist/createPagination.js +71 -0
  76. package/dist/createPopoverSelect.svelte.js +6 -12
  77. package/dist/createStepper.d.ts +44 -0
  78. package/dist/createStepper.js +26 -0
  79. package/dist/createTooltip.svelte.d.ts +32 -0
  80. package/dist/createTooltip.svelte.js +61 -0
  81. package/dist/datetime/timezone.d.ts +36 -0
  82. package/dist/datetime/timezone.js +110 -0
  83. package/dist/dock-context.d.ts +52 -0
  84. package/dist/dock-context.js +1 -0
  85. package/dist/dock-manager-model.d.ts +136 -0
  86. package/dist/dock-manager-model.js +514 -0
  87. package/dist/dock-model.d.ts +105 -0
  88. package/dist/dock-model.js +288 -0
  89. package/dist/dock-popout.d.ts +30 -0
  90. package/dist/dock-popout.js +81 -0
  91. package/dist/editor-contract.d.ts +2 -0
  92. package/dist/filter-operators.d.ts +6 -0
  93. package/dist/filter-operators.js +35 -2
  94. package/dist/filtering/excel-filters.d.ts +18 -1
  95. package/dist/filtering/excel-filters.js +56 -0
  96. package/dist/form-field.d.ts +73 -0
  97. package/dist/form-field.js +15 -0
  98. package/dist/index.d.ts +25 -5
  99. package/dist/index.js +40 -4
  100. package/dist/list-nav.d.ts +18 -0
  101. package/dist/list-nav.js +30 -0
  102. package/dist/list-option.d.ts +2 -0
  103. package/dist/menu-item.d.ts +20 -0
  104. package/dist/menu-item.js +1 -0
  105. package/dist/menus.d.ts +5 -0
  106. package/dist/menus.js +68 -16
  107. package/dist/panel-resize.d.ts +25 -0
  108. package/dist/panel-resize.js +40 -0
  109. package/dist/popover.d.ts +29 -3
  110. package/dist/popover.js +22 -6
  111. package/dist/recurrence.d.ts +33 -7
  112. package/dist/recurrence.js +151 -13
  113. package/dist/row-drag.d.ts +8 -0
  114. package/dist/row-drag.js +11 -0
  115. package/dist/scheduler-ical.d.ts +30 -0
  116. package/dist/scheduler-ical.js +180 -0
  117. package/dist/scheduler-model.d.ts +269 -0
  118. package/dist/scheduler-model.js +602 -0
  119. package/dist/scheduler-view.svelte.d.ts +22 -0
  120. package/dist/scheduler-view.svelte.js +13 -0
  121. package/dist/spreadsheet.js +20 -12
  122. package/dist/ui-app.types.d.ts +3 -0
  123. package/package.json +5 -1
  124. package/src/DockNodeView.svelte +306 -0
  125. package/src/DockPopoutHost.svelte +17 -0
  126. package/src/GridMenus.svelte +83 -3
  127. package/src/SvAutoComplete.svelte +5 -3
  128. package/src/SvCarousel.svelte +20 -42
  129. package/src/SvCarousel.test.ts +68 -0
  130. package/src/SvColorInput.svelte +4 -3
  131. package/src/SvComboBox.svelte +7 -3
  132. package/src/SvCommand.svelte +28 -85
  133. package/src/SvCommand.test.ts +78 -0
  134. package/src/SvCountryInput.svelte +1 -1
  135. package/src/SvDateRangeInput.svelte +1 -1
  136. package/src/SvDateTimePicker.svelte +9 -3
  137. package/src/SvDockLayout.svelte +244 -0
  138. package/src/SvDockManager.svelte +922 -0
  139. package/src/SvDrawer.svelte +69 -47
  140. package/src/SvDropDownList.svelte +85 -13
  141. package/src/SvField.svelte +21 -4
  142. package/src/SvForm.svelte +201 -91
  143. package/src/SvForm.test.ts +299 -0
  144. package/src/SvGrid.controller.svelte.ts +152 -28
  145. package/src/SvGrid.css +193 -5
  146. package/src/SvGrid.svelte +357 -12
  147. package/src/SvGrid.types.ts +463 -1
  148. package/src/SvGridDropdown.svelte +8 -1
  149. package/src/SvGridSelect.svelte +1 -0
  150. package/src/SvListBox.svelte +36 -14
  151. package/src/SvMaskedInput.svelte +4 -3
  152. package/src/SvMenu.svelte +1 -1
  153. package/src/SvMenu.test.ts +24 -0
  154. package/src/SvMenuList.svelte +23 -71
  155. package/src/SvModal.svelte +12 -29
  156. package/src/SvModal.test.ts +82 -0
  157. package/src/SvMultiSelect.svelte +1 -0
  158. package/src/SvNumberInput.svelte +4 -3
  159. package/src/SvPagination.svelte +18 -18
  160. package/src/SvPasswordInput.svelte +3 -2
  161. package/src/SvPhoneInput.svelte +3 -2
  162. package/src/SvPopover.svelte +1 -1
  163. package/src/SvStepper.svelte +6 -9
  164. package/src/SvTagsInput.svelte +3 -2
  165. package/src/SvTooltip.svelte +12 -23
  166. package/src/SvTooltip.test.ts +73 -0
  167. package/src/SvTour.svelte +32 -4
  168. package/src/SvTreeSelect.svelte +1 -0
  169. package/src/build-api.ts +74 -51
  170. package/src/columns.ts +5 -0
  171. package/src/conditional-formatting.test.ts +104 -0
  172. package/src/conditional-formatting.ts +176 -19
  173. package/src/createCarousel.svelte.ts +104 -0
  174. package/src/createCommand.svelte.ts +125 -0
  175. package/src/createForm.svelte.ts +333 -0
  176. package/src/createMenu.svelte.ts +202 -0
  177. package/src/createOverlay.svelte.ts +101 -0
  178. package/src/createPagination.test.ts +65 -0
  179. package/src/createPagination.ts +84 -0
  180. package/src/createPopoverSelect.svelte.ts +5 -9
  181. package/src/createStepper.test.ts +62 -0
  182. package/src/createStepper.ts +61 -0
  183. package/src/createTooltip.svelte.ts +86 -0
  184. package/src/datetime/timezone.test.ts +69 -0
  185. package/src/datetime/timezone.ts +134 -0
  186. package/src/dock-context.ts +47 -0
  187. package/src/dock-manager-model.test.ts +275 -0
  188. package/src/dock-manager-model.ts +593 -0
  189. package/src/dock-model.test.ts +163 -0
  190. package/src/dock-model.ts +368 -0
  191. package/src/dock-popout.ts +94 -0
  192. package/src/editor-contract.ts +2 -0
  193. package/src/filter-operators.test.ts +59 -0
  194. package/src/filter-operators.ts +35 -2
  195. package/src/filtering/excel-filters.test.ts +86 -1
  196. package/src/filtering/excel-filters.ts +65 -0
  197. package/src/form-field.ts +87 -0
  198. package/src/index.ts +158 -4
  199. package/src/list-nav.test.ts +49 -0
  200. package/src/list-nav.ts +29 -0
  201. package/src/list-option.ts +2 -0
  202. package/src/menu-item.ts +21 -0
  203. package/src/menus.test.ts +48 -0
  204. package/src/menus.ts +73 -16
  205. package/src/panel-resize.ts +52 -0
  206. package/src/popover.bounds.test.ts +93 -0
  207. package/src/popover.ts +51 -7
  208. package/src/recurrence.test.ts +88 -1
  209. package/src/recurrence.ts +166 -15
  210. package/src/row-drag.ts +10 -0
  211. package/src/scheduler-ical.test.ts +68 -0
  212. package/src/scheduler-ical.ts +180 -0
  213. package/src/scheduler-model.test.ts +546 -0
  214. package/src/scheduler-model.ts +853 -0
  215. package/src/scheduler-view.svelte.ts +33 -0
  216. package/src/spreadsheet.ts +20 -12
  217. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  218. package/src/ui-app.types.ts +3 -0
  219. package/src/virtual.test.ts +6 -4
@@ -2,6 +2,51 @@ import type { Snippet } from "svelte";
2
2
  import type { CellEditorType, ColumnDef, RowData, SvGridApi, TableFeatures } from "./index";
3
3
  import type { ConditionalFormat } from "./conditional-formatting";
4
4
  import type { MenuItem } from "./SvMenuList.svelte";
5
+ /** The calendar views the scheduler can render. {@link SchedulerConfig}. The
6
+ * `timeline*` views are horizontal: time runs left→right and resources are
7
+ * rows (a single "All" row without `resourceField`). */
8
+ export type SchedulerView = "month" | "week" | "day" | "agenda" | "timelineDay" | "timelineWeek" | "timelineMonth" | "timelineYear";
9
+ /**
10
+ * How overlapping ("colliding") time-grid events are laid out:
11
+ * - `split` - every collision divides the column width evenly (default).
12
+ * - `cap` - show up to `maxColumns` columns; the rest collapse into a
13
+ * clickable `+N more` overflow tile.
14
+ * - `stack` - overlapping events overlap with a horizontal offset + z-order
15
+ * instead of shrinking, so each stays readable (hover to raise).
16
+ */
17
+ export type SchedulerCollisionMode = "split" | "cap" | "stack";
18
+ /** A scheduler resource - a person / room / machine an event can be assigned to. */
19
+ export type SchedulerResource = {
20
+ id: string;
21
+ title?: string;
22
+ color?: string;
23
+ /**
24
+ * This resource's own working windows (hours), optionally per weekday - e.g. a
25
+ * doctor available Mon/Wed/Fri 9-13 and Tue/Thu 14-18. Overrides the global
26
+ * `businessHours` / `nonWorkingDays` for this resource's columns: time outside
27
+ * every matching window is shaded, and (with `restrictToBusinessHours`)
28
+ * non-bookable. A weekday with no matching window is a full day off.
29
+ */
30
+ availability?: ReadonlyArray<{
31
+ days?: ReadonlyArray<number>;
32
+ start: number;
33
+ end: number;
34
+ }>;
35
+ /**
36
+ * Per-date overrides to this resource's weekly `availability` - a specific day
37
+ * off (`off: true`, e.g. vacation) or custom hours (`windows`) for that one
38
+ * date. Matched by calendar date; takes precedence over `availability` for the
39
+ * matching day.
40
+ */
41
+ dateOverrides?: ReadonlyArray<{
42
+ date: Date | number | string;
43
+ off?: boolean;
44
+ windows?: ReadonlyArray<{
45
+ start: number;
46
+ end: number;
47
+ }>;
48
+ }>;
49
+ };
5
50
  import type { ChartType, ChartSpec, ChartAnnotation, ChartReferenceLine } from "./chart";
6
51
  /** One aggregated bucket returned by a server-side `getAggregate`. */
7
52
  export type ChartAggregateBucket = {
@@ -422,6 +467,399 @@ export type BoardConfig<TFeatures extends TableFeatures = TableFeatures, TData e
422
467
  /** Called whenever the board layout changes, with the serializable state. */
423
468
  onLayoutChange?: (layout: BoardLayout) => void;
424
469
  };
470
+ /**
471
+ * The built-in event detail drawer for the scheduler. Set `scheduler.drawer`
472
+ * (`true` for all fields, or this object) and clicking an event opens a drawer
473
+ * with an `SvForm` of its fields. Mirrors {@link BoardDrawerConfig}.
474
+ */
475
+ export type SchedulerDrawerConfig<TData extends RowData = RowData> = {
476
+ /** Fields to show, in order. Omit for every column that has a `field`. */
477
+ fields?: ReadonlyArray<keyof TData & string>;
478
+ /** Drawer title - a string or derived from the row. Defaults to the title field. */
479
+ title?: string | ((row: TData) => string);
480
+ /** Which edge the drawer opens from. Defaults to `'right'`. */
481
+ side?: "right" | "left" | "top" | "bottom";
482
+ /** Drawer size (any CSS length). Defaults to `'380px'`. */
483
+ size?: string;
484
+ /** Save button label. Defaults to `'Save'`. */
485
+ submitLabel?: string;
486
+ /** Form columns inside the drawer. Defaults to `1`. */
487
+ columns?: number;
488
+ };
489
+ /** Emitted when an event is dragged to a new time (and/or resource). */
490
+ export type SchedulerEventMoveEvent<TData extends RowData = RowData> = {
491
+ /** The dragged row. */
492
+ row: TData;
493
+ /** New start / end after the move. */
494
+ start: Date;
495
+ end: Date;
496
+ /** Whether the event is all-day after the move. */
497
+ allDay: boolean;
498
+ /** Resource it came from (only with `resourceField`). */
499
+ fromResource?: string;
500
+ /** Resource it was dropped on (only with `resourceField`). */
501
+ toResource?: string;
502
+ };
503
+ /** Emitted when an event's start or end edge is dragged to resize it. */
504
+ export type SchedulerEventResizeEvent<TData extends RowData = RowData> = {
505
+ row: TData;
506
+ start: Date;
507
+ end: Date;
508
+ };
509
+ /**
510
+ * A drag-selected range (see `rangeSelectable`). `start`/`end` are a single
511
+ * CONTINUOUS datetime range - dragging across days makes it a longer span, not a
512
+ * per-day rectangle. `days` / `resourceIds` list the days / resources the range
513
+ * touched (for reference); `allDay` is true when the all-day row was dragged.
514
+ */
515
+ export type SchedulerRangeSelection = {
516
+ start: Date;
517
+ end: Date;
518
+ allDay?: boolean;
519
+ /** Every day the range touched (one entry for a within-a-day drag). */
520
+ days: Date[];
521
+ /** Every resource the range touched (empty without `resourceField`). */
522
+ resourceIds: string[];
523
+ };
524
+ /** Emitted when an event's built-in editor/drawer is saved. */
525
+ export type SchedulerEventCommitEvent<TData extends RowData = RowData> = {
526
+ row: TData;
527
+ /** Only the fields that changed, `{ field: newValue }`. */
528
+ changes: Record<string, unknown>;
529
+ /** The full set of edited field values. */
530
+ values: Record<string, unknown>;
531
+ };
532
+ /**
533
+ * One override of a single occurrence of a recurring event (stored in the row's
534
+ * `recurrenceExceptionsField`). `occurrenceStart` identifies the occurrence (its
535
+ * original start, like an iCal `RECURRENCE-ID`); `deleted` removes just that one,
536
+ * otherwise the given fields override it. Times are the same ISO / Date shape you
537
+ * store on the row.
538
+ */
539
+ export type SchedulerException = {
540
+ occurrenceStart: string | number | Date;
541
+ deleted?: boolean;
542
+ start?: string | number | Date;
543
+ end?: string | number | Date;
544
+ title?: string;
545
+ allDay?: boolean;
546
+ };
547
+ /** How a change to a recurring event should apply. */
548
+ export type SchedulerEditScope = "occurrence" | "following" | "series";
549
+ /**
550
+ * Fired when a single occurrence of a recurring event is edited or deleted (the
551
+ * user chose "This event"). Append/merge `exception` into the row's
552
+ * `recurrenceExceptionsField`; for `deleted` it carries `{ deleted: true }`.
553
+ */
554
+ export type SchedulerOccurrenceChangeEvent<TData extends RowData = RowData> = {
555
+ row: TData;
556
+ /** The occurrence's original start (its identity within the series). */
557
+ occurrenceStart: Date;
558
+ /** The override to store (already in real-instant time). */
559
+ exception: SchedulerException;
560
+ /**
561
+ * Which scope the user chose. `'occurrence'` (default): merge `exception` into
562
+ * the row. `'following'` ("this and all following"): split the series - stop
563
+ * the original series before `occurrenceStart` and start a NEW series at it
564
+ * carrying `exception`'s start/end/deleted. `'series'` never reaches here (it
565
+ * edits the base row directly). Handle `'following'` in your own data.
566
+ */
567
+ scope: SchedulerEditScope;
568
+ };
569
+ /**
570
+ * Turns the grid into a calendar / scheduler. Set `scheduler` and the grid
571
+ * renders its rows as events on a Month / Week / Day / Agenda calendar (bucketed
572
+ * by time, and optionally split into per-resource columns) instead of a table.
573
+ * Dragging an event fires {@link SchedulerConfig.onEventMove} where you reassign
574
+ * the start / end (and resource) on your own data. Like the Kanban board, it is
575
+ * a pure *view of the grid*: it renders the grid's filtered + sorted rows and
576
+ * writes back only through callbacks, never mutating your data.
577
+ */
578
+ export type SchedulerConfig<TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData> = {
579
+ /** Field holding each event's start (`Date` | epoch-ms | ISO string). Required. */
580
+ startField: keyof TData & string;
581
+ /** Field holding the end. Omit to use `defaultDurationMin` from the start. */
582
+ endField?: keyof TData & string;
583
+ /** Boolean field marking an event as all-day (rendered in the all-day row). */
584
+ allDayField?: keyof TData & string;
585
+ /** Field for the event title. Defaults to the first column's field. */
586
+ titleField?: keyof TData & string;
587
+ /** Field holding a per-event accent color (any CSS color). Else `color`. */
588
+ colorField?: keyof TData & string;
589
+ /** Fallback accent color for every event. */
590
+ color?: string;
591
+ /**
592
+ * Field holding a SECONDARY accent color, rendered as a strip on the LEFT edge
593
+ * of the event - distinct from the main `colorField`. Lets an event encode two
594
+ * dimensions at once (e.g. main color = person, left strip = role).
595
+ */
596
+ secondaryColorField?: keyof TData & string;
597
+ /**
598
+ * Field holding a free/busy status that drives a distinct visual treatment
599
+ * (Outlook-style): `'busy'` (default solid), `'free'` (hollow / outline),
600
+ * `'tentative'` (hatched), `'oof'` / `'outOfOffice'` (distinct tint). Ties to
601
+ * iCal `STATUS`. Unrecognised values render as busy.
602
+ */
603
+ statusField?: keyof TData & string;
604
+ /**
605
+ * Field holding minutes-before-start for a reminder. When set (and the event
606
+ * is upcoming) the scheduler fires `onReminder` once as that lead time is
607
+ * crossed - pair it with a toast. Ties to iCal `VALARM`.
608
+ */
609
+ reminderField?: keyof TData & string;
610
+ /** Fired once when an event's reminder lead time is crossed (see `reminderField`). */
611
+ onReminder?: (row: TData, minutesUntil: number) => void;
612
+ /**
613
+ * Field holding a {@link RecurrenceRule} (or array) - the row renders as one
614
+ * event per matching day in view, keeping its time-of-day + duration.
615
+ */
616
+ recurrenceField?: keyof TData & string;
617
+ /**
618
+ * Field holding an array of {@link SchedulerException} - per-occurrence
619
+ * overrides of a recurring event (a moved / edited / deleted single instance).
620
+ * When set, editing a recurring occurrence offers "This event" vs "All events"
621
+ * and "This event" fires {@link onOccurrenceChange}.
622
+ */
623
+ recurrenceExceptionsField?: keyof TData & string;
624
+ /** Event length in minutes when a row has a start but no `endField`. Default 60. */
625
+ defaultDurationMin?: number;
626
+ /** Which views to offer in the toolbar. Default all four. */
627
+ views?: ReadonlyArray<SchedulerView>;
628
+ /** The view shown first. Default `'month'`. */
629
+ initialView?: SchedulerView;
630
+ /** The date the calendar opens on. Defaults to "today". */
631
+ initialDate?: Date | number | string;
632
+ /**
633
+ * Controlled anchor date - when this changes the calendar navigates to it (a
634
+ * mini date-picker or external "go to" can drive the view). Pair with
635
+ * {@link onNavigate} for two-way sync.
636
+ */
637
+ date?: Date | number | string;
638
+ /** Fired when the visible date changes (prev / next / today). */
639
+ onNavigate?: (date: Date) => void;
640
+ /** First day of the week, 0-6 (0 = Sunday). Default 0. */
641
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
642
+ /** Time-grid slot size in minutes (week / day snap granularity). Default 30. */
643
+ slotMinutes?: number;
644
+ /**
645
+ * Slot sizes (minutes) offered as a runtime picker in the Week / Day time-grid
646
+ * ruler, e.g. `[60, 30, 15, 5]`. When set, a size selector appears in the
647
+ * toolbar and the ruler subdivides accordingly; the active size starts at
648
+ * `slotMinutes`. Omit to hide the picker (the ruler still uses `slotMinutes`).
649
+ */
650
+ slotSizes?: ReadonlyArray<number>;
651
+ /** First / last hour shown in the time-grid band (0-24). Default 0..24. */
652
+ dayStartHour?: number;
653
+ dayEndHour?: number;
654
+ /**
655
+ * Working-hours window (hours, e.g. `{ start: 9, end: 17 }`). Time outside it
656
+ * is shaded in the Week / Day time-grid. Visual only - see `disableConflicts`
657
+ * to enforce.
658
+ */
659
+ businessHours?: {
660
+ start: number;
661
+ end: number;
662
+ };
663
+ /** Weekday numbers (0 = Sun … 6 = Sat) shaded as non-working (e.g. `[0, 6]`). */
664
+ nonWorkingDays?: ReadonlyArray<number>;
665
+ /** Shade elapsed time on today's column (the past is "used up"). */
666
+ shadeUntilNow?: boolean;
667
+ /**
668
+ * Enforce the shaded time: reject a drag / resize / create that lands outside
669
+ * `businessHours` or on a `nonWorkingDays` day (snap back + flash). Without
670
+ * this the shading is a visual hint only.
671
+ */
672
+ restrictToBusinessHours?: boolean;
673
+ /**
674
+ * Prevent double-booking: dragging / resizing / creating an event so it
675
+ * overlaps another event **on the same resource** is rejected and reverted.
676
+ */
677
+ disableConflicts?: boolean;
678
+ /**
679
+ * Hard-blocked hour bands (e.g. a daily maintenance window `[{ start: 12, end: 13 }]`).
680
+ * Unlike `businessHours` these are ALWAYS non-bookable (drop / create rejected,
681
+ * no opt-in needed) and rendered as a distinct restricted (hatched) band.
682
+ */
683
+ restrictedHours?: ReadonlyArray<{
684
+ start: number;
685
+ end: number;
686
+ }>;
687
+ /** Specific calendar dates that are fully blocked (no bookings) - e.g. closures. */
688
+ restrictedDates?: ReadonlyArray<Date | number | string>;
689
+ /**
690
+ * Highlighted dates (holidays, launches …) - a coloured strip + optional label
691
+ * on that day's column / month cell. Decorative; does not block booking.
692
+ */
693
+ specialDates?: ReadonlyArray<{
694
+ date: Date | number | string;
695
+ label?: string;
696
+ color?: string;
697
+ }>;
698
+ /** Earliest navigable / bookable date. Prev-nav stops here and creates are blocked before it. */
699
+ minDate?: Date | number | string;
700
+ /** Latest navigable / bookable date. Next-nav stops here and creates are blocked after it. */
701
+ maxDate?: Date | number | string;
702
+ /**
703
+ * Cap concurrent events per resource at any moment: a drag / resize / create
704
+ * that would make more than N events overlap on the same resource is rejected.
705
+ */
706
+ maxEventsPerSlot?: number;
707
+ /**
708
+ * Enable **undo / redo** of drag-move + resize with `Ctrl/Cmd+Z` and
709
+ * `Ctrl/Cmd+Shift+Z` (or `Ctrl+Y`). The scheduler re-emits the move/resize
710
+ * callbacks with the reversed values, so your data follows.
711
+ */
712
+ history?: boolean;
713
+ /**
714
+ * Show the current-time indicator - a line across today's column(s) in the
715
+ * Week / Day time-grid and the Day timeline, tracking the local time. On by
716
+ * default; set to `false` to hide it.
717
+ */
718
+ nowIndicator?: boolean;
719
+ /**
720
+ * IANA time zone (e.g. `'America/New_York'`) the whole calendar is shown in -
721
+ * the hour ruler, event positions, day boundaries, all-day grouping and the
722
+ * now-line. Defaults to the browser's local zone. Event `start`/`end` must be
723
+ * instant-unambiguous (UTC / offset ISO or epoch ms) for this to be correct;
724
+ * bare local strings are read as wall-clock in this zone.
725
+ */
726
+ timeZone?: string;
727
+ /**
728
+ * Extra read-only hour rulers for other zones (a "world clock"), shown left of
729
+ * the primary ruler in the Week / Day time-grid. Each entry's `label` heads its
730
+ * column (defaults to a short zone abbreviation).
731
+ */
732
+ secondaryTimeZones?: ReadonlyArray<{
733
+ id: string;
734
+ label?: string;
735
+ }>;
736
+ /**
737
+ * How overlapping time-grid events are laid out. `split` (default) divides the
738
+ * width evenly per collision; `cap` shows up to `maxColumns` then a clickable
739
+ * `+N more` tile; `stack` overlaps them with an offset instead of shrinking.
740
+ */
741
+ collisionMode?: SchedulerCollisionMode;
742
+ /** For `collisionMode: 'cap'`: max side-by-side columns before overflow (min 2). Default 3. */
743
+ maxColumns?: number;
744
+ /** How many days the agenda view spans. Default 30. */
745
+ agendaDays?: number;
746
+ /**
747
+ * Field whose value groups events by resource (people, rooms, machines). In
748
+ * the Week / Day time-grid the columns split into resource x day groups; a
749
+ * resource filter/legend appears in every view. See {@link SchedulerResource}.
750
+ */
751
+ resourceField?: keyof TData & string;
752
+ /** Explicit, ordered resources (with colors). Omit to derive from the data. */
753
+ resources?: ReadonlyArray<SchedulerResource>;
754
+ /**
755
+ * Time-grid resource grouping order. `false` (default) groups by resource then
756
+ * day (resource-major); `true` groups by day then resource (date-major), like
757
+ * Smart's `groupByDate`. Only affects Week / Day when `resourceField` is set.
758
+ */
759
+ groupByDate?: boolean;
760
+ /** Timeline views only: width (px) of the left resource-label gutter. Default 160. */
761
+ resourceAreaWidth?: number;
762
+ /** `timelineDay` tick size + move/resize snap, in minutes. Default = `slotMinutes`. */
763
+ timelineSlotMinutes?: number;
764
+ /** Timeline views only: height (px) of one event lane inside a resource row. Default 26. */
765
+ timelineLaneHeight?: number;
766
+ /** Enable drag-to-move and edge-resize. Without it the calendar is read-only. */
767
+ editable?: boolean;
768
+ /** Fired when an event is dragged to a new time / resource. */
769
+ onEventMove?: (event: SchedulerEventMoveEvent<TData>) => void;
770
+ /** Fired when an event edge is dragged to resize it. */
771
+ onEventResize?: (event: SchedulerEventResizeEvent<TData>) => void;
772
+ /**
773
+ * Fired to create an event - double-clicking an empty slot, or confirming a
774
+ * pending range when no {@link onRangeSelect} is set. `allDay` is `true` when
775
+ * the slot / range is an all-day one (the all-day row, a month day cell, or a
776
+ * multi-day timeline zoom), so the handler can set the `allDayField`.
777
+ */
778
+ onEventAdd?: (start: Date, end: Date, resourceId?: string, allDay?: boolean) => void;
779
+ /**
780
+ * Fired when the event's Delete button is used. Setting this shows a Delete
781
+ * button in the detail drawer (and a `Delete` item is easy to add via
782
+ * `eventMenu`). Remove the row from your data in the handler.
783
+ */
784
+ onEventDelete?: (row: TData) => void;
785
+ /**
786
+ * Fired when a single occurrence of a recurring event is moved / resized /
787
+ * deleted with scope "This event". Merge `event.exception` into the row's
788
+ * `recurrenceExceptionsField` array (keyed by `occurrenceStart`).
789
+ */
790
+ onOccurrenceChange?: (event: SchedulerOccurrenceChangeEvent<TData>) => void;
791
+ /**
792
+ * Selecting empty cells: click or drag across the time-grid / timeline to mark
793
+ * a time range (and, across days / resources, a rectangle of cells). Arrow keys
794
+ * move the selection and `Shift`+arrows extend it. Marking only *marks* the
795
+ * range - it does NOT create anything. The event is created on an explicit
796
+ * confirm: press `Enter`, or right-click the selection and choose "Add Event".
797
+ * Confirming fires {@link onRangeSelect}, or falls back to `onEventAdd` for the
798
+ * first cell; `Escape` cancels the marker.
799
+ *
800
+ * On by default; set to `false` to disable.
801
+ */
802
+ rangeSelectable?: boolean;
803
+ /**
804
+ * Fired when a pending cell range is confirmed with `Enter` or the "Add Event"
805
+ * context menu (see {@link rangeSelectable}) - not on drag release.
806
+ */
807
+ onRangeSelect?: (selection: SchedulerRangeSelection) => void;
808
+ /**
809
+ * Multi-selecting existing events: Ctrl/Cmd-click toggles, Shift-click
810
+ * range-selects; a plain click clears. Selected events move together when one
811
+ * is dragged, and `Delete` removes them (via `onEventDelete`).
812
+ *
813
+ * On by default; set to `false` to disable.
814
+ */
815
+ eventSelectable?: boolean;
816
+ /** Fired when the set of multi-selected events changes. */
817
+ onEventSelectionChange?: (rows: TData[]) => void;
818
+ /**
819
+ * An "unscheduled" backlog list shown beside the Week / Day grid: drag an item
820
+ * onto the calendar to schedule it. Each item is `{ id, title, durationMin?,
821
+ * color? }`. Dropping fires {@link onSchedule} with the drop time + resource.
822
+ */
823
+ unscheduled?: ReadonlyArray<{
824
+ id: string;
825
+ title: string;
826
+ durationMin?: number;
827
+ color?: string;
828
+ }>;
829
+ /** Optional heading for the unscheduled backlog panel. Default "Unscheduled". */
830
+ backlogTitle?: string;
831
+ /** Fired when an unscheduled item is dropped on the grid - create an event from it. */
832
+ onSchedule?: (item: {
833
+ id: string;
834
+ title: string;
835
+ durationMin?: number;
836
+ color?: string;
837
+ }, start: Date, resourceId?: string) => void;
838
+ /**
839
+ * Fired when an event is dragged OFF the calendar onto the backlog panel -
840
+ * remove it from the schedule (and typically push it back to `unscheduled`).
841
+ * Requires the backlog panel (an `unscheduled` list) to be visible.
842
+ */
843
+ onUnschedule?: (row: TData) => void;
844
+ /** Custom event body. Receives the row. Omit for the built-in default (title). */
845
+ event?: Snippet<[TData]>;
846
+ /**
847
+ * Hover tooltip for an event. Set a `Snippet<[TData]>` for custom content, or
848
+ * `true` for the built-in tooltip (title + time + resource). Omit to disable.
849
+ */
850
+ tooltip?: boolean | Snippet<[TData]>;
851
+ /** Delay (ms) before the hover tooltip opens. Default 400. */
852
+ tooltipDelay?: number;
853
+ /** Built-in detail drawer: `true` for all fields, or a config object. */
854
+ drawer?: boolean | SchedulerDrawerConfig<TData>;
855
+ /** Fired when the drawer / editor is saved. */
856
+ onEventCommit?: (event: SchedulerEventCommitEvent<TData>) => void;
857
+ /** Right-click menu for an event. Return items or `undefined` to suppress. */
858
+ eventMenu?: (row: TData) => MenuItem[] | undefined;
859
+ /** Show the search box (binds to the grid's global filter). Default `true`. */
860
+ searchable?: boolean;
861
+ searchPlaceholder?: string;
862
+ };
425
863
  export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends RowData = RowData> = {
426
864
  data: ReadonlyArray<TData>;
427
865
  columns: Array<ColumnDef<TFeatures, TData>>;
@@ -431,6 +869,12 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
431
869
  * {@link BoardConfig}.
432
870
  */
433
871
  board?: BoardConfig<TFeatures, TData>;
872
+ /**
873
+ * Scheduler / calendar mode. When set, the grid renders its rows as events on
874
+ * a Month / Week / Day / Agenda calendar instead of a table. See
875
+ * {@link SchedulerConfig}.
876
+ */
877
+ scheduler?: SchedulerConfig<TFeatures, TData>;
434
878
  /**
435
879
  * Right-click context menu. `true` shows the default item set (copy, cut,
436
880
  * paste, clear, insert row above/below, remove row, remove column). Pass an
@@ -783,6 +1227,13 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
783
1227
  * or omit it to apply to every column. Later entries win on conflict.
784
1228
  */
785
1229
  conditionalFormats?: ReadonlyArray<ConditionalFormat<TData>>;
1230
+ /**
1231
+ * Which rows feed the min/max range that `colorScale` / `dataBar` formats
1232
+ * scale against. `visible` (default): the currently displayed rows (after
1233
+ * filtering + paging), so the heat map adapts to what's on screen. `all`:
1234
+ * the full unfiltered dataset, for a scale that stays put as you filter.
1235
+ */
1236
+ conditionalStatScope?: "visible" | "all";
786
1237
  onCellValueChange?: (event: {
787
1238
  rowIndex: number;
788
1239
  columnId: string;
@@ -991,7 +1442,7 @@ export type CellEditState = {
991
1442
  editorType: CellEditorType;
992
1443
  value: unknown;
993
1444
  } | null;
994
- export type FilterOperator = "contains" | "equals" | "startsWith" | "greaterThan" | "lessThan" | "between" | "isBlank";
1445
+ export type FilterOperator = "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" | "regex" | "in" | "notIn" | "greaterThan" | "lessThan" | "between" | "isBlank" | "isNotBlank";
995
1446
  export type FilterOption = {
996
1447
  value: FilterOperator;
997
1448
  label: string;
@@ -88,6 +88,8 @@
88
88
  left: 0,
89
89
  width: 0,
90
90
  openUpward: false,
91
+ maxHeight: 0,
92
+ availHeight: 0,
91
93
  })
92
94
 
93
95
  const selectedArr = $derived(
@@ -376,7 +378,12 @@
376
378
  Panel is `position: fixed` + portal'd to <body> so it escapes
377
379
  the grid's overflow:hidden scroll container. -->
378
380
  {@const needsScroll = options.length > 10}
379
- {@const panelMax = needsScroll ? 10 * 32 + (multiple ? 40 : 0) + 8 : null}
381
+ <!-- Cap to the comfortable 10-row height AND the room the viewport allows,
382
+ so a panel near the bottom edge scrolls instead of overflowing. -->
383
+ {@const panelMax = Math.min(
384
+ needsScroll ? 10 * 32 + (multiple ? 40 : 0) + 8 : Number.POSITIVE_INFINITY,
385
+ panelRect.maxHeight,
386
+ )}
380
387
  <div
381
388
  class="sv-grid-dropdown-panel"
382
389
  class:sv-grid-dropdown-panel-fits={!needsScroll}
@@ -153,6 +153,7 @@
153
153
  style:top={`${gs.rect.top}px`}
154
154
  style:left={`${gs.rect.left}px`}
155
155
  style:min-width={`${Math.max(gs.rect.width, 320)}px`}
156
+ style:max-height={`${gs.rect.maxHeight}px`}
156
157
  onkeydown={gs.onPanelKeydown}
157
158
  >
158
159
  {#if searchable}
@@ -5,7 +5,7 @@
5
5
  * via `value` (scalar or array) + `onChange`. Scales to huge option sets with
6
6
  * `virtual` (fixed-row windowing), and each row can be an `itemTemplate`.
7
7
  */
8
- import { flushSync, type Snippet } from 'svelte'
8
+ import { type Snippet } from 'svelte'
9
9
  import { groupOptions, hasGroups, type ListOption } from './list-option'
10
10
  import { virtualRange, scrollToIndex } from './virtual'
11
11
  import { createListbox } from './createListbox.svelte'
@@ -101,17 +101,25 @@
101
101
  class:is-invalid={invalid}
102
102
  class:is-virtual={useVirtual}
103
103
  style:--sv-rows={rows}
104
- onscroll={(e) => { scrollTop = e.currentTarget.scrollTop; if (useVirtual) flushSync() }}
104
+ style:--sv-row-h={`${rowHeight}px`}
105
+ onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
105
106
  bind:clientHeight={viewportH}
106
107
  {...lb.rootProps()}
107
108
  >
108
109
  {#if useVirtual}
109
- <!-- Fixed-height sizer establishes the scroll range; rows are absolutely
110
- positioned by transform so scrolling never reflows (no flash). -->
111
- <li class="sv-listbox__sizer" aria-hidden="true" style:height={`${vr.totalHeight}px`}></li>
110
+ <!-- Windowing by top/bottom spacers with the visible rows IN FLOW between
111
+ them (padTop + rows + padBottom always sum to the full height). Plain
112
+ flow rows (no absolute positioning, no will-change) keep the scroller
113
+ off its own compositor layer, so a fast jump repaints in step with the
114
+ scroll instead of showing the blank frame a promoted layer would. The
115
+ window updates from `scrollTop` state, which Svelte flushes before the
116
+ next paint - no flushSync (it over-flushes app-wide and, on a fling,
117
+ starves the very handler that has to keep up with the scroll). -->
118
+ <li class="sv-listbox__spacer" aria-hidden="true" style:height={`${vr.padTop}px`}></li>
112
119
  {#each windowed as opt (opt.value)}
113
120
  {@render optionLi(opt, opt.index)}
114
121
  {/each}
122
+ <li class="sv-listbox__spacer" aria-hidden="true" style:height={`${vr.padBottom}px`}></li>
115
123
  {:else}
116
124
  {#each groupOptions(options) as g (g.group ?? ' ')}
117
125
  {#if g.group != null}<li class="sv-listbox__group" role="presentation">{g.group}</li>{/if}
@@ -132,7 +140,6 @@
132
140
  class:is-active={lb.isActive(index)}
133
141
  class:is-disabled={opt.disabled}
134
142
  style:height={`${rowHeight}px`}
135
- style:transform={useVirtual ? `translateY(${index * rowHeight}px)` : undefined}
136
143
  {...lb.optionProps(index)}
137
144
  >
138
145
  {#if multiple}<span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>{/if}
@@ -144,7 +151,7 @@
144
151
  .sv-listbox {
145
152
  --_accent: var(--sg-accent, #2563eb);
146
153
  margin: 0; padding: 4px; list-style: none;
147
- max-height: calc(var(--sv-rows, 7) * 32px + 8px); overflow-y: auto;
154
+ max-height: calc(var(--sv-rows, 7) * var(--sv-row-h, 32px) + 8px); overflow-y: auto;
148
155
  background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
149
156
  border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
150
157
  width: 220px; outline: none;
@@ -152,15 +159,30 @@
152
159
  .sv-listbox:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
153
160
  .sv-listbox.is-invalid { border-color: var(--sg-danger, #dc2626); }
154
161
  .sv-listbox.is-invalid:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
155
- /* Pre-promote the scrolling-contents layer at mount so the FIRST scroll
156
- doesn't trigger a one-time layer promotion + repaint (the initial flash). */
157
- .sv-listbox.is-virtual { position: relative; will-change: scroll-position; }
158
- .sv-listbox__sizer { padding: 0; margin: 0; pointer-events: none; }
159
- .sv-listbox.is-virtual .sv-listbox__opt {
160
- position: absolute; inset-inline: 4px; top: 4px;
162
+ /* Spacers reserve the off-screen height above/below the windowed rows. They
163
+ also paint faint per-row skeleton bars: on a fast scrollbar-thumb drag the
164
+ compositor can reveal this padding before JS re-windows (a drag jumps many
165
+ rows in one frame - overscan can't cover it), so the user would see blank.
166
+ The skeleton fills that gap with placeholder rows that the real rows paint
167
+ over a frame later. Bars are one per `--sv-row-h` band, aligned to the row
168
+ grid (padTop/padBottom are exact multiples of the row height). */
169
+ .sv-listbox__spacer {
170
+ padding: 0; margin: 0; flex: none; pointer-events: none; list-style: none;
171
+ background-image: linear-gradient(
172
+ to bottom,
173
+ transparent 0,
174
+ transparent calc((var(--sv-row-h, 32px) - 10px) / 2),
175
+ var(--sg-skeleton, color-mix(in srgb, currentColor 9%, transparent)) calc((var(--sv-row-h, 32px) - 10px) / 2),
176
+ var(--sg-skeleton, color-mix(in srgb, currentColor 9%, transparent)) calc((var(--sv-row-h, 32px) + 10px) / 2),
177
+ transparent calc((var(--sv-row-h, 32px) + 10px) / 2),
178
+ transparent var(--sv-row-h, 32px)
179
+ );
180
+ background-size: 62% var(--sv-row-h, 32px);
181
+ background-repeat: repeat-y;
182
+ background-position: 12px 0;
161
183
  }
162
184
  .sv-listbox__opt {
163
- display: flex; align-items: center; gap: 8px; box-sizing: border-box; padding: 0 10px;
185
+ display: flex; align-items: center; gap: 8px; box-sizing: border-box; flex: none; padding: 0 10px;
164
186
  border-radius: 6px; cursor: pointer; font-size: 13px;
165
187
  }
166
188
  .sv-listbox__opt.is-active { background: var(--sg-row-hover-bg, #f1f5f9); }
@@ -27,7 +27,7 @@
27
27
  }
28
28
 
29
29
  let {
30
- value = '',
30
+ value = $bindable(''),
31
31
  onChange,
32
32
  mask = '',
33
33
  placeholder,
@@ -53,7 +53,8 @@
53
53
 
54
54
  const mi = createMaskedInput({
55
55
  value: () => value,
56
- onChange: (m, r, c) => onChange?.(m, r, c),
56
+ // Write the masked `value` (so `bind:value` works) AND fire `onChange`.
57
+ onChange: (m, r, c) => { value = m; onChange?.(m, r, c) },
57
58
  mask: () => mask,
58
59
  placeholder: () => placeholder,
59
60
  disabled: () => disabled,
@@ -72,7 +73,7 @@
72
73
  {#if prefixIcon}<span class="sv-masked__adorn">{@render prefixIcon()}</span>{/if}
73
74
  <input class="sv-masked__input" {...mi.inputProps()} />
74
75
  {#if clearable && mi.masked && !disabled && !readonly}
75
- <button type="button" class="sv-masked__clear" aria-label="Clear" tabindex="-1" onclick={() => onChange?.('', '', false)}>
76
+ <button type="button" class="sv-masked__clear" aria-label="Clear" tabindex="-1" onclick={() => { value = ''; onChange?.('', '', false) }}>
76
77
  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
77
78
  </button>
78
79
  {/if}
@@ -20,6 +20,6 @@ type Props = SvEditorProps & {
20
20
  prefixIcon?: Snippet;
21
21
  suffixIcon?: Snippet;
22
22
  };
23
- declare const SvMaskedInput: import("svelte").Component<Props, {}, "">;
23
+ declare const SvMaskedInput: import("svelte").Component<Props, {}, "value">;
24
24
  type SvMaskedInput = ReturnType<typeof SvMaskedInput>;
25
25
  export default SvMaskedInput;
@@ -45,7 +45,7 @@
45
45
 
46
46
  let anchorEl = $state<HTMLSpanElement | null>(null)
47
47
  let panelEl = $state<HTMLDivElement | null>(null)
48
- let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
48
+ let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
49
49
 
50
50
  function setOpen(v: boolean) { if (v !== open) { open = v; onOpenChange?.(v) } }
51
51
  function toggle() { setOpen(!open) }