Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Modal under Tailwind is hidden when closed, centred over a dark-mode-aware scrim when open, closes on Escape or a backdrop click, returns focus to the trigger, and no longer needs Bootstrap JS (it ignores it, so no `Illegal invocation` and no stuck `modal-open` on body).
- Stepper under Tailwind lays the steps out as a row with the label under each circle.
- ProgressBar takes its fill colour from the theme per variant instead of the Bootstrap `bg-*` names.
- Tooltip under Tailwind puts the bubble classes on a popup element that shows on hover and focus, with no Bootstrap JS.
- Input and Select under Tailwind show the label above the field. Bootstrap keeps its floating label.
- Under Tailwind, Modal no longer uses Bootstrap JS. A `data-bs-toggle="modal"` trigger is not supported there, so use the Modal API. Escape closes only the topmost of stacked modals, body scroll is locked while one is open, and `getInstance()` returns an object with show, hide and toggle.
- The imperative `Tooltip({element})` under Tailwind now shows on hover and focus, not always. The declarative tooltip sets `aria-describedby` on its trigger and Escape dismisses it.
- ProgressBar falls back to the primary fill for a variant the theme does not define.
- Input under Tailwind no longer repeats the label as the placeholder, and its icon centres on the field. A vertical Stepper puts the label beside the circle.
- Skeleton renders the requested number of lines when `lines`, `rows` or `cols` arrive as strings such as `"3"`, and caps each at 100.

## [2.3.0] - 2026-10-09

