| 1 |
import React, { useLayoutEffect, useRef, useState } from "react"; |
| 2 |
import Select from "react-select"; |
| 3 |
import AsyncSelect from "react-select/async"; |
| 4 |
import { __ } from "@wordpress/i18n"; |
| 5 |
|
| 6 |
// Multi-select where the control keeps showing the placeholder and the chosen |
| 7 |
// items render as removable chips *below* the field (design-system style). |
| 8 |
// Pass `options` for a static list, or `loadOptions` for async search. |
| 9 |
const GroupMultiSelect = ({ |
| 10 |
options, |
| 11 |
loadOptions, |
| 12 |
value, |
| 13 |
onChange, |
| 14 |
isDisabled = false, |
| 15 |
// `loading` + a non-zero `loadingCount` (the saved-selection size) mean the |
| 16 |
// group already has assignments still being resolved — show a single |
| 17 |
// placeholder chip so the row is reserved while they load. The count only |
| 18 |
// gates the skeleton; it no longer maps to the number of placeholder chips. |
| 19 |
loading = false, |
| 20 |
loadingCount = 0, |
| 21 |
placeholder = __("Select FAQ groups…", "betterdocs"), |
| 22 |
noOptionsMessage = () => __("No FAQ groups found", "betterdocs"), |
| 23 |
}) => { |
| 24 |
const removeOne = (val) => |
| 25 |
onChange((value || []).filter((opt) => opt.value !== val)); |
| 26 |
|
| 27 |
// Animate the chip area's height so the row eases between states — empty → |
| 28 |
// skeleton → real chips — and between any number of selected chips (which |
| 29 |
// may wrap to a different number of rows than the skeleton reserved), |
| 30 |
// instead of snapping/jumping. We measure the natural content height of the |
| 31 |
// inner (unconstrained) wrapper and transition the outer wrapper's height to |
| 32 |
// it; the outer is `overflow: hidden`, so clipping it never feeds back into |
| 33 |
// the measured height (no ResizeObserver loop). |
| 34 |
const measureRef = useRef(null); |
| 35 |
const [areaHeight, setAreaHeight] = useState(null); |
| 36 |
|
| 37 |
useLayoutEffect(() => { |
| 38 |
const el = measureRef.current; |
| 39 |
if (!el || typeof ResizeObserver === "undefined") return; |
| 40 |
// Seed the height before the first paint so the modal opens at the right |
| 41 |
// size (no initial expand animation), then keep it in sync as chips, |
| 42 |
// skeletons, or wrapping change. |
| 43 |
const sync = () => setAreaHeight(el.scrollHeight); |
| 44 |
sync(); |
| 45 |
const ro = new ResizeObserver(sync); |
| 46 |
ro.observe(el); |
| 47 |
return () => ro.disconnect(); |
| 48 |
}, []); |
| 49 |
|
| 50 |
const sharedProps = { |
| 51 |
isMulti: true, |
| 52 |
controlShouldRenderValue: false, |
| 53 |
hideSelectedOptions: false, |
| 54 |
closeMenuOnSelect: false, |
| 55 |
isClearable: false, |
| 56 |
className: "bd-faq-select bd-woo-assign-select", |
| 57 |
classNamePrefix: "bd-faq-select", |
| 58 |
value, |
| 59 |
isDisabled, |
| 60 |
onChange: (selected) => onChange(selected || []), |
| 61 |
menuPortalTarget: |
| 62 |
typeof document !== "undefined" ? document.body : null, |
| 63 |
menuPlacement: "auto", |
| 64 |
styles: { menuPortal: (base) => ({ ...base, zIndex: 9999 }) }, |
| 65 |
placeholder, |
| 66 |
noOptionsMessage, |
| 67 |
}; |
| 68 |
|
| 69 |
return ( |
| 70 |
<div className="bd-faq-multiselect"> |
| 71 |
{loadOptions ? ( |
| 72 |
<AsyncSelect |
| 73 |
{...sharedProps} |
| 74 |
cacheOptions |
| 75 |
defaultOptions |
| 76 |
loadOptions={loadOptions} |
| 77 |
/> |
| 78 |
) : ( |
| 79 |
<Select {...sharedProps} options={options} /> |
| 80 |
)} |
| 81 |
<div |
| 82 |
className="bd-faq-chips-anim" |
| 83 |
style={areaHeight == null ? undefined : { height: areaHeight }} |
| 84 |
> |
| 85 |
<div className="bd-faq-chips-measure" ref={measureRef}> |
| 86 |
{loading && loadingCount > 0 ? ( |
| 87 |
// A single placeholder chip — one row only. The exact |
| 88 |
// saved count is irrelevant here; the height animation |
| 89 |
// eases up to however many rows the real chips need. |
| 90 |
<div className="bd-faq-selected-chips" aria-hidden="true"> |
| 91 |
<span className="bd-faq-chip bd-faq-chip--skeleton" /> |
| 92 |
</div> |
| 93 |
) : value && value.length > 0 ? ( |
| 94 |
<div className="bd-faq-selected-chips"> |
| 95 |
{value.map((opt, i) => ( |
| 96 |
<span |
| 97 |
className={`bd-faq-chip bd-faq-chip--${(i % 6) + 1}`} |
| 98 |
key={opt.value} |
| 99 |
> |
| 100 |
<span className="bd-faq-chip__label"> |
| 101 |
{opt.label} |
| 102 |
</span> |
| 103 |
<button |
| 104 |
type="button" |
| 105 |
className="bd-faq-chip__remove" |
| 106 |
aria-label={__("Remove", "betterdocs")} |
| 107 |
disabled={isDisabled} |
| 108 |
onClick={() => removeOne(opt.value)} |
| 109 |
> |
| 110 |
× |
| 111 |
</button> |
| 112 |
</span> |
| 113 |
))} |
| 114 |
</div> |
| 115 |
) : null} |
| 116 |
</div> |
| 117 |
</div> |
| 118 |
</div> |
| 119 |
); |
| 120 |
}; |
| 121 |
|
| 122 |
export default GroupMultiSelect; |
| 123 |
|