diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 0000000..6dcadae
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,61 @@
+name: Deploy Documentation
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'website/**'
+ - '.github/workflows/deploy-docs.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: website
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: website/package-lock.json
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build website
+ run: npm run build
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v4
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: website/build
+
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 83fe2a6..d2ab167 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
.phpunit.cache
vendor
+node_modules
+.docusaurus
+build
diff --git a/readme.md b/readme.md
index 0f8300d..5de5de1 100644
--- a/readme.md
+++ b/readme.md
@@ -1,878 +1,83 @@
# Tangible Object
-A WordPress tool suite for building data-driven admin interfaces with a clean four-layer architecture.
+A WordPress tool suite for building data-driven admin interfaces with a clean, layered architecture.
-## Architecture
-
-The suite separates concerns into four distinct layers:
-
-1. **DataSet** - Define field types and coercion rules
-2. **EditorLayout** - Compose the editor structure (sections, tabs, fields)
-3. **Renderer** - Generate HTML output from the layout
-4. **RequestHandler** - Handle CRUD operations with validation
+**[View Full Documentation →](https://tangibleinc.github.io/object/)**
-## Complete Example: Contact Form Entries Admin Page
+## Quick Start
-This example shows how to create a WordPress admin page for managing contact form entries, with list, create, and edit views.
-
-### Step 1: Define Your Data Object
-
-Create a file to define and configure your data object. This is typically done once during plugin initialization.
+The easiest way to use Tangible Object is through the **DataView** API:
```php
-add_string('name')
- ->add_string('email')
- ->add_string('message')
- ->add_boolean('subscribe');
-
- // =========================================================================
- // LAYER 2: Editor Composition
- // =========================================================================
- $layout = new Layout($dataset);
-
- $layout->section('Contact Information', function(Section $s) {
- $s->field('name')
- ->placeholder('Full name')
- ->help('The sender\'s full name');
- $s->field('email')
- ->placeholder('email@example.com');
- });
- $layout->section('Message', function(Section $s) {
- $s->field('message');
- $s->field('subscribe');
- });
-
- $layout->sidebar(function(Sidebar $sb) {
- $sb->actions(['save', 'delete']);
- });
-
- // =========================================================================
- // LAYER 3: UI Presentation
- // =========================================================================
- $renderer = new HtmlRenderer();
-
- // =========================================================================
- // LAYER 4: Request Handling
- // =========================================================================
- // Note: CPT slugs must be 20 characters or less
- $object = new PluralObject('contact_entry');
- $object->set_dataset($dataset);
- $object->register([
- 'public' => false,
- 'label' => 'Contact Entries',
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'contact_entry',
+ 'label' => 'Contact Entry',
+ 'fields' => [
+ 'name' => 'string',
+ 'email' => 'email',
+ 'message' => 'text',
+ 'subscribe' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Contact Entries',
+ 'icon' => 'dashicons-email',
+ ],
]);
- $handler = new PluralHandler($object);
- $handler
+ $view->get_handler()
->add_validator('name', Validators::required())
->add_validator('email', Validators::required())
- ->add_validator('email', Validators::email())
- ->before_create(function($data) {
- $data['created_at'] = current_time('mysql');
- return $data;
- });
-
- return [
- 'dataset' => $dataset,
- 'layout' => $layout,
- 'renderer' => $renderer,
- 'handler' => $handler,
- ];
-}
-```
-
-### Step 2: Create the Admin Page
-
-Register the admin menu and handle the different views.
-
-```php
-list();
- $entities = $result->get_entities();
-
- // Prepare data for rendering
- $rows = array_map(function($entity) {
- return [
- 'id' => $entity->get_id(),
- 'name' => $entity->get('name'),
- 'email' => $entity->get('email'),
- 'message' => $entity->get('message'),
- 'subscribe' => $entity->get('subscribe'),
- ];
- }, $entities);
-
- // Page header
- ?>
-
-
- Contact Entries
- Add New
-
-
-
-
No contact entries found.
-
-
-
-
- | Name |
- Email |
- Subscribed |
- Actions |
-
-
-
-
-
- |
- |
- |
-
-
- Edit
-
- |
-
- Delete
-
- |
-
-
-
-
-
-
-
-
-
Add New Contact Entry
-
-
-
-
-
- - get_field() . ': ' . $error->get_message()); ?>
-
-
-
-
-
-
-
-
-
- ← Back to list
-
-
-
- read($id);
-
- if ($result->is_error()) {
- wp_die('Contact entry not found.');
- }
-
- $entity = $result->get_entity();
- $data = [
- 'name' => $entity->get('name'),
- 'email' => $entity->get('email'),
- 'message' => $entity->get('message'),
- 'subscribe' => $entity->get('subscribe'),
- ];
-
- ?>
-
-
Edit Contact Entry
-
-
-
-
-
- - get_field() . ': ' . $error->get_message()); ?>
-
-
-
-
-
-
-
-
-
- ← Back to list
-
-
-
- create([
- 'name' => sanitize_text_field($_POST['name'] ?? ''),
- 'email' => sanitize_email($_POST['email'] ?? ''),
- 'message' => sanitize_textarea_field($_POST['message'] ?? ''),
- 'subscribe' => !empty($_POST['subscribe']),
- ]);
-
- if ($result->is_error()) {
- // Re-render form with errors
- render_contact_create_view($contact, $result->get_errors(), $_POST);
- } else {
- // Redirect to edit view with success message
- $new_id = $result->get_entity()->get_id();
- wp_redirect(admin_url('admin.php?page=contact-entries&action=edit&id=' . $new_id . '&created=1'));
- exit;
- }
- break;
-
- case 'edit':
- // Verify nonce
- if (!wp_verify_nonce($_POST['_wpnonce'], 'update_contact_' . $id)) {
- wp_die('Security check failed.');
- }
-
- // Attempt to update
- $result = $handler->update($id, [
- 'name' => sanitize_text_field($_POST['name'] ?? ''),
- 'email' => sanitize_email($_POST['email'] ?? ''),
- 'message' => sanitize_textarea_field($_POST['message'] ?? ''),
- 'subscribe' => !empty($_POST['subscribe']),
- ]);
-
- if ($result->is_error()) {
- // Re-render form with errors
- render_contact_edit_view($contact, $id, $result->get_errors());
- } else {
- // Redirect back with success message
- wp_redirect(admin_url('admin.php?page=contact-entries&action=edit&id=' . $id . '&updated=1'));
- exit;
- }
- break;
-
- case 'delete':
- // Verify nonce
- if (!wp_verify_nonce($_GET['_wpnonce'], 'delete_contact_' . $id)) {
- wp_die('Security check failed.');
- }
-
- // Delete the entry
- $handler->delete($id);
-
- // Redirect to list
- wp_redirect(admin_url('admin.php?page=contact-entries&deleted=1'));
- exit;
- break;
- }
-}
-```
-
-## DataSet Field Types
-
-```php
-$dataset = new DataSet();
-$dataset->add_string('title'); // Text fields
-$dataset->add_integer('count'); // Number fields (renders as type="number")
-$dataset->add_boolean('is_active'); // Checkbox fields
-```
-
-Type coercion happens automatically:
-- Strings like `'5'` become integers when the field is `add_integer()`
-- Values like `'yes'`, `'true'`, `'1'`, `'on'` become `true` for boolean fields
-
-## EditorLayout Structure
-
-### Sections
-
-```php
-$layout->section('Section Label', function(Section $s) {
- $s->field('field_name')
- ->placeholder('Placeholder text')
- ->help('Help text shown below the field')
- ->readonly() // Make field read-only
- ->width('50%'); // Set field width
-
- $s->columns(2); // Display fields in 2 columns
- $s->condition('other_field', true); // Show section only when other_field is true
-});
-```
-
-### Tabs
-
-```php
-use Tangible\EditorLayout\Tabs;
-use Tangible\EditorLayout\Tab;
-
-$layout->tabs(function(Tabs $tabs) {
- $tabs->tab('Content', function(Tab $t) {
- $t->field('title');
- $t->field('body');
- });
- $tabs->tab('Settings', function(Tab $t) {
- $t->field('is_published');
- });
-});
-```
-
-### Nesting
-
-Sections and tabs can be nested arbitrarily:
-
-```php
-$layout->section('Main', function(Section $s) {
- $s->field('title');
-
- // Nested section
- $s->section('Advanced', function(Section $nested) {
- $nested->field('slug');
- });
-
- // Tabs inside section
- $s->tabs(function(Tabs $tabs) {
- $tabs->tab('Details', function(Tab $t) {
- $t->field('description');
- });
- });
-});
-```
-
-### Sidebar
-
-```php
-$layout->sidebar(function(Sidebar $sb) {
- $sb->field('status')->readonly();
- $sb->actions(['save', 'delete']);
-});
-```
-
-## Validators
-
-Built-in validators:
-
-```php
-use Tangible\RequestHandler\Validators;
-
-$handler
- ->add_validator('field', Validators::required())
- ->add_validator('field', Validators::min_length(3))
- ->add_validator('field', Validators::max_length(100))
- ->add_validator('count', Validators::min(0))
- ->add_validator('count', Validators::max(100))
- ->add_validator('status', Validators::in(['draft', 'published']))
- ->add_validator('email', Validators::email());
-```
-
-Custom validators:
-
-```php
-$handler->add_validator('slug', function($value) {
- if (preg_match('/[^a-z0-9-]/', $value)) {
- return new \Tangible\RequestHandler\ValidationError(
- 'Slug can only contain lowercase letters, numbers, and hyphens'
- );
- }
- return true;
-});
-```
-
-## Lifecycle Hooks
-
-```php
-// Modify data before create
-$handler->before_create(function(array $data) {
- $data['created_at'] = current_time('mysql');
- return $data;
-});
-
-// React after create
-$handler->after_create(function($entity) {
- do_action('my_plugin_contact_created', $entity);
-});
-
-// Modify data before update (receives entity and new data)
-$handler->before_update(function($entity, array $data) {
- $data['updated_at'] = current_time('mysql');
- return $data;
-});
-
-// React after update
-$handler->after_update(function($entity) {
- // Send notification, clear cache, etc.
-});
-
-// Cancel deletion by returning false
-$handler->before_delete(function($entity) {
- if ($entity->get('is_protected')) {
- return false; // Cancels deletion
- }
- return true;
-});
+ ->add_validator('email', Validators::email());
-// React after delete (receives the deleted ID)
-$handler->after_delete(function($id) {
- // Cleanup related data
+ $view->register();
});
```
-## SingularObject for Settings Pages
+This creates:
+- A Custom Post Type for your data
+- An admin menu page with list, create, and edit views
+- Form handling with validation and sanitization
+- Full CRUD operations
-For single-instance data like plugin settings, site configuration, or any data that exists as a single persistent instance, use `SingularObject` and `SingularHandler`.
-
-**Key differences from PluralObject:**
-- No create/delete operations (the object always exists)
-- Only read and update operations
-- Data is stored in a single WordPress option by default
-- Lifecycle hooks receive data arrays instead of entities
-
-### Complete Example: Plugin Settings Page
-
-This example shows how to create a WordPress admin page for managing plugin settings.
-
-#### Step 1: Define Your Settings Object
-
-```php
-add_string('api_key')
- ->add_string('api_endpoint')
- ->add_boolean('debug_mode')
- ->add_integer('cache_ttl')
- ->add_integer('max_retries');
-
- // =========================================================================
- // LAYER 2: Editor Composition
- // =========================================================================
- $layout = new Layout($dataset);
-
- $layout->section('API Configuration', function(Section $s) {
- $s->field('api_key')
- ->placeholder('Enter your API key')
- ->help('Your API key from the dashboard');
- $s->field('api_endpoint')
- ->placeholder('https://api.example.com')
- ->help('The API endpoint URL');
- });
-
- $layout->section('Performance', function(Section $s) {
- $s->field('cache_ttl')
- ->help('Cache time-to-live in seconds (0 to disable)');
- $s->field('max_retries')
- ->help('Maximum retry attempts for failed requests');
- });
-
- $layout->section('Development', function(Section $s) {
- $s->field('debug_mode')
- ->help('Enable detailed logging for troubleshooting');
- });
-
- $layout->sidebar(function(Sidebar $sb) {
- $sb->actions(['save']);
- });
-
- // =========================================================================
- // LAYER 3: UI Presentation
- // =========================================================================
- $renderer = new HtmlRenderer();
-
- // =========================================================================
- // LAYER 4: Request Handling
- // =========================================================================
- $object = new SingularObject('my_plugin_settings');
- $object->set_dataset($dataset);
-
- $handler = new SingularHandler($object);
- $handler
- ->add_validator('api_key', Validators::required())
- ->add_validator('cache_ttl', Validators::min(0))
- ->add_validator('max_retries', Validators::min(0))
- ->add_validator('max_retries', Validators::max(10))
- ->before_update(function($current, $data) {
- // Clear cache when TTL changes
- if (($current['cache_ttl'] ?? 0) !== ($data['cache_ttl'] ?? 0)) {
- delete_transient('my_plugin_api_cache');
- }
- return $data;
- })
- ->after_update(function($data) {
- // Log settings change
- if ($data['debug_mode']) {
- error_log('Plugin settings updated');
- }
- });
-
- return [
- 'dataset' => $dataset,
- 'layout' => $layout,
- 'renderer' => $renderer,
- 'handler' => $handler,
- ];
-}
-```
-
-#### Step 2: Create the Admin Page
-
-```php
-read();
- $data = $result->get_data();
-
- ?>
-
-
My Plugin Settings
-
-
-
-
-
-
-
-
-
- - get_field() . ': ' . $error->get_message()); ?>
-
-
-
-
-
-
-
- update([
- 'api_key' => sanitize_text_field($_POST['api_key'] ?? ''),
- 'api_endpoint' => esc_url_raw($_POST['api_endpoint'] ?? ''),
- 'debug_mode' => !empty($_POST['debug_mode']),
- 'cache_ttl' => (int) ($_POST['cache_ttl'] ?? 3600),
- 'max_retries' => (int) ($_POST['max_retries'] ?? 3),
- ]);
-
- if ($result->is_error()) {
- // Re-render form with errors
- render_settings_form($settings, $result->get_errors());
- } else {
- // Re-render with success message
- render_settings_form($settings, [], 'Settings saved successfully.');
- }
-}
-```
-
-### SingularHandler Operations
-
-Unlike `PluralHandler`, `SingularHandler` only provides two operations:
-
-```php
-// Read current values
-$result = $handler->read();
-$data = $result->get_data(); // Returns associative array of all field values
-
-// Update values (partial updates supported)
-$result = $handler->update([
- 'api_key' => 'new-key',
- 'debug_mode' => true,
-]);
-
-if ($result->is_success()) {
- $updated_data = $result->get_data();
-}
-```
-
-### SingularHandler Lifecycle Hooks
-
-The `SingularHandler` supports `before_update` and `after_update` hooks with signatures different from `PluralHandler`:
-
-```php
-// Modify data before update (receives current data and new data)
-$handler->before_update(function(array $current, array $data) {
- // $current = existing values before update
- // $data = new values being saved
-
- // You can compare to detect changes
- if ($current['api_key'] !== $data['api_key']) {
- // API key changed, maybe invalidate tokens
- }
-
- return $data; // Return modified data
-});
-
-// React after update (receives the updated data)
-$handler->after_update(function(array $data) {
- // $data = all field values after update
- do_action('my_plugin_settings_updated', $data);
-});
-```
-
-### Custom Storage
-
-By default, `SingularObject` uses `OptionStorage` which stores data in a single WordPress option. You can provide custom storage by implementing the `SingularStorage` interface:
-
-```php
-use Tangible\DataObject\SingularStorage;
-
-class NetworkOptionStorage implements SingularStorage {
- protected array $values = [];
- protected string $slug;
-
- public function __construct(string $slug) {
- $this->slug = $slug;
- }
+## Architecture
- public function set(string $slug, mixed $value): void {
- $this->values[$slug] = $value;
- }
+For advanced customization, Tangible Object exposes four underlying layers:
- public function get(string $slug): mixed {
- return $this->values[$slug] ?? null;
- }
+1. **DataSet** - Define field types and coercion rules
+2. **EditorLayout** - Compose the editor structure (sections, tabs, fields)
+3. **Renderer** - Generate HTML output from the layout
+4. **RequestHandler** - Handle CRUD operations with validation
- public function save(): void {
- update_site_option($this->slug, $this->values);
- }
+[Learn more about the architecture →](https://tangibleinc.github.io/object/advanced/architecture)
- public function load(): void {
- $this->values = get_site_option($this->slug, []);
- }
-}
+## Documentation
-// Use custom storage
-$storage = new NetworkOptionStorage('my_network_settings');
-$object = new SingularObject('my_network_settings', $storage);
-```
+- [Getting Started](https://tangibleinc.github.io/object/getting-started/quick-start)
+- [DataView Configuration](https://tangibleinc.github.io/object/dataview/configuration)
+- [Field Types](https://tangibleinc.github.io/object/dataview/field-types)
+- [Layouts](https://tangibleinc.github.io/object/layouts/overview)
+- [Validation](https://tangibleinc.github.io/object/dataview/validation)
+- [Examples](https://tangibleinc.github.io/object/examples/settings-page)
## Requirements
- PHP 8.0+
- WordPress 5.0+
+
+## License
+
+MIT
diff --git a/website/babel.config.js b/website/babel.config.js
new file mode 100644
index 0000000..e00595d
--- /dev/null
+++ b/website/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
+};
diff --git a/website/docs/advanced/architecture.md b/website/docs/advanced/architecture.md
new file mode 100644
index 0000000..39e2d25
--- /dev/null
+++ b/website/docs/advanced/architecture.md
@@ -0,0 +1,212 @@
+---
+sidebar_position: 1
+title: Architecture
+description: Understanding the four-layer architecture
+---
+
+# Four-Layer Architecture
+
+Tangible Object separates concerns into four distinct layers. While DataView handles all of this for you, understanding the layers helps with advanced customization.
+
+## Layer Overview
+
+```
+┌─────────────────────────────────────────────┐
+│ DataView │
+│ (High-level orchestration) │
+└─────────────────────────────────────────────┘
+ │
+ ┌─────────────────┼─────────────────┐
+ ▼ ▼ ▼
+┌─────────┐ ┌─────────────┐ ┌──────────┐
+│ DataSet │ │EditorLayout │ │ Renderer │
+└─────────┘ └─────────────┘ └──────────┘
+ │ │ │
+ └────────────┬────┘ │
+ ▼ │
+ ┌──────────────┐ │
+ │RequestHandler│◄──────────────┘
+ └──────────────┘
+```
+
+## Layer 1: DataSet
+
+Defines field types and handles type coercion.
+
+```php
+use Tangible\DataObject\DataSet;
+
+$dataset = new DataSet();
+$dataset
+ ->add_string('title')
+ ->add_string('email')
+ ->add_integer('count')
+ ->add_boolean('is_active');
+```
+
+### Type Coercion
+
+DataSet automatically coerces values:
+
+```php
+$dataset->coerce([
+ 'count' => '42', // String → Integer: 42
+ 'is_active' => 'yes', // String → Boolean: true
+]);
+```
+
+### Available Types
+
+- `add_string($name)` - Text values
+- `add_integer($name)` - Whole numbers
+- `add_boolean($name)` - True/false
+
+## Layer 2: EditorLayout
+
+Composes the editor structure with sections, tabs, and fields.
+
+```php
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Section;
+use Tangible\EditorLayout\Tabs;
+use Tangible\EditorLayout\Tab;
+use Tangible\EditorLayout\Sidebar;
+
+$layout = new Layout($dataset);
+
+$layout->section('Details', function(Section $s) {
+ $s->field('title')
+ ->placeholder('Enter title')
+ ->help('The main title');
+ $s->field('count');
+});
+
+$layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('Settings', function(Tab $t) {
+ $t->field('is_active');
+ });
+});
+
+$layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save', 'delete']);
+});
+```
+
+## Layer 3: Renderer
+
+Generates HTML from the layout structure.
+
+```php
+use Tangible\Renderer\HtmlRenderer;
+
+$renderer = new HtmlRenderer();
+$html = $renderer->render_editor($layout, [
+ 'title' => 'My Item',
+ 'count' => 5,
+]);
+```
+
+## Layer 4: RequestHandler
+
+Handles CRUD operations with validation.
+
+### PluralObject & PluralHandler
+
+For multiple items (like posts):
+
+```php
+use Tangible\DataObject\PluralObject;
+use Tangible\RequestHandler\PluralHandler;
+use Tangible\RequestHandler\Validators;
+
+$object = new PluralObject('my_item');
+$object->set_dataset($dataset);
+$object->register([
+ 'public' => false,
+ 'label' => 'My Items',
+]);
+
+$handler = new PluralHandler($object);
+$handler
+ ->add_validator('title', Validators::required())
+ ->before_create(function($data) {
+ $data['created_at'] = current_time('mysql');
+ return $data;
+ });
+
+// CRUD operations
+$result = $handler->create(['title' => 'New Item', 'count' => 1]);
+$result = $handler->read($id);
+$result = $handler->update($id, ['count' => 2]);
+$result = $handler->delete($id);
+$result = $handler->list();
+```
+
+### SingularObject & SingularHandler
+
+For single-instance data (like settings):
+
+```php
+use Tangible\DataObject\SingularObject;
+use Tangible\RequestHandler\SingularHandler;
+
+$object = new SingularObject('my_settings');
+$object->set_dataset($dataset);
+
+$handler = new SingularHandler($object);
+
+// Only read and update
+$result = $handler->read();
+$result = $handler->update(['title' => 'New Value']);
+```
+
+## Using Layers Directly
+
+For maximum control, use the layers directly instead of DataView:
+
+```php
+// 1. Define data
+$dataset = new DataSet();
+$dataset->add_string('name')->add_string('email');
+
+// 2. Create layout
+$layout = new Layout($dataset);
+$layout->section('Contact', function(Section $s) {
+ $s->field('name');
+ $s->field('email');
+});
+
+// 3. Set up handler
+$object = new PluralObject('contact');
+$object->set_dataset($dataset);
+$object->register();
+
+$handler = new PluralHandler($object);
+$handler->add_validator('email', Validators::email());
+
+// 4. Create renderer
+$renderer = new HtmlRenderer();
+
+// 5. Build admin page manually
+add_action('admin_menu', function() use ($layout, $handler, $renderer) {
+ add_menu_page(
+ 'Contacts',
+ 'Contacts',
+ 'manage_options',
+ 'contacts',
+ function() use ($layout, $handler, $renderer) {
+ // Handle requests and render
+ }
+ );
+});
+```
+
+## When to Use Layers Directly
+
+Use the layers directly when you need:
+- Custom admin page structure
+- Non-standard workflows
+- Integration with existing systems
+- Maximum flexibility
+
+For most cases, DataView provides everything you need with less code.
diff --git a/website/docs/advanced/custom-field-types.md b/website/docs/advanced/custom-field-types.md
new file mode 100644
index 0000000..20339c2
--- /dev/null
+++ b/website/docs/advanced/custom-field-types.md
@@ -0,0 +1,178 @@
+---
+sidebar_position: 2
+title: Custom Field Types
+description: Registering custom field types
+---
+
+# Custom Field Types
+
+You can register custom field types to extend DataView's capabilities.
+
+## Registration
+
+Access the field registry and register your type:
+
+```php
+use Tangible\DataObject\DataSet;
+
+$view = new DataView([...]);
+$registry = $view->get_field_registry();
+
+$registry->register_type('phone', [
+ 'dataset' => DataSet::TYPE_STRING,
+ 'sanitizer' => function($value) {
+ return preg_replace('/[^0-9+\-\s()]/', '', $value);
+ },
+ 'schema' => ['type' => 'varchar', 'length' => 20],
+ 'input' => 'tel',
+]);
+```
+
+## Registration Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `dataset` | string | DataSet type constant (`TYPE_STRING`, `TYPE_INTEGER`, `TYPE_BOOLEAN`) |
+| `sanitizer` | callable | Function to sanitize input values |
+| `schema` | array | Database column definition (for `'storage' => 'database'`) |
+| `input` | string | HTML input type |
+
+## Example: Currency Field
+
+Store prices as cents (integers), but allow decimal input:
+
+```php
+$registry->register_type('currency', [
+ 'dataset' => DataSet::TYPE_INTEGER,
+ 'sanitizer' => function($value) {
+ // Convert dollars to cents
+ $float = floatval(str_replace(['$', ','], '', $value));
+ return (int) round($float * 100);
+ },
+ 'schema' => ['type' => 'int', 'length' => 11],
+ 'input' => 'text',
+]);
+
+// Usage
+$view = new DataView([
+ 'slug' => 'product',
+ 'label' => 'Product',
+ 'fields' => [
+ 'name' => 'string',
+ 'price' => 'currency', // Custom type
+ ],
+]);
+```
+
+## Example: Slug Field
+
+Auto-sanitize to URL-friendly format:
+
+```php
+$registry->register_type('slug', [
+ 'dataset' => DataSet::TYPE_STRING,
+ 'sanitizer' => function($value) {
+ return sanitize_title($value);
+ },
+ 'schema' => ['type' => 'varchar', 'length' => 200],
+ 'input' => 'text',
+]);
+```
+
+## Example: JSON Field
+
+Store structured data as JSON:
+
+```php
+$registry->register_type('json', [
+ 'dataset' => DataSet::TYPE_STRING,
+ 'sanitizer' => function($value) {
+ if (is_string($value)) {
+ // Validate JSON
+ $decoded = json_decode($value, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return $value;
+ }
+ return '{}';
+ }
+ return json_encode($value);
+ },
+ 'schema' => ['type' => 'longtext'],
+ 'input' => 'textarea',
+]);
+```
+
+## Example: Color Picker
+
+```php
+$registry->register_type('color', [
+ 'dataset' => DataSet::TYPE_STRING,
+ 'sanitizer' => function($value) {
+ // Validate hex color
+ if (preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
+ return $value;
+ }
+ return '#000000';
+ },
+ 'schema' => ['type' => 'varchar', 'length' => 7],
+ 'input' => 'color',
+]);
+```
+
+## Registration Timing
+
+Register custom types **before** creating the DataView that uses them:
+
+```php
+// Get a registry instance
+$registry = new \Tangible\DataView\FieldTypeRegistry();
+
+// Register custom types
+$registry->register_type('phone', [...]);
+$registry->register_type('currency', [...]);
+
+// Create DataView (it will use the same registry)
+$view = new DataView([
+ 'slug' => 'contact',
+ 'label' => 'Contact',
+ 'fields' => [
+ 'name' => 'string',
+ 'phone' => 'phone', // Custom type
+ 'budget' => 'currency', // Custom type
+ ],
+]);
+```
+
+## Sharing Types Across Views
+
+For multiple DataViews sharing custom types:
+
+```php
+// Create and configure a shared registry
+$registry = new \Tangible\DataView\FieldTypeRegistry();
+$registry->register_type('phone', [...]);
+
+// Use it for multiple views
+$view1 = new DataView(['fields' => ['phone' => 'phone'], ...]);
+$view2 = new DataView(['fields' => ['mobile' => 'phone'], ...]);
+```
+
+## Database Schema Options
+
+For `'storage' => 'database'`, the schema defines the column:
+
+```php
+'schema' => [
+ 'type' => 'varchar', // Column type
+ 'length' => 255, // Column length
+]
+
+// Common types:
+// varchar(length)
+// int(length)
+// tinyint(1) - for booleans
+// text
+// longtext
+// date
+// datetime
+```
diff --git a/website/docs/advanced/i18n.md b/website/docs/advanced/i18n.md
new file mode 100644
index 0000000..1fb2706
--- /dev/null
+++ b/website/docs/advanced/i18n.md
@@ -0,0 +1,167 @@
+---
+sidebar_position: 4
+title: Internationalization
+description: Making your DataView translatable
+---
+
+# Internationalization (i18n)
+
+WordPress i18n tools scan source files for translation function calls. To ensure your DataView labels are translatable, pass pre-translated strings.
+
+## Basic Usage
+
+Instead of a simple string, pass an array with translated labels:
+
+```php
+$view = new DataView([
+ 'slug' => 'book',
+ 'label' => [
+ 'singular' => __('Book', 'my-plugin'),
+ 'plural' => __('Books', 'my-plugin'),
+ ],
+ 'fields' => [
+ 'title' => 'string',
+ ],
+]);
+```
+
+## Available Label Keys
+
+| Key | Default | Description |
+|-----|---------|-------------|
+| `singular` | (required) | Singular form ("Book") |
+| `plural` | Auto-generated | Plural form ("Books") |
+| `all_items` | `{plural}` | List page title |
+| `add_new` | "Add New" | Add button text |
+| `add_new_item` | "Add New {singular}" | Create page title |
+| `edit_item` | "Edit {singular}" | Edit page title |
+| `new_item` | "New {singular}" | New item text |
+| `view_item` | "View {singular}" | View item text |
+| `view_items` | "View {plural}" | View items text |
+| `search_items` | "Search {plural}" | Search text |
+| `not_found` | "No {plural} found" | Empty list message |
+| `not_found_in_trash` | "No {plural} found in Trash" | Empty trash message |
+| `settings` | "{singular} Settings" | Settings page title |
+| `item_created` | "Item created successfully." | Create notice |
+| `item_updated` | "Item updated successfully." | Update notice |
+| `item_deleted` | "Item deleted successfully." | Delete notice |
+| `settings_saved` | "Settings saved successfully." | Settings notice |
+| `menu_name` | `{plural}` | WordPress menu name |
+
+## Complete Example
+
+```php
+$view = new DataView([
+ 'slug' => 'book',
+ 'label' => [
+ // Required
+ 'singular' => __('Book', 'my-plugin'),
+ 'plural' => __('Books', 'my-plugin'),
+
+ // Page titles
+ 'all_items' => __('All Books', 'my-plugin'),
+ 'add_new_item' => __('Add New Book', 'my-plugin'),
+ 'edit_item' => __('Edit Book', 'my-plugin'),
+
+ // WordPress labels
+ 'add_new' => __('Add New', 'my-plugin'),
+ 'new_item' => __('New Book', 'my-plugin'),
+ 'view_item' => __('View Book', 'my-plugin'),
+ 'view_items' => __('View Books', 'my-plugin'),
+ 'search_items' => __('Search Books', 'my-plugin'),
+ 'not_found' => __('No books found', 'my-plugin'),
+ 'not_found_in_trash' => __('No books found in Trash', 'my-plugin'),
+
+ // Success notices
+ 'item_created' => __('Book created successfully.', 'my-plugin'),
+ 'item_updated' => __('Book updated successfully.', 'my-plugin'),
+ 'item_deleted' => __('Book deleted successfully.', 'my-plugin'),
+
+ // Menu
+ 'menu_name' => __('Books', 'my-plugin'),
+ ],
+ 'fields' => [...],
+ 'ui' => [
+ 'menu_label' => __('Books', 'my-plugin'),
+ ],
+]);
+```
+
+## Settings Page Example
+
+```php
+$view = new DataView([
+ 'slug' => 'my_plugin_settings',
+ 'label' => [
+ 'singular' => __('Settings', 'my-plugin'),
+ 'settings' => __('Plugin Settings', 'my-plugin'),
+ 'settings_saved' => __('Settings saved.', 'my-plugin'),
+ ],
+ 'storage' => 'option',
+ 'mode' => 'singular',
+ 'ui' => [
+ 'menu_label' => __('My Plugin', 'my-plugin'),
+ 'parent' => 'options-general.php',
+ ],
+]);
+```
+
+## Minimal Setup
+
+At minimum, provide translated `singular` and `plural` labels:
+
+```php
+$view = new DataView([
+ 'slug' => 'product',
+ 'label' => [
+ 'singular' => __('Product', 'my-plugin'),
+ 'plural' => __('Products', 'my-plugin'),
+ ],
+ 'fields' => ['name' => 'string'],
+]);
+```
+
+Other labels are auto-generated from these:
+- "Add New Product" from singular
+- "Edit Product" from singular
+- "Products" for menu from plural
+
+## Accessing Labels
+
+Get labels programmatically:
+
+```php
+$config = $view->get_config();
+
+$config->get_singular_label(); // "Product"
+$config->get_plural_label(); // "Products"
+$config->get_label('add_new_item'); // "Add New Product"
+$config->get_label('custom', 'Default'); // "Default"
+```
+
+## Translation Files
+
+Ensure your plugin generates proper translation files:
+
+```bash
+# Generate POT file
+wp i18n make-pot . languages/my-plugin.pot
+
+# Create translations
+# languages/my-plugin-de_DE.po
+
+# Compile to MO
+wp i18n make-mo languages/
+```
+
+Load translations in your plugin:
+
+```php
+add_action('init', function() {
+ load_plugin_textdomain(
+ 'my-plugin',
+ false,
+ dirname(plugin_basename(__FILE__)) . '/languages/'
+ );
+});
+```
diff --git a/website/docs/advanced/repeaters.md b/website/docs/advanced/repeaters.md
new file mode 100644
index 0000000..cf33f48
--- /dev/null
+++ b/website/docs/advanced/repeaters.md
@@ -0,0 +1,245 @@
+---
+sidebar_position: 3
+title: Repeaters
+description: Managing collections of sub-items
+---
+
+# Repeater Fields
+
+Repeater fields allow users to manage collections of sub-items within a single entity. Data is stored as JSON.
+
+## Basic Definition
+
+```php
+'fields' => [
+ 'name' => 'string',
+ 'items' => [
+ 'type' => 'repeater',
+ 'sub_fields' => [
+ ['name' => 'title', 'type' => 'string'],
+ ['name' => 'quantity', 'type' => 'integer'],
+ ['name' => 'active', 'type' => 'boolean'],
+ ],
+ ],
+]
+```
+
+## Sub-Field Types
+
+Repeater sub-fields support JSON-compatible primitive types:
+
+| Type | Description |
+|------|-------------|
+| `string` | Text values |
+| `integer` | Numeric values |
+| `boolean` | True/false values |
+
+## Configuration Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `sub_fields` | array | (required) | Array of sub-field definitions |
+| `layout` | string | `'table'` | Layout style: `'table'` or `'block'` |
+| `min_rows` | int | - | Minimum number of rows |
+| `max_rows` | int | - | Maximum number of rows |
+| `button_label` | string | - | Custom "Add" button text |
+| `default` | array | `[]` | Default rows for new items |
+| `description` | string | - | Help text for the field |
+
+## Sub-Field Options
+
+Each sub-field accepts:
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `name` | string | Field identifier (required) |
+| `type` | string | Field type (required) |
+| `label` | string | Display label |
+| `placeholder` | string | Placeholder text |
+| `description` | string | Help text |
+| `min` | int | Minimum value (for integers) |
+| `max` | int | Maximum value (for integers) |
+
+## Complete Example
+
+```php
+$view = new DataView([
+ 'slug' => 'invoice',
+ 'label' => 'Invoice',
+ 'fields' => [
+ 'customer_name' => 'string',
+ 'line_items' => [
+ 'type' => 'repeater',
+ 'label' => 'Line Items',
+ 'description' => 'Add products or services',
+ 'layout' => 'table',
+ 'min_rows' => 1,
+ 'max_rows' => 50,
+ 'button_label' => 'Add Line Item',
+ 'sub_fields' => [
+ [
+ 'name' => 'description',
+ 'type' => 'string',
+ 'label' => 'Description',
+ 'placeholder' => 'Product or service',
+ ],
+ [
+ 'name' => 'quantity',
+ 'type' => 'integer',
+ 'label' => 'Qty',
+ 'min' => 1,
+ ],
+ [
+ 'name' => 'unit_price',
+ 'type' => 'integer',
+ 'label' => 'Price (cents)',
+ 'min' => 0,
+ ],
+ [
+ 'name' => 'taxable',
+ 'type' => 'boolean',
+ 'label' => 'Tax',
+ ],
+ ],
+ 'default' => [
+ ['description' => '', 'quantity' => 1, 'unit_price' => 0, 'taxable' => true],
+ ],
+ ],
+ ],
+]);
+```
+
+## Reading Repeater Data
+
+Repeater data is stored as JSON. Decode it when reading:
+
+```php
+$handler = $view->get_handler();
+$result = $handler->read($id);
+$entity = $result->get_entity();
+
+$line_items = json_decode($entity->get('line_items'), true);
+
+foreach ($line_items as $item) {
+ echo $item['description'] . ': ';
+ echo $item['quantity'] . ' × ' . $item['unit_price'];
+}
+```
+
+## Writing Repeater Data
+
+Encode data as JSON when creating or updating:
+
+```php
+$handler->create([
+ 'customer_name' => 'John Doe',
+ 'line_items' => json_encode([
+ ['description' => 'Widget', 'quantity' => 2, 'unit_price' => 1000, 'taxable' => true],
+ ['description' => 'Service', 'quantity' => 1, 'unit_price' => 5000, 'taxable' => false],
+ ]),
+]);
+```
+
+## Data Structure
+
+Rows include a `key` property for identification:
+
+```json
+[
+ {"key": "abc123", "description": "Widget", "quantity": 2, "unit_price": 1000},
+ {"key": "def456", "description": "Service", "quantity": 1, "unit_price": 5000}
+]
+```
+
+The `key` is managed automatically by the renderer.
+
+## Layout Options
+
+### Table Layout
+
+Displays rows as a table with columns:
+
+```php
+'layout' => 'table',
+```
+
+Best for:
+- Many short fields
+- Numeric data
+- Quick scanning
+
+### Block Layout
+
+Displays each row as a card/block:
+
+```php
+'layout' => 'block',
+```
+
+Best for:
+- Fewer, longer fields
+- Text content
+- Complex sub-structures
+
+## Renderer Support
+
+| Renderer | Repeater Support |
+|----------|------------------|
+| HtmlRenderer | Basic table UI |
+| TangibleFieldsRenderer | Full support with drag-and-drop |
+
+For the best repeater experience, use TangibleFieldsRenderer:
+
+```php
+use Tangible\Renderer\TangibleFieldsRenderer;
+
+$view->set_renderer(new TangibleFieldsRenderer());
+```
+
+## Security
+
+The repeater sanitizer:
+- Strips nested arrays/objects (only primitives allowed)
+- Sanitizes all string values
+- Returns `[]` for invalid JSON
+- Preserves the `key` field
+
+## Processing Repeater Data
+
+Common patterns for working with repeater data:
+
+### Calculate Totals
+
+```php
+$handler->before_create(function($data) {
+ $items = json_decode($data['line_items'] ?? '[]', true);
+ $total = 0;
+
+ foreach ($items as $item) {
+ $total += ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0);
+ }
+
+ $data['total'] = $total;
+ return $data;
+});
+```
+
+### Validate Rows
+
+```php
+$handler->add_validator('line_items', function($value) {
+ $items = json_decode($value, true);
+
+ if (empty($items)) {
+ return new ValidationError('At least one line item is required');
+ }
+
+ foreach ($items as $i => $item) {
+ if (empty($item['description'])) {
+ return new ValidationError("Row " . ($i + 1) . ": Description is required");
+ }
+ }
+
+ return true;
+});
+```
diff --git a/website/docs/api-reference.md b/website/docs/api-reference.md
new file mode 100644
index 0000000..6b49aba
--- /dev/null
+++ b/website/docs/api-reference.md
@@ -0,0 +1,226 @@
+---
+sidebar_position: 100
+title: API Reference
+description: Complete API reference for all classes
+---
+
+# API Reference
+
+Quick reference for all public APIs.
+
+## DataView
+
+### Constructor
+
+```php
+$view = new DataView(array $config);
+```
+
+### Methods
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `register()` | `static` | Registers admin menu and hooks |
+| `get_handler()` | `PluralHandler\|SingularHandler` | Request handler |
+| `get_object()` | `PluralObject\|SingularObject` | Data object |
+| `get_dataset()` | `DataSet` | Dataset instance |
+| `get_config()` | `DataViewConfig` | Configuration |
+| `get_field_registry()` | `FieldTypeRegistry` | Field registry |
+| `url(string $action, ?int $id)` | `string` | Admin URL |
+| `set_layout(callable $callback)` | `static` | Custom layout |
+| `set_renderer(Renderer $renderer)` | `static` | Custom renderer |
+| `handle_request()` | `void` | Handle current request |
+
+## DataViewConfig
+
+### Properties (readonly)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `$slug` | string | Unique identifier |
+| `$label` | string | Singular label |
+| `$labels` | array | Full labels array |
+| `$fields` | array | Field definitions |
+| `$field_configs` | array | Full field configs |
+| `$storage` | string | Storage type |
+| `$mode` | string | plural/singular |
+| `$capability` | string | Required capability |
+| `$storage_options` | array | Storage options |
+| `$ui` | array | UI configuration |
+
+### Methods
+
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `is_plural()` | `bool` | Is plural mode |
+| `is_singular()` | `bool` | Is singular mode |
+| `get_menu_page()` | `string` | Menu slug |
+| `get_menu_label()` | `string` | Menu label |
+| `get_parent_menu()` | `?string` | Parent menu |
+| `get_icon()` | `string` | Menu icon |
+| `get_position()` | `?int` | Menu position |
+| `get_singular_label()` | `string` | Singular label |
+| `get_plural_label()` | `?string` | Plural label |
+| `get_label(string $key, ?string $fallback)` | `?string` | Specific label |
+| `get_field_config(string $name)` | `?array` | Field config |
+
+## PluralHandler
+
+### CRUD Methods
+
+```php
+$result = $handler->create(array $data);
+$result = $handler->read(int $id);
+$result = $handler->update(int $id, array $data);
+$result = $handler->delete(int $id);
+$result = $handler->list();
+```
+
+### Validation
+
+```php
+$handler->add_validator(string $field, callable $validator);
+```
+
+### Lifecycle Hooks
+
+```php
+$handler->before_create(callable $callback);
+$handler->after_create(callable $callback);
+$handler->before_update(callable $callback);
+$handler->after_update(callable $callback);
+$handler->before_delete(callable $callback);
+$handler->after_delete(callable $callback);
+```
+
+## SingularHandler
+
+### Methods
+
+```php
+$result = $handler->read();
+$result = $handler->update(array $data);
+$handler->add_validator(string $field, callable $validator);
+$handler->before_update(callable $callback);
+$handler->after_update(callable $callback);
+```
+
+## Validators
+
+```php
+use Tangible\RequestHandler\Validators;
+
+Validators::required();
+Validators::email();
+Validators::min_length(int $length);
+Validators::max_length(int $length);
+Validators::min(int $value);
+Validators::max(int $value);
+Validators::in(array $values);
+```
+
+## Result Object
+
+```php
+$result->is_success();
+$result->is_error();
+$result->get_entity(); // PluralHandler
+$result->get_entities(); // PluralHandler::list()
+$result->get_data(); // SingularHandler
+$result->get_errors();
+```
+
+## ValidationError
+
+```php
+use Tangible\RequestHandler\ValidationError;
+
+$error = new ValidationError(string $message, ?string $field = null);
+$error->get_message();
+$error->get_field();
+```
+
+## Layout Classes
+
+### Layout
+
+```php
+$layout = new Layout(DataSet $dataset);
+$layout->section(string $label, callable $callback);
+$layout->tabs(callable $callback);
+$layout->sidebar(callable $callback);
+$layout->get_structure();
+$layout->get_dataset();
+```
+
+### Section
+
+```php
+$section->field(string $name);
+$section->section(string $label, callable $callback);
+$section->tabs(callable $callback);
+$section->columns(int $count);
+$section->condition(string $field, mixed $value);
+```
+
+### Field (in Section)
+
+```php
+$section->field('name')
+ ->placeholder(string $text)
+ ->help(string $text)
+ ->readonly()
+ ->width(string $width);
+```
+
+### Tabs
+
+```php
+$tabs->tab(string $label, callable $callback);
+```
+
+### Tab
+
+```php
+$tab->field(string $name);
+$tab->section(string $label, callable $callback);
+$tab->tabs(callable $callback);
+```
+
+### Sidebar
+
+```php
+$sidebar->field(string $name);
+$sidebar->actions(array $actions);
+```
+
+## FieldTypeRegistry
+
+```php
+$registry->has_type(string $type);
+$registry->get_dataset_type(string $type);
+$registry->get_sanitizer(string $type);
+$registry->get_schema(string $type);
+$registry->get_input_type(string $type);
+$registry->register_type(string $name, array $config);
+```
+
+## Renderer Interface
+
+```php
+interface Renderer {
+ public function render_editor(Layout $layout, array $data = []): string;
+ public function render_list(DataSet $dataset, array $entities): string;
+}
+```
+
+## UrlBuilder
+
+```php
+$builder = new UrlBuilder(DataViewConfig $config);
+$builder->url(string $action, ?int $id, array $extra);
+$builder->url_with_nonce(string $action, ?int $id, string $nonce_action);
+$builder->get_current_action();
+$builder->get_current_id();
+$builder->get_nonce_action(string $action, ?int $id);
+```
diff --git a/website/docs/dataview/configuration.md b/website/docs/dataview/configuration.md
new file mode 100644
index 0000000..22d88f0
--- /dev/null
+++ b/website/docs/dataview/configuration.md
@@ -0,0 +1,123 @@
+---
+sidebar_position: 2
+title: Configuration
+description: DataView configuration options reference
+---
+
+# Configuration
+
+This page documents all configuration options available when creating a DataView.
+
+## Required Options
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `slug` | string | Unique identifier. Must be lowercase alphanumeric with underscores, starting with a letter or underscore. For CPT storage, must be 20 characters or less. |
+| `label` | string\|array | Singular label or array with label configuration. See [Internationalization](/advanced/i18n). |
+| `fields` | array | Field name to type mapping. See [Field Types](/dataview/field-types). |
+
+## Optional Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `storage` | string | `'cpt'` | Storage backend: `'cpt'`, `'database'`, or `'option'`. |
+| `mode` | string | `'plural'` | `'plural'` for multiple items, `'singular'` for settings. |
+| `capability` | string | `'manage_options'` | Required WordPress capability to access the admin page. |
+| `storage_options` | array | `[]` | Additional options passed to storage adapter. |
+| `ui` | array | See below | Admin UI configuration. |
+
+## UI Options
+
+```php
+'ui' => [
+ 'menu_page' => 'my_page', // Menu page slug (defaults to config slug)
+ 'menu_label' => 'My Items', // Menu label (defaults to config label)
+ 'parent' => null, // Parent menu slug (null for top-level)
+ 'icon' => 'dashicons-admin-generic', // Menu icon
+ 'position' => null, // Menu position (null for default)
+]
+```
+
+### Parent Menu Options
+
+To add your page as a submenu:
+
+```php
+// Under Settings
+'parent' => 'options-general.php'
+
+// Under Tools
+'parent' => 'tools.php'
+
+// Under a custom post type
+'parent' => 'edit.php?post_type=product'
+
+// Under another plugin's menu
+'parent' => 'my-plugin-slug'
+```
+
+## Complete Example
+
+```php
+use Tangible\DataView\DataView;
+
+$view = new DataView([
+ // Required
+ 'slug' => 'customer',
+ 'label' => [
+ 'singular' => 'Customer',
+ 'plural' => 'Customers',
+ ],
+ 'fields' => [
+ 'name' => 'string',
+ 'email' => 'email',
+ 'company' => 'string',
+ 'notes' => 'text',
+ 'is_active' => 'boolean',
+ 'created_at' => 'datetime',
+ ],
+
+ // Optional
+ 'storage' => 'cpt',
+ 'mode' => 'plural',
+ 'capability' => 'manage_options',
+
+ 'storage_options' => [
+ // CPT-specific options
+ 'public' => false,
+ ],
+
+ 'ui' => [
+ 'menu_page' => 'customers',
+ 'menu_label' => 'Customers',
+ 'parent' => null,
+ 'icon' => 'dashicons-groups',
+ 'position' => 30,
+ ],
+]);
+```
+
+## Configuration Object
+
+After creating a DataView, you can access the parsed configuration:
+
+```php
+$config = $view->get_config();
+
+// Access properties
+$config->slug; // 'customer'
+$config->label; // 'Customer'
+$config->fields; // ['name' => 'string', ...]
+$config->storage; // 'cpt'
+$config->mode; // 'plural'
+$config->capability; // 'manage_options'
+
+// Helper methods
+$config->is_plural(); // true
+$config->is_singular(); // false
+$config->get_menu_page(); // 'customers'
+$config->get_menu_label(); // 'Customers'
+$config->get_parent_menu(); // null
+$config->get_icon(); // 'dashicons-groups'
+$config->get_position(); // 30
+```
diff --git a/website/docs/dataview/field-types.md b/website/docs/dataview/field-types.md
new file mode 100644
index 0000000..6403e71
--- /dev/null
+++ b/website/docs/dataview/field-types.md
@@ -0,0 +1,118 @@
+---
+sidebar_position: 3
+title: Field Types
+description: Available field types and their configuration
+---
+
+# Field Types
+
+DataView provides built-in field types that handle data type mapping, input sanitization, and HTML input rendering.
+
+## Built-in Types
+
+| Type | HTML Input | Sanitizer | Description |
+|------|------------|-----------|-------------|
+| `string` | text | `sanitize_text_field` | Single-line text |
+| `text` | textarea | `sanitize_textarea_field` | Multi-line text |
+| `email` | email | `sanitize_email` | Email address |
+| `url` | url | `esc_url_raw` | URL |
+| `integer` | number | `intval` | Whole numbers |
+| `boolean` | checkbox | custom | True/false values |
+| `date` | date | `sanitize_text_field` | Date (YYYY-MM-DD) |
+| `datetime` | datetime-local | `sanitize_text_field` | Date and time |
+| `repeater` | repeater | JSON sanitizer | Collection of sub-items |
+
+## Defining Fields
+
+Fields can be defined in two formats:
+
+### Simple Format
+
+Use a string for the type:
+
+```php
+'fields' => [
+ 'name' => 'string',
+ 'email' => 'email',
+ 'count' => 'integer',
+ 'notes' => 'text',
+]
+```
+
+### Complex Format
+
+Use an array with a `type` key for additional configuration:
+
+```php
+'fields' => [
+ 'name' => [
+ 'type' => 'string',
+ 'label' => 'Full Name',
+ 'placeholder' => 'Enter your name',
+ 'description' => 'Your legal name',
+ ],
+ 'quantity' => [
+ 'type' => 'integer',
+ 'min' => 1,
+ 'max' => 100,
+ ],
+]
+```
+
+The complex format is required for repeater fields and allows additional configuration for any type.
+
+## Field Configuration Options
+
+Common options available in complex format:
+
+| Option | Type | Description |
+|--------|------|-------------|
+| `type` | string | The field type (required) |
+| `label` | string | Display label (auto-generated from name if not set) |
+| `placeholder` | string | Placeholder text for input |
+| `description` | string | Help text shown below the field |
+
+Type-specific options:
+
+| Type | Option | Description |
+|------|--------|-------------|
+| `integer` | `min` | Minimum value |
+| `integer` | `max` | Maximum value |
+| `text` | `rows` | Number of textarea rows |
+
+## Boolean Sanitization
+
+The boolean sanitizer accepts various truthy values:
+
+- `true`, `'1'`, `'true'`, `'yes'`, `'on'` → `true`
+- `false`, `'0'`, `''`, `'no'`, any other value → `false`
+
+## Type Coercion
+
+DataView automatically coerces values based on field type:
+
+```php
+// Integer field with string input
+$handler->create(['count' => '5']); // Stored as integer 5
+
+// Boolean field with various inputs
+$handler->create(['active' => 'yes']); // Stored as true
+$handler->create(['active' => '0']); // Stored as false
+```
+
+## Custom Field Types
+
+You can register custom field types. See [Custom Field Types](/advanced/custom-field-types) for details.
+
+```php
+$registry = $view->get_field_registry();
+
+$registry->register_type('phone', [
+ 'dataset' => DataSet::TYPE_STRING,
+ 'sanitizer' => function($value) {
+ return preg_replace('/[^0-9+\-\s()]/', '', $value);
+ },
+ 'schema' => ['type' => 'varchar', 'length' => 20],
+ 'input' => 'tel',
+]);
+```
diff --git a/website/docs/dataview/lifecycle-hooks.md b/website/docs/dataview/lifecycle-hooks.md
new file mode 100644
index 0000000..dbe4d76
--- /dev/null
+++ b/website/docs/dataview/lifecycle-hooks.md
@@ -0,0 +1,223 @@
+---
+sidebar_position: 6
+title: Lifecycle Hooks
+description: React to create, update, and delete operations
+---
+
+# Lifecycle Hooks
+
+Lifecycle hooks let you execute custom logic before or after CRUD operations.
+
+## Plural Mode Hooks
+
+### before_create
+
+Modify data before creating an entity:
+
+```php
+$handler->before_create(function(array $data) {
+ $data['created_at'] = current_time('mysql');
+ $data['created_by'] = get_current_user_id();
+ return $data;
+});
+```
+
+### after_create
+
+React after an entity is created:
+
+```php
+$handler->after_create(function($entity) {
+ // Send notification
+ wp_mail(
+ get_option('admin_email'),
+ 'New entry created',
+ 'A new ' . $entity->get('title') . ' was created.'
+ );
+
+ // Clear cache
+ delete_transient('my_items_list');
+});
+```
+
+### before_update
+
+Modify data before updating. Receives the entity and new data:
+
+```php
+$handler->before_update(function($entity, array $data) {
+ $data['updated_at'] = current_time('mysql');
+
+ // Track who made changes
+ $data['updated_by'] = get_current_user_id();
+
+ return $data;
+});
+```
+
+### after_update
+
+React after an update:
+
+```php
+$handler->after_update(function($entity) {
+ // Clear specific cache
+ delete_transient('item_' . $entity->get_id());
+
+ // Log the change
+ do_action('my_plugin_item_updated', $entity);
+});
+```
+
+### before_delete
+
+Control whether deletion proceeds. Return `false` to cancel:
+
+```php
+$handler->before_delete(function($entity) {
+ // Prevent deletion of protected items
+ if ($entity->get('is_protected')) {
+ return false;
+ }
+
+ // Or check user permissions
+ if (!current_user_can('delete_others_posts')) {
+ return false;
+ }
+
+ return true;
+});
+```
+
+### after_delete
+
+Clean up after deletion. Receives the deleted ID:
+
+```php
+$handler->after_delete(function($id) {
+ // Clean up related data
+ global $wpdb;
+ $wpdb->delete('my_related_table', ['parent_id' => $id]);
+
+ // Clear caches
+ delete_transient('my_items_list');
+});
+```
+
+## Singular Mode Hooks
+
+Singular mode (settings pages) has different hook signatures since there's no entity concept.
+
+### before_update
+
+Receives current data and new data:
+
+```php
+$handler->before_update(function(array $current, array $data) {
+ // Detect changes
+ if ($current['api_key'] !== $data['api_key']) {
+ // API key changed, invalidate tokens
+ delete_transient('my_api_token');
+ }
+
+ // Clear cache if cache settings changed
+ if ($current['cache_ttl'] !== $data['cache_ttl']) {
+ wp_cache_flush();
+ }
+
+ return $data;
+});
+```
+
+### after_update
+
+Receives the updated data:
+
+```php
+$handler->after_update(function(array $data) {
+ // Log settings change
+ if ($data['debug_mode']) {
+ error_log('Plugin settings updated: ' . json_encode($data));
+ }
+
+ // Trigger action for other plugins
+ do_action('my_plugin_settings_updated', $data);
+});
+```
+
+## Chaining Hooks
+
+Hooks can be chained for cleaner code:
+
+```php
+$handler
+ ->before_create(function($data) {
+ $data['created_at'] = current_time('mysql');
+ return $data;
+ })
+ ->after_create(function($entity) {
+ delete_transient('items_list');
+ })
+ ->before_update(function($entity, $data) {
+ $data['updated_at'] = current_time('mysql');
+ return $data;
+ })
+ ->after_update(function($entity) {
+ delete_transient('item_' . $entity->get_id());
+ });
+```
+
+## Common Patterns
+
+### Auto-generate Slugs
+
+```php
+$handler->before_create(function($data) {
+ if (empty($data['slug'])) {
+ $data['slug'] = sanitize_title($data['title']);
+ }
+ return $data;
+});
+```
+
+### Initialize Counters
+
+```php
+$handler->before_create(function($data) {
+ $data['view_count'] = 0;
+ $data['like_count'] = 0;
+ return $data;
+});
+```
+
+### Send Notifications
+
+```php
+$handler->after_create(function($entity) {
+ $user = get_user_by('id', $entity->get('user_id'));
+ if ($user) {
+ wp_mail(
+ $user->user_email,
+ 'Your submission was received',
+ 'Thank you for submitting ' . $entity->get('title')
+ );
+ }
+});
+```
+
+### Cascade Deletes
+
+```php
+$handler->after_delete(function($id) {
+ // Delete child items
+ $children = get_posts([
+ 'post_type' => 'child_item',
+ 'meta_key' => 'parent_id',
+ 'meta_value' => $id,
+ ]);
+
+ foreach ($children as $child) {
+ wp_delete_post($child->ID, true);
+ }
+});
+```
diff --git a/website/docs/dataview/overview.md b/website/docs/dataview/overview.md
new file mode 100644
index 0000000..63dfb84
--- /dev/null
+++ b/website/docs/dataview/overview.md
@@ -0,0 +1,145 @@
+---
+sidebar_position: 1
+title: Overview
+description: Understanding the DataView high-level API
+---
+
+# DataView Overview
+
+The DataView layer is a high-level facade that orchestrates all Tangible Object components to provide a simple, declarative API for creating WordPress admin interfaces.
+
+## What DataView Does
+
+Instead of manually wiring together DataSet, EditorLayout, Renderer, and RequestHandler, DataView lets you define everything in a single configuration:
+
+```php
+use Tangible\DataView\DataView;
+
+$view = new DataView([
+ 'slug' => 'book',
+ 'label' => 'Book',
+ 'fields' => [
+ 'title' => 'string',
+ 'author' => 'string',
+ 'isbn' => 'string',
+ 'published' => 'date',
+ 'in_stock' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Books',
+ 'icon' => 'dashicons-book',
+ ],
+]);
+
+$view->register();
+```
+
+This single declaration:
+- Creates a DataSet with the specified fields
+- Sets up the appropriate storage adapter (CPT by default)
+- Creates a request handler with proper sanitization
+- Registers an admin menu page
+- Handles all CRUD operations with forms and validation
+
+## Modes
+
+DataView supports two modes:
+
+### Plural Mode (Default)
+
+For managing multiple items with full CRUD operations:
+
+```php
+$view = new DataView([
+ 'slug' => 'product',
+ 'label' => 'Product',
+ 'mode' => 'plural', // This is the default
+ 'fields' => [...],
+]);
+```
+
+Provides:
+- List view with all items
+- Create form
+- Edit form
+- Delete action
+
+### Singular Mode
+
+For single-instance data like plugin settings:
+
+```php
+$view = new DataView([
+ 'slug' => 'my_plugin_settings',
+ 'label' => 'Settings',
+ 'mode' => 'singular',
+ 'storage' => 'option',
+ 'fields' => [...],
+]);
+```
+
+Provides:
+- Single form for reading/updating settings
+- No create/delete operations
+
+## Accessing Components
+
+After creating a DataView, you can access the underlying components:
+
+```php
+$view = new DataView([...]);
+
+// Get the request handler for validation and hooks
+$handler = $view->get_handler();
+
+// Get the data object
+$object = $view->get_object();
+
+// Get the dataset
+$dataset = $view->get_dataset();
+
+// Get the configuration
+$config = $view->get_config();
+
+// Get the field registry
+$registry = $view->get_field_registry();
+```
+
+## Programmatic Usage
+
+DataView can be used outside the admin context for programmatic data access:
+
+```php
+$view = new DataView([
+ 'slug' => 'subscriber',
+ 'label' => 'Subscriber',
+ 'fields' => [
+ 'email' => 'email',
+ 'subscribed' => 'boolean',
+ ],
+]);
+
+$handler = $view->get_handler();
+
+// Create
+$result = $handler->create([
+ 'email' => 'user@example.com',
+ 'subscribed' => true,
+]);
+
+// Read
+$result = $handler->read($id);
+$entity = $result->get_entity();
+
+// Update
+$handler->update($id, ['subscribed' => false]);
+
+// Delete
+$handler->delete($id);
+
+// List all
+$result = $handler->list();
+foreach ($result->get_entities() as $entity) {
+ echo $entity->get('email');
+}
+```
diff --git a/website/docs/dataview/storage.md b/website/docs/dataview/storage.md
new file mode 100644
index 0000000..711f7a3
--- /dev/null
+++ b/website/docs/dataview/storage.md
@@ -0,0 +1,145 @@
+---
+sidebar_position: 4
+title: Storage
+description: Storage backends for DataView
+---
+
+# Storage Options
+
+DataView supports multiple storage backends to suit different use cases.
+
+## Custom Post Type (Default)
+
+```php
+'storage' => 'cpt',
+```
+
+Uses WordPress Custom Post Types. Best for:
+- Integration with existing WordPress workflows
+- Content that benefits from post features (revisions, author, etc.)
+- Compatibility with WordPress admin features
+
+**Limitations:**
+- CPT slugs must be 20 characters or less
+- Uses post meta for field storage
+
+**Storage options:**
+
+```php
+'storage_options' => [
+ 'public' => false, // Hide from frontend
+ 'show_in_rest' => true, // Enable REST API
+ 'supports' => ['title'], // Post type supports
+],
+```
+
+## Database
+
+```php
+'storage' => 'database',
+```
+
+Uses custom database tables via the Database Module. Best for:
+- High-volume data
+- Complex queries
+- Data that doesn't fit the post model
+- Better performance for large datasets
+
+**Storage options:**
+
+```php
+'storage_options' => [
+ 'version' => 1, // Increment when schema changes
+],
+```
+
+The database schema is auto-generated from field definitions:
+
+| Field Type | Database Column |
+|------------|-----------------|
+| `string` | VARCHAR(255) |
+| `text` | TEXT |
+| `email` | VARCHAR(255) |
+| `url` | VARCHAR(512) |
+| `integer` | INT(11) |
+| `boolean` | TINYINT(1) |
+| `date` | DATE |
+| `datetime` | DATETIME |
+| `repeater` | LONGTEXT |
+
+## Option
+
+```php
+'storage' => 'option',
+```
+
+Uses WordPress options. Best for:
+- Singular mode (settings pages)
+- Single-instance data
+- Simple key-value storage
+
+**Note:** This storage type is typically used with `'mode' => 'singular'`.
+
+```php
+$view = new DataView([
+ 'slug' => 'my_plugin_settings',
+ 'label' => 'Settings',
+ 'storage' => 'option',
+ 'mode' => 'singular',
+ 'fields' => [
+ 'api_key' => 'string',
+ 'debug_mode' => 'boolean',
+ ],
+]);
+```
+
+## Choosing a Storage Backend
+
+| Use Case | Recommended Storage |
+|----------|---------------------|
+| Plugin settings | `option` + `singular` mode |
+| Content-like data (posts, pages) | `cpt` |
+| High-volume transactional data | `database` |
+| Data needing WordPress features | `cpt` |
+| Custom queries and joins | `database` |
+| Simple CRUD with few records | `cpt` |
+
+## Example: Settings Page
+
+```php
+$view = new DataView([
+ 'slug' => 'my_plugin_settings',
+ 'label' => 'Settings',
+ 'storage' => 'option',
+ 'mode' => 'singular',
+ 'fields' => [
+ 'api_key' => 'string',
+ 'api_url' => 'url',
+ 'cache_ttl' => 'integer',
+ 'debug_mode' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'My Plugin',
+ 'parent' => 'options-general.php',
+ ],
+]);
+```
+
+## Example: High-Volume Data
+
+```php
+$view = new DataView([
+ 'slug' => 'analytics_event',
+ 'label' => 'Event',
+ 'storage' => 'database',
+ 'storage_options' => [
+ 'version' => 1,
+ ],
+ 'fields' => [
+ 'event_type' => 'string',
+ 'user_id' => 'integer',
+ 'data' => 'text',
+ 'created_at' => 'datetime',
+ ],
+]);
+```
diff --git a/website/docs/dataview/validation.md b/website/docs/dataview/validation.md
new file mode 100644
index 0000000..95110b5
--- /dev/null
+++ b/website/docs/dataview/validation.md
@@ -0,0 +1,153 @@
+---
+sidebar_position: 5
+title: Validation
+description: Validating data with built-in and custom validators
+---
+
+# Validation
+
+DataView provides a flexible validation system through the request handler.
+
+## Adding Validators
+
+Access the handler and add validators:
+
+```php
+use Tangible\RequestHandler\Validators;
+
+$view = new DataView([...]);
+
+$handler = $view->get_handler();
+
+$handler
+ ->add_validator('title', Validators::required())
+ ->add_validator('email', Validators::email());
+```
+
+Multiple validators can be added to the same field:
+
+```php
+$handler
+ ->add_validator('email', Validators::required())
+ ->add_validator('email', Validators::email());
+```
+
+## Built-in Validators
+
+### Required
+
+Ensures the field has a non-empty value:
+
+```php
+$handler->add_validator('name', Validators::required());
+```
+
+### Email
+
+Validates email format:
+
+```php
+$handler->add_validator('email', Validators::email());
+```
+
+### String Length
+
+```php
+$handler->add_validator('username', Validators::min_length(3));
+$handler->add_validator('username', Validators::max_length(20));
+```
+
+### Numeric Range
+
+```php
+$handler->add_validator('age', Validators::min(0));
+$handler->add_validator('age', Validators::max(120));
+```
+
+### Allowed Values
+
+Ensures the value is one of the allowed options:
+
+```php
+$handler->add_validator('status', Validators::in(['draft', 'published', 'archived']));
+```
+
+## Custom Validators
+
+Create custom validation logic by passing a callable:
+
+```php
+use Tangible\RequestHandler\ValidationError;
+
+$handler->add_validator('slug', function($value) {
+ if (!preg_match('/^[a-z0-9-]+$/', $value)) {
+ return new ValidationError(
+ 'Slug can only contain lowercase letters, numbers, and hyphens'
+ );
+ }
+ return true;
+});
+```
+
+### Validator Signature
+
+Custom validators receive:
+- `$value` - The field value being validated
+
+They should return:
+- `true` - Validation passed
+- `ValidationError` - Validation failed with a message
+
+### Example: Unique Value
+
+```php
+$handler->add_validator('email', function($value) use ($view) {
+ // Check if email already exists
+ $existing = $view->get_handler()->list();
+ foreach ($existing->get_entities() as $entity) {
+ if ($entity->get('email') === $value) {
+ return new ValidationError('This email is already registered');
+ }
+ }
+ return true;
+});
+```
+
+## Validation Errors
+
+When validation fails, errors are available on the result:
+
+```php
+$result = $handler->create([
+ 'name' => '', // Required field is empty
+ 'email' => 'invalid', // Invalid email format
+]);
+
+if ($result->is_error()) {
+ $errors = $result->get_errors();
+
+ foreach ($errors as $error) {
+ echo $error->get_field() . ': ' . $error->get_message();
+ }
+}
+```
+
+In the admin UI, validation errors are automatically displayed above the form.
+
+## Validation Order
+
+Validators run in the order they were added. If a validator fails, subsequent validators for that field still run, and all errors are collected.
+
+```php
+$handler
+ ->add_validator('password', Validators::required())
+ ->add_validator('password', Validators::min_length(8))
+ ->add_validator('password', function($value) {
+ if (!preg_match('/[A-Z]/', $value)) {
+ return new ValidationError('Must contain an uppercase letter');
+ }
+ return true;
+ });
+```
+
+All three validators run, and all failing validators contribute to the error list.
diff --git a/website/docs/examples/crud-admin.md b/website/docs/examples/crud-admin.md
new file mode 100644
index 0000000..ae07353
--- /dev/null
+++ b/website/docs/examples/crud-admin.md
@@ -0,0 +1,249 @@
+---
+sidebar_position: 2
+title: CRUD Admin
+description: Building a full CRUD admin interface
+---
+
+# CRUD Admin Example
+
+This example shows how to create a complete admin interface for managing data with create, read, update, and delete operations.
+
+## Basic CRUD
+
+```php
+use Tangible\DataView\DataView;
+use Tangible\RequestHandler\Validators;
+
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'contact_entry',
+ 'label' => [
+ 'singular' => 'Contact Entry',
+ 'plural' => 'Contact Entries',
+ ],
+ 'fields' => [
+ 'name' => 'string',
+ 'email' => 'email',
+ 'message' => 'text',
+ 'subscribe' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Contact Entries',
+ 'icon' => 'dashicons-email',
+ ],
+ ]);
+
+ $view->get_handler()
+ ->add_validator('name', Validators::required())
+ ->add_validator('email', Validators::required())
+ ->add_validator('email', Validators::email());
+
+ $view->register();
+});
+```
+
+## Complete Blog Post Manager
+
+A more complex example with custom layout, validation, and hooks:
+
+```php
+use Tangible\DataView\DataView;
+use Tangible\RequestHandler\Validators;
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Section;
+use Tangible\EditorLayout\Sidebar;
+use Tangible\EditorLayout\Tabs;
+use Tangible\EditorLayout\Tab;
+
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'blog_post',
+ 'label' => [
+ 'singular' => __('Blog Post', 'my-theme'),
+ 'plural' => __('Blog Posts', 'my-theme'),
+ 'add_new_item' => __('Write New Post', 'my-theme'),
+ 'edit_item' => __('Edit Post', 'my-theme'),
+ ],
+ 'fields' => [
+ 'title' => 'string',
+ 'slug' => 'string',
+ 'content' => 'text',
+ 'excerpt' => 'text',
+ 'author_email' => 'email',
+ 'published_at' => 'datetime',
+ 'is_featured' => 'boolean',
+ 'view_count' => 'integer',
+ ],
+ 'ui' => [
+ 'menu_label' => __('Blog Posts', 'my-theme'),
+ 'icon' => 'dashicons-edit',
+ 'position' => 5,
+ ],
+ ]);
+
+ // Custom layout with tabs
+ $view->set_layout(function(Layout $layout) {
+ $layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('Content', function(Tab $tab) {
+ $tab->field('title')
+ ->placeholder('Post title')
+ ->help('The main title of the post');
+ $tab->field('slug')
+ ->placeholder('post-url-slug')
+ ->help('URL-friendly identifier');
+ $tab->field('content')
+ ->help('Main post content');
+ $tab->field('excerpt')
+ ->help('Short summary for listings');
+ });
+
+ $tabs->tab('Meta', function(Tab $tab) {
+ $tab->field('author_email');
+ $tab->field('published_at');
+ $tab->field('view_count')->readonly();
+ });
+ });
+
+ $layout->sidebar(function(Sidebar $sidebar) {
+ $sidebar->field('is_featured');
+ $sidebar->actions(['save', 'delete']);
+ });
+ });
+
+ // Validation
+ $view->get_handler()
+ ->add_validator('title', Validators::required())
+ ->add_validator('title', Validators::max_length(200))
+ ->add_validator('slug', Validators::required())
+ ->add_validator('author_email', Validators::email())
+ ->add_validator('slug', function($value) {
+ if (!preg_match('/^[a-z0-9-]+$/', $value)) {
+ return new \Tangible\RequestHandler\ValidationError(
+ 'Slug must contain only lowercase letters, numbers, and hyphens'
+ );
+ }
+ return true;
+ });
+
+ // Lifecycle hooks
+ $view->get_handler()
+ ->before_create(function($data) {
+ $data['view_count'] = 0;
+ if (empty($data['slug'])) {
+ $data['slug'] = sanitize_title($data['title']);
+ }
+ return $data;
+ })
+ ->after_create(function($entity) {
+ delete_transient('blog_posts_list');
+ })
+ ->before_update(function($entity, $data) {
+ // Auto-generate slug if empty
+ if (empty($data['slug']) && !empty($data['title'])) {
+ $data['slug'] = sanitize_title($data['title']);
+ }
+ return $data;
+ })
+ ->after_update(function($entity) {
+ delete_transient('blog_post_' . $entity->get_id());
+ delete_transient('blog_posts_list');
+ })
+ ->after_delete(function($id) {
+ delete_transient('blog_post_' . $id);
+ delete_transient('blog_posts_list');
+ });
+
+ $view->register();
+});
+```
+
+## Product Catalog
+
+An e-commerce product example:
+
+```php
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'product',
+ 'label' => [
+ 'singular' => 'Product',
+ 'plural' => 'Products',
+ ],
+ 'fields' => [
+ 'name' => 'string',
+ 'sku' => 'string',
+ 'description' => 'text',
+ 'price' => 'integer',
+ 'sale_price' => 'integer',
+ 'stock_quantity' => 'integer',
+ 'is_active' => 'boolean',
+ 'is_featured' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Products',
+ 'icon' => 'dashicons-cart',
+ ],
+ ]);
+
+ $view->set_layout(function(Layout $layout) {
+ $layout->section('Product Information', function(Section $s) {
+ $s->field('name')
+ ->placeholder('Product name');
+ $s->field('sku')
+ ->placeholder('SKU-001')
+ ->help('Stock Keeping Unit');
+ $s->field('description');
+ });
+
+ $layout->section('Pricing', function(Section $s) {
+ $s->columns(2);
+ $s->field('price')
+ ->help('Regular price in cents');
+ $s->field('sale_price')
+ ->help('Sale price in cents (leave empty for no sale)');
+ });
+
+ $layout->section('Inventory', function(Section $s) {
+ $s->field('stock_quantity');
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->field('is_active')
+ ->help('Show on storefront');
+ $sb->field('is_featured')
+ ->help('Display on homepage');
+ $sb->actions(['save', 'delete']);
+ });
+ });
+
+ $view->get_handler()
+ ->add_validator('name', Validators::required())
+ ->add_validator('sku', Validators::required())
+ ->add_validator('price', Validators::required())
+ ->add_validator('price', Validators::min(0))
+ ->add_validator('stock_quantity', Validators::min(0));
+
+ $view->register();
+});
+```
+
+## Submenu Under CPT
+
+Add a related data manager under a custom post type:
+
+```php
+$view = new DataView([
+ 'slug' => 'product_review',
+ 'label' => 'Review',
+ 'fields' => [
+ 'product_id' => 'integer',
+ 'rating' => 'integer',
+ 'comment' => 'text',
+ 'approved' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Reviews',
+ 'parent' => 'edit.php?post_type=product',
+ ],
+]);
+```
diff --git a/website/docs/examples/invoice-manager.md b/website/docs/examples/invoice-manager.md
new file mode 100644
index 0000000..fc5154e
--- /dev/null
+++ b/website/docs/examples/invoice-manager.md
@@ -0,0 +1,316 @@
+---
+sidebar_position: 3
+title: Invoice Manager
+description: Complex example with repeater fields
+---
+
+# Invoice Manager Example
+
+This example demonstrates a complete invoice management system using TangibleFieldsRenderer with repeater fields for line items.
+
+## Complete Implementation
+
+```php
+use Tangible\DataView\DataView;
+use Tangible\Renderer\TangibleFieldsRenderer;
+use Tangible\RequestHandler\Validators;
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Section;
+use Tangible\EditorLayout\Sidebar;
+
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'invoice',
+ 'label' => [
+ 'singular' => __('Invoice', 'my-plugin'),
+ 'plural' => __('Invoices', 'my-plugin'),
+ ],
+ 'fields' => [
+ // Customer info
+ 'customer_name' => [
+ 'type' => 'string',
+ 'label' => __('Customer Name', 'my-plugin'),
+ 'placeholder' => 'Enter customer name',
+ ],
+ 'customer_email' => [
+ 'type' => 'email',
+ 'label' => __('Customer Email', 'my-plugin'),
+ 'placeholder' => 'customer@example.com',
+ ],
+
+ // Invoice details
+ 'invoice_date' => [
+ 'type' => 'date',
+ 'label' => __('Invoice Date', 'my-plugin'),
+ ],
+ 'due_date' => [
+ 'type' => 'date',
+ 'label' => __('Due Date', 'my-plugin'),
+ ],
+
+ // Line items (repeater)
+ 'line_items' => [
+ 'type' => 'repeater',
+ 'label' => __('Line Items', 'my-plugin'),
+ 'description' => __('Add products or services to this invoice', 'my-plugin'),
+ 'layout' => 'table',
+ 'min_rows' => 1,
+ 'max_rows' => 100,
+ 'button_label' => __('Add Line Item', 'my-plugin'),
+ 'sub_fields' => [
+ [
+ 'name' => 'description',
+ 'type' => 'string',
+ 'label' => __('Description', 'my-plugin'),
+ 'placeholder' => 'Product or service',
+ ],
+ [
+ 'name' => 'quantity',
+ 'type' => 'integer',
+ 'label' => __('Qty', 'my-plugin'),
+ 'min' => 1,
+ ],
+ [
+ 'name' => 'unit_price',
+ 'type' => 'integer',
+ 'label' => __('Unit Price (cents)', 'my-plugin'),
+ 'min' => 0,
+ ],
+ [
+ 'name' => 'taxable',
+ 'type' => 'boolean',
+ 'label' => __('Tax', 'my-plugin'),
+ ],
+ ],
+ 'default' => [
+ ['description' => '', 'quantity' => 1, 'unit_price' => 0, 'taxable' => true],
+ ],
+ ],
+
+ // Notes
+ 'notes' => [
+ 'type' => 'text',
+ 'label' => __('Notes', 'my-plugin'),
+ 'rows' => 4,
+ 'description' => 'Additional notes to include on the invoice',
+ ],
+
+ // Calculated fields (read-only in UI)
+ 'subtotal' => 'integer',
+ 'tax_total' => 'integer',
+ 'total' => 'integer',
+
+ // Status
+ 'is_paid' => [
+ 'type' => 'boolean',
+ 'label' => __('Paid', 'my-plugin'),
+ ],
+ ],
+ 'ui' => [
+ 'menu_label' => __('Invoices', 'my-plugin'),
+ 'icon' => 'dashicons-media-spreadsheet',
+ ],
+ ]);
+
+ // Use TangibleFieldsRenderer for rich UI
+ $view->set_renderer(new TangibleFieldsRenderer());
+
+ // Custom layout
+ $view->set_layout(function(Layout $layout) {
+ $layout->section('Customer Information', function(Section $s) {
+ $s->columns(2);
+ $s->field('customer_name');
+ $s->field('customer_email');
+ });
+
+ $layout->section('Invoice Details', function(Section $s) {
+ $s->columns(2);
+ $s->field('invoice_date');
+ $s->field('due_date');
+ });
+
+ $layout->section('Line Items', function(Section $s) {
+ $s->field('line_items');
+ });
+
+ $layout->section('Summary', function(Section $s) {
+ $s->columns(3);
+ $s->field('subtotal')->readonly();
+ $s->field('tax_total')->readonly();
+ $s->field('total')->readonly();
+ });
+
+ $layout->section('Additional Info', function(Section $s) {
+ $s->field('notes');
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->field('is_paid');
+ $sb->actions(['save', 'delete']);
+ });
+ });
+
+ // Validation
+ $view->get_handler()
+ ->add_validator('customer_name', Validators::required())
+ ->add_validator('customer_email', Validators::required())
+ ->add_validator('customer_email', Validators::email())
+ ->add_validator('invoice_date', Validators::required());
+
+ // Calculate totals before saving
+ $view->get_handler()
+ ->before_create(function($data) {
+ return calculate_invoice_totals($data);
+ })
+ ->before_update(function($entity, $data) {
+ return calculate_invoice_totals($data);
+ });
+
+ $view->register();
+});
+
+/**
+ * Calculate invoice totals from line items.
+ */
+function calculate_invoice_totals(array $data): array {
+ $line_items = json_decode($data['line_items'] ?? '[]', true);
+ $subtotal = 0;
+ $tax_total = 0;
+ $tax_rate = 0.08; // 8% tax rate
+
+ foreach ($line_items as $item) {
+ $line_total = ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0);
+ $subtotal += $line_total;
+
+ if (!empty($item['taxable'])) {
+ $tax_total += (int) round($line_total * $tax_rate);
+ }
+ }
+
+ $data['subtotal'] = $subtotal;
+ $data['tax_total'] = $tax_total;
+ $data['total'] = $subtotal + $tax_total;
+
+ return $data;
+}
+```
+
+## Display Invoice on Frontend
+
+```php
+function display_invoice($invoice_id) {
+ // Get the DataView handler
+ $view = get_invoice_dataview();
+ $result = $view->get_handler()->read($invoice_id);
+
+ if ($result->is_error()) {
+ return 'Invoice not found.
';
+ }
+
+ $entity = $result->get_entity();
+ $line_items = json_decode($entity->get('line_items'), true);
+
+ ob_start();
+ ?>
+
+
Invoice
+
+
+ get('customer_name')); ?>
+ get('customer_email')); ?>
+
+
+
+
Invoice Date: get('invoice_date')); ?>
+
Due Date: get('due_date')); ?>
+
+
+
+
+
+ | Description |
+ Qty |
+ Unit Price |
+ Total |
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+ | Subtotal |
+ get('subtotal')); ?> |
+
+
+ | Tax |
+ get('tax_total')); ?> |
+
+
+ | Total |
+ get('total')); ?> |
+
+
+
+
+ get('notes')): ?>
+
+
Notes
+
get('notes')); ?>
+
+
+
+
+ get('is_paid')): ?>
+ PAID
+
+ UNPAID
+
+
+
+ 'faq',
+ 'label' => 'FAQ',
+ 'fields' => [
+ 'title' => 'string',
+ 'questions' => [
+ 'type' => 'repeater',
+ 'layout' => 'block',
+ 'sub_fields' => [
+ ['name' => 'question', 'type' => 'string', 'label' => 'Question'],
+ ['name' => 'answer', 'type' => 'string', 'label' => 'Answer'],
+ ],
+ ],
+ ],
+ ]);
+
+ $view->set_renderer(new TangibleFieldsRenderer());
+ $view->register();
+});
+```
diff --git a/website/docs/examples/settings-page.md b/website/docs/examples/settings-page.md
new file mode 100644
index 0000000..f8f9da9
--- /dev/null
+++ b/website/docs/examples/settings-page.md
@@ -0,0 +1,200 @@
+---
+sidebar_position: 1
+title: Settings Page
+description: Building a plugin settings page
+---
+
+# Settings Page Example
+
+This example shows how to create a comprehensive plugin settings page with multiple sections.
+
+## Basic Settings
+
+```php
+use Tangible\DataView\DataView;
+
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'my_plugin_settings',
+ 'label' => 'Settings',
+ 'fields' => [
+ 'api_key' => 'string',
+ 'debug_mode' => 'boolean',
+ ],
+ 'storage' => 'option',
+ 'mode' => 'singular',
+ 'ui' => [
+ 'menu_label' => 'My Plugin',
+ 'parent' => 'options-general.php',
+ ],
+ ]);
+
+ $view->register();
+});
+```
+
+## Complete Settings Page
+
+A full-featured settings page with sections and validation:
+
+```php
+use Tangible\DataView\DataView;
+use Tangible\RequestHandler\Validators;
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Section;
+use Tangible\EditorLayout\Sidebar;
+
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'my_plugin_settings',
+ 'label' => [
+ 'singular' => __('Settings', 'my-plugin'),
+ 'settings' => __('My Plugin Settings', 'my-plugin'),
+ 'settings_saved' => __('Settings saved successfully.', 'my-plugin'),
+ ],
+ 'fields' => [
+ // API Settings
+ 'api_key' => 'string',
+ 'api_endpoint' => 'url',
+ 'api_timeout' => 'integer',
+
+ // Cache Settings
+ 'cache_enabled' => 'boolean',
+ 'cache_ttl' => 'integer',
+
+ // Display Settings
+ 'items_per_page' => 'integer',
+ 'date_format' => 'string',
+
+ // Debug
+ 'debug_mode' => 'boolean',
+ 'log_level' => 'string',
+ ],
+ 'storage' => 'option',
+ 'mode' => 'singular',
+ 'ui' => [
+ 'menu_label' => __('My Plugin', 'my-plugin'),
+ 'parent' => 'options-general.php',
+ ],
+ ]);
+
+ // Custom layout with sections
+ $view->set_layout(function(Layout $layout) {
+ $layout->section(__('API Configuration', 'my-plugin'), function(Section $s) {
+ $s->field('api_key')
+ ->placeholder('Enter your API key')
+ ->help('Get your API key from the dashboard');
+ $s->field('api_endpoint')
+ ->placeholder('https://api.example.com/v1');
+ $s->field('api_timeout')
+ ->help('Request timeout in seconds');
+ });
+
+ $layout->section(__('Caching', 'my-plugin'), function(Section $s) {
+ $s->field('cache_enabled')
+ ->help('Enable response caching');
+ $s->field('cache_ttl')
+ ->help('Cache time-to-live in seconds');
+ });
+
+ $layout->section(__('Display', 'my-plugin'), function(Section $s) {
+ $s->field('items_per_page');
+ $s->field('date_format')
+ ->placeholder('Y-m-d H:i:s');
+ });
+
+ $layout->section(__('Development', 'my-plugin'), function(Section $s) {
+ $s->field('debug_mode')
+ ->help('Enable detailed logging');
+ $s->field('log_level')
+ ->help('Options: debug, info, warning, error');
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save']);
+ });
+ });
+
+ // Validation
+ $view->get_handler()
+ ->add_validator('api_key', Validators::required())
+ ->add_validator('api_timeout', Validators::min(1))
+ ->add_validator('api_timeout', Validators::max(60))
+ ->add_validator('cache_ttl', Validators::min(0))
+ ->add_validator('items_per_page', Validators::min(1))
+ ->add_validator('items_per_page', Validators::max(100))
+ ->add_validator('log_level', Validators::in(['debug', 'info', 'warning', 'error']));
+
+ // Clear cache when settings change
+ $view->get_handler()
+ ->before_update(function($current, $data) {
+ if (($current['cache_ttl'] ?? 0) !== ($data['cache_ttl'] ?? 0) ||
+ ($current['cache_enabled'] ?? false) !== ($data['cache_enabled'] ?? false)) {
+ wp_cache_flush();
+ }
+ return $data;
+ });
+
+ $view->register();
+});
+```
+
+## Accessing Settings Elsewhere
+
+Read settings values in your plugin:
+
+```php
+function my_plugin_get_setting($key, $default = null) {
+ $settings = get_option('my_plugin_settings', []);
+ return $settings[$key] ?? $default;
+}
+
+// Usage
+$api_key = my_plugin_get_setting('api_key');
+$debug = my_plugin_get_setting('debug_mode', false);
+```
+
+## Settings with Tabs
+
+For many settings, use tabs to organize:
+
+```php
+use Tangible\EditorLayout\Tabs;
+use Tangible\EditorLayout\Tab;
+
+$view->set_layout(function(Layout $layout) {
+ $layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('General', function(Tab $t) {
+ $t->field('site_title');
+ $t->field('tagline');
+ });
+
+ $tabs->tab('API', function(Tab $t) {
+ $t->field('api_key');
+ $t->field('api_endpoint');
+ });
+
+ $tabs->tab('Advanced', function(Tab $t) {
+ $t->field('debug_mode');
+ $t->field('log_level');
+ });
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save']);
+ });
+});
+```
+
+## Top-Level Menu
+
+For a top-level settings menu instead of under Settings:
+
+```php
+'ui' => [
+ 'menu_label' => 'My Plugin',
+ 'icon' => 'dashicons-admin-generic',
+ 'position' => 80,
+ // No 'parent' key = top-level menu
+]
+```
diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md
new file mode 100644
index 0000000..3c378ba
--- /dev/null
+++ b/website/docs/getting-started/installation.md
@@ -0,0 +1,55 @@
+---
+sidebar_position: 1
+title: Installation
+description: How to install Tangible Object in your WordPress project
+---
+
+# Installation
+
+## Via Composer
+
+The recommended way to install Tangible Object is via Composer:
+
+```bash
+composer require tangible/object
+```
+
+Then include the autoloader in your plugin or theme:
+
+```php
+require_once __DIR__ . '/vendor/autoload.php';
+```
+
+## Manual Installation
+
+1. Download the latest release from the [GitHub repository](https://github.com/tangibleinc/object)
+2. Extract to your plugin's directory
+3. Include the main file:
+
+```php
+require_once __DIR__ . '/path/to/object/plugin.php';
+```
+
+## Verifying Installation
+
+To verify the installation is working, you can create a simple DataView:
+
+```php
+use Tangible\DataView\DataView;
+
+add_action('admin_menu', function() {
+ $view = new DataView([
+ 'slug' => 'test_item',
+ 'label' => 'Test',
+ 'fields' => [
+ 'title' => 'string',
+ ],
+ 'storage' => 'option',
+ 'mode' => 'singular',
+ ]);
+
+ $view->register();
+});
+```
+
+After activating your plugin, you should see a "Test" menu item in the WordPress admin.
diff --git a/website/docs/getting-started/quick-start.md b/website/docs/getting-started/quick-start.md
new file mode 100644
index 0000000..689f5a5
--- /dev/null
+++ b/website/docs/getting-started/quick-start.md
@@ -0,0 +1,80 @@
+---
+sidebar_position: 2
+title: Quick Start
+description: Create your first admin interface in minutes
+---
+
+# Quick Start
+
+This guide will walk you through creating a complete admin interface for managing contact form entries.
+
+## Step 1: Define Your DataView
+
+Create a new PHP file in your plugin (e.g., `includes/contact-entries.php`):
+
+```php
+ 'contact_entry',
+ 'label' => 'Contact Entry',
+ 'fields' => [
+ 'name' => 'string',
+ 'email' => 'email',
+ 'message' => 'text',
+ 'subscribe' => 'boolean',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Contact Entries',
+ 'icon' => 'dashicons-email',
+ ],
+ ]);
+
+ // Add validation
+ $view->get_handler()
+ ->add_validator('name', Validators::required())
+ ->add_validator('email', Validators::required())
+ ->add_validator('email', Validators::email());
+
+ $view->register();
+});
+```
+
+## Step 2: Include in Your Plugin
+
+Add the file to your main plugin file:
+
+```php
+require_once __DIR__ . '/includes/contact-entries.php';
+```
+
+## Step 3: That's It!
+
+Visit your WordPress admin. You'll see a new "Contact Entries" menu item with:
+
+- A **list view** showing all entries
+- A **create form** for adding new entries
+- An **edit form** for updating entries
+- **Delete** functionality
+- **Validation** that enforces required fields and valid email format
+
+## What Just Happened?
+
+With about 20 lines of code, DataView:
+
+1. Registered a Custom Post Type for your data
+2. Set up automatic type coercion (strings, booleans, etc.)
+3. Generated a responsive admin menu page
+4. Built list, create, and edit views with proper WordPress styling
+5. Added form handling with sanitization and validation
+6. Implemented full CRUD operations
+
+## Next Steps
+
+- [Configure field types](/dataview/field-types) for more control
+- [Add custom layouts](/layouts/overview) with sections and tabs
+- [Set up lifecycle hooks](/dataview/lifecycle-hooks) for custom logic
+- [Use different storage backends](/dataview/storage) like CPT or options
diff --git a/website/docs/intro.md b/website/docs/intro.md
new file mode 100644
index 0000000..2fa9cdd
--- /dev/null
+++ b/website/docs/intro.md
@@ -0,0 +1,74 @@
+---
+sidebar_position: 1
+slug: /
+title: Introduction
+description: A WordPress tool suite for building data-driven admin interfaces
+---
+
+# Tangible Object
+
+A WordPress tool suite for building data-driven admin interfaces with a clean, layered architecture.
+
+## What is Tangible Object?
+
+Tangible Object provides a structured approach to building WordPress admin interfaces. Whether you need a simple settings page or a full CRUD interface for custom data, this framework handles the complexity while keeping your code clean and maintainable.
+
+## Two Ways to Build
+
+### The Easy Way: DataView
+
+For most use cases, **DataView** is all you need. It's a high-level API that lets you define your entire admin interface in a single configuration array:
+
+```php
+use Tangible\DataView\DataView;
+
+$view = new DataView([
+ 'slug' => 'contact_entry',
+ 'label' => 'Contact',
+ 'fields' => [
+ 'name' => 'string',
+ 'email' => 'email',
+ 'message' => 'text',
+ ],
+ 'ui' => [
+ 'menu_label' => 'Contact Entries',
+ ],
+]);
+
+$view->register();
+```
+
+This single declaration creates:
+- A Custom Post Type to store your data
+- An admin menu page
+- List, create, and edit views
+- Form handling with validation
+- Proper sanitization
+
+[Get started with DataView →](/getting-started/quick-start)
+
+### The Flexible Way: Four-Layer Architecture
+
+For advanced customization, you can work directly with the four underlying layers:
+
+1. **DataSet** - Define field types and coercion rules
+2. **EditorLayout** - Compose the editor structure (sections, tabs, fields)
+3. **Renderer** - Generate HTML output from the layout
+4. **RequestHandler** - Handle CRUD operations with validation
+
+[Learn about the architecture →](/advanced/architecture)
+
+## Key Features
+
+- **Declarative configuration** - Define your data structure once, get forms and validation automatically
+- **Multiple storage backends** - Custom Post Types, database tables, or WordPress options
+- **Flexible layouts** - Sections, tabs, sidebars, and nested structures
+- **Built-in validation** - Required fields, email, min/max, custom validators
+- **Lifecycle hooks** - React to create, update, and delete operations
+- **Repeater fields** - Manage collections of sub-items
+- **Multiple renderers** - Plain HTML or rich Tangible Fields components
+
+## Requirements
+
+- PHP 8.0+
+- WordPress 5.0+
diff --git a/website/docs/layouts/custom-layouts.md b/website/docs/layouts/custom-layouts.md
new file mode 100644
index 0000000..436bbfd
--- /dev/null
+++ b/website/docs/layouts/custom-layouts.md
@@ -0,0 +1,196 @@
+---
+sidebar_position: 5
+title: Custom Layouts
+description: Advanced layout customization techniques
+---
+
+# Custom Layouts
+
+This page covers advanced layout patterns and techniques.
+
+## Layout Structure
+
+When you call `set_layout()`, you're building a structure that the renderer will convert to HTML:
+
+```php
+$view->set_layout(function(Layout $layout) {
+ // Build your structure here
+});
+```
+
+The layout object tracks:
+- **Items** - Sections, tabs, and fields in the main area
+- **Sidebar** - Sidebar configuration (optional)
+- **Dataset** - Reference to the DataSet for field type info
+
+## Accessing the Structure
+
+You can inspect the layout structure:
+
+```php
+$structure = $layout->get_structure();
+
+// Returns:
+// [
+// 'items' => [...],
+// 'sidebar' => [...],
+// ]
+```
+
+This is useful when building custom renderers.
+
+## Conditional Sections
+
+Show sections based on field values:
+
+```php
+$layout->section('Basic', function(Section $s) {
+ $s->field('product_type'); // 'physical' or 'digital'
+});
+
+$layout->section('Shipping', function(Section $s) {
+ $s->condition('product_type', 'physical');
+
+ $s->field('weight');
+ $s->field('dimensions');
+});
+
+$layout->section('Download', function(Section $s) {
+ $s->condition('product_type', 'digital');
+
+ $s->field('download_url');
+ $s->field('download_limit');
+});
+```
+
+## Dynamic Layout Building
+
+Build layouts programmatically:
+
+```php
+$view->set_layout(function(Layout $layout) use ($custom_fields) {
+ // Standard fields
+ $layout->section('Basic', function(Section $s) {
+ $s->field('title');
+ $s->field('description');
+ });
+
+ // Dynamic fields from configuration
+ if (!empty($custom_fields)) {
+ $layout->section('Custom Fields', function(Section $s) use ($custom_fields) {
+ foreach ($custom_fields as $field) {
+ $s->field($field['name'])
+ ->help($field['description'] ?? '');
+ }
+ });
+ }
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save', 'delete']);
+ });
+});
+```
+
+## Reusable Layout Components
+
+Create reusable layout functions:
+
+```php
+function add_seo_section(Layout $layout) {
+ $layout->section('SEO', function(Section $s) {
+ $s->field('meta_title')
+ ->placeholder('Page title for search engines');
+ $s->field('meta_description')
+ ->help('Max 160 characters');
+ $s->field('meta_keywords');
+ });
+}
+
+function add_status_sidebar(Layout $layout) {
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->field('status');
+ $sb->field('publish_date');
+ $sb->actions(['save', 'delete']);
+ });
+}
+
+// Use in multiple DataViews
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Content', function(Section $s) {
+ $s->field('title');
+ $s->field('body');
+ });
+
+ add_seo_section($layout);
+ add_status_sidebar($layout);
+});
+```
+
+## Complex Nesting Example
+
+A deeply nested layout for complex data:
+
+```php
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Event', function(Section $s) {
+ $s->field('title');
+ $s->field('description');
+
+ // Location section
+ $s->section('Location', function(Section $loc) {
+ $loc->field('venue_name');
+
+ $loc->section('Address', function(Section $addr) {
+ $addr->columns(2);
+ $addr->field('street');
+ $addr->field('city');
+ $addr->field('state');
+ $addr->field('zip');
+ });
+
+ $loc->section('Coordinates', function(Section $coords) {
+ $coords->columns(2);
+ $coords->field('latitude');
+ $coords->field('longitude');
+ });
+ });
+
+ // Schedule tabs
+ $s->tabs(function(Tabs $tabs) {
+ $tabs->tab('Date & Time', function(Tab $t) {
+ $t->field('start_date');
+ $t->field('end_date');
+ $t->field('timezone');
+ });
+
+ $tabs->tab('Recurrence', function(Tab $t) {
+ $t->field('is_recurring');
+ $t->field('recurrence_pattern');
+ $t->field('recurrence_end');
+ });
+ });
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->field('status');
+ $sb->field('is_featured');
+ $sb->field('max_attendees');
+ $sb->actions(['save', 'delete']);
+ });
+});
+```
+
+## Layout Without Sidebar
+
+Not all layouts need a sidebar:
+
+```php
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Settings', function(Section $s) {
+ $s->field('option1');
+ $s->field('option2');
+ });
+
+ // The save button will appear at the bottom of the form
+});
+```
diff --git a/website/docs/layouts/overview.md b/website/docs/layouts/overview.md
new file mode 100644
index 0000000..90af114
--- /dev/null
+++ b/website/docs/layouts/overview.md
@@ -0,0 +1,133 @@
+---
+sidebar_position: 1
+title: Overview
+description: Introduction to EditorLayout for structuring forms
+---
+
+# Layouts Overview
+
+The EditorLayout system lets you structure your forms with sections, tabs, sidebars, and nested layouts.
+
+## Default Layout
+
+By default, DataView creates a simple layout with all fields in a single section:
+
+```php
+$view = new DataView([
+ 'slug' => 'product',
+ 'label' => 'Product',
+ 'fields' => [
+ 'title' => 'string',
+ 'description' => 'text',
+ 'price' => 'integer',
+ 'in_stock' => 'boolean',
+ ],
+]);
+```
+
+This renders all fields in order with auto-generated labels.
+
+## Custom Layouts
+
+Use `set_layout()` to define a custom structure:
+
+```php
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Section;
+use Tangible\EditorLayout\Sidebar;
+
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Product Details', function(Section $s) {
+ $s->field('title')
+ ->placeholder('Product name')
+ ->help('The display name for this product');
+ $s->field('description');
+ });
+
+ $layout->section('Inventory', function(Section $s) {
+ $s->field('price');
+ $s->field('in_stock');
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save', 'delete']);
+ });
+});
+```
+
+## Layout Components
+
+### Sections
+
+Group related fields together:
+
+```php
+$layout->section('Section Title', function(Section $s) {
+ $s->field('field_name');
+});
+```
+
+### Tabs
+
+Organize content into tabbed panels:
+
+```php
+$layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('Tab 1', function(Tab $t) {
+ $t->field('field1');
+ });
+ $tabs->tab('Tab 2', function(Tab $t) {
+ $t->field('field2');
+ });
+});
+```
+
+### Sidebar
+
+Add a sidebar for status fields and actions:
+
+```php
+$layout->sidebar(function(Sidebar $sb) {
+ $sb->field('status');
+ $sb->actions(['save', 'delete']);
+});
+```
+
+## Nesting
+
+Sections and tabs can be nested arbitrarily:
+
+```php
+$layout->section('Main', function(Section $s) {
+ $s->field('title');
+
+ // Nested section
+ $s->section('Advanced', function(Section $nested) {
+ $nested->field('slug');
+ });
+
+ // Tabs inside section
+ $s->tabs(function(Tabs $tabs) {
+ $tabs->tab('Content', function(Tab $t) {
+ $t->field('body');
+ });
+ $tabs->tab('SEO', function(Tab $t) {
+ $t->field('meta_description');
+ });
+ });
+});
+```
+
+## Field Configuration
+
+Within layouts, you can configure field presentation:
+
+```php
+$s->field('title')
+ ->placeholder('Enter title')
+ ->help('Help text shown below')
+ ->readonly()
+ ->width('50%');
+```
+
+See [Sections](/layouts/sections) for all field options.
diff --git a/website/docs/layouts/sections.md b/website/docs/layouts/sections.md
new file mode 100644
index 0000000..b018bc6
--- /dev/null
+++ b/website/docs/layouts/sections.md
@@ -0,0 +1,163 @@
+---
+sidebar_position: 2
+title: Sections
+description: Grouping fields into sections
+---
+
+# Sections
+
+Sections group related fields under a labeled heading.
+
+## Basic Section
+
+```php
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Section;
+
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Contact Information', function(Section $s) {
+ $s->field('name');
+ $s->field('email');
+ $s->field('phone');
+ });
+});
+```
+
+## Field Options
+
+Configure how fields are displayed:
+
+```php
+$s->field('title')
+ ->placeholder('Enter a title') // Placeholder text
+ ->help('Help text below field') // Help text
+ ->readonly() // Make read-only
+ ->width('50%'); // Set width
+```
+
+### placeholder
+
+Sets the input placeholder text:
+
+```php
+$s->field('email')->placeholder('you@example.com');
+```
+
+### help
+
+Displays help text below the field:
+
+```php
+$s->field('slug')->help('URL-friendly identifier, lowercase with hyphens');
+```
+
+### readonly
+
+Makes the field read-only (displayed but not editable):
+
+```php
+$s->field('created_at')->readonly();
+```
+
+### width
+
+Sets the field width (useful for inline fields):
+
+```php
+$s->field('first_name')->width('50%');
+$s->field('last_name')->width('50%');
+```
+
+## Section Options
+
+### columns
+
+Display fields in multiple columns:
+
+```php
+$layout->section('Address', function(Section $s) {
+ $s->columns(2);
+
+ $s->field('street');
+ $s->field('city');
+ $s->field('state');
+ $s->field('zip');
+});
+```
+
+### condition
+
+Show section conditionally based on another field's value:
+
+```php
+$layout->section('Shipping Address', function(Section $s) {
+ $s->condition('needs_shipping', true);
+
+ $s->field('shipping_street');
+ $s->field('shipping_city');
+});
+```
+
+## Nested Sections
+
+Sections can contain other sections:
+
+```php
+$layout->section('Product', function(Section $s) {
+ $s->field('title');
+ $s->field('description');
+
+ $s->section('Pricing', function(Section $nested) {
+ $nested->field('price');
+ $nested->field('sale_price');
+ });
+
+ $s->section('Inventory', function(Section $nested) {
+ $nested->field('stock_quantity');
+ $nested->field('allow_backorders');
+ });
+});
+```
+
+## Sections with Tabs
+
+Embed tabs within a section:
+
+```php
+$layout->section('Product Details', function(Section $s) {
+ $s->field('title');
+
+ $s->tabs(function(Tabs $tabs) {
+ $tabs->tab('Description', function(Tab $t) {
+ $t->field('short_description');
+ $t->field('full_description');
+ });
+
+ $tabs->tab('Specifications', function(Tab $t) {
+ $t->field('weight');
+ $t->field('dimensions');
+ });
+ });
+});
+```
+
+## Multiple Top-Level Sections
+
+```php
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Basic Info', function(Section $s) {
+ $s->field('title');
+ $s->field('slug');
+ });
+
+ $layout->section('Content', function(Section $s) {
+ $s->field('body');
+ $s->field('excerpt');
+ });
+
+ $layout->section('Settings', function(Section $s) {
+ $s->field('is_published');
+ $s->field('publish_date');
+ });
+});
+```
diff --git a/website/docs/layouts/sidebar.md b/website/docs/layouts/sidebar.md
new file mode 100644
index 0000000..8d28907
--- /dev/null
+++ b/website/docs/layouts/sidebar.md
@@ -0,0 +1,130 @@
+---
+sidebar_position: 4
+title: Sidebar
+description: Adding sidebars for actions and metadata
+---
+
+# Sidebar
+
+The sidebar provides a fixed panel for status information and action buttons.
+
+## Basic Sidebar
+
+```php
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Sidebar;
+
+$view->set_layout(function(Layout $layout) {
+ $layout->section('Content', function(Section $s) {
+ $s->field('title');
+ $s->field('body');
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save', 'delete']);
+ });
+});
+```
+
+## Sidebar Fields
+
+Add fields to the sidebar for quick access:
+
+```php
+$layout->sidebar(function(Sidebar $sb) {
+ $sb->field('status');
+ $sb->field('publish_date');
+ $sb->field('is_featured');
+
+ $sb->actions(['save', 'delete']);
+});
+```
+
+## Field Options
+
+Sidebar fields support the same options as section fields:
+
+```php
+$sb->field('created_at')->readonly();
+$sb->field('status')->help('Current publication status');
+```
+
+## Actions
+
+The `actions()` method defines which buttons appear:
+
+```php
+// Save and delete buttons
+$sb->actions(['save', 'delete']);
+
+// Save only (for settings pages)
+$sb->actions(['save']);
+```
+
+## Sidebar for Settings Pages
+
+For singular mode (settings), typically only a save button is needed:
+
+```php
+$view = new DataView([
+ 'slug' => 'settings',
+ 'mode' => 'singular',
+ 'storage' => 'option',
+ 'fields' => [...],
+]);
+
+$view->set_layout(function(Layout $layout) {
+ $layout->section('API', function(Section $s) {
+ $s->field('api_key');
+ $s->field('api_url');
+ });
+
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->actions(['save']);
+ });
+});
+```
+
+## Complete Example
+
+A product editor with status sidebar:
+
+```php
+$view->set_layout(function(Layout $layout) {
+ // Main content area
+ $layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('General', function(Tab $t) {
+ $t->field('title');
+ $t->field('description');
+ $t->field('price');
+ });
+
+ $tabs->tab('Inventory', function(Tab $t) {
+ $t->field('sku');
+ $t->field('stock_quantity');
+ $t->field('allow_backorders');
+ });
+ });
+
+ // Sidebar with status and actions
+ $layout->sidebar(function(Sidebar $sb) {
+ // Status fields
+ $sb->field('status')
+ ->help('Publication status');
+ $sb->field('is_featured')
+ ->help('Show on homepage');
+
+ // Read-only metadata
+ $sb->field('created_at')->readonly();
+ $sb->field('updated_at')->readonly();
+ $sb->field('view_count')->readonly();
+
+ // Action buttons
+ $sb->actions(['save', 'delete']);
+ });
+});
+```
+
+## Sidebar Position
+
+The sidebar is typically rendered on the right side of the form, following WordPress admin conventions. The exact styling depends on the renderer being used.
diff --git a/website/docs/layouts/tabs.md b/website/docs/layouts/tabs.md
new file mode 100644
index 0000000..0c4c854
--- /dev/null
+++ b/website/docs/layouts/tabs.md
@@ -0,0 +1,160 @@
+---
+sidebar_position: 3
+title: Tabs
+description: Organizing content with tabbed navigation
+---
+
+# Tabs
+
+Tabs organize content into separate panels, reducing visual clutter for complex forms.
+
+## Basic Tabs
+
+```php
+use Tangible\EditorLayout\Layout;
+use Tangible\EditorLayout\Tabs;
+use Tangible\EditorLayout\Tab;
+
+$view->set_layout(function(Layout $layout) {
+ $layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('General', function(Tab $t) {
+ $t->field('title');
+ $t->field('description');
+ });
+
+ $tabs->tab('Settings', function(Tab $t) {
+ $t->field('is_published');
+ $t->field('publish_date');
+ });
+ });
+});
+```
+
+## Tab Content
+
+Tabs can contain fields, sections, or nested tabs.
+
+### Fields in Tabs
+
+```php
+$tabs->tab('Content', function(Tab $t) {
+ $t->field('title')
+ ->placeholder('Enter title');
+ $t->field('body')
+ ->help('Main content area');
+});
+```
+
+### Sections in Tabs
+
+```php
+$tabs->tab('Details', function(Tab $t) {
+ $t->section('Basic', function(Section $s) {
+ $s->field('name');
+ $s->field('email');
+ });
+
+ $t->section('Address', function(Section $s) {
+ $s->field('street');
+ $s->field('city');
+ });
+});
+```
+
+### Nested Tabs
+
+```php
+$tabs->tab('Advanced', function(Tab $t) {
+ $t->tabs(function(Tabs $nested) {
+ $nested->tab('SEO', function(Tab $seo) {
+ $seo->field('meta_title');
+ $seo->field('meta_description');
+ });
+
+ $nested->tab('Social', function(Tab $social) {
+ $social->field('og_title');
+ $social->field('og_image');
+ });
+ });
+});
+```
+
+## Tabs Inside Sections
+
+Tabs can be placed inside sections for organized layouts:
+
+```php
+$layout->section('Product', function(Section $s) {
+ $s->field('title');
+ $s->field('price');
+
+ $s->tabs(function(Tabs $tabs) {
+ $tabs->tab('Description', function(Tab $t) {
+ $t->field('short_description');
+ $t->field('full_description');
+ });
+
+ $tabs->tab('Images', function(Tab $t) {
+ $t->field('main_image');
+ $t->field('gallery');
+ });
+
+ $tabs->tab('Inventory', function(Tab $t) {
+ $t->field('sku');
+ $t->field('stock_quantity');
+ });
+ });
+});
+```
+
+## Complete Example
+
+A blog post editor with multiple tab groups:
+
+```php
+$view->set_layout(function(Layout $layout) {
+ // Main content tabs
+ $layout->tabs(function(Tabs $tabs) {
+ $tabs->tab('Content', function(Tab $t) {
+ $t->field('title')
+ ->placeholder('Post title');
+ $t->field('body')
+ ->help('Main post content');
+ $t->field('excerpt')
+ ->help('Short summary for listings');
+ });
+
+ $tabs->tab('Media', function(Tab $t) {
+ $t->field('featured_image');
+ $t->field('gallery');
+ });
+
+ $tabs->tab('SEO', function(Tab $t) {
+ $t->section('Meta Tags', function(Section $s) {
+ $s->field('meta_title');
+ $s->field('meta_description');
+ });
+
+ $t->section('Social Sharing', function(Section $s) {
+ $s->field('og_title');
+ $s->field('og_description');
+ $s->field('og_image');
+ });
+ });
+
+ $tabs->tab('Advanced', function(Tab $t) {
+ $t->field('slug');
+ $t->field('custom_css');
+ $t->field('custom_js');
+ });
+ });
+
+ // Sidebar
+ $layout->sidebar(function(Sidebar $sb) {
+ $sb->field('status');
+ $sb->field('publish_date');
+ $sb->field('author');
+ $sb->actions(['save', 'delete']);
+ });
+});
+```
diff --git a/website/docs/renderers/custom-renderers.md b/website/docs/renderers/custom-renderers.md
new file mode 100644
index 0000000..06a0026
--- /dev/null
+++ b/website/docs/renderers/custom-renderers.md
@@ -0,0 +1,208 @@
+---
+sidebar_position: 4
+title: Custom Renderers
+description: Building your own renderer
+---
+
+# Custom Renderers
+
+You can create custom renderers to control exactly how forms are displayed.
+
+## Implementing the Interface
+
+Create a class that implements the `Renderer` interface:
+
+```php
+use Tangible\Renderer\Renderer;
+use Tangible\DataObject\DataSet;
+use Tangible\EditorLayout\Layout;
+
+class MyCustomRenderer implements Renderer {
+
+ public function render_editor(Layout $layout, array $data = []): string {
+ // Render the edit/create form
+ }
+
+ public function render_list(DataSet $dataset, array $entities): string {
+ // Render the list view
+ }
+}
+```
+
+## Using Your Renderer
+
+```php
+$view = new DataView([...]);
+$view->set_renderer(new MyCustomRenderer());
+$view->register();
+```
+
+## Accessing Layout Structure
+
+The layout provides its structure for rendering:
+
+```php
+public function render_editor(Layout $layout, array $data = []): string {
+ $structure = $layout->get_structure();
+ $dataset = $layout->get_dataset();
+
+ // Structure format:
+ // [
+ // 'items' => [
+ // ['type' => 'section', 'label' => 'General', 'fields' => [...]],
+ // ['type' => 'tabs', 'tabs' => [...]],
+ // ],
+ // 'sidebar' => [
+ // 'fields' => [...],
+ // 'actions' => ['save', 'delete'],
+ // ],
+ // ]
+
+ $html = '';
+
+ return $html;
+}
+```
+
+## Rendering Items
+
+Handle different item types:
+
+```php
+protected function render_item(array $item, array $data, DataSet $dataset): string {
+ switch ($item['type']) {
+ case 'section':
+ return $this->render_section($item, $data, $dataset);
+ case 'tabs':
+ return $this->render_tabs($item, $data, $dataset);
+ case 'field':
+ return $this->render_field($item, $data, $dataset);
+ default:
+ return '';
+ }
+}
+
+protected function render_section(array $section, array $data, DataSet $dataset): string {
+ $html = '';
+ $html .= '
' . esc_html($section['label']) . '
';
+
+ foreach ($section['items'] ?? [] as $item) {
+ $html .= $this->render_item($item, $data, $dataset);
+ }
+
+ foreach ($section['fields'] ?? [] as $field) {
+ $html .= $this->render_field($field, $data, $dataset);
+ }
+
+ $html .= '';
+ return $html;
+}
+```
+
+## Rendering Fields
+
+Get field info from the dataset:
+
+```php
+protected function render_field(array $field, array $data, DataSet $dataset): string {
+ $slug = $field['slug'];
+ $value = $data[$slug] ?? '';
+ $type = $dataset->get_type($slug);
+
+ $html = '';
+ return $html;
+}
+```
+
+## Complete Example
+
+A Bootstrap-styled renderer:
+
+```php
+class BootstrapRenderer implements Renderer {
+
+ public function render_editor(Layout $layout, array $data = []): string {
+ $structure = $layout->get_structure();
+ $dataset = $layout->get_dataset();
+
+ $html = '