Dark mode, and a Tailwind theme that reads the `--rnx-*` tokens. `npm test`
Expand Down
7 changes: 5 additions & 2 deletions components/Input/Input.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,23 @@ export function Input({
label ? resolvePartClasses('input', 'floatingWrapper') : ''
);

const labelClass = resolvePartClasses('input', 'label');
const labelClass = cn(resolvePartClasses('input', 'label'), label ? resolvePartClasses('input', 'floatingLabel') : '');
const fieldClass = resolvePartClasses('input', 'field');
const iconClass = resolvePartClasses('input', 'icon');
const helpClass = resolvePartClasses('input', 'help') || resolvePartClasses('formgroup', 'help');
const errorClass = resolvePartClasses('input', 'error') || resolvePartClasses('formgroup', 'error');

const template = () => `
<div class="${wrapperClass}">
${fieldClass ? `<div class="${fieldClass}">` : ''}
${icon ? `<span class="${iconClass}" aria-hidden="true"><i class="${escapeHtml(resolveIcon(icon))}"></i></span>` : ''}
<input
class="${inputClass}"
id="${escapeHtml(finalId)}"
type="${escapeHtml(type)}"
name="${escapeHtml(name)}"
value="${escapeHtml(value)}"
placeholder="${escapeHtml(placeholder || (label ? label : ''))}"
placeholder="${escapeHtml(placeholder || (label && !fieldClass ? label : ''))}"
${isRequired ? 'required aria-required="true"' : ''}
${isDisabled ? 'disabled' : ''}
${isReadonly ? 'readonly' : ''}
Expand All @@ -116,6 +118,7 @@ export function Input({
data-rnx-ignore="true"
${attrs}
>
${fieldClass ? '</div>' : ''}
${label ? `<label for="${escapeHtml(finalId)}" class="${labelClass}">${escapeHtml(label)}</label>` : ''}
${help ? `<div id="${escapeHtml(helpId)}" class="${helpClass}">${escapeHtml(help)}</div>` : ''}
${error ? `<div id="${escapeHtml(errorId)}" class="${errorClass}">${escapeHtml(error)}</div>` : ''}
Expand Down
138 changes: 132 additions & 6 deletions components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,133 @@ import { createComponent } from '../../utils/createComponent.js';
import { bs } from '../../utils/bootstrap.js';
import { createFocusTrap, announce } from '../../utils/a11y.js';
import { sanitizeHtml, escapeHtml } from '../../utils/security.js';
import { resolveClasses, resolvePartClasses } from '../../utils/ThemeProvider.js';
import themeProvider, { resolveClasses, resolvePartClasses } from '../../utils/ThemeProvider.js';
import { cn } from '../../utils/classNames.js';

let modalIdCounter = 0;

const themeState = (component, state) => {
const theme = themeProvider.getTheme();
return (theme && theme.components[component] && theme.components[component].states &&
theme.components[component].states[state]) || '';
};

const tokens = (str) => String(str || '').split(/\s+/).filter(Boolean);

// Open standalone modals, topmost last. Escape closes only the top one.
const openModals = [];
let savedBodyOverflow = null;

const lockScroll = () => {
if (openModals.length === 1 && document.body) {
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
};

const unlockScroll = () => {
if (openModals.length === 0 && savedBodyOverflow !== null && document.body) {
document.body.style.overflow = savedBodyOverflow;
savedBodyOverflow = null;
}
};

const removeFromStack = (entry) => {
const i = openModals.indexOf(entry);
if (i !== -1) openModals.splice(i, 1);
unlockScroll();
};

/**
* Show/hide behaviour for themes without Bootstrap JS.
* Fires shown.bs.modal / hidden.bs.modal so existing listeners keep working.
*/
function setupStandaloneModal(el, { title, dismissable, overlayClass }) {
const dialog = el.querySelector('[data-ref="dialog"]');
const focusTrap = dialog ? createFocusTrap(dialog) : null;
const openTokens = tokens(themeState('modal', 'open'));
const closedTokens = tokens(themeState('modal', 'closed'));
const entry = { el };
let isOpen = false;
let trigger = null;

const overlay = document.createElement('div');
overlay.className = overlayClass || '';
overlay.setAttribute('data-ref', 'overlay');
overlay.setAttribute('aria-hidden', 'true');
el.insertBefore(overlay, el.firstChild);

const apply = () => {
openTokens.forEach(t => el.classList.toggle(t, isOpen));
closedTokens.forEach(t => el.classList.toggle(t, !isOpen));
el.style.display = isOpen ? '' : 'none';
el.setAttribute('aria-hidden', isOpen ? 'false' : 'true');
};

const show = () => {
if (isOpen) return;
const active = document.activeElement;
trigger = active && !openModals.some(m => m.el.contains(active)) ? active : null;
isOpen = true;
openModals.push(entry);
lockScroll();
apply();
if (focusTrap) focusTrap.activate();
if (dialog && !dialog.contains(document.activeElement)) el.focus();
if (title) announce(`${title} dialog opened`, 'polite');
el.dispatchEvent(new Event('shown.bs.modal'));
};

const hide = () => {
if (!isOpen) return;
isOpen = false;
removeFromStack(entry);
apply();
if (focusTrap) focusTrap.deactivate(false);
const next = openModals[openModals.length - 1];
if (trigger && typeof trigger.focus === 'function' && document.contains(trigger)) trigger.focus();
else if (next) next.el.focus();
trigger = null;
if (title) announce(`${title} dialog closed`, 'polite');
el.dispatchEvent(new Event('hidden.bs.modal'));
};

const onClick = (e) => {
const t = e.target;
if (dismissable && (t === el || t === overlay)) return hide();
if (t.closest && t.closest('[data-bs-dismiss="modal"]') && el.contains(t.closest('[data-bs-dismiss="modal"]'))) hide();
};

const detach = () => {
document.removeEventListener('keydown', onDocKeydown);
removeFromStack(entry);
isOpen = false;
};

function onDocKeydown(e) {
if (e.key !== 'Escape') return;
if (!el.isConnected) return detach();
if (isOpen && dismissable && openModals[openModals.length - 1] === entry) hide();
}

el.addEventListener('click', onClick);
document.addEventListener('keydown', onDocKeydown);

const instance = { show, hide, toggle: () => (isOpen ? hide() : show()), dispose: () => {} };
el.show = show;
el.hide = hide;
el.toggle = instance.toggle;
el.getInstance = () => instance;
apply();

return () => {
el.removeEventListener('click', onClick);
detach();
if (focusTrap && focusTrap.isActive()) focusTrap.deactivate();
overlay.remove();
};
}

/**
* Modal Component - CSS Framework Agnostic
*
Expand Down Expand Up @@ -53,6 +175,7 @@ export function Modal({ id = '', title = '', dismissable = true, children = [],
const headerClass = resolvePartClasses('modal', 'header') || 'modal-header';
const titleClass = resolvePartClasses('modal', 'title') || 'modal-title';
const closeClass = resolvePartClasses('modal', 'close') || 'btn-close';
const closeGlyph = resolvePartClasses('modal', 'close') ? '<span aria-hidden="true">×</span>' : '';
const bodyClass = resolvePartClasses('modal', 'body') || 'modal-body';
const footerClass = resolvePartClasses('modal', 'footer') || 'modal-footer';

Expand All @@ -64,7 +187,7 @@ export function Modal({ id = '', title = '', dismissable = true, children = [],
${title ? `
<div class="${headerClass}">
<h5 class="${titleClass}" id="${escapeHtml(modalId)}-label">${escapeHtml(title)}</h5>
${dismissable ? `<button type="button" class="${closeClass}" data-bs-dismiss="modal" aria-label="Close"></button>` : ''}
${dismissable ? `<button type="button" class="${closeClass}" data-bs-dismiss="modal" aria-label="Close">${closeGlyph}</button>` : ''}
</div>
` : ''}

Expand All @@ -85,9 +208,12 @@ export function Modal({ id = '', title = '', dismissable = true, children = [],
const component = createComponent(template, { id: modalId, title, children: mainContent, footer: footerContent });

component.useEffect((el) => {
// Check if Bootstrap JS is available
// Themes that declare a modal open state (Tailwind) drive themselves, even when Bootstrap JS is on the page
if (themeState('modal', 'open')) {
return setupStandaloneModal(el, { title, dismissable, overlayClass: resolvePartClasses('modal', 'overlay') });
}

if (!bs.isAvailable() || !bs.Modal) {
// Graceful fallback or no-op
return;
}

Expand All @@ -98,7 +224,7 @@ export function Modal({ id = '', title = '', dismissable = true, children = [],

// Initialize Bootstrap Modal
// We try to get existing instance or create new one
let modalInstance = bs.Modal.getInstance(el);
let modalInstance = typeof bs.Modal.getInstance === 'function' ? bs.Modal.getInstance(el) : null;
if (!modalInstance) {
modalInstance = new bs.Modal(el, dismissable ? {} : { backdrop: 'static', keyboard: false });
}
Expand Down Expand Up @@ -175,7 +301,7 @@ export function Modal({ id = '', title = '', dismissable = true, children = [],

// If the modal is still open, hide it before disposing
// This prevents backdrop from getting stuck
if (modalInstance) {
if (modalInstance && typeof modalInstance.dispose === 'function') {
// We carefully check if the element is still in DOM to avoid errors
modalInstance.dispose();
}
Expand Down
4 changes: 2 additions & 2 deletions components/ProgressBar/ProgressBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { createComponent } from '../../utils/createComponent.js';
import { resolveClasses, resolvePartClasses } from '../../utils/ThemeProvider.js';
import { resolveClasses, resolvePartClasses, resolveUtility } from '../../utils/ThemeProvider.js';
import { cn } from '../../utils/classNames.js';
import { escapeHtml } from '../../utils/security.js';

Expand Down Expand Up @@ -65,7 +65,7 @@ export function ProgressBar({
resolvePartClasses('progressbar', 'bar'),
'progress-bar',
`progress-bar-${variant}`,
`bg-${variant}`, // Color utility
resolveUtility('background', variant) || resolveUtility('background', 'primary') || `bg-${variant}`,
striped ? 'striped progress-bar-striped bg-gradient-to-r' : '',
animated && striped ? 'animated progress-bar-animated animate-pulse' : '',
indeterminate ? 'indeterminate progress-indeterminate' : ''
Expand Down
2 changes: 1 addition & 1 deletion components/Select/Select.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function Select({
);

const wrapperClass = label ? resolvePartClasses('input', 'floatingWrapper') : '';
const labelClass = resolvePartClasses('input', 'label');
const labelClass = cn(resolvePartClasses('input', 'label'), label ? resolvePartClasses('input', 'floatingLabel') : '');
const helpClass = resolvePartClasses('input', 'help') || resolvePartClasses('formgroup', 'help');
const errorClass = resolvePartClasses('input', 'error') || resolvePartClasses('formgroup', 'error');

Expand Down
9 changes: 9 additions & 0 deletions components/Skeleton/Skeleton.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { escapeAttribute } from '../../utils/security.js';
import { resolveClasses, resolvePartClasses } from '../../utils/ThemeProvider.js';
import { cn } from '../../utils/classNames.js';

const MAX_COUNT = 100;
const SR_ONLY_STYLE = 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;';

/**
Expand Down Expand Up @@ -49,6 +50,14 @@ export function Skeleton({
animation = 'wave',
className = ''
} = {}) {
const count = (n, fallback) => {
const v = Math.floor(Number(n));
return Number.isFinite(v) && v >= 0 ? Math.min(v, MAX_COUNT) : fallback;
};
lines = count(lines, 3);
rows = count(rows, 5);
cols = count(cols, 4);

// Validate variant
const validVariants = ['text', 'circle', 'rectangle', 'card', 'table'];
if (!validVariants.includes(variant)) {
Expand Down
20 changes: 10 additions & 10 deletions components/Stepper/Stepper.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const Stepper = (props = {}) => {

let activeStep = currentStep;

const renderStepHeader = (step, index) => {
const renderStepHeader = (step, index, vertical = false) => {
const circlePart = resolvePartClasses('stepper', 'circle');
const labelPart = resolvePartClasses('stepper', 'label');
const isActive = index === activeStep;
Expand All @@ -50,7 +50,7 @@ export const Stepper = (props = {}) => {
: themeState('stepper', 'pending');

return `
<div class="stepper-step-header">
<div class="${cn((vertical && resolvePartClasses('stepper', 'headerVertical')) || resolvePartClasses('stepper', 'header'), 'stepper-step-header')}">
<div class="${cn(circlePart, circleState, 'stepper-step-indicator')}" aria-hidden="true">
${isCompleted ? '✓' : index + 1}
</div>
Expand All @@ -59,8 +59,8 @@ export const Stepper = (props = {}) => {
`;
};

const stepAttrs = (index) => {
const stepPart = resolvePartClasses('stepper', 'step');
const stepAttrs = (index, vertical = false) => {
const stepPart = (vertical && resolvePartClasses('stepper', 'stepVertical')) || resolvePartClasses('stepper', 'step');
const isActive = index === activeStep;
const isCompleted = index < activeStep;
return `class="${cn(stepPart, 'stepper-step', isActive ? 'active' : '', isCompleted ? 'completed' : '')}" data-step="${index}"${isActive ? ' aria-current="step"' : ''}`;
Expand All @@ -78,7 +78,7 @@ export const Stepper = (props = {}) => {
if (orientation === 'horizontal') {
return `
<div class="${stepperClass}" data-ref="stepper">
<ol class="stepper-steps" style="list-style: none; margin: 0; padding: 0;">
<ol class="${cn(resolvePartClasses('stepper', 'list'), 'stepper-steps')}" style="list-style: none; margin: 0; padding: 0;">
${steps.map((step, index) => `
<li ${stepAttrs(index)}>
${renderStepHeader(step, index)}
Expand All @@ -95,14 +95,14 @@ export const Stepper = (props = {}) => {

return `
<div class="${stepperClass}" data-ref="stepper">
<ol class="stepper-vertical" style="list-style: none; margin: 0; padding: 0;">
<ol class="${cn(resolvePartClasses('stepper', 'listVertical'), 'stepper-vertical')}" style="list-style: none; margin: 0; padding: 0;">
${steps.map((step, index) => `
<li ${stepAttrs(index)}>
${renderStepHeader(step, index)}
<div class="stepper-content">
<li ${stepAttrs(index, true)}>
${renderStepHeader(step, index, true)}
<div class="${cn(resolvePartClasses('stepper', 'contentVertical'), 'stepper-content')}">
${step.content ? sanitizeHtml(step.content) : ''}
</div>
${index < steps.length - 1 ? `<div class="${cn(connectorPart, 'stepper-connector-vertical')}" aria-hidden="true"></div>` : ''}
${index < steps.length - 1 ? `<div class="${cn(resolvePartClasses('stepper', 'connectorVertical') || connectorPart, 'stepper-connector-vertical')}" aria-hidden="true"></div>` : ''}
</li>
`).join('')}
</ol>
Expand Down
Loading
Loading