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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# @wedevs/plugin-ui

Scoped, themeable React component library for WordPress plugins. Built on ShadCN patterns, Tailwind CSS v4, and Base-UI primitives.

## Architecture

```
src/
├── components/
│ ├── ui/ # Core ShadCN-style components (150+ exports)
│ ├── settings/ # Schema-driven settings system
│ └── wordpress/ # WordPress integration (Layout, DataViews)
├── providers/ # ThemeProvider (CSS variable injection)
├── themes/ # Built-in theme presets
├── hooks/ # useMobile, useWindowDimensions
└── lib/ # Utilities (cn, renderIcon, WpMedia, wordpress-date)
```
Comment on lines +7 to +17
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language tag to the architecture code fence.

This block is missing a fence language, which will keep tripping markdownlint (MD040). text would be the right fit here.

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 7-7: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` around lines 7 - 17, The Markdown code fence containing the
architecture tree lacks a language tag (triggers markdownlint MD040); update the
opening triple-backticks to include a language (use ```text) so the block
becomes fenced with ```text and resolves the lint error while preserving the
existing content.


## Import Patterns

```tsx
// Main entry (includes styles)
import { Settings, Button, ThemeProvider } from '@wedevs/plugin-ui';

// Sub-path exports
import { Settings } from '@wedevs/plugin-ui/settings';
import { Button, Input } from '@wedevs/plugin-ui/components/ui';
import { ThemeProvider } from '@wedevs/plugin-ui/providers';
import { defaultTheme, createTheme } from '@wedevs/plugin-ui/themes';
import { cn } from '@wedevs/plugin-ui/utils';

// Styles (import in your entry point)
import '@wedevs/plugin-ui/styles.css';
```

## CSS Setup (Tailwind v4)

```css
@import "tailwindcss";
@import "@wedevs/plugin-ui/styles.css" layer(plugin-ui);
```

## Settings System

Schema-driven settings page with hierarchical navigation, dependency evaluation, validation, and WordPress hook extensibility.

### Element Hierarchy

`page` → `subpage` → `tab` → `section` → `subsection` → `field` / `fieldgroup`

### Basic Usage

```tsx
import { Settings } from '@wedevs/plugin-ui';

