editableTabTitle.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import {useEffect, useMemo, useRef, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {motion} from 'framer-motion';
  5. import {GrowingInput} from 'sentry/components/growingInput';
  6. import {Tooltip} from 'sentry/components/tooltip';
  7. interface EditableTabTitleProps {
  8. isEditing: boolean;
  9. isSelected: boolean;
  10. label: string;
  11. onChange: (newLabel: string) => void;
  12. setIsEditing: (isEditing: boolean) => void;
  13. }
  14. function EditableTabTitle({
  15. label,
  16. onChange,
  17. isEditing,
  18. isSelected,
  19. setIsEditing,
  20. }: EditableTabTitleProps) {
  21. const [inputValue, setInputValue] = useState(label);
  22. useEffect(() => {
  23. setInputValue(label);
  24. }, [label]);
  25. const theme = useTheme();
  26. const inputRef = useRef<HTMLInputElement>(null);
  27. const isEmpty = !inputValue.trim();
  28. const memoizedStyles = useMemo(() => {
  29. return {fontWeight: isSelected ? theme.fontWeightBold : theme.fontWeightNormal};
  30. }, [isSelected, theme.fontWeightBold, theme.fontWeightNormal]);
  31. const handleOnBlur = (e: React.FocusEvent<HTMLInputElement, Element>) => {
  32. e.stopPropagation();
  33. e.preventDefault();
  34. const trimmedInputValue = inputValue.trim();
  35. if (!isEditing) {
  36. return;
  37. }
  38. if (isEmpty) {
  39. setInputValue(label);
  40. setIsEditing(false);
  41. return;
  42. }
  43. if (trimmedInputValue !== label) {
  44. onChange(trimmedInputValue);
  45. setInputValue(trimmedInputValue);
  46. }
  47. setIsEditing(false);
  48. };
  49. const handleOnKeyDown = (e: React.KeyboardEvent) => {
  50. if (e.key === 'Enter') {
  51. inputRef.current?.blur();
  52. }
  53. if (e.key === 'Escape') {
  54. setInputValue(label.trim());
  55. setIsEditing(false);
  56. }
  57. if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === ' ') {
  58. e.stopPropagation();
  59. }
  60. };
  61. useEffect(() => {
  62. if (isEditing) {
  63. requestAnimationFrame(() => {
  64. inputRef.current?.focus();
  65. inputRef.current?.select();
  66. });
  67. } else {
  68. inputRef.current?.blur();
  69. }
  70. }, [isEditing, inputRef]);
  71. const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  72. setInputValue(e.target.value);
  73. };
  74. return (
  75. <Tooltip title={label} disabled={isEditing} showOnlyOnOverflow skipWrapper>
  76. <motion.div layout="position" transition={{duration: 0.2}}>
  77. {isSelected && isEditing ? (
  78. <StyledGrowingInput
  79. value={inputValue}
  80. onChange={handleOnChange}
  81. onKeyDown={handleOnKeyDown}
  82. onBlur={handleOnBlur}
  83. ref={inputRef}
  84. style={memoizedStyles}
  85. isEditing={isEditing}
  86. maxLength={128}
  87. onPointerDown={e => {
  88. e.stopPropagation();
  89. if (!isEditing) {
  90. e.preventDefault();
  91. }
  92. }}
  93. onMouseDown={e => {
  94. e.stopPropagation();
  95. if (!isEditing) {
  96. e.preventDefault();
  97. }
  98. }}
  99. />
  100. ) : (
  101. <UnselectedTabTitle
  102. onDoubleClick={() => setIsEditing(true)}
  103. onPointerDown={e => {
  104. if (isSelected) {
  105. e.stopPropagation();
  106. e.preventDefault();
  107. }
  108. }}
  109. onMouseDown={e => {
  110. if (isSelected) {
  111. e.stopPropagation();
  112. e.preventDefault();
  113. }
  114. }}
  115. isSelected={isSelected}
  116. >
  117. {label}
  118. </UnselectedTabTitle>
  119. )}
  120. </motion.div>
  121. </Tooltip>
  122. );
  123. }
  124. export default EditableTabTitle;
  125. const UnselectedTabTitle = styled('div')<{isSelected: boolean}>`
  126. height: 20px;
  127. max-width: ${p => (p.isSelected ? '325px' : '310px')};
  128. white-space: nowrap;
  129. overflow: hidden;
  130. text-overflow: ellipsis;
  131. padding-right: 1px;
  132. cursor: pointer;
  133. `;
  134. const StyledGrowingInput = styled(GrowingInput)<{
  135. isEditing: boolean;
  136. }>`
  137. position: relative;
  138. border: none;
  139. margin: 0;
  140. padding: 0;
  141. background: transparent;
  142. min-height: 0px;
  143. height: 20px;
  144. border-radius: 0px;
  145. text-overflow: ellipsis;
  146. cursor: text;
  147. max-width: 325px;
  148. &,
  149. &:focus,
  150. &:active,
  151. &:hover {
  152. box-shadow: none;
  153. }
  154. `;