@siratik/wp-component-library 0.1.1 → 0.1.2

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 +187 -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,400 @@
1
+ /**
2
+ * Shared geocoding helper for admin and editor address search.
3
+ *
4
+ * Uses Nominatim (OpenStreetMap) — free, no API key required.
5
+ *
6
+ * IMPORTANT — Nominatim usage policy:
7
+ * https://operations.osmfoundation.org/policies/nominatim/
8
+ *
9
+ * - Hard limit: 1 request/second. Requests from a page are spaced at
10
+ * least 2 seconds apart (MIN_REQUEST_INTERVAL_MS) as a safety margin.
11
+ * - **No autocomplete-style as-you-type queries.** Callers MUST be
12
+ * explicit-submit (Enter / button click) and NOT fire on every
13
+ * keystroke or debounced keystroke burst. Use
14
+ * `<GeocoderSearchControl>` which enforces this pattern.
15
+ * - Provide visible attribution (see `GEOCODER_ATTRIBUTION_HTML`).
16
+ *
17
+ * Returns normalized `GeocodeResult` objects so consumers don't have
18
+ * to care about the raw provider response shape — this keeps the door
19
+ * open to swapping in LocationIQ/MapTiler/Pelias later (those services
20
+ * DO support autocomplete) without touching the React components.
21
+ *
22
+ * @package Siratik/WP-Component-Library
23
+ */
24
+
25
+ const NOMINATIM_BASE = "https://nominatim.openstreetmap.org/search";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Request spacing
29
+ //
30
+ // Nominatim allows at most one request a second. Network requests from this
31
+ // page are spaced at least two seconds apart, a safety margin: a search that
32
+ // comes sooner waits its turn. Cache hits don't wait. A search cancelled
33
+ // while it waits sends nothing.
34
+ // ---------------------------------------------------------------------------
35
+ export const MIN_REQUEST_INTERVAL_MS = 2000;
36
+
37
+ let nextRequestAt = 0;
38
+
39
+ /**
40
+ * Reserve the next request slot and wait for it.
41
+ *
42
+ * @param {AbortSignal} [signal]
43
+ * @returns {Promise<void>} Rejects with an AbortError if cancelled first.
44
+ */
45
+ function waitForRequestSlot(signal) {
46
+ const now = Date.now();
47
+ const startAt = Math.max(now, nextRequestAt);
48
+ nextRequestAt = startAt + MIN_REQUEST_INTERVAL_MS;
49
+
50
+ const delay = startAt - now;
51
+ if (delay <= 0) {
52
+ return Promise.resolve();
53
+ }
54
+
55
+ return new Promise((resolve, reject) => {
56
+ const timer = setTimeout(resolve, delay);
57
+ signal?.addEventListener(
58
+ "abort",
59
+ () => {
60
+ clearTimeout(timer);
61
+ reject(new DOMException("Aborted", "AbortError"));
62
+ },
63
+ { once: true },
64
+ );
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Test helper: forget the last request time.
70
+ */
71
+ export function resetGeocoderSpacing() {
72
+ nextRequestAt = 0;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Per-session geocode result cache
77
+ //
78
+ // Nominatim's own usage policy encourages result caching. sessionStorage is
79
+ // ideal: results are scoped to the browser tab (not persisted cross-session),
80
+ // which keeps data fresh while eliminating redundant round-trips when the
81
+ // user re-searches the same address within the same admin session.
82
+ //
83
+ // TTL: 10 minutes — protects against stale results after map updates while
84
+ // still providing meaningful cache hits during normal admin workflows.
85
+ // ---------------------------------------------------------------------------
86
+ const GEOCODE_CACHE_TTL_MS = 10 * 60 * 1000;
87
+
88
+ /**
89
+ * Build a stable sessionStorage key from the geocode parameters.
90
+ *
91
+ * @param {string} query
92
+ * @param {number} limit
93
+ * @param {string[]} countryCodes
94
+ * @returns {string}
95
+ */
96
+ function buildCacheKey(query, limit, countryCodes) {
97
+ const payload = JSON.stringify({
98
+ q: query.toLowerCase().trim(),
99
+ l: limit,
100
+ cc: Array.isArray(countryCodes) ? [...countryCodes].sort() : [],
101
+ });
102
+ try {
103
+ return "geocode_" + btoa(encodeURIComponent(payload)).slice(0, 80);
104
+ } catch {
105
+ // btoa may throw on unusual unicode — fall back to raw (still unique).
106
+ return "geocode_" + encodeURIComponent(payload).slice(0, 80);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * @param {string} key
112
+ * @returns {GeocodeResult[]|null}
113
+ */
114
+ function readGeocodeCache(key) {
115
+ try {
116
+ const raw = sessionStorage.getItem(key);
117
+ if (!raw) {
118
+ return null;
119
+ }
120
+ const { ts, data } = JSON.parse(raw);
121
+ if (Date.now() - ts > GEOCODE_CACHE_TTL_MS) {
122
+ sessionStorage.removeItem(key);
123
+ return null;
124
+ }
125
+ return data;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * @param {string} key
133
+ * @param {GeocodeResult[]} data
134
+ */
135
+ function writeGeocodeCache(key, data) {
136
+ try {
137
+ sessionStorage.setItem(key, JSON.stringify({ ts: Date.now(), data }));
138
+ } catch {
139
+ // sessionStorage may be full or unavailable (private-browsing mode,
140
+ // storage quota exceeded, etc.) — silently skip caching.
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Countries we currently serve. Constrains Nominatim to these so US
146
+ * street searches don't compete with global noise (e.g. typing "600"
147
+ * returning Iceland / Lesotho / Taiwan before any US address).
148
+ *
149
+ * Override per-call by passing `countryCodes` to `geocodeAddress`,
150
+ * or pass `countryCodes={[]}` (or any custom list) to
151
+ * `<GeocoderSearchControl>` to search globally / a different set.
152
+ */
153
+ export const DEFAULT_GEOCODER_COUNTRY_CODES = Object.freeze([
154
+ "us", // United States
155
+ "ca", // Canada
156
+ "au", // Australia
157
+ "gb", // United Kingdom
158
+ "ie", // Ireland
159
+ ]);
160
+
161
+ /**
162
+ * @typedef {Object} GeocodeAddress
163
+ * @property {string} street House number + road (or just road).
164
+ * @property {string} houseNumber
165
+ * @property {string} road
166
+ * @property {string} city city || town || village || hamlet || suburb
167
+ * @property {string} state
168
+ * @property {string} postcode
169
+ * @property {string} country
170
+ * @property {string} countryCode ISO-3166-1 alpha-2, uppercase.
171
+ */
172
+
173
+ /**
174
+ * @typedef {Object} GeocodeResult
175
+ * @property {string} id Stable key for React lists.
176
+ * @property {string} displayName Short, comma-separated label.
177
+ * @property {number} lat
178
+ * @property {number} lng
179
+ * @property {GeocodeAddress} address Structured address fields.
180
+ * @property {string} type OSM type/category (e.g. "house", "city").
181
+ * @property {number} importance Nominatim importance score (0..1).
182
+ */
183
+
184
+ /**
185
+ * Run a forward geocode against Nominatim.
186
+ *
187
+ * @param {Object} options
188
+ * @param {string} options.query
189
+ * @param {number} [options.limit=8]
190
+ * @param {string} [options.acceptLanguage="en"]
191
+ * @param {string[]} [options.countryCodes] ISO 3166-1 alpha-2 list to constrain results to.
192
+ * Defaults to DEFAULT_GEOCODER_COUNTRY_CODES.
193
+ * Pass an empty array to search globally.
194
+ * @param {AbortSignal} [options.signal]
195
+ * @returns {Promise<GeocodeResult[]>}
196
+ */
197
+ export async function geocodeAddress({
198
+ query,
199
+ limit = 8,
200
+ acceptLanguage = "en",
201
+ countryCodes = DEFAULT_GEOCODER_COUNTRY_CODES,
202
+ signal,
203
+ } = {}) {
204
+ const trimmed = (query || "").trim();
205
+ if (trimmed.length < 3) {
206
+ return [];
207
+ }
208
+
209
+ // Check session cache before hitting the network.
210
+ const cacheKey = buildCacheKey(trimmed, limit, countryCodes);
211
+ const cached = readGeocodeCache(cacheKey);
212
+ if (cached) {
213
+ return cached;
214
+ }
215
+
216
+ const params = new URLSearchParams({
217
+ q: trimmed,
218
+ format: "jsonv2",
219
+ addressdetails: "1",
220
+ dedupe: "1",
221
+ limit: String(limit),
222
+ "accept-language": acceptLanguage,
223
+ });
224
+
225
+ if (Array.isArray(countryCodes) && countryCodes.length > 0) {
226
+ params.set(
227
+ "countrycodes",
228
+ countryCodes
229
+ .filter((c) => typeof c === "string" && c.length === 2)
230
+ .map((c) => c.toLowerCase())
231
+ .join(","),
232
+ );
233
+ }
234
+
235
+ try {
236
+ await waitForRequestSlot(signal);
237
+ if (signal?.aborted) {
238
+ return [];
239
+ }
240
+
241
+ const response = await fetch(`${NOMINATIM_BASE}?${params.toString()}`, {
242
+ headers: { Accept: "application/json" },
243
+ signal,
244
+ });
245
+ if (!response.ok) {
246
+ return [];
247
+ }
248
+ const data = await response.json();
249
+ if (!Array.isArray(data)) {
250
+ return [];
251
+ }
252
+ const results = data.map(toGeocodeResult).filter(Boolean);
253
+ writeGeocodeCache(cacheKey, results);
254
+ return results;
255
+ } catch (err) {
256
+ // Aborted requests are expected (explicit-submit UX can still abort
257
+ // an in-flight request if the user submits a new search quickly).
258
+ if (err?.name === "AbortError") {
259
+ return [];
260
+ }
261
+ // eslint-disable-next-line no-console -- surfaces failed lookups, which otherwise return no results silently.
262
+ console.error("Nominatim geocoding error:", err);
263
+ return [];
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Normalize a raw Nominatim feature into a GeocodeResult.
269
+ *
270
+ * @param {Object} item
271
+ * @returns {GeocodeResult|null}
272
+ */
273
+ function toGeocodeResult(item) {
274
+ const lat = Number(item.lat);
275
+ const lng = Number(item.lon);
276
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
277
+ return null;
278
+ }
279
+
280
+ const a = item.address || {};
281
+ const houseNumber = a.house_number || "";
282
+ const road = a.road || a.pedestrian || a.footway || a.cycleway || "";
283
+ const street = houseNumber && road ? `${houseNumber} ${road}` : road;
284
+
285
+ return {
286
+ id: `${item.osm_type ?? "x"}-${item.osm_id ?? `${lng},${lat}`}-${item.place_id ?? ""}`,
287
+ displayName: formatDisplayName(item, { street, road }),
288
+ lat,
289
+ lng,
290
+ address: {
291
+ street,
292
+ houseNumber,
293
+ road,
294
+ city: a.city || a.town || a.village || a.hamlet || a.suburb || "",
295
+ state: a.state || a.region || a["ISO3166-2-lvl4"] || "",
296
+ postcode: a.postcode || "",
297
+ country: a.country || "",
298
+ countryCode: (a.country_code || "").toUpperCase(),
299
+ },
300
+ type: item.type || item.category || "",
301
+ importance: typeof item.importance === "number" ? item.importance : 0,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Build a short, readable label. Nominatim's own `display_name` is
307
+ * comma-heavy and very long ("Boston, Suffolk County, Massachusetts,
308
+ * United States, 02108, United States of America") so we rebuild it
309
+ * from the structured address fields.
310
+ */
311
+ function formatDisplayName(item, { street, road }) {
312
+ const a = item.address || {};
313
+ const parts = [];
314
+
315
+ if (street) {
316
+ parts.push(street);
317
+ } else if (road) {
318
+ parts.push(road);
319
+ } else {
320
+ const poi =
321
+ a.amenity ||
322
+ a.shop ||
323
+ a.tourism ||
324
+ a.leisure ||
325
+ a.office ||
326
+ a.building ||
327
+ item.name;
328
+ if (poi) {
329
+ parts.push(poi);
330
+ }
331
+ }
332
+
333
+ const city = a.city || a.town || a.village || a.hamlet || a.suburb;
334
+ if (city) {
335
+ parts.push(city);
336
+ }
337
+ if (a.state) {
338
+ parts.push(a.state);
339
+ }
340
+ if (a.postcode) {
341
+ parts.push(a.postcode);
342
+ }
343
+ if (a.country_code) {
344
+ parts.push(a.country_code.toUpperCase());
345
+ }
346
+
347
+ return parts.length ? parts.join(", ") : item.display_name || "";
348
+ }
349
+
350
+ /**
351
+ * Convert a GeocodeResult into the organization location
352
+ * field shape consumed by LocationMapField.
353
+ *
354
+ * @param {GeocodeResult} result
355
+ * @returns {{street:string,city:string,state:string,zip:string,country:string,lat:number,lng:number}|null}
356
+ */
357
+ export function toOrganizationLocation(result) {
358
+ if (!result) {
359
+ return null;
360
+ }
361
+ const a = result.address;
362
+ return {
363
+ street: a.street || "",
364
+ city: a.city || "",
365
+ state: a.state || "",
366
+ zip: a.postcode || "",
367
+ country: a.countryCode || "US",
368
+ lat: result.lat,
369
+ lng: result.lng,
370
+ };
371
+ }
372
+
373
+ /**
374
+ * One-line lookup query for an organization-shaped address: street, city,
375
+ * "state ZIP". Line 2 (suite, unit, floor) is left out: it makes lookups fail.
376
+ *
377
+ * @param {Object} location Object with address_* keys.
378
+ * @returns {string}
379
+ */
380
+ export function toAddressQuery(location) {
381
+ const value = location && typeof location === "object" ? location : {};
382
+ return [
383
+ value.address_street,
384
+ value.address_city,
385
+ [value.address_state, value.address_zip]
386
+ .map((part) => String(part || "").trim())
387
+ .filter(Boolean)
388
+ .join(" "),
389
+ ]
390
+ .map((part) => String(part || "").trim())
391
+ .filter(Boolean)
392
+ .join(", ");
393
+ }
394
+
395
+ /**
396
+ * Visible attribution required by the Nominatim usage policy.
397
+ * Render this somewhere near the search control (small print is fine).
398
+ */
399
+ export const GEOCODER_ATTRIBUTION_HTML =
400
+ 'Search by <a href="https://nominatim.openstreetmap.org" target="_blank" rel="noopener noreferrer">Nominatim</a> / © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Address search (Nominatim): the explicit-submit search control and the
3
+ * geocoding helper behind it. No API key; never searches as you type.
4
+ *
5
+ * @package Siratik/WP-Component-Library
6
+ */
7
+
8
+ export { GeocoderSearchControl } from "./GeocoderSearchControl";
9
+ export { AddressEntryControl } from "./AddressEntryControl";
10
+ export { getCountryOptions } from "./countryOptions";
11
+ export {
12
+ geocodeAddress,
13
+ toOrganizationLocation,
14
+ GEOCODER_ATTRIBUTION_HTML,
15
+ DEFAULT_GEOCODER_COUNTRY_CODES,
16
+ } from "./geocoder";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Icon Component
3
+ *
4
+ * Renders an SVG icon. Uses the stored `source` string directly (preferred,
5
+ * ensures frontend-saved icons always render even if the set is later removed),
6
+ * with a registry fallback for display-only usage.
7
+ */
8
+
9
+ import { forwardRef } from "@wordpress/element";
10
+ import { useIcon } from "./hooks";
11
+ import { safeSvgSource } from "./utils";
12
+
13
+ // eslint-disable-next-line no-shadow -- named for React DevTools.
14
+ const IconDisplay = forwardRef(function IconDisplay(
15
+ { name, iconSet, source, onClick, className = "", style, ...rest },
16
+ ref,
17
+ ) {
18
+ // Prefer the directly-passed source (from saved attributes) for reliability.
19
+ // Fall back to registry lookup for display-only usage (e.g. rendering by name).
20
+ const registryIcon = useIcon(iconSet, name);
21
+ const svgSource = safeSvgSource(source || registryIcon?.source || "");
22
+
23
+ if (!svgSource) {
24
+ return null;
25
+ }
26
+
27
+ const clickProps = {};
28
+ if (typeof onClick === "function") {
29
+ clickProps.role = "button";
30
+ clickProps.tabIndex = 0;
31
+ clickProps["aria-label"] = "Change Icon";
32
+ clickProps.onClick = onClick;
33
+ clickProps.onKeyDown = (e) => {
34
+ if (e.key === "Enter" || e.key === " ") {
35
+ e.preventDefault();
36
+ onClick(e);
37
+ }
38
+ };
39
+ }
40
+
41
+ return (
42
+ <div
43
+ ref={ref}
44
+ className={`wpcl-icon${className ? ` ${className}` : ""}`}
45
+ dangerouslySetInnerHTML={{ __html: svgSource }}
46
+ style={style}
47
+ {...clickProps}
48
+ {...rest}
49
+ />
50
+ );
51
+ });
52
+
53
+ export { IconDisplay as Icon };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * IconPicker Component
3
+ *
4
+ * Inspector-panel icon picker. Shows the currently selected icon
5
+ * (or a placeholder) as a button — clicking opens the shared modal.
6
+ *
7
+ * Matches the 10up IconPicker API:
8
+ * <IconPicker value={icon} onChange={handleChange} label="Icon" />
9
+ */
10
+
11
+ import { BaseControl, Button } from "@wordpress/components";
12
+ import { useState, useCallback } from "@wordpress/element";
13
+ import { __ } from "@wordpress/i18n";
14
+
15
+ import { IconPickerModal } from "./IconPickerModal";
16
+ import { PickerPreview, PickerName } from "./styles";
17
+ import { PLACEHOLDER_SVG, safeSvgSource } from "./utils";
18
+
19
+ /**
20
+ * @param {Object} props
21
+ * @param {Object} [props.value] Current icon { name, iconSet, source }.
22
+ * @param {Function} props.onChange Called with new icon value.
23
+ * @param {string} [props.label] Label for the BaseControl.
24
+ * @param {string} [props.className]
25
+ */
26
+ export function IconPicker({ value, onChange, label = "", className = "" }) {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+
29
+ const openModal = useCallback(() => setIsOpen(true), []);
30
+ const closeModal = useCallback(() => setIsOpen(false), []);
31
+
32
+ const hasIcon = value?.source;
33
+ // The stored source is block data, so sanitize it like every other sink.
34
+ const svgSource = (hasIcon && safeSvgSource(value.source)) || PLACEHOLDER_SVG;
35
+
36
+ return (
37
+ // eslint-disable-next-line @wordpress/no-base-control-with-label-without-id -- the button has its own aria-label.
38
+ <BaseControl
39
+ label={label || __("Icon", "wp-component-library")}
40
+ className={className || undefined}
41
+ __nextHasNoMarginBottom
42
+ >
43
+ <Button
44
+ variant="secondary"
45
+ onClick={openModal}
46
+ style={{ width: "100%", justifyContent: "flex-start", gap: "8px" }}
47
+ aria-label={
48
+ hasIcon
49
+ ? __("Change icon", "wp-component-library")
50
+ : __("Choose icon", "wp-component-library")
51
+ }
52
+ >
53
+ <PickerPreview dangerouslySetInnerHTML={{ __html: svgSource }} />
54
+ <PickerName>
55
+ {hasIcon
56
+ ? value.label || value.name
57
+ : __("Choose icon\u2026", "wp-component-library")}
58
+ </PickerName>
59
+ </Button>
60
+
61
+ {isOpen && (
62
+ <IconPickerModal
63
+ value={value}
64
+ onChange={onChange}
65
+ onClose={closeModal}
66
+ />
67
+ )}
68
+ </BaseControl>
69
+ );
70
+ }