TreeNameInput.tsx 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. import type { FC, InputHTMLAttributes } from 'react';
  2. import { useState } from 'react';
  3. import { useTranslation } from 'next-i18next';
  4. import { debounce } from 'throttle-debounce';
  5. import type { InputValidationResult } from '~/client/util/use-input-validator';
  6. import {
  7. useInputValidator,
  8. ValidationTarget,
  9. } from '~/client/util/use-input-validator';
  10. import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
  11. import type { TreeItemToolProps } from '../interfaces';
  12. type TreeNameInputProps = {
  13. /**
  14. * Props from headless-tree's getRenameInputProps()
  15. * Includes value, onChange, onBlur, onKeyDown, ref
  16. */
  17. inputProps: InputHTMLAttributes<HTMLInputElement> & {
  18. ref?: (r: HTMLInputElement | null) => void;
  19. };
  20. /**
  21. * Validation function for the input value
  22. */
  23. validateName?: (name: string) => InputValidationResult | null;
  24. /**
  25. * Placeholder text
  26. */
  27. placeholder?: string;
  28. /**
  29. * Additional CSS class
  30. */
  31. className?: string;
  32. };
  33. /**
  34. * Unified input component for tree item name editing (rename/create)
  35. * Uses headless-tree's renamingFeature for keyboard handling (Enter/Escape)
  36. */
  37. const TreeNameInputSubstance: FC<TreeNameInputProps> = ({
  38. inputProps,
  39. validateName,
  40. placeholder,
  41. className,
  42. }) => {
  43. const [validationResult, setValidationResult] =
  44. useState<InputValidationResult | null>(null);
  45. const validate = debounce(300, (value: string) => {
  46. setValidationResult(validateName?.(value) ?? null);
  47. });
  48. const isInvalid = validationResult != null;
  49. return (
  50. <div className={`${className ?? ''} flex-fill`}>
  51. <input
  52. {...inputProps}
  53. onChange={(e) => {
  54. inputProps.onChange?.(e);
  55. validate(e.target.value);
  56. }}
  57. onBlur={(e) => {
  58. setValidationResult(null);
  59. inputProps.onBlur?.(e);
  60. }}
  61. type="text"
  62. placeholder={placeholder}
  63. className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
  64. />
  65. {isInvalid && (
  66. <div className="invalid-feedback d-block my-1">
  67. {validationResult.message}
  68. </div>
  69. )}
  70. </div>
  71. );
  72. };
  73. /**
  74. * Tree item name input component for rename/create mode
  75. * Wraps TreeNameInputSubstance with headless-tree's item props
  76. */
  77. export const TreeNameInput: FC<TreeItemToolProps> = ({ item }) => {
  78. const { t } = useTranslation();
  79. const inputValidator = useInputValidator(ValidationTarget.PAGE);
  80. const validateName = (name: string): InputValidationResult | null => {
  81. return inputValidator(name) ?? null;
  82. };
  83. // Show placeholder only for create mode
  84. const isCreating = item.getId() === CREATING_PAGE_VIRTUAL_ID;
  85. const placeholder = isCreating ? t('Input page name') : undefined;
  86. return (
  87. <TreeNameInputSubstance
  88. inputProps={item.getRenameInputProps()}
  89. validateName={validateName}
  90. placeholder={placeholder}
  91. className="flex-grow-1"
  92. />
  93. );
  94. };