Initial
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
||||
126
BorderRadiusControl.js
Normal file
126
BorderRadiusControl.js
Normal file
@ -0,0 +1,126 @@
|
||||
import { useState } from '@wordpress/element';
|
||||
import {
|
||||
BaseControl,
|
||||
Button,
|
||||
__experimentalUnitControl as UnitControl,
|
||||
RangeControl,
|
||||
__experimentalHStack as HStack,
|
||||
__experimentalVStack as VStack,
|
||||
} from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { link, linkOff } from '@wordpress/icons';
|
||||
|
||||
export default function BorderRadiusControl({ label, value = { radius: '0px' }, onChange }) {
|
||||
// Determine if value is advanced initially
|
||||
const initialAdvanced = value.topLeft || value.topRight || value.bottomLeft || value.bottomRight ? true : false;
|
||||
const [advanced, setAdvanced] = useState(initialAdvanced);
|
||||
|
||||
const parseValue = (val) => {
|
||||
if (!val) return 0;
|
||||
if (typeof val === 'string') return parseInt(val);
|
||||
if (val.radius) return parseInt(val.radius);
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Flat handlers
|
||||
const handleFlatChange = (val) => {
|
||||
const newValue = { radius: val };
|
||||
console.log('Flat change →', newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
const handleFlatRangeChange = (val) => {
|
||||
const currentUnit = value.radius?.replace(/[0-9]/g, '') || 'px';
|
||||
const newValue = { radius: `${val}${currentUnit}` };
|
||||
console.log('Flat range change →', newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
// Advanced handlers
|
||||
const handleCornerChange = (corner, val) => {
|
||||
// Always return a full object with all four corners
|
||||
const newValue = {
|
||||
topLeft: corner === 'topLeft' ? val : value.topLeft || '0px',
|
||||
topRight: corner === 'topRight' ? val : value.topRight || '0px',
|
||||
bottomRight: corner === 'bottomRight' ? val : value.bottomRight || '0px',
|
||||
bottomLeft: corner === 'bottomLeft' ? val : value.bottomLeft || '0px',
|
||||
};
|
||||
console.log('Corner change →', newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseControl label={label}>
|
||||
<HStack gap={8} align="flex-start">
|
||||
{/* Left column: controls */}
|
||||
<VStack gap={8} style={{ flex: 1 }}>
|
||||
{!advanced && (
|
||||
<HStack gap={8} align="center">
|
||||
<UnitControl
|
||||
value={value.radius || '0px'}
|
||||
onChange={handleFlatChange}
|
||||
label={__('Radius', 'directory-listings')}
|
||||
min={0}
|
||||
max={500}
|
||||
style={{ flex: '0 0 auto' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<RangeControl
|
||||
value={parseValue(value)}
|
||||
withInputField={false}
|
||||
onChange={handleFlatRangeChange}
|
||||
min={0}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{advanced && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<UnitControl
|
||||
label={__('Top Left', 'directory-listings')}
|
||||
value={value.topLeft || '0px'}
|
||||
onChange={(val) => handleCornerChange('topLeft', val)}
|
||||
min={0}
|
||||
max={500}
|
||||
/>
|
||||
<UnitControl
|
||||
label={__('Top Right', 'directory-listings')}
|
||||
value={value.topRight || '0px'}
|
||||
onChange={(val) => handleCornerChange('topRight', val)}
|
||||
min={0}
|
||||
max={500}
|
||||
/>
|
||||
|
||||
<UnitControl
|
||||
label={__('Bottom Left', 'directory-listings')}
|
||||
value={value.bottomLeft || '0px'}
|
||||
onChange={(val) => handleCornerChange('bottomLeft', val)}
|
||||
min={0}
|
||||
max={500}
|
||||
/>
|
||||
<UnitControl
|
||||
label={__('Bottom Right', 'directory-listings')}
|
||||
value={value.bottomRight || '0px'}
|
||||
onChange={(val) => handleCornerChange('bottomRight', val)}
|
||||
min={0}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* Right column: toggle button */}
|
||||
<VStack>
|
||||
<Button
|
||||
icon={advanced ? linkOff : link}
|
||||
iconSize={24}
|
||||
isSecondary
|
||||
onClick={() => setAdvanced(!advanced)}
|
||||
aria-label={__('Toggle advanced border radius', 'directory-listings')}
|
||||
/>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
107
BoxShadowControl.js
Normal file
107
BoxShadowControl.js
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState } from '@wordpress/element';
|
||||
import {
|
||||
Dropdown,
|
||||
Button,
|
||||
__experimentalHStack as HStack,
|
||||
} from '@wordpress/components';
|
||||
import { solidLine } from '@wordpress/icons';
|
||||
|
||||
export default function BoxShadowControl({
|
||||
label = 'Box Shadow',
|
||||
value = '',
|
||||
onSelect,
|
||||
}) {
|
||||
const boxShadows = [
|
||||
'rgba(0, 0, 0, 0.1) 0px 0px 10px 0px',
|
||||
'0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06)',
|
||||
'0 10px 15px rgba(0,0,0,0.2), 0 4px 6px rgba(0,0,0,0.1)',
|
||||
'0 20px 25px rgba(0,0,0,0.3), 0 10px 10px rgba(0,0,0,0.2)',
|
||||
'0 0 10px rgba(0,0,0,0.5)',
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Dropdown
|
||||
className="box-shadow-control"
|
||||
style={{ width: '100%' }}
|
||||
popoverProps={{ placement: 'bottom-start' }}
|
||||
renderToggle={({ isOpen, onToggle }) => (
|
||||
<HStack
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onToggle()}
|
||||
gap={6}
|
||||
align="center"
|
||||
style={{
|
||||
width: '100%', // full width toggle
|
||||
padding: '6px 10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
|
||||
{value && (
|
||||
<Button
|
||||
icon={solidLine}
|
||||
isSecondary
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelect(''); // clear the shadow
|
||||
}}
|
||||
aria-label="Clear box shadow"
|
||||
style={{ marginLeft: 'auto' }} // optional: push to right
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
renderContent={() => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '8px',
|
||||
}}
|
||||
>
|
||||
{boxShadows.map((shadow, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '40px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: shadow,
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
onClick={() => onSelect(shadow)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
isSecondary
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
onClick={() => onSelect('')}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
66
CodeControl.js
Normal file
66
CodeControl.js
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
import { TextareaControl } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
/**
|
||||
* Simple validator functions for languages.
|
||||
* You can expand these for more robust validation.
|
||||
*/
|
||||
const validators = {
|
||||
css: (code) => {
|
||||
// Very basic CSS validation: checks for opening/closing braces
|
||||
const openBraces = (code.match(/{/g) || []).length;
|
||||
const closeBraces = (code.match(/}/g) || []).length;
|
||||
return openBraces === closeBraces;
|
||||
},
|
||||
js: (code) => {
|
||||
try {
|
||||
// Attempt to parse as JavaScript
|
||||
new Function(code);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
html: (code) => {
|
||||
// Very basic HTML check: matching opening/closing tags
|
||||
const openTags = (code.match(/<[^/!][^>]*>/g) || []).length;
|
||||
const closeTags = (code.match(/<\/[^>]+>/g) || []).length;
|
||||
return openTags === closeTags;
|
||||
},
|
||||
};
|
||||
|
||||
export default function CodeControl({
|
||||
value,
|
||||
onChange,
|
||||
language = 'css',
|
||||
label = __('Code', 'your-text-domain'),
|
||||
rows = 10,
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Validate code whenever value or language changes
|
||||
const isValid = validators[language]
|
||||
? validators[language](value)
|
||||
: true; // fallback: no validation
|
||||
setError(!isValid);
|
||||
}, [value, language]);
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'monospace', whiteSpace: 'pre' }}>
|
||||
<TextareaControl
|
||||
label={label + (error ? ' ⚠️ Invalid ' + language : '')}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`Enter ${language} code here...`}
|
||||
rows={rows}
|
||||
style={{
|
||||
borderColor: error ? 'red' : undefined,
|
||||
background: '#f5f5f5',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
ColorControl.js
Normal file
83
ColorControl.js
Normal file
@ -0,0 +1,83 @@
|
||||
import { useState, useRef } from '@wordpress/element';
|
||||
import { Popover, ToolbarButton, ColorPalette, __experimentalHStack as HStack } from '@wordpress/components';
|
||||
|
||||
const ColorControl = ({ name, colors = [], value, onChange, className }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef();
|
||||
|
||||
// Resolve what to show as the swatch color
|
||||
const resolveColorValue = (val) => {
|
||||
if (!val) return '';
|
||||
if (val.startsWith('var(')) {
|
||||
// Convert var(--wp--preset--color--primary) → #hex from the palette
|
||||
const match = colors.find(c => val.includes(c.slug));
|
||||
return match?.color || val;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
const selectedColor = resolveColorValue(value);
|
||||
|
||||
const handleChange = (colorValue) => {
|
||||
// Find if it matches a theme color
|
||||
const match = colors.find(c => c.color === colorValue);
|
||||
|
||||
let finalValue = colorValue;
|
||||
if (match?.slug) {
|
||||
// Store CSS variable instead of raw color code
|
||||
finalValue = `var(--wp--preset--color--${match.slug})`;
|
||||
}
|
||||
|
||||
if (onChange) onChange(finalValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarButton
|
||||
ref={buttonRef}
|
||||
className={className ? className : ''}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: selectedColor,
|
||||
border: '1px solid #ccc',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
/>
|
||||
{name}
|
||||
</ToolbarButton>
|
||||
|
||||
{isOpen && (
|
||||
<Popover
|
||||
anchorRef={buttonRef}
|
||||
placement="bottom-start"
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
minWidth: '250px',
|
||||
}}
|
||||
>
|
||||
<ColorPalette
|
||||
colors={colors}
|
||||
value={selectedColor}
|
||||
onChange={handleChange}
|
||||
enableAlpha
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorControl;
|
||||
139
DirectoryListingItem.js
Normal file
139
DirectoryListingItem.js
Normal file
@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
|
||||
function isOpenNow(operatingHours = []) {
|
||||
if (!operatingHours.length) return null;
|
||||
|
||||
const now = new Date();
|
||||
const dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
||||
const today = dayNames[now.getDay()];
|
||||
const todaysHours = operatingHours.filter(h => h.day === today);
|
||||
if (!todaysHours.length) return null;
|
||||
|
||||
for (let h of todaysHours) {
|
||||
if (h.closed) continue;
|
||||
if (h.open_24_hours) return true;
|
||||
if (!h.opening_time || !h.closing_time) continue;
|
||||
|
||||
const [openH, openM] = h.opening_time.split(':').map(Number);
|
||||
const [closeH, closeM] = h.closing_time.split(':').map(Number);
|
||||
|
||||
const openDate = new Date(now);
|
||||
openDate.setHours(openH, openM, 0, 0);
|
||||
const closeDate = new Date(now);
|
||||
closeDate.setHours(closeH, closeM, 0, 0);
|
||||
|
||||
if (now >= openDate && now <= closeDate) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default function DirectoryListingItem({
|
||||
post,
|
||||
imageSrc = '',
|
||||
imageMediaID = null,
|
||||
restRoot = '', // must pass multisite REST root from View.js
|
||||
onQuickView,
|
||||
style = 'hero',
|
||||
displayCategoryTitle = '',
|
||||
displayCategoryURL = '#',
|
||||
operatingHours = [],
|
||||
titleTag,
|
||||
postDescription,
|
||||
}) {
|
||||
const [finalImageSrc, setFinalImageSrc] = useState(imageSrc);
|
||||
const [isOpen, setIsOpen] = useState(null);
|
||||
const TitleTag = titleTag || 'h3';
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(isOpenNow(operatingHours));
|
||||
}, [operatingHours]);
|
||||
|
||||
// Fetch image URL if imageMediaID is provided
|
||||
useEffect(() => {
|
||||
if (!imageMediaID) return;
|
||||
if (!restRoot) {
|
||||
console.error('DirectoryListingItem: restRoot is required for multisite media fetch.');
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
const url = `${restRoot}wp/v2/media/${imageMediaID}`;
|
||||
console.log('Fetching media URL:', url);
|
||||
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Media fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (isMounted && data?.source_url) {
|
||||
setFinalImageSrc(data.source_url);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch media:', err));
|
||||
|
||||
return () => { isMounted = false; };
|
||||
}, [imageMediaID, restRoot]);
|
||||
|
||||
const heroStyle = style === 'hero' && finalImageSrc ? { backgroundImage: `url(${finalImageSrc})` } : {};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{style !== 'hero' && (
|
||||
<div className="listing-left">
|
||||
{finalImageSrc ? (
|
||||
<img src={finalImageSrc} alt={post.title?.rendered || 'Post image'} />
|
||||
) : (
|
||||
<div className="placeholder-image">No Image</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="listing-right">
|
||||
{displayCategoryTitle && (
|
||||
<a
|
||||
className="listing-category-button"
|
||||
href={displayCategoryURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{displayCategoryTitle}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<TitleTag>
|
||||
{post.title?.rendered ? (
|
||||
<a href={post.link} dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
|
||||
) : (
|
||||
<span>No title</span>
|
||||
)}
|
||||
</TitleTag>
|
||||
|
||||
{postDescription ? (
|
||||
<p dangerouslySetInnerHTML={{ __html: postDescription }} />
|
||||
) : null}
|
||||
|
||||
{isOpen !== null && (
|
||||
<span className={`open-status ${isOpen ? 'open' : 'closed'}`}>
|
||||
{isOpen ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button className="quickview-button" onClick={() => onQuickView(post)}>
|
||||
QuickView
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return style === 'hero' ? (
|
||||
<a href={post.link} className={`directory-listing ${style}`} style={heroStyle}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<article className={`directory-listing ${style}`} style={heroStyle}>
|
||||
{content}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
32
JustificationControl.js
Normal file
32
JustificationControl.js
Normal file
@ -0,0 +1,32 @@
|
||||
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
|
||||
import { justifyLeft, justifyCenter, justifyRight } from '@wordpress/icons';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export default function JustificationControl({ value, onChange, label = __('Justification', 'directory-listings') }) {
|
||||
const options = [
|
||||
{ icon: justifyLeft, value: 'left', label: __('Left', 'directory-listings') },
|
||||
{ icon: justifyCenter, value: 'center', label: __('Center', 'directory-listings') },
|
||||
{ icon: justifyRight, value: 'right', label: __('Right', 'directory-listings') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="justification-control">
|
||||
{/* Label above toolbar */}
|
||||
<div className="justification-control__label" style={{ marginBottom: '4px', display: 'block' }}>
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<ToolbarGroup label={label}>
|
||||
{options.map((option) => (
|
||||
<ToolbarButton
|
||||
key={option.value}
|
||||
icon={option.icon}
|
||||
label={option.label} // accessible for screen readers
|
||||
isPressed={value === option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ToolbarGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
QuickViewModal.js
Normal file
15
QuickViewModal.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { Fragment } from '@wordpress/element';
|
||||
|
||||
export default function QuickViewModal({ post, onClose }) {
|
||||
if (!post) return null;
|
||||
|
||||
return (
|
||||
<div className="quickview-modal" onClick={onClose}>
|
||||
<div className="quickview-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="quickview-close" onClick={onClose}>×</button>
|
||||
<h3 dangerouslySetInnerHTML={{ __html: post.title?.rendered || '' }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: post.content?.rendered || '' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
ShareModal.js
Normal file
48
ShareModal.js
Normal file
@ -0,0 +1,48 @@
|
||||
function ShareModal({ postUrl }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
if (!postUrl) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setVisible(true)}>Share</button>
|
||||
{visible && (
|
||||
<div className="listing-share-modal" style={{
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '20px',
|
||||
maxWidth: '400px',
|
||||
width: '90%',
|
||||
borderRadius: '6px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<button
|
||||
aria-label="Close"
|
||||
style={{ position: 'absolute', top: 10, right: 10, fontSize: 24, background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
onClick={() => setVisible(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2>Share this listing</h2>
|
||||
<input type="text" className="share-link" readOnly value={postUrl} style={{ width: '100%', marginBottom: 10 }} />
|
||||
<div className="listing-share-links">
|
||||
<a href={`https://twitter.com/share?url=${encodeURIComponent(postUrl)}`} target="_blank">Twitter</a>
|
||||
<a href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(postUrl)}`} target="_blank">Facebook</a>
|
||||
<a href={`mailto:?body=${encodeURIComponent(postUrl)}`} target="_blank">Email</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
156
SpacingControl.js
Normal file
156
SpacingControl.js
Normal file
@ -0,0 +1,156 @@
|
||||
import { useState } from '@wordpress/element';
|
||||
|
||||
import {
|
||||
BaseControl,
|
||||
Button,
|
||||
RangeControl,
|
||||
__experimentalHStack as HStack,
|
||||
__experimentalVStack as VStack,
|
||||
__experimentalUnitControl as UnitControl,
|
||||
Icon
|
||||
} from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { sidesHorizontal, sidesVertical, sidesTop, sidesBottom, sidesLeft, sidesRight, settings } from '@wordpress/icons';
|
||||
|
||||
export default function SpacingControl({ label, value = {}, onChange, themeSpacing = {} }) {
|
||||
const [verticalAdvanced, setVerticalAdvanced] = useState(false);
|
||||
const [horizontalAdvanced, setHorizontalAdvanced] = useState(false);
|
||||
const [advancedMode, setAdvancedMode] = useState(false); // full advanced toggle
|
||||
|
||||
const parseValue = (val) => (val ? parseInt(val) : 0);
|
||||
|
||||
const handleChange = (sides, val) => {
|
||||
const unit = val.toString().replace(/[0-9]/g, '') || 'px';
|
||||
const newValue = { ...value };
|
||||
sides.forEach((side) => {
|
||||
newValue[side] = `${parseInt(val)}${unit}`;
|
||||
});
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
// --- Generate marks for RangeControl from theme.json spacing ---
|
||||
const spacingMarks = themeSpacing?.spacingSizes?.map((s) => ({
|
||||
value: parseInt(s.size), // you may need to strip "px" or clamp()
|
||||
label: s.name,
|
||||
})) || [];
|
||||
|
||||
// --- Advanced 4-row mode ---
|
||||
if (advancedMode) {
|
||||
const sides = [
|
||||
{ key: 'top', sideIcon: sidesTop },
|
||||
{ key: 'bottom', sideIcon: sidesBottom },
|
||||
{ key: 'left', sideIcon: sidesLeft },
|
||||
{ key: 'right', sideIcon: sidesRight },
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseControl label={label}>
|
||||
<VStack gap={16}>
|
||||
<Button variant="secondary" onClick={() => setAdvancedMode(false)}>
|
||||
{__('Back to Simple Mode', 'directory-listings')}
|
||||
</Button>
|
||||
|
||||
{sides.map(({ key, sideIcon }) => (
|
||||
<VStack key={key} gap={4}>
|
||||
<HStack align="center" gap={8}>
|
||||
<Icon icon={sideIcon} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<RangeControl
|
||||
value={parseValue(value[key] || 0)}
|
||||
onChange={(val) => handleChange([key], val)}
|
||||
min={0}
|
||||
max={Math.max(...spacingMarks.map((m) => m.value))}
|
||||
step={1}
|
||||
marks={spacingMarks}
|
||||
withInputField={false}
|
||||
/>
|
||||
</div>
|
||||
<UnitControl
|
||||
value={value[key] || '0px'}
|
||||
onChange={(val) => handleChange([key], val)}
|
||||
min={0}
|
||||
max={Math.max(...spacingMarks.map((m) => m.value))}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Simple vertical + horizontal mode ---
|
||||
return (
|
||||
<BaseControl label={label}>
|
||||
<VStack gap={16}>
|
||||
<Button variant="secondary" onClick={() => setAdvancedMode(true)}>
|
||||
{__('Advanced Mode', 'directory-listings')}
|
||||
</Button>
|
||||
|
||||
{/* Vertical */}
|
||||
<VStack gap={4}>
|
||||
<HStack align="center" gap={8}>
|
||||
<Icon icon={sidesVertical} />
|
||||
{verticalAdvanced && (
|
||||
<UnitControl
|
||||
value={value.top || '0px'}
|
||||
onChange={(val) => handleChange(['top', 'bottom'], val)}
|
||||
min={0}
|
||||
max={Math.max(...spacingMarks.map((m) => m.value))}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<RangeControl
|
||||
value={parseValue(value.top || 0)}
|
||||
onChange={(val) => handleChange(['top', 'bottom'], val)}
|
||||
min={0}
|
||||
max={Math.max(...spacingMarks.map((m) => m.value))}
|
||||
step={1}
|
||||
marks={spacingMarks}
|
||||
withInputField={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={settings}
|
||||
isSecondary
|
||||
onClick={() => setVerticalAdvanced(!verticalAdvanced)}
|
||||
aria-label={__('Toggle vertical spacing input', 'directory-listings')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* Horizontal */}
|
||||
<VStack gap={4}>
|
||||
<HStack align="center" gap={8}>
|
||||
<Icon icon={sidesHorizontal} />
|
||||
{horizontalAdvanced && (
|
||||
<UnitControl
|
||||
value={value.left || '0px'}
|
||||
onChange={(val) => handleChange(['left', 'right'], val)}
|
||||
min={0}
|
||||
max={Math.max(...spacingMarks.map((m) => m.value))}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<RangeControl
|
||||
value={parseValue(value.left || 0)}
|
||||
onChange={(val) => handleChange(['left', 'right'], val)}
|
||||
min={0}
|
||||
max={Math.max(...spacingMarks.map((m) => m.value))}
|
||||
step={1}
|
||||
marks={spacingMarks}
|
||||
withInputField={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={settings}
|
||||
isSecondary
|
||||
onClick={() => setHorizontalAdvanced(!horizontalAdvanced)}
|
||||
aria-label={__('Toggle horizontal spacing input', 'directory-listings')}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</BaseControl>
|
||||
);
|
||||
}
|
||||
60
TextSizeControl.js
Normal file
60
TextSizeControl.js
Normal file
@ -0,0 +1,60 @@
|
||||
import { useState, Fragment } from '@wordpress/element';
|
||||
import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption, __experimentalUnitControl as UnitControl, Icon } from '@wordpress/components';
|
||||
import { useSettings } from '@wordpress/block-editor';
|
||||
import { settings } from '@wordpress/icons';
|
||||
|
||||
export default function TextSizeControl({ value = '', onChange, title = 'Font Size' }) {
|
||||
const { typography } = useSettings() || {};
|
||||
const fontSizes = (typography && typography.fontSizes) || [];
|
||||
|
||||
const letters = ['S', 'M', 'L', 'XL', 'XXL'];
|
||||
const sizeMap = {};
|
||||
fontSizes.forEach((fs, index) => {
|
||||
const cssSize = fs.fluid?.max || fs.size;
|
||||
const key = letters[index] || fs.slug || fs.name;
|
||||
sizeMap[key] = cssSize;
|
||||
});
|
||||
|
||||
const initialKey = Object.keys(sizeMap).find(k => sizeMap[k] === value) || Object.keys(sizeMap)[0];
|
||||
const [selected, setSelected] = useState(initialKey);
|
||||
const [advanced, setAdvanced] = useState(false); // toggle between preset and UnitControl
|
||||
|
||||
const handleToggleChange = (key) => {
|
||||
setSelected(key);
|
||||
if (onChange) onChange(sizeMap[key]);
|
||||
console.log('[TextSizeControl] Toggle selected:', key, '=>', sizeMap[key]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', cursor: 'pointer' }}
|
||||
onClick={() => setAdvanced(!advanced)}
|
||||
>
|
||||
<span style={{ fontWeight: 'bold' }}>{title}</span>
|
||||
<Icon icon={settings} />
|
||||
</div>
|
||||
|
||||
{!advanced ? (
|
||||
<ToggleGroupControl
|
||||
label=""
|
||||
isBlock
|
||||
value={selected}
|
||||
onChange={handleToggleChange}
|
||||
>
|
||||
{Object.keys(sizeMap).map((key) => (
|
||||
<ToggleGroupControlOption key={key} value={key} label={key} />
|
||||
))}
|
||||
</ToggleGroupControl>
|
||||
) : (
|
||||
<UnitControl
|
||||
value={value || ''}
|
||||
onChange={(newValue) => {
|
||||
console.log('[TextSizeControl] UnitControl value:', newValue);
|
||||
if (onChange) onChange(newValue);
|
||||
}}
|
||||
label="" // optional, already have header
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
11
package.json
Normal file
11
package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@rangeldigital/wp-components",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"private": false,
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user