diff --git a/packages/js/services/wp/use-display-feedback.ts b/packages/js/services/wp/use-display-feedback.ts new file mode 100644 index 00000000..950cf21a --- /dev/null +++ b/packages/js/services/wp/use-display-feedback.ts @@ -0,0 +1,62 @@ +import { useGlobalNotices } from '@wpsocio/ui/wp/global-notices'; +import { FORM_ERROR } from '@wpsocio/utilities/constants.js'; +import { getErrorStrings } from '@wpsocio/utilities/misc.js'; +import type { AnyObject } from '@wpsocio/utilities/types.js'; +import { useCallback, useMemo } from 'react'; + +interface DisplayFeedback { + clearNotices: VoidFunction; + displayValidationErrors: (errors: AnyObject, error?: string) => void; + displaySubmitErrors: (errors: AnyObject, submitError?: string) => void; +} + +type DF = DisplayFeedback; + +export const useDisplayFeedback = (): DF => { + const { createErrorNotice, removeAllNotices } = useGlobalNotices(); + + const displayErrors = useCallback( + (errors: AnyObject) => { + const errorStrings = getErrorStrings(errors); + for (const error of errorStrings) { + createErrorNotice(error); + } + }, + [createErrorNotice], + ); + + const clearNotices = useCallback(() => { + removeAllNotices('snackbar'); + }, [removeAllNotices]); + + const displaySubmitErrors = useCallback( + ({ [FORM_ERROR]: formError, ...errors }, submitError) => { + // biome-ignore lint/suspicious/noConsoleLog: + console.log({ errors, submitError, formError }); + clearNotices(); + + if (submitError || formError) { + createErrorNotice(submitError || formError); + } + displayErrors(errors); + }, + [displayErrors, createErrorNotice, clearNotices], + ); + + const displayValidationErrors = useCallback( + (errors) => { + clearNotices(); + displayErrors(errors); + }, + [displayErrors, clearNotices], + ); + + return useMemo( + () => ({ + clearNotices, + displaySubmitErrors, + displayValidationErrors, + }), + [clearNotices, displaySubmitErrors, displayValidationErrors], + ); +}; diff --git a/packages/js/shared-ui/components/wp/admin-container/index.tsx b/packages/js/shared-ui/components/wp/admin-container/index.tsx new file mode 100644 index 00000000..79118277 --- /dev/null +++ b/packages/js/shared-ui/components/wp/admin-container/index.tsx @@ -0,0 +1,17 @@ +import { Flex, __experimentalView as View } from '@wordpress/components'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; + +export type WpAdminContainerProps = React.ComponentProps; + +export function WpAdminContainer({ + children, + className, + ...props +}: WpAdminContainerProps) { + return ( + + {children} + + ); +} diff --git a/packages/js/shared-ui/components/wp/admin-container/styles.module.scss b/packages/js/shared-ui/components/wp/admin-container/styles.module.scss new file mode 100644 index 00000000..70db5b86 --- /dev/null +++ b/packages/js/shared-ui/components/wp/admin-container/styles.module.scss @@ -0,0 +1,25 @@ +@use '@wpsocio/ui/wp-base-styles.scss' as wp; + +:global(#wpcontent) { + padding-inline: 0 !important; +} +:global(#wpwrap) { + background: #fff; +} + +.container { + max-width: 64rem; + padding: 0.75rem; + + &:global(.components-flex) { + width: auto; + } + + @include wp.break-small { + padding: 1.5rem; + } + + .view { + width: 100%; + } +} \ No newline at end of file diff --git a/packages/js/shared-ui/components/wp/main-menu/index.tsx b/packages/js/shared-ui/components/wp/main-menu/index.tsx new file mode 100644 index 00000000..76221c14 --- /dev/null +++ b/packages/js/shared-ui/components/wp/main-menu/index.tsx @@ -0,0 +1,28 @@ +import { MenuGroup, MenuItem } from '@wordpress/components'; +import { __ } from '@wpsocio/i18n'; +import { chevronRight } from '@wpsocio/ui/icons/wp'; +import styles from './styles.module.scss'; + +export type MenuItemProps = React.ComponentProps; + +export type MainMenuProps = { + items: MenuItemProps[]; +}; + +export function MainMenu({ items }: MainMenuProps) { + return ( + + {items.map((item, index) => ( + + {item.children || item.label || item.title} + + ))} + + ); +} diff --git a/packages/js/shared-ui/components/wp/main-menu/styles.module.scss b/packages/js/shared-ui/components/wp/main-menu/styles.module.scss new file mode 100644 index 00000000..56627e32 --- /dev/null +++ b/packages/js/shared-ui/components/wp/main-menu/styles.module.scss @@ -0,0 +1,26 @@ +@use '@wpsocio/ui/wp-base-styles.scss' as wp; + +.container { + div[role="group"] { + display: flex; + flex-direction: column; + gap: 1rem + } + + :global(.components-menu-item__button) { + height: auto; + padding: 0; + } + + :global(.components-menu-item__item) { + @include wp.body-x-large(); + } + + :global(.components-menu-item__info) { + @include wp.body-large(); + } + + :global(.components-menu-item__info) { + text-align: start; + } +} \ No newline at end of file diff --git a/packages/js/shared-ui/components/wp/plugin-header/index.tsx b/packages/js/shared-ui/components/wp/plugin-header/index.tsx new file mode 100644 index 00000000..7bd16483 --- /dev/null +++ b/packages/js/shared-ui/components/wp/plugin-header/index.tsx @@ -0,0 +1,50 @@ +import { + __experimentalDivider as Divider, + Flex, + FlexBlock, + __experimentalView as View, +} from '@wordpress/components'; +import { __experimentalHeading as Heading } from '@wordpress/components'; +import styles from './styles.module.scss'; + +export interface PluginHeaderProps { + title: string; + description?: string; + version?: string; + logoUrl?: string; +} + +export const PluginHeader: React.FC = ({ + logoUrl, + title, + version, + description, +}) => { + return ( + + + + {logoUrl && ( +
+ {title} +
+ )} + + {title} + {version ? ( + +  v{version} + + ) : null} + +
+
+ {description ? ( + + {description} + + ) : null} + +
+ ); +}; diff --git a/packages/js/shared-ui/components/wp/plugin-header/styles.module.scss b/packages/js/shared-ui/components/wp/plugin-header/styles.module.scss new file mode 100644 index 00000000..603802a9 --- /dev/null +++ b/packages/js/shared-ui/components/wp/plugin-header/styles.module.scss @@ -0,0 +1,34 @@ +@use '@wpsocio/ui/wp-base-styles.scss' as wp; + +.container { + .logo { + height: 2rem; + width: 2rem; + + img { + height: 100%; + width: 100%; + object-fit: contain; + } + } + + .title { + @include wp.heading-x-large(); + font-size: 1.5rem; + } + + .version { + @include wp.body-medium(); + color: wp.$gray-700; + font-style: italic; + } + + .description { + @include wp.body-large(); + color: wp.$gray-700; + } + + hr { + color: wp.$gray-200; + } +} \ No newline at end of file diff --git a/packages/js/shared-ui/components/wp/plugin-sub-page/index.tsx b/packages/js/shared-ui/components/wp/plugin-sub-page/index.tsx new file mode 100644 index 00000000..2f804cce --- /dev/null +++ b/packages/js/shared-ui/components/wp/plugin-sub-page/index.tsx @@ -0,0 +1,59 @@ +import { + Button, + Flex, + FlexBlock, + FlexItem, + __experimentalHeading as Heading, + __experimentalView as View, +} from '@wordpress/components'; +import { __, isRTL } from '@wpsocio/i18n'; +import { chevronLeft, chevronRight } from '@wpsocio/ui/icons/wp'; +import styles from './styles.module.scss'; + +export interface PluginSubPageProps { + title: string; + description?: string; + children?: React.ReactNode; + onClickBack?: VoidFunction; + backButtonLabel?: string; + headerAction?: React.ReactNode; +} + +export const PluginSubPage: React.FC = ({ + title, + description, + children, + onClickBack, + headerAction, + backButtonLabel, +}) => { + return ( + + + + + {onClickBack ? ( + + + + ) : null} + + + {title} + + {description} + + + + {headerAction} + + {children} + + ); +}; diff --git a/packages/js/shared-ui/components/wp/plugin-sub-page/styles.module.scss b/packages/js/shared-ui/components/wp/plugin-sub-page/styles.module.scss new file mode 100644 index 00000000..556dd8d8 --- /dev/null +++ b/packages/js/shared-ui/components/wp/plugin-sub-page/styles.module.scss @@ -0,0 +1,24 @@ +@use '@wpsocio/ui/wp-base-styles.scss' as wp; + +.container { + padding-block: 1rem; + + .header { + // padding to account for back button icon padding + padding-inline-end: 1rem; + width: auto; + } + + .title { + @include wp.heading-x-large(); + } + + .description { + @include wp.body-large(); + color: wp.$gray-700; + } + + .content { + padding: 2rem 1rem; + } +} \ No newline at end of file diff --git a/packages/js/shared-ui/definitions.d.ts b/packages/js/shared-ui/definitions.d.ts new file mode 100644 index 00000000..6f525371 --- /dev/null +++ b/packages/js/shared-ui/definitions.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/packages/js/shared-ui/form/wp/form-field.tsx b/packages/js/shared-ui/form/wp/form-field.tsx new file mode 100644 index 00000000..bcbf72a7 --- /dev/null +++ b/packages/js/shared-ui/form/wp/form-field.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from '@wpsocio/form'; +import { + type ControllerProps, + type FieldPath, + type FieldValues, + FormField as FormFieldUI, +} from '@wpsocio/ui/wp/form'; + +export const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + const { control } = useFormContext(); + + return ; +}; diff --git a/packages/js/shared-ui/form/wp/form-item/index.tsx b/packages/js/shared-ui/form/wp/form-item/index.tsx new file mode 100644 index 00000000..87778373 --- /dev/null +++ b/packages/js/shared-ui/form/wp/form-item/index.tsx @@ -0,0 +1,25 @@ +import { FormItem as FormItemUI, FormMessage } from '@wpsocio/ui/wp/form'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; + +export interface FormItemProps extends React.HTMLAttributes { + afterMessage?: React.ReactNode; + control?: React.ReactNode; +} + +export function FormItem({ + afterMessage, + control, + children, + ...props +}: FormItemProps) { + return ( + +
{control || children}
+
+ +
+ {afterMessage} +
+ ); +} diff --git a/packages/js/shared-ui/form/wp/form-item/styles.module.scss b/packages/js/shared-ui/form/wp/form-item/styles.module.scss new file mode 100644 index 00000000..419e8c8d --- /dev/null +++ b/packages/js/shared-ui/form/wp/form-item/styles.module.scss @@ -0,0 +1,8 @@ +@use '@wpsocio/ui/wp-base-styles.scss' as wp; + +.container { + + .message-wrapper { + margin-top: 0.5rem; + } +} \ No newline at end of file diff --git a/packages/js/shared-ui/package.json b/packages/js/shared-ui/package.json index 58d2e902..558ff685 100644 --- a/packages/js/shared-ui/package.json +++ b/packages/js/shared-ui/package.json @@ -5,11 +5,14 @@ "license": "MIT", "type": "module", "dependencies": { + "@wordpress/base-styles": "^6.2.0", + "@wordpress/components": "^29.8.0", "@wpsocio/form": "workspace:*", "@wpsocio/i18n": "workspace:*", "@wpsocio/services": "workspace:*", "@wpsocio/ui": "workspace:*", "@wpsocio/utilities": "workspace:*", + "clsx": "^2.1.1", "ramda": "^0.32.0", "zod": "^4.2.1" }, diff --git a/packages/js/shared-ui/tsconfig.json b/packages/js/shared-ui/tsconfig.json index a7ddea65..3487b528 100644 --- a/packages/js/shared-ui/tsconfig.json +++ b/packages/js/shared-ui/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../config/tsconfig.react.json", "exclude": ["**/node_modules"], - "include": ["components", "form", "wptelegram"], + "include": ["definitions.d.ts", "components", "form", "wp", "wptelegram"], "compilerOptions": { // "jsx": "react-jsx", "esModuleInterop": true, diff --git a/packages/js/ui/base-styles.scss b/packages/js/ui/base-styles.scss new file mode 100644 index 00000000..947cdba7 --- /dev/null +++ b/packages/js/ui/base-styles.scss @@ -0,0 +1,5 @@ +@use "./wp-base-styles.scss" as wp; + +p { + @include wp.body-large(); +} diff --git a/packages/js/ui/definitions.d.ts b/packages/js/ui/definitions.d.ts new file mode 100644 index 00000000..6f525371 --- /dev/null +++ b/packages/js/ui/definitions.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.scss' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/packages/js/ui/package.json b/packages/js/ui/package.json index 38551b8c..9901fedb 100644 --- a/packages/js/ui/package.json +++ b/packages/js/ui/package.json @@ -10,15 +10,19 @@ "typecheck": "tsc --noEmit" }, "exports": { + "./base-styles.scss": "./base-styles.scss", "./globals.css": "./src/styles/globals.css", "./mixins.scss": "./src/styles/mixins.scss", "./postcss.config": "./postcss.config.mjs", "./lib/*": "./src/lib/*.ts", "./components/*": "./src/components/*.tsx", + "./wp-base-styles.scss": "./wp-base-styles.scss", "./wrappers": "./src/wrappers/index.ts", + "./wp/*": "./src/wp/*/index.tsx", "./wrappers/*": "./src/wrappers/*.tsx", "./wrappers/types": "./src/wrappers/types.ts", "./icons": "./src/icons/index.tsx", + "./icons/wp": "./src/icons/wp.ts", "./hooks/*": "./src/hooks/*.ts" }, "dependencies": { @@ -40,6 +44,11 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@wordpress/base-styles": "^6.2.0", + "@wordpress/components": "^29.8.0", + "@wordpress/data": "^10.22.0", + "@wordpress/icons": "^10.22.0", + "@wordpress/notices": "^5.26.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", diff --git a/packages/js/ui/src/icons/wp.ts b/packages/js/ui/src/icons/wp.ts new file mode 100644 index 00000000..98940a7d --- /dev/null +++ b/packages/js/ui/src/icons/wp.ts @@ -0,0 +1,10 @@ +export { + chevronRight, + chevronLeft, + close, + plus, + check, + cloud, + trash, + edit, +} from '@wordpress/icons'; diff --git a/packages/js/ui/src/wp/button-group-control/index.tsx b/packages/js/ui/src/wp/button-group-control/index.tsx new file mode 100644 index 00000000..14a27833 --- /dev/null +++ b/packages/js/ui/src/wp/button-group-control/index.tsx @@ -0,0 +1,59 @@ +import { Button, __experimentalGrid as Grid } from '@wordpress/components'; +import { useCallback } from 'react'; +import { FieldsetAsLabel } from '../fieldset-as-label/index.js'; + +type Option = { + label: string; + value: string; +}; + +export interface ButtonGroupControlProps { + label?: React.ReactNode; + options: Option[]; + value: string[]; + onChange: (newValue: string[]) => void; +} + +/** + * A multi-select toggle button group component with accessibility via fieldset/legend. + */ +export function ButtonGroupControl({ + label, + options, + value = [], + onChange, +}: ButtonGroupControlProps) { + const toggleValue = useCallback( + (val: string) => { + const newValue = value.includes(val) + ? value.filter((v) => v !== val) + : [...value, val]; + onChange(newValue); + }, + [value, onChange], + ); + + return ( + + + {options.map(({ label, value: optionValue }) => { + const isSelected = value.includes(optionValue); + + return ( + + ); + })} + + + ); +} diff --git a/packages/js/ui/src/wp/fieldset-as-label/index.tsx b/packages/js/ui/src/wp/fieldset-as-label/index.tsx new file mode 100644 index 00000000..3ba7c1e5 --- /dev/null +++ b/packages/js/ui/src/wp/fieldset-as-label/index.tsx @@ -0,0 +1,37 @@ +import { __experimentalView as View } from '@wordpress/components'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; + +export type FieldsetAsLabelProps = React.HTMLAttributes & { + label: React.ReactNode; + labelProps?: React.HTMLAttributes; + description?: React.ReactNode; +}; + +export function FieldsetAsLabel({ + label, + children, + labelProps, + description, + ...props +}: FieldsetAsLabelProps) { + return ( +
+ + {label} + + {children} + {description ? ( + + {description} + + ) : null} +
+ ); +} diff --git a/packages/js/ui/src/wp/fieldset-as-label/styles.module.scss b/packages/js/ui/src/wp/fieldset-as-label/styles.module.scss new file mode 100644 index 00000000..50ca6c14 --- /dev/null +++ b/packages/js/ui/src/wp/fieldset-as-label/styles.module.scss @@ -0,0 +1,17 @@ +.label { + font-size: 0.6875rem; // 11px + font-weight: 500; + line-height: 1.4; + text-transform: uppercase; + display: block; + margin-bottom: calc(8px); + padding: 0px; +} + +.description { + margin-top: calc(8px); + margin-bottom: 0px; + font-size: 12px; + font-style: normal; + color: rgb(117, 117, 117); +} diff --git a/packages/js/ui/src/wp/form/index.tsx b/packages/js/ui/src/wp/form/index.tsx new file mode 100644 index 00000000..4908be91 --- /dev/null +++ b/packages/js/ui/src/wp/form/index.tsx @@ -0,0 +1,194 @@ +import { Slot } from '@radix-ui/react-slot'; +import { __experimentalView as View } from '@wordpress/components'; +import clsx from 'clsx'; +import { createContext, forwardRef, useContext, useId } from 'react'; +import { + Controller, + type ControllerProps, + type FieldPath, + type FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form'; +import styles from './styles.module.scss'; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext); + const itemContext = useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error('useFormField should be used within '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = createContext( + {} as FormItemContextValue, +); + +const FormItem = forwardRef< + HTMLDivElement, + React.HTMLAttributes +>((props, ref) => { + const id = useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = 'FormItem'; + +const FormLabel = forwardRef< + React.ElementRef<'label'>, + React.ComponentPropsWithoutRef<'label'> & { isRequired?: boolean } +>(({ className, children, isRequired, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( + + <> + {children} + {isRequired && ( + + )} + + + ); +}); +FormLabel.displayName = 'FormLabel'; + +const FormControl = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); + + return ( + + ); +}); +FormControl.displayName = 'FormControl'; + +const FormDescription = forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField(); + + return ( +

+ ); +}); +FormDescription.displayName = 'FormDescription'; + +const FormMessage = forwardRef< + HTMLSpanElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField(); + const body = error ? String(error?.message) : children; + + if (!body) { + return null; + } + + return ( + + {body} + + ); +}); +FormMessage.displayName = 'FormMessage'; + +export { + useFormField, + FormProvider, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +}; + +export type { ControllerProps, FieldPath, FieldValues }; diff --git a/packages/js/ui/src/wp/form/styles.module.scss b/packages/js/ui/src/wp/form/styles.module.scss new file mode 100644 index 00000000..bc23373f --- /dev/null +++ b/packages/js/ui/src/wp/form/styles.module.scss @@ -0,0 +1,25 @@ +@use '../../../wp-base-styles.scss' as wp; + +.form-label { + @include wp.body-large(); + font-weight: 500; + + &--error { + color: wp.$alert-red; + } + + &--required { + color: wp.$alert-red; + margin-inline-start: 0.25rem; + } +} + +.form-description { + color: wp.$gray-700; + @include wp.body-medium(); +} + +.form-message { + color: wp.$alert-red; + @include wp.body-medium(); +} \ No newline at end of file diff --git a/packages/js/ui/src/wp/global-notices/index.tsx b/packages/js/ui/src/wp/global-notices/index.tsx new file mode 100644 index 00000000..2ebcd637 --- /dev/null +++ b/packages/js/ui/src/wp/global-notices/index.tsx @@ -0,0 +1,34 @@ +/** + * Inspired by the same component in Jetpack. + */ +import { SnackbarList } from '@wordpress/components'; +import styles from './styles.module.scss'; +import { useGlobalNotices } from './use-global-notices.js'; + +export type GlobalNoticesProps = { + maxVisibleNotices?: number; +}; + +/** + * Renders the global notices. + */ +export function GlobalNotices({ maxVisibleNotices = 3 }: GlobalNoticesProps) { + const { getNotices, removeNotice } = useGlobalNotices(); + + const snackbarNotices = getNotices() + // Filter to only include snackbar notices. + // @ts-expect-error - The type is not correctly inferred. + .filter(({ type }) => type === 'snackbar') + // Slices from the tail end of the list. + .slice(-maxVisibleNotices); + + return ( + + ); +} + +export * from './use-global-notices.js'; diff --git a/packages/js/ui/src/wp/global-notices/styles.module.scss b/packages/js/ui/src/wp/global-notices/styles.module.scss new file mode 100644 index 00000000..810f4971 --- /dev/null +++ b/packages/js/ui/src/wp/global-notices/styles.module.scss @@ -0,0 +1,28 @@ +@use '../../../wp-base-styles.scss' as gb; + +.global-notices { + + &:global(.components-snackbar-list) { + display: flex; + flex-direction: column; + align-items: flex-end; + + position: fixed; + inset-block-start: auto; // top + inset-block-end: 0.5rem; // bottom + inset-inline: 0; // left and right + // Modals have 100000, so this needs to be above them + z-index: 100001; + + @include gb.break-small { + width: auto; + inset-inline: unset; // left and right + inset-block-end: 3rem; + inset-inline-end: 1rem; + } + + @include gb.break-medium { + inset-block-end: 2rem; + } + } +} \ No newline at end of file diff --git a/packages/js/ui/src/wp/global-notices/use-global-notices.ts b/packages/js/ui/src/wp/global-notices/use-global-notices.ts new file mode 100644 index 00000000..01fbb6b2 --- /dev/null +++ b/packages/js/ui/src/wp/global-notices/use-global-notices.ts @@ -0,0 +1,50 @@ +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import type { WPNotice } from '@wordpress/notices/build-types/store/selectors.js'; +import { useCallback, useMemo } from 'react'; + +type NoticesStore = ReturnType<(typeof noticesStore)['instantiate']>; + +export type TGlobalNotices = ReturnType & + ReturnType; + +/** + * The global notices hook. + * + * @return The global notices selectors and actions. + */ +export function useGlobalNotices() { + const actionCreators = useDispatch(noticesStore); + const notices = useSelect((select) => select(noticesStore).getNotices(), []); + + const createNotice = useCallback( + (status, content, options) => { + return actionCreators.createNotice(status, content, { + type: 'snackbar', + id: status + content, + ...options, + }); + }, + [actionCreators.createNotice], + ); + + return useMemo(() => { + return { + ...actionCreators, + createNotice, + createErrorNotice(content, options) { + return createNotice('error', content, options); + }, + createInfoNotice(content, options) { + return createNotice('info', content, options); + }, + createSuccessNotice(content, options) { + return createNotice('success', content, options); + }, + createWarningNotice(content, options) { + return createNotice('warning', content, options); + }, + getNotices: (): WPNotice[] => notices, + }; + }, [actionCreators, createNotice, notices]); +} diff --git a/packages/js/ui/src/wrappers/select.tsx b/packages/js/ui/src/wrappers/select.tsx index 22310f53..4ab595fd 100644 --- a/packages/js/ui/src/wrappers/select.tsx +++ b/packages/js/ui/src/wrappers/select.tsx @@ -1,6 +1,4 @@ import * as React from 'react'; - -import { cn } from '../lib/utils.js'; import { SelectContent, SelectGroup, @@ -10,6 +8,7 @@ import { Select as SelectUI, SelectValue, } from '../components/select.js'; +import { cn } from '../lib/utils.js'; import { Spinner } from './spinner.js'; import type { OptionProps } from './types.js'; @@ -75,7 +74,7 @@ const Select = React.forwardRef( portalContainer={portalContainer || getPortalContainer()} > {options.map((option) => ( - + {(() => { if ('options' in option && Array.isArray(option.options)) { return ( diff --git a/packages/js/ui/tsconfig.json b/packages/js/ui/tsconfig.json index 0ef76649..54db67ff 100644 --- a/packages/js/ui/tsconfig.json +++ b/packages/js/ui/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../../config/tsconfig.react.json", - "include": ["src"], + "include": ["src", "definitions.d.ts"], "exclude": ["**/node_modules"], "compilerOptions": { "jsx": "react-jsx", diff --git a/packages/js/ui/wp-base-styles.scss b/packages/js/ui/wp-base-styles.scss new file mode 100644 index 00000000..933e9570 --- /dev/null +++ b/packages/js/ui/wp-base-styles.scss @@ -0,0 +1,6 @@ +@forward '@wordpress/base-styles/z-index'; +@forward '@wordpress/base-styles/colors'; +@forward '@wordpress/base-styles/variables'; +@forward '@wordpress/base-styles/breakpoints'; +@forward '@wordpress/base-styles/mixins'; +@forward '@wordpress/base-styles/animations'; diff --git a/packages/js/utilities/hooks/use-wp-live-date.ts b/packages/js/utilities/hooks/use-wp-live-date.ts new file mode 100644 index 00000000..04e0b607 --- /dev/null +++ b/packages/js/utilities/hooks/use-wp-live-date.ts @@ -0,0 +1,23 @@ +import { date } from '@wordpress/date'; +import { useEffect, useMemo, useState } from 'react'; + +// "July 23, 2025 10:19:45 am" +const defaultFormat = 'F j, Y g:i:s a'; + +export function useWpLiveDate(dateFormat = defaultFormat) { + const [key, setKey] = useState(0); + + useEffect(() => { + const interval = setInterval(() => setKey((prev) => prev + 1), 1000); + + return () => clearInterval(interval); + }, []); + + return useMemo(() => { + // This is to force re-render when key changes + // And to avoid lint warnings about dependency not used + key.toString(); + + return date(dateFormat, undefined, undefined); + }, [key, dateFormat]); +} diff --git a/packages/js/utilities/package.json b/packages/js/utilities/package.json index 107c0ecc..6e4fd315 100644 --- a/packages/js/utilities/package.json +++ b/packages/js/utilities/package.json @@ -7,6 +7,7 @@ "module": "index.ts", "dependencies": { "@paralleldrive/cuid2": "^3.0.6", + "@wordpress/date": "^5.26.0", "@wpsocio/i18n": "workspace:*", "copy-to-clipboard": "^3.3.3", "ramda": "^0.32.0"