diff --git a/src/components/TextField/TextField.stories.tsx b/src/components/TextField/TextField.stories.tsx index 4ce403e..7ee2c9c 100644 --- a/src/components/TextField/TextField.stories.tsx +++ b/src/components/TextField/TextField.stories.tsx @@ -44,7 +44,10 @@ const meta: Meta = { options: accents, control: {type: "select"}, }, - + type: { + options: ["text", "number", "password"], + control: {type: "select"}, + }, label: hideInTable, value: hideInTable, defaultValue: hideInTable, @@ -61,6 +64,8 @@ export const TextField: StoryObj = { placeholder: "Enter text", disabled: false, fullWidth: false, + strict: false, + type: "text", before: "🔍", after: "🔑", }, diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 72b0d9c..6cf2e68 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,12 +1,14 @@ import React, { - ChangeEventHandler, + ChangeEvent, ComponentProps, forwardRef, + KeyboardEvent, memo, ReactNode, useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from "react"; @@ -16,7 +18,8 @@ import classnames from "classnames"; import {cloneOrCreateElement} from "../../utils"; import {useComponentProps} from "../../providers"; -import {TextFieldVariant, TextFieldSize, TextFieldRadius, TextFieldAccent} from "./types"; +import {normalizeNumberInput} from "./utils"; +import {TextFieldAccent, TextFieldRadius, TextFieldSize, TextFieldVariant} from "./types"; import styles from "./text-field.module.scss"; @@ -44,6 +47,7 @@ export interface TextFieldProps extends ComponentProps<"input"> { inputClassName?: string; afterClassName?: string; beforeClassName?: string; + strict?: boolean; } const TextField = forwardRef((props, ref) => { @@ -55,7 +59,8 @@ const TextField = forwardRef((props, ref) => { label, fullWidth, type = "text", - value: propValue = "", + strict, + value: propValue, defaultValue, before, after, @@ -64,12 +69,20 @@ const TextField = forwardRef((props, ref) => { afterClassName, beforeClassName, onChange, + onKeyDown, ...other } = {...useComponentProps("textField"), ...props}; - const [value, setValue] = useState(defaultValue || propValue); + const [value, setValue] = useState(() => { + if (propValue != null) return String(propValue); + if (defaultValue != null) return String(defaultValue); + return ""; + }); + const inputRef = useRef(null); + const strictNumberType = useMemo(() => type === "number" && !!strict, [type, strict]); + useImperativeHandle( ref, () => ({ @@ -83,22 +96,61 @@ const TextField = forwardRef((props, ref) => { return inputRef.current?.value; }, setValue(value: string | number | undefined) { - setValue(value); + setValue(value == null ? "" : String(value)); }, }), [] ); - const handleChange = useCallback>( - event => { - setValue(event.currentTarget.value); - onChange?.(event); + const handleChange = useCallback( + (event: ChangeEvent) => { + let newValue = event.currentTarget.value ?? ""; + + if (strictNumberType) { + newValue = normalizeNumberInput(newValue); + } + + setValue(newValue); + + onChange?.({ + ...event, + currentTarget: { + ...event.currentTarget, + value: newValue, + }, + }); + }, + [onChange, strictNumberType] + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (strictNumberType && event.key.length === 1) { + // Only handle single-character printable keys here + // composition and paste handled in onChange + const {selectionStart, selectionEnd, value} = event.currentTarget; + + const start = selectionStart ?? value.length; + const end = selectionEnd ?? start; + + const next = value.slice(0, start) + event.key + value.slice(end); + const normalized = normalizeNumberInput(next); + + if (normalized !== next) { + event.preventDefault(); + } + } + + onKeyDown?.(event); }, - [onChange] + [onKeyDown, strictNumberType] ); useEffect(() => { - setValue(propValue); + const text = propValue == null ? "" : String(propValue); + + setValue(strictNumberType ? normalizeNumberInput(text) : text); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [propValue]); return ( @@ -124,12 +176,13 @@ const TextField = forwardRef((props, ref) => { {cloneOrCreateElement(after, {className: classnames(styles["text-field__after"], afterClassName)}, "span")} diff --git a/src/components/TextField/text-field.module.scss b/src/components/TextField/text-field.module.scss index b748b90..4f29653 100644 --- a/src/components/TextField/text-field.module.scss +++ b/src/components/TextField/text-field.module.scss @@ -10,7 +10,7 @@ $root: text-field; font-weight: var(--text-field-font-weight, 400); font-size: var(--text-field-font-size, 14px); letter-spacing: var(--text-field-letter-spacing, 0.5px); - line-height: var(--text-field-line-height, var(--line-height, 1 rem)); + line-height: var(--text-field-line-height, var(--line-height, 1rem)); padding: var(--text-field-padding, 8px 12px); border-radius: var(--text-field-border-radius, 8px); transition: @@ -43,10 +43,12 @@ $root: text-field; outline: none; background: transparent; transition: color var(--text-field-speed-color, var(--speed-color)); + appearance: textfield; &:focus { outline: none; } + &:disabled { cursor: not-allowed; } diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts new file mode 100644 index 0000000..c317df7 --- /dev/null +++ b/src/components/TextField/utils.ts @@ -0,0 +1,56 @@ +/** + * Normalizes user-typed numeric input. + * + * - Allows partial values ("-", ".", "1e", "1e-") + * - Supports decimals and scientific notation + */ +export const normalizeNumberInput = (raw: string): string => { + if (!raw) return ""; + + const filtered = raw.replace(/[^0-9eE+\-.]/g, ""); + + let result = ""; + let hasExponent = false; + let hasDot = false; + let isInExponent = false; + let canUseSign = true; + + for (let i = 0; i < filtered.length; i++) { + const ch = filtered[i]; + + if (ch >= "0" && ch <= "9") { + result += ch; + canUseSign = false; + continue; + } + + if (ch === ".") { + if (!isInExponent && !hasDot) { + result += ch; + hasDot = true; + } + continue; + } + + if (ch === "e" || ch === "E") { + if (!hasExponent) { + if (/\d/.test(result)) { + result += ch; + hasExponent = true; + isInExponent = true; + canUseSign = true; + } + } + continue; + } + + if (ch === "+" || ch === "-") { + if (canUseSign) { + result += ch; + canUseSign = false; + } + } + } + + return result; +}; diff --git a/src/components/Truncate/Truncate.stories.tsx b/src/components/Truncate/Truncate.stories.tsx index 4da9836..6732cf7 100644 --- a/src/components/Truncate/Truncate.stories.tsx +++ b/src/components/Truncate/Truncate.stories.tsx @@ -59,57 +59,13 @@ export default meta; export const Truncate: StoryObj = { args: { - text: "https://www.figma.com/design/T7txseZ8nSmsnjglF5vTye/node-id=7719-343&p=f&t=6Tl4gAOTDaPxfsHE-0", - middle: false, + middle: true, separator: "...", }, render: props => , }; -export const Inline: StoryObj = { - args: { - text: "Very long text that should be truncated to fit in line with a button", - middle: true, - }, - - render: props => ( -
-
-
- - -
- -
-
- - -
-
- ), -}; - const TruncateStoryRender = (props: TruncateProps) => { const [searchWords, setSearchWords] = useState(""); @@ -120,20 +76,6 @@ const TruncateStoryRender = (props: TruncateProps) => { return (
-
- -
- { } + {...props} />
} - middle - style={{flexShrink: 1}} + {...props} /> - +
))} @@ -174,3 +116,45 @@ const TruncateStoryRender = (props: TruncateProps) => { ); }; + +export const Inline: StoryObj = { + args: { + text: "Very long text that should be truncated to fit in line with a button", + middle: true, + }, + + render: props => ( +
+
+
+ +
+ +
+
+ + +
+
+ ), +}; diff --git a/src/components/Truncate/Truncate.tsx b/src/components/Truncate/Truncate.tsx index 76b1ab1..437e753 100644 --- a/src/components/Truncate/Truncate.tsx +++ b/src/components/Truncate/Truncate.tsx @@ -12,6 +12,8 @@ import classnames from "classnames"; import {useComponentProps} from "../../providers"; +import {calculateMiddleTruncate} from "./utils"; + import styles from "./truncate.module.scss"; export interface TruncateProps extends ComponentProps<"span"> { @@ -22,69 +24,6 @@ export interface TruncateProps extends ComponentProps<"span"> { render?: (text: string) => React.ReactNode; } -const MAX_CACHE_SIZE = 1000; -const cache = new Map(); -let canvas: HTMLCanvasElement | null = null; - -const addToCache = (key: string, value: string) => { - if (cache.size >= MAX_CACHE_SIZE) { - const oldestKey = cache.keys().next().value; - if (oldestKey !== undefined) { - cache.delete(oldestKey); - } - } - cache.set(key, value); -}; - -const calculateMiddleTruncate = ( - text: string, - maxWidth: number, - font: string, - letterSpacing: string, - separator: string -) => { - const cacheKey = `${text}-${maxWidth}-${font}-${letterSpacing}-${separator}`; - if (cache.has(cacheKey)) return cache.get(cacheKey)!; - - if (!canvas) { - canvas = document.createElement("canvas"); - } - const context = canvas.getContext("2d"); - if (!context) return text; - context.font = font; - context.letterSpacing = letterSpacing; - - const measure = (txt: string) => context.measureText(txt).width; - - if (measure(text) <= maxWidth) { - addToCache(cacheKey, text); - return text; - } - - let low = 0; - let high = text.length; - let result = ""; - - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const leftHalf = Math.ceil(mid / 2); - const rightHalf = Math.floor(mid / 2); - - const trimmed = text.slice(0, leftHalf) + separator + text.slice(text.length - rightHalf); - - if (measure(trimmed) <= maxWidth) { - result = trimmed; - low = mid + 1; - } else { - high = mid - 1; - } - } - - const finalResult = result || text[0] + separator + text.slice(-1); - addToCache(cacheKey, finalResult); - return finalResult; -}; - const Truncate: ForwardRefRenderFunction = (props, ref) => { const { text = "", diff --git a/src/components/Truncate/truncate.module.scss b/src/components/Truncate/truncate.module.scss index 5db786c..3063a91 100644 --- a/src/components/Truncate/truncate.module.scss +++ b/src/components/Truncate/truncate.module.scss @@ -14,13 +14,6 @@ &--middle { text-overflow: clip; - - padding-right: var(--truncate-around-space, 8px); - - @include theme.rtl() { - padding-right: 0; - padding-left: var(--truncate-around-space, 8px); - } } &__hidden { diff --git a/src/components/Truncate/utils.ts b/src/components/Truncate/utils.ts new file mode 100644 index 0000000..1eb96d1 --- /dev/null +++ b/src/components/Truncate/utils.ts @@ -0,0 +1,62 @@ +const MAX_CACHE_SIZE = 1000; +const cache = new Map(); +let canvas: HTMLCanvasElement | null = null; + +const addToCache = (key: string, value: string) => { + if (cache.size >= MAX_CACHE_SIZE) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(key, value); +}; + +export const calculateMiddleTruncate = ( + text: string, + maxWidth: number, + font: string, + letterSpacing: string, + separator: string +) => { + const cacheKey = `${text}-${maxWidth}-${font}-${letterSpacing}-${separator}`; + if (cache.has(cacheKey)) return cache.get(cacheKey)!; + + if (!canvas) { + canvas = document.createElement("canvas"); + } + const context = canvas.getContext("2d"); + if (!context) return text; + context.font = font; + context.letterSpacing = letterSpacing; + + const measure = (txt: string) => context.measureText(txt).width; + + if (measure(text) <= maxWidth) { + addToCache(cacheKey, text); + return text; + } + + let low = 0; + let high = text.length; + let result = ""; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const leftHalf = Math.ceil(mid / 2); + const rightHalf = Math.floor(mid / 2); + + const trimmed = text.slice(0, leftHalf) + separator + text.slice(text.length - rightHalf); + + if (measure(trimmed) <= maxWidth) { + result = trimmed; + low = mid + 1; + } else { + high = mid - 1; + } + } + + const finalResult = result || text[0] + separator + text.slice(-1); + addToCache(cacheKey, finalResult); + return finalResult; +};