NumberWithUnit.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import React, { useState } from 'react';
  2. import { InputNumber, Select, Space, SpaceProps } from 'antd';
  3. import type { InputNumberProps } from 'antd/es/input-number';
  4. import type { SelectProps } from 'antd/es/select';
  5. const { Option } = Select;
  6. export interface NumberUnitOption {
  7. label: React.ReactNode;
  8. value: string;
  9. }
  10. export interface NumberUnitValue {
  11. number?: number;
  12. unit?: string;
  13. }
  14. export type NumberWithUnitProps = SpaceProps & {
  15. value?: NumberUnitValue;
  16. onChange?: (next: NumberUnitValue) => void;
  17. numberClassName?: string;
  18. unitClassName?: string;
  19. className?: string;
  20. options: NumberUnitOption[];
  21. defaultUnit?: string;
  22. defaultNumber?: number;
  23. numberProps?: Partial<InputNumberProps>;
  24. selectProps?: Partial<SelectProps>;
  25. };
  26. const NumberWithUnit: React.FC<NumberWithUnitProps> = ({
  27. value = {},
  28. onChange,
  29. numberClassName,
  30. unitClassName,
  31. className,
  32. options,
  33. defaultUnit,
  34. defaultNumber,
  35. numberProps,
  36. selectProps,
  37. onBlur,
  38. ...rest // ← 这里会收到、style 等
  39. }) => {
  40. const unit = value?.unit ?? defaultUnit ?? undefined;
  41. const [isComposing, setIsComposing] = useState(false);
  42. const triggerChange = (changed: Partial<NumberUnitValue>) => {
  43. onChange?.({ ...value, ...changed });
  44. };
  45. // 处理键盘输入 - 阻止非数字字符的输入
  46. const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  47. // 如果正在使用输入法,不拦截
  48. if (isComposing) return;
  49. // 允许的功能键
  50. const allowedKeys = [
  51. 'Backspace',
  52. 'Delete',
  53. 'ArrowLeft',
  54. 'ArrowRight',
  55. 'ArrowUp',
  56. 'ArrowDown',
  57. 'Tab',
  58. 'Enter',
  59. 'Escape',
  60. 'Home',
  61. 'End',
  62. ];
  63. // 如果是功能键,允许
  64. if (allowedKeys.includes(e.key)) return;
  65. // 如果是 Ctrl/Cmd 组合键(如 Ctrl+A, Ctrl+C, Ctrl+V),允许
  66. if (e.ctrlKey || e.metaKey) return;
  67. // 如果不是数字,阻止输入
  68. if (!/^\d$/.test(e.key)) {
  69. e.preventDefault();
  70. }
  71. };
  72. // 处理粘贴 - 过滤粘贴内容中的非数字字符
  73. const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
  74. e.preventDefault();
  75. // 获取剪贴板内容
  76. const pastedText = e.clipboardData.getData('text');
  77. // 只保留数字
  78. const cleanedText = pastedText.replace(/\D/g, '');
  79. if (cleanedText) {
  80. const numValue = parseInt(cleanedText, 10);
  81. triggerChange({ number: numValue });
  82. }
  83. };
  84. // 处理输入法开始
  85. const handleCompositionStart = () => {
  86. setIsComposing(true);
  87. };
  88. // 处理输入法结束 - 过滤输入法输入的非数字字符
  89. const handleCompositionEnd = (
  90. e: React.CompositionEvent<HTMLInputElement>
  91. ) => {
  92. setIsComposing(false);
  93. // 获取输入的内容并过滤
  94. const inputValue = e.currentTarget.value;
  95. const cleanedValue = inputValue.replace(/\D/g, '');
  96. if (cleanedValue) {
  97. const numValue = parseInt(cleanedValue, 10);
  98. triggerChange({ number: numValue });
  99. } else {
  100. // 如果过滤后没有有效数字,清空
  101. triggerChange({ number: undefined });
  102. }
  103. };
  104. return (
  105. <>
  106. <style>{`
  107. .my-space .ant-space-item:nth-child(1) {
  108. width: 70%;
  109. }
  110. .my-space .ant-space-item:nth-child(2) {
  111. width: 30%;
  112. }
  113. `}</style>
  114. <Space {...rest} className={`${className} my-space`}>
  115. <InputNumber
  116. className={numberClassName}
  117. value={value?.number}
  118. onChange={(n) =>
  119. triggerChange({ number: typeof n === 'number' ? n : undefined })
  120. }
  121. min={0}
  122. precision={0}
  123. parser={(value) => value?.replace(/\D/g, '') || ''}
  124. keyboard={true}
  125. onKeyDown={handleKeyDown}
  126. onPaste={handlePaste}
  127. onCompositionStart={handleCompositionStart}
  128. onCompositionEnd={handleCompositionEnd}
  129. {...numberProps}
  130. defaultValue={defaultNumber}
  131. onBlur={onBlur}
  132. />
  133. <Select
  134. className={unitClassName}
  135. value={unit}
  136. onChange={(u) => triggerChange({ unit: u })}
  137. {...selectProps}
  138. >
  139. {options.map((opt) => (
  140. <Option key={opt.value} value={opt.value}>
  141. {opt.label}
  142. </Option>
  143. ))}
  144. </Select>
  145. </Space>
  146. </>
  147. );
  148. };
  149. export default NumberWithUnit;