0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

AdminX handling '/' in textfields vs. searchbar shortcut (#18283)

refs. https://github.com/TryGhost/Product/issues/3349

- We've added a keyboard shortcut '/' to focus in on the searchfield in
AdminX. However this didn't handle the case when the focus is already in
a textfield and when tried to enter e.g. "https://", then at the '/'
character it focused on the searchfield.

---------

Co-authored-by: Ronald Langeveld <hi@ronaldlangeveld.com>
This commit is contained in:
Peter Zimon 2023-09-25 15:22:10 +03:00 committed by GitHub
parent fac61e4d30
commit c4773b946b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 124 additions and 19 deletions

View file

@ -2,6 +2,8 @@ import React from 'react';
import '../src/styles/demo.css';
import type { Preview } from "@storybook/react";
import '../src/admin-x-ds/providers/DesignSystemProvider';
import DesignSystemProvider from '../src/admin-x-ds/providers/DesignSystemProvider';
const preview: Preview = {
parameters: {
@ -29,7 +31,9 @@ const preview: Preview = {
background: (scheme === 'dark' ? '#131416' : '')
}}>
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
<Story />
<DesignSystemProvider>
<Story />
</DesignSystemProvider>
</div>);
},
],

View file

@ -1,3 +1,4 @@
import DesignSystemProvider from './admin-x-ds/providers/DesignSystemProvider';
import GlobalDataProvider from './components/providers/GlobalDataProvider';
import MainContent from './MainContent';
import NiceModal from '@ebay/nice-modal-react';
@ -53,16 +54,18 @@ function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, t
<GlobalDataProvider>
<RoutingProvider externalNavigate={externalNavigate}>
<GlobalDirtyStateProvider>
<div className={appClassName} id="admin-x-root" style={{
height: '100vh',
width: '100%'
}}
>
<Toaster />
<NiceModal.Provider>
<MainContent />
</NiceModal.Provider>
</div>
<DesignSystemProvider>
<div className={appClassName} id="admin-x-root" style={{
height: '100vh',
width: '100%'
}}
>
<Toaster />
<NiceModal.Provider>
<MainContent />
</NiceModal.Provider>
</div>
</DesignSystemProvider>
</GlobalDirtyStateProvider>
</RoutingProvider>
</GlobalDataProvider>

View file

@ -5,6 +5,7 @@ import React, {forwardRef, useEffect, useId, useRef, useState} from 'react';
import clsx from 'clsx';
import {EditorView} from '@codemirror/view';
import {Extension} from '@codemirror/state';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export interface CodeEditorProps extends Omit<ReactCodeMirrorProps, 'value' | 'onChange' | 'extensions'> {
title?: string;
@ -43,6 +44,15 @@ const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function
const sizeRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(100);
const [resolvedExtensions, setResolvedExtensions] = React.useState<Extension[] | null>(null);
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = () => {
setFocusState(false);
};
useEffect(() => {
Promise.all(extensions).then(setResolvedExtensions);
@ -76,7 +86,9 @@ const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function
height={height === 'full' ? '100%' : height}
theme={theme}
value={value}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
{...props}
/>
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black'} htmlFor={id} useLabelTag={true}>{title}</Heading>}

View file

@ -1,5 +1,6 @@
import * as Sentry from '@sentry/react';
import React, {ReactNode, Suspense, useCallback, useMemo} from 'react';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export interface HtmlEditorProps {
value?: string
@ -107,6 +108,14 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
}
console.error(error); // eslint-disable-line
}, []);
const {setFocusState} = useFocusContext();
const handleBlur = () => {
if (onBlur) {
onBlur();
}
setFocusState(false);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
@ -154,7 +163,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
placeholderClassName='koenig-lexical-editor-input-placeholder'
placeholderText={placeholder}
singleParagraph={true}
onBlur={onBlur}
onBlur={handleBlur}
>
<koenig.HtmlOutputPlugin html={value} setHtml={handleSetHtml} />
</koenig.KoenigComposableEditor>
@ -174,9 +183,13 @@ const HtmlEditor: React.FC<HtmlEditorProps & {
editorUrl: config.editor.url,
editorVersion: config.editor.version
}), [config.editor.url, config.editor.version]);
const {setFocusState} = useFocusContext();
// this is not ideal, we need to add a focus plugin inside the Koenig editor package to handle this properly
const handleFocus = () => {
setFocusState(true);
};
return <div className={className || 'w-full'}>
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit">
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit" onFocus={handleFocus}>
<ErrorHandler>
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
<KoenigWrapper {...props} editor={editorResource} />

View file

