diff --git a/apps/docs/package.json b/apps/docs/package.json index f549ed199f..8b12dc9b92 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -6,11 +6,19 @@ "main": "src/pages/index.js", "private": true, "dependencies": { + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/language": "^6.12.1", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.39.12", "@hpe-design/icons-grommet": "catalog:", + "@lezer/highlight": "^1.2.3", "@mdx-js/loader": "^3.0.1", "@mdx-js/react": "^3.0.1", "@next/mdx": "^14.1.4", "@shared/aries-core": "workspace:*", + "@uiw/codemirror-theme-github": "^4.25.4", "grommet": "catalog:", "grommet-icons": "catalog:grommet-stable", "hpe-design-tokens": "^2.1.0", diff --git a/apps/docs/src/components/content/CodeEditor.js b/apps/docs/src/components/content/CodeEditor.js new file mode 100644 index 0000000000..3bb4979db8 --- /dev/null +++ b/apps/docs/src/components/content/CodeEditor.js @@ -0,0 +1,222 @@ +import React, { useContext, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { ThemeContext, Box } from 'grommet'; +import { EditorView, keymap } from '@codemirror/view'; +import { EditorState } from '@codemirror/state'; +import { javascript } from '@codemirror/lang-javascript'; +import { defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; +import { prism } from 'grommet-theme-hpe'; + +export const CodeEditor = ({ code, onChange }) => { + const theme = useContext(ThemeContext); + const editorRef = useRef(null); + const viewRef = useRef(null); + const onChangeRef = useRef(onChange); + const isDark = theme.dark; + + // Keep onChange ref updated without triggering recreation + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + if (!editorRef.current) return undefined; + + // Don't recreate if editor already exists with same theme mode + if (viewRef.current) { + return; + } + + // Get HPE theme colors + const getColor = colorName => { + const colorValue = theme.global.colors[colorName]; + if (typeof colorValue === 'object') { + return colorValue[isDark ? 'dark' : 'light'] || colorValue; + } + return colorValue || colorName; + }; + + // Get the HPE prism theme for syntax highlighting colors + const hpePrismTheme = isDark ? prism.dark : prism.light; + + // Create HPE theme extension + const hpeTheme = EditorView.theme( + { + '&': { + height: '100%', + fontSize: '14px', + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Consolas, Liberation Mono, Menlo, monospace', + }, + '.cm-editor': { + backgroundColor: getColor('background-front'), + color: getColor('text-default'), + border: `1px solid ${getColor('border-weak')}`, + borderRadius: '4px', + }, + '.cm-scroller': { + backgroundColor: 'transparent', + lineHeight: '1.5', + }, + '.cm-content': { + backgroundColor: 'transparent', + color: getColor('text-default'), + padding: '12px', + caretColor: getColor('text-strong'), + minHeight: '100px', + }, + '.cm-content[contenteditable="true"]': { + outline: 'none', + }, + '.cm-line': { + padding: '0', + }, + '.cm-gutters': { + display: 'none', + }, + '.cm-focused': { + outline: `2px solid ${getColor('border-selected')}`, + outlineOffset: '-2px', + }, + '&.cm-focused .cm-cursor': { + borderLeftColor: getColor('text-strong'), + borderLeftWidth: '2px', + }, + '&.cm-focused .cm-selectionBackground, ::selection': { + backgroundColor: isDark + ? 'rgba(0, 255, 135, 0.2)' + : 'rgba(0, 125, 96, 0.2)', + }, + '.cm-selectionBackground': { + backgroundColor: isDark + ? 'rgba(255, 255, 255, 0.1)' + : 'rgba(0, 0, 0, 0.1)', + }, + }, + { dark: isDark }, + ); + + // Create HPE syntax highlighting style using exact prism theme colors + const hpeSyntaxHighlighting = HighlightStyle.define([ + // Comments + { + tag: tags.comment, + color: hpePrismTheme.comment?.color, + fontStyle: 'italic', + }, + // Keywords (import, export, const, let, var, function, etc.) + { + tag: [ + tags.keyword, + tags.controlKeyword, + tags.definitionKeyword, + tags.modifier, + tags.moduleKeyword, + ], + color: hpePrismTheme.keyword?.color, + fontWeight: '500', + }, + // Strings + { + tag: [tags.string, tags.special(tags.string)], + color: hpePrismTheme.string?.color, + }, + // Numbers + { + tag: [tags.number, tags.literal], + color: hpePrismTheme.number?.color, + }, + // Component names and functions + { + tag: [ + tags.variableName, + tags.function(tags.variableName), + tags.definition(tags.variableName), + ], + color: hpePrismTheme['maybe-class-name']?.color, + }, + // JSX Tags + { + tag: [tags.tagName], + color: hpePrismTheme.keyword?.color, + fontWeight: '500', + }, + // Attributes + { + tag: [tags.attributeName, tags.propertyName], + color: hpePrismTheme['attr-name']?.color, + }, + // Operators + { + tag: [tags.operator], + color: hpePrismTheme.operator?.color, + }, + // Punctuation + { + tag: [tags.punctuation, tags.separator, tags.bracket], + color: + hpePrismTheme['code[class*="language-"]']?.color || + getColor('text-default'), + }, + // Boolean values + { + tag: [tags.bool], + color: hpePrismTheme.boolean?.color, + }, + ]); + + // Create editor state + const state = EditorState.create({ + doc: code, + extensions: [ + EditorView.editable.of(true), + hpeTheme, + syntaxHighlighting(hpeSyntaxHighlighting), + keymap.of([...defaultKeymap, indentWithTab]), + javascript({ jsx: true }), + EditorView.updateListener.of(update => { + if (update.docChanged && onChangeRef.current) { + onChangeRef.current(update.state.doc.toString()); + } + }), + ], + }); + + // Create editor view + const view = new EditorView({ + state, + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => { + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + }; + }, [isDark]); // Only recreate when theme mode changes + + // Handle external code changes + useEffect(() => { + if (viewRef.current && viewRef.current.state.doc.toString() !== code) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: viewRef.current.state.doc.length, + insert: code, + }, + }); + } + }, [code]); + + return ; +}; + +CodeEditor.propTypes = { + code: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/apps/docs/src/components/content/ComponentPlayground.js b/apps/docs/src/components/content/ComponentPlayground.js new file mode 100644 index 0000000000..87aa778dde --- /dev/null +++ b/apps/docs/src/components/content/ComponentPlayground.js @@ -0,0 +1,460 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Box, + Button, + CheckBox, + Select, + Text, + TextInput, + Heading, + ResponsiveContext, + Grommet, + Grid, +} from 'grommet'; +import { Copy, Moon } from '@hpe-design/icons-grommet'; +import { hpe } from 'grommet-theme-hpe'; +import { CodeEditor } from './CodeEditor'; + +const ICON_OPTIONS = [ + { label: 'None', value: null }, + { label: 'NewWindow', value: 'NewWindow' }, + { label: 'LinkNext', value: 'LinkNext' }, + { label: 'LinkPrevious', value: 'LinkPrevious' }, +]; + +export const ComponentPlayground = ({ + component: Component, + defaultProps = {}, + controls = [], + codeTemplate, +}) => { + const [componentProps, setComponentProps] = useState(defaultProps); + const [copied, setCopied] = useState(false); + const [code, setCode] = useState(''); + const [isCodeManuallyEdited, setIsCodeManuallyEdited] = useState(false); + const [codeError, setCodeError] = useState(null); + // eslint-disable-next-line max-len + const [previewTheme, setPreviewTheme] = useState('light'); // 'light' or 'dark' + + const togglePreviewTheme = () => { + setPreviewTheme(prev => (prev === 'light' ? 'dark' : 'light')); + }; + + const handlePropChange = (propName, value) => { + setComponentProps(prev => { + const newProps = { ...prev, [propName]: value }; + return newProps; + }); + }; + + const generateCode = () => { + // Use custom code template if provided + if (codeTemplate) { + return codeTemplate(componentProps); + } + + const propsString = Object.entries(componentProps) + .filter( + ([, value]) => value !== null && value !== undefined && value !== '', + ) + .map(([key, value]) => { + if (key === 'icon' && value) { + return `icon={<${value} />}`; + } + if (typeof value === 'boolean') { + return value ? key : null; + } + if (typeof value === 'string') { + return `${key}="${value}"`; + } + return null; + }) + .filter(Boolean) + .join(' '); + + const iconImport = componentProps.icon + ? `import { ${componentProps.icon} } from '@hpe-design/icons-grommet';\n` + : ''; + + const componentName = Component.displayName || 'Component'; + const propsCode = propsString ? ` ${propsString}` : ''; + + return `${iconImport}import { ${componentName} } from 'grommet'; + +<${componentName}${propsCode} />`; + }; + + // Update code when componentProps change, unless user manually edited it + useEffect(() => { + if (!isCodeManuallyEdited) { + const newCode = generateCode(); + setCode(newCode); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [componentProps, isCodeManuallyEdited]); + + const handleCodeChange = newCode => { + setCode(newCode); + setIsCodeManuallyEdited(true); + + // Validate code syntax + const componentName = Component.displayName || 'Component'; + const componentRegex = new RegExp( + `<${componentName}([^/>]*)(/>|>.*?)`, + 's', + ); + + // Check for basic JSX syntax errors + if ( + newCode.includes(`<${componentName}`) && + !componentRegex.test(newCode) + ) { + setCodeError('Syntax Error: Incomplete or malformed JSX tag'); + return; + } + + // Check for unclosed quotes + const quoteMatches = newCode.match(/"/g); + if (quoteMatches && quoteMatches.length % 2 !== 0) { + setCodeError('Syntax Error: Unclosed quote'); + return; + } + + // Check for unclosed braces in JSX + const braceMatches = newCode.match(/\{/g); + const closeBraceMatches = newCode.match(/\}/g); + if ((braceMatches?.length || 0) !== (closeBraceMatches?.length || 0)) { + setCodeError('Syntax Error: Unclosed brace'); + return; + } + + // Clear error if validation passes + setCodeError(null); + + // Parse the code to extract props + try { + const match = newCode.match(componentRegex); + + if (match) { + const propsString = match[1]; + const newProps = { ...defaultProps }; // Start with default props + + // Parse all string props: propName="value" + const stringPropRegex = /(\w+)="([^"]*)"/g; + let stringMatch; + // eslint-disable-next-line no-cond-assign + while ((stringMatch = stringPropRegex.exec(propsString)) !== null) { + const [, propName, propValue] = stringMatch; + newProps[propName] = propValue; + } + + // Parse icon={} + const iconMatch = propsString.match(/icon=\{<(\w+)\s*\/>\}/); + if (iconMatch) { + // eslint-disable-next-line prefer-destructuring + newProps.icon = iconMatch[1]; + } else if (!propsString.includes('icon=')) { + // If icon prop is not in the code, ensure it's not in newProps + delete newProps.icon; + } + + // Parse boolean props (just the prop name without =) + // Get all possible boolean props from controls + const boolProps = controls + .filter(c => c.type === 'checkbox') + .map(c => c.name); + + boolProps.forEach(prop => { + const hasProp = new RegExp(`\\b${prop}\\b(?!=)`).test(propsString); + if (hasProp) { + newProps[prop] = true; + } else if (defaultProps[prop] === undefined) { + newProps[prop] = false; + } + }); + + console.log('Parsed props from code:', newProps); + // Replace props entirely when code is manually edited + setComponentProps(newProps); + } + } catch (error) { + // If parsing fails, just keep the code change + setCodeError(`Error: ${error.message}`); + } + }; + + const handleCopy = () => { + navigator.clipboard.writeText(code || generateCode()); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const renderControl = (control, isLast = false) => { + const { name, type, options, displayLabel } = control; + + let controlElement; + + switch (type) { + case 'text': + controlElement = ( + handlePropChange(name, event.target.value)} + placeholder={`Enter ${displayLabel || name}`} + size="small" + focusIndicator + /> + ); + break; + + case 'select': + controlElement = ( + opt.value === componentProps[name], + ) || { label: 'None', value: null } + } + onChange={({ option }) => handlePropChange(name, option.value)} + size="small" + /> + ); + break; + + default: + return null; + } + + return ( + + + {displayLabel || name} + + {controlElement} + + ); + }; + + // Dynamically import icon if needed + const ComponentWithIcon = () => { + const propsToRender = { ...componentProps }; + + // Add onClick handler to prevent default behavior for interactive + // components + if (!propsToRender.onClick) { + propsToRender.onClick = e => { + e.preventDefault(); + e.stopPropagation(); + }; + } + + if (componentProps.icon) { + const icons = require('@hpe-design/icons-grommet'); + const IconComponent = icons[componentProps.icon]; + if (IconComponent) { + propsToRender.icon = ; + } + } + + return ; + }; + + return ( + + {size => { + return ( + + +