Add max character count to inputs (#12006)

This commit is contained in:
Tom Moor
2026-04-12 11:44:28 -04:00
committed by GitHub
parent 4d799e7690
commit 6874b02cc7
11 changed files with 110 additions and 9 deletions
+62 -2
View File
@@ -6,6 +6,7 @@ import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import Fade from "~/components/Fade";
import { undraggableOnDesktop } from "~/styles";
export const NativeTextarea = styled.textarea<{
@@ -97,6 +98,7 @@ export const Outline = styled(Flex)<{
hasError?: boolean;
$focused?: boolean;
}>`
position: relative;
flex: 1;
margin: ${(props) =>
props.margin !== undefined ? props.margin : "0 0 16px"};
@@ -119,6 +121,19 @@ export const Outline = styled(Flex)<{
user-select: none;
`;
const CharacterCount = styled.span`
position: absolute;
top: 0;
right: 0;
font-size: 11px;
line-height: 1;
padding: 2px 4px;
border-radius: 0 0 0 2px;
background: ${s("inputBorder")};
color: ${s("textTertiary")};
pointer-events: none;
`;
export const LabelText = styled.div`
font-weight: 500;
padding-bottom: 4px;
@@ -141,6 +156,8 @@ export interface Props extends Omit<
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
icon?: React.ReactNode;
/** Show a character count near the maxLength limit. Always shown for textareas, opt-in for other types. */
showCharacterCount?: boolean;
/** Like autoFocus, but also select any text in the input */
autoSelect?: boolean;
/** Callback is triggered with the CMD+Enter keyboard combo */
@@ -157,6 +174,21 @@ function Input(
) {
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const [charCount, setCharCount] = React.useState(() => {
if (typeof props.value === "string") {
return props.value.length;
}
if (typeof props.defaultValue === "string") {
return props.defaultValue.length;
}
return 0;
});
React.useEffect(() => {
if (typeof props.value === "string") {
setCharCount(props.value.length);
}
}, [props.value]);
const handleBlur = (ev: React.SyntheticEvent) => {
setFocused(false);
@@ -174,6 +206,15 @@ function Input(
}
};
const handleChange = (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setCharCount(ev.target.value.length);
if (props.onChange) {
props.onChange(ev);
}
};
const handleKeyDown = (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
@@ -206,13 +247,21 @@ function Input(
flex,
prefix,
labelHidden,
maxLength,
showCharacterCount,
onFocus,
onBlur,
onChange,
onRequestSubmit,
children,
...rest
} = props;
const showCharCount =
(type === "textarea" || showCharacterCount) &&
maxLength !== undefined &&
charCount >= maxLength * 0.9;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -238,8 +287,10 @@ function Input(
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
// set it after "rest" to override props from spread.
maxLength={maxLength}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
) : (
<NativeInput
@@ -253,10 +304,19 @@ function Input(
hasPrefix={!!prefix}
type={type}
{...rest}
// set it after "rest" to override "onKeyDown" from prop.
// set it after "rest" to override "onKeyDown" and "onChange" from prop.
maxLength={maxLength}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
)}
{showCharCount && (
<Fade>
<CharacterCount>
{charCount}/{maxLength}
</CharacterCount>
</Fade>
)}
{children}
</Outline>
</label>
+5
View File
@@ -2,6 +2,7 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { UserRole } from "@shared/types";
import { UserValidation } from "@shared/validations";
import type User from "~/models/User";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "~/components/Input";
@@ -131,6 +132,8 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
onChange={handleChange}
error={!name ? t("Name can't be empty") : undefined}
value={name}
maxLength={UserValidation.maxNameLength}
showCharacterCount
autoSelect
required
flex
@@ -192,6 +195,8 @@ export function UserChangeEmailDialog({ user, onSubmit }: Props) {
onChange={handleChange}
error={!email ? t("Email can't be empty") : error}
value={email}
maxLength={UserValidation.maxEmailLength}
showCharacterCount
autoSelect
required
flex
+4
View File
@@ -232,6 +232,8 @@ function Details() {
autoComplete="organization"
value={name}
onChange={handleNameChange}
maxLength={TeamValidation.maxNameLength}
showCharacterCount
required
/>
</SettingRow>
@@ -246,6 +248,8 @@ function Details() {
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
setDescription(ev.target.value);
}}
maxLength={TeamValidation.maxDescriptionLength}
showCharacterCount
/>
</SettingRow>
<SettingRow
+3
View File
@@ -12,6 +12,7 @@ import { UserChangeEmailDialog } from "~/components/UserDialogs";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { UserValidation } from "@shared/validations";
import ImageInput from "./components/ImageInput";
import SettingRow from "./components/SettingRow";
@@ -91,6 +92,8 @@ const Profile = () => {
autoComplete="name"
value={name}
onChange={handleNameChange}
maxLength={UserValidation.maxNameLength}
showCharacterCount
required
/>
</SettingRow>
@@ -87,6 +87,8 @@ export function CreateGroupDialog() {
label="Name"
onChange={(e) => setName(e.target.value)}
value={name}
maxLength={GroupValidation.maxNameLength}
showCharacterCount
required
autoFocus
flex
@@ -169,6 +171,8 @@ export function EditGroupDialog({ group, onSubmit }: Props) {
label={t("Name")}
onChange={handleNameChange}
value={name}
maxLength={GroupValidation.maxNameLength}
showCharacterCount
disabled={group.isExternallyManaged}
required
autoFocus
+5 -1
View File
@@ -63,7 +63,11 @@ class Group extends ParanoidModel<
InferAttributes<Group>,
Partial<InferCreationAttributes<Group>>
> {
@Length({ min: 0, max: 255, msg: "name must be 255 characters or less" })
@Length({
min: 0,
max: GroupValidation.maxNameLength,
msg: `name must be ${GroupValidation.maxNameLength} characters or less`,
})
@NotContainsUrl
@Column
name: string;
+4 -1
View File
@@ -121,7 +121,10 @@ class Team extends ParanoidModel<
subdomain: string | null;
@Unique
@Length({ max: 255, msg: "domain must be 255 characters or less" })
@Length({
max: TeamValidation.maxDomainLength,
msg: `domain must be ${TeamValidation.maxDomainLength} characters or less`,
})
@IsFQDN
@Column
domain: string | null;
+4 -1
View File
@@ -31,7 +31,10 @@ class TeamDomain extends IdModel<
msg: "You chose a restricted domain, please try another.",
})
@NotEmpty
@Length({ max: 255, msg: "name must be 255 characters or less" })
@Length({
max: TeamValidation.maxDomainLength,
msg: `name must be ${TeamValidation.maxDomainLength} characters or less`,
})
@IsFQDN
@Column
name: string;
+8 -2
View File
@@ -48,13 +48,19 @@ class WebhookSubscription extends ParanoidModel<
static eventNamespace = "webhookSubscriptions";
@NotEmpty
@Length({ max: 255, msg: "Webhook name be less than 255 characters" })
@Length({
max: WebhookSubscriptionValidation.maxNameLength,
msg: `Webhook name must be ${WebhookSubscriptionValidation.maxNameLength} characters or less`,
})
@Column
name: string;
@IsUrl
@NotEmpty
@Length({ max: 255, msg: "Webhook url be less than 255 characters" })
@Length({
max: WebhookSubscriptionValidation.maxUrlLength,
msg: `Webhook url must be ${WebhookSubscriptionValidation.maxUrlLength} characters or less`,
})
@Column
url: string;
+2 -2
View File
@@ -55,7 +55,7 @@ export type GroupsInfoReq = z.infer<typeof GroupsInfoSchema>;
export const GroupsCreateSchema = z.object({
body: z.object({
/** Group name */
name: z.string(),
name: z.string().max(GroupValidation.maxNameLength),
/** Group description */
description: z
.string()
@@ -73,7 +73,7 @@ export type GroupsCreateReq = z.infer<typeof GroupsCreateSchema>;
export const GroupsUpdateSchema = z.object({
body: BaseIdSchema.extend({
/** Group name */
name: z.string().optional(),
name: z.string().max(GroupValidation.maxNameLength).optional(),
/** Group description */
description: z
.string()
+9
View File
@@ -67,6 +67,8 @@ export const DocumentValidation = {
};
export const GroupValidation = {
/** The maximum length of the group name */
maxNameLength: 255,
/** The maximum length of the group description */
maxDescriptionLength: 2000,
};
@@ -133,6 +135,9 @@ export const TeamValidation = {
/** The maximum length of the team subdomain for self-hosted */
maxSubdomainSelfHostedLength: 255,
/** The maximum length of a team domain */
maxDomainLength: 255,
/** The maximum length of MCP workspace guidance */
maxGuidanceMCPLength: 2000,
};
@@ -151,6 +156,10 @@ export const UserValidation = {
export const WebhookSubscriptionValidation = {
/** The maximum number of webhooks per team */
maxSubscriptions: 10,
/** The maximum length of the webhook name */
maxNameLength: 255,
/** The maximum length of the webhook url */
maxUrlLength: 255,
};
export const EmojiValidation = {