Files

251 lines
8.5 KiB
JavaScript

/*
* SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Component Factory - Creates UI components from templates.
*
* Factory functions create DOM elements from templates without attaching
* event handlers (callers add their own handlers). This keeps factories
* pure and reusable.
*
* DEPENDENCY RULES:
* factories → utils (NEVER controllers/cores/stores)
*/
import { cloneTemplate, bindTemplateData } from "../utils/html.js";
import { TEMPLATE_IDS } from "../utils/constants.js";
/**
* Map of input types to their template IDs
*/
const INPUT_TEMPLATES = {
text: TEMPLATE_IDS.TEXT_INPUT,
number: TEMPLATE_IDS.NUMBER_INPUT,
select: TEMPLATE_IDS.SELECT_INPUT,
checkbox: TEMPLATE_IDS.CHECKBOX_INPUT,
textarea: TEMPLATE_IDS.TEXTAREA_INPUT,
slider: TEMPLATE_IDS.SLIDER_INPUT,
};
/**
* Create an input element from a template.
*
* @param {string} type - Input type: "text", "number", "select", "checkbox", "textarea", "slider"
* @param {Object} [options={}] - Input configuration options
* @param {string} [options.name] - Input name attribute
* @param {*} [options.value] - Initial value
* @param {string} [options.placeholder] - Placeholder text
* @param {boolean} [options.disabled] - Whether input is disabled
* @param {Array<{value: string, label: string}>} [options.options] - Options for select inputs
* @param {number} [options.min] - Minimum value for number/slider inputs
* @param {number} [options.max] - Maximum value for number/slider inputs
* @param {number} [options.step] - Step value for number/slider inputs
* @returns {HTMLElement|null} The input element, or null if template not found
*/
function createInput(type, options = {}) {
const templateId = INPUT_TEMPLATES[type];
if (!templateId) {
console.warn(`[componentFactory] Unknown input type: ${type}`);
return null;
}
const element = cloneTemplate(templateId);
if (!element) {
return null;
}
const input = element.querySelector("input, select, textarea") || element;
// Apply common options
if (options.name) {
input.name = options.name;
}
if (options.placeholder) {
input.placeholder = options.placeholder;
}
if (options.disabled) {
input.disabled = true;
}
// Type-specific configuration
switch (type) {
case "text":
case "textarea":
if (options.value !== undefined) {
input.value = options.value;
}
break;
case "number":
if (options.value !== undefined) {
input.value = options.value;
}
if (options.min !== undefined) {
input.min = options.min;
}
if (options.max !== undefined) {
input.max = options.max;
}
if (options.step !== undefined) {
input.step = options.step;
}
break;
case "checkbox":
if (options.value !== undefined) {
input.checked = Boolean(options.value);
}
if (options.label) {
const labelSpan = element.querySelector("[data-bind='label']");
if (labelSpan) {
labelSpan.textContent = options.label;
}
}
break;
case "select":
if (options.options) {
options.options.forEach(({ value, label }) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
input.appendChild(opt);
});
}
if (options.value !== undefined) {
input.value = options.value;
}
break;
case "slider":
// Slider template has its own structure, configure via data attributes or specific selectors
const rangeInput = element.querySelector('input[type="range"]');
const numberInput = element.querySelector('input[type="number"]');
if (rangeInput) {
if (options.value !== undefined) rangeInput.value = options.value;
if (options.min !== undefined) rangeInput.min = options.min;
if (options.max !== undefined) rangeInput.max = options.max;
if (options.step !== undefined) rangeInput.step = options.step;
}
if (numberInput) {
if (options.value !== undefined) numberInput.value = options.value;
if (options.min !== undefined) numberInput.min = options.min;
if (options.max !== undefined) numberInput.max = options.max;
if (options.step !== undefined) numberInput.step = options.step;
}
break;
}
return element;
}
/**
* Create a form field with label and input.
*
* @param {Object} config - Field configuration
* @param {string} config.label - Field label text
* @param {string} config.inputType - Input type (passed to createInput)
* @param {Object} [config.inputOptions={}] - Options passed to createInput
* @param {string} [config.fieldClass] - Additional class for the field container
* @param {string} [config.labelClass] - Additional class for the label
* @param {string} [config.inputClass] - Additional class for the input
* @param {Object} [config.dataAttributes={}] - Data attributes to add to the input
* @returns {{field: HTMLElement, input: HTMLElement|null}} The field container and input element
*/
export function createFormField(config) {
const { label, inputType, inputOptions = {}, fieldClass, labelClass, inputClass, dataAttributes = {} } = config;
const field = cloneTemplate(TEMPLATE_IDS.FORM_FIELD);
if (!field) {
return { field: null, input: null };
}
// Apply custom field class
if (fieldClass) {
field.classList.add(fieldClass);
}
// Apply custom label class
const labelEl = field.querySelector("[data-bind='label']");
if (labelEl && labelClass) {
labelEl.classList.add(labelClass);
}
bindTemplateData(field, { label });
const inputWrap = field.querySelector("[data-element='input-wrap']");
const input = createInput(inputType, inputOptions);
if (inputWrap && input) {
// Apply custom input class
const actualInput = input.querySelector("input, select, textarea") || input;
if (inputClass) {
actualInput.classList.add(inputClass);
}
// Apply data attributes
Object.entries(dataAttributes).forEach(([key, value]) => {
actualInput.dataset[key] = value;
});
inputWrap.appendChild(input);
}
// Return the actual input element for event binding
const actualInput = input?.querySelector("input, select, textarea") || input;
return { field, input: actualInput };
}
/**
* Create a split button with main action and caret for popover.
* The split button has two distinct click targets: a main button for the primary action
* and a caret button that typically opens a dropdown/popover with options.
*
* @param {Object} config - Split button configuration
* @param {string} config.label - Main button label text
* @param {string} [config.title] - Main button title/tooltip
* @param {string} [config.caretTitle="More options"] - Caret button title/tooltip
* @param {string} [config.variant="primary"] - Button variant: "primary", "secondary"
* @param {boolean} [config.disabled=false] - Whether main button is initially disabled
* @returns {{container: HTMLElement, mainBtn: HTMLElement, caretBtn: HTMLElement}}
*/
export function createSplitButton(config) {
const { label, title, caretTitle = "More options", variant = "primary", disabled = false } = config;
const container = document.createElement("div");
container.className = "rtx-remix-split-btn";
const mainBtn = document.createElement("button");
mainBtn.type = "button";
mainBtn.className = `rtx-remix-btn rtx-remix-btn-${variant}`;
mainBtn.textContent = label;
if (title) mainBtn.title = title;
if (disabled) mainBtn.disabled = true;
const caretBtn = document.createElement("button");
caretBtn.type = "button";
caretBtn.className = `rtx-remix-btn rtx-remix-btn-${variant}`;
caretBtn.title = caretTitle;
caretBtn.innerHTML = '<span class="pi pi-chevron-down"></span>';
container.appendChild(mainBtn);
container.appendChild(caretBtn);
return { container, mainBtn, caretBtn };
}