UNPKG

lightview

Version:

A reactive UI library with features of Bau, Juris, and HTMX plus safe LLM UI generation

374 lines (326 loc) 12.1 kB
/** * Lightview Components - Radio & RadioGroup * Radio button components using DaisyUI 5 styling with validation support * @see https://daisyui.com/components/radio/ * * Uses DaisyUI's form-control pattern: * <div class="form-control"> * <label class="label cursor-pointer"> * <span class="label-text">Option</span> * <input type="radio" class="radio" /> * </label> * </div> */ import '../daisyui.js'; /** * Radio Component (Single radio button) * @param {Object} props - Radio properties * @param {string} props.name - Radio group name * @param {*} props.value - Radio value * @param {boolean|function} props.checked - Checked state * @param {string} props.size - 'xs' | 'sm' | 'md' | 'lg' (default: 'md') * @param {string} props.color - 'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error' * @param {boolean} props.disabled - Disable radio * @param {string} props.label - Label text * @param {Function} props.onChange - Change handler */ const Radio = (props = {}) => { const { tags } = globalThis.Lightview || {}; const LVX = globalThis.LightviewX || {}; if (!tags) { console.error('Lightview not found'); return null; } const { div, input, label, span, shadowDOM } = tags; const { name, value, checked = false, size = 'md', color, disabled = false, label: labelText, onChange, id, class: className = '', useShadow, ...rest } = props; const radioId = id || `radio-${Math.random().toString(36).slice(2, 9)}`; // Build DaisyUI radio classes const getRadioClass = () => { const classes = ['radio']; if (size && size !== 'md') { classes.push(`radio-${size}`); } if (color) { classes.push(`radio-${color}`); } return classes.join(' '); }; const radioInput = input({ type: 'radio', class: getRadioClass(), name, value, checked: typeof checked === 'function' ? checked : checked, disabled, id: radioId, onchange: onChange ? (e) => onChange(e.target.value, e) : undefined, ...rest }); // If no label, return just the radio if (!labelText) { return radioInput; } const formControl = div({ class: `form-control ${className}`.trim() }, label({ class: 'label cursor-pointer', style: 'justify-content: flex-start; gap: 0;' }, radioInput, span({ class: 'label-text', style: 'margin-left: 0.5rem;' }, labelText) ) ); // Check if we should use shadow DOM let usesShadow = false; if (LVX.shouldUseShadow) { usesShadow = LVX.shouldUseShadow(useShadow); } else { usesShadow = useShadow === true; } if (usesShadow) { const adoptedStyleSheets = LVX.getAdoptedStyleSheets ? LVX.getAdoptedStyleSheets() : []; const themeValue = LVX.themeSignal ? () => LVX.themeSignal.value : 'light'; return div({ class: 'content', style: 'display: inline-block' }, shadowDOM({ mode: 'open', adoptedStyleSheets }, div({ 'data-theme': themeValue }, formControl) ) ); } return formControl; }; /** * RadioGroup Component * @param {Object} props - RadioGroup properties * @param {Array} props.options - Array of options: string[] or {value, label, description, disabled}[] * @param {*|Signal} props.value - Selected value (controlled) * @param {*} props.defaultValue - Default value (uncontrolled) * @param {string} props.name - Group name for form submission * @param {string} props.label - Group label * @param {string} props.helper - Helper text * @param {string|Function} props.error - Error message * @param {Function} props.validate - Validation function (value) => errorMessage | null * @param {string} props.color - 'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error' * @param {string} props.size - 'xs' | 'sm' | 'md' | 'lg' (default: 'md') * @param {boolean} props.horizontal - Horizontal layout * @param {boolean} props.disabled - Disable all options * @param {boolean} props.required - Mark as required * @param {Function} props.onChange - Value change handler * @param {boolean} props.useShadow - Render in Shadow DOM */ const RadioGroup = (props = {}) => { const { tags, signal } = globalThis.Lightview || {}; const LVX = globalThis.LightviewX || {}; if (!tags) { console.error('Lightview not found'); return null; } const { div, fieldset, legend, input, label, span, p, shadowDOM } = tags; const { options = [], value, defaultValue, name = `radio-group-${Math.random().toString(36).slice(2, 9)}`, label: groupLabel, helper, error, validate, color, size = 'md', horizontal = false, disabled = false, required = false, onChange, class: className = '', useShadow, ...rest } = props; // Normalize options const normalizedOptions = (Array.isArray(options) ? options : []).map(opt => typeof opt === 'string' ? { value: opt, label: opt } : opt ); // Internal state const internalValue = signal ? signal(defaultValue !== undefined ? defaultValue : null) : { value: defaultValue !== undefined ? defaultValue : null }; const internalError = signal ? signal(null) : { value: null }; const isControlled = value !== undefined; const getValue = () => { if (isControlled) { return typeof value === 'function' ? value() : (value && typeof value.value !== 'undefined') ? value.value : value; } return internalValue.value; }; const getError = () => { if (error) { const err = typeof error === 'function' ? error() : error; if (err) return err; } return internalError.value; }; const runValidation = (val) => { if (validate) { const result = validate(val); internalError.value = result; return result; } if (required && !val) { internalError.value = 'Please select an option'; return 'Please select an option'; } internalError.value = null; return null; }; const handleChange = (optValue) => { if (!isControlled) { internalValue.value = optValue; } if (isControlled && value && typeof value.value !== 'undefined') { value.value = optValue; } runValidation(optValue); if (onChange) onChange(optValue); }; // Build radio class const getRadioClass = () => { const classes = ['radio']; if (size && size !== 'md') classes.push(`radio-${size}`); if (color) classes.push(`radio-${color}`); return classes.join(' '); }; // Build options const radioOptions = normalizedOptions.map(opt => { const optDisabled = disabled || opt.disabled; const isChecked = () => getValue() === opt.value; return label({ class: 'label cursor-pointer', style: 'justify-content: flex-start; gap: 0;' }, input({ type: 'radio', class: getRadioClass(), name, value: opt.value, checked: isChecked, disabled: optDisabled, onchange: () => handleChange(opt.value) }), div({ style: 'display: flex; flex-direction: column; margin-left: 0.5rem;' }, span({ class: 'label-text' }, opt.label), opt.description ? span({ class: 'label-text-alt', style: 'opacity: 0.7;' }, opt.description) : null ) ); }); // Build the component const fieldsetContent = []; if (groupLabel) { fieldsetContent.push( legend({ class: 'fieldset-legend' }, groupLabel, required ? span({ class: 'text-error' }, ' *') : null ) ); } fieldsetContent.push( div({ style: horizontal ? 'display: flex; flex-wrap: wrap; gap: 1rem;' : 'display: flex; flex-direction: column; gap: 0.5rem;', role: 'radiogroup', 'aria-label': groupLabel }, ...radioOptions) ); // Helper or error text if (helper || validate || error || required) { fieldsetContent.push( () => { const currentError = getError(); if (currentError) { return p({ class: 'label text-error', role: 'alert' }, currentError); } if (helper) { return p({ class: 'label' }, helper); } return null; } ); } const wrapperEl = fieldset({ class: `fieldset ${className}`.trim(), ...rest }, ...fieldsetContent); // Check if we should use shadow DOM let usesShadow = false; if (LVX.shouldUseShadow) { usesShadow = LVX.shouldUseShadow(useShadow); } else { usesShadow = useShadow === true; } if (usesShadow) { const adoptedStyleSheets = LVX.getAdoptedStyleSheets ? LVX.getAdoptedStyleSheets() : []; const themeValue = LVX.themeSignal ? () => LVX.themeSignal.value : 'light'; return span({ style: 'margin-right: 0.5rem' }, shadowDOM({ mode: 'open', adoptedStyleSheets }, div({ 'data-theme': themeValue, style: 'display: inline-block' }, wrapperEl) ) ); } return wrapperEl; }; // Auto-register globalThis.Lightview.tags.Radio = Radio; globalThis.Lightview.tags.RadioGroup = RadioGroup; // Register as Custom Elements if (globalThis.LightviewX?.customElementWrapper) { if (!customElements.get('lv-radio')) { customElements.define('lv-radio', globalThis.LightviewX.customElementWrapper(Radio, { attributeMap: { name: String, value: String, checked: Boolean, size: String, color: String, disabled: Boolean, label: String, id: String, class: String } })); } if (!customElements.get('lv-radio-group')) { customElements.define('lv-radio-group', globalThis.LightviewX.customElementWrapper(RadioGroup, { attributeMap: { options: Array, value: String, defaultValue: String, name: String, label: String, helper: String, error: String, color: String, size: String, horizontal: Boolean, disabled: Boolean, required: Boolean, class: String } })); } } else if (globalThis.LightviewX?.createCustomElement) { const RadioElement = globalThis.LightviewX.createCustomElement(Radio); if (!customElements.get('lv-radio')) { customElements.define('lv-radio', RadioElement); } const RadioGroupElement = globalThis.LightviewX.createCustomElement(RadioGroup); if (!customElements.get('lv-radio-group')) { customElements.define('lv-radio-group', RadioGroupElement); } } export default Radio; export { Radio, RadioGroup };