Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/TextField/TextField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ const meta: Meta<typeof TextFieldComponent> = {
options: accents,
control: {type: "select"},
},

type: {
options: ["text", "number", "password"],
control: {type: "select"},
},
label: hideInTable,
value: hideInTable,
defaultValue: hideInTable,
Expand All @@ -61,6 +64,8 @@ export const TextField: StoryObj<typeof TextFieldComponent> = {
placeholder: "Enter text",
disabled: false,
fullWidth: false,
strict: false,
type: "text",
before: "🔍",
after: "🔑",
},
Expand Down
79 changes: 66 additions & 13 deletions src/components/TextField/TextField.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, {
ChangeEventHandler,
ChangeEvent,
ComponentProps,
forwardRef,
KeyboardEvent,
memo,
ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
Expand All @@ -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";

Expand Down Expand Up @@ -44,6 +47,7 @@ export interface TextFieldProps extends ComponentProps<"input"> {
inputClassName?: string;
afterClassName?: string;
beforeClassName?: string;
strict?: boolean;
}

const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
Expand All @@ -55,7 +59,8 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
label,
fullWidth,
type = "text",
value: propValue = "",
strict,
value: propValue,
defaultValue,
before,
after,
Expand All @@ -64,12 +69,20 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
afterClassName,
beforeClassName,
onChange,
onKeyDown,
...other
} = {...useComponentProps("textField"), ...props};

const [value, setValue] = useState<string | number | undefined>(defaultValue || propValue);
const [value, setValue] = useState<string>(() => {
if (propValue != null) return String(propValue);
if (defaultValue != null) return String(defaultValue);
return "";
});

const inputRef = useRef<HTMLInputElement | null>(null);

const strictNumberType = useMemo(() => type === "number" && !!strict, [type, strict]);

useImperativeHandle(
ref,
() => ({
Expand All @@ -83,22 +96,61 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
return inputRef.current?.value;
},
setValue(value: string | number | undefined) {
setValue(value);
setValue(value == null ? "" : String(value));
},
}),
[]
);

const handleChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
event => {
setValue(event.currentTarget.value);
onChange?.(event);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
Expand All @@ -124,12 +176,13 @@ const TextField = forwardRef<TextFieldActions, TextFieldProps>((props, ref) => {
<input
{...other}
ref={inputRef}
type={type}
type={strictNumberType ? "text" : type}
inputMode={strictNumberType ? "decimal" : other.inputMode}
value={value}
defaultValue={defaultValue}
aria-label={label}
className={classnames(styles["text-field__input"], inputClassName)}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
{cloneOrCreateElement(after, {className: classnames(styles["text-field__after"], afterClassName)}, "span")}
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/components/TextField/text-field.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
}
Expand Down
56 changes: 56 additions & 0 deletions src/components/TextField/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
};
108 changes: 46 additions & 62 deletions src/components/Truncate/Truncate.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,57 +59,13 @@ export default meta;

export const Truncate: StoryObj<TruncateProps> = {
args: {
text: "https://www.figma.com/design/T7txseZ8nSmsnjglF5vTye/node-id=7719-343&p=f&t=6Tl4gAOTDaPxfsHE-0",
middle: false,
middle: true,
separator: "...",
},

render: props => <TruncateStoryRender {...props} />,
};

export const Inline: StoryObj<TruncateProps> = {
args: {
text: "Very long text that should be truncated to fit in line with a button",
middle: true,
},

render: props => (
<div style={{display: "flex", flexDirection: "column", gap: "20px"}}>
<Header title="Inline Truncate with Button (flex-start)" />
<div
style={{
display: "flex",
alignItems: "center",
border: "1px solid #ccc",
padding: "10px",
width: "300px",
resize: "horizontal",
overflow: "auto",
}}
>
<TruncateComponent {...props} style={{flexShrink: 1}} />
<button style={{flexShrink: 0, marginLeft: "10px"}}>Button</button>
</div>

<Header title="Inline Truncate with Button (always follows)" />
<div
style={{
display: "flex",
alignItems: "center",
border: "1px solid #ccc",
padding: "10px",
width: "400px",
resize: "horizontal",
overflow: "auto",
}}
>
<TruncateComponent {...props} style={{flexShrink: 1}} />
<button style={{flexShrink: 0, marginLeft: "10px"}}>Action</button>
</div>
</div>
),
};

const TruncateStoryRender = (props: TruncateProps) => {
const [searchWords, setSearchWords] = useState("");

Expand All @@ -120,20 +76,6 @@ const TruncateStoryRender = (props: TruncateProps) => {

return (
<div style={{display: "flex", flexDirection: "column", gap: "20px", alignItems: "center"}}>
<div
style={{
resize: "horizontal",
overflow: "auto",
border: "1px solid #ccc",
padding: "15px 10px",
minWidth: "0px",
width: "400px",
maxWidth: "800px",
}}
>
<TruncateComponent {...props} />
</div>

<ViewportProvider
style={{
border: "1px solid black",
Expand All @@ -157,15 +99,15 @@ const TruncateStoryRender = (props: TruncateProps) => {
<TruncateComponent
text={title}
render={text => <Highlight textToHighlight={text} searchWords={[searchWords]} />}
{...props}
/>
<div style={{display: "flex", alignItems: "center", minWidth: 0}}>
<TruncateComponent
text={url}
render={text => <Highlight textToHighlight={text} searchWords={[searchWords]} />}
middle
style={{flexShrink: 1}}
{...props}
/>
<button style={{flexShrink: 0, marginLeft: "8px"}}>Button</button>
<button style={{marginLeft: "10px"}}>Open</button>
</div>
</div>
))}
Expand All @@ -174,3 +116,45 @@ const TruncateStoryRender = (props: TruncateProps) => {
</div>
);
};

export const Inline: StoryObj<TruncateProps> = {
args: {
text: "Very long text that should be truncated to fit in line with a button",
middle: true,
},

render: props => (
<div style={{display: "flex", flexDirection: "column"}}>
<Header title="Inline Truncate" />
<div
style={{
marginTop: "10px",
resize: "horizontal",
overflow: "auto",
border: "1px solid #ccc",
padding: "10px",
width: "400px",
}}
>
<TruncateComponent {...props} />
</div>

<Header title="Inline Truncate with Button (flex-start)" />
<div
style={{
marginTop: "10px",
resize: "horizontal",
overflow: "auto",
border: "1px solid #ccc",
padding: "10px",
width: "300px",
display: "flex",
alignItems: "center",
}}
>
<TruncateComponent {...props} />
<button style={{marginLeft: "10px"}}>Button</button>
</div>
</div>
),
};
Loading