diff --git a/.gitignore b/.gitignore index 22a8969..6aea623 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules /dist storybook-static -debug-storybook.log \ No newline at end of file +debug-storybook.log +.vscode \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 70e5a6a..09e382c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@dnd-kit/react": "^0.3.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@wordpress/api-fetch": "^7.43.0", "@wordpress/components": "^32.5.0", "@wordpress/dataviews": "^14.0.0", "@wordpress/hooks": "^4.43.0", @@ -8251,6 +8252,20 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/api-fetch": { + "version": "7.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.44.0.tgz", + "integrity": "sha512-KZP5Y0AzUVPRbwCsp2MUNEjIyYPJdaa7ojzYyc/IVlaAlbXVdd0Ofk8UDf4l8PjtXkyyPs9pX9sFy5iNcrF2cQ==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/i18n": "^6.17.0", + "@wordpress/url": "^4.44.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/babel-preset-default": { "version": "8.39.0", "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.39.0.tgz", @@ -8875,9 +8890,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.43.0.tgz", - "integrity": "sha512-BY7GPjEwhOlgkavVak40E3RtA8Z9ehydqTZckRoesMRjXYfxKSzr1C1FT4wAPS5uXM1pNlWivfofMaJjVNQu5w==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.44.0.tgz", + "integrity": "sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -8895,13 +8910,13 @@ } }, "node_modules/@wordpress/i18n": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.16.0.tgz", - "integrity": "sha512-D8yiDLzOrs9Aa4Cc1nm7m2OMilZeG9Qd7zHauMIDQujwHOe9xrOyH9ppDDko6AAWb+GeUYsf5zf2Efu5saLq0w==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.17.0.tgz", + "integrity": "sha512-v1SLBweg7CRzQ+5+WSC1U93i8h9d3AoB0YBvMsd6gWI5vO8Zh4YKlEMexvrHQC++WN83egwqux84fWEdeU0MUA==", "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.43.0", + "@wordpress/hooks": "^4.44.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "tannin": "^1.2.0" @@ -9468,6 +9483,19 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/url": { + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.44.0.tgz", + "integrity": "sha512-kWalXttgtRwFy4szBPX9dJcqHErRC0V9JuZ7uxdrxxdXl6WNv+lx8SYpLx12q3Zk6zNIw73M8E5wHON7eyXZZw==", + "license": "GPL-2.0-or-later", + "dependencies": { + "remove-accents": "^0.5.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/warning": { "version": "3.43.0", "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.43.0.tgz", diff --git a/package.json b/package.json index bde37bb..fdfd6cb 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@dnd-kit/react": "^0.3.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@wordpress/api-fetch": "^7.43.0", "@wordpress/components": "^32.5.0", "@wordpress/dataviews": "^14.0.0", "@wordpress/hooks": "^4.43.0", diff --git a/src/components/settings/Settings.stories.tsx b/src/components/settings/Settings.stories.tsx index 81006e5..05b66ba 100644 --- a/src/components/settings/Settings.stories.tsx +++ b/src/components/settings/Settings.stories.tsx @@ -3576,7 +3576,7 @@ const dokanSettingsSchema: SettingsElement[] = [ "dependencies": [], "validations": [], "variant": "notice", - "value": null, + "value": '', "notice_type": "warning", "notice_icon": "Info", "notice_title": "", @@ -3600,7 +3600,7 @@ const dokanSettingsSchema: SettingsElement[] = [ "dependencies": [], "validations": [], "variant": "notice", - "value": null, + "value": '', "notice_type": "warning", "notice_icon": "Info", "notice_title": "Size Guide Title", @@ -5121,7 +5121,6 @@ const dokanSettingsSchema: SettingsElement[] = [ "doc_link": "" } ], - "description": "Set up AI to elevate your platform with enhanced capabilities.", "dependency_key": "product_generation", "dependencies": [], "validations": [], diff --git a/src/components/settings/index.tsx b/src/components/settings/index.tsx index 179c7e2..fc1dc8a 100644 --- a/src/components/settings/index.tsx +++ b/src/components/settings/index.tsx @@ -27,6 +27,8 @@ export function Settings({ applyFilters, initialPage, onNavigate, + searchPlaceholder, + searchable = true, }: SettingsProps) { return ( ); @@ -56,9 +60,13 @@ export function Settings({ function SettingsInner({ title, className, + searchPlaceholder, + searchable, }: { title?: string; className?: string; + searchPlaceholder?: string; + searchable?: boolean; }) { const { loading, activeSubpage, isSidebarVisible } = useSettings(); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); @@ -78,7 +86,7 @@ function SettingsInner({ return (
)} - + )} @@ -184,7 +196,8 @@ function usePrevious(value: T): T | undefined { // Re-exports // ============================================ -export { useSettings } from './settings-context'; +export { SettingsProvider, useSettings } from './settings-context'; export type { ApplyFiltersFunction } from './settings-context'; +export { FieldRenderer } from './field-renderer'; export { formatSettingsData, extractValues } from './settings-formatter'; export type { SettingsElement, SettingsProps, FieldComponentProps, SaveButtonRenderProps } from './settings-types'; diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 38a69f0..1bd92ef 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -1,8 +1,9 @@ +import { useState } from 'react'; import type { SettingsElement as SettingsElementType } from './settings-types'; import { useSettings } from './settings-context'; import { FieldRenderer } from './field-renderer'; import { cn } from '@/lib/utils'; -import { FileText, Info } from "lucide-react"; +import { ChevronDown, FileText, Info } from "lucide-react"; import { ScrollArea, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui"; import { Button } from "@/components/ui/button"; import { RawHTML } from "@wordpress/element"; @@ -43,8 +44,12 @@ export function SettingsContent({ className }: { className?: string }) { }; // Determine whether to show a save area - // Hidden when the active page/subpage sets hide_save: true (e.g. License page) - const showSaveArea = Boolean(save) && !contentSource?.hide_save; + // Hidden when the active page/subpage — or the currently-active tab — sets + // hide_save: true. Tab-level hide_save lets a single tab under a shared + // scope (e.g. an action-only tab) opt out without affecting siblings. + const activeTabElement = tabs.find((t) => t.id === activeTab); + const showSaveArea = + Boolean(save) && !contentSource?.hide_save && !activeTabElement?.hide_save; if (!contentSource) { return ( @@ -209,6 +214,8 @@ function ContentBlock({ element }: { element: SettingsElementType }) { function SettingsSection({ section }: { section: SettingsElementType }) { const { shouldDisplay } = useSettings(); + const collapsible = section.collapsible === true; + const [open, setOpen] = useState(!section.collapsed); if (!shouldDisplay(section)) { return null; @@ -217,11 +224,44 @@ function SettingsSection({ section }: { section: SettingsElementType }) { const sectionLabel = section.label || section.title || ''; const hasHeading = Boolean(sectionLabel || section.description); const tooltip = section?.tooltip || ''; + const hasChildren = (section.children?.length ?? 0) > 0; + const contentId = `settings-section-content-${section.id}`; + const bodyVisible = !collapsible || open; + + const toggle = () => setOpen((o) => !o); + const onHeadingKeyDown = (e: React.KeyboardEvent) => { + if (!collapsible) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }; + + // Always a
so nested interactive children (doc links, tooltip triggers, + // field controls) keep their native behavior — a +

{tooltip}

@@ -252,25 +295,38 @@ function SettingsSection({ section }: { section: SettingsElementType }) {

)}
- {section.doc_link && ( - - - { section.doc_link_text ?? '' } - - )} -
+
+ {section.doc_link && ( + e.stopPropagation()} + className="text-muted-foreground flex gap-1 items-center text-sm hover:text-foreground transition-colors" + > + + { section.doc_link_text ?? '' } + + )} + {collapsible && ( + + )} +
+ )} -
- {section.children?.map((child) => ( - - ))} -
+ {hasChildren && bodyVisible && ( +
+ {section.children?.map((child) => ( + + ))} +
+ )} ); } diff --git a/src/components/settings/settings-sidebar.tsx b/src/components/settings/settings-sidebar.tsx index 4206ab7..c42b345 100644 --- a/src/components/settings/settings-sidebar.tsx +++ b/src/components/settings/settings-sidebar.tsx @@ -53,7 +53,15 @@ function collectSearchableText(element: SettingsElement): string { return texts.join(' ').toLowerCase(); } -export function SettingsSidebar({ className }: { className?: string }) { +export function SettingsSidebar({ + className, + searchPlaceholder, + searchable = true, +}: { + className?: string; + searchPlaceholder?: string; + searchable?: boolean; +}) { const { schema, activePage, @@ -152,23 +160,25 @@ export function SettingsSidebar({ className }: { className?: string }) { return (
{/* Deep-search input */} -
-
- - setSearch(e.target.value)} - className="h-8 pl-8" - aria-label="Search settings" - data-testid="settings-search" - /> + {searchable && ( +
+
+ + setSearch(e.target.value)} + className="h-8 pl-8" + placeholder={searchPlaceholder} + aria-label="Search settings" + data-testid="settings-search" + /> +
-
+ )} { const isPage = schema.some((p) => p.id === item.id); diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts index 831720d..07b67e8 100644 --- a/src/components/settings/settings-types.ts +++ b/src/components/settings/settings-types.ts @@ -38,7 +38,7 @@ export type SettingsElementOption = { export type SettingsElement = { id: string; type: 'page' | 'subpage' | 'tab' | 'section' | 'subsection' | 'field' | 'fieldgroup' | string; - is_danger: boolean; + is_danger?: boolean; variant?: string; icon?: string; /** Primary display text (preferred over `title`). */ @@ -54,7 +54,7 @@ export type SettingsElement = { // Field-specific value?: string | number | boolean | Array | Record; - default?: string | number | boolean | Array; + default?: string | number | boolean | Array | Record; options?: SettingsElementOption[]; readonly?: boolean; disabled?: boolean; @@ -108,6 +108,11 @@ export type SettingsElement = { field_group_id?: string; priority?: number; + /** Section-only: render the card with a collapse toggle in its header. */ + collapsible?: boolean; + /** Section-only: initial collapsed state when `collapsible` is true. */ + collapsed?: boolean; + // Validation error (runtime) validationError?: string; @@ -178,6 +183,10 @@ export interface SettingsProps { initialPage?: string; /** Called whenever the active page changes. Use to sync a URL query param. */ onNavigate?: (pageId: string) => void; + /** Placeholder text for the sidebar search input. */ + searchPlaceholder?: string; + /** Show the sidebar search input. Default: true. */ + searchable?: boolean; } export interface FieldComponentProps { diff --git a/src/components/top-bar.tsx b/src/components/top-bar.tsx index e00df70..9914610 100644 --- a/src/components/top-bar.tsx +++ b/src/components/top-bar.tsx @@ -51,12 +51,14 @@ function TopBar({className, logo, versions = [], rightSideComponents = <>}: T ); } - return <> - { Component } - { - versions.length - 1 !== index && - } - + return ( + + { Component } + { + versions.length - 1 !== index && + } + + ); }) }
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 764a37d..c2f0d6c 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -432,7 +432,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-group" data-sidebar="group" className={cn( - "p-2 relative flex w-full min-w-0 flex-col", + "p-2 px-4 relative flex w-full min-w-0 flex-col", className )} {...props} diff --git a/src/components/wordpress/AdminNotice.tsx b/src/components/wordpress/AdminNotice.tsx index bf00dad..7301f73 100644 --- a/src/components/wordpress/AdminNotice.tsx +++ b/src/components/wordpress/AdminNotice.tsx @@ -2,7 +2,7 @@ import { RawHTML, useState } from '@wordpress/element'; import { ShieldAlert, ChevronLeft, ChevronRight } from 'lucide-react'; import { useNotices } from '@/hooks/use-notices'; import { cn } from '@/lib/utils'; -import { Modal, ModalDescription, ModalFooter, ModalHeader, ModalTitle } from '../ui/modal'; +import { Modal, ModalDescription, ModalFooter } from '../ui/modal'; import { Button } from '../ui/button'; export interface NoticeAction { @@ -12,7 +12,7 @@ export interface NoticeAction { ajax_data?: { action: string; nonce: string; - [key: string]: any; + [key: string]: unknown; }; target?: '_self' | '_blank'; class?: string; @@ -33,7 +33,7 @@ export interface Notice { ajax_data?: { action: string; nonce: string; - [key: string]: any; + [key: string]: unknown; }; priority?: number; } @@ -211,8 +211,8 @@ const AdminNotice = ({ interval = 5000, notices: initialNotices = [], noticesUrl }; return ( -
-
+
+
+
{ value && (
+
{ value.length > 0 && (
{ value.map( ( url, i ) => ( diff --git a/src/components/wordpress/layout-menu.tsx b/src/components/wordpress/layout-menu.tsx index 8a45560..5223ead 100644 --- a/src/components/wordpress/layout-menu.tsx +++ b/src/components/wordpress/layout-menu.tsx @@ -150,7 +150,7 @@ export const LayoutMenuSearch = forwardRef<
diff --git a/src/components/wordpress/style.css b/src/components/wordpress/style.css index d228592..c937b81 100644 --- a/src/components/wordpress/style.css +++ b/src/components/wordpress/style.css @@ -85,6 +85,9 @@ border-bottom-left-radius: var(--dokan-dataviews-radius); border-bottom-right-radius: var(--dokan-dataviews-radius); } +.pui-root-dataviews .dataviews-pagination .components-select-control { + margin: 0 !important; +} .pui-root-dataviews .dataviews-wrapper .dataviews-view-table tr td:first-child, .pui-root-dataviews .dataviews-wrapper .dataviews-view-table tr th:first-child { padding-left: 20px !important; diff --git a/src/hooks/use-notices.ts b/src/hooks/use-notices.ts index 1a038f5..90895e5 100644 --- a/src/hooks/use-notices.ts +++ b/src/hooks/use-notices.ts @@ -1,5 +1,6 @@ import { Notice, NoticeAction } from '@/components/wordpress/AdminNotice'; -import { useEffect, useState } from 'react'; +import apiFetch from '@wordpress/api-fetch'; +import { useCallback, useEffect, useState } from 'react'; interface NoticesOptions { interval?: number; @@ -27,8 +28,9 @@ export const useNotices = ({ [key: number]: boolean; }>({}); - const fetchNotices = async () => { + const fetchNotices = useCallback(async () => { setIsLoading(true); + setError(null); try { const url = new URL(noticesUrl as string, window.location.href); @@ -39,13 +41,10 @@ export const useNotices = ({ }); } - const res = await fetch(url.toString()); + const data = await apiFetch({ + url: url.toString() + }); - if (!res.ok) { - throw new Error(`Failed to fetch notices: ${res.status}`); - } - - const data: Notice[] | { success: boolean; data: Notice[] } = await res.json(); const fetched = Array.isArray(data) ? data : data.data; setNotices(fetched); } catch (err) { @@ -53,7 +52,7 @@ export const useNotices = ({ } finally { setIsLoading(false); } - }; + }, [noticesUrl, noticesUrlArgs]); useEffect(() => { if (!noticesUrl) { @@ -61,7 +60,7 @@ export const useNotices = ({ } fetchNotices(); - }, [noticesUrl, noticesUrlArgs]); + }, [noticesUrl, noticesUrlArgs, fetchNotices]); useEffect(() => { if (!isAutoSliding || notices.length <= 1) { @@ -122,16 +121,13 @@ export const useNotices = ({ body.append(key, String(value)); }); - const response = await fetch(actionUrl, { + await apiFetch({ + url: actionUrl, method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body + body, + parse: false }); - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - removeNoticeAt(noticeIndex); } else { removeNoticeAt(noticeIndex); diff --git a/src/index.ts b/src/index.ts index 4306d4b..3c9e06d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,6 +148,8 @@ export { ModalFooter, ModalHeader, ModalOverlay, ModalTitle, // Notice Notice, NoticeAction, NoticeTitle, + // AdminNotice (WP-style admin notice card) + AdminNotice, // Popover Popover, PopoverContent, @@ -354,13 +356,16 @@ export { License, type LicenseProps, type LicenseLabels, type LicenseStatus, typ // Settings (schema-driven settings page) export { Settings, + SettingsProvider, useSettings, + FieldRenderer, formatSettingsData, extractValues, type ApplyFiltersFunction, type SettingsProps, type SettingsElement, type FieldComponentProps, + type SaveButtonRenderProps, } from './components/settings'; export { SettingsSkeleton } from './components/settings/settings-skeleton'; diff --git a/src/styles.css b/src/styles.css index 71ff9c1..b6b57b9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -252,6 +252,11 @@ background-color: transparent; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } } /* ============================================ diff --git a/webpack.config.js b/webpack.config.js index ae69f63..2c7371f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,6 +21,7 @@ module.exports = { 'react-dom': 'react-dom', 'react/jsx-runtime': 'react/jsx-runtime', '@wordpress/element': '@wordpress/element', + '@wordpress/api-fetch': '@wordpress/api-fetch', '@wordpress/components': '@wordpress/components', '@wordpress/block-editor': '@wordpress/block-editor', '@wordpress/date': '@wordpress/date',