@ -3,6 +3,7 @@ import React, {useId} from 'react';
import Heading from '../Heading';
import Hint from '../Hint';
import clsx from 'clsx';
import {useFocusContext} from '../../providers/DesignSystemProvider';
type ResizeOptions = 'both' | 'vertical' | 'horizontal' | 'none';
type FontStyles = 'sans' | 'mono';
@ -40,6 +41,15 @@ const TextArea: React.FC<TextAreaProps> = ({
...props
}) => {
const id = useId();
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = () => {
setFocusState(false);
};
let styles = clsx(
'peer order-2 rounded-sm border px-3 py-2 dark:text-white',
@ -78,7 +88,9 @@ const TextArea: React.FC<TextAreaProps> = ({
placeholder={placeholder}
rows={rows}
value={value}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
{...props}>
</textarea>
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black dark:!text-grey-300 dark:peer-focus:!text-white'} htmlFor={id} useLabelTag={true}>{title}</Heading>}

View file

@ -10,7 +10,11 @@ const meta = {
title: 'Global / Form / Textfield',
component: TextField,
tags: ['autodocs'],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (
<div style={{maxWidth: '400px'}}>
{_story()}
</div>
)],
argTypes: {
hint: {
control: 'text'

View file

@ -2,6 +2,7 @@ import Heading from '../Heading';
import Hint from '../Hint';
import React, {useId} from 'react';
import clsx from 'clsx';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export type TextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
inputRef?: React.RefObject<HTMLInputElement>;
@ -50,6 +51,18 @@ const TextField: React.FC<TextFieldProps> = ({
...props
}) => {
const id = useId();
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (onBlur) {
onBlur(e);
}
setFocusState(false);
};
const disabledBorderClasses = border && 'border-grey-300 dark:border-grey-900';
const enabledBorderClasses = border && 'border-grey-500 hover:border-grey-700 focus:border-black dark:border-grey-800 dark:hover:border-grey-700 dark:focus:border-grey-500';
@ -77,8 +90,9 @@ const TextField: React.FC<TextFieldProps> = ({
placeholder={placeholder}
type={type}
value={value}
onBlur={onBlur}
onBlur={handleBlur}
onChange={onChange}
onFocus={handleFocus}
{...props} />;
if (rightPlaceholder) {
@ -120,7 +134,7 @@ const TextField: React.FC<TextFieldProps> = ({
</div>
);
} else {
return field;
return (field);
}
};

View file

@ -1,6 +1,7 @@
import React, {useEffect, useState} from 'react';
import TextField, {TextFieldProps} from './TextField';
import validator from 'validator';
import {useFocusContext} from '../../providers/DesignSystemProvider';
const formatUrl = (value: string, baseUrl?: string) => {
let url = value.trim();
@ -103,6 +104,7 @@ const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
onChange: (value: string) => void;
}> = ({baseUrl, value, transformPathWithoutSlash, onChange, ...props}) => {
const [displayedUrl, setDisplayedUrl] = useState('');
const {setFocusState} = useFocusContext();
useEffect(() => {
setDisplayedUrl(formatUrl(value || '', baseUrl).display);
@ -123,6 +125,7 @@ const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
setDisplayedUrl(urls.display);
onChange(urls.save);
setFocusState(false);
};
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
@ -132,6 +135,7 @@ const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
}
props.onFocus?.(e);
setFocusState(true);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {

View file

@ -0,0 +1,37 @@
// FocusContext.tsx
import React, {createContext, useContext, useState} from 'react';
interface DesignSystemContextType {
isAnyTextFieldFocused: boolean;
setFocusState: (value: boolean) => void;
}
const DesignSystemContext = createContext<DesignSystemContextType | undefined>(undefined);
export const useFocusContext = () => {
const context = useContext(DesignSystemContext);
if (!context) {
throw new Error('useFocusContext must be used within a FocusProvider');
}
return context;
};
interface DesignSystemProviderProps {
children: React.ReactNode;
}
const DesignSystemProvider: React.FC<DesignSystemProviderProps> = ({children}) => {
const [isAnyTextFieldFocused, setIsAnyTextFieldFocused] = useState(false);
const setFocusState = (value: boolean) => {
setIsAnyTextFieldFocused(value);
};
return (
<DesignSystemContext.Provider value={{isAnyTextFieldFocused, setFocusState}}>
{children}
</DesignSystemContext.Provider>
);
};
export default DesignSystemProvider;

View file

@ -12,6 +12,7 @@ import {searchKeywords as generalSearchKeywords} from './settings/general/Genera
import {getSettingValues} from '../api/settings';
import {searchKeywords as membershipSearchKeywords} from './settings/membership/MembershipSettings';
import {searchKeywords as siteSearchKeywords} from './settings/site/SiteSettings';
import {useFocusContext} from '../admin-x-ds/providers/DesignSystemProvider';
import {useGlobalData} from './providers/GlobalDataProvider';
import {useSearch} from './providers/ServiceProvider';
@ -19,11 +20,12 @@ const Sidebar: React.FC = () => {
const {filter, setFilter} = useSearch();
const {updateRoute} = useRouting();
const searchInputRef = useRef<HTMLInputElement | null>(null);
const {isAnyTextFieldFocused} = useFocusContext();
// Focus in on search field when pressing CMD+K/CTRL+K
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === '/') {
if (e.key === '/' && !isAnyTextFieldFocused) {
e?.preventDefault();
if (searchInputRef.current) {
searchInputRef.current.focus();
@ -34,7 +36,7 @@ const Sidebar: React.FC = () => {
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}, []);
});
// Auto-focus on searchfield on page load
useEffect(() => {