@siratik/wp-component-library 0.1.1 → 0.1.3

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 (96) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +43 -5
  3. package/components/attachment-image/README.md +118 -0
  4. package/components/attachment-image/index.js +105 -0
  5. package/components/attributes-panel/README.md +92 -0
  6. package/components/attributes-panel/index.js +324 -0
  7. package/components/badge/README.md +61 -0
  8. package/components/badge/index.js +105 -0
  9. package/components/block-appender/README.md +158 -0
  10. package/components/block-appender/index.js +135 -0
  11. package/components/block-extension/index.js +214 -0
  12. package/components/business-hours/SpecialHoursControl.js +211 -0
  13. package/components/business-hours/WeeklyHoursControl.js +419 -0
  14. package/components/business-hours/index.js +8 -0
  15. package/components/button/README.md +172 -0
  16. package/components/button/index.js +84 -0
  17. package/components/captcha-placeholder/CloudflareTurnstile.js +111 -0
  18. package/components/captcha-placeholder/GoogleRecaptcha.js +132 -0
  19. package/components/captcha-placeholder/HCaptcha.js +99 -0
  20. package/components/captcha-placeholder/README.md +228 -0
  21. package/components/captcha-placeholder/index.js +39 -0
  22. package/components/captcha-placeholder/styles.js +33 -0
  23. package/components/geocoder/AddressEntryControl.js +390 -0
  24. package/components/geocoder/GeocoderSearchControl.js +353 -0
  25. package/components/geocoder/countryOptions.js +66 -0
  26. package/components/geocoder/geocoder.js +400 -0
  27. package/components/geocoder/index.js +16 -0
  28. package/components/icon-picker/Icon.js +53 -0
  29. package/components/icon-picker/IconPicker.js +70 -0
  30. package/components/icon-picker/IconPickerModal.js +474 -0
  31. package/components/icon-picker/IconPickerToolbarButton.js +60 -0
  32. package/components/icon-picker/InlineIconPicker.js +92 -0
  33. package/components/icon-picker/api.js +181 -0
  34. package/components/icon-picker/config-bootstrap.js +108 -0
  35. package/components/icon-picker/hooks.js +157 -0
  36. package/components/icon-picker/index.js +39 -0
  37. package/components/icon-picker/load-script.js +33 -0
  38. package/components/icon-picker/loaders.js +79 -0
  39. package/components/icon-picker/store.js +223 -0
  40. package/components/icon-picker/styles.js +232 -0
  41. package/components/icon-picker/utils.js +138 -0
  42. package/components/layout/README.md +268 -0
  43. package/components/link-tools-panel/README.md +49 -0
  44. package/components/link-tools-panel/index.js +198 -0
  45. package/components/link-tools-panel/styles.js +90 -0
  46. package/components/media/README.md +288 -0
  47. package/components/media/SettingsMediaUpload.js +186 -0
  48. package/components/media/editor-media-upload.js +65 -0
  49. package/components/media/image-controls.js +196 -0
  50. package/components/media/index.js +13 -0
  51. package/components/media/inspector-media-upload.js +290 -0
  52. package/components/media/toolbar-media-upload.js +53 -0
  53. package/components/media/utils/aspect-ratios.js +118 -0
  54. package/components/meta/MetaPanel.js +75 -0
  55. package/components/meta/README.md +46 -0
  56. package/components/meta/index.js +6 -0
  57. package/components/meta/meta-gated-editor.js +282 -0
  58. package/components/query/README.md +340 -0
  59. package/components/query/cardTemplatePartPanel.js +69 -0
  60. package/components/query/cardTemplatePartSelect.js +165 -0
  61. package/components/query/cardTemplatePartUtils.js +119 -0
  62. package/components/query/columnCountControl.js +63 -0
  63. package/components/query/displayTypeSelect.js +52 -0
  64. package/components/query/examples.js +166 -0
  65. package/components/query/hideCurrentPostControl.js +47 -0
  66. package/components/query/index.js +20 -0
  67. package/components/query/manualPostSelector.js +125 -0
  68. package/components/query/manualTermSelector.js +141 -0
  69. package/components/query/orderBySelect.js +95 -0
  70. package/components/query/postTypeSelect.js +67 -0
  71. package/components/query/postsPerPageControl.js +51 -0
  72. package/components/query/selectionModeControl.js +61 -0
  73. package/components/query/taxonomySelect.js +139 -0
  74. package/components/query/utils.js +75 -0
  75. package/components/repeater/README.md +159 -0
  76. package/components/repeater/index.js +829 -0
  77. package/components/segmented-bar/README.md +30 -0
  78. package/components/segmented-bar/index.js +68 -0
  79. package/components/sortable-select/README.md +153 -0
  80. package/components/sortable-select/custom-sortable-token.js +127 -0
  81. package/components/sortable-select/index.js +693 -0
  82. package/components/style-picker/README.md +36 -0
  83. package/components/style-picker/index.js +92 -0
  84. package/components/variable-field/README.md +299 -0
  85. package/components/variable-field/index.js +592 -0
  86. package/components/variable-field/value-to-chip-html.js +38 -0
  87. package/components/variable-inserter/README.md +68 -0
  88. package/components/variable-inserter/index.js +168 -0
  89. package/constants/statusColors.js +1 -1
  90. package/editor.js +16 -0
  91. package/index.js +127 -0
  92. package/package.json +21 -13
  93. package/utils/kitaris-localize.js +128 -0
  94. package/utils/slug-aware-color-setter.js +27 -0
  95. package/build/index.asset.php +0 -20
  96. package/build/index.js +0 -1242
