Skip to content

Commit da2963e

Browse files
dancormierb-kelly
authored andcommitted
feat(plugins): add placeholder to empty input (#116)
fixes: #103
1 parent 90d427a commit da2963e

File tree

7 files changed

+216
-0
lines changed

7 files changed

+216
-0
lines changed

site/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ domReady(() => {
199199
},
200200
},
201201
},
202+
placeholderText: "This is placeholder text, so start typing…",
202203
richTextOptions: {
203204
linkPreviewProviders: [
204205
ExampleTextOnlyLinkPreviewProvider,

src/commonmark/editor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
defaultImageUploadHandler,
1010
} from "../shared/prosemirror-plugins/image-upload";
1111
import { interfaceManagerPlugin } from "../shared/prosemirror-plugins/interface-manager";
12+
import { placeholderPlugin } from "../shared/prosemirror-plugins/placeholder";
1213
import {
1314
editableCheck,
1415
readonlyPlugin,
@@ -58,6 +59,7 @@ export class CommonmarkEditor extends BaseView {
5859
this.options.imageUpload,
5960
this.options.parserFeatures.validateLink
6061
),
62+
placeholderPlugin(this.options.placeholderText),
6163
readonlyPlugin(),
6264
],
6365
}),
@@ -77,6 +79,7 @@ export class CommonmarkEditor extends BaseView {
7779
editorHelpLink: null,
7880
menuParentContainer: null,
7981
parserFeatures: defaultParserFeatures,
82+
placeholderText: null,
8083
imageUpload: {
8184
handler: defaultImageUploadHandler,
8285
},

src/rich-text/editor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { codePasteHandler } from "./plugins/code-paste-handler";
3939
import { linkPasteHandler } from "./plugins/link-paste-handler";
4040
import { linkPreviewPlugin, LinkPreviewProvider } from "./plugins/link-preview";
4141
import { linkTooltipPlugin } from "./plugins/link-editor";
42+
import { placeholderPlugin } from "../shared/prosemirror-plugins/placeholder";
4243
import { plainTextPasteHandler } from "./plugins/plain-text-paste-handler";
4344
import { spoilerToggle } from "./plugins/spoiler-toggle";
4445
import { tables } from "./plugins/tables";
@@ -96,6 +97,7 @@ export class RichTextEditor extends BaseView {
9697
this.options.pluginParentContainer
9798
),
9899
linkTooltipPlugin(this.options.parserFeatures),
100+
placeholderPlugin(this.options.placeholderText),
99101
richTextImageUpload(
100102
this.options.imageUpload,
101103
this.options.parserFeatures.validateLink
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Node } from "prosemirror-model";
2+
import { Plugin, PluginKey } from "prosemirror-state";
3+
import { Decoration, DecorationSet } from "prosemirror-view";
4+
5+
/**
6+
* Creates a placeholder decoration on the document's first child
7+
* @param doc The root document node
8+
* @param placeholder The placeholder text
9+
*/
10+
function createPlaceholderDecoration(doc: Node, placeholder: string) {
11+
// TODO check for image upload placeholder
12+
const showPlaceholder =
13+
!doc.textContent &&
14+
doc.childCount === 1 &&
15+
doc.firstChild.childCount === 0;
16+
17+
if (!showPlaceholder) {
18+
return DecorationSet.empty;
19+
}
20+
21+
const $pos = doc.resolve(1);
22+
return DecorationSet.create(doc, [
23+
Decoration.node($pos.before(), $pos.after(), {
24+
"data-placeholder": placeholder,
25+
}),
26+
]);
27+
}
28+
29+
/** Plugin that adds placeholder text to the editor when it's empty */
30+
export function placeholderPlugin(placeholder: string): Plugin {
31+
if (!placeholder?.trim()) {
32+
return new Plugin({});
33+
}
34+
35+
return new Plugin<DecorationSet>({
36+
key: new PluginKey("placeholder"),
37+
state: {
38+
init: (_, state) =>
39+
createPlaceholderDecoration(state.doc, placeholder),
40+
apply: (tr, value) => {
41+
if (!tr.docChanged) {
42+
return value.map(tr.mapping, tr.doc);
43+
}
44+
45+
return createPlaceholderDecoration(tr.doc, placeholder);
46+
},
47+
},
48+
props: {
49+
decorations(this: Plugin<DecorationSet>, state) {
50+
return this.getState(state);
51+
},
52+
},
53+
view(view) {
54+
view.dom.setAttribute("aria-placeholder", placeholder);
55+
56+
return {
57+
destroy() {
58+
view.dom.removeAttribute("aria-placeholder");
59+
},
60+
};
61+
},
62+
});
63+
}

src/shared/view.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface CommonViewOptions {
1515
editorHelpLink?: string;
1616
/** The features to allow/disallow on the markdown parser */
1717
parserFeatures?: CommonmarkParserFeatures;
18+
/** The placeholder text for an empty editor */
19+
placeholderText?: string;
1820
/**
1921
* Function to get the container to place the menu bar;
2022
* defaults to returning this editor's target's parentNode

src/styles/custom-components.less

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,21 @@
209209
font-variant-ligatures: none;
210210
}
211211

212+
& [data-placeholder] {
213+
position: relative;
214+
215+
&::before {
216+
color: var(--fc-light);
217+
position: absolute;
218+
content: attr(data-placeholder);
219+
pointer-events: none;
220+
}
221+
222+
.s-input__readonly &::before {
223+
color: inherit;
224+
}
225+
}
226+
212227
// taken from prosemirror.css for compatibility
213228
.ProseMirror-hideselection *::selection {
214229
background: transparent;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { CommonmarkEditor } from "../../../src/commonmark/editor";
2+
import { RichTextEditor } from "../../../src/rich-text/editor";
3+
import { placeholderPlugin } from "../../../src/shared/prosemirror-plugins/placeholder";
4+
5+
const PLACEHOLDER_TEXT = "This is a placeholder";
6+
7+
function commonmarkView(
8+
markdown: string,
9+
placeholderText: string
10+
): CommonmarkEditor {
11+
return new CommonmarkEditor(document.createElement("div"), markdown, {
12+
placeholderText,
13+
});
14+
}
15+
16+
function richView(markdownInput: string, placeholderText: string) {
17+
return new RichTextEditor(document.createElement("div"), markdownInput, {
18+
placeholderText,
19+
});
20+
}
21+
22+
describe("placeholder plugin", () => {
23+
describe("commonmark", () => {
24+
it("should add placeholder when the editor is empty", () => {
25+
const view = commonmarkView("", PLACEHOLDER_TEXT);
26+
const elAttr =
27+
view.editorView.dom.firstElementChild.getAttribute(
28+
"data-placeholder"
29+
);
30+
const ariaAttr =
31+
view.editorView.dom.getAttribute("aria-placeholder");
32+
33+
expect(elAttr).toBe(PLACEHOLDER_TEXT);
34+
expect(ariaAttr).toBe(PLACEHOLDER_TEXT);
35+
});
36+
37+
it("should not add placeholder when the editor is populated", () => {
38+
const view = commonmarkView("Hello world", PLACEHOLDER_TEXT);
39+
const elAttr =
40+
view.editorView.dom.firstElementChild.getAttribute(
41+
"data-placeholder"
42+
);
43+
const ariaAttr =
44+
view.editorView.dom.getAttribute("aria-placeholder");
45+
46+
expect(elAttr).toBeNull();
47+
expect(ariaAttr).toBe(PLACEHOLDER_TEXT);
48+
});
49+
50+
it("should remove placeholder when text is added", () => {
51+
const view = commonmarkView("", PLACEHOLDER_TEXT);
52+
let elAttr =
53+
view.editorView.dom.firstElementChild.getAttribute(
54+
"data-placeholder"
55+
);
56+
57+
expect(elAttr).toBe(PLACEHOLDER_TEXT);
58+
59+
view.editorView.dispatch(
60+
view.editorView.state.tr.insertText("Hello world")
61+
);
62+
63+
elAttr =
64+
view.editorView.dom.firstElementChild.getAttribute(
65+
"data-placeholder"
66+
);
67+
expect(elAttr).toBeNull();
68+
});
69+
});
70+
71+
describe("rich-text", () => {
72+
it("should add placeholder when the editor is empty", () => {
73+
const view = richView("", PLACEHOLDER_TEXT);
74+
const elAttr =
75+
view.editorView.dom.firstElementChild.getAttribute(
76+
"data-placeholder"
77+
);
78+
const ariaAttr =
79+
view.editorView.dom.getAttribute("aria-placeholder");
80+
81+
expect(elAttr).toBe(PLACEHOLDER_TEXT);
82+
expect(ariaAttr).toBe(PLACEHOLDER_TEXT);
83+
});
84+
85+
it("should not add placeholder when the editor is populated", () => {
86+
const view = richView("# Hello", PLACEHOLDER_TEXT);
87+
const elAttr =
88+
view.editorView.dom.firstElementChild.getAttribute(
89+
"data-placeholder"
90+
);
91+
const ariaAttr =
92+
view.editorView.dom.getAttribute("aria-placeholder");
93+
94+
expect(elAttr).toBeNull();
95+
expect(ariaAttr).toBe(PLACEHOLDER_TEXT);
96+
});
97+
98+
it("should remove placeholder when text is added", () => {
99+
const view = richView("", PLACEHOLDER_TEXT);
100+
let elAttr =
101+
view.editorView.dom.firstElementChild.getAttribute(
102+
"data-placeholder"
103+
);
104+
105+
expect(elAttr).toBe(PLACEHOLDER_TEXT);
106+
107+
view.editorView.dispatch(
108+
view.editorView.state.tr.insertText("Hello world")
109+
);
110+
111+
elAttr =
112+
view.editorView.dom.firstElementChild.getAttribute(
113+
"data-placeholder"
114+
);
115+
expect(elAttr).toBeNull();
116+
});
117+
});
118+
119+
describe("plugin", () => {
120+
it("should not activate when the placeholder text is invalid", () => {
121+
// start with a valid option to set the base case
122+
let plugin = placeholderPlugin(PLACEHOLDER_TEXT);
123+
expect(plugin.spec.state).toBeDefined();
124+
125+
// now try with an invalid option
126+
plugin = placeholderPlugin("");
127+
expect(plugin.spec.state).toBeUndefined();
128+
});
129+
});
130+
});

0 commit comments

Comments
 (0)