/**
* 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: `
{{text}}
`,
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;
},
};