Skip to content

Commit

Permalink
v3 - custom string input with character count (#529)
Browse files Browse the repository at this point in the history
* feat(studio): custom string input component with character count

Composes the default string input rendering with a simple character count. Initially only used in SEO fields, but can be later applied where applicable.

* feat(StringInputWithCharacterCount): max count

* feat(StringInputWithCharacterCount): use character count input for more string fields

* refactor(schema): rename Rule → rule in validation function

* feat(StringInputWithCharacterCount): handle plural form

* feat(StringInputWithCharacterCount): make maxCount required and show characters left
  • Loading branch information
mathiazom authored Sep 13, 2024
1 parent 5920350 commit ef6932d
Show file tree
Hide file tree
Showing 30 changed files with 279 additions and 115 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Box, Stack, Text } from "@sanity/ui";
import { StringInputProps } from "sanity";
import styles from "./stringInputWithCharacterCount.module.css";

type StringInputWithCharacterCountProps = StringInputProps & {
maxCount: number;
};

const SR_COUNT_HINT_ID = "string-input-with-character-count-input-info";

export const StringInputWithCharacterCount = ({
maxCount,
...defaultProps
}: StringInputWithCharacterCountProps) => {
const characterCount = defaultProps.value?.length ?? 0;
const charactersLeft = maxCount - characterCount;
const isOverLimit = charactersLeft < 0;
const countText = `${Math.abs(charactersLeft)} character${charactersLeft !== 1 ? "s" : ""} ${isOverLimit ? "over limit" : charactersLeft === maxCount ? "allowed" : "left"}`;

return (
<Stack space={3}>
<Box>
{defaultProps.renderDefault({
...defaultProps,
elementProps: {
...defaultProps.elementProps,
"aria-describedby": `${defaultProps.elementProps["aria-describedby"]} ${SR_COUNT_HINT_ID}`,
},
})}
</Box>
<span id={SR_COUNT_HINT_ID} className={styles.srOnly}>
{`You can enter up to ${maxCount} character${charactersLeft !== 1 ? "s" : ""}`}
</span>
<Text
size={1}
muted
className={isOverLimit ? styles.overLimit : ""}
aria-live={"polite"}
>
{countText}
</Text>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.srOnly {
/* visually hidden input to still receive focus and support screen readers */
/* https://developer.mozilla.org/en-US/docs/Web/API/File_API/Using_files_from_web_applications#using_a_label_element_to_trigger_a_hidden_file_input_element */
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}

.overLimit {
color: rgb(203, 38, 24) !important;
}
7 changes: 6 additions & 1 deletion studio/schemas/builders/pageBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import testimonals from "../objects/sections/testimonials";
import imageSection from "../objects/sections/image";
import grid from "../objects/sections/grid";
import contactForm from "../objects/sections/form";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

export const pageBuilderID = "pageBuilder";

Expand All @@ -25,7 +26,11 @@ const pageBuilder = defineType({
description:
"Enter a distinctive name for the dynamic page to help content editors easily identify and manage it. This name is used internally and is not visible on your website.",
type: "string",
validation: (Rule) => Rule.required().max(30),
validation: (rule) => rule.required().max(30),
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: 30 }),
},
}),
pageSlug,
seo,
Expand Down
19 changes: 16 additions & 3 deletions studio/schemas/documents/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineField, defineType } from "sanity";
import seo from "../objects/seo";
import { pageSlug } from "../schemaTypes/slug";
import { title } from "../fields/text";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

export const blogId = "blog";

Expand All @@ -16,7 +17,11 @@ const blog = defineType({
description:
"Enter a distinctive name for the page to help content editors easily identify and manage it. This name is used internally and is not visible on your website.",
type: "string",
validation: (Rule) => Rule.required().max(30),
validation: (rule) => rule.required().max(30),
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: 30 }),
},
}),
pageSlug,
seo,
Expand All @@ -28,7 +33,11 @@ const blog = defineType({
"Enter the label used to refer to all posts regardless of their category. This label will be displayed in the filter section on the main blog page. Examples include 'news', 'stories', or 'posts'.",
type: "string",
initialValue: "All posts",
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required().max(20),
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: 20 }),
},
}),
defineField({
name: "categories",
Expand All @@ -48,7 +57,11 @@ const blog = defineType({
description:
"The name of the category. This will be displayed on the website and used for organizing blog posts.",
type: "string",
validation: (Rule) => Rule.required().min(1).max(100),
validation: (rule) => rule.required().min(1).max(100),
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: 100 }),
},
}),
],
}),
Expand Down
6 changes: 6 additions & 0 deletions studio/schemas/documents/companyInfo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineType, defineField } from "sanity";
import seo from "../objects/seo";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

export const companyInfoID = "companyInfo";

