diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 59c13058cc3..5bbe99c196e 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -103,14 +103,15 @@ module.exports = { to: {path: '^@tryghost/(admin-x-framework|admin-x-design-system)'} }, // ============================================================ - // apps/ — admin-x-design-system/ is a leaf; must not depend on higher layers + // apps/ — admin-x-design-system/ may consume Shade while it is being retired, + // but must not depend on application/framework layers // ============================================================ { - name: 'admin-x-design-system-is-leaf', - comment: 'admin-x-design-system/ must not depend on shade or admin-x-framework.', + name: 'admin-x-design-system-does-not-depend-on-framework', + comment: 'admin-x-design-system/ must not depend on admin-x-framework.', severity: 'error', from: {path: '^apps/admin-x-design-system/'}, - to: {path: '^@tryghost/(shade|admin-x-framework)'} + to: {path: '^@tryghost/admin-x-framework'} }, // ============================================================ // apps/ — admin-x-framework/ must not depend on feature apps @@ -152,4 +153,3 @@ module.exports = { exclude: {path: '(^|/)(node_modules|coverage|coverage-next|test|built|dist)/'} } }; - diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 49b6c4deeb0..7c069b3ec33 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -25,9 +25,6 @@ "types" ], "devDependencies": { - "@codemirror/lang-html": "catalog:", - "@codemirror/state": "catalog:", - "@dnd-kit/utilities": "3.2.2", "@internal/cfg-eslint-react": "workspace:*", "@storybook/addon-docs": "catalog:", "@storybook/addon-links": "catalog:", @@ -38,6 +35,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@types/validator": "catalog:", + "@typescript/native": "catalog:", "@vitejs/plugin-react": "catalog:", "autoprefixer": "10.5.2", "chai": "catalog:", @@ -52,7 +50,6 @@ "sinon": "catalog:", "storybook": "catalog:", "tailwindcss": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "validator": "catalog:", "vite": "catalog:", @@ -60,8 +57,6 @@ "vitest": "catalog:" }, "dependencies": { - "@dnd-kit/core": "6.3.1", - "@dnd-kit/sortable": "7.0.2", "@ebay/nice-modal-react": "catalog:", "@radix-ui/react-form": "catalog:", "@radix-ui/react-separator": "catalog:", @@ -69,9 +64,7 @@ "@radix-ui/react-tooltip": "catalog:", "@sentry/react": "catalog:", "@tryghost/shade": "workspace:*", - "@uiw/react-codemirror": "catalog:", "clsx": "catalog:", - "react-colorful": "5.7.0", "react-hot-toast": "catalog:" }, "peerDependencies": { diff --git a/apps/admin-x-design-system/src/design-system-app.tsx b/apps/admin-x-design-system/src/design-system-app.tsx index 08f226d2b7b..f07dd5fb606 100644 --- a/apps/admin-x-design-system/src/design-system-app.tsx +++ b/apps/admin-x-design-system/src/design-system-app.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import React from 'react'; -import {FetchKoenigLexical} from './global/form/html-editor'; +import type {FetchKoenigLexical} from './providers/design-system-provider'; import DesignSystemProvider from './providers/design-system-provider'; export interface DesignSystemAppProps extends React.HTMLProps { diff --git a/apps/admin-x-design-system/src/global/form/code-editor.stories.tsx b/apps/admin-x-design-system/src/global/form/code-editor.stories.tsx deleted file mode 100644 index 10754f68280..00000000000 --- a/apps/admin-x-design-system/src/global/form/code-editor.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {html} from '@codemirror/lang-html'; -import {useArgs} from 'storybook/preview-api'; -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import CodeEditor from './code-editor'; - -const meta = { - title: 'Global / Form / Code Editor', - component: CodeEditor, - tags: ['autodocs'], - // decorators: [(_story: () => ReactNode) => (
{_story()}
)], - argTypes: { - hint: { - control: 'text' - }, - extensions: { - table: { - disable: true - } - } - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const WithValue: Story = { - render: function Component(args) { - const [, updateArgs] = useArgs(); - - return updateArgs({value})} />; - }, - args: { - extensions: [html()], - value: '

HTML goes here

' - } -}; - -export const WithTitle: Story = { - args: { - extensions: [html()], - title: 'Header code' - } -}; - -export const WithHint: Story = { - args: { - extensions: [html()], - hint: 'Here\'s some hint' - } -}; - -export const Error: Story = { - args: { - title: 'Header code', - extensions: [html()], - hint: 'Don\'t use script tags', - value: '', - error: true - } -}; - diff --git a/apps/admin-x-design-system/src/global/form/color-indicator.stories.tsx b/apps/admin-x-design-system/src/global/form/color-indicator.stories.tsx deleted file mode 100644 index 6bac7aeee0c..00000000000 --- a/apps/admin-x-design-system/src/global/form/color-indicator.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import ColorIndicator from './color-indicator'; - -const meta = { - title: 'Global / Form / Color Indicator', - component: ColorIndicator, - tags: ['autodocs'], - argTypes: {}, - args: { - swatches: [], - onSwatchChange: () => {}, - onTogglePicker: () => {}, - isExpanded: false - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Basic: Story = { - args: { - } -}; - -export const WithValue: Story = { - args: { - value: '#ff0000' - } -}; - -export const WithSwatches: Story = { - args: { - swatches: [ - {hex: '#ff0000', title: 'Red'}, - {hex: '#00ff00', title: 'Green'}, - {hex: '#0000ff', title: 'Blue'}, - {hex: '#642642', value: 'accent', title: 'Accent'}, - {hex: '#00000000', value: 'transparent', title: 'Transparent'} - ] - } -}; - -export const SwatchSelected: Story = { - args: { - swatches: [ - {hex: '#ff0000', title: 'Red'}, - {hex: '#00ff00', title: 'Green'}, - {hex: '#0000ff', title: 'Blue'}, - {hex: '#642642', value: 'accent', title: 'Accent'}, - {hex: '#00000000', value: 'transparent', title: 'Transparent'} - ], - value: 'accent' - } -}; diff --git a/apps/admin-x-design-system/src/global/form/color-indicator.tsx b/apps/admin-x-design-system/src/global/form/color-indicator.tsx deleted file mode 100644 index 36770faf856..00000000000 --- a/apps/admin-x-design-system/src/global/form/color-indicator.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import clsx from 'clsx'; -import {Fragment, MouseEvent, useRef} from 'react'; -import Heading from '../heading'; - -type SwatchSizes = 'md' | 'lg'; - -const ColorSwatch: React.FC<{ - hex: string; - value?: string | null; - title: string; - size?: SwatchSizes; - isSelected: boolean; - onSelect: (value: string | null) => void; -}> = ({hex, value, title, size = 'md', isSelected, onSelect}) => { - const ref = useRef(null); - - const onSelectHandler = (e: MouseEvent) => { - e.preventDefault(); - - if (value !== undefined) { - onSelect(value); - } else { - onSelect(hex!); - } - }; - - const isTransparent = (hex.length === 4 && hex[3] === '0') || (hex.length === 8 && hex.slice(6) === '00'); - - let sizeClass = 'h-5 w-5'; - switch (size) { - case 'lg': - sizeClass = 'w-6 h-6'; - break; - } - - return ( - - ); -}; - -export type SwatchOption = { - hex: string; - value?: string | null; - title: string; - customContent?: JSX.Element; -} - -export interface ColorIndicatorProps { - title?: string; - value?: string | null; - swatches: SwatchOption[]; - swatchSize?: SwatchSizes; - onSwatchChange: (newValue: string | null) => void; - onTogglePicker: () => void; - isExpanded: boolean; - picker?: boolean; - containerClassName?: string; -} - -/** Should usually be used via [ColorPickerField](?path=/docs/global-form-color-picker-field--docs) */ -const ColorIndicator: React.FC = ({title, value, swatches, swatchSize = 'md', onSwatchChange, onTogglePicker, isExpanded, picker = true, containerClassName}) => { - const selectedSwatch = swatches.find(swatch => swatch.value === value || swatch.hex === value); - - containerClassName = clsx( - 'flex flex-col gap-3' - ); - - const showSwatches = isExpanded || !picker; - - return ( -
- {title && {title}} -
- {showSwatches && ( -
- {swatches.map(({customContent, ...swatch}) => ( - customContent ? {customContent} : - ))} -
- )} - {picker && ( - - )} -
-
- ); -}; - -export default ColorIndicator; diff --git a/apps/admin-x-design-system/src/global/form/color-picker-field.stories.tsx b/apps/admin-x-design-system/src/global/form/color-picker-field.stories.tsx deleted file mode 100644 index 1d2807e992c..00000000000 --- a/apps/admin-x-design-system/src/global/form/color-picker-field.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import ColorPickerField from './color-picker-field'; - -const meta = { - title: 'Global / Form / Color Picker Field', - component: ColorPickerField, - tags: ['autodocs'], - argTypes: {}, - render: (args) => { - return ( -
- -
- ); - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Basic: Story = { - args: {} -}; - -export const WithTitle: Story = { - args: { - title: 'Colour' - } -}; - -export const WithHint: Story = { - args: { - title: 'Colour', - hint: 'Pick a colour' - } -}; - -export const WithError: Story = { - args: { - title: 'Colour', - hint: 'Please select a colour', - error: true - } -}; - -export const WithValue: Story = { - args: { - title: 'Colour', - value: '#ff0000' - } -}; - -export const RightToLeft: Story = { - args: { - title: 'Colour', - direction: 'rtl' - } -}; - -export const RightToLeftHint: Story = { - args: { - title: 'Colour', - hint: 'Pick a colour', - direction: 'rtl' - } -}; - -export const WithSwatches: Story = { - args: { - title: 'Colour', - direction: 'rtl', - swatches: [ - {hex: '#ff0000', title: 'Red'}, - {hex: '#00ff00', title: 'Green'}, - {hex: '#0000ff', title: 'Blue'} - ] - } -}; diff --git a/apps/admin-x-design-system/src/global/form/color-picker-field.tsx b/apps/admin-x-design-system/src/global/form/color-picker-field.tsx deleted file mode 100644 index 9465ac5c521..00000000000 --- a/apps/admin-x-design-system/src/global/form/color-picker-field.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import clsx from 'clsx'; -import {ReactNode, createContext, useContext, useEffect, useId, useMemo, useState} from 'react'; -import {debounce} from '../../utils/debounce'; -import ColorIndicator, {SwatchOption} from './color-indicator'; -import ColorPicker from './color-picker'; - -const ColorPickerContext = createContext<{colorPickers: Array<{ id: string; setExpanded: ((expanded: boolean) => void) }>}>({ - colorPickers: [] -}); - -const to6DigitHex = (hex: string) => { - if (hex.length === 4) { - return hex.replace(/#(.)(.)(.)/, '#$1$1$2$2$3$3'); - } else { - return hex; - } -}; - -export interface ColorPickerFieldProps { - testId?: string; - title?: ReactNode; - direction?: 'ltr' | 'rtl'; - hint?: ReactNode; - error?: boolean; - value?: string | null; - eyedropper?: boolean; - clearButtonValue?: string | null; - onChange?: (newValue: string | null) => void; - swatches?: SwatchOption[]; - alwaysOpen?: boolean; - debounceMs?: number; -} - -const ColorPickerField: React.FC = ({testId, title, direction, value, hint, error, eyedropper, clearButtonValue, onChange, swatches = [], alwaysOpen = false, debounceMs}) => { - const [isExpanded, setExpanded] = useState(false); - const [localValue, setLocalValue] = useState(value); - const context = useContext(ColorPickerContext); - const id = useId(); - - useEffect(() => { - setLocalValue((currentValue) => { - // If the current value is the 3-digit equivalent of the new value, - // the user probably typed it as 3 digits so keep showing it that way in the UI - if (to6DigitHex(currentValue || '') === value) { - return currentValue; - } - - return value; - }); - }, [value]); - - useEffect(() => { - context.colorPickers.push({id, setExpanded}); - - return () => { - context.colorPickers = context.colorPickers.filter(item => item.id !== id); - }; - }, [context, id]); - - // Close on click outside - useEffect(() => { - if (isExpanded) { - context.colorPickers.filter(item => item.id !== id).forEach(item => item.setExpanded(false)); - - const closePicker = () => { - setExpanded(false); - }; - document.addEventListener('click', closePicker); - - return () => { - document.removeEventListener('click', closePicker); - }; - } - }, [context, id, isExpanded]); - - const debouncedOnChange = useMemo(() => { - if (onChange && debounceMs) { - return debounce(onChange, debounceMs); - } else { - return onChange; - } - }, [debounceMs, onChange]); - - const handleChange = (newValue: string | null) => { - setLocalValue(newValue); - debouncedOnChange?.(newValue ? to6DigitHex(newValue) : null); - }; - - let content = ( - { - handleChange(newValue); - setExpanded(false); - }} - onTogglePicker={() => !alwaysOpen && setExpanded(!isExpanded)} - /> - ); - - if (title) { - content = ( -
-
- {content} -
-
!alwaysOpen && setExpanded(!isExpanded)}> - {title} - {hint &&
{hint}
} -
-
- ); - } - - const selectedSwatch = swatches.find(swatch => swatch.value === localValue); - - return ( -
event.stopPropagation()}> - {content} - {(alwaysOpen || isExpanded) && } -
- ); -}; - -export default ColorPickerField; diff --git a/apps/admin-x-design-system/src/global/form/color-picker.stories.tsx b/apps/admin-x-design-system/src/global/form/color-picker.stories.tsx deleted file mode 100644 index 615588c1c57..00000000000 --- a/apps/admin-x-design-system/src/global/form/color-picker.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import ColorPicker from './color-picker'; - -const meta = { - title: 'Global / Form / Color Picker', - component: ColorPicker, - tags: ['autodocs'], - argTypes: {} -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Basic: Story = { - args: {} -}; diff --git a/apps/admin-x-design-system/src/global/form/color-picker.tsx b/apps/admin-x-design-system/src/global/form/color-picker.tsx deleted file mode 100644 index 52605538c76..00000000000 --- a/apps/admin-x-design-system/src/global/form/color-picker.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import clsx from 'clsx'; -import {MouseEvent, UIEvent, useCallback, useEffect, useRef} from 'react'; -import {HexColorInput, HexColorPicker} from 'react-colorful'; -import EyedropperIcon from '../../assets/icons/eyedropper.svg?react'; -import Button from '../button'; - -declare global { - interface Window { - // Experimental API - see https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper - EyeDropper?: { - new(): { - open(): Promise<{sRGBHex: string}>; - } - } - } -} - -export interface ColorPickerProps { - hexValue?: string; - eyedropper?: boolean; - clearButtonValue?: string | null; - containerClassName?: string; - onChange?: (newValue: string | null) => void; -} - -/** Should usually be used via [ColorPickerField](?path=/docs/global-form-color-picker-field--docs) */ -const ColorPicker: React.FC = ({hexValue, eyedropper, clearButtonValue, containerClassName, onChange}) => { - // HexColorInput doesn't support adding a ref on the input itself - const inputWrapperRef = useRef(null); - - const stopPropagation = useCallback((e: UIEvent) => { - e.stopPropagation(); - - const inputElement = inputWrapperRef.current?.querySelector('input'); - const isInputField = e.target === inputElement; - - // Allow text selection for events on the input field - if (isInputField) { - return; - } - - // Prevent closing the color picker when clicking somewhere inside it - inputWrapperRef.current?.querySelector('input')?.focus(); - - e.preventDefault(); - }, []); - - const isUsingColorPicker = useRef(false); - - const stopUsingColorPicker = useCallback(() => { - isUsingColorPicker.current = false; - inputWrapperRef.current?.querySelector('input')?.focus(); - - document.removeEventListener('mouseup', stopUsingColorPicker); - document.removeEventListener('touchend', stopUsingColorPicker); - }, []); - - const startUsingColorPicker = useCallback(() => { - isUsingColorPicker.current = true; - - document.addEventListener('mouseup', stopUsingColorPicker); - document.addEventListener('touchend', stopUsingColorPicker); - }, [stopUsingColorPicker]); - - const openColorPicker = useCallback((e: MouseEvent) => { - e.preventDefault(); - - if (!window.EyeDropper) { - return; - } - - isUsingColorPicker.current = true; - document.body.style.setProperty('pointer-events', 'none'); - - const eyeDropper = new window.EyeDropper(); - eyeDropper.open() - .then(result => onChange?.(result.sRGBHex)) - .finally(() => { - isUsingColorPicker.current = false; - document.body.style.removeProperty('pointer-events'); - inputWrapperRef.current?.querySelector('input')?.focus(); - }); - }, [onChange]); - - useEffect(() => { - inputWrapperRef.current?.querySelector('input')?.focus(); - }, []); - - const focusHexInputOnClick = useCallback(() => { - inputWrapperRef.current?.querySelector('input')?.focus(); - }, []); - - containerClassName = clsx( - 'mt-2', - containerClassName - ); - - /* - position: absolute; - z-index: 300; - background: white; - padding: 10px; - border-radius: 12px; - top: 230px; - right: 50px; - min-width: 240px; - */ - - return ( -
- -
-
- # - - {eyedropper && !!window.EyeDropper && ( - - )} -
- - {clearButtonValue !== undefined &&
-
- ); -}; - -export default ColorPicker; diff --git a/apps/admin-x-design-system/src/global/form/file-upload.stories.tsx b/apps/admin-x-design-system/src/global/form/file-upload.stories.tsx deleted file mode 100644 index 7242c163aab..00000000000 --- a/apps/admin-x-design-system/src/global/form/file-upload.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react-vite'; - -import FileUpload from './file-upload'; - -const meta = { - title: 'Global / Form / File Upload', - component: FileUpload, - tags: ['autodocs'] -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - id: 'test-file', - onUpload: (file: File) => { - alert(`You're uploading: ${file.name}`); - }, - children: 'Click here to upload' - } -}; - -export const Custom: Story = { - args: { - id: 'test-file', - onUpload: (file: File) => { - alert(`You're uploading: ${file.name}`); - }, - children: ( -
- Click here to upload -
- ) - } -}; diff --git a/apps/admin-x-design-system/src/global/form/file-upload.tsx b/apps/admin-x-design-system/src/global/form/file-upload.tsx deleted file mode 100644 index 9e45ab178c6..00000000000 --- a/apps/admin-x-design-system/src/global/form/file-upload.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, {CSSProperties, ChangeEvent, useState} from 'react'; -import clsx from 'clsx'; - -export interface FileUploadProps { - id: string; - - /** - * Can be string or any component that has no default onClick eventh handline. - * E.g. buttons and links won't work. If it's text then it's styled as the - * default button. - */ - children?: string | React.ReactNode; - className?: string; - dragIndicatorClassName?: string; - onUpload: (file: File) => void; - style?: CSSProperties; - unstyled?: boolean; - inputRef?: React.RefObject; - inputTestId?: string; - accept?: string; -} - -const FileUpload: React.FC = ({id, onUpload, children, style, unstyled = false, inputRef, inputTestId, className, dragIndicatorClassName, accept, ...props}) => { - const [fileKey, setFileKey] = useState(Date.now()); - const [isDragging, setIsDragging] = useState(false); - - const handleFileChange = (event: ChangeEvent) => { - const selectedFile = event.target.files?.[0]; - if (selectedFile) { - onUpload?.(selectedFile); - } - setFileKey(Date.now()); - }; - - const handleDrop = (event: React.DragEvent) => { - handleStopDragging(event); - const selectedFile = event.dataTransfer.files?.[0]; - if (selectedFile) { - onUpload?.(selectedFile); - } - setFileKey(Date.now()); - }; - - const handleDragging = (event: React.DragEvent) => { - event.preventDefault(); - setIsDragging(true); - }; - - const handleStopDragging = (event: React.DragEvent) => { - event.preventDefault(); - setIsDragging(false); - }; - - return ( -