@@ -0,0 +1,829 @@
1
+ import { ADMIN_COLOR } from "../../constants/adminColors";
2
+ import {
3
+ closestCenter,
4
+ DndContext,
5
+ DragOverlay,
6
+ KeyboardSensor,
7
+ PointerSensor,
8
+ useSensor,
9
+ useSensors,
10
+ } from "@dnd-kit/core";
11
+ import {
12
+ restrictToParentElement,
13
+ restrictToVerticalAxis,
14
+ } from "@dnd-kit/modifiers";
15
+ import {
16
+ arrayMove,
17
+ SortableContext,
18
+ sortableKeyboardCoordinates,
19
+ useSortable,
20
+ verticalListSortingStrategy,
21
+ } from "@dnd-kit/sortable";
22
+ import { CSS } from "@dnd-kit/utilities";
23
+ import styled from "@emotion/styled";
24
+ import { Button } from "@wordpress/components";
25
+ import { __ } from "@wordpress/i18n";
26
+ import {
27
+ chevronDown,
28
+ chevronUp,
29
+ dragHandle,
30
+ plusCircle,
31
+ trash,
32
+ } from "@wordpress/icons";
33
+ import PropTypes from "prop-types";
34
+ import {
35
+ createContext,
36
+ useContext,
37
+ useEffect,
38
+ useRef,
39
+ useState,
40
+ } from "@wordpress/element";
41
+
42
+ // Styled components
43
+ const StyledRepeater = styled.div`
44
+ /* A core Modal's open animation keeps its end transform (fill-mode
45
+ forwards); a transformed frame throws off dnd-kit's keyboard
46
+ measuring, so arrow keys skip an item. The end keyframe is the
47
+ resting state, so dropping the fill changes nothing visually.
48
+ The held transform was also what positioned the absolute modal
49
+ header; position: relative keeps the header in the frame without
50
+ affecting the fixed-position drag overlay. */
51
+ .components-modal__screen-overlay:not(.is-animating-out)
52
+ .components-modal__frame:has(&) {
53
+ animation-fill-mode: none;
54
+ position: relative;
55
+ }
56
+
57
+ .built-repeater__items {
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 1.5rem;
61
+ margin-bottom: 3rem;
62
+ }
63
+
64
+ &.wpcl-repeater--collapsible .built-repeater__items {
65
+ gap: 0.75rem;
66
+ }
67
+
68
+ .built-repeater__empty-state {
69
+ padding: 1rem;
70
+ background: #f0f0f0;
71
+ border-radius: 4px;
72
+ margin-bottom: 1rem;
73
+ opacity: 0.75;
74
+ }
75
+
76
+ .built-repeater__drag-overlay {
77
+ opacity: 0.95;
78
+ box-shadow:
79
+ 0 15px 30px rgba(0, 0, 0, 0.15),
80
+ 0 5px 15px rgba(0, 0, 0, 0.08);
81
+ border-radius: 8px;
82
+ background: ${ADMIN_COLOR.surface};
83
+ cursor: grabbing;
84
+ transition: transform 0.2s ease;
85
+
86
+ > div {
87
+ background: ${ADMIN_COLOR.surface};
88
+ border-radius: 8px;
89
+ }
90
+ }
91
+ `;
92
+
93
+ const StyledSortableItem = styled.div`
94
+ display: flex;
95
+ align-items: flex-start;
96
+ gap: 1rem;
97
+ background-color: ${ADMIN_COLOR.surface};
98
+ border: 1px solid ${ADMIN_COLOR.border};
99
+ border-radius: 4px;
100
+ padding: 1rem;
101
+ transition: all 0.2s ease;
102
+ position: relative;
103
+
104
+ &.wpcl-repeater__item--dragging {
105
+ opacity: 0.3;
106
+ }
107
+
108
+ &.wpcl-repeater__item--integrated {
109
+ /* In integrated mode, ensure proper flex layout */
110
+ > * {
111
+ flex-grow: 1;
112
+ }
113
+
114
+ /* Drag handle, row actions, and remove button should not grow */
115
+ .built-repeater__drag-handle,
116
+ .built-repeater__remove-item,
117
+ .built-repeater__item-action {
118
+ flex-grow: 0;
119
+ flex-shrink: 0;
120
+ margin-top: 26px;
121
+ }
122
+
123
+ .built-repeater__item-action.components-button {
124
+ width: auto;
125
+ min-width: 36px;
126
+ }
127
+
128
+ /* Inline toggles beside labeled TextControl / SelectControl rows. */
129
+ .built-repeater__inline-toggle {
130
+ flex-grow: 0;
131
+ flex-shrink: 0;
132
+ align-self: flex-end;
133
+
134
+ .components-toggle-control {
135
+ margin-bottom: 0;
136
+ }
137
+
138
+ &.built-repeater__inline-toggle--category-selected
139
+ .components-base-control__help {
140
+ visibility: hidden;
141
+ }
142
+ }
143
+
144
+ /* Read-only summary rows — meta left, actions right. */
145
+ &:has(.built-repeater__summary) {
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ gap: 1rem;
149
+
150
+ > * {
151
+ flex-grow: 0;
152
+ }
153
+
154
+ .built-repeater__summary {
155
+ display: flex;
156
+ align-items: center;
157
+ flex-wrap: wrap;
158
+ gap: 0.75rem;
159
+ flex: 1 1 auto;
160
+ min-width: 0;
161
+ }
162
+
163
+ .built-repeater__summary-actions {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 0.75rem;
167
+ flex-shrink: 0;
168
+ }
169
+
170
+ .built-repeater__summary-name {
171
+ flex-shrink: 1;
172
+ min-width: 0;
173
+ font-weight: 500;
174
+ overflow: hidden;
175
+ text-overflow: ellipsis;
176
+ white-space: nowrap;
177
+ }
178
+
179
+ .built-repeater__drag-handle,
180
+ .built-repeater__remove-item,
181
+ .built-repeater__item-action {
182
+ margin-top: 0;
183
+ }
184
+ }
185
+ }
186
+
187
+ .built-repeater__item-handle {
188
+ cursor: grab;
189
+ flex-shrink: 0;
190
+ transition:
191
+ transform 0.15s ease,
192
+ opacity 0.15s ease;
193
+
194
+ &:active {
195
+ cursor: grabbing;
196
+ opacity: 0.7;
197
+ }
198
+
199
+ button {
200
+ transition:
201
+ background-color 0.15s ease,
202
+ color 0.15s ease;
203
+
204
+ &:hover {
205
+ background-color: ${ADMIN_COLOR.brandFill};
206
+ color: ${ADMIN_COLOR.onBrand};
207
+ }
208
+ }
209
+ }
210
+
211
+ .built-repeater__item-content {
212
+ flex-grow: 1;
213
+ width: 100%;
214
+ box-sizing: border-box;
215
+ }
216
+
217
+ .built-repeater__item-actions {
218
+ flex-shrink: 0;
219
+ }
220
+
221
+ &.wpcl-repeater__item--collapsible {
222
+ flex-direction: column;
223
+ align-items: stretch;
224
+ gap: 0;
225
+ padding: 0;
226
+
227
+ .built-repeater__item-header {
228
+ display: flex;
229
+ align-items: center;
230
+ gap: 0.75rem;
231
+ padding: 0.75rem 1rem;
232
+ }
233
+
234
+ &.wpcl-repeater__item--expanded .built-repeater__item-header {
235
+ border-bottom: 1px solid ${ADMIN_COLOR.border};
236
+ }
237
+
238
+ .built-repeater__summary {
239
+ display: flex;
240
+ align-items: center;
241
+ flex-wrap: wrap;
242
+ gap: 0.75rem;
243
+ flex: 1 1 auto;
244
+ min-width: 0;
245
+ }
246
+
247
+ .built-repeater__summary-name {
248
+ flex-shrink: 1;
249
+ min-width: 0;
250
+ font-weight: 500;
251
+ overflow: hidden;
252
+ text-overflow: ellipsis;
253
+ white-space: nowrap;
254
+ }
255
+
256
+ .built-repeater__item-header .components-toggle-control {
257
+ margin-bottom: 0;
258
+ }
259
+
260
+ .built-repeater__item-toggle,
261
+ .built-repeater__item-handle,
262
+ .built-repeater__item-actions,
263
+ .built-repeater__drag-handle,
264
+ .built-repeater__remove-item {
265
+ flex-shrink: 0;
266
+ margin-top: 0;
267
+ }
268
+
269
+ .built-repeater__item-content {
270
+ padding: 1rem;
271
+ }
272
+ }
273
+ `;
274
+
275
+ const StyledDragHandle = styled.div`
276
+ margin-top: 26px;
277
+ flex-shrink: 0;
278
+ `;
279
+
280
+ /**
281
+ * restrictToParentElement, with the dragged rect's edges rebuilt from its size.
282
+ *
283
+ * When the drag overlay resizes after mounting (e.g. responsive columns
284
+ * settling), dnd-kit updates only its width/height and keeps the first
285
+ * bottom/right, so the item looks taller than it is and can't reach the
286
+ * last slot.
287
+ */
288
+ const restrictToParent = (args) => {
289
+ const rect = args.draggingNodeRect;
290
+
291
+ return restrictToParentElement({
292
+ ...args,
293
+ draggingNodeRect: rect
294
+ ? {
295
+ ...rect,
296
+ bottom: rect.top + rect.height,
297
+ right: rect.left + rect.width,
298
+ }
299
+ : rect,
300
+ });
301
+ };
302
+
303
+ const itemChromeButtonProps = {
304
+ variant: "secondary",
305
+ iconSize: 20,
306
+ };
307
+
308
+ /**
309
+ * Context for repeater item data
310
+ */
311
+ const RepeaterItemContext = createContext();
312
+
313
+ /**
314
+ * Drag handle component that can be used within renderItem
315
+ */
316
+ export const DragHandle = ({
317
+ label = __("Drag to reorder", "wp-component-library"),
318
+ ...props
319
+ }) => {
320
+ const context = useContext(RepeaterItemContext);
321
+
322
+ if (!context) {
323
+ // eslint-disable-next-line no-console -- developer misuse warning.
324
+ console.warn("DragHandle must be used within a Repeater item");
325
+ return null;
326
+ }
327
+
328
+ const { attributes, listeners, isDragging } = context;
329
+
330
+ // Escape cancels the drag (dnd-kit listens on the document); mark it
331
+ // handled so a surrounding Modal doesn't close as well.
332
+ const onKeyDown = (event) => {
333
+ if (isDragging && event.key === "Escape") {
334
+ event.preventDefault();
335
+ }
336
+ listeners?.onKeyDown?.(event);
337
+ props.onKeyDown?.(event);
338
+ };
339
+
340
+ return (
341
+ <StyledDragHandle className="built-repeater__drag-handle">
342
+ {/* The button is the drag control, so it isn't nested in another one. */}
343
+ <Button
344
+ icon={dragHandle}
345
+ label={label}
346
+ {...attributes}
347
+ {...listeners}
348
+ {...itemChromeButtonProps}
349
+ {...props}
350
+ onKeyDown={onKeyDown}
351
+ />
352
+ </StyledDragHandle>
353
+ );
354
+ };
355
+
356
+ /**
357
+ * Remove button component that can be used within renderItem
358
+ */
359
+ export const RemoveButton = ({ label = __("Remove", "wp-component-library"), ...props }) => {
360
+ const context = useContext(RepeaterItemContext);
361
+
362
+ if (!context) {
363
+ // eslint-disable-next-line no-console -- developer misuse warning.
364
+ console.warn("RemoveButton must be used within a Repeater item");
365
+ return null;
366
+ }
367
+
368
+ const { onRemove, id, canRemove } = context;
369
+
370
+ if (!canRemove) {
371
+ return null;
372
+ }
373
+
374
+ return (
375
+ <Button
376
+ className="built-repeater__remove-item"
377
+ label={label}
378
+ icon={trash}
379
+ isDestructive={true}
380
+ onClick={() => onRemove(id)}
381
+ {...itemChromeButtonProps}
382
+ {...props}
383
+ />
384
+ );
385
+ };
386
+
387
+ const ExpandButton = ({ isExpanded, onToggle }) => (
388
+ <Button
389
+ className="built-repeater__item-toggle"
390
+ icon={isExpanded ? chevronUp : chevronDown}
391
+ label={
392
+ isExpanded
393
+ ? __("Collapse", "wp-component-library")
394
+ : __("Expand", "wp-component-library")
395
+ }
396
+ onClick={(event) => {
397
+ event.preventDefault();
398
+ event.stopPropagation();
399
+ onToggle?.();
400
+ }}
401
+ aria-expanded={isExpanded}
402
+ {...itemChromeButtonProps}
403
+ />
404
+ );
405
+
406
+ /**
407
+ * Sortable item component for the repeater
408
+ */
409
+ const SortableItem = ({
410
+ id,
411
+ children,
412
+ onRemove,
413
+ canRemove,
414
+ isDragOverlay = false,
415
+ renderMode = "default",
416
+ enableReorder = true,
417
+ collapsible = false,
418
+ isExpanded = false,
419
+ onToggle,
420
+ summary = null,
421
+ }) => {
422
+ const {
423
+ attributes,
424
+ listeners,
425
+ setNodeRef,
426
+ transform,
427
+ transition,
428
+ isDragging,
429
+ } = useSortable({ id, disabled: !enableReorder });
430
+
431
+ const style = {
432
+ transform: CSS.Transform.toString(transform),
433
+ transition,
434
+ };
435
+
436
+ const dragProps = enableReorder ? { ...attributes, ...listeners } : {};
437
+
438
+ const contextValue = {
439
+ attributes: enableReorder ? attributes : {},
440
+ listeners: enableReorder ? listeners : {},
441
+ onRemove,
442
+ id,
443
+ canRemove,
444
+ isDragging,
445
+ };
446
+
447
+ const itemClassName = [
448
+ renderMode === "integrated" && !collapsible
449
+ ? "wpcl-repeater__item--integrated"
450
+ : "",
451
+ collapsible ? "wpcl-repeater__item--collapsible" : "",
452
+ collapsible && isExpanded ? "wpcl-repeater__item--expanded" : "",
453
+ isDragging ? "wpcl-repeater__item--dragging" : "",
454
+ ]
455
+ .filter(Boolean)
456
+ .join(" ");
457
+
458
+ if (collapsible) {
459
+ return (
460
+ <RepeaterItemContext.Provider value={contextValue}>
461
+ <StyledSortableItem
462
+ ref={setNodeRef}
463
+ style={style}
464
+ className={itemClassName}
465
+ >
466
+ <div className="built-repeater__item-header">
467
+ {enableReorder && <DragHandle />}
468
+ {summary ? (
469
+ <div className="built-repeater__summary">
470
+ {summary}
471
+ </div>
472
+ ) : (
473
+ <div className="built-repeater__summary" />
474
+ )}
475
+ {!isDragOverlay && (
476
+ <ExpandButton
477
+ isExpanded={isExpanded}
478
+ onToggle={onToggle}
479
+ />
480
+ )}
481
+ {!isDragOverlay && <RemoveButton />}
482
+ </div>
483
+ {isExpanded && children ? (
484
+ <div className="built-repeater__item-content">
485
+ {children}
486
+ </div>
487
+ ) : null}
488
+ </StyledSortableItem>
489
+ </RepeaterItemContext.Provider>
490
+ );
491
+ }
492
+
493
+ if (renderMode === "integrated") {
494
+ return (
495
+ <RepeaterItemContext.Provider value={contextValue}>
496
+ <StyledSortableItem
497
+ ref={setNodeRef}
498
+ style={style}
499
+ className={`wpcl-repeater__item--integrated ${isDragging ? "wpcl-repeater__item--dragging" : ""}`}
500
+ >
501
+ {children}
502
+ </StyledSortableItem>
503
+ </RepeaterItemContext.Provider>
504
+ );
505
+ }
506
+
507
+ // Default mode with built-in controls
508
+ return (
509
+ <StyledSortableItem
510
+ ref={setNodeRef}
511
+ style={style}
512
+ className={isDragging ? "wpcl-repeater__item--dragging" : ""}
513
+ >
514
+ {enableReorder && (
515
+ <div className="built-repeater__item-handle" {...dragProps}>
516
+ <Button
517
+ icon={dragHandle}
518
+ label={__("Drag to reorder", "wp-component-library")}
519
+ tabIndex={-1}
520
+ {...itemChromeButtonProps}
521
+ />
522
+ </div>
523
+ )}
524
+ <div className="built-repeater__item-content">{children}</div>
525
+ {!isDragOverlay && canRemove && (
526
+ <div className="built-repeater__item-actions">
527
+ <Button
528
+ label={__("Remove", "wp-component-library")}
529
+ icon={trash}
530
+ isDestructive={true}
531
+ onClick={() => onRemove(id)}
532
+ {...itemChromeButtonProps}
533
+ />
534
+ </div>
535
+ )}
536
+ </StyledSortableItem>
537
+ );
538
+ };
539
+
540
+ /**
541
+ * A flexible repeater component with drag-and-drop functionality
542
+ *
543
+ * @param {Object} props
544
+ * @param {Array} props.items - Array of items to be rendered
545
+ * @param {Function} props.renderItem - Function to render each item's content
546
+ * @param {Function} props.onAdd - Callback when add button is clicked
547
+ * @param {Function} props.onRemove - Callback when remove button is clicked
548
+ * @param {Function} props.onReorder - Callback when items are reordered
549
+ * @param {string} props.className - Additional CSS classes
550
+ * @param {string} props.addButtonText - Text for the add button
551
+ * @param {string} props.emptyStateText - Text to show when list is empty
552
+ * @param {number} props.maxItems - Maximum number of items allowed
553
+ * @param {number} props.minItems - Minimum number of items required
554
+ * @param {string} props.renderMode - Control layout mode: "default" or "integrated"
555
+ * @param {boolean} props.enableReorder - Whether items can be dragged to reorder (default true).
556
+ * When false, no drag handle renders and items keep
557
+ * whatever order `items` is in — for lists where order
558
+ * doesn't matter (e.g. a set of find/replace pairs).
559
+ * @param {boolean} props.collapsible - When true, each item shows a header (optional
560
+ * `renderSummary` + chevron) and `renderItem` only while expanded.
561
+ * @param {Function} props.renderSummary - Header content always visible when `collapsible` is true.
562
+ * @param {boolean} props.defaultExpanded - When `collapsible`, existing items start open (default false).
563
+ * @param {boolean} props.expandNewItems - When `collapsible`, a newly added item starts open (default true).
564
+ */
565
+ const Repeater = ({
566
+ items = [],
567
+ renderItem,
568
+ onAdd,
569
+ onRemove,
570
+ onReorder,
571
+ className = "",
572
+ addButtonText = __("Add Item", "wp-component-library"),
573
+ emptyStateText = __(
574
+ "No items added yet. Click the button below to add one.",
575
+ "wp-component-library",
576
+ ),
577
+ maxItems = null,
578
+ minItems = 0,
579
+ renderMode = "default",
580
+ enableReorder = true,
581
+ collapsible = false,
582
+ renderSummary = null,
583
+ defaultExpanded = false,
584
+ expandNewItems = true,
585
+ }) => {
586
+ const [activeId, setActiveId] = useState(null);
587
+ const [expandedIds, setExpandedIds] = useState(() => {
588
+ if (!collapsible || !defaultExpanded) {
589
+ return new Set();
590
+ }
591
+ return new Set(items.map((item) => item.id));
592
+ });
593
+ const prevIdsRef = useRef(null);
594
+ const itemIdsKey = items.map((item) => item.id).join("\0");
595
+
596
+ useEffect(() => {
597
+ const currentIds = itemIdsKey ? itemIdsKey.split("\0") : [];
598
+
599
+ if (!collapsible) {
600
+ prevIdsRef.current = currentIds;
601
+ return;
602
+ }
603
+
604
+ if (prevIdsRef.current === null) {
605
+ prevIdsRef.current = currentIds;
606
+ return;
607
+ }
608
+
609
+ const prev = new Set(prevIdsRef.current);
610
+ const added = currentIds.filter((id) => !prev.has(id));
611
+ const currentIdSet = new Set(currentIds);
612
+
613
+ setExpandedIds((current) => {
614
+ const next = new Set();
615
+ current.forEach((id) => {
616
+ if (currentIdSet.has(id)) {
617
+ next.add(id);
618
+ }
619
+ });
620
+ if (expandNewItems && added.length === 1) {
621
+ next.add(added[0]);
622
+ }
623
+ if (
624
+ next.size === current.size &&
625
+ [...next].every((id) => current.has(id))
626
+ ) {
627
+ return current;
628
+ }
629
+ return next;
630
+ });
631
+
632
+ prevIdsRef.current = currentIds;
633
+ }, [collapsible, expandNewItems, itemIdsKey]);
634
+
635
+ const toggleExpanded = (id) => {
636
+ setExpandedIds((current) => {
637
+ const next = new Set(current);
638
+ if (next.has(id)) {
639
+ next.delete(id);
640
+ } else {
641
+ next.add(id);
642
+ }
643
+ return next;
644
+ });
645
+ };
646
+
647
+ const sensors = useSensors(
648
+ useSensor(PointerSensor, {
649
+ activationConstraint: {
650
+ distance: 8,
651
+ },
652
+ }),
653
+ useSensor(KeyboardSensor, {
654
+ coordinateGetter: sortableKeyboardCoordinates,
655
+ }),
656
+ );
657
+
658
+ const handleDragStart = (event) => {
659
+ if (!enableReorder) {
660
+ return;
661
+ }
662
+ setActiveId(event.active.id);
663
+ };
664
+
665
+ const handleDragEnd = (event) => {
666
+ if (!enableReorder) {
667
+ return;
668
+ }
669
+ const { active, over } = event;
670
+
671
+ if (active.id !== over.id) {
672
+ const oldIndex = items.findIndex((item) => item.id === active.id);
673
+ const newIndex = items.findIndex((item) => item.id === over.id);
674
+
675
+ const newItems = arrayMove(items, oldIndex, newIndex);
676
+ onReorder(newItems);
677
+ }
678
+
679
+ setActiveId(null);
680
+ };
681
+
682
+ const handleRemove = (id) => {
683
+ if (items.length > minItems) {
684
+ onRemove(id);
685
+ }
686
+ };
687
+
688
+ const canAddItem = maxItems === null || items.length < maxItems;
689
+ const canRemoveItem = items.length > minItems;
690
+
691
+ const activeItem = activeId
692
+ ? items.find((item) => item.id === activeId)
693
+ : null;
694
+ const activeIndex = activeItem
695
+ ? items.findIndex((item) => item.id === activeId)
696
+ : -1;
697
+
698
+ const repeaterClassName = [
699
+ className,
700
+ collapsible ? "wpcl-repeater--collapsible" : "",
701
+ ]
702
+ .filter(Boolean)
703
+ .join(" ");
704
+
705
+ return (
706
+ <StyledRepeater className={repeaterClassName}>
707
+ <DndContext
708
+ sensors={sensors}
709
+ collisionDetection={closestCenter}
710
+ onDragStart={handleDragStart}
711
+ onDragEnd={handleDragEnd}
712
+ modifiers={[restrictToVerticalAxis, restrictToParent]}
713
+ >
714
+ {items.length === 0 ? (
715
+ <div className="built-repeater__empty-state">
716
+ {emptyStateText}
717
+ </div>
718
+ ) : (
719
+ <SortableContext
720
+ items={items.map((item) => item.id)}
721
+ strategy={verticalListSortingStrategy}
722
+ >
723
+ <div className="built-repeater__items">
724
+ {items.map((item, index) => {
725
+ const isExpanded =
726
+ !collapsible || expandedIds.has(item.id);
727
+
728
+ return (
729
+ <SortableItem
730
+ key={item.id}
731
+ id={item.id}
732
+ onRemove={handleRemove}
733
+ canRemove={canRemoveItem}
734
+ renderMode={renderMode}
735
+ enableReorder={enableReorder}
736
+ collapsible={collapsible}
737
+ isExpanded={isExpanded}
738
+ onToggle={() => toggleExpanded(item.id)}
739
+ summary={
740
+ collapsible && renderSummary
741
+ ? renderSummary(item, index)
742
+ : null
743
+ }
744
+ >
745
+ {isExpanded
746
+ ? renderItem(item, index)
747
+ : null}
748
+ </SortableItem>
749
+ );
750
+ })}
751
+ </div>
752
+ </SortableContext>
753
+ )}
754
+ <DragOverlay>
755
+ {activeId && activeItem ? (
756
+ <div className="built-repeater__drag-overlay">
757
+ <SortableItem
758
+ id={activeId}
759
+ isDragOverlay={true}
760
+ renderMode={renderMode}
761
+ canRemove={canRemoveItem}
762
+ onRemove={handleRemove}
763
+ enableReorder={enableReorder}
764
+ collapsible={collapsible}
765
+ isExpanded={false}
766
+ summary={
767
+ collapsible && renderSummary
768
+ ? renderSummary(activeItem, activeIndex)
769
+ : null
770
+ }
771
+ >
772
+ {collapsible
773
+ ? null
774
+ : renderItem(activeItem, activeIndex)}
775
+ </SortableItem>
776
+ </div>
777
+ ) : null}
778
+ </DragOverlay>
779
+ </DndContext>
780
+ {canAddItem && (
781
+ <div className="built-repeater__add-button">
782
+ <Button
783
+ variant="secondary"
784
+ icon={plusCircle}
785
+ iconPosition="left"
786
+ onClick={onAdd}
787
+ >
788
+ {addButtonText}
789
+ </Button>
790
+ </div>
791
+ )}
792
+ </StyledRepeater>
793
+ );
794
+ };
795
+
796
+ Repeater.propTypes = {
797
+ items: PropTypes.arrayOf(
798
+ PropTypes.shape({
799
+ id: PropTypes.string.isRequired,
800
+ }),
801
+ ).isRequired,
802
+ renderItem: PropTypes.func.isRequired,
803
+ onAdd: PropTypes.func.isRequired,
804
+ onRemove: PropTypes.func.isRequired,
805
+ onReorder: (props, propName, componentName) => {
806
+ if (props.enableReorder === false) {
807
+ return null;
808
+ }
809
+ if (typeof props[propName] !== "function") {
810
+ return new Error(
811
+ `\`${propName}\` is required in \`${componentName}\` unless \`enableReorder\` is false.`,
812
+ );
813
+ }
814
+ return null;
815
+ },
816
+ className: PropTypes.string,
817
+ addButtonText: PropTypes.string,
818
+ emptyStateText: PropTypes.string,
819
+ maxItems: PropTypes.number,
820
+ minItems: PropTypes.number,
821
+ renderMode: PropTypes.oneOf(["default", "integrated"]),
822
+ enableReorder: PropTypes.bool,
823
+ collapsible: PropTypes.bool,
824
+ renderSummary: PropTypes.func,
825
+ defaultExpanded: PropTypes.bool,
826
+ expandNewItems: PropTypes.bool,
827
+ };
828
+
829
+ export { Repeater };