@extrachill/components 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2026-03-25
4
+
5
+ ### Changed
6
+ - Convert all components from JSX to TypeScript with exported prop interfaces
7
+ - Remove @wordpress/components and @wordpress/element peer dependencies — use plain React
8
+ - Add proper npm package config (exports, tsconfig, homeboy.json)
9
+ - Add DataTable, Pagination, SearchBox, Modal, Tabs components
10
+
11
+ ## [0.1.0] - 2026-03-02
12
+
13
+ ### Added
14
+ - Initial release with DataTable, Pagination, SearchBox, Modal components
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # @extrachill/components
2
+
3
+ Shared React components for the Extra Chill Platform ecosystem.
4
+
5
+ ## Overview
6
+
7
+ This package provides reusable UI components used across multiple Extra Chill WordPress plugins, ensuring consistent design and reducing code duplication.
8
+
9
+ ## Components
10
+
11
+ - **DataTable** - Sortable data table with configurable columns
12
+ - **Pagination** - Page navigation with configurable items per page
13
+ - **SearchBox** - Debounced search input
14
+ - **Modal** - Accessible modal dialog
15
+ - **Tabs** - Controlled tab navigation for React apps and blocks
16
+
17
+ ## Installation
18
+
19
+ From a plugin within the Extra Chill Platform:
20
+
21
+ ```json
22
+ {
23
+ "dependencies": {
24
+ "@extrachill/components": "file:../../extrachill-components"
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```jsx
32
+ import { DataTable, Pagination, SearchBox, Modal, Tabs } from '@extrachill/components';
33
+ import '@extrachill/components/styles/components.scss';
34
+
35
+ function MyComponent() {
36
+ return (
37
+ <DataTable
38
+ columns={[
39
+ { key: 'name', label: 'Name', sortable: true },
40
+ { key: 'email', label: 'Email' }
41
+ ]}
42
+ data={users}
43
+ onSort={handleSort}
44
+ />
45
+ );
46
+ }
47
+ ```
48
+
49
+ ## Peer Dependencies
50
+
51
+ This package requires the following peer dependencies (provided by `@wordpress/scripts`):
52
+
53
+ - `@wordpress/components` ^28.0.0
54
+ - `@wordpress/element` ^6.0.0
55
+ - `react` ^18.0.0
56
+
57
+ ## Used By
58
+
59
+ - `extrachill-admin-tools` - Network administration tools
60
+ - `extrachill-analytics` - Analytics dashboard
61
+ - `extrachill-studio` - Team collaboration workspace
62
+
63
+ ## License
64
+
65
+ GPL-2.0+
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react';
2
+ export interface DataTableColumn<T = Record<string, unknown>> {
3
+ key: string;
4
+ label: string;
5
+ width?: string;
6
+ render?: (value: unknown, row: T) => ReactNode;
7
+ }
8
+ export interface DataTableProps<T = Record<string, unknown>> {
9
+ columns: DataTableColumn<T>[];
10
+ data: T[];
11
+ isLoading?: boolean;
12
+ selectable?: boolean;
13
+ selectedIds?: Array<string | number>;
14
+ onSelectChange?: (ids: Array<string | number>) => void;
15
+ emptyMessage?: string;
16
+ rowKey?: string;
17
+ }
18
+ export declare function DataTable<T extends Record<string, unknown>>({ columns, data, isLoading, selectable, selectedIds, onSelectChange, emptyMessage, rowKey, }: DataTableProps<T>): import("react/jsx-runtime").JSX.Element;
19
+ //# sourceMappingURL=DataTable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DataTable.d.ts","sourceRoot":"","sources":["../src/DataTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,WAAW,eAAe,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,SAAS,CAAC;CAC/C;AAED,MAAM,WAAW,cAAc,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC1D,OAAO,EAAE,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9B,IAAI,EAAE,CAAC,EAAE,CAAC;IACV,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IACrC,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC5D,OAAO,EACP,IAAI,EACJ,SAAiB,EACjB,UAAkB,EAClB,WAAgB,EAChB,cAAyB,EACzB,YAA+B,EAC/B,MAAa,GACb,EAAE,cAAc,CAAC,CAAC,CAAC,2CA+EnB"}
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function DataTable({ columns, data, isLoading = false, selectable = false, selectedIds = [], onSelectChange = () => { }, emptyMessage = 'No data found.', rowKey = 'id', }) {
3
+ const allSelected = data.length > 0 && selectedIds.length === data.length;
4
+ const handleSelectAll = (e) => {
5
+ if (e.target.checked) {
6
+ onSelectChange(data.map((row) => row[rowKey]));
7
+ }
8
+ else {
9
+ onSelectChange([]);
10
+ }
11
+ };
12
+ const handleSelectRow = (id, checked) => {
13
+ if (checked) {
14
+ onSelectChange([...selectedIds, id]);
15
+ }
16
+ else {
17
+ onSelectChange(selectedIds.filter((sid) => sid !== id));
18
+ }
19
+ };
20
+ if (isLoading) {
21
+ return (_jsx("div", { className: "ec-data-table__loading", children: _jsx("span", { children: "Loading..." }) }));
22
+ }
23
+ if (data.length === 0) {
24
+ return (_jsx("div", { className: "ec-data-table__empty", children: _jsx("p", { children: emptyMessage }) }));
25
+ }
26
+ return (_jsxs("table", { className: "ec-data-table wp-list-table widefat fixed striped", children: [_jsx("thead", { children: _jsxs("tr", { children: [selectable && (_jsx("th", { className: "ec-data-table__check-column", children: _jsx("input", { type: "checkbox", checked: allSelected, onChange: handleSelectAll }) })), columns.map((col) => (_jsx("th", { style: col.width ? { width: col.width } : undefined, children: col.label }, col.key)))] }) }), _jsx("tbody", { children: data.map((row) => (_jsxs("tr", { children: [selectable && (_jsx("td", { className: "ec-data-table__check-column", children: _jsx("input", { type: "checkbox", checked: selectedIds.includes(row[rowKey]), onChange: (e) => handleSelectRow(row[rowKey], e.target.checked) }) })), columns.map((col) => (_jsx("td", { children: col.render ? col.render(row[col.key], row) : row[col.key] }, col.key)))] }, row[rowKey]))) })] }));
27
+ }
@@ -0,0 +1,10 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface ModalProps {
3
+ title: string;
4
+ isOpen: boolean;
5
+ onClose: () => void;
6
+ children: ReactNode;
7
+ className?: string;
8
+ }
9
+ export declare function Modal({ title, isOpen, onClose, children, className, }: ModalProps): import("react/jsx-runtime").JSX.Element | null;
10
+ //# sourceMappingURL=Modal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../src/Modal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA0B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAE/D,MAAM,WAAW,UAAU;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,KAAK,CAAC,EACrB,KAAK,EACL,MAAM,EACN,OAAO,EACP,QAAQ,EACR,SAAc,GACd,EAAE,UAAU,kDAmCZ"}
package/dist/Modal.js ADDED
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useCallback } from 'react';
3
+ export function Modal({ title, isOpen, onClose, children, className = '', }) {
4
+ const handleKeyDown = useCallback((e) => {
5
+ if (e.key === 'Escape') {
6
+ onClose();
7
+ }
8
+ }, [onClose]);
9
+ useEffect(() => {
10
+ if (isOpen) {
11
+ document.addEventListener('keydown', handleKeyDown);
12
+ return () => document.removeEventListener('keydown', handleKeyDown);
13
+ }
14
+ }, [isOpen, handleKeyDown]);
15
+ if (!isOpen) {
16
+ return null;
17
+ }
18
+ return (_jsxs("div", { className: `ec-modal ${className}`, role: "dialog", "aria-label": title, children: [_jsx("div", { className: "ec-modal__backdrop", onClick: onClose, "aria-hidden": "true" }), _jsxs("div", { className: "ec-modal__content", children: [_jsxs("div", { className: "ec-modal__header", children: [_jsx("h2", { children: title }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", children: "\u00D7" })] }), _jsx("div", { className: "ec-modal__body", children: children })] })] }));
19
+ }
@@ -0,0 +1,8 @@
1
+ export interface PaginationProps {
2
+ currentPage: number;
3
+ totalPages: number;
4
+ totalItems: number;
5
+ onPageChange: (page: number) => void;
6
+ }
7
+ export declare function Pagination({ currentPage, totalPages, totalItems, onPageChange, }: PaginationProps): import("react/jsx-runtime").JSX.Element | null;
8
+ //# sourceMappingURL=Pagination.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pagination.d.ts","sourceRoot":"","sources":["../src/Pagination.tsx"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAED,wBAAgB,UAAU,CAAC,EAC1B,WAAW,EACX,UAAU,EACV,UAAU,EACV,YAAY,GACZ,EAAE,eAAe,kDA4BjB"}
@@ -0,0 +1,7 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ export function Pagination({ currentPage, totalPages, totalItems, onPageChange, }) {
3
+ if (totalPages <= 1) {
4
+ return null;
5
+ }
6
+ return (_jsxs("div", { className: "ec-pagination", children: [_jsxs("span", { className: "ec-pagination__info", children: ["Page ", currentPage, " of ", totalPages, " (", totalItems, " items)"] }), _jsxs("div", { className: "ec-pagination__buttons", children: [_jsx("button", { type: "button", disabled: currentPage <= 1, onClick: () => onPageChange(currentPage - 1), children: "Previous" }), _jsx("button", { type: "button", disabled: currentPage >= totalPages, onClick: () => onPageChange(currentPage + 1), children: "Next" })] })] }));
7
+ }
@@ -0,0 +1,8 @@
1
+ export interface SearchBoxProps {
2
+ value?: string;
3
+ onSearch: (value: string) => void;
4
+ placeholder?: string;
5
+ onClear?: () => void;
6
+ }
7
+ export declare function SearchBox({ value, onSearch, placeholder, onClear, }: SearchBoxProps): import("react/jsx-runtime").JSX.Element;
8
+ //# sourceMappingURL=SearchBox.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SearchBox.d.ts","sourceRoot":"","sources":["../src/SearchBox.tsx"],"names":[],"mappings":"AAEA,MAAM,WAAW,cAAc;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,wBAAgB,SAAS,CAAC,EACzB,KAAU,EACV,QAAQ,EACR,WAAyB,EACzB,OAAO,GACP,EAAE,cAAc,2CAyChB"}
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ export function SearchBox({ value = '', onSearch, placeholder = 'Search...', onClear, }) {
4
+ const [inputValue, setInputValue] = useState(value);
5
+ const handleSearch = () => {
6
+ onSearch(inputValue);
7
+ };
8
+ const handleClear = () => {
9
+ setInputValue('');
10
+ if (onClear) {
11
+ onClear();
12
+ }
13
+ else {
14
+ onSearch('');
15
+ }
16
+ };
17
+ const handleKeyDown = (e) => {
18
+ if (e.key === 'Enter') {
19
+ handleSearch();
20
+ }
21
+ };
22
+ return (_jsxs("div", { className: "ec-search-box", children: [_jsx("input", { type: "text", value: inputValue, onChange: (e) => setInputValue(e.target.value), placeholder: placeholder, onKeyDown: handleKeyDown }), _jsx("button", { type: "button", onClick: handleSearch, children: "Search" }), inputValue && (_jsx("button", { type: "button", onClick: handleClear, children: "Clear" }))] }));
23
+ }
package/dist/Tabs.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export interface TabItem {
2
+ id: string;
3
+ label: string;
4
+ badge?: number;
5
+ }
6
+ export interface TabsProps {
7
+ tabs: TabItem[];
8
+ active: string;
9
+ onChange: (id: string) => void;
10
+ classPrefix?: string;
11
+ className?: string;
12
+ }
13
+ export declare function Tabs({ tabs, active, onChange, classPrefix, className, }: TabsProps): import("react/jsx-runtime").JSX.Element | null;
14
+ //# sourceMappingURL=Tabs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Tabs.d.ts","sourceRoot":"","sources":["../src/Tabs.tsx"],"names":[],"mappings":"AAAA,MAAM,WAAW,OAAO;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,IAAI,CAAC,EACpB,IAAS,EACT,MAAM,EACN,QAAQ,EACR,WAAuB,EACvB,SAAc,GACd,EAAE,SAAS,kDA8BX"}
package/dist/Tabs.js ADDED
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function Tabs({ tabs = [], active, onChange, classPrefix = 'ec-tabs', className = '', }) {
3
+ if (tabs.length === 0) {
4
+ return null;
5
+ }
6
+ const rootClass = [`${classPrefix}__tabs`, className].filter(Boolean).join(' ');
7
+ return (_jsx("div", { className: rootClass, role: "tablist", "aria-orientation": "horizontal", children: tabs.map((tab) => {
8
+ const isActive = active === tab.id;
9
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": isActive, className: `${classPrefix}__tab${isActive ? ' is-active' : ''}`, onClick: () => onChange(tab.id), children: [tab.label, tab.badge != null && tab.badge > 0 && (_jsx("span", { className: `${classPrefix}__tab-badge`, children: tab.badge }))] }, tab.id));
10
+ }) }));
11
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @extrachill/components
3
+ *
4
+ * Shared React components for the Extra Chill Platform.
5
+ */
6
+ export { DataTable, type DataTableProps, type DataTableColumn } from './DataTable.tsx';
7
+ export { Pagination, type PaginationProps } from './Pagination.tsx';
8
+ export { SearchBox, type SearchBoxProps } from './SearchBox.tsx';
9
+ export { Modal, type ModalProps } from './Modal.tsx';
10
+ export { Tabs, type TabsProps, type TabItem } from './Tabs.tsx';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvF,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @extrachill/components
3
+ *
4
+ * Shared React components for the Extra Chill Platform.
5
+ */
6
+ export { DataTable } from "./DataTable.js";
7
+ export { Pagination } from "./Pagination.js";
8
+ export { SearchBox } from "./SearchBox.js";
9
+ export { Modal } from "./Modal.js";
10
+ export { Tabs } from "./Tabs.js";
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@extrachill/components",
3
+ "version": "0.2.0",
4
+ "description": "Shared React components for the Extra Chill Platform.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ "./package.json": "./package.json",
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./styles/components.scss": "./styles/components.scss",
15
+ "./styles/*": "./styles/*",
16
+ "./src": {
17
+ "types": "./src/index.tsx",
18
+ "default": "./src/index.tsx"
19
+ },
20
+ "./src/*": {
21
+ "types": "./src/*.tsx",
22
+ "default": "./src/*.tsx"
23
+ }
24
+ },
25
+ "files": [
26
+ "src",
27
+ "dist",
28
+ "styles",
29
+ "README.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "scripts": {
33
+ "build": "rm -rf dist *.tsbuildinfo && tsc",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "keywords": [
38
+ "extrachill",
39
+ "react",
40
+ "components",
41
+ "wordpress",
42
+ "design-system"
43
+ ],
44
+ "author": "Chris Huber <hello@chubes.net>",
45
+ "license": "GPL-2.0+",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/Extra-Chill/extrachill-components.git"
49
+ },
50
+ "homepage": "https://github.com/Extra-Chill/extrachill-components",
51
+ "bugs": "https://github.com/Extra-Chill/extrachill-components/issues",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "peerDependencies": {
56
+ "react": ">=18.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/react": "^18.0.0",
60
+ "typescript": "^5.7.0"
61
+ }
62
+ }
@@ -0,0 +1,109 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface DataTableColumn<T = Record<string, unknown>> {
4
+ key: string;
5
+ label: string;
6
+ width?: string;
7
+ render?: (value: unknown, row: T) => ReactNode;
8
+ }
9
+
10
+ export interface DataTableProps<T = Record<string, unknown>> {
11
+ columns: DataTableColumn<T>[];
12
+ data: T[];
13
+ isLoading?: boolean;
14
+ selectable?: boolean;
15
+ selectedIds?: Array<string | number>;
16
+ onSelectChange?: (ids: Array<string | number>) => void;
17
+ emptyMessage?: string;
18
+ rowKey?: string;
19
+ }
20
+
21
+ export function DataTable<T extends Record<string, unknown>>({
22
+ columns,
23
+ data,
24
+ isLoading = false,
25
+ selectable = false,
26
+ selectedIds = [],
27
+ onSelectChange = () => {},
28
+ emptyMessage = 'No data found.',
29
+ rowKey = 'id',
30
+ }: DataTableProps<T>) {
31
+ const allSelected = data.length > 0 && selectedIds.length === data.length;
32
+
33
+ const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
34
+ if (e.target.checked) {
35
+ onSelectChange(data.map((row) => row[rowKey] as string | number));
36
+ } else {
37
+ onSelectChange([]);
38
+ }
39
+ };
40
+
41
+ const handleSelectRow = (id: string | number, checked: boolean) => {
42
+ if (checked) {
43
+ onSelectChange([...selectedIds, id]);
44
+ } else {
45
+ onSelectChange(selectedIds.filter((sid) => sid !== id));
46
+ }
47
+ };
48
+
49
+ if (isLoading) {
50
+ return (
51
+ <div className="ec-data-table__loading">
52
+ <span>Loading...</span>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ if (data.length === 0) {
58
+ return (
59
+ <div className="ec-data-table__empty">
60
+ <p>{emptyMessage}</p>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <table className="ec-data-table wp-list-table widefat fixed striped">
67
+ <thead>
68
+ <tr>
69
+ {selectable && (
70
+ <th className="ec-data-table__check-column">
71
+ <input
72
+ type="checkbox"
73
+ checked={allSelected}
74
+ onChange={handleSelectAll}
75
+ />
76
+ </th>
77
+ )}
78
+ {columns.map((col) => (
79
+ <th key={col.key} style={col.width ? { width: col.width } : undefined}>
80
+ {col.label}
81
+ </th>
82
+ ))}
83
+ </tr>
84
+ </thead>
85
+ <tbody>
86
+ {data.map((row) => (
87
+ <tr key={row[rowKey] as string | number}>
88
+ {selectable && (
89
+ <td className="ec-data-table__check-column">
90
+ <input
91
+ type="checkbox"
92
+ checked={selectedIds.includes(row[rowKey] as string | number)}
93
+ onChange={(e) =>
94
+ handleSelectRow(row[rowKey] as string | number, e.target.checked)
95
+ }
96
+ />
97
+ </td>
98
+ )}
99
+ {columns.map((col) => (
100
+ <td key={col.key}>
101
+ {col.render ? col.render(row[col.key], row) : (row[col.key] as ReactNode)}
102
+ </td>
103
+ ))}
104
+ </tr>
105
+ ))}
106
+ </tbody>
107
+ </table>
108
+ );
109
+ }
package/src/Modal.tsx ADDED
@@ -0,0 +1,52 @@
1
+ import { useEffect, useCallback, type ReactNode } from 'react';
2
+
3
+ export interface ModalProps {
4
+ title: string;
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ children: ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ export function Modal({
12
+ title,
13
+ isOpen,
14
+ onClose,
15
+ children,
16
+ className = '',
17
+ }: ModalProps) {
18
+ const handleKeyDown = useCallback(
19
+ (e: KeyboardEvent) => {
20
+ if (e.key === 'Escape') {
21
+ onClose();
22
+ }
23
+ },
24
+ [onClose]
25
+ );
26
+
27
+ useEffect(() => {
28
+ if (isOpen) {
29
+ document.addEventListener('keydown', handleKeyDown);
30
+ return () => document.removeEventListener('keydown', handleKeyDown);
31
+ }
32
+ }, [isOpen, handleKeyDown]);
33
+
34
+ if (!isOpen) {
35
+ return null;
36
+ }
37
+
38
+ return (
39
+ <div className={`ec-modal ${className}`} role="dialog" aria-label={title}>
40
+ <div className="ec-modal__backdrop" onClick={onClose} aria-hidden="true" />
41
+ <div className="ec-modal__content">
42
+ <div className="ec-modal__header">
43
+ <h2>{title}</h2>
44
+ <button type="button" onClick={onClose} aria-label="Close">
45
+ &times;
46
+ </button>
47
+ </div>
48
+ <div className="ec-modal__body">{children}</div>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,41 @@
1
+ export interface PaginationProps {
2
+ currentPage: number;
3
+ totalPages: number;
4
+ totalItems: number;
5
+ onPageChange: (page: number) => void;
6
+ }
7
+
8
+ export function Pagination({
9
+ currentPage,
10
+ totalPages,
11
+ totalItems,
12
+ onPageChange,
13
+ }: PaginationProps) {
14
+ if (totalPages <= 1) {
15
+ return null;
16
+ }
17
+
18
+ return (
19
+ <div className="ec-pagination">
20
+ <span className="ec-pagination__info">
21
+ Page {currentPage} of {totalPages} ({totalItems} items)
22
+ </span>
23
+ <div className="ec-pagination__buttons">
24
+ <button
25
+ type="button"
26
+ disabled={currentPage <= 1}
27
+ onClick={() => onPageChange(currentPage - 1)}
28
+ >
29
+ Previous
30
+ </button>
31
+ <button
32
+ type="button"
33
+ disabled={currentPage >= totalPages}
34
+ onClick={() => onPageChange(currentPage + 1)}
35
+ >
36
+ Next
37
+ </button>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,56 @@
1
+ import { useState, type KeyboardEvent } from 'react';
2
+
3
+ export interface SearchBoxProps {
4
+ value?: string;
5
+ onSearch: (value: string) => void;
6
+ placeholder?: string;
7
+ onClear?: () => void;
8
+ }
9
+
10
+ export function SearchBox({
11
+ value = '',
12
+ onSearch,
13
+ placeholder = 'Search...',
14
+ onClear,
15
+ }: SearchBoxProps) {
16
+ const [inputValue, setInputValue] = useState(value);
17
+
18
+ const handleSearch = () => {
19
+ onSearch(inputValue);
20
+ };
21
+
22
+ const handleClear = () => {
23
+ setInputValue('');
24
+ if (onClear) {
25
+ onClear();
26
+ } else {
27
+ onSearch('');
28
+ }
29
+ };
30
+
31
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
32
+ if (e.key === 'Enter') {
33
+ handleSearch();
34
+ }
35
+ };
36
+
37
+ return (
38
+ <div className="ec-search-box">
39
+ <input
40
+ type="text"
41
+ value={inputValue}
42
+ onChange={(e) => setInputValue(e.target.value)}
43
+ placeholder={placeholder}
44
+ onKeyDown={handleKeyDown}
45
+ />
46
+ <button type="button" onClick={handleSearch}>
47
+ Search
48
+ </button>
49
+ {inputValue && (
50
+ <button type="button" onClick={handleClear}>
51
+ Clear
52
+ </button>
53
+ )}
54
+ </div>
55
+ );
56
+ }
package/src/Tabs.tsx ADDED
@@ -0,0 +1,51 @@
1
+ export interface TabItem {
2
+ id: string;
3
+ label: string;
4
+ badge?: number;
5
+ }
6
+
7
+ export interface TabsProps {
8
+ tabs: TabItem[];
9
+ active: string;
10
+ onChange: (id: string) => void;
11
+ classPrefix?: string;
12
+ className?: string;
13
+ }
14
+
15
+ export function Tabs({
16
+ tabs = [],
17
+ active,
18
+ onChange,
19
+ classPrefix = 'ec-tabs',
20
+ className = '',
21
+ }: TabsProps) {
22
+ if (tabs.length === 0) {
23
+ return null;
24
+ }
25
+
26
+ const rootClass = [`${classPrefix}__tabs`, className].filter(Boolean).join(' ');
27
+
28
+ return (
29
+ <div className={rootClass} role="tablist" aria-orientation="horizontal">
30
+ {tabs.map((tab) => {
31
+ const isActive = active === tab.id;
32
+
33
+ return (
34
+ <button
35
+ key={tab.id}
36
+ type="button"
37
+ role="tab"
38
+ aria-selected={isActive}
39
+ className={`${classPrefix}__tab${isActive ? ' is-active' : ''}`}
40
+ onClick={() => onChange(tab.id)}
41
+ >
42
+ {tab.label}
43
+ {tab.badge != null && tab.badge > 0 && (
44
+ <span className={`${classPrefix}__tab-badge`}>{tab.badge}</span>
45
+ )}
46
+ </button>
47
+ );
48
+ })}
49
+ </div>
50
+ );
51
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @extrachill/components
3
+ *
4
+ * Shared React components for the Extra Chill Platform.
5
+ */
6
+
7
+ export { DataTable, type DataTableProps, type DataTableColumn } from './DataTable.tsx';
8
+ export { Pagination, type PaginationProps } from './Pagination.tsx';
9
+ export { SearchBox, type SearchBoxProps } from './SearchBox.tsx';
10
+ export { Modal, type ModalProps } from './Modal.tsx';
11
+ export { Tabs, type TabsProps, type TabItem } from './Tabs.tsx';
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Shared Component Styles
3
+ *
4
+ * Styles for extrachill-components shared across plugins.
5
+ */
6
+
7
+ // Data Table
8
+ .ec-data-table {
9
+ &__loading,
10
+ &__empty {
11
+ padding: 40px;
12
+ text-align: center;
13
+ background: #f9f9f9;
14
+ border: 1px solid #ddd;
15
+ border-radius: 4px;
16
+
17
+ .components-spinner {
18
+ margin-right: 10px;
19
+ }
20
+ }
21
+
22
+ &__check-column {
23
+ width: 40px;
24
+ padding: 8px !important;
25
+ }
26
+
27
+ // Override WP table styles
28
+ &.wp-list-table {
29
+ margin-top: 15px;
30
+
31
+ th,
32
+ td {
33
+ vertical-align: middle;
34
+ }
35
+ }
36
+ }
37
+
38
+ // Pagination
39
+ .ec-pagination {
40
+ display: flex;
41
+ justify-content: space-between;
42
+ align-items: center;
43
+ margin-top: 15px;
44
+ padding-top: 15px;
45
+ border-top: 1px solid #eee;
46
+
47
+ &__info {
48
+ color: #646970;
49
+ font-size: 13px;
50
+ }
51
+
52
+ &__buttons {
53
+ display: flex;
54
+ gap: 8px;
55
+ }
56
+ }
57
+
58
+ // Search Box
59
+ .ec-search-box {
60
+ display: flex;
61
+ gap: 8px;
62
+ align-items: flex-end;
63
+ margin-bottom: 15px;
64
+
65
+ .components-text-control {
66
+ min-width: 300px;
67
+ }
68
+ }
69
+
70
+ // Modal overrides
71
+ .ec-modal {
72
+ .components-modal__content {
73
+ min-width: 500px;
74
+ }
75
+ }
76
+
77
+ // Tabs — default styles for classPrefix="ec-tabs"
78
+ // Consumers with custom classPrefix (e.g. "ec-am") duplicate
79
+ // these rules under their own namespace in their block SCSS.
80
+ .ec-tabs__tabs {
81
+ display: inline-flex;
82
+ gap: var(--spacing-sm, 0.5rem);
83
+ flex-wrap: wrap;
84
+ }
85
+
86
+ .ec-tabs__tab {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: var(--spacing-xs, 0.25rem);
90
+ border: 1px solid var(--border-color, #ddd);
91
+ background: var(--background-color, #fff);
92
+ padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
93
+ border-radius: var(--border-radius-sm, 4px);
94
+ cursor: pointer;
95
+ color: var(--text-color, #111);
96
+ font: inherit;
97
+ font-size: var(--font-size-base, 1rem);
98
+ font-weight: 600;
99
+ transition: background-color 0.2s, color 0.2s, border-color 0.2s;
100
+
101
+ &:hover {
102
+ background: var(--card-background, #f1f5f9);
103
+ }
104
+
105
+ &.is-active {
106
+ border-color: var(--header-background, #1a1a1a);
107
+ background: var(--header-background, #1a1a1a);
108
+ color: var(--header-text-color, #fff);
109
+ }
110
+ }
111
+
112
+ .ec-tabs__tab-badge {
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ min-width: 20px;
117
+ height: 20px;
118
+ padding: 0 6px;
119
+ border-radius: 10px;
120
+ background: var(--accent, #53940b);
121
+ color: var(--background-color, #fff);
122
+ font-size: var(--font-size-xs, 0.625rem);
123
+ font-weight: 600;
124
+
125
+ .ec-tabs__tab.is-active & {
126
+ background: var(--background-color, #fff);
127
+ color: var(--header-background, #1a1a1a);
128
+ }
129
+ }
130
+
131
+ // Badges (commonly used with tables)
132
+ .ec-badge {
133
+ display: inline-block;
134
+ padding: 3px 8px;
135
+ border-radius: 3px;
136
+ font-size: 12px;
137
+ font-weight: 500;
138
+
139
+ &--yes,
140
+ &--success {
141
+ background: #d4edda;
142
+ color: #155724;
143
+ }
144
+
145
+ &--no,
146
+ &--error {
147
+ background: #f8d7da;
148
+ color: #721c24;
149
+ }
150
+
151
+ &--info {
152
+ background: #cce5ff;
153
+ color: #004085;
154
+ }
155
+
156
+ &--warning {
157
+ background: #fff3cd;
158
+ color: #856404;
159
+ }
160
+ }