/** * KTUI - Free & Open-Source Tailwind UI Components by Keenthemes * Copyright 2025 by Keenthemes Inc */ import { KTSelectConfigInterface, KTSelectOption } from './config'; import { renderTemplateString } from './utils'; /** * Default HTML string templates for KTSelect. All UI structure is defined here. * Users can override any template by providing a matching key in the config.templates object. */ export const coreTemplateStrings = { dropdown: ``, options: ``, error: ``, wrapper: `
`, combobox: `
`, placeholder: `
`, display: ` `, option: `
  • {{text}}
  • `, search: ``, searchEmpty: `
    `, loading: `
  • `, tag: `
    `, loadMore: `
  • `, selectAll: `
    `, tagRemoveButton: ``, }; /** * Template interface for KTSelect component * Each method returns an HTML string or HTMLElement */ export interface KTSelectTemplateInterface { /** * Renders the dropdown content container */ dropdown: ( config: KTSelectConfigInterface & { zindex?: number; content?: string }, ) => HTMLElement; /** * Renders the options container */ options: ( config: KTSelectConfigInterface & { options?: string }, ) => HTMLElement; /** * Renders the load more button for pagination */ loadMore: (config: KTSelectConfigInterface) => HTMLElement; /** * Renders an error message in the dropdown */ error: ( config: KTSelectConfigInterface & { errorMessage: string }, ) => HTMLElement; // Main components wrapper: (config: KTSelectConfigInterface) => HTMLElement; display: (config: KTSelectConfigInterface) => HTMLElement; // Option rendering option: ( option: KTSelectOption | HTMLOptionElement, config: KTSelectConfigInterface, ) => HTMLElement; // Search and empty states search: (config: KTSelectConfigInterface) => HTMLElement; searchEmpty: (config: KTSelectConfigInterface) => HTMLElement; loading: ( config: KTSelectConfigInterface, loadingMessage: string, ) => HTMLElement; // Multi-select tag: ( option: HTMLOptionElement, config: KTSelectConfigInterface, ) => HTMLElement; placeholder: (config: KTSelectConfigInterface) => HTMLElement; selectAll: (config: KTSelectConfigInterface) => HTMLElement; } /** * Default templates for KTSelect component */ function stringToElement(html: string): HTMLElement { const template = document.createElement('template'); template.innerHTML = html.trim(); return template.content.firstElementChild as HTMLElement; } /** * User-supplied template overrides. Use setTemplateStrings() to add or update. */ let userTemplateStrings: Partial = {}; /** * Register or update user template overrides. * @param templates Partial template object to merge with defaults. */ export function setTemplateStrings( templates: Partial, ): void { userTemplateStrings = { ...userTemplateStrings, ...templates }; } /** * Get the complete template set, merging defaults, user overrides, and config templates. * @param config Optional config object with a "templates" property. */ export function getTemplateStrings( config?: KTSelectConfigInterface, ): typeof coreTemplateStrings { const templates = config && typeof config === 'object' && 'templates' in config ? (config as KTSelectConfigInterface).templates : undefined; if (templates) { return { ...coreTemplateStrings, ...userTemplateStrings, ...templates }; } return { ...coreTemplateStrings, ...userTemplateStrings }; } /** * Default templates for KTSelect component */ export const defaultTemplates: KTSelectTemplateInterface = { /** * Renders the dropdown content */ dropdown: ( config: KTSelectConfigInterface & { zindex?: number; content?: string }, ) => { const template = getTemplateStrings(config).dropdown; // If a custom dropdownTemplate is provided, it's responsible for its own content. // Otherwise, the base template is used, and content is appended later. if (config.dropdownTemplate) { const renderedCustomTemplate = renderTemplateString( config.dropdownTemplate, { zindex: config.zindex ? String(config.zindex) : '', // content: config.content || '', // No longer pass content to custom template directly here class: config.dropdownClass || '', }, ); // The custom template IS the dropdown element const customDropdownEl = stringToElement(renderedCustomTemplate); if (config.zindex) customDropdownEl.style.zIndex = String(config.zindex); if (config.dropdownClass) customDropdownEl.classList.add(...config.dropdownClass.split(' ')); return customDropdownEl; } const html = template .replace('{{zindex}}', config.zindex ? String(config.zindex) : '') // .replace('{{content}}', '') // Content is no longer part of the base template string .replace('{{class}}', config.dropdownClass || ''); return stringToElement(html); }, /** * Renders the options container for the dropdown */ options: (config: KTSelectConfigInterface & { options?: string }) => { const template = getTemplateStrings(config).options; const html = template .replace('{{label}}', config.label || 'Options') .replace('{{height}}', config.height ? String(config.height) : '250') // .replace('{{options}}', '') // Options are now appended dynamically .replace('{{class}}', config.optionsClass || ''); return stringToElement(html); }, /** * Renders the load more button for pagination */ loadMore: (config: KTSelectConfigInterface): HTMLElement => { const html = getTemplateStrings(config) .loadMore // .replace('{{loadMoreText}}', config.loadMoreText || 'Load more...') // Content is no longer in template string .replace('{{class}}', config.loadMoreClass || ''); const element = stringToElement(html); element.textContent = config.loadMoreText || 'Load more...'; return element; }, /** * Renders an error message in the dropdown */ error: ( config: KTSelectConfigInterface & { errorMessage: string }, ): HTMLElement => { // Changed return type to HTMLElement const template = getTemplateStrings(config).error; const html = template // .replace('{{errorMessage}}', config.errorMessage || 'An error occurred') // Content is no longer in template string .replace('{{class}}', config.errorClass || ''); const element = stringToElement(html); element.textContent = config.errorMessage || 'An error occurred'; return element; }, /** * Renders the main container for the select component */ wrapper: (config: KTSelectConfigInterface): HTMLElement => { const html = getTemplateStrings(config).wrapper.replace( '{{class}}', config.wrapperClass || '', ); const element = stringToElement(html); return element; }, /** * Renders the display element (trigger) for the select */ display: (config: KTSelectConfigInterface): HTMLElement => { const html = getTemplateStrings(config) .display.replace('{{tabindex}}', config.disabled ? '-1' : '0') .replace('{{label}}', config.label || config.placeholder || 'Select...') .replace('{{disabled}}', config.disabled ? 'aria-disabled="true"' : '') .replace('{{placeholder}}', config.placeholder || 'Select...') .replace('{{class}}', config.displayClass || ''); const element = stringToElement(html); // Add data-multiple attribute if in multiple select mode if (config.multiple) { element.setAttribute('data-multiple', 'true'); } return element; }, /** * Renders a single option */ option: ( option: KTSelectOption | HTMLOptionElement, config: KTSelectConfigInterface, ): HTMLElement => { const isHtmlOption = option instanceof HTMLOptionElement; let optionData: Record; if (isHtmlOption) { // If it's a plain HTMLOptionElement, construct data similarly to how KTSelectOption would // This branch might be less common if KTSelectOption instances are always used for rendering. const el = option as HTMLOptionElement; const textContent = el.textContent || ''; optionData = { value: el.value, text: textContent, selected: el.selected, disabled: el.disabled, // This captures original disabled state content: textContent, // Default content to text // Attempt to get custom config for this specific option value if available ...(config.optionsConfig?.[el.value] || {}), }; } else { // If it's a KTSelectOption class instance (from './option') // which should have the getOptionDataForTemplate method. optionData = ( option as import('./option').KTSelectOption ).getOptionDataForTemplate(); } let content = String(optionData?.text || '').trim(); // Default content to option's text if (config.optionTemplate) { // Use the user-provided template string, rendering with the full optionData. // renderTemplateString will replace {{key}} with values from optionData. content = renderTemplateString(config.optionTemplate, optionData); } else { content = String(optionData.text || optionData.content || ''); } // Use the core option template string as the base structure. const baseTemplate = getTemplateStrings(config).option; const optionClasses = [config.optionClass || '']; if (optionData.disabled) { optionClasses.push('disabled'); } // Populate the base template for the
  • attributes. // The actual display content (text or custom HTML) will be set on the inner span later. const html = renderTemplateString(baseTemplate, { ...optionData, // Pass all data for {{value}}, {{text}}, {{selected}}, {{disabled}}, etc. class: optionClasses.join(' ').trim() || '', selected: optionData.selected ? 'aria-selected="true"' : 'aria-selected="false"', disabled: optionData.disabled ? 'aria-disabled="true"' : '', content: content, // This is for the {{content}} placeholder within the option template string itself }); const element = stringToElement(html); // If a custom option template is provided, replace the element's innerHTML with the content. if (config.optionTemplate) { element.innerHTML = content; } // Ensure data-text attribute is set to the original, clean text for searching/filtering element.setAttribute('data-text', String(optionData?.text || '').trim()); return element; }, /** * Renders the search input */ search: (config: KTSelectConfigInterface): HTMLElement => { const html = getTemplateStrings(config) .search.replace( '{{searchPlaceholder}}', config.searchPlaceholder || 'Search...', ) .replace('{{class}}', config.searchClass || ''); return stringToElement(html); }, /** * Renders the no results message */ searchEmpty: (config: KTSelectConfigInterface): HTMLElement => { const html = getTemplateStrings(config).searchEmpty.replace( '{{class}}', config.searchEmptyClass || '', ); let content = config.searchEmpty || 'No results'; if (config.searchEmptyTemplate) { content = renderTemplateString(config.searchEmptyTemplate, { class: config.searchEmptyClass || '', }); const element = stringToElement(html); element.innerHTML = content; // For templates, content can be HTML return element; } else { const element = stringToElement(html); element.textContent = content; // For simple text, use textContent return element; } }, /** * Renders the loading state */ loading: ( config: KTSelectConfigInterface, loadingMessage: string, ): HTMLElement => { const html = getTemplateStrings(config).loading.replace( '{{class}}', config.loadingClass || '', ); const element = stringToElement(html); element.textContent = loadingMessage || 'Loading options...'; return element; }, /** * Renders a tag for multi-select */ tag: ( option: HTMLOptionElement, config: KTSelectConfigInterface, ): HTMLElement => { const template = getTemplateStrings(config).tag; let preparedContent = option.textContent || option.innerText || option.value || ''; // Default content is the option's text if (config.tagTemplate) { let tagTemplateString = config.tagTemplate; const optionValue = option.getAttribute('data-value') || option.value; // Replace all {{varname}} in option.innerHTML with values from _config.optionsConfig Object.entries( ( config.optionsConfig as unknown as Record< string, Record > )?.[optionValue] || {}, ).forEach(([key, val]) => { if ( typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean' ) { tagTemplateString = tagTemplateString.replace( new RegExp(`{{${key}}}`, 'g'), String(val), ); } }); // Render the custom tag template with option data preparedContent = renderTemplateString(tagTemplateString, { title: option.title, id: option.id, class: config.tagClass || '', // This class is for content, not the main tag div // content: option.innerHTML, // Avoid direct innerHTML from option due to potential XSS text: option.innerText || option.textContent || '', value: optionValue, }); } // Append the remove button HTML string to the prepared content preparedContent += getTemplateStrings(config).tagRemoveButton; const html = template // .replace('{{title}}', option.title) // Title is part of preparedContent if using custom template // .replace('{{id}}', option.id) // ID is part of preparedContent if using custom template .replace('{{class}}', config.tagClass || ''); // Class for the main tag div const element = stringToElement(html); element.innerHTML = preparedContent; // Set the fully prepared content (text/HTML + remove button) return element; }, /** * Renders the placeholder for the select */ placeholder: (config: KTSelectConfigInterface): HTMLElement => { const html = getTemplateStrings(config).placeholder.replace( '{{class}}', config.placeholderClass || '', ); let content = config.placeholder || 'Select...'; if (config.placeholderTemplate) { content = renderTemplateString(config.placeholderTemplate, { placeholder: config.placeholder || 'Select...', class: config.placeholderClass || '', }); const element = stringToElement(html); element.innerHTML = content; // For templates, content can be HTML return element; } else { const element = stringToElement(html); element.textContent = content; // For simple text, use textContent return element; } }, selectAll: (config: KTSelectConfigInterface): HTMLElement => { const template = getTemplateStrings(config).selectAll; const element = stringToElement( template.replace('{{text}}', config.selectAllText || 'Select All'), ); return element; }, };