4599-feat(front): Add Copy Button to Floating Inputs (#4789)
Closes #4599 **Changes:** - Added copy button to floating inputs of Text, Number, Phone, Link and Email fields. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Weiko <corentin@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -0,0 +1,37 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconCopy } from 'twenty-ui';
|
||||
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
padding: 0 ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
export type LightCopyIconButtonProps = {
|
||||
copyText: string;
|
||||
};
|
||||
|
||||
export const LightCopyIconButton = ({ copyText }: LightCopyIconButtonProps) => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledButtonContainer>
|
||||
<LightIconButton
|
||||
className="copy-button"
|
||||
Icon={IconCopy}
|
||||
onClick={() => {
|
||||
enqueueSnackBar('Text copied to clipboard', {
|
||||
variant: 'success',
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
});
|
||||
navigator.clipboard.writeText(copyText);
|
||||
}}
|
||||
aria-label="Copy to Clipboard"
|
||||
/>
|
||||
</StyledButtonContainer>
|
||||
);
|
||||
};
|
||||
@ -4,6 +4,7 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
|
||||
import { useEmailField } from '../../../hooks/useEmailField';
|
||||
@ -104,7 +105,7 @@ const meta: Meta = {
|
||||
onTab: { control: false },
|
||||
onShiftTab: { control: false },
|
||||
},
|
||||
decorators: [clearMocksDecorator],
|
||||
decorators: [clearMocksDecorator, SnackBarDecorator],
|
||||
parameters: {
|
||||
clearMocks: true,
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
|
||||
import { useNumberField } from '../../../hooks/useNumberField';
|
||||
@ -105,7 +106,7 @@ const meta: Meta = {
|
||||
onTab: { control: false },
|
||||
onShiftTab: { control: false },
|
||||
},
|
||||
decorators: [clearMocksDecorator],
|
||||
decorators: [clearMocksDecorator, SnackBarDecorator],
|
||||
parameters: {
|
||||
clearMocks: true,
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
|
||||
import { usePhoneField } from '../../../hooks/usePhoneField';
|
||||
@ -105,7 +106,7 @@ const meta: Meta = {
|
||||
onTab: { control: false },
|
||||
onShiftTab: { control: false },
|
||||
},
|
||||
decorators: [clearMocksDecorator],
|
||||
decorators: [clearMocksDecorator, SnackBarDecorator],
|
||||
parameters: {
|
||||
clearMocks: true,
|
||||
},
|
||||
|
||||
@ -4,6 +4,7 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
|
||||
|
||||
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
|
||||
import { FieldMetadataType } from '~/generated/graphql';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
|
||||
import { FieldContextProvider } from '../../../__stories__/FieldContextProvider';
|
||||
import { useTextField } from '../../../hooks/useTextField';
|
||||
@ -104,7 +105,7 @@ const meta: Meta = {
|
||||
onTab: { control: false },
|
||||
onShiftTab: { control: false },
|
||||
},
|
||||
decorators: [clearMocksDecorator],
|
||||
decorators: [clearMocksDecorator, SnackBarDecorator],
|
||||
parameters: {
|
||||
clearMocks: true,
|
||||
},
|
||||
|
||||
@ -6,6 +6,7 @@ import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const useRegisterInputEvents = <T>({
|
||||
inputRef,
|
||||
copyRef,
|
||||
inputValue,
|
||||
onEscape,
|
||||
onEnter,
|
||||
@ -15,6 +16,7 @@ export const useRegisterInputEvents = <T>({
|
||||
hotkeyScope,
|
||||
}: {
|
||||
inputRef: React.RefObject<any>;
|
||||
copyRef?: React.RefObject<any>;
|
||||
inputValue: T;
|
||||
onEscape: (inputValue: T) => void;
|
||||
onEnter: (inputValue: T) => void;
|
||||
@ -24,10 +26,9 @@ export const useRegisterInputEvents = <T>({
|
||||
hotkeyScope: string;
|
||||
}) => {
|
||||
useListenClickOutside({
|
||||
refs: [inputRef],
|
||||
refs: [inputRef, copyRef].filter(isDefined),
|
||||
callback: (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
onClickOutside?.(event, inputValue);
|
||||
},
|
||||
enabled: isDefined(onClickOutside),
|
||||
|
||||
@ -3,11 +3,13 @@ import styled from '@emotion/styled';
|
||||
import { OVERLAY_BACKGROUND } from '@/ui/theme/constants/OverlayBackground';
|
||||
|
||||
const StyledFieldInputOverlay = styled.div`
|
||||
align-items: center;
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
${OVERLAY_BACKGROUND}
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: space-between;
|
||||
margin: -1px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledFieldTextAreaOverlay = styled.div`
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
align-items: center;
|
||||
backdrop-filter: blur(8px);
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
height: 32px;
|
||||
margin: -1px;
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ReactPhoneNumberInput from 'react-phone-number-input';
|
||||
import styled from '@emotion/styled';
|
||||
import { TEXT_INPUT_STYLE } from 'twenty-ui';
|
||||
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
|
||||
|
||||
@ -13,14 +15,17 @@ const StyledContainer = styled.div`
|
||||
border: none;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: start;
|
||||
`;
|
||||
|
||||
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
height: 32px;
|
||||
${TEXT_INPUT_STYLE}
|
||||
padding: 0;
|
||||
|
||||
.PhoneInputInput {
|
||||
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||
@ -43,6 +48,14 @@ const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
height: 12px;
|
||||
}
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(8)});
|
||||
`;
|
||||
|
||||
const StyledLightIconButtonContainer = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
export type PhoneInputProps = {
|
||||
@ -56,6 +69,7 @@ export type PhoneInputProps = {
|
||||
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
|
||||
onChange?: (newText: string) => void;
|
||||
hotkeyScope: string;
|
||||
copyButton?: boolean;
|
||||
};
|
||||
|
||||
export const PhoneInput = ({
|
||||
@ -68,10 +82,12 @@ export const PhoneInput = ({
|
||||
onClickOutside,
|
||||
hotkeyScope,
|
||||
onChange,
|
||||
copyButton = true,
|
||||
}: PhoneInputProps) => {
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(value);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const copyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue);
|
||||
@ -84,6 +100,7 @@ export const PhoneInput = ({
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
copyRef: copyRef,
|
||||
inputValue: internalValue ?? '',
|
||||
onEnter,
|
||||
onEscape,
|
||||
@ -104,6 +121,11 @@ export const PhoneInput = ({
|
||||
withCountryCallingCode={true}
|
||||
countrySelectComponent={PhoneCountryPickerDropdownButton}
|
||||
/>
|
||||
{copyButton && (
|
||||
<StyledLightIconButtonContainer ref={copyRef}>
|
||||
<LightCopyIconButton copyText={value} />
|
||||
</StyledLightIconButtonContainer>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
@ -20,19 +21,35 @@ export type TextAreaInputProps = {
|
||||
hotkeyScope: string;
|
||||
onChange?: (newText: string) => void;
|
||||
maxRows?: number;
|
||||
copyButton?: boolean;
|
||||
};
|
||||
|
||||
const StyledTextArea = styled(TextareaAutosize)`
|
||||
${TEXT_INPUT_STYLE}
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
resize: none;
|
||||
width: calc(100% - ${({ theme }) => theme.spacing(7)});
|
||||
`;
|
||||
|
||||
const StyledTextAreaContainer = styled.div`
|
||||
box-shadow: ${({ theme }) => theme.boxShadow.strong};
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(1)};
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
`;
|
||||
|
||||
const StyledLightIconButtonContainer = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
export const TextAreaInput = ({
|
||||
disabled,
|
||||
className,
|
||||
@ -47,6 +64,7 @@ export const TextAreaInput = ({
|
||||
onClickOutside,
|
||||
onChange,
|
||||
maxRows,
|
||||
copyButton = true,
|
||||
}: TextAreaInputProps) => {
|
||||
const [internalText, setInternalText] = useState(value);
|
||||
|
||||
@ -56,6 +74,7 @@ export const TextAreaInput = ({
|
||||
};
|
||||
|
||||
const wrapperRef = useRef<HTMLTextAreaElement>(null);
|
||||
const copyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(wrapperRef.current)) {
|
||||
@ -68,6 +87,7 @@ export const TextAreaInput = ({
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
copyRef: copyRef,
|
||||
inputValue: internalText,
|
||||
onEnter,
|
||||
onEscape,
|
||||
@ -78,15 +98,22 @@ export const TextAreaInput = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledTextArea
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
ref={wrapperRef}
|
||||
onChange={handleChange}
|
||||
autoFocus={autoFocus}
|
||||
value={internalText}
|
||||
maxRows={maxRows}
|
||||
/>
|
||||
<StyledTextAreaContainer>
|
||||
<StyledTextArea
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
ref={wrapperRef}
|
||||
onChange={handleChange}
|
||||
autoFocus={autoFocus}
|
||||
value={internalText}
|
||||
maxRows={maxRows}
|
||||
/>
|
||||
{copyButton && (
|
||||
<StyledLightIconButtonContainer ref={copyRef}>
|
||||
<LightCopyIconButton copyText={internalText} />
|
||||
</StyledLightIconButtonContainer>
|
||||
)}
|
||||
</StyledTextAreaContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
|
||||
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle';
|
||||
|
||||
@ -21,6 +22,7 @@ type TextInputProps = {
|
||||
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
|
||||
hotkeyScope: string;
|
||||
onChange?: (newText: string) => void;
|
||||
copyButton?: boolean;
|
||||
};
|
||||
|
||||
export const TextInput = ({
|
||||
@ -34,10 +36,12 @@ export const TextInput = ({
|
||||
onShiftTab,
|
||||
onClickOutside,
|
||||
onChange,
|
||||
copyButton = true,
|
||||
}: TextInputProps) => {
|
||||
const [internalText, setInternalText] = useState(value);
|
||||
|
||||
const wrapperRef = useRef<HTMLInputElement>(null);
|
||||
const copyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setInternalText(event.target.value);
|
||||
@ -50,6 +54,7 @@ export const TextInput = ({
|
||||
|
||||
useRegisterInputEvents({
|
||||
inputRef: wrapperRef,
|
||||
copyRef: copyRef,
|
||||
inputValue: internalText,
|
||||
onEnter,
|
||||
onEscape,
|
||||
@ -60,13 +65,20 @@ export const TextInput = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledTextInput
|
||||
autoComplete="off"
|
||||
ref={wrapperRef}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
autoFocus={autoFocus}
|
||||
value={internalText}
|
||||
/>
|
||||
<>
|
||||
<StyledTextInput
|
||||
autoComplete="off"
|
||||
ref={wrapperRef}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
autoFocus={autoFocus}
|
||||
value={internalText}
|
||||
/>
|
||||
{copyButton && (
|
||||
<div ref={copyRef}>
|
||||
<LightCopyIconButton copyText={internalText} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -106,7 +106,7 @@ export const LightIconButton = ({
|
||||
active={active}
|
||||
title={title}
|
||||
>
|
||||
{Icon && <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />}
|
||||
{Icon && <Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user