claritykit-svelte
Version:
A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility
219 lines (218 loc) • 6.99 kB
JavaScript
/**
* Svelte actions for ClarityKit components
* Provides reusable DOM interactions and behaviors
*/
import { getThemeManager } from './theme';
import { isBrowser, safelyAccessDOM } from './environment';
/**
* Auto-theme action that applies theme management to an element
* Automatically initializes theme on mount and handles theme changes
*
* @param node - The DOM element to apply theming to
* @param options - Configuration options for theme behavior
* @returns Action object with destroy method
*/
export function autoTheme(node, options = {}) {
const { applyToElement = false, onThemeChange } = options;
const themeManager = getThemeManager();
let unsubscribe = null;
// Initialize theme
const currentTheme = themeManager.getCurrentTheme();
if (applyToElement) {
// Apply theme class to the specific element
node.classList.remove('ck-theme-light', 'ck-theme-dark');
node.classList.add(`ck-theme-${currentTheme}`);
}
// Set up theme change listener
unsubscribe = themeManager.onThemeChange((theme) => {
if (applyToElement) {
node.classList.remove('ck-theme-light', 'ck-theme-dark');
node.classList.add(`ck-theme-${theme}`);
}
if (onThemeChange) {
onThemeChange(theme);
}
});
return {
destroy() {
if (unsubscribe) {
unsubscribe();
}
}
};
}
/**
* Click outside action - triggers callback when clicking outside the element
* Useful for modals, dropdowns, and other overlay components
*
* @param node - The DOM element to watch for outside clicks
* @param callback - Function to call when clicking outside
* @returns Action object with destroy method
*/
export function clickOutside(node, callback) {
function handleClick(event) {
if (!node.contains(event.target)) {
callback();
}
}
if (isBrowser) {
safelyAccessDOM(() => {
document.addEventListener('click', handleClick, true);
});
}
return {
destroy() {
if (isBrowser) {
safelyAccessDOM(() => {
document.removeEventListener('click', handleClick, true);
});
}
}
};
}
/**
* Focus trap action - keeps focus within the element
* Useful for modals and other overlay components that need to manage focus
*
* @param node - The DOM element to trap focus within
* @param options - Configuration options
* @returns Action object with destroy method
*/
export function focusTrap(node, options = {}) {
const { active = true, initialFocus } = options;
let isActive = active;
let focusableElements = [];
function updateFocusableElements() {
focusableElements = Array.from(node.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'));
}
function handleKeyDown(event) {
if (!isActive || event.key !== 'Tab')
return;
updateFocusableElements();
if (focusableElements.length === 0)
return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = safelyAccessDOM(() => document.activeElement);
if (event.shiftKey && activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
else if (!event.shiftKey && activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
function activate() {
if (!isActive)
return;
updateFocusableElements();
// Focus initial element
if (initialFocus) {
const target = typeof initialFocus === 'string'
? node.querySelector(initialFocus)
: initialFocus;
if (target) {
target.focus();
}
}
else if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}
// Set up immediately if active
if (isActive) {
setTimeout(activate, 0); // Use setTimeout to ensure DOM is ready
}
node.addEventListener('keydown', handleKeyDown);
return {
update(newOptions) {
isActive = newOptions.active ?? true;
if (isActive) {
activate();
}
},
destroy() {
node.removeEventListener('keydown', handleKeyDown);
}
};
}
/**
* Auto-resize action for textareas
* Automatically adjusts textarea height based on content
*
* @param node - The textarea element
* @param options - Configuration options
* @returns Action object with update and destroy methods
*/
export function autoResize(node, options = {}) {
const { minHeight = 0, maxHeight = 500 } = options;
function resize() {
node.style.height = 'auto';
const scrollHeight = node.scrollHeight;
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
node.style.height = `${newHeight}px`;
}
// Initial resize
resize();
node.addEventListener('input', resize);
return {
update(newOptions) {
Object.assign(options, newOptions);
resize();
},
destroy() {
node.removeEventListener('input', resize);
}
};
}
/**
* Portal action - renders content in a different DOM location
* Useful for modals, tooltips, and other overlay components
*
* @param node - The element to portal
* @param target - Target element or selector where content should be rendered
* @returns Action object with update and destroy methods
*/
export function portal(node, target = 'body') {
let targetElement = null;
function getTargetElement() {
if (typeof target === 'string') {
if (!isBrowser) {
return null; // Return null on server, portal won't work anyway
}
const element = safelyAccessDOM(() => {
return document.querySelector(target);
});
if (!element) {
throw new Error(`Portal target "${target}" not found`);
}
return element;
}
return target;
}
function mount() {
if (!isBrowser)
return; // Skip on server
targetElement = getTargetElement();
if (targetElement) {
targetElement.appendChild(node);
}
}
function unmount() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
mount();
return {
update(newTarget) {
unmount();
target = newTarget;
mount();
},
destroy() {
unmount();
}
};
}