diff --git a/.storybook/components/ProjectTemplate.tsx b/.storybook/components/ProjectTemplate.tsx index 4601dc976a7..cc891beaff1 100644 --- a/.storybook/components/ProjectTemplate.tsx +++ b/.storybook/components/ProjectTemplate.tsx @@ -16,7 +16,7 @@ import { import { addCustomCSSWithScoping } from '@ui5/webcomponents-react-base/internal/utils'; import { clsx } from 'clsx'; import type { ReactNode } from 'react'; -import { useRef, useState } from 'react'; +import { useId, useState } from 'react'; import classes from './ProjectTemplate.module.css'; interface ProjectTemplatePropTypes { @@ -58,7 +58,7 @@ export function ProjectTemplate(props: ProjectTemplatePropTypes) { note, } = props; const [popoverOpen, setPopoverOpen] = useState(false); - const linkRef = useRef(null); + const linkId = useId() + '-link'; return ( @@ -68,7 +68,7 @@ export function ProjectTemplate(props: ProjectTemplatePropTypes) { Currently not supported by V2.{' '} { setPopoverOpen(true); @@ -79,7 +79,7 @@ export function ProjectTemplate(props: ProjectTemplatePropTypes) { { setPopoverOpen(false); diff --git a/eslint.config.mjs b/eslint.config.mjs index c4ce0190e2f..119d9be249a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,7 +48,7 @@ const config = tseslint.config( reactPlugin.configs.flat.recommended, // This is not a plugin object, but a shareable config object reactPlugin.configs.flat['jsx-runtime'], // Add this if you are using React 17+ // eslint-plugin-react-hooks - ...reactHooksPlugin.configs.recommended, + reactHooksPlugin.configs.flat.recommended, { languageOptions: { globals: { diff --git a/examples/react-router-ts/package-lock.json b/examples/react-router-ts/package-lock.json index 49f0b9fa73f..72dc5415535 100644 --- a/examples/react-router-ts/package-lock.json +++ b/examples/react-router-ts/package-lock.json @@ -27,7 +27,7 @@ "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "6.1.1", + "eslint-plugin-react-hooks": "7.0.0", "globals": "16.5.0", "typescript": "5.8.3", "typescript-eslint": "8.46.3", @@ -4179,14 +4179,15 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-6.1.1.tgz", - "integrity": "sha512-St9EKZzOAQF704nt2oJvAKZHjhrpg25ClQoaAlHmPZuajFldVLqRDW4VBNAS01NzeiQF0m0qhG1ZA807K6aVaQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.0.tgz", + "integrity": "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", "zod": "^3.22.4 || ^4.0.0", "zod-validation-error": "^3.0.3 || ^4.0.0" }, @@ -4962,6 +4963,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hosted-git-info": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.3.tgz", diff --git a/examples/react-router-ts/package.json b/examples/react-router-ts/package.json index fd19e651171..c67a32694bc 100644 --- a/examples/react-router-ts/package.json +++ b/examples/react-router-ts/package.json @@ -32,7 +32,7 @@ "eslint-plugin-import": "2.32.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "6.1.1", + "eslint-plugin-react-hooks": "7.0.0", "globals": "16.5.0", "typescript": "5.8.3", "typescript-eslint": "8.46.3", diff --git a/examples/vite-ts/eslint.config.mjs b/examples/vite-ts/eslint.config.mjs index cfd81c9345d..47a84020368 100644 --- a/examples/vite-ts/eslint.config.mjs +++ b/examples/vite-ts/eslint.config.mjs @@ -7,18 +7,16 @@ import tseslint from 'typescript-eslint'; export default tseslint.config( { ignores: ['dist'] }, { - extends: [js.configs.recommended, ...tseslint.configs.recommended], + extends: [js.configs.recommended, ...tseslint.configs.recommended, reactHooks.configs.flat.recommended, ], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { - 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { - ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, }, diff --git a/examples/vite-ts/package-lock.json b/examples/vite-ts/package-lock.json index 8a6dd0e59b6..cb1cf64f699 100644 --- a/examples/vite-ts/package-lock.json +++ b/examples/vite-ts/package-lock.json @@ -24,7 +24,7 @@ "@vitejs/plugin-react": "5.1.0", "cypress": "15.6.0", "eslint": "9.39.1", - "eslint-plugin-react-hooks": "6.1.1", + "eslint-plugin-react-hooks": "7.0.0", "eslint-plugin-react-refresh": "0.4.24", "globals": "16.5.0", "typescript-eslint": "8.46.3", @@ -3098,14 +3098,15 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-6.1.1.tgz", - "integrity": "sha512-St9EKZzOAQF704nt2oJvAKZHjhrpg25ClQoaAlHmPZuajFldVLqRDW4VBNAS01NzeiQF0m0qhG1ZA807K6aVaQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.0.tgz", + "integrity": "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", "zod": "^3.22.4 || ^4.0.0", "zod-validation-error": "^3.0.3 || ^4.0.0" }, @@ -3754,6 +3755,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/http-signature": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", diff --git a/examples/vite-ts/package.json b/examples/vite-ts/package.json index 751432a196f..713e26cbbe6 100644 --- a/examples/vite-ts/package.json +++ b/examples/vite-ts/package.json @@ -30,7 +30,7 @@ "@vitejs/plugin-react": "5.1.0", "cypress": "15.6.0", "eslint": "9.39.1", - "eslint-plugin-react-hooks": "6.1.1", + "eslint-plugin-react-hooks": "7.0.0", "eslint-plugin-react-refresh": "0.4.24", "globals": "16.5.0", "typescript-eslint": "8.46.3", diff --git a/package.json b/package.json index 67a8c0bf4e8..3e109d5bd42 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "eslint-plugin-no-only-tests": "3.3.0", "eslint-plugin-prettier": "5.5.4", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "6.1.1", + "eslint-plugin-react-hooks": "7.0.0", "eslint-plugin-storybook": "9.1.16", "glob": "11.0.3", "globals": "16.5.0", diff --git a/packages/ai/src/components/Input/TestComp.tsx b/packages/ai/src/components/Input/TestComp.tsx new file mode 100644 index 00000000000..65fef7152d2 --- /dev/null +++ b/packages/ai/src/components/Input/TestComp.tsx @@ -0,0 +1,320 @@ +import { Input, InputPropTypes } from '@/components/Input/index.js'; +import { MenuItem } from '@ui5/webcomponents-react'; +import { useRef, useState, useTransition } from 'react'; +import { useFakeStream } from '../../../../../.storybook/utils.js'; + +function useFakeStream(typingDelay = 10, startingDelay = 1500) { + const [value, setValue] = useState(''); + const [transitionIsPending, startTransition] = useTransition(); // active character updates + const [isProcessing, setIsProcessing] = useState(false); // starting delay + const [isTyping, setIsTyping] = useState(false); // actively typing characters + const intervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + + type StartStreamOptions = { + text: string; + onComplete?: (fullText: string) => void; + onProcessingComplete?: () => void; + }; + + const startStream = ({ text, onComplete, onProcessingComplete }: StartStreamOptions) => { + // Stop previous stream and timeout + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + setValue(''); + setIsProcessing(true); + + timeoutRef.current = setTimeout(() => { + setIsProcessing(false); + + if (onProcessingComplete) { + onProcessingComplete(); + } + + setIsTyping(true); + let index = 0; + + intervalRef.current = setInterval(() => { + if (index < text.length) { + const nextChar = text[index]; + index++; + + startTransition(() => { + setValue((prev) => prev + nextChar); + }); + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsTyping(false); + + if (onComplete) { + onComplete(text); + } + } + }, typingDelay); + }, startingDelay); + }; + + const stopStream = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setIsProcessing(false); + setIsTyping(false); + }; + + return { value, transitionIsPending, isProcessing, isTyping, setValue, startStream, stopStream }; +} + +const SAMPLE_TEXTS = { + en: 'Innovation managers lead with creativity.', + bg: 'Мениджърите по иновации водят с креативност.', + de: 'Innovationsmanager führen mit Kreativität.', + expanded: 'They combine creative ideas with strategic action.', + simplified: 'They lead using creativity.', + summarized: 'Driving innovation creatively.', +}; + +const MENU_CONFIG = [ + { + text: 'Regenerate', + action: 'regenerate', + processingLabel: 'Regenerating text...', + completedLabel: 'Regenerated text', + textKey: 'en', + replaces: 'generate', + }, + { + text: 'Fix spelling and grammar', + action: 'fixSpelling', + processingLabel: 'Fixing spelling and grammar...', + completedLabel: 'Fixed spelling and grammar', + textKey: 'en', + startsSection: true, + }, + { + text: 'Rewrite text', + isGroup: true, + children: [ + { + text: 'Simplify', + action: 'simplify', + processingLabel: 'Simplifying text...', + completedLabel: 'Simplified text', + textKey: 'simplified', + isChild: true, + }, + { + text: 'Expand', + action: 'expand', + processingLabel: 'Expanding text...', + completedLabel: 'Expanded text', + textKey: 'expanded', + isChild: true, + }, + { + text: 'Summarize', + action: 'summarize', + processingLabel: 'Summarizing text...', + completedLabel: 'Summarized text', + textKey: 'summarized', + isChild: true, + }, + ], + }, + { + text: 'Translate', + isGroup: true, + children: [ + { + text: 'English', + action: 'translateEN', + processingLabel: 'Translating to English...', + completedLabel: 'Translated to English', + textKey: 'en', + isChild: true, + }, + { + text: 'German', + action: 'translateDE', + processingLabel: 'Translating to German...', + completedLabel: 'Translated to German', + textKey: 'de', + isChild: true, + }, + { + text: 'Bulgarian', + action: 'translateBG', + processingLabel: 'Translating to Bulgarian...', + completedLabel: 'Translated to Bulgarian', + textKey: 'bg', + isChild: true, + }, + ], + }, +]; + +type VersionHistoryItem = { + action: string; + endAction: string; + timestamp: string; + value: string; +}; + +const initialPlaceholder = 'Write your title'; + +function AIInput(props) { + const [versionHistory, setVersionHistory] = useState([]); + const [currentHistoryIndex, setCurrentHistoryIndex] = useState(-1); + const [placeholder, setPlaceholder] = useState(initialPlaceholder); + const hasHistory = versionHistory.length > 0; + const currentActionRef = useRef(''); + const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(50); + + const handleVersionChange: InputPropTypes['onVersionChange'] = (e) => { + setCurrentHistoryIndex((prev) => (e.detail.backwards ? prev - 1 : prev + 1)); + }; + + const handleItemClick: InputPropTypes['onItemClick'] = (e) => { + const { action, processingLabel, textKey } = e.detail.item.dataset; + if (isProcessing || !action) { + return; + } + currentActionRef.current = action; + + let text = SAMPLE_TEXTS[textKey ?? 'en']; + switch (action) { + case 'simplify': { + text = SAMPLE_TEXTS.simplified; + break; + } + case 'expand': { + text = SAMPLE_TEXTS.expanded; + break; + } + case 'summarize': { + text = SAMPLE_TEXTS.expanded; + break; + } + case 'translateEN': { + text = SAMPLE_TEXTS.en; + break; + } + case 'translateDE': { + text = SAMPLE_TEXTS.de; + break; + } + case 'translateBG': { + text = SAMPLE_TEXTS.bg; + break; + } + } + + setPlaceholder(processingLabel); + startStream({ + text, + onProcessingComplete: () => { + setPlaceholder(initialPlaceholder); + }, + onComplete: (fullText) => { + setVersionHistory((prev) => [ + ...prev, + { action, endAction: 'completed', timestamp: new Date().toISOString(), value: fullText }, + ]); + setCurrentHistoryIndex((prev) => prev + 1); + setValue(''); + }, + }); + }; + + const handleStopGeneration: InputPropTypes['onStopGeneration'] = (e) => { + stopStream(); + setVersionHistory((prev) => [ + ...prev, + { + action: currentActionRef.current, + endAction: 'stopped', + timestamp: new Date().toISOString(), + value: e.target.value, + }, + ]); + setCurrentHistoryIndex((prev) => prev + 1); + setValue(''); + }; + + const handleInput: InputPropTypes['onInput'] = (e) => { + setValue(e.target.value); + }; + + return ( + + {hasHistory ? ( + MENU_CONFIG.map((configItem, index) => { + if (configItem.replaces && !hasHistory) { + return null; + } + + if (configItem.isGroup && Array.isArray(configItem.children)) { + return ( + + {configItem.children.map((child) => ( + + ))} + + ); + } + + return ( + + ); + }) + ) : ( + + )} + + } + /> + ); +} diff --git a/packages/ai/src/components/TextArea/TestComp.tsx b/packages/ai/src/components/TextArea/TestComp.tsx new file mode 100644 index 00000000000..859fa0314ef --- /dev/null +++ b/packages/ai/src/components/TextArea/TestComp.tsx @@ -0,0 +1,202 @@ +import { TextArea, TextAreaPropTypes } from '@/components/TextArea/index.js'; +import { Menu, MenuItem, MenuPropTypes } from '@ui5/webcomponents-react'; +import { useEffect, useRef, useState, useTransition } from 'react'; + +type StartStreamOptions = { + text: string; + onComplete?: (fullText: string) => void; + onProcessingComplete?: () => void; +}; +export function useFakeStream(initialValue = '', typingDelay = 10, startingDelay = 1500) { + const [value, setValue] = useState(initialValue); + const [transitionIsPending, startTransition] = useTransition(); // active character updates + const [isProcessing, setIsProcessing] = useState(false); // starting delay + const [isTyping, setIsTyping] = useState(false); // actively typing characters + const intervalRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + const isProcessingRef = useRef(isProcessing); + const isTypingRef = useRef(isTyping); + isProcessingRef.current = isProcessing; + isTypingRef.current = isTyping; + + const startStream = ({ text, onComplete, onProcessingComplete }: StartStreamOptions) => { + // Stop previous stream and timeout + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + setValue(''); + setIsProcessing(true); + + timeoutRef.current = setTimeout(() => { + setIsProcessing(false); + + if (onProcessingComplete) { + onProcessingComplete(); + } + + setIsTyping(true); + let index = 0; + + intervalRef.current = setInterval(() => { + if (index < text.length) { + const nextChar = text[index]; + index++; + + startTransition(() => { + setValue((prev) => prev + nextChar); + }); + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setIsTyping(false); + + if (onComplete) { + onComplete(text); + } + } + }, typingDelay); + }, startingDelay); + }; + + const stopStream = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setIsProcessing(false); + setIsTyping(false); + }; + + return { value, transitionIsPending, isProcessing, isTyping, setValue, startStream, stopStream }; +} + +export function useStopStreamByESC(loading: boolean, stopStream: () => void, onStop?: () => void) { + const loadingRef = useRef(loading); + loadingRef.current = loading; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && loadingRef.current) { + stopStream(); + if (onStop) { + onStop(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [stopStream, onStop]); +} + +const SAMPLE_TEXT = + 'Innovation managers operate with both creativity and business acumen, driving initiatives that cultivate an innovation-friendly culture, streamline the execution of new ideas, and ultimately unlock value for the organization and its customers.'; + +type VersionHistoryItem = { + action: string; + endAction: string; + timestamp: string; + value: string; + promptDescription: string; +}; + +function AITextArea(props) { + const { value, isTyping, isProcessing, setValue, startStream, stopStream } = useFakeStream(); + const [versionHistory, setVersionHistory] = useState([]); + const [currentHistoryIndex, setCurrentHistoryIndex] = useState(-1); + const [promptDescription, setPromptDescription] = useState(''); + const currentActionRef = useRef(''); + const isLoading = isProcessing || isTyping; + + const handleItemClick: MenuPropTypes['onItemClick'] = (e) => { + const { action } = e.detail.item.dataset; + if (isProcessing || !action) { + return; + } + currentActionRef.current = action; + setPromptDescription('Generating text...'); + startStream({ + text: SAMPLE_TEXT, + onComplete: (fullText) => { + setVersionHistory((prev) => [ + ...prev, + { + action, + endAction: 'completed', + timestamp: new Date().toISOString(), + value: fullText, + promptDescription: 'Generated text', + }, + ]); + setCurrentHistoryIndex((prev) => prev + 1); + setValue(''); + setPromptDescription(''); + }, + }); + }; + + const handleStopGeneration: TextAreaPropTypes['onStopGeneration'] = () => { + stopStream(); + handleStop(); + }; + + const handleStop = () => { + setVersionHistory((prev) => [ + ...prev, + { + action: currentActionRef.current, + endAction: 'stopped', + timestamp: new Date().toISOString(), + value: value, + promptDescription: 'Generated text (stopped)', + }, + ]); + setCurrentHistoryIndex((prev) => prev + 1); + setValue(''); + setPromptDescription(''); + }; + + const handleVersionChange: TextAreaPropTypes['onVersionChange'] = (e) => { + setCurrentHistoryIndex((prev) => (e.detail.backwards ? prev - 1 : prev + 1)); + setValue(''); + }; + + const handleInput: TextAreaPropTypes['onInput'] = (e) => { + setValue(e.target.value); + }; + + useStopStreamByESC(isLoading, stopStream, handleStop); + + return ( +