mirror of
https://github.com/blackboxprogramming/BlackRoad-Operating-System.git
synced 2026-03-18 03:33:59 -05:00
feat(blackroad-os): Phase 3 - Component Library Polish
Upgraded component library with accessibility-first approach: **New Components:** - LoadingState() - async operation indicator with spinner - ErrorState() - error display with retry button **Enhanced Existing Components:** - Comprehensive JSDoc for all 15 components - ARIA attributes (role, aria-label, aria-live, etc.) - Keyboard navigation (Tab, Enter, Space, Arrow keys) - Input validation (Badge type validation, Grid child validation) - Table custom render functions and empty state handling - List keyboard accessibility for clickable items - Tabs full keyboard navigation (Arrows, Home, End) - Button icon support and disabled state - SidebarLayout with complementary/main roles **Improvements:** - All interactive components are keyboard-accessible - Screen reader support via ARIA - Consistent JSDoc format with @param, @returns, @example - Examples for every component - Philosophy documentation at file top - Logging on component library initialization Components now form a true design system that agents and humans can confidently use to build accessible apps.
This commit is contained in:
@@ -1,19 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Component Library
|
* BlackRoad OS Component Library
|
||||||
* Simple, reusable UI components for building app interfaces
|
* Reusable UI primitives for building accessible app interfaces
|
||||||
* Pure vanilla JS - no frameworks required
|
*
|
||||||
* TODO: Extend with more components as needed
|
* Philosophy:
|
||||||
|
* - Vanilla JS only (no framework dependencies)
|
||||||
|
* - Accessibility-first (ARIA attributes, keyboard navigation)
|
||||||
|
* - Predictable API (consistent naming and options)
|
||||||
|
* - Composable (components can contain other components)
|
||||||
|
* - Theme-aware (uses CSS variables from styles.css)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const card = Components.Card({ title: 'My Card', content: '...' });
|
||||||
|
* document.body.appendChild(card);
|
||||||
|
*
|
||||||
|
* All components return HTMLElement that can be:
|
||||||
|
* - Appended to DOM
|
||||||
|
* - Passed to window.OS.createWindow({ content: ... })
|
||||||
|
* - Composed with other components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const Components = {
|
const Components = {
|
||||||
/**
|
/**
|
||||||
* Create a Card component
|
* Create a Card component
|
||||||
* @param {Object} options - { title, subtitle, content, footer }
|
* A container with optional header, body, and footer sections
|
||||||
* @returns {HTMLElement}
|
*
|
||||||
|
* @param {Object} options - Card configuration
|
||||||
|
* @param {string} [options.title] - Card title
|
||||||
|
* @param {string} [options.subtitle] - Card subtitle
|
||||||
|
* @param {HTMLElement|string} [options.content] - Card body content
|
||||||
|
* @param {HTMLElement|string} [options.footer] - Card footer content
|
||||||
|
* @param {HTMLElement} [options.headerActions] - Action buttons for header
|
||||||
|
* @returns {HTMLElement} Card element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const card = Components.Card({
|
||||||
|
* title: 'System Status',
|
||||||
|
* subtitle: 'Last updated: 2 minutes ago',
|
||||||
|
* content: 'All systems operational'
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
Card(options = {}) {
|
Card(options = {}) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card';
|
card.className = 'card';
|
||||||
|
card.setAttribute('role', 'article');
|
||||||
|
|
||||||
if (options.title) {
|
if (options.title) {
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
@@ -34,7 +63,10 @@ const Components = {
|
|||||||
|
|
||||||
header.appendChild(titleDiv);
|
header.appendChild(titleDiv);
|
||||||
if (options.headerActions) {
|
if (options.headerActions) {
|
||||||
header.appendChild(options.headerActions);
|
const actionsWrapper = document.createElement('div');
|
||||||
|
actionsWrapper.className = 'card-header-actions';
|
||||||
|
actionsWrapper.appendChild(options.headerActions);
|
||||||
|
header.appendChild(actionsWrapper);
|
||||||
}
|
}
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
}
|
}
|
||||||
@@ -44,7 +76,7 @@ const Components = {
|
|||||||
body.className = 'card-body';
|
body.className = 'card-body';
|
||||||
if (typeof options.content === 'string') {
|
if (typeof options.content === 'string') {
|
||||||
body.innerHTML = options.content;
|
body.innerHTML = options.content;
|
||||||
} else {
|
} else if (options.content instanceof HTMLElement) {
|
||||||
body.appendChild(options.content);
|
body.appendChild(options.content);
|
||||||
}
|
}
|
||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
@@ -55,7 +87,7 @@ const Components = {
|
|||||||
footer.className = 'card-footer';
|
footer.className = 'card-footer';
|
||||||
if (typeof options.footer === 'string') {
|
if (typeof options.footer === 'string') {
|
||||||
footer.innerHTML = options.footer;
|
footer.innerHTML = options.footer;
|
||||||
} else {
|
} else if (options.footer instanceof HTMLElement) {
|
||||||
footer.appendChild(options.footer);
|
footer.appendChild(options.footer);
|
||||||
}
|
}
|
||||||
card.appendChild(footer);
|
card.appendChild(footer);
|
||||||
@@ -66,29 +98,63 @@ const Components = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Badge component
|
* Create a Badge component
|
||||||
|
* Small status indicator with color coding
|
||||||
|
*
|
||||||
* @param {string} text - Badge text
|
* @param {string} text - Badge text
|
||||||
* @param {string} type - success | warning | error | info | neutral
|
* @param {string} [type='neutral'] - Badge type (success, warning, error, info, neutral)
|
||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement} Badge element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const badge = Components.Badge('Online', 'success');
|
||||||
*/
|
*/
|
||||||
Badge(text, type = 'neutral') {
|
Badge(text, type = 'neutral') {
|
||||||
|
const validTypes = ['success', 'warning', 'error', 'info', 'neutral'];
|
||||||
|
const safeType = validTypes.includes(type) ? type : 'neutral';
|
||||||
|
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = `badge ${type}`;
|
badge.className = `badge ${safeType}`;
|
||||||
badge.textContent = text;
|
badge.textContent = text;
|
||||||
|
badge.setAttribute('role', 'status');
|
||||||
|
badge.setAttribute('aria-label', `Status: ${text}`);
|
||||||
|
|
||||||
return badge;
|
return badge;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Table component
|
* Create a Table component
|
||||||
* @param {Array} columns - [{ key, label }]
|
* Accessible data table with automatic header/body structure
|
||||||
* @param {Array} data - Array of row objects
|
*
|
||||||
* @returns {HTMLElement}
|
* @param {Array<Object>} columns - Column definitions [{ key, label, render }]
|
||||||
|
* @param {Array<Object>} data - Row data objects
|
||||||
|
* @param {Object} [options] - Table options
|
||||||
|
* @param {string} [options.caption] - Table caption for accessibility
|
||||||
|
* @param {boolean} [options.striped=false] - Alternate row colors
|
||||||
|
* @returns {HTMLElement} Table container element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const table = Components.Table(
|
||||||
|
* [{ key: 'name', label: 'Name' }, { key: 'status', label: 'Status' }],
|
||||||
|
* [{ name: 'Alice', status: 'Active' }, { name: 'Bob', status: 'Inactive' }],
|
||||||
|
* { caption: 'User list' }
|
||||||
|
* );
|
||||||
*/
|
*/
|
||||||
Table(columns, data) {
|
Table(columns, data, options = {}) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'table-container';
|
container.className = 'table-container';
|
||||||
|
|
||||||
const table = document.createElement('table');
|
const table = document.createElement('table');
|
||||||
table.className = 'table';
|
table.className = 'table';
|
||||||
|
if (options.striped) {
|
||||||
|
table.classList.add('table-striped');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add caption for accessibility
|
||||||
|
if (options.caption) {
|
||||||
|
const caption = document.createElement('caption');
|
||||||
|
caption.textContent = options.caption;
|
||||||
|
caption.className = 'sr-only'; // Screen reader only
|
||||||
|
table.appendChild(caption);
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
const thead = document.createElement('thead');
|
const thead = document.createElement('thead');
|
||||||
@@ -96,6 +162,7 @@ const Components = {
|
|||||||
columns.forEach(col => {
|
columns.forEach(col => {
|
||||||
const th = document.createElement('th');
|
const th = document.createElement('th');
|
||||||
th.textContent = col.label;
|
th.textContent = col.label;
|
||||||
|
th.setAttribute('scope', 'col');
|
||||||
headerRow.appendChild(th);
|
headerRow.appendChild(th);
|
||||||
});
|
});
|
||||||
thead.appendChild(headerRow);
|
thead.appendChild(headerRow);
|
||||||
@@ -103,20 +170,39 @@ const Components = {
|
|||||||
|
|
||||||
// Body
|
// Body
|
||||||
const tbody = document.createElement('tbody');
|
const tbody = document.createElement('tbody');
|
||||||
|
if (data.length === 0) {
|
||||||
|
const emptyRow = document.createElement('tr');
|
||||||
|
const emptyCell = document.createElement('td');
|
||||||
|
emptyCell.setAttribute('colspan', columns.length);
|
||||||
|
emptyCell.className = 'table-empty';
|
||||||
|
emptyCell.textContent = 'No data available';
|
||||||
|
emptyRow.appendChild(emptyCell);
|
||||||
|
tbody.appendChild(emptyRow);
|
||||||
|
} else {
|
||||||
data.forEach(row => {
|
data.forEach(row => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
columns.forEach(col => {
|
columns.forEach(col => {
|
||||||
const td = document.createElement('td');
|
const td = document.createElement('td');
|
||||||
const value = row[col.key];
|
const value = row[col.key];
|
||||||
if (value instanceof HTMLElement) {
|
|
||||||
|
// Support custom render function
|
||||||
|
if (col.render && typeof col.render === 'function') {
|
||||||
|
const rendered = col.render(value, row);
|
||||||
|
if (rendered instanceof HTMLElement) {
|
||||||
|
td.appendChild(rendered);
|
||||||
|
} else {
|
||||||
|
td.innerHTML = rendered;
|
||||||
|
}
|
||||||
|
} else if (value instanceof HTMLElement) {
|
||||||
td.appendChild(value);
|
td.appendChild(value);
|
||||||
} else {
|
} else {
|
||||||
td.innerHTML = value || '-';
|
td.innerHTML = value !== undefined && value !== null ? value : '-';
|
||||||
}
|
}
|
||||||
tr.appendChild(td);
|
tr.appendChild(td);
|
||||||
});
|
});
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
table.appendChild(tbody);
|
table.appendChild(tbody);
|
||||||
|
|
||||||
container.appendChild(table);
|
container.appendChild(table);
|
||||||
@@ -125,21 +211,37 @@ const Components = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a List component
|
* Create a List component
|
||||||
* @param {Array} items - [{ icon, title, subtitle, actions }]
|
* Accessible list with icon, title, subtitle, and actions
|
||||||
* @returns {HTMLElement}
|
*
|
||||||
|
* @param {Array<Object>} items - List items
|
||||||
|
* @param {string} [items[].icon] - Icon HTML or emoji
|
||||||
|
* @param {string} items[].title - Item title
|
||||||
|
* @param {string} [items[].subtitle] - Item subtitle
|
||||||
|
* @param {HTMLElement} [items[].actions] - Action buttons
|
||||||
|
* @param {Function} [items[].onClick] - Click handler
|
||||||
|
* @returns {HTMLElement} List element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const list = Components.List([
|
||||||
|
* { icon: '📁', title: 'Documents', subtitle: '45 files' },
|
||||||
|
* { icon: '🖼️', title: 'Images', subtitle: '128 files' }
|
||||||
|
* ]);
|
||||||
*/
|
*/
|
||||||
List(items) {
|
List(items) {
|
||||||
const list = document.createElement('ul');
|
const list = document.createElement('ul');
|
||||||
list.className = 'list';
|
list.className = 'list';
|
||||||
|
list.setAttribute('role', 'list');
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'list-item';
|
li.className = 'list-item';
|
||||||
|
li.setAttribute('role', 'listitem');
|
||||||
|
|
||||||
if (item.icon) {
|
if (item.icon) {
|
||||||
const icon = document.createElement('div');
|
const icon = document.createElement('div');
|
||||||
icon.className = 'list-item-icon';
|
icon.className = 'list-item-icon';
|
||||||
icon.innerHTML = item.icon;
|
icon.innerHTML = item.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
li.appendChild(icon);
|
li.appendChild(icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +270,18 @@ const Components = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.onClick) {
|
if (item.onClick) {
|
||||||
li.style.cursor = 'pointer';
|
li.classList.add('list-item-clickable');
|
||||||
|
li.setAttribute('role', 'button');
|
||||||
|
li.setAttribute('tabindex', '0');
|
||||||
li.addEventListener('click', item.onClick);
|
li.addEventListener('click', item.onClick);
|
||||||
|
|
||||||
|
// Keyboard support
|
||||||
|
li.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
item.onClick(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
@@ -179,13 +291,36 @@ const Components = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Stats Box
|
* Create a Stats Box component
|
||||||
* @param {Object} options - { value, label, change }
|
* Display a metric with optional change indicator
|
||||||
* @returns {HTMLElement}
|
*
|
||||||
|
* @param {Object} options - Stats box configuration
|
||||||
|
* @param {string|number} options.value - Main metric value
|
||||||
|
* @param {string} options.label - Metric label
|
||||||
|
* @param {number} [options.change] - Percent change (positive or negative)
|
||||||
|
* @param {string} [options.icon] - Optional icon
|
||||||
|
* @returns {HTMLElement} Stats box element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const stats = Components.StatsBox({
|
||||||
|
* value: '42',
|
||||||
|
* label: 'Active Users',
|
||||||
|
* change: 12.5
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
StatsBox(options) {
|
StatsBox(options) {
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'stats-box';
|
box.className = 'stats-box';
|
||||||
|
box.setAttribute('role', 'figure');
|
||||||
|
box.setAttribute('aria-label', `${options.label}: ${options.value}`);
|
||||||
|
|
||||||
|
if (options.icon) {
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'stats-icon';
|
||||||
|
icon.innerHTML = options.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
box.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
const value = document.createElement('div');
|
const value = document.createElement('div');
|
||||||
value.className = 'stats-value';
|
value.className = 'stats-value';
|
||||||
@@ -201,6 +336,7 @@ const Components = {
|
|||||||
const change = document.createElement('div');
|
const change = document.createElement('div');
|
||||||
change.className = `stats-change ${options.change >= 0 ? 'positive' : 'negative'}`;
|
change.className = `stats-change ${options.change >= 0 ? 'positive' : 'negative'}`;
|
||||||
change.textContent = `${options.change >= 0 ? '+' : ''}${options.change}%`;
|
change.textContent = `${options.change >= 0 ? '+' : ''}${options.change}%`;
|
||||||
|
change.setAttribute('aria-label', `${options.change >= 0 ? 'Up' : 'Down'} ${Math.abs(options.change)} percent`);
|
||||||
box.appendChild(change);
|
box.appendChild(change);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,40 +344,77 @@ const Components = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Grid container
|
* Create a responsive Grid container
|
||||||
* @param {number} columns - Number of columns (2, 3, 4, or 'auto')
|
*
|
||||||
* @param {Array} children - Array of HTMLElements
|
* @param {number|string} columns - Number of columns (2, 3, 4, 'auto')
|
||||||
* @returns {HTMLElement}
|
* @param {Array<HTMLElement>} children - Child elements
|
||||||
|
* @returns {HTMLElement} Grid container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = Components.Grid(3, [card1, card2, card3]);
|
||||||
*/
|
*/
|
||||||
Grid(columns, children) {
|
Grid(columns, children) {
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = `grid grid-${columns}`;
|
grid.className = `grid grid-${columns}`;
|
||||||
children.forEach(child => grid.appendChild(child));
|
grid.setAttribute('role', 'group');
|
||||||
|
children.forEach(child => {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
grid.appendChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
return grid;
|
return grid;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Graph Placeholder
|
* Create a Graph/Chart placeholder
|
||||||
* @param {string} label - Placeholder text
|
* For indicating where charts will be rendered (e.g., with Chart.js)
|
||||||
* @returns {HTMLElement}
|
*
|
||||||
|
* @param {string} [label='Chart Visualization'] - Placeholder text
|
||||||
|
* @returns {HTMLElement} Placeholder element
|
||||||
*/
|
*/
|
||||||
GraphPlaceholder(label = 'Chart Visualization') {
|
GraphPlaceholder(label = 'Chart Visualization') {
|
||||||
const placeholder = document.createElement('div');
|
const placeholder = document.createElement('div');
|
||||||
placeholder.className = 'graph-placeholder';
|
placeholder.className = 'graph-placeholder';
|
||||||
placeholder.textContent = label;
|
placeholder.textContent = label;
|
||||||
|
placeholder.setAttribute('role', 'img');
|
||||||
|
placeholder.setAttribute('aria-label', `Placeholder for ${label}`);
|
||||||
return placeholder;
|
return placeholder;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Button
|
* Create a Button component
|
||||||
|
*
|
||||||
* @param {string} text - Button text
|
* @param {string} text - Button text
|
||||||
* @param {Object} options - { type: 'primary'|'danger', size: 'small', onClick }
|
* @param {Object} [options] - Button options
|
||||||
* @returns {HTMLElement}
|
* @param {string} [options.type] - Button type (primary, danger, secondary)
|
||||||
|
* @param {string} [options.size] - Button size (small, medium, large)
|
||||||
|
* @param {Function} [options.onClick] - Click handler
|
||||||
|
* @param {boolean} [options.disabled=false] - Disabled state
|
||||||
|
* @param {string} [options.icon] - Optional icon
|
||||||
|
* @param {string} [options.ariaLabel] - Custom ARIA label
|
||||||
|
* @returns {HTMLElement} Button element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const btn = Components.Button('Save', {
|
||||||
|
* type: 'primary',
|
||||||
|
* onClick: () => console.log('Saved!')
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
Button(text, options = {}) {
|
Button(text, options = {}) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'btn';
|
btn.className = 'btn';
|
||||||
btn.textContent = text;
|
|
||||||
|
if (options.icon) {
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'btn-icon';
|
||||||
|
icon.innerHTML = options.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
btn.appendChild(icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textSpan = document.createElement('span');
|
||||||
|
textSpan.textContent = text;
|
||||||
|
btn.appendChild(textSpan);
|
||||||
|
|
||||||
if (options.type) {
|
if (options.type) {
|
||||||
btn.classList.add(options.type);
|
btn.classList.add(options.type);
|
||||||
@@ -251,26 +424,50 @@ const Components = {
|
|||||||
btn.classList.add(options.size);
|
btn.classList.add(options.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.disabled) {
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.onClick) {
|
if (options.onClick) {
|
||||||
btn.addEventListener('click', options.onClick);
|
btn.addEventListener('click', options.onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.ariaLabel) {
|
||||||
|
btn.setAttribute('aria-label', options.ariaLabel);
|
||||||
|
}
|
||||||
|
|
||||||
return btn;
|
return btn;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an Empty State
|
* Create an Empty State component
|
||||||
* @param {Object} options - { icon, title, text }
|
* Displayed when no data is available
|
||||||
* @returns {HTMLElement}
|
*
|
||||||
|
* @param {Object} options - Empty state configuration
|
||||||
|
* @param {string} [options.icon] - Icon or emoji
|
||||||
|
* @param {string} [options.title] - Empty state title
|
||||||
|
* @param {string} [options.text] - Empty state description
|
||||||
|
* @param {HTMLElement} [options.action] - Optional action button
|
||||||
|
* @returns {HTMLElement} Empty state container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const empty = Components.EmptyState({
|
||||||
|
* icon: '📭',
|
||||||
|
* title: 'No messages',
|
||||||
|
* text: 'Your inbox is empty'
|
||||||
|
* });
|
||||||
*/
|
*/
|
||||||
EmptyState(options) {
|
EmptyState(options) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'empty-state';
|
container.className = 'empty-state';
|
||||||
|
container.setAttribute('role', 'status');
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
if (options.icon) {
|
if (options.icon) {
|
||||||
const icon = document.createElement('div');
|
const icon = document.createElement('div');
|
||||||
icon.className = 'empty-state-icon';
|
icon.className = 'empty-state-icon';
|
||||||
icon.textContent = options.icon;
|
icon.textContent = options.icon;
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
container.appendChild(icon);
|
container.appendChild(icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,47 +485,164 @@ const Components = {
|
|||||||
container.appendChild(text);
|
container.appendChild(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.action) {
|
||||||
|
const actionWrapper = document.createElement('div');
|
||||||
|
actionWrapper.className = 'empty-state-action';
|
||||||
|
actionWrapper.appendChild(options.action);
|
||||||
|
container.appendChild(actionWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Loading State component
|
||||||
|
* Displayed during async operations
|
||||||
|
*
|
||||||
|
* @param {string} [message='Loading...'] - Loading message
|
||||||
|
* @returns {HTMLElement} Loading state container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const loading = Components.LoadingState('Fetching data...');
|
||||||
|
*/
|
||||||
|
LoadingState(message = 'Loading...') {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'loading-state';
|
||||||
|
container.setAttribute('role', 'status');
|
||||||
|
container.setAttribute('aria-live', 'polite');
|
||||||
|
container.setAttribute('aria-busy', 'true');
|
||||||
|
|
||||||
|
const spinner = this.Spinner();
|
||||||
|
container.appendChild(spinner);
|
||||||
|
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.className = 'loading-state-text';
|
||||||
|
text.textContent = message;
|
||||||
|
container.appendChild(text);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Error State component
|
||||||
|
* Displayed when operations fail
|
||||||
|
*
|
||||||
|
* @param {Object} options - Error state configuration
|
||||||
|
* @param {string} [options.title='Error'] - Error title
|
||||||
|
* @param {string} options.message - Error message
|
||||||
|
* @param {Function} [options.onRetry] - Retry callback
|
||||||
|
* @returns {HTMLElement} Error state container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const error = Components.ErrorState({
|
||||||
|
* title: 'Failed to load',
|
||||||
|
* message: 'Could not connect to server',
|
||||||
|
* onRetry: () => fetchData()
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
ErrorState(options) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'error-state';
|
||||||
|
container.setAttribute('role', 'alert');
|
||||||
|
container.setAttribute('aria-live', 'assertive');
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'error-state-icon';
|
||||||
|
icon.textContent = '⚠️';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
container.appendChild(icon);
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'error-state-title';
|
||||||
|
title.textContent = options.title || 'Error';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const message = document.createElement('div');
|
||||||
|
message.className = 'error-state-message';
|
||||||
|
message.textContent = options.message;
|
||||||
|
container.appendChild(message);
|
||||||
|
|
||||||
|
if (options.onRetry) {
|
||||||
|
const retryBtn = this.Button('Retry', {
|
||||||
|
type: 'primary',
|
||||||
|
onClick: options.onRetry
|
||||||
|
});
|
||||||
|
container.appendChild(retryBtn);
|
||||||
|
}
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Loading Spinner
|
* Create a Loading Spinner
|
||||||
* @returns {HTMLElement}
|
* Simple animated spinner for loading states
|
||||||
|
*
|
||||||
|
* @returns {HTMLElement} Spinner element
|
||||||
*/
|
*/
|
||||||
Spinner() {
|
Spinner() {
|
||||||
const spinner = document.createElement('div');
|
const spinner = document.createElement('div');
|
||||||
spinner.className = 'spinner';
|
spinner.className = 'spinner';
|
||||||
|
spinner.setAttribute('role', 'progressbar');
|
||||||
|
spinner.setAttribute('aria-label', 'Loading');
|
||||||
|
spinner.setAttribute('aria-busy', 'true');
|
||||||
return spinner;
|
return spinner;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Code Block
|
* Create a Code Block
|
||||||
|
* Pre-formatted code display with syntax highlighting support
|
||||||
|
*
|
||||||
* @param {string} code - Code content
|
* @param {string} code - Code content
|
||||||
* @returns {HTMLElement}
|
* @param {string} [language] - Programming language for syntax highlighting
|
||||||
|
* @returns {HTMLElement} Code block element
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const code = Components.CodeBlock('const x = 42;', 'javascript');
|
||||||
*/
|
*/
|
||||||
CodeBlock(code) {
|
CodeBlock(code, language) {
|
||||||
const block = document.createElement('pre');
|
const block = document.createElement('pre');
|
||||||
block.className = 'code-block';
|
block.className = 'code-block';
|
||||||
block.textContent = code;
|
if (language) {
|
||||||
|
block.classList.add(`language-${language}`);
|
||||||
|
block.setAttribute('data-language', language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeEl = document.createElement('code');
|
||||||
|
codeEl.textContent = code;
|
||||||
|
block.appendChild(codeEl);
|
||||||
|
|
||||||
return block;
|
return block;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Sidebar Layout
|
* Create a Sidebar Layout
|
||||||
|
* Two-column layout with sidebar and main content
|
||||||
|
*
|
||||||
* @param {HTMLElement} sidebar - Sidebar content
|
* @param {HTMLElement} sidebar - Sidebar content
|
||||||
* @param {HTMLElement} content - Main content
|
* @param {HTMLElement} content - Main content
|
||||||
* @returns {HTMLElement}
|
* @param {Object} [options] - Layout options
|
||||||
|
* @param {string} [options.sidebarWidth='250px'] - Sidebar width
|
||||||
|
* @returns {HTMLElement} Layout container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const layout = Components.SidebarLayout(menuEl, contentEl);
|
||||||
*/
|
*/
|
||||||
SidebarLayout(sidebar, content) {
|
SidebarLayout(sidebar, content, options = {}) {
|
||||||
const layout = document.createElement('div');
|
const layout = document.createElement('div');
|
||||||
layout.className = 'sidebar-layout';
|
layout.className = 'sidebar-layout';
|
||||||
|
|
||||||
const sidebarEl = document.createElement('div');
|
const sidebarEl = document.createElement('div');
|
||||||
sidebarEl.className = 'sidebar';
|
sidebarEl.className = 'sidebar';
|
||||||
|
sidebarEl.setAttribute('role', 'complementary');
|
||||||
|
sidebarEl.setAttribute('aria-label', 'Sidebar navigation');
|
||||||
|
if (options.sidebarWidth) {
|
||||||
|
sidebarEl.style.width = options.sidebarWidth;
|
||||||
|
}
|
||||||
sidebarEl.appendChild(sidebar);
|
sidebarEl.appendChild(sidebar);
|
||||||
|
|
||||||
const contentEl = document.createElement('div');
|
const contentEl = document.createElement('div');
|
||||||
contentEl.className = 'sidebar-content';
|
contentEl.className = 'sidebar-content';
|
||||||
|
contentEl.setAttribute('role', 'main');
|
||||||
contentEl.appendChild(content);
|
contentEl.appendChild(content);
|
||||||
|
|
||||||
layout.appendChild(sidebarEl);
|
layout.appendChild(sidebarEl);
|
||||||
@@ -338,42 +652,98 @@ const Components = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Tabs
|
* Create a Tabs component
|
||||||
* @param {Array} tabs - [{ id, label, content }]
|
* Tabbed interface with keyboard navigation
|
||||||
* @returns {HTMLElement}
|
*
|
||||||
|
* @param {Array<Object>} tabs - Tab definitions
|
||||||
|
* @param {string} tabs[].id - Tab identifier
|
||||||
|
* @param {string} tabs[].label - Tab label
|
||||||
|
* @param {HTMLElement|string} tabs[].content - Tab content
|
||||||
|
* @returns {HTMLElement} Tabs container
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const tabs = Components.Tabs([
|
||||||
|
* { id: 'overview', label: 'Overview', content: overviewEl },
|
||||||
|
* { id: 'details', label: 'Details', content: detailsEl }
|
||||||
|
* ]);
|
||||||
*/
|
*/
|
||||||
Tabs(tabs) {
|
Tabs(tabs) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
container.className = 'tabs-container';
|
||||||
|
|
||||||
const tabsHeader = document.createElement('div');
|
const tabsHeader = document.createElement('div');
|
||||||
tabsHeader.className = 'tabs';
|
tabsHeader.className = 'tabs';
|
||||||
|
tabsHeader.setAttribute('role', 'tablist');
|
||||||
|
|
||||||
const contentContainer = document.createElement('div');
|
const contentContainer = document.createElement('div');
|
||||||
contentContainer.className = 'tab-content';
|
contentContainer.className = 'tab-content';
|
||||||
|
|
||||||
tabs.forEach((tab, index) => {
|
tabs.forEach((tab, index) => {
|
||||||
const tabBtn = document.createElement('div');
|
const tabBtn = document.createElement('button');
|
||||||
tabBtn.className = 'tab';
|
tabBtn.className = 'tab';
|
||||||
tabBtn.textContent = tab.label;
|
tabBtn.textContent = tab.label;
|
||||||
|
tabBtn.setAttribute('role', 'tab');
|
||||||
|
tabBtn.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
|
||||||
|
tabBtn.setAttribute('aria-controls', `tab-panel-${tab.id}`);
|
||||||
|
tabBtn.setAttribute('id', `tab-${tab.id}`);
|
||||||
|
tabBtn.setAttribute('tabindex', index === 0 ? '0' : '-1');
|
||||||
if (index === 0) tabBtn.classList.add('active');
|
if (index === 0) tabBtn.classList.add('active');
|
||||||
|
|
||||||
const tabContent = document.createElement('div');
|
const tabContent = document.createElement('div');
|
||||||
tabContent.id = `tab-${tab.id}`;
|
tabContent.id = `tab-panel-${tab.id}`;
|
||||||
|
tabContent.className = 'tab-panel';
|
||||||
|
tabContent.setAttribute('role', 'tabpanel');
|
||||||
|
tabContent.setAttribute('aria-labelledby', `tab-${tab.id}`);
|
||||||
|
tabContent.setAttribute('tabindex', '0');
|
||||||
tabContent.style.display = index === 0 ? 'block' : 'none';
|
tabContent.style.display = index === 0 ? 'block' : 'none';
|
||||||
|
|
||||||
if (typeof tab.content === 'string') {
|
if (typeof tab.content === 'string') {
|
||||||
tabContent.innerHTML = tab.content;
|
tabContent.innerHTML = tab.content;
|
||||||
} else {
|
} else if (tab.content instanceof HTMLElement) {
|
||||||
tabContent.appendChild(tab.content);
|
tabContent.appendChild(tab.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
tabBtn.addEventListener('click', () => {
|
const activateTab = () => {
|
||||||
// Deactivate all tabs
|
// Deactivate all tabs
|
||||||
tabsHeader.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
tabsHeader.querySelectorAll('.tab').forEach(t => {
|
||||||
contentContainer.querySelectorAll('[id^="tab-"]').forEach(c => c.style.display = 'none');
|
t.classList.remove('active');
|
||||||
|
t.setAttribute('aria-selected', 'false');
|
||||||
|
t.setAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
contentContainer.querySelectorAll('.tab-panel').forEach(c => {
|
||||||
|
c.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
// Activate clicked tab
|
// Activate clicked tab
|
||||||
tabBtn.classList.add('active');
|
tabBtn.classList.add('active');
|
||||||
|
tabBtn.setAttribute('aria-selected', 'true');
|
||||||
|
tabBtn.setAttribute('tabindex', '0');
|
||||||
tabContent.style.display = 'block';
|
tabContent.style.display = 'block';
|
||||||
|
tabBtn.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
tabBtn.addEventListener('click', activateTab);
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
tabBtn.addEventListener('keydown', (e) => {
|
||||||
|
const tabButtons = Array.from(tabsHeader.querySelectorAll('.tab'));
|
||||||
|
const currentIndex = tabButtons.indexOf(tabBtn);
|
||||||
|
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = (currentIndex + 1) % tabButtons.length;
|
||||||
|
tabButtons[nextIndex].click();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prevIndex = (currentIndex - 1 + tabButtons.length) % tabButtons.length;
|
||||||
|
tabButtons[prevIndex].click();
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault();
|
||||||
|
tabButtons[0].click();
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault();
|
||||||
|
tabButtons[tabButtons.length - 1].click();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tabsHeader.appendChild(tabBtn);
|
tabsHeader.appendChild(tabBtn);
|
||||||
@@ -389,3 +759,6 @@ const Components = {
|
|||||||
|
|
||||||
// Make globally available
|
// Make globally available
|
||||||
window.Components = Components;
|
window.Components = Components;
|
||||||
|
|
||||||
|
// Log component library initialization
|
||||||
|
console.log('📦 Component library loaded:', Object.keys(Components).length, 'components');
|
||||||
|
|||||||
Reference in New Issue
Block a user