<Settings
schema={settingsSchema} // SettingsElement[] (flat or hierarchical)
values={values} // Record<string, any> keyed by dependency_key
onChange={(scopeId, key, value) => {
setValues(prev => ({ ...prev, [key]: value }));
}}
onSave={async (scopeId, treeValues, flatValues) => {
// treeValues: nested object built from dot-separated keys
// e.g. { dokan: { general: { store_name: "..." } } }
// flatValues: original flat dot-keyed values
// e.g. { "dokan.general.store_name": "..." }
await api.post(`/settings/${scopeId}`, treeValues);
}}
renderSaveButton={({ dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>Save</Button>
)}
hookPrefix="my_plugin" // WordPress filter hook prefix
applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility
/>
```

### Key Concepts

- **`dependency_key`**: Unique key on each field element, used as the key in `values` and `flatValues`
- **Dependencies**: Elements can conditionally show/hide based on other field values via `dependencies` array
- **Validation**: Per-field `validations` array with rules and error messages
- **Dirty tracking**: Per-scope (subpage/page) dirty state; resets only on successful save
- **Error handling**: If `onSave` throws `{ errors: { fieldKey: "message" } }`, errors display on the relevant fields
- **Extensibility**: `applyFilters` enables WordPress hooks like `{hookPrefix}_settings_{variant}_field`

### Settings Hooks

```tsx
import { useSettings } from '@wedevs/plugin-ui';

const {
values, // All current field values
activePage, // Current page element
activeSubpage, // Current subpage element (if any)
activeTab, // Current tab element (if any)
isPageDirty, // (pageId) => boolean
getPageValues, // (pageId) => Record<string, any>
errors, // Record<string, string> validation errors
} = useSettings();
```

## Theme System

```tsx
import { ThemeProvider, createTheme } from '@wedevs/plugin-ui';

// Use a built-in preset
<ThemeProvider theme={defaultTheme}>
<App />
</ThemeProvider>

// Create a custom theme
const myTheme = createTheme({
primary: '220 90% 56%', // HSL values (without hsl() wrapper)
background: '0 0% 100%',
foreground: '0 0% 3.9%',
// ... see ThemeTokens type for all available tokens
});
```

Built-in presets: `defaultTheme`, `slateTheme`, `amberMinimalTheme`, `t3ChatTheme`, `midnightBloomTheme`, `bubblegumTheme`, `cyberpunkTheme`, `twitterTheme` (each with a dark variant).

## UI Components

### Form
`Input`, `Textarea`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `DatePicker`, `DateRangePicker`, `Calendar`, `CurrencyInput`, `InputOTP`, `RichTextEditor`, `FileUpload`

Card variants: `CheckboxCard`, `RadioCard`, `SwitchCard`
Labeled variants: `LabeledCheckbox`, `LabeledRadio`, `LabeledSwitch`

### Layout
`Card`, `Tabs`, `Separator`, `ScrollArea`, `Layout` (WordPress sidebar+content), `Sidebar` (shadcn primitives), `Field` (form control wrapper with label, description, error)

### Data Display
`Badge`, `Avatar`, `AvatarGroup`, `Progress`, `CircularProgress`, `Skeleton`, `Spinner`, `Thumbnail`, `DataViews` (WordPress DataViews wrapper)

### Overlay
`Modal`, `Sheet`, `Popover`, `Tooltip`, `DropdownMenu`, `AlertDialog`, `Toaster` (sonner toast)

### Feedback
`Alert`, `Notice`, `toast()` (from sonner)

### WordPress-Specific
`Layout` (sidebar layout with WordPress hook integration), `DataViews` (wraps `@wordpress/dataviews` with filter hooks), `LayoutMenu` (navigation menu)

## WordPress Integration

### Layout Component

```tsx
import { Layout, LayoutHeader, LayoutBody, LayoutSidebar, LayoutMain } from '@wedevs/plugin-ui';

<Layout namespace="my-plugin">
<LayoutHeader>Header</LayoutHeader>
<LayoutBody>
<LayoutSidebar menuItems={menuItems} />
<LayoutMain>Content</LayoutMain>
</LayoutBody>
</Layout>
```

### DataViews Component

```tsx
import { DataViews } from '@wedevs/plugin-ui';

<DataViews
namespace="my-plugin"
data={items}
fields={fields}
actions={actions}
paginationInfo={paginationInfo}
/>
```

Supports WordPress filter hooks: `{snakeNamespace}_dataviews_{elementName}`

## Conventions

- **Composition pattern**: All components use compound component pattern (e.g., `Card` + `CardHeader` + `CardContent`)
- **`cn()` utility**: Use for merging Tailwind classes — combines `clsx` + `tailwind-merge`
- **`Field` wrapper**: Use to wrap form controls with consistent label, description, and error display
- **No WordPress dependency in UI components**: Only `Layout`, `DataViews`, and `Settings` (via `applyFilters`) touch WordPress APIs
- **Externals**: React, ReactDOM, and WordPress packages (`@wordpress/components`, `@wordpress/dataviews`, `@wordpress/hooks`, `@wordpress/i18n`, `@wordpress/date`) are externalized — consumers must provide them

## Before Committing & Pushing

GitHub CI runs these checks on PRs to `main`. Run them locally before pushing to avoid failures:

```bash
npm run lint # ESLint (src/**/*.{ts,tsx})
npm run typecheck # tsc --noEmit
```

Both must pass. The CI pipeline (`.github/workflows/ci.yml`) runs these on `ubuntu-latest` with Node 24.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Capitalize GitHub consistently.

Use the official casing here to avoid a docs/style nit slipping into a reference section.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~195-~195: The official name of this software platform is spelled with a capital “H”.
Context: ...t ``` Both must pass. The CI pipeline (.github/workflows/ci.yml) runs these on `ubunt...

(GITHUB)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLAUDE.md` at line 195, The documentation text uses an incorrect casing for
"GitHub" — update the string in CLAUDE.md (the sentence referencing the CI
pipeline `.github/workflows/ci.yml`) to use the official casing "GitHub" so the
reference reads "The CI pipeline (`.github/workflows/ci.yml`) runs these on
`ubuntu-latest` with Node 24." ensuring consistent capitalization throughout the
file.


## Documentation

- `src/components/settings/Settings.mdx` — Full settings API reference
- `DEVELOPER_GUIDE.md` — WordPress integration guide
- `src/DeveloperGuide.mdx` — Storybook developer guide
4 changes: 2 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,11 +1128,11 @@ function SettingsPage() {
onChange={(scopeId, key, value) => {
setValues(prev => ({ ...prev, [key]: value }));
}}
onSave={async (scopeId, pageValues) => {
onSave={async (scopeId, treeValues) => {
await apiFetch({
path: `/my-plugin/v1/settings/${scopeId}`,
method: 'POST',
data: pageValues,
data: treeValues,
});
}}
renderSaveButton={({ dirty, onSave }) => (
Expand Down
4 changes: 2 additions & 2 deletions src/DeveloperGuide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -912,8 +912,8 @@ function SettingsPage() {
hookPrefix="my_plugin"
applyFilters={applyFilters}
onChange={(scopeId, key, value) => setValues(prev => ({ ...prev, [key]: value }))}
onSave={async (scopeId, pageValues) => {
await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', data: pageValues });
onSave={async (scopeId, treeValues) => {
await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', data: treeValues });
}}
renderSaveButton={({ dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>{__('Save Changes', 'my-plugin')}</Button>
Expand Down
26 changes: 16 additions & 10 deletions src/components/settings/Settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,10 @@ function MySettingsPage() {
onChange={(scopeId, key, value) => {
setValues((prev) => ({ ...prev, [key]: value }));
}}
onSave={(scopeId, pageValues) => {
// POST pageValues to your REST API
console.log("Saving", scopeId, pageValues);
onSave={(scopeId, treeValues, flatValues) => {
// treeValues: nested object from dot-separated keys
// flatValues: original flat dot-keyed values
console.log("Saving", scopeId, treeValues, flatValues);
}}
renderSaveButton={({ dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>
Expand Down Expand Up @@ -528,7 +529,7 @@ Fired whenever a field value changes.
/>
```

### `onSave(scopeId, values)`
### `onSave(scopeId, treeValues, flatValues)`

Fired when the save button is clicked. Only receives values **scoped to the active page/subpage**.

Expand All @@ -547,19 +548,24 @@ Fired when the save button is clicked. Only receives values **scoped to the acti
<td>Active subpage ID, or page ID if no subpage</td>
</tr>
<tr>
<td><code>values</code></td>
<td><code>treeValues</code></td>
<td><code>{'Record<string, any>'}</code></td>
<td>Nested object built from dot-separated keys (e.g. <code>{'{"dokan":{"general":{"store_name":"..."}}}'}</code>)</td>
</tr>
<tr>
<td><code>flatValues</code></td>
<td><code>{'Record<string, any>'}</code></td>
<td>Key-value pairs for fields in this scope only</td>
<td>Original flat dot-keyed values (e.g. <code>{'{"dokan.general.store_name":"..."}'}</code>)</td>
</tr>
</tbody>
</table>

```tsx
<Settings
onSave={async (scopeId, pageValues) => {
onSave={async (scopeId, treeValues, flatValues) => {
await fetch(`/wp-json/my-plugin/v1/settings/${scopeId}`, {
method: "POST",
body: JSON.stringify(pageValues),
body: JSON.stringify(treeValues),
});
}}
/>
Expand Down Expand Up @@ -616,7 +622,7 @@ import { Settings, Button } from "@wedevs/plugin-ui";
<tr>
<td><code>onSave</code></td>
<td><code>{'() => void'}</code></td>
<td>Call this to trigger <code>onSave(scopeId, scopeValues)</code></td>
<td>Call this to trigger save — internally gathers scope values and calls the consumer's <code>onSave(scopeId, treeValues, flatValues)</code></td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -810,7 +816,7 @@ const initialValues = extractValues(schema);
</tr>
<tr>
<td><code>onSave</code></td>
<td><code>{'(scopeId, values) => void'}</code></td>
<td><code>{'(scopeId, treeValues, flatValues) => void'}</code></td>
<td>—</td>
<td>Called on save; enables save button area</td>
</tr>
Expand Down
8 changes: 4 additions & 4 deletions src/components/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function SettingsContent({ className }: { className?: string }) {
setActiveTab,
isPageDirty,
getPageValues,
onSave,
save,
renderSaveButton,
} = useSettings();

Expand All @@ -34,13 +34,13 @@ export function SettingsContent({ className }: { className?: string }) {
const dirty = isPageDirty(scopeId);

const handleSave = () => {
if (!onSave) return;
if (!save) return;
const scopeValues = getPageValues(scopeId);
onSave(scopeId, scopeValues);
save(scopeId, scopeValues);
};

// Determine whether to show a save area
const showSaveArea = Boolean(onSave);
const showSaveArea = Boolean(save);

if (!contentSource) {
return (
Expand Down
37 changes: 30 additions & 7 deletions src/components/settings/settings-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ export interface SettingsContextValue {
isPageDirty: (pageId: string) => boolean;
/** Get only the values that belong to a specific page */
getPageValues: (pageId: string) => Record<string, any>;
/** Consumer-provided save handler (exposed so SettingsContent can call it) */
onSave?: (pageId: string, values: Record<string, any>) => void | Promise<void>;
/** Trigger a save for the given scope. Builds treeValues from flat pageValues, then calls the consumer's onSave(scopeId, treeValues, flatValues). */
save?: (scopeId: string, pageValues: Record<string, any>) => void | Promise<void>;
/** Consumer-provided render function for the save button */
renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode;
}
Expand All @@ -88,7 +88,7 @@ export interface SettingsProviderProps {
schema: SettingsElement[];
values?: Record<string, any>;
onChange?: (scopeId: string, key: string, value: any) => void;
onSave?: (scopeId: string, values: Record<string, any>) => void | Promise<void>;
onSave?: (scopeId: string, treeValues: Record<string, any>, flatValues: Record<string, any>) => void | Promise<void>;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, read the settings-context.tsx file to see the context around line 91
head -100 src/components/settings/settings-context.tsx | tail -20

Repository: getdokan/plugin-ui

Length of output: 895


🏁 Script executed:

# Get more context - read the full file or at least the type/interface definition
wc -l src/components/settings/settings-context.tsx

Repository: getdokan/plugin-ui

Length of output: 110


🏁 Script executed:

# Search for onSave usages in the codebase
rg -n 'onSave' --type=ts --type=tsx -B 2 -A 2 | head -100

Repository: getdokan/plugin-ui

Length of output: 89


🏁 Script executed:

# Search for onSave usages without type filters
rg -n 'onSave' -B 2 -A 2

Repository: getdokan/plugin-ui

Length of output: 15495


🏁 Script executed:

# Also check how onSave is called/invoked in the file
rg -n 'onSave\s*\(' -B 1 -A 1

Repository: getdokan/plugin-ui

Length of output: 1625


🏁 Script executed:

# Verify the exact line in Settings.stories.tsx to understand the context better
sed -n '1080,1095p' src/components/settings/Settings.stories.tsx

Repository: getdokan/plugin-ui

Length of output: 911


🏁 Script executed:

# Check if there are any other invocations of onSave callback we might have missed
rg -n 'onSave\s*\(\s*[^)]*\s*\)' -B 1 -A 1

Repository: getdokan/plugin-ui

Length of output: 1625


Breaking change: onSave signature updated — some call sites still need updating.

The new signature (scopeId, treeValues, flatValues) is a breaking change. Several usages have not been updated:

  • src/components/settings/Settings.stories.tsx line 1084 still uses 2 parameters
  • src/DeveloperGuide.mdx and DEVELOPER_GUIDE.md examples still use 2 parameters

Update these call sites to accept the new third parameter flatValues or ignore it with a rest parameter if unused.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/settings/settings-context.tsx` at line 91, The onSave callback
signature in settings-context.tsx changed to onSave?: (scopeId: string,
treeValues: Record<string, any>, flatValues: Record<string, any>) => void |
Promise<void>, so update all call sites that still use two parameters (e.g., the
Settings.stories usage and the Developer Guide examples) to accept the third
flatValues argument or explicitly ignore it (for example change (scopeId,
treeValues) => ... to (scopeId, treeValues, flatValues) => ... or (scopeId,
treeValues, ..._rest) => ...), ensuring any handlers that call or implement
onSave (named onSave) match the new three-parameter signature.

renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode;
loading?: boolean;
hookPrefix?: string;
Expand Down Expand Up @@ -261,10 +261,33 @@ export function SettingsProvider({
const handleOnSave = useCallback(
async (pageId: string, pageValues: Record<string, any>) => {
if (!onSave) return;
await Promise.resolve(onSave(pageId, pageValues));
resetPageDirty(pageId);
// Build nested tree from dot-separated keys
const treeValues: Record<string, any> = {};
for (const [dotKey, val] of Object.entries(pageValues)) {
const parts = dotKey.split('.');
let cursor: Record<string, any> = treeValues;
for (let i = 0; i < parts.length - 1; i++) {
if (!(parts[i] in cursor) || typeof cursor[parts[i]] !== 'object') {
cursor[parts[i]] = {};
}
cursor = cursor[parts[i]];
}
cursor[parts[parts.length - 1]] = val;
}
try {
await Promise.resolve(onSave(pageId, treeValues, pageValues));
resetPageDirty(pageId);
} catch (error: any) {
console.error('[Settings] onSave error caught:', error);
// If the error contains field-level errors (e.g. from a 400 response),
// merge them into the errors state so they display on the relevant fields.
// Error keys should match field dependency_key values.
if (error && typeof error === 'object' && error.errors && typeof error.errors === 'object') {
setErrors((prev) => ({ ...prev, ...error.errors }));
}
}
},
[onSave, resetPageDirty]
[onSave, resetPageDirty, setErrors]
);

// Update a field value
Expand Down Expand Up @@ -479,7 +502,7 @@ export function SettingsProvider({
isSidebarVisible,
isPageDirty,
getPageValues,
onSave: handleOnSave,
save: handleOnSave,
renderSaveButton,
}),
[
Expand Down
Loading
Loading