Expand All @@ -24,6 +25,11 @@ const companyInfo = defineType({
type: "string",
title: "Site Name",
description: "The name of your website.",
validation: (rule) => rule.max(60),
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: 60 }),
},
}),
defineField({
name: "defaultLanguage",
Expand Down
6 changes: 6 additions & 0 deletions studio/schemas/documents/companyLocation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineField, defineType } from "sanity";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

export const companyLocationID = "companyLocation";
export const companyLocationNameID = "companyLocationName";
Expand All @@ -13,6 +14,11 @@ const companyLocation = defineType({
name: companyLocationNameID,
type: "string",
title: "Location",
validation: (rule) => rule.max(50),
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: 50 }),
},
}),
],
});
Expand Down
4 changes: 2 additions & 2 deletions studio/schemas/documents/navigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ const navigationManager = defineType({
"Add links to the main menu. These links will appear at the top of your website and help visitors navigate to important sections. The first Call to Action (CTA) will be styled as a primary link button. Note: The order in which you add the links here is how they will be displayed on the website.",
type: "array",
of: [{ type: linkID }, { type: callToActionFieldID }],
validation: (Rule) =>
Rule.custom((links) => {
validation: (rule) =>
rule.custom((links) => {
if (!Array.isArray(links)) return true;
const ctaCount = links.filter(
(link) => link._type === callToActionFieldID,
Expand Down
12 changes: 6 additions & 6 deletions studio/schemas/documents/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const posts = defineType({
description: "Select the date and time when this post will be published.",
type: "datetime",
initialValue: () => new Date().toISOString(),
validation: (Rule) =>
Rule.required().custom((date, context) => {
validation: (rule) =>
rule.required().custom((date, context) => {
// Ensure date is not undefined or null
if (!date) return "The publish date is required.";

Expand Down Expand Up @@ -57,7 +57,7 @@ const posts = defineType({
components: {
input: CategorySelector,
},
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required(),
}),
defineField({
name: "lead",
Expand All @@ -69,15 +69,15 @@ const posts = defineType({
defineField({
...richText,
description: "Enter the introductory text for the post.",
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required(),
}),
defineField({
...image,
description: "Upload a featured image for the post.",
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required(),
}),
],
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required(),
}),
defineField({
...richText,
Expand Down
9 changes: 7 additions & 2 deletions studio/schemas/fields/categories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineField } from "sanity";
import { defineField, StringInputProps } from "sanity";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

export const categoriesId = "categories";

Expand All @@ -11,7 +12,11 @@ const categories = defineField({
name: "category",
type: "string",
title: "Category",
validation: (Rule) => Rule.required().min(1).max(100),
validation: (rule) => rule.required().min(1).max(100),
components: {
input: (props: StringInputProps) =>
StringInputWithCharacterCount({ ...props, maxCount: 100 }),
},
},
],
});
Expand Down
34 changes: 17 additions & 17 deletions studio/schemas/fields/media.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineField } from "sanity";
import { defineField, StringInputProps } from "sanity";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

export enum ImageAlignment {
Left = "left",
Expand All @@ -15,20 +16,25 @@ const alignmentOptions = [
{ title: "Right", value: ImageAlignment.Right },
];

const imageAltField = defineField({
name: "alt",
type: "string",
title: "Alternative Text",
description:
"Provide a description of the image for accessibility. Leave empty if the image is purely decorative.",
validation: (rule) => rule.max(100),
components: {
input: (props: StringInputProps) =>
StringInputWithCharacterCount({ ...props, maxCount: 100 }),
},
});

const image = defineField({
name: "image",
title: "Image",
type: "image",
options: { hotspot: true },
fields: [
{
name: "alt",
type: "string",
title: "Alternative Text",
description:
"Provide a description of the image for accessibility. Leave empty if the image is purely decorative.",
},
],
fields: [imageAltField],
});

export const imageExtended = defineField({
Expand All @@ -37,13 +43,7 @@ export const imageExtended = defineField({
type: "image",
options: { hotspot: true },
fields: [
{
name: "alt",
type: "string",
title: "Alternative Text",
description:
"Provide a description of the image for accessibility. Leave empty if the image is purely decorative.",
},
imageAltField,
{
name: "imageAlignment",
title: "Image Alignment",
Expand Down
14 changes: 11 additions & 3 deletions studio/schemas/fields/text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StringRule, defineField } from "sanity";
import { StringInputWithCharacterCount } from "../../components/stringInputWithCharacterCount/StringInputWithCharacterCount";

enum titleID {
basic = "basicTitle",
Expand All @@ -22,8 +23,8 @@ const createField = ({
isRequired = false,
maxLength = 60,
}: CreateFieldProps) => {
const validationRules = (Rule: StringRule) => {
let rules = Rule.max(maxLength);
const validationRules = (rule: StringRule) => {
let rules = rule.max(maxLength);
if (isRequired) {
rules = rules.required();
}
Expand All @@ -35,6 +36,10 @@ const createField = ({
title,
type: "string",
validation: validationRules,
components: {
input: (props) =>
StringInputWithCharacterCount({ ...props, maxCount: maxLength }),
},
});
};

Expand All @@ -53,7 +58,10 @@ export const optionalSubtitle = defineField({
name: subtitleID.optional,
title: "Subtitle",
type: "string",
validation: (Rule) => Rule.max(60),
validation: (rule) => rule.max(60),
components: {
input: (props) => StringInputWithCharacterCount({ ...props, maxCount: 60 }),
},
});

export const richTextID = "richText";
Expand Down
8 changes: 4 additions & 4 deletions studio/schemas/objects/compensations/benefitsByLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const benefitType = defineField({
layout: BENEFIT_TYPES.length > 5 ? "dropdown" : "radio",
},
initialValue: BENEFIT_TYPE_BASIC_VALUE,
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required(),
});

export const benefitsByLocation = defineField({
Expand All @@ -45,7 +45,7 @@ export const benefitsByLocation = defineField({
...location,
description:
"Select the office location for which you are entering benefits information. Each location must be unique.",
validation: (Rule) => Rule.required(),
validation: (rule) => rule.required(),
},
defineField({
name: "benefits",
Expand Down Expand Up @@ -93,8 +93,8 @@ export const benefitsByLocation = defineField({
},
},
],
validation: (Rule) =>
Rule.custom((benefitsByLocation) => {
validation: (rule) =>
rule.custom((benefitsByLocation) => {
const isNotDuplicate: boolean = checkForDuplicateLocations(
benefitsByLocation as DocumentWithLocation[] | undefined,
);
Expand Down
Loading

0 comments on commit ef6932d

Please sign in to comment.