This commit is contained in:
Jeremy Rangel
2025-11-30 00:50:56 -08:00
commit f1e7005b26
12 changed files with 844 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

126
BorderRadiusControl.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)}
>
&times;
</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
View 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
View 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
View 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"
}
}