diff --git a/.claude/commands/settings-integration.md b/.claude/commands/settings-integration.md index 2cdffca..f910ae2 100644 --- a/.claude/commands/settings-integration.md +++ b/.claude/commands/settings-integration.md @@ -1,597 +1,597 @@ ---- -name: "Settings Integration" -description: Guide for integrating plugin-ui into a WordPress plugin — covers PHP schema, REST API, frontend wiring, CSS, testing, and common pitfalls. -category: Integration -tags: [settings, plugin-ui, wordpress, php, rest-api, react] ---- - -You are helping a developer integrate the `@wedevs/plugin-ui` `` component into a WordPress plugin. - -When invoked with `/settings-integration`, read the current codebase to understand the integration state, then guide, scaffold, or fix whatever the user asks. - ---- - -## First-Time Setup - -### 1. Install the package - -plugin-ui is a **private weDevs package** — not on npm. Choose based on your intent: - -**Using plugin-ui (consumer — no changes needed):** - -```bash -# Install directly from GitHub -pnpm add github:getdokan/plugin-ui -``` - -Or pin a specific tag/branch: - -```bash -pnpm add github:getdokan/plugin-ui#main -pnpm add github:getdokan/plugin-ui#v2.0.0 -``` - -**Contributing to plugin-ui (local development):** - -Clone plugin-ui alongside your plugin, then reference it by local path: - -```json -// package.json -"dependencies": { - "@wedevs/plugin-ui": "file:../plugin-ui" -} -``` - -```bash -pnpm install -``` - -With the local path approach, changes you make in `../plugin-ui/src` are picked up after rebuilding plugin-ui (`pnpm run build` inside plugin-ui). If TypeScript types drift after a rebuild, force-copy the updated dist: - -```bash -cp -r ../plugin-ui/dist/* node_modules/@wedevs/plugin-ui/dist/ -``` - -### 2. Peer dependencies - -```bash -pnpm add react react-dom @wordpress/i18n @wordpress/api-fetch @wordpress/hooks -pnpm add -D @types/react @types/react-dom -``` - -### 3. Enqueue in PHP - -```php -wp_enqueue_script( - 'my-plugin-admin', - plugin_dir_url(__FILE__) . 'assets/js/main.js', - [ 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-hooks' ], - MY_PLUGIN_VERSION, - true -); -wp_enqueue_style( - 'my-plugin-admin', - plugin_dir_url(__FILE__) . 'assets/css/main.css', - [], - MY_PLUGIN_VERSION -); -// Provide REST nonce for apiFetch -wp_add_inline_script( - 'my-plugin-admin', - sprintf( - 'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( "%s" ) );', - wp_create_nonce( 'wp_rest' ) - ), - 'after' -); -``` - -### 4. Mount point in PHP template - -```php -// In your admin page callback -echo '
'; -``` - -### 5. React entry point - -```tsx -// src/index.tsx -import { createRoot } from 'react-dom/client'; -import './index.css'; -import MyApp from './apps/my-plugin'; - -const mountPoint = document.getElementById('my-plugin-settings'); -if (mountPoint) { - createRoot(mountPoint).render(); -} -``` - ---- - -## Architecture Overview - -The integration has two layers that must stay in sync: - -``` -PHP (Backend) React (Frontend) -───────────────────────────────── ──────────────────────────────────── -AbstractSettingsSchema useSettings hook - └─ get_schema() → flat array ──► schema[] → plugin-ui - └─ save(data) ◄── onSave(treeValues) - └─ get_option_key() useSettingsPage hook (query params) - -WP_REST_Controller - └─ GET /settings/schema ──► schemaEndpoint - └─ GET /settings ──► (values merged into schema) - └─ POST /settings ◄── saveEndpoint -``` - ---- - -## PHP Backend - -### 1. AbstractSettingsSchema - -Every integration extends this abstract class. The key contract: - -```php -abstract class AbstractSettingsSchema implements SettingsSchemaInterface { - abstract protected function slug(): string; // e.g. 'my-integration' - - // Override to add integration-specific pages/sections/fields - protected function pages(): array { return []; } - protected function sections(): array { return []; } - protected function fields(array $settings): array { return []; } - - // Override to add integration-specific sanitization - protected function sanitize(array $data): array { - return $this->sanitize_common($data); // always call parent - } -} -``` - -**Option key** is derived from `slug()` — check your base class implementation for the exact pattern (e.g. `my_plugin_{slug}_settings`). - -### 2. Field Definition Structure - -```php -[ - 'id' => 'tag_line', // CRITICAL: must match WP REST args key for nested objects - 'type' => 'field', - 'variant' => 'text', // see variant table below - 'label' => __('Tag Line', 'my-plugin'), - 'section_id' => 'app_identity', // groups this field under its section - 'value' => $settings['tag_line'] ?? '', - 'description' => __('App tagline text.', 'my-plugin'), -] -``` - -**Available variants:** -| Variant | Use for | -|---|---| -| `text` | Plain text input | -| `show_hide` | Secrets/passwords — eye toggle button | -| `color_picker` | Hex color values | -| `switch` | Boolean toggle | -| `wp_media_upload` | Image/media URLs | -| `number` | Numeric values | -| `select` | Dropdown | -| `textarea` | Multi-line text | -| `rich_text` | WYSIWYG editor | - -**Never use `'password'` variant** — use `'show_hide'` instead. - -### 3. Section-Grouped Payload (Critical) - -plugin-ui's `enrichNode()` **always overwrites** any PHP-set `dependency_key` with: -``` -child.dependency_key = parent.dependency_key + '.' + child.id -``` - -So a field `id='tag_line'` under section `id='app_identity'` gets `dependency_key='app_identity.tag_line'`. - -`handleOnSave` splits on `.` to build `treeValues`: -```json -{ "app_identity": { "tag_line": "My App", "app_logo": "https://..." } } -``` - -**PHP `sanitize()` must read section-grouped data:** -```php -protected function sanitize(array $data): array { - $result = $this->sanitize_common($data); // handles common sections - - if (isset($data['app_identity'])) { - $s = $data['app_identity']; - $result['tag_line'] = sanitize_text_field($s['tag_line'] ?? ''); - $result['app_logo'] = esc_url_raw($s['app_logo'] ?? ''); - } - return $result; -} -``` - -### 4. Nested Object Fields and WP REST Args - -For nested objects, field **IDs must match the WP REST args property names**: - -```php -// CORRECT — field id matches REST args property key -[ 'id' => 'app_id', 'section_id' => 'push_notifications', ... ] -[ 'id' => 'api_key', 'section_id' => 'push_notifications', ... ] - -// WRONG — WP REST strips 'push_app_id' (not in registered properties) -[ 'id' => 'push_app_id', 'section_id' => 'push_notifications', ... ] -``` - -WP REST **strips unknown properties** from registered `object` args. If stripped, the object becomes `{}` and `required: true` properties fail validation. - -**Set `'required' => false` on nested properties** to allow partial saves: -```php -'push_notifications' => [ - 'required' => false, - 'type' => 'object', - 'properties' => [ - 'app_id' => [ 'required' => false, 'type' => 'string', 'default' => '' ], - 'api_key' => [ 'required' => false, 'type' => 'string', 'default' => '' ], - ], -], -``` - -### 5. Partial Save / Merge Pattern - -`save()` must **merge** with existing data, not overwrite: -```php -public function save(array $data): void { - $existing = get_option($this->get_option_key(), []); - $sanitized = $this->sanitize($data); - update_option($this->get_option_key(), array_merge($existing, $sanitized), false); -} -``` - -The frontend only sends the current page's sections on save. `array_merge` ensures other sections are preserved. - -### 6. sanitize_hex_color Gotcha - -`sanitize_hex_color()` returns `null` for invalid hex strings. Always use real hex values in defaults and tests (e.g. `'#FF9472'`, not placeholder strings like `'#MYCOLOR'`). - ---- - -## Frontend - -### 1. App Structure - -```tsx -import { ThemeProvider, Settings, Toaster } from '@wedevs/plugin-ui'; -import { __ } from '@wordpress/i18n'; -import { useSettings, useSettingsPage } from '../../hooks'; - -const MyApp = () => { - const { schema, values, isLoading, onChange, onSave } = useSettings( - 'my-plugin/v1/settings/schema', - 'my-plugin/v1/settings' - ); - const { initialPage, onNavigate } = useSettingsPage(); - - return ( - -
- - -
-
- ); -}; -``` - -Pass `loading={isLoading}` directly to `` — it renders a `` automatically while data loads. No manual loading guard needed. - -### 2. useSettings Hook - -Implement in `src/hooks/useSettings.ts`: - -```ts -import { useState, useCallback } from 'react'; -import apiFetch from '@wordpress/api-fetch'; -import { toast } from '@wedevs/plugin-ui'; -import { __ } from '@wordpress/i18n'; - -export function useSettings(schemaEndpoint: string, saveEndpoint: string) { - const [schema, setSchema] = useState([]); - const [values, setValues] = useState({}); - const [isLoading, setIsLoading] = useState(true); - - const fetchSchema = useCallback(async () => { - const data = await apiFetch({ path: schemaEndpoint }); - setSchema(data.schema); - setValues(data.values); - setIsLoading(false); - }, [schemaEndpoint]); - - const onChange = useCallback((key: string, value: any) => { - setValues((prev) => ({ ...prev, [key]: value })); - }, []); - - const onSave = useCallback( - async (_scopeId: string, treeValues: Record) => { - try { - await apiFetch({ path: saveEndpoint, method: 'POST', data: treeValues }); - await fetchSchema(); // always refresh — server is source of truth - toast.success(__('Settings saved.', 'my-plugin')); - } catch (err: any) { - toast.error(err?.message ?? __('Failed to save settings.', 'my-plugin')); - } - }, - [saveEndpoint, fetchSchema] - ); - - return { schema, values, isLoading, onChange, onSave }; -} -``` - -**Always `fetchSchema()` after save** — the server sanitizes values (e.g. strips invalid hex) and the UI must reflect the canonical saved state. - -### 3. useSettingsPage Hook (Query Param Persistence) - -Implement in `src/hooks/useSettingsPage.ts`: - -```ts -import { useCallback } from 'react'; - -const PARAM = 'settings_page'; - -export function useSettingsPage() { - const initialPage = new URLSearchParams(window.location.search).get(PARAM) ?? undefined; - - const onNavigate = useCallback((pageId: string) => { - const url = new URL(window.location.href); - url.searchParams.set(PARAM, pageId); - window.history.replaceState(null, '', url.toString()); - }, []); - - return { initialPage, onNavigate }; -} -``` - -URL becomes: `admin.php?page=my-plugin-settings&settings_page=appearance` - -The `initialPage` prop on `` seeds the active page from the URL on mount. `onNavigate` updates the URL without a page reload when the user switches pages. - ---- - -## CSS — WordPress Admin Conflicts - -### 1. Root Scoping - -All app output must be wrapped in `
`. All CSS resets must be scoped to these selectors to avoid leaking into WP admin. - -### 2. Scoped Tailwind Preflight + Utilities (Critical) - -WordPress admin loads its own global styles. Tailwind's default preflight resets affect everything on the page if applied globally. **Scope both preflight and utilities inside your root selector** so they only apply within your plugin's UI: - -```css -/* src/index.css */ -@import "tailwindcss"; -@source "./"; /* or path to your src dir */ -@import "@wedevs/plugin-ui/dist/index.css"; - -/* Replace with your plugin's actual mount-point selectors */ -.pui-root, -.your-plugin-root { - @import 'tailwindcss/preflight.css' layer(base) important; - @import 'tailwindcss/utilities.css' layer(utilities) important; - - @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentColor); - } - - &.dark *, - &.dark ::after, - &.dark ::before, - &.dark ::backdrop, - &.dark ::file-selector-button { - border-color: var(--color-gray-600, currentColor); - } - - button:not(:disabled), - [role='button']:not(:disabled) { - @apply cursor-pointer; - } - } -} -``` - -**Why scoping matters:** Without it, Tailwind preflight zeroes out all WP admin styles globally — breaking the admin menu, notices, and other plugins. With scoping, resets only apply inside your mount point. - -**The root selectors** are the `class` attributes on your React mount divs. Always include `.pui-root` (plugin-ui's own wrapper class) plus any top-level class your plugin adds. - -### 3. WordPress Admin Heading/Paragraph Overrides - -WP admin's stylesheet sets `h2, h3 { color: #1d2327; font-size: 1.3em }` and paragraph margins. These leak into your UI even with scoped preflight because they come from WP admin's own CSS, not Tailwind. Reset them explicitly: - -```css -.pui-root h1, .pui-root h2, .pui-root h3, -.pui-root h4, .pui-root h5, .pui-root h6 { - color: inherit !important; - font-size: inherit !important; - font-weight: inherit !important; - margin: 0; -} - -.pui-root p { - color: inherit !important; - font-size: inherit !important; - margin: 0; -} -``` - -### 4. Tailwind v3/v4 Transform Conflict - -Other WP plugins may use Tailwind v3 which generates: -```css -.-translate-y-1\/2 { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(...); } -``` - -plugin-ui uses Tailwind v4's individual `translate` CSS property. Both apply simultaneously causing double-translation. Fix: - -```css -/* Reset Tailwind v3 transform shorthand from other plugins */ -.pui-root [class*="translate-"], -.pui-root [class*="-translate-"] { - transform: none !important; -} -``` - -This is safe — Tailwind v4 uses `translate` (not `transform`) for positioning. - ---- - -## Extending Fields via Filter Hooks - -Every field variant is wrapped with `applyFilters(hookName, , element)`. - -**Hook name pattern:** `${hookPrefix}_settings_${variant}_field` - -Default `hookPrefix` is `'plugin_ui'`. Override via ``. - -### Override an existing variant - -```tsx -import { addFilter } from '@wordpress/hooks'; - -addFilter( - 'my_plugin_settings_text_field', - 'my-plugin/custom-text', - (DefaultComponent, element) => { - if (element.id === 'special_field') { - return ; - } - return DefaultComponent; - } -); -``` - -### Register a completely new variant - -In PHP schema: `'variant' => 'date_picker'`. Unknown variants hit the `default` switch case and fire `${hookPrefix}_settings_date_picker_field` with `` as the default: - -```tsx -addFilter( - 'my_plugin_settings_date_picker_field', - 'my-plugin/date-picker', - (_Fallback, element) => -); -``` - -### Wire applyFilters to `` - -The `applyFilters` prop must receive the actual `@wordpress/hooks` function — otherwise filters are no-ops: - -```tsx -import { applyFilters } from '@wordpress/hooks'; - - -``` - ---- - -## PHP Unit Testing - -Use `WP_Test_REST_TestCase` to test the REST endpoints: - -```php -class SettingsApiTest extends WP_Test_REST_TestCase { - protected $server; - protected $admin_id; - - public function set_up(): void { - parent::set_up(); - global $wp_rest_server; - $this->server = $wp_rest_server = new WP_REST_Server(); - do_action('rest_api_init'); - $this->admin_id = $this->factory->user->create(['role' => 'administrator']); - wp_set_current_user($this->admin_id); - delete_option('my_plugin_settings'); - } - - private function post_request(string $path, array $body): array { - $request = new WP_REST_Request('POST', $path); - $request->set_header('Content-Type', 'application/json'); - $request->set_body(wp_json_encode($body)); - $response = $this->server->dispatch($request); - $this->assertNotWPError($response); - return $response->get_data(); - } -} -``` - -**Test the section-grouped payload** — not flat keys: -```php -// CORRECT -$this->post_request('/my-plugin/v1/settings', [ - 'app_identity' => ['tag_line' => 'My App', 'app_logo' => ''], -]); - -// WRONG — old flat format, will not be read by sanitize() -$this->post_request('/my-plugin/v1/settings', ['tag_line' => 'My App']); -``` - -**Test partial save preserves other sections:** -```php -// Call schema->save() directly to bypass WP REST arg defaults injection -$schema->save(['app_identity' => ['tag_line' => 'Original', 'app_logo' => '']]); -$schema->save(['appearance' => ['primary_color' => '#111111']]); - -$saved = get_option('my_option_key'); -$this->assertSame('Original', $saved['tag_line']); // preserved -$this->assertSame('#111111', $saved['primary_color']); // updated -``` - ---- - -## Common Pitfalls Checklist - -| Pitfall | Fix | -|---|---| -| Using `'password'` variant | Use `'show_hide'` | -| Field IDs don't match WP REST args properties | Align IDs — WP REST strips unknown properties | -| WP REST arg properties have `required: true` | Set `required: false` to allow partial saves | -| `save()` overwrites the entire option | Use `array_merge($existing, $sanitized)` | -| `sanitize_hex_color()` returns null | Only pass valid hex strings; never use placeholder values | -| Tailwind preflight applied globally | Scope `preflight.css` + `utilities.css` inside root selector | -| Double-translation from Tailwind v3 conflict | Add `transform: none !important` reset inside `.pui-root` | -| WP admin h2/h3/p styles leaking in | Reset `color`, `font-size`, `margin` inside `.pui-root` | -| Active page lost on reload | Use `useSettingsPage` hook with `initialPage` + `onNavigate` | -| Toast not appearing | Add `` inside `
` | -| `fetchSchema()` not called after save | Always call it — server sanitizes values | -| Filters not running | Pass `applyFilters` from `@wordpress/hooks` to `` | - ---- - -## Steps for a New Integration - -1. **PHP**: Create `{Integration}SettingsSchema extends AbstractSettingsSchema` — implement `slug()`, `sections()`, `fields()`, `sanitize()` -2. **PHP**: Create `{Integration}SettingsController extends WP_REST_Controller` — register `GET /settings/schema`, `GET /settings`, `POST /settings` -3. **PHP**: Register the controller in your `ServiceProvider` / `Integration` class -4. **Frontend**: Create an app component with `ThemeProvider + pui-root + Settings + Toaster` -5. **Frontend**: Implement `useSettings(schemaEndpoint, saveEndpoint)` and `useSettingsPage()` hooks -6. **Frontend**: Pass `initialPage` + `onNavigate` to ``; pass `applyFilters` if using filter hooks -7. **CSS**: Scope Tailwind preflight/utilities inside your root selectors; add heading/paragraph resets and transform conflict fix -8. **Tests**: Write `WP_Test_REST_TestCase` tests covering schema, fetch, save each section, partial save, sanitization, auth guard +--- +name: "Settings Integration" +description: Guide for integrating plugin-ui into a WordPress plugin — covers PHP schema, REST API, frontend wiring, CSS, testing, and common pitfalls. +category: Integration +tags: [settings, plugin-ui, wordpress, php, rest-api, react] +--- + +You are helping a developer integrate the `@wedevs/plugin-ui` `` component into a WordPress plugin. + +When invoked with `/settings-integration`, read the current codebase to understand the integration state, then guide, scaffold, or fix whatever the user asks. + +--- + +## First-Time Setup + +### 1. Install the package + +plugin-ui is a **private weDevs package** — not on npm. Choose based on your intent: + +**Using plugin-ui (consumer — no changes needed):** + +```bash +# Install directly from GitHub +pnpm add github:getdokan/plugin-ui +``` + +Or pin a specific tag/branch: + +```bash +pnpm add github:getdokan/plugin-ui#main +pnpm add github:getdokan/plugin-ui#v2.0.0 +``` + +**Contributing to plugin-ui (local development):** + +Clone plugin-ui alongside your plugin, then reference it by local path: + +```json +// package.json +"dependencies": { + "@wedevs/plugin-ui": "file:../plugin-ui" +} +``` + +```bash +pnpm install +``` + +With the local path approach, changes you make in `../plugin-ui/src` are picked up after rebuilding plugin-ui (`pnpm run build` inside plugin-ui). If TypeScript types drift after a rebuild, force-copy the updated dist: + +```bash +cp -r ../plugin-ui/dist/* node_modules/@wedevs/plugin-ui/dist/ +``` + +### 2. Peer dependencies + +```bash +pnpm add react react-dom @wordpress/i18n @wordpress/api-fetch @wordpress/hooks +pnpm add -D @types/react @types/react-dom +``` + +### 3. Enqueue in PHP + +```php +wp_enqueue_script( + 'my-plugin-admin', + plugin_dir_url(__FILE__) . 'assets/js/main.js', + [ 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-hooks' ], + MY_PLUGIN_VERSION, + true +); +wp_enqueue_style( + 'my-plugin-admin', + plugin_dir_url(__FILE__) . 'assets/css/main.css', + [], + MY_PLUGIN_VERSION +); +// Provide REST nonce for apiFetch +wp_add_inline_script( + 'my-plugin-admin', + sprintf( + 'wp.apiFetch.use( wp.apiFetch.createNonceMiddleware( "%s" ) );', + wp_create_nonce( 'wp_rest' ) + ), + 'after' +); +``` + +### 4. Mount point in PHP template + +```php +// In your admin page callback +echo '
'; +``` + +### 5. React entry point + +```tsx +// src/index.tsx +import { createRoot } from 'react-dom/client'; +import './index.css'; +import MyApp from './apps/my-plugin'; + +const mountPoint = document.getElementById('my-plugin-settings'); +if (mountPoint) { + createRoot(mountPoint).render(); +} +``` + +--- + +## Architecture Overview + +The integration has two layers that must stay in sync: + +``` +PHP (Backend) React (Frontend) +───────────────────────────────── ──────────────────────────────────── +AbstractSettingsSchema useSettings hook + └─ get_schema() → flat array ──► schema[] → plugin-ui + └─ save(data) ◄── onSave(treeValues) + └─ get_option_key() useSettingsPage hook (query params) + +WP_REST_Controller + └─ GET /settings/schema ──► schemaEndpoint + └─ GET /settings ──► (values merged into schema) + └─ POST /settings ◄── saveEndpoint +``` + +--- + +## PHP Backend + +### 1. AbstractSettingsSchema + +Every integration extends this abstract class. The key contract: + +```php +abstract class AbstractSettingsSchema implements SettingsSchemaInterface { + abstract protected function slug(): string; // e.g. 'my-integration' + + // Override to add integration-specific pages/sections/fields + protected function pages(): array { return []; } + protected function sections(): array { return []; } + protected function fields(array $settings): array { return []; } + + // Override to add integration-specific sanitization + protected function sanitize(array $data): array { + return $this->sanitize_common($data); // always call parent + } +} +``` + +**Option key** is derived from `slug()` — check your base class implementation for the exact pattern (e.g. `my_plugin_{slug}_settings`). + +### 2. Field Definition Structure + +```php +[ + 'id' => 'tag_line', // CRITICAL: must match WP REST args key for nested objects + 'type' => 'field', + 'variant' => 'text', // see variant table below + 'label' => __('Tag Line', 'my-plugin'), + 'section_id' => 'app_identity', // groups this field under its section + 'value' => $settings['tag_line'] ?? '', + 'description' => __('App tagline text.', 'my-plugin'), +] +``` + +**Available variants:** +| Variant | Use for | +|---|---| +| `text` | Plain text input | +| `show_hide` | Secrets/passwords — eye toggle button | +| `color_picker` | Hex color values | +| `switch` | Boolean toggle | +| `wp_media_upload` | Image/media URLs | +| `number` | Numeric values | +| `select` | Dropdown | +| `textarea` | Multi-line text | +| `rich_text` | WYSIWYG editor | + +**Never use `'password'` variant** — use `'show_hide'` instead. + +### 3. Section-Grouped Payload (Critical) + +plugin-ui's `enrichNode()` **always overwrites** any PHP-set `dependency_key` with: +``` +child.dependency_key = parent.dependency_key + '.' + child.id +``` + +So a field `id='tag_line'` under section `id='app_identity'` gets `dependency_key='app_identity.tag_line'`. + +`handleOnSave` splits on `.` to build `treeValues`: +```json +{ "app_identity": { "tag_line": "My App", "app_logo": "https://..." } } +``` + +**PHP `sanitize()` must read section-grouped data:** +```php +protected function sanitize(array $data): array { + $result = $this->sanitize_common($data); // handles common sections + + if (isset($data['app_identity'])) { + $s = $data['app_identity']; + $result['tag_line'] = sanitize_text_field($s['tag_line'] ?? ''); + $result['app_logo'] = esc_url_raw($s['app_logo'] ?? ''); + } + return $result; +} +``` + +### 4. Nested Object Fields and WP REST Args + +For nested objects, field **IDs must match the WP REST args property names**: + +```php +// CORRECT — field id matches REST args property key +[ 'id' => 'app_id', 'section_id' => 'push_notifications', ... ] +[ 'id' => 'api_key', 'section_id' => 'push_notifications', ... ] + +// WRONG — WP REST strips 'push_app_id' (not in registered properties) +[ 'id' => 'push_app_id', 'section_id' => 'push_notifications', ... ] +``` + +WP REST **strips unknown properties** from registered `object` args. If stripped, the object becomes `{}` and `required: true` properties fail validation. + +**Set `'required' => false` on nested properties** to allow partial saves: +```php +'push_notifications' => [ + 'required' => false, + 'type' => 'object', + 'properties' => [ + 'app_id' => [ 'required' => false, 'type' => 'string', 'default' => '' ], + 'api_key' => [ 'required' => false, 'type' => 'string', 'default' => '' ], + ], +], +``` + +### 5. Partial Save / Merge Pattern + +`save()` must **merge** with existing data, not overwrite: +```php +public function save(array $data): void { + $existing = get_option($this->get_option_key(), []); + $sanitized = $this->sanitize($data); + update_option($this->get_option_key(), array_merge($existing, $sanitized), false); +} +``` + +The frontend only sends the current page's sections on save. `array_merge` ensures other sections are preserved. + +### 6. sanitize_hex_color Gotcha + +`sanitize_hex_color()` returns `null` for invalid hex strings. Always use real hex values in defaults and tests (e.g. `'#FF9472'`, not placeholder strings like `'#MYCOLOR'`). + +--- + +## Frontend + +### 1. App Structure + +```tsx +import { ThemeProvider, Settings, Toaster } from '@wedevs/plugin-ui'; +import { __ } from '@wordpress/i18n'; +import { useSettings, useSettingsPage } from '../../hooks'; + +const MyApp = () => { + const { schema, values, isLoading, onChange, onSave } = useSettings( + 'my-plugin/v1/settings/schema', + 'my-plugin/v1/settings' + ); + const { initialPage, onNavigate } = useSettingsPage(); + + return ( + +
+ + +
+
+ ); +}; +``` + +Pass `loading={isLoading}` directly to `` — it renders a `` automatically while data loads. No manual loading guard needed. + +### 2. useSettings Hook + +Implement in `src/hooks/useSettings.ts`: + +```ts +import { useState, useCallback } from 'react'; +import apiFetch from '@wordpress/api-fetch'; +import { toast } from '@wedevs/plugin-ui'; +import { __ } from '@wordpress/i18n'; + +export function useSettings(schemaEndpoint: string, saveEndpoint: string) { + const [schema, setSchema] = useState([]); + const [values, setValues] = useState({}); + const [isLoading, setIsLoading] = useState(true); + + const fetchSchema = useCallback(async () => { + const data = await apiFetch({ path: schemaEndpoint }); + setSchema(data.schema); + setValues(data.values); + setIsLoading(false); + }, [schemaEndpoint]); + + const onChange = useCallback((key: string, value: any) => { + setValues((prev) => ({ ...prev, [key]: value })); + }, []); + + const onSave = useCallback( + async (_scopeId: string, treeValues: Record) => { + try { + await apiFetch({ path: saveEndpoint, method: 'POST', data: treeValues }); + await fetchSchema(); // always refresh — server is source of truth + toast.success(__('Settings saved.', 'my-plugin')); + } catch (err: any) { + toast.error(err?.message ?? __('Failed to save settings.', 'my-plugin')); + } + }, + [saveEndpoint, fetchSchema] + ); + + return { schema, values, isLoading, onChange, onSave }; +} +``` + +**Always `fetchSchema()` after save** — the server sanitizes values (e.g. strips invalid hex) and the UI must reflect the canonical saved state. + +### 3. useSettingsPage Hook (Query Param Persistence) + +Implement in `src/hooks/useSettingsPage.ts`: + +```ts +import { useCallback } from 'react'; + +const PARAM = 'settings_page'; + +export function useSettingsPage() { + const initialPage = new URLSearchParams(window.location.search).get(PARAM) ?? undefined; + + const onNavigate = useCallback((pageId: string) => { + const url = new URL(window.location.href); + url.searchParams.set(PARAM, pageId); + window.history.replaceState(null, '', url.toString()); + }, []); + + return { initialPage, onNavigate }; +} +``` + +URL becomes: `admin.php?page=my-plugin-settings&settings_page=appearance` + +The `initialPage` prop on `` seeds the active page from the URL on mount. `onNavigate` updates the URL without a page reload when the user switches pages. + +--- + +## CSS — WordPress Admin Conflicts + +### 1. Root Scoping + +All app output must be wrapped in `
`. All CSS resets must be scoped to these selectors to avoid leaking into WP admin. + +### 2. Scoped Tailwind Preflight + Utilities (Critical) + +WordPress admin loads its own global styles. Tailwind's default preflight resets affect everything on the page if applied globally. **Scope both preflight and utilities inside your root selector** so they only apply within your plugin's UI: + +```css +/* src/index.css */ +@import "tailwindcss"; +@source "./"; /* or path to your src dir */ +@import "@wedevs/plugin-ui/dist/index.css"; + +/* Replace with your plugin's actual mount-point selectors */ +.pui-root, +.your-plugin-root { + @import 'tailwindcss/preflight.css' layer(base) important; + @import 'tailwindcss/utilities.css' layer(utilities) important; + + @layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } + + &.dark *, + &.dark ::after, + &.dark ::before, + &.dark ::backdrop, + &.dark ::file-selector-button { + border-color: var(--color-gray-600, currentColor); + } + + button:not(:disabled), + [role='button']:not(:disabled) { + @apply cursor-pointer; + } + } +} +``` + +**Why scoping matters:** Without it, Tailwind preflight zeroes out all WP admin styles globally — breaking the admin menu, notices, and other plugins. With scoping, resets only apply inside your mount point. + +**The root selectors** are the `class` attributes on your React mount divs. Always include `.pui-root` (plugin-ui's own wrapper class) plus any top-level class your plugin adds. + +### 3. WordPress Admin Heading/Paragraph Overrides + +WP admin's stylesheet sets `h2, h3 { color: #1d2327; font-size: 1.3em }` and paragraph margins. These leak into your UI even with scoped preflight because they come from WP admin's own CSS, not Tailwind. Reset them explicitly: + +```css +.pui-root h1, .pui-root h2, .pui-root h3, +.pui-root h4, .pui-root h5, .pui-root h6 { + color: inherit !important; + font-size: inherit !important; + font-weight: inherit !important; + margin: 0; +} + +.pui-root p { + color: inherit !important; + font-size: inherit !important; + margin: 0; +} +``` + +### 4. Tailwind v3/v4 Transform Conflict + +Other WP plugins may use Tailwind v3 which generates: +```css +.-translate-y-1\/2 { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(...); } +``` + +plugin-ui uses Tailwind v4's individual `translate` CSS property. Both apply simultaneously causing double-translation. Fix: + +```css +/* Reset Tailwind v3 transform shorthand from other plugins */ +.pui-root [class*="translate-"], +.pui-root [class*="-translate-"] { + transform: none !important; +} +``` + +This is safe — Tailwind v4 uses `translate` (not `transform`) for positioning. + +--- + +## Extending Fields via Filter Hooks + +Every field variant is wrapped with `applyFilters(hookName, , element)`. + +**Hook name pattern:** `${hookPrefix}_settings_${variant}_field` + +Default `hookPrefix` is `'plugin_ui'`. Override via ``. + +### Override an existing variant + +```tsx +import { addFilter } from '@wordpress/hooks'; + +addFilter( + 'my_plugin_settings_text_field', + 'my-plugin/custom-text', + (DefaultComponent, element) => { + if (element.id === 'special_field') { + return ; + } + return DefaultComponent; + } +); +``` + +### Register a completely new variant + +In PHP schema: `'variant' => 'date_picker'`. Unknown variants hit the `default` switch case and fire `${hookPrefix}_settings_date_picker_field` with `` as the default: + +```tsx +addFilter( + 'my_plugin_settings_date_picker_field', + 'my-plugin/date-picker', + (_Fallback, element) => +); +``` + +### Wire applyFilters to `` + +The `applyFilters` prop must receive the actual `@wordpress/hooks` function — otherwise filters are no-ops: + +```tsx +import { applyFilters } from '@wordpress/hooks'; + + +``` + +--- + +## PHP Unit Testing + +Use `WP_Test_REST_TestCase` to test the REST endpoints: + +```php +class SettingsApiTest extends WP_Test_REST_TestCase { + protected $server; + protected $admin_id; + + public function set_up(): void { + parent::set_up(); + global $wp_rest_server; + $this->server = $wp_rest_server = new WP_REST_Server(); + do_action('rest_api_init'); + $this->admin_id = $this->factory->user->create(['role' => 'administrator']); + wp_set_current_user($this->admin_id); + delete_option('my_plugin_settings'); + } + + private function post_request(string $path, array $body): array { + $request = new WP_REST_Request('POST', $path); + $request->set_header('Content-Type', 'application/json'); + $request->set_body(wp_json_encode($body)); + $response = $this->server->dispatch($request); + $this->assertNotWPError($response); + return $response->get_data(); + } +} +``` + +**Test the section-grouped payload** — not flat keys: +```php +// CORRECT +$this->post_request('/my-plugin/v1/settings', [ + 'app_identity' => ['tag_line' => 'My App', 'app_logo' => ''], +]); + +// WRONG — old flat format, will not be read by sanitize() +$this->post_request('/my-plugin/v1/settings', ['tag_line' => 'My App']); +``` + +**Test partial save preserves other sections:** +```php +// Call schema->save() directly to bypass WP REST arg defaults injection +$schema->save(['app_identity' => ['tag_line' => 'Original', 'app_logo' => '']]); +$schema->save(['appearance' => ['primary_color' => '#111111']]); + +$saved = get_option('my_option_key'); +$this->assertSame('Original', $saved['tag_line']); // preserved +$this->assertSame('#111111', $saved['primary_color']); // updated +``` + +--- + +## Common Pitfalls Checklist + +| Pitfall | Fix | +|---|---| +| Using `'password'` variant | Use `'show_hide'` | +| Field IDs don't match WP REST args properties | Align IDs — WP REST strips unknown properties | +| WP REST arg properties have `required: true` | Set `required: false` to allow partial saves | +| `save()` overwrites the entire option | Use `array_merge($existing, $sanitized)` | +| `sanitize_hex_color()` returns null | Only pass valid hex strings; never use placeholder values | +| Tailwind preflight applied globally | Scope `preflight.css` + `utilities.css` inside root selector | +| Double-translation from Tailwind v3 conflict | Add `transform: none !important` reset inside `.pui-root` | +| WP admin h2/h3/p styles leaking in | Reset `color`, `font-size`, `margin` inside `.pui-root` | +| Active page lost on reload | Use `useSettingsPage` hook with `initialPage` + `onNavigate` | +| Toast not appearing | Add `` inside `
` | +| `fetchSchema()` not called after save | Always call it — server sanitizes values | +| Filters not running | Pass `applyFilters` from `@wordpress/hooks` to `` | + +--- + +## Steps for a New Integration + +1. **PHP**: Create `{Integration}SettingsSchema extends AbstractSettingsSchema` — implement `slug()`, `sections()`, `fields()`, `sanitize()` +2. **PHP**: Create `{Integration}SettingsController extends WP_REST_Controller` — register `GET /settings/schema`, `GET /settings`, `POST /settings` +3. **PHP**: Register the controller in your `ServiceProvider` / `Integration` class +4. **Frontend**: Create an app component with `ThemeProvider + pui-root + Settings + Toaster` +5. **Frontend**: Implement `useSettings(schemaEndpoint, saveEndpoint)` and `useSettingsPage()` hooks +6. **Frontend**: Pass `initialPage` + `onNavigate` to ``; pass `applyFilters` if using filter hooks +7. **CSS**: Scope Tailwind preflight/utilities inside your root selectors; add heading/paragraph resets and transform conflict fix +8. **Tests**: Write `WP_Test_REST_TestCase` tests covering schema, fetch, save each section, partial save, sanitization, auth guard diff --git a/.gitignore b/.gitignore index 6aea623..0ae3e09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -/node_modules -/dist -storybook-static -debug-storybook.log -.vscode \ No newline at end of file +/node_modules +/dist +storybook-static +debug-storybook.log +.vscode +config.bat diff --git a/.storybook/babel-plugin-story-source.js b/.storybook/babel-plugin-story-source.js index 55fad25..8c5287a 100644 --- a/.storybook/babel-plugin-story-source.js +++ b/.storybook/babel-plugin-story-source.js @@ -1,132 +1,132 @@ -/** - * Babel plugin that extracts wrapper/demo function source code and injects it - * as `parameters.docs.source.code` into Storybook story exports. - * - * Problem: Stories that use `render: () => ` cause Storybook's - * "Show code" panel to display `` instead of the actual - * component usage inside the wrapper. - * - * Solution: At compile time, this plugin: - * 1. Collects all top-level non-exported function declarations (the wrappers). - * 2. For each story export whose render body is ``, it copies - * the wrapper function's original source into - * `parameters.docs.source.code` so Storybook displays it verbatim. - */ -module.exports = function storySourcePlugin({ types: t }) { - return { - name: 'story-source-injector', - visitor: { - Program: { - enter(programPath, state) { - const fileSource = state.file.code; - if (!fileSource) return; - - // ── Step 1: collect wrapper functions ──────────────────────── - const wrapperFunctions = new Map(); - - for (const stmt of programPath.get('body')) { - // Skip any exported declaration - if ( - stmt.isExportNamedDeclaration() || - stmt.isExportDefaultDeclaration() - ) { - continue; - } - - if (stmt.isFunctionDeclaration()) { - const name = stmt.node.id?.name; - if (!name) continue; - - const { start, end } = stmt.node; - if (start == null || end == null) continue; - - wrapperFunctions.set(name, fileSource.slice(start, end)); - } - } - - if (wrapperFunctions.size === 0) return; - - // ── Step 2: inject source into matching story exports ──────── - for (const stmt of programPath.get('body')) { - if (!stmt.isExportNamedDeclaration()) continue; - - const decl = stmt.get('declaration'); - if (!decl.isVariableDeclaration()) continue; - - for (const declarator of decl.get('declarations')) { - let init = declarator.get('init'); - - // Unwrap `{ ... } satisfies Type` or `{ ... } as Type` - if ( - init.isTSSatisfiesExpression?.() || - init.isTSAsExpression?.() - ) { - init = init.get('expression'); - } - - if (!init.isObjectExpression()) continue; - - const properties = init.get('properties'); - - // Find `render` property - const renderProp = properties.find( - (p) => - p.isObjectProperty() && - p.get('key').isIdentifier({ name: 'render' }) - ); - if (!renderProp) continue; - - const renderVal = renderProp.get('value'); - if (!renderVal.isArrowFunctionExpression()) continue; - - const renderBody = renderVal.get('body'); - if (!renderBody.isJSXElement()) continue; - - // Must be a self-closing element: - const opening = renderBody.get('openingElement'); - const nameNode = opening.get('name'); - if (!nameNode.isJSXIdentifier()) continue; - - const wrapperName = nameNode.node.name; - if (!wrapperFunctions.has(wrapperName)) continue; - - // Skip stories that already define their own `parameters` - const hasParams = properties.some( - (p) => - p.isObjectProperty() && - p.get('key').isIdentifier({ name: 'parameters' }) - ); - if (hasParams) continue; - - // Build: parameters: { docs: { source: { code, language } } } - const sourceCode = wrapperFunctions.get(wrapperName); - - const sourceObj = t.objectExpression([ - t.objectProperty( - t.identifier('code'), - t.stringLiteral(sourceCode) - ), - t.objectProperty( - t.identifier('language'), - t.stringLiteral('tsx') - ), - ]); - - const docsObj = t.objectExpression([ - t.objectProperty(t.identifier('source'), sourceObj), - ]); - - const paramsObj = t.objectExpression([ - t.objectProperty(t.identifier('docs'), docsObj), - ]); - - init.node.properties.push( - t.objectProperty(t.identifier('parameters'), paramsObj) - ); - } - } - }, - }, - }, - }; -}; +/** + * Babel plugin that extracts wrapper/demo function source code and injects it + * as `parameters.docs.source.code` into Storybook story exports. + * + * Problem: Stories that use `render: () => ` cause Storybook's + * "Show code" panel to display `` instead of the actual + * component usage inside the wrapper. + * + * Solution: At compile time, this plugin: + * 1. Collects all top-level non-exported function declarations (the wrappers). + * 2. For each story export whose render body is ``, it copies + * the wrapper function's original source into + * `parameters.docs.source.code` so Storybook displays it verbatim. + */ +module.exports = function storySourcePlugin({ types: t }) { + return { + name: 'story-source-injector', + visitor: { + Program: { + enter(programPath, state) { + const fileSource = state.file.code; + if (!fileSource) return; + + // ── Step 1: collect wrapper functions ──────────────────────── + const wrapperFunctions = new Map(); + + for (const stmt of programPath.get('body')) { + // Skip any exported declaration + if ( + stmt.isExportNamedDeclaration() || + stmt.isExportDefaultDeclaration() + ) { + continue; + } + + if (stmt.isFunctionDeclaration()) { + const name = stmt.node.id?.name; + if (!name) continue; + + const { start, end } = stmt.node; + if (start == null || end == null) continue; + + wrapperFunctions.set(name, fileSource.slice(start, end)); + } + } + + if (wrapperFunctions.size === 0) return; + + // ── Step 2: inject source into matching story exports ──────── + for (const stmt of programPath.get('body')) { + if (!stmt.isExportNamedDeclaration()) continue; + + const decl = stmt.get('declaration'); + if (!decl.isVariableDeclaration()) continue; + + for (const declarator of decl.get('declarations')) { + let init = declarator.get('init'); + + // Unwrap `{ ... } satisfies Type` or `{ ... } as Type` + if ( + init.isTSSatisfiesExpression?.() || + init.isTSAsExpression?.() + ) { + init = init.get('expression'); + } + + if (!init.isObjectExpression()) continue; + + const properties = init.get('properties'); + + // Find `render` property + const renderProp = properties.find( + (p) => + p.isObjectProperty() && + p.get('key').isIdentifier({ name: 'render' }) + ); + if (!renderProp) continue; + + const renderVal = renderProp.get('value'); + if (!renderVal.isArrowFunctionExpression()) continue; + + const renderBody = renderVal.get('body'); + if (!renderBody.isJSXElement()) continue; + + // Must be a self-closing element: + const opening = renderBody.get('openingElement'); + const nameNode = opening.get('name'); + if (!nameNode.isJSXIdentifier()) continue; + + const wrapperName = nameNode.node.name; + if (!wrapperFunctions.has(wrapperName)) continue; + + // Skip stories that already define their own `parameters` + const hasParams = properties.some( + (p) => + p.isObjectProperty() && + p.get('key').isIdentifier({ name: 'parameters' }) + ); + if (hasParams) continue; + + // Build: parameters: { docs: { source: { code, language } } } + const sourceCode = wrapperFunctions.get(wrapperName); + + const sourceObj = t.objectExpression([ + t.objectProperty( + t.identifier('code'), + t.stringLiteral(sourceCode) + ), + t.objectProperty( + t.identifier('language'), + t.stringLiteral('tsx') + ), + ]); + + const docsObj = t.objectExpression([ + t.objectProperty(t.identifier('source'), sourceObj), + ]); + + const paramsObj = t.objectExpression([ + t.objectProperty(t.identifier('docs'), docsObj), + ]); + + init.node.properties.push( + t.objectProperty(t.identifier('parameters'), paramsObj) + ); + } + } + }, + }, + }, + }; +}; diff --git a/.storybook/main.ts b/.storybook/main.ts index f3cd9b2..deeeed8 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,130 +1,130 @@ -import type { StorybookConfig } from "@storybook/react-webpack5"; -import { createRequire } from "module"; -import path from "path"; -import { fileURLToPath } from "url"; - - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const require = createRequire(import.meta.url); - -// Base path for deployment to subpath (e.g. GitHub Pages: https://owner.github.io/repo/) -const basePath = process.env.STORYBOOK_BASE_PATH || "/"; - -const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|tsx|js|jsx)"], - staticDirs: ["./static"], - addons: [ - "@storybook/addon-a11y", - "@chromatic-com/storybook", - "@storybook/addon-docs", - "@storybook/addon-themes" - ], - - framework: { - name: "@storybook/react-webpack5", - options: {}, - }, - - webpackFinal: async (config) => { - // Use relative publicPath when deployed to a subpath (e.g. GitHub Pages) so chunk URLs - // resolve as ./3285....js under the current path instead of /plugin-ui//plugin-ui/... - if (basePath !== "/" && config.output) { - config.output.publicPath = "./"; - } - // Provide React globally so story/component bundles that reference React (e.g. React.createElement) don't throw - const { ProvidePlugin } = require("webpack"); - config.plugins = config.plugins ?? []; - config.plugins.push( - new ProvidePlugin({ - React: [require.resolve("react"), "default"], - }) - ); - config.resolve = config.resolve ?? {}; - - const projectRoot = path.resolve(__dirname, ".."); - - config.resolve.alias = { - ...config.resolve.alias, - "@": path.resolve(projectRoot, "src"), - - // Ensure a SINGLE React instance (prevents hook + context crashes) - react: path.resolve(projectRoot, "node_modules/react"), - "react-dom": path.resolve(projectRoot, "node_modules/react-dom"), - }; - - // -------------------------------------------------- - // POSTCSS (Tailwind) support - // -------------------------------------------------- - const postcssLoader = { - loader: require.resolve("postcss-loader"), - options: { postcssOptions: require("../postcss.config.js") }, - }; - - const rules = config.module?.rules ?? []; - - for (const rule of rules) { - if ( - rule && - typeof rule === "object" && - rule.test instanceof RegExp && - rule.test.test("x.css") - ) { - const use = Array.isArray(rule.use) ? rule.use : [rule.use].filter(Boolean); - - const hasPostcss = use.some( - (u: unknown) => - typeof u === "object" && - u && - "loader" in (u as object) && - String((u as { loader?: string }).loader).includes("postcss") - ); - - if (!hasPostcss) rule.use = [...use, postcssLoader]; - break; - } - } - - // -------------------------------------------------- - // Transpile JSX/TS in story files so "Show code" shows readable JSX. - // Run Babel as a pre-loader so it runs before csf-plugin/export-order on story files. - // -------------------------------------------------- - const babelLoaderForStories = { - loader: require.resolve("babel-loader"), - options: { - plugins: [ - require.resolve("./babel-plugin-story-source"), - ], - presets: [ - [require.resolve("@babel/preset-react"), { runtime: "automatic" }], - [require.resolve("@babel/preset-typescript"), { allowDeclareFields: true }], - ], - }, - }; - config.module?.rules?.unshift({ - test: /\.stories\.(jsx|tsx)$/, - use: [babelLoaderForStories], - enforce: "pre" as const, - }); - - // Transpile TS/TSX from src (components imported by stories) - config.module?.rules?.push({ - test: /\.(ts|tsx)$/, - exclude: /node_modules/, - use: [ - { - loader: require.resolve("babel-loader"), - options: { - presets: [ - [require.resolve("@babel/preset-react"), { runtime: "automatic" }], - [require.resolve("@babel/preset-typescript"), { allowDeclareFields: true }], - ], - }, - }, - ], - }); - - return config; - }, -}; - +import type { StorybookConfig } from "@storybook/react-webpack5"; +import { createRequire } from "module"; +import path from "path"; +import { fileURLToPath } from "url"; + + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Base path for deployment to subpath (e.g. GitHub Pages: https://owner.github.io/repo/) +const basePath = process.env.STORYBOOK_BASE_PATH || "/"; + +const config: StorybookConfig = { + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(ts|tsx|js|jsx)"], + staticDirs: ["./static"], + addons: [ + "@storybook/addon-a11y", + "@chromatic-com/storybook", + "@storybook/addon-docs", + "@storybook/addon-themes" + ], + + framework: { + name: "@storybook/react-webpack5", + options: {}, + }, + + webpackFinal: async (config) => { + // Use relative publicPath when deployed to a subpath (e.g. GitHub Pages) so chunk URLs + // resolve as ./3285....js under the current path instead of /plugin-ui//plugin-ui/... + if (basePath !== "/" && config.output) { + config.output.publicPath = "./"; + } + // Provide React globally so story/component bundles that reference React (e.g. React.createElement) don't throw + const { ProvidePlugin } = require("webpack"); + config.plugins = config.plugins ?? []; + config.plugins.push( + new ProvidePlugin({ + React: [require.resolve("react"), "default"], + }) + ); + config.resolve = config.resolve ?? {}; + + const projectRoot = path.resolve(__dirname, ".."); + + config.resolve.alias = { + ...config.resolve.alias, + "@": path.resolve(projectRoot, "src"), + + // Ensure a SINGLE React instance (prevents hook + context crashes) + react: path.resolve(projectRoot, "node_modules/react"), + "react-dom": path.resolve(projectRoot, "node_modules/react-dom"), + }; + + // -------------------------------------------------- + // POSTCSS (Tailwind) support + // -------------------------------------------------- + const postcssLoader = { + loader: require.resolve("postcss-loader"), + options: { postcssOptions: require("../postcss.config.js") }, + }; + + const rules = config.module?.rules ?? []; + + for (const rule of rules) { + if ( + rule && + typeof rule === "object" && + rule.test instanceof RegExp && + rule.test.test("x.css") + ) { + const use = Array.isArray(rule.use) ? rule.use : [rule.use].filter(Boolean); + + const hasPostcss = use.some( + (u: unknown) => + typeof u === "object" && + u && + "loader" in (u as object) && + String((u as { loader?: string }).loader).includes("postcss") + ); + + if (!hasPostcss) rule.use = [...use, postcssLoader]; + break; + } + } + + // -------------------------------------------------- + // Transpile JSX/TS in story files so "Show code" shows readable JSX. + // Run Babel as a pre-loader so it runs before csf-plugin/export-order on story files. + // -------------------------------------------------- + const babelLoaderForStories = { + loader: require.resolve("babel-loader"), + options: { + plugins: [ + require.resolve("./babel-plugin-story-source"), + ], + presets: [ + [require.resolve("@babel/preset-react"), { runtime: "automatic" }], + [require.resolve("@babel/preset-typescript"), { allowDeclareFields: true }], + ], + }, + }; + config.module?.rules?.unshift({ + test: /\.stories\.(jsx|tsx)$/, + use: [babelLoaderForStories], + enforce: "pre" as const, + }); + + // Transpile TS/TSX from src (components imported by stories) + config.module?.rules?.push({ + test: /\.(ts|tsx)$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve("babel-loader"), + options: { + presets: [ + [require.resolve("@babel/preset-react"), { runtime: "automatic" }], + [require.resolve("@babel/preset-typescript"), { allowDeclareFields: true }], + ], + }, + }, + ], + }); + + return config; + }, +}; + export default config; \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js index 7d7ffac..22e95c3 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,131 +1,131 @@ -import React from "react"; -import { ThemeProvider } from "../src/providers"; -import * as Themes from "../src/themes"; -import "../src/styles.css"; - -// Optional: theme-by-class-name decorator (light/dark on html); skip if addon not installed -let withThemeByClassName = null; -try { - // eslint-disable-next-line global-require -- optional addon - withThemeByClassName = require("@storybook/addon-themes").withThemeByClassName; -} catch { - // Addon not available; decorators below will omit it -} - -// Ensure React is available in the story iframe -if (typeof window !== "undefined") { - window.React = React; -} - -export const parameters = { - controls: { - matchers: { color: /(background|color)$/i, date: /Date$/i }, - expanded: true, - }, - layout: "centered", - a11y: { - test: "error", - }, - viewport: { - viewports: { - mobile: { - name: "Mobile", - styles: { width: "375px", height: "667px" }, - type: "mobile", - }, - tablet: { - name: "Tablet", - styles: { width: "768px", height: "1024px" }, - type: "tablet", - }, - desktop: { - name: "Desktop", - styles: { width: "1280px", height: "800px" }, - type: "desktop", - }, - }, - }, -}; - -export const globalTypes = { - brand: { - name: 'Brand', - description: 'Global brand theme', - defaultValue: 'default', - toolbar: { - icon: 'paintbrush', - items: [ - { value: 'default', title: 'Default' }, - { value: 'amber-minimal', title: 'Amber Minimal' }, - { value: 't3-chat', title: 'T3 Chat' }, - { value: 'midnight-bloom', title: 'Midnight Bloom' }, - { value: 'bubblegum', title: 'Bubblegum' }, - { value: 'cyberpunk', title: 'Cyberpunk' }, - { value: 'twitter', title: 'Twitter' }, - { value: 'slate', title: 'Slate' }, - { value: 'claude', title: 'Claude' }, - { value: 'claymorphism', title: 'Claymorphism' }, - { value: 'clean-slate', title: 'Clean Slate' }, - { value: 'modern-minimal', title: 'Modern Minimal' }, - { value: 'nature', title: 'Nature' }, - { value: 'neo-brutalism', title: 'Neo Brutalism' }, - { value: 'notebook', title: 'Notebook' }, - { value: 'ocean-breeze', title: 'Ocean Breeze' }, - { value: 'supabase', title: 'Supabase' }, - { value: 'terminal', title: 'Terminal' }, - { value: 'whatsapp', title: 'WhatsApp' }, - ], - showName: true, - }, - }, -}; - -export const decorators = [ - ...(withThemeByClassName - ? [ - withThemeByClassName({ - themes: { light: "light", dark: "dark" }, - defaultTheme: "light", - }), - ] - : []), - (Story, context) => { - const { brand } = context.globals; - const mode = context.globals.theme || 'light'; - - const themeMap = { - default: { tokens: Themes.defaultTheme, darkTokens: Themes.defaultDarkTheme }, - 'amber-minimal': { tokens: Themes.amberMinimalTheme, darkTokens: Themes.amberMinimalDarkTheme }, - 't3-chat': { tokens: Themes.t3ChatTheme, darkTokens: Themes.t3ChatDarkTheme }, - 'midnight-bloom': { tokens: Themes.midnightBloomTheme, darkTokens: Themes.midnightBloomDarkTheme }, - 'bubblegum': { tokens: Themes.bubblegumTheme, darkTokens: Themes.bubblegumDarkTheme }, - 'cyberpunk': { tokens: Themes.cyberpunkTheme, darkTokens: Themes.cyberpunkDarkTheme }, - 'twitter': { tokens: Themes.twitterTheme, darkTokens: Themes.twitterDarkTheme }, - slate: { tokens: Themes.slateTheme, darkTokens: Themes.slateDarkTheme }, - claude: { tokens: Themes.claudeTheme, darkTokens: Themes.claudeDarkTheme }, - claymorphism: { tokens: Themes.claymorphismTheme, darkTokens: Themes.claymorphismDarkTheme }, - 'clean-slate': { tokens: Themes.cleanSlateTheme, darkTokens: Themes.cleanSlateDarkTheme }, - 'modern-minimal': { tokens: Themes.modernMinimalTheme, darkTokens: Themes.modernMinimalDarkTheme }, - nature: { tokens: Themes.natureTheme, darkTokens: Themes.natureDarkTheme }, - 'neo-brutalism': { tokens: Themes.neoBrutalismTheme, darkTokens: Themes.neoBrutalismDarkTheme }, - notebook: { tokens: Themes.notebookTheme, darkTokens: Themes.notebookDarkTheme }, - 'ocean-breeze': { tokens: Themes.oceanBreezeTheme, darkTokens: Themes.oceanBreezeDarkTheme }, - supabase: { tokens: Themes.supabaseTheme, darkTokens: Themes.supabaseDarkTheme }, - terminal: { tokens: Themes.terminalTheme, darkTokens: Themes.terminalDarkTheme }, - whatsapp: { tokens: Themes.whatsappTheme, darkTokens: Themes.whatsappDarkTheme }, - }; - - const activeBrand = themeMap[brand] || themeMap.default; - - return React.createElement( - ThemeProvider, - { - pluginId: "storybook", - mode: mode, - tokens: activeBrand.tokens, - darkTokens: activeBrand.darkTokens, - }, - React.createElement("div", { className: "bg-background text-foreground min-h-[200px] p-6" }, React.createElement(Story)) - ); - }, -]; +import React from "react"; +import { ThemeProvider } from "../src/providers"; +import * as Themes from "../src/themes"; +import "../src/styles.css"; + +// Optional: theme-by-class-name decorator (light/dark on html); skip if addon not installed +let withThemeByClassName = null; +try { + // eslint-disable-next-line global-require -- optional addon + withThemeByClassName = require("@storybook/addon-themes").withThemeByClassName; +} catch { + // Addon not available; decorators below will omit it +} + +// Ensure React is available in the story iframe +if (typeof window !== "undefined") { + window.React = React; +} + +export const parameters = { + controls: { + matchers: { color: /(background|color)$/i, date: /Date$/i }, + expanded: true, + }, + layout: "centered", + a11y: { + test: "error", + }, + viewport: { + viewports: { + mobile: { + name: "Mobile", + styles: { width: "375px", height: "667px" }, + type: "mobile", + }, + tablet: { + name: "Tablet", + styles: { width: "768px", height: "1024px" }, + type: "tablet", + }, + desktop: { + name: "Desktop", + styles: { width: "1280px", height: "800px" }, + type: "desktop", + }, + }, + }, +}; + +export const globalTypes = { + brand: { + name: 'Brand', + description: 'Global brand theme', + defaultValue: 'default', + toolbar: { + icon: 'paintbrush', + items: [ + { value: 'default', title: 'Default' }, + { value: 'amber-minimal', title: 'Amber Minimal' }, + { value: 't3-chat', title: 'T3 Chat' }, + { value: 'midnight-bloom', title: 'Midnight Bloom' }, + { value: 'bubblegum', title: 'Bubblegum' }, + { value: 'cyberpunk', title: 'Cyberpunk' }, + { value: 'twitter', title: 'Twitter' }, + { value: 'slate', title: 'Slate' }, + { value: 'claude', title: 'Claude' }, + { value: 'claymorphism', title: 'Claymorphism' }, + { value: 'clean-slate', title: 'Clean Slate' }, + { value: 'modern-minimal', title: 'Modern Minimal' }, + { value: 'nature', title: 'Nature' }, + { value: 'neo-brutalism', title: 'Neo Brutalism' }, + { value: 'notebook', title: 'Notebook' }, + { value: 'ocean-breeze', title: 'Ocean Breeze' }, + { value: 'supabase', title: 'Supabase' }, + { value: 'terminal', title: 'Terminal' }, + { value: 'whatsapp', title: 'WhatsApp' }, + ], + showName: true, + }, + }, +}; + +export const decorators = [ + ...(withThemeByClassName + ? [ + withThemeByClassName({ + themes: { light: "light", dark: "dark" }, + defaultTheme: "light", + }), + ] + : []), + (Story, context) => { + const { brand } = context.globals; + const mode = context.globals.theme || 'light'; + + const themeMap = { + default: { tokens: Themes.defaultTheme, darkTokens: Themes.defaultDarkTheme }, + 'amber-minimal': { tokens: Themes.amberMinimalTheme, darkTokens: Themes.amberMinimalDarkTheme }, + 't3-chat': { tokens: Themes.t3ChatTheme, darkTokens: Themes.t3ChatDarkTheme }, + 'midnight-bloom': { tokens: Themes.midnightBloomTheme, darkTokens: Themes.midnightBloomDarkTheme }, + 'bubblegum': { tokens: Themes.bubblegumTheme, darkTokens: Themes.bubblegumDarkTheme }, + 'cyberpunk': { tokens: Themes.cyberpunkTheme, darkTokens: Themes.cyberpunkDarkTheme }, + 'twitter': { tokens: Themes.twitterTheme, darkTokens: Themes.twitterDarkTheme }, + slate: { tokens: Themes.slateTheme, darkTokens: Themes.slateDarkTheme }, + claude: { tokens: Themes.claudeTheme, darkTokens: Themes.claudeDarkTheme }, + claymorphism: { tokens: Themes.claymorphismTheme, darkTokens: Themes.claymorphismDarkTheme }, + 'clean-slate': { tokens: Themes.cleanSlateTheme, darkTokens: Themes.cleanSlateDarkTheme }, + 'modern-minimal': { tokens: Themes.modernMinimalTheme, darkTokens: Themes.modernMinimalDarkTheme }, + nature: { tokens: Themes.natureTheme, darkTokens: Themes.natureDarkTheme }, + 'neo-brutalism': { tokens: Themes.neoBrutalismTheme, darkTokens: Themes.neoBrutalismDarkTheme }, + notebook: { tokens: Themes.notebookTheme, darkTokens: Themes.notebookDarkTheme }, + 'ocean-breeze': { tokens: Themes.oceanBreezeTheme, darkTokens: Themes.oceanBreezeDarkTheme }, + supabase: { tokens: Themes.supabaseTheme, darkTokens: Themes.supabaseDarkTheme }, + terminal: { tokens: Themes.terminalTheme, darkTokens: Themes.terminalDarkTheme }, + whatsapp: { tokens: Themes.whatsappTheme, darkTokens: Themes.whatsappDarkTheme }, + }; + + const activeBrand = themeMap[brand] || themeMap.default; + + return React.createElement( + ThemeProvider, + { + pluginId: "storybook", + mode: mode, + tokens: activeBrand.tokens, + darkTokens: activeBrand.darkTokens, + }, + React.createElement("div", { className: "bg-background text-foreground min-h-[200px] p-6" }, React.createElement(Story)) + ); + }, +]; diff --git a/CLAUDE.md b/CLAUDE.md index 90cbbff..8563549 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,201 +1,201 @@ -# @wedevs/plugin-ui - -Scoped, themeable React component library for WordPress plugins. Built on ShadCN patterns, Tailwind CSS v4, and Base-UI primitives. - -## Architecture - -``` -src/ -├── components/ -│ ├── ui/ # Core ShadCN-style components (150+ exports) -│ ├── settings/ # Schema-driven settings system -│ └── wordpress/ # WordPress integration (Layout, DataViews) -├── providers/ # ThemeProvider (CSS variable injection) -├── themes/ # Built-in theme presets -├── hooks/ # useMobile, useWindowDimensions -└── lib/ # Utilities (cn, renderIcon, WpMedia, wordpress-date) -``` - -## Import Patterns - -```tsx -// Main entry (includes styles) -import { Settings, Button, ThemeProvider } from '@wedevs/plugin-ui'; - -// Sub-path exports -import { Settings } from '@wedevs/plugin-ui/settings'; -import { Button, Input } from '@wedevs/plugin-ui/components/ui'; -import { ThemeProvider } from '@wedevs/plugin-ui/providers'; -import { defaultTheme, createTheme } from '@wedevs/plugin-ui/themes'; -import { cn } from '@wedevs/plugin-ui/utils'; - -// Styles (import in your entry point) -import '@wedevs/plugin-ui/styles.css'; -``` - -## CSS Setup (Tailwind v4) - -```css -@import "tailwindcss"; -@import "@wedevs/plugin-ui/styles.css" layer(plugin-ui); -``` - -## Settings System - -Schema-driven settings page with hierarchical navigation, dependency evaluation, validation, and WordPress hook extensibility. - -### Element Hierarchy - -`page` → `subpage` → `tab` → `section` → `subsection` → `field` / `fieldgroup` - -### Basic Usage - -```tsx -import { Settings } from '@wedevs/plugin-ui'; - - keyed by dependency_key - onChange={(scopeId, key, value) => { - setValues(prev => ({ ...prev, [key]: value })); - }} - onSave={async (scopeId, treeValues, flatValues) => { - // treeValues: nested object built from dot-separated keys - // e.g. { dokan: { general: { store_name: "..." } } } - // flatValues: original flat dot-keyed values - // e.g. { "dokan.general.store_name": "..." } - await api.post(`/settings/${scopeId}`, treeValues); - }} - renderSaveButton={({ dirty, hasErrors, onSave }) => ( - - )} - hookPrefix="my_plugin" // WordPress filter hook prefix - applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility -/> -``` - -### Key Concepts - -- **`dependency_key`**: Unique key on each field element, used as the key in `values` and `flatValues` -- **Dependencies**: Elements can conditionally show/hide based on other field values via `dependencies` array -- **Validation**: Per-field `validations` array with rules and error messages -- **Dirty tracking**: Per-scope (subpage/page) dirty state; resets only on successful save -- **Error handling**: If `onSave` throws `{ errors: { fieldKey: "message" } }`, errors display on the relevant fields -- **Extensibility**: `applyFilters` enables WordPress hooks like `{hookPrefix}_settings_{variant}_field` - -### Settings Hooks - -```tsx -import { useSettings } from '@wedevs/plugin-ui'; - -const { - values, // All current field values - activePage, // Current page element - activeSubpage, // Current subpage element (if any) - activeTab, // Current tab element (if any) - isPageDirty, // (pageId) => boolean - getPageValues, // (pageId) => Record - errors, // Record validation errors -} = useSettings(); -``` - -## Theme System - -```tsx -import { ThemeProvider, createTheme } from '@wedevs/plugin-ui'; - -// Use a built-in preset - - - - -// Create a custom theme -const myTheme = createTheme({ - primary: '220 90% 56%', // HSL values (without hsl() wrapper) - background: '0 0% 100%', - foreground: '0 0% 3.9%', - // ... see ThemeTokens type for all available tokens -}); -``` - -Built-in presets: `defaultTheme`, `slateTheme`, `amberMinimalTheme`, `t3ChatTheme`, `midnightBloomTheme`, `bubblegumTheme`, `cyberpunkTheme`, `twitterTheme` (each with a dark variant). - -## UI Components - -### Form -`Input`, `Textarea`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `DatePicker`, `DateRangePicker`, `Calendar`, `CurrencyInput`, `InputOTP`, `RichTextEditor`, `FileUpload` - -Card variants: `CheckboxCard`, `RadioCard`, `SwitchCard` -Labeled variants: `LabeledCheckbox`, `LabeledRadio`, `LabeledSwitch` - -### Layout -`Card`, `Tabs`, `Separator`, `ScrollArea`, `Layout` (WordPress sidebar+content), `Sidebar` (shadcn primitives), `Field` (form control wrapper with label, description, error) - -### Data Display -`Badge`, `Avatar`, `AvatarGroup`, `Progress`, `CircularProgress`, `Skeleton`, `Spinner`, `Thumbnail`, `DataViews` (WordPress DataViews wrapper) - -### Overlay -`Modal`, `Sheet`, `Popover`, `Tooltip`, `DropdownMenu`, `AlertDialog`, `Toaster` (sonner toast) - -### Feedback -`Alert`, `Notice`, `toast()` (from sonner) - -### WordPress-Specific -`Layout` (sidebar layout with WordPress hook integration), `DataViews` (wraps `@wordpress/dataviews` with filter hooks), `LayoutMenu` (navigation menu) - -## WordPress Integration - -### Layout Component - -```tsx -import { Layout, LayoutHeader, LayoutBody, LayoutSidebar, LayoutMain } from '@wedevs/plugin-ui'; - - - Header - - - Content - - -``` - -### DataViews Component - -```tsx -import { DataViews } from '@wedevs/plugin-ui'; - - -``` - -Supports WordPress filter hooks: `{snakeNamespace}_dataviews_{elementName}` - -## Conventions - -- **Composition pattern**: All components use compound component pattern (e.g., `Card` + `CardHeader` + `CardContent`) -- **`cn()` utility**: Use for merging Tailwind classes — combines `clsx` + `tailwind-merge` -- **`Field` wrapper**: Use to wrap form controls with consistent label, description, and error display -- **No WordPress dependency in UI components**: Only `Layout`, `DataViews`, `DataForm`, and `Settings` (via `applyFilters`) touch WordPress APIs -- **Externals**: React, ReactDOM, and WordPress packages (`@wordpress/components`, `@wordpress/dataviews`, `@wordpress/hooks`, `@wordpress/i18n`, `@wordpress/date`) are externalized — consumers must provide them - -## Before Committing & Pushing - -GitHub CI runs these checks on PRs to `main`. Run them locally before pushing to avoid failures: - -```bash -npm run lint # ESLint (src/**/*.{ts,tsx}) -npm run typecheck # tsc --noEmit -``` - -Both must pass. The CI pipeline (`.github/workflows/ci.yml`) runs these on `ubuntu-latest` with Node 24. - -## Documentation - -- `src/components/settings/Settings.mdx` — Full settings API reference -- `DEVELOPER_GUIDE.md` — WordPress integration guide -- `src/DeveloperGuide.mdx` — Storybook developer guide +# @wedevs/plugin-ui + +Scoped, themeable React component library for WordPress plugins. Built on ShadCN patterns, Tailwind CSS v4, and Base-UI primitives. + +## Architecture + +``` +src/ +├── components/ +│ ├── ui/ # Core ShadCN-style components (150+ exports) +│ ├── settings/ # Schema-driven settings system +│ └── wordpress/ # WordPress integration (Layout, DataViews) +├── providers/ # ThemeProvider (CSS variable injection) +├── themes/ # Built-in theme presets +├── hooks/ # useMobile, useWindowDimensions +└── lib/ # Utilities (cn, renderIcon, WpMedia, wordpress-date) +``` + +## Import Patterns + +```tsx +// Main entry (includes styles) +import { Settings, Button, ThemeProvider } from '@wedevs/plugin-ui'; + +// Sub-path exports +import { Settings } from '@wedevs/plugin-ui/settings'; +import { Button, Input } from '@wedevs/plugin-ui/components/ui'; +import { ThemeProvider } from '@wedevs/plugin-ui/providers'; +import { defaultTheme, createTheme } from '@wedevs/plugin-ui/themes'; +import { cn } from '@wedevs/plugin-ui/utils'; + +// Styles (import in your entry point) +import '@wedevs/plugin-ui/styles.css'; +``` + +## CSS Setup (Tailwind v4) + +```css +@import "tailwindcss"; +@import "@wedevs/plugin-ui/styles.css" layer(plugin-ui); +``` + +## Settings System + +Schema-driven settings page with hierarchical navigation, dependency evaluation, validation, and WordPress hook extensibility. + +### Element Hierarchy + +`page` → `subpage` → `tab` → `section` → `subsection` → `field` / `fieldgroup` + +### Basic Usage + +```tsx +import { Settings } from '@wedevs/plugin-ui'; + + keyed by dependency_key + onChange={(scopeId, key, value) => { + setValues(prev => ({ ...prev, [key]: value })); + }} + onSave={async (scopeId, treeValues, flatValues) => { + // treeValues: nested object built from dot-separated keys + // e.g. { dokan: { general: { store_name: "..." } } } + // flatValues: original flat dot-keyed values + // e.g. { "dokan.general.store_name": "..." } + await api.post(`/settings/${scopeId}`, treeValues); + }} + renderSaveButton={({ dirty, hasErrors, onSave }) => ( + + )} + hookPrefix="my_plugin" // WordPress filter hook prefix + applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility +/> +``` + +### Key Concepts + +- **`dependency_key`**: Unique key on each field element, used as the key in `values` and `flatValues` +- **Dependencies**: Elements can conditionally show/hide based on other field values via `dependencies` array +- **Validation**: Per-field `validations` array with rules and error messages +- **Dirty tracking**: Per-scope (subpage/page) dirty state; resets only on successful save +- **Error handling**: If `onSave` throws `{ errors: { fieldKey: "message" } }`, errors display on the relevant fields +- **Extensibility**: `applyFilters` enables WordPress hooks like `{hookPrefix}_settings_{variant}_field` + +### Settings Hooks + +```tsx +import { useSettings } from '@wedevs/plugin-ui'; + +const { + values, // All current field values + activePage, // Current page element + activeSubpage, // Current subpage element (if any) + activeTab, // Current tab element (if any) + isPageDirty, // (pageId) => boolean + getPageValues, // (pageId) => Record + errors, // Record validation errors +} = useSettings(); +``` + +## Theme System + +```tsx +import { ThemeProvider, createTheme } from '@wedevs/plugin-ui'; + +// Use a built-in preset + + + + +// Create a custom theme +const myTheme = createTheme({ + primary: '220 90% 56%', // HSL values (without hsl() wrapper) + background: '0 0% 100%', + foreground: '0 0% 3.9%', + // ... see ThemeTokens type for all available tokens +}); +``` + +Built-in presets: `defaultTheme`, `slateTheme`, `amberMinimalTheme`, `t3ChatTheme`, `midnightBloomTheme`, `bubblegumTheme`, `cyberpunkTheme`, `twitterTheme` (each with a dark variant). + +## UI Components + +### Form +`Input`, `Textarea`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `DatePicker`, `DateRangePicker`, `Calendar`, `CurrencyInput`, `InputOTP`, `RichTextEditor`, `FileUpload` + +Card variants: `CheckboxCard`, `RadioCard`, `SwitchCard` +Labeled variants: `LabeledCheckbox`, `LabeledRadio`, `LabeledSwitch` + +### Layout +`Card`, `Tabs`, `Separator`, `ScrollArea`, `Layout` (WordPress sidebar+content), `Sidebar` (shadcn primitives), `Field` (form control wrapper with label, description, error) + +### Data Display +`Badge`, `Avatar`, `AvatarGroup`, `Progress`, `CircularProgress`, `Skeleton`, `Spinner`, `Thumbnail`, `DataViews` (WordPress DataViews wrapper) + +### Overlay +`Modal`, `Sheet`, `Popover`, `Tooltip`, `DropdownMenu`, `AlertDialog`, `Toaster` (sonner toast) + +### Feedback +`Alert`, `Notice`, `toast()` (from sonner) + +### WordPress-Specific +`Layout` (sidebar layout with WordPress hook integration), `DataViews` (wraps `@wordpress/dataviews` with filter hooks), `LayoutMenu` (navigation menu) + +## WordPress Integration + +### Layout Component + +```tsx +import { Layout, LayoutHeader, LayoutBody, LayoutSidebar, LayoutMain } from '@wedevs/plugin-ui'; + + + Header + + + Content + + +``` + +### DataViews Component + +```tsx +import { DataViews } from '@wedevs/plugin-ui'; + + +``` + +Supports WordPress filter hooks: `{snakeNamespace}_dataviews_{elementName}` + +## Conventions + +- **Composition pattern**: All components use compound component pattern (e.g., `Card` + `CardHeader` + `CardContent`) +- **`cn()` utility**: Use for merging Tailwind classes — combines `clsx` + `tailwind-merge` +- **`Field` wrapper**: Use to wrap form controls with consistent label, description, and error display +- **No WordPress dependency in UI components**: Only `Layout`, `DataViews`, `DataForm`, and `Settings` (via `applyFilters`) touch WordPress APIs +- **Externals**: React, ReactDOM, and WordPress packages (`@wordpress/components`, `@wordpress/dataviews`, `@wordpress/hooks`, `@wordpress/i18n`, `@wordpress/date`) are externalized — consumers must provide them + +## Before Committing & Pushing + +GitHub CI runs these checks on PRs to `main`. Run them locally before pushing to avoid failures: + +```bash +npm run lint # ESLint (src/**/*.{ts,tsx}) +npm run typecheck # tsc --noEmit +``` + +Both must pass. The CI pipeline (`.github/workflows/ci.yml`) runs these on `ubuntu-latest` with Node 24. + +## Documentation + +- `src/components/settings/Settings.mdx` — Full settings API reference +- `DEVELOPER_GUIDE.md` — WordPress integration guide +- `src/DeveloperGuide.mdx` — Storybook developer guide diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 5bb5079..070f5c5 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1,1387 +1,1387 @@ -# @wedevs/plugin-ui — Developer Guide - -A ShadCN-style React component library built for WordPress plugins. Provides 50+ themed, accessible UI components powered by Tailwind CSS v4, `@base-ui/react` headless primitives, and first-class WordPress integration. - ---- - -## Table of Contents - -- [Quick Start](#quick-start) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Project Setup](#project-setup) - - [1. package.json](#1-packagejson) - - [2. Webpack Configuration](#2-webpack-configuration) - - [3. PostCSS Configuration](#3-postcss-configuration) - - [4. TypeScript Configuration](#4-typescript-configuration) - - [5. CSS Setup (Tailwind v4)](#5-css-setup-tailwind-v4) - - [6. PHP Asset Enqueuing](#6-php-asset-enqueuing) - - [7. React Entry Point](#7-react-entry-point) -- [ThemeProvider](#themeprovider) - - [Theme Tokens](#theme-tokens) - - [Dark Mode](#dark-mode) - - [Built-in Theme Presets](#built-in-theme-presets) -- [Component Catalog](#component-catalog) - - [Layout Components](#layout-components) - - [Form Components](#form-components) - - [Data Display Components](#data-display-components) - - [Overlay Components](#overlay-components) - - [Feedback Components](#feedback-components) - - [Navigation Components](#navigation-components) - - [WordPress-specific Components](#wordpress-specific-components) -- [WordPress Integration](#wordpress-integration) - - [Layout System](#layout-system) - - [Settings Page](#settings-page) - - [File Upload (WP Media)](#file-upload-wp-media) - - [Date/Calendar with WP Locale](#datecalendar-with-wp-locale) - - [DataViews](#dataviews) -- [Pro Plugin / Add-on Architecture](#pro-plugin--add-on-architecture) - - [Sharing plugin-ui via Webpack Externals](#sharing-plugin-ui-via-webpack-externals) - - [Hook-based Extension Pattern](#hook-based-extension-pattern) -- [Utilities & Hooks](#utilities--hooks) -- [Re-exported Libraries](#re-exported-libraries) - ---- - -## Quick Start - -```tsx -import { ThemeProvider, Button, Card, CardHeader, CardTitle, CardContent } from '@wedevs/plugin-ui'; -import '@wedevs/plugin-ui/styles.css'; - -function App() { - return ( - - - - Hello World - - - - - - - ); -} -``` - ---- - -## Prerequisites - -| Tool | Version | -|------|---------| -| Node.js | 18+ | -| npm | 9+ | -| WordPress | 6.4+ | -| `@wordpress/scripts` | 28+ | -| React | 18.2+ | -| Tailwind CSS | 4.x | - ---- - -## Installation - -```bash -# From the same monorepo / adjacent directory -npm install @wedevs/plugin-ui@file:../plugin-ui - -# Or from npm (when published) -npm install @wedevs/plugin-ui -``` - ---- - -## Project Setup - -### 1. package.json - -```json -{ - "name": "my-wordpress-plugin", - "dependencies": { - "@wedevs/plugin-ui": "file:../plugin-ui", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^7.6.1" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.1.0", - "@wordpress/scripts": "^28.0.0", - "postcss": "^8.4.47", - "tailwindcss": "^4.1.0" - }, - "scripts": { - "build": "wp-scripts build", - "start": "wp-scripts start" - } -} -``` - -### 2. Webpack Configuration - -Use `@wordpress/scripts` as the base, with custom entry points: - -```js -// webpack.config.js -const defaultConfig = require('@wordpress/scripts/config/webpack.config'); -const path = require('path'); - -module.exports = { - ...defaultConfig, - entry: { - 'my-plugin-app': path.resolve(__dirname, 'src/index.tsx'), - }, - output: { - ...defaultConfig.output, - filename: '[name].js', - }, - resolve: { - ...defaultConfig.resolve, - alias: { - ...defaultConfig.resolve?.alias, - '@': path.resolve(__dirname, 'src'), - }, - extensions: [ - ...(defaultConfig.resolve?.extensions || []), - '.ts', '.tsx', - ], - }, -}; -``` - -### 3. PostCSS Configuration - -```js -// postcss.config.js -module.exports = { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; -``` - -### 4. TypeScript Configuration - -```json -// tsconfig.json -{ - "compilerOptions": { - "target": "ES5", - "module": "ESNext", - "moduleResolution": "node", - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "build"] -} -``` - -### 5. CSS Setup (Tailwind v4) - -This is the most critical configuration step. The layered approach scopes Tailwind's preflight reset and utilities to your app container, preventing style conflicts with WordPress. - -```css -/* src/styles/main.css */ - -/* 1. Declare layer order for deterministic specificity */ -@layer theme, base, components, utilities; - -/* 2. Import Tailwind theme (design tokens layer) */ -@import "tailwindcss/theme.css" layer(theme); - -/* 3. Scan your source files and plugin-ui for Tailwind classes */ -@source "../../src"; -@source "../../../node_modules/@wedevs/plugin-ui/dist"; - -/* 4. Scope preflight (CSS reset) and utilities to your app container. - This prevents Tailwind from resetting styles on the rest of the page. - Replace #my-plugin-app with your actual mount-point ID. */ -#my-plugin-app { - @import "tailwindcss/preflight.css" layer(base); - @import "tailwindcss/utilities.css" layer(utilities) important; -} - -/* 5. Import plugin-ui component styles */ -@import '@wedevs/plugin-ui/styles.css'; - -/* 6. Define your theme tokens on .pui-root (set by ThemeProvider) */ -.pui-root { - --background: oklch(1 0 0); - --foreground: oklch(0.1450 0 0); - --primary: oklch(.511 .262 276.966); - --primary-foreground: oklch(0.9850 0 0); - /* ... other token overrides ... */ - --radius: 0.625rem; -} - -/* 7. Map CSS variables to Tailwind theme tokens */ -@theme inline { - /* Colors */ - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-success: var(--success); - --color-success-foreground: var(--success-foreground); - --color-warning: var(--warning); - --color-warning-foreground: var(--warning-foreground); - --color-info: var(--info); - --color-info-foreground: var(--info-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - - /* Typography */ - --font-sans: var(--font-sans); - --font-serif: var(--font-serif); - --font-mono: var(--font-mono); - - /* Border Radius */ - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - - /* Shadows */ - --shadow-2xs: var(--shadow-2xs); - --shadow-xs: var(--shadow-xs); - --shadow-sm: var(--shadow-sm); - --shadow: var(--shadow); - --shadow-md: var(--shadow-md); - --shadow-lg: var(--shadow-lg); - --shadow-xl: var(--shadow-xl); - --shadow-2xl: var(--shadow-2xl); -} -``` - -> **Why layered imports?** Scoping preflight and utilities to your container (`#my-plugin-app`) prevents Tailwind's CSS reset from interfering with WordPress admin styles. The `important` keyword ensures your utility classes win over WordPress defaults within the scope. -> -> **Portal-based components (Modals, Popovers):** plugin-ui's `Modal` component automatically creates a `.pui-root` portal container on `document.body`. If you use Tailwind utility classes inside portaled content, add `.pui-root` as an additional scope: -> ```css -> #my-plugin-app, -> .pui-root { -> @import "tailwindcss/preflight.css" layer(base); -> @import "tailwindcss/utilities.css" layer(utilities) important; -> } -> ``` -> -> **Why is `@theme inline` needed?** Tailwind v4 needs to know about your CSS variables to generate utilities like `bg-primary`, `text-muted-foreground`, etc. The `@theme inline` block maps `--primary` (set by ThemeProvider) to `--color-primary` (used by Tailwind). - -### 6. PHP Asset Enqueuing - -Register and enqueue the built JS/CSS in your WordPress plugin: - -```php - esc_url_raw(get_rest_url()), - 'nonce' => wp_create_nonce('wp_rest'), - ] - ); - } -} -``` - -Create a container div for React to mount into: - -```php -// admin-page.php -
-``` - -> **Note:** `@wordpress/scripts` automatically generates the `.asset.php` file with correct WordPress dependencies during build. - -### 7. React Entry Point - -```tsx -// src/index.tsx -import React from 'react'; -import { createRoot } from 'react-dom/client'; -import { ThemeProvider, type ThemeTokens } from '@wedevs/plugin-ui'; -import { HashRouter } from 'react-router-dom'; -import App from './App'; -import './styles/main.css'; - -const myThemeTokens: ThemeTokens = { - primary: 'oklch(.511 .262 276.966)', - primaryForeground: 'oklch(0.9850 0 0)', - radius: '0.625rem', - // ... more tokens as needed -}; - -const container = document.getElementById('my-plugin-app'); -if (container) { - const root = createRoot(container); - root.render( - - - - - - ); -} -``` - ---- - -## ThemeProvider - -The `ThemeProvider` wraps your app and injects CSS custom properties under the `.pui-root` scope. All plugin-ui components read these variables for their styles. - -```tsx -import { ThemeProvider } from '@wedevs/plugin-ui'; - - - {children} - -``` - -Access the theme context in child components: - -```tsx -import { useTheme } from '@wedevs/plugin-ui'; - -function ThemeToggle() { - const { mode, setMode } = useTheme(); - return ( - - ); -} -``` - -### Theme Tokens - -All tokens use the OKLCh color space for perceptually uniform color manipulation: - -| Token | Description | -|-------|-------------| -| `background` / `foreground` | Page background and text | -| `card` / `cardForeground` | Card surfaces | -| `popover` / `popoverForeground` | Dropdown/popover surfaces | -| `primary` / `primaryForeground` | Primary brand color | -| `secondary` / `secondaryForeground` | Secondary color | -| `muted` / `mutedForeground` | Muted/disabled state | -| `accent` / `accentForeground` | Accent highlights | -| `destructive` / `destructiveForeground` | Danger/delete actions | -| `success` / `successForeground` | Success state | -| `warning` / `warningForeground` | Warning state | -| `info` / `infoForeground` | Informational state | -| `border` | Default border color | -| `input` | Input border color | -| `ring` | Focus ring color | -| `chart1`–`chart5` | Chart/graph colors | -| `sidebar*` | Sidebar-specific colors | -| `radius` | Base border radius | -| `fontSans` / `fontSerif` / `fontMono` | Font families | -| `shadow*` | Shadow values | - -### Dark Mode - -Pass both `tokens` and `darkTokens` to enable dark mode: - -```tsx -const lightTokens: ThemeTokens = { - background: 'oklch(1 0 0)', - foreground: 'oklch(0.1450 0 0)', - primary: 'oklch(.511 .262 276.966)', - // ... -}; - -const darkTokens: ThemeTokens = { - background: 'oklch(0.1450 0 0)', - foreground: 'oklch(0.9850 0 0)', - primary: 'oklch(0.9220 0 0)', - // ... -}; - - - - -``` - -### Built-in Theme Presets - -Use pre-made themes instead of defining tokens manually: - -```tsx -import { defaultTheme, defaultDarkTheme, twitterTheme, cyberpunkTheme } from '@wedevs/plugin-ui'; - - -``` - -Available presets: `defaultTheme`, `slateTheme`, `amberMinimalTheme`, `t3ChatTheme`, `midnightBloomTheme`, `bubblegumTheme`, `cyberpunkTheme`, `twitterTheme` — each with a `*DarkTheme` variant. - ---- - -## Component Catalog - -### Layout Components - -#### Button - -```tsx -import { Button } from '@wedevs/plugin-ui'; - - - - - - - - -``` - -**Variants:** `default`, `secondary`, `outline`, `ghost`, `destructive`, `outline-destructive`, `success`, `outline-success`, `link` -**Sizes:** `default`, `xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg` - -#### Card - -```tsx -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, CardAction } from '@wedevs/plugin-ui'; - - - - Title - Description text - - - Content here - Footer - -``` - -#### Tabs - -```tsx -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@wedevs/plugin-ui'; - - - - General - Advanced - - General settings... - Advanced settings... - -``` - -#### Separator - -```tsx -import { Separator } from '@wedevs/plugin-ui'; - - - -``` - -#### ScrollArea - -```tsx -import { ScrollArea, ScrollBar } from '@wedevs/plugin-ui'; - - -
Long content...
- -
-``` - -### Form Components - -#### Input - -```tsx -import { Input } from '@wedevs/plugin-ui'; - - - -``` - -#### InputGroup - -```tsx -import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton } from '@wedevs/plugin-ui'; - - - https:// - - Go - -``` - -#### CurrencyInput - -```tsx -import { CurrencyInput } from '@wedevs/plugin-ui'; - - setAmount(val)} - currency="USD" - currencyOptions={[ - { value: 'USD', label: '$' }, - { value: 'EUR', label: '€' }, - ]} -/> -``` - -#### Textarea - -```tsx -import { Textarea } from '@wedevs/plugin-ui'; - -