@guardian/stand 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +41 -0
  2. package/dist/byline/Byline.cjs +8 -1
  3. package/package.json +42 -35
  4. package/.changeset/README.md +0 -8
  5. package/.changeset/config.json +0 -11
  6. package/.prettierrc +0 -1
  7. package/.storybook/main.ts +0 -12
  8. package/.storybook/preview.tsx +0 -83
  9. package/CHANGELOG.md +0 -7
  10. package/dist/types/.storybook/main.d.ts +0 -3
  11. package/dist/types/.storybook/preview.d.ts +0 -3
  12. package/dist/types/jest-setup-after-env.d.ts +0 -1
  13. package/dist/types/src/byline/Byline.stories.d.ts +0 -206
  14. package/dist/types/src/byline/Byline.test.d.ts +0 -1
  15. package/dist/types/src/mocks/prosemirror-view.d.ts +0 -10
  16. package/eslint.config.js +0 -14
  17. package/jest-setup-after-env.ts +0 -1
  18. package/jest.config.js +0 -12
  19. package/rollup.config.js +0 -49
  20. package/src/byline/Byline.stories.tsx +0 -186
  21. package/src/byline/Byline.test.tsx +0 -450
  22. package/src/byline/Byline.tsx +0 -524
  23. package/src/byline/Preview.tsx +0 -59
  24. package/src/byline/contributors-fixture.ts +0 -1006
  25. package/src/byline/lib.test.ts +0 -179
  26. package/src/byline/lib.ts +0 -426
  27. package/src/byline/placeholder.ts +0 -30
  28. package/src/byline/plugins.ts +0 -186
  29. package/src/byline/schema.ts +0 -62
  30. package/src/byline/styles.ts +0 -246
  31. package/src/byline/theme.ts +0 -45
  32. package/src/byline/util.ts +0 -5
  33. package/src/index.ts +0 -2
  34. package/src/mocks/prosemirror-view.ts +0 -19
  35. package/tsconfig.json +0 -19
  36. /package/dist/types/{src/byline → byline}/Byline.d.ts +0 -0
  37. /package/dist/types/{src/byline → byline}/Preview.d.ts +0 -0
  38. /package/dist/types/{src/byline → byline}/contributors-fixture.d.ts +0 -0
  39. /package/dist/types/{src/byline → byline}/lib.d.ts +0 -0
  40. /package/dist/types/{src/byline → byline}/lib.test.d.ts +0 -0
  41. /package/dist/types/{src/byline → byline}/placeholder.d.ts +0 -0
  42. /package/dist/types/{src/byline → byline}/plugins.d.ts +0 -0
  43. /package/dist/types/{src/byline → byline}/schema.d.ts +0 -0
  44. /package/dist/types/{src/byline → byline}/styles.d.ts +0 -0
  45. /package/dist/types/{src/byline → byline}/theme.d.ts +0 -0
  46. /package/dist/types/{src/byline → byline}/util.d.ts +0 -0
  47. /package/dist/types/{src/index.d.ts → index.d.ts} +0 -0
@@ -1,524 +0,0 @@
1
- import {
2
- createInvisiblesPlugin,
3
- nbSpace,
4
- space,
5
- } from '@guardian/prosemirror-invisibles';
6
- import { dropCursor } from 'prosemirror-dropcursor';
7
- import { history } from 'prosemirror-history';
8
- import type { Node } from 'prosemirror-model';
9
- import type { Transaction } from 'prosemirror-state';
10
- import { EditorState } from 'prosemirror-state';
11
- import { EditorView } from 'prosemirror-view';
12
- import type { FocusEventHandler} from 'react';
13
- import { useEffect, useRef, useState } from 'react';
14
- import type {
15
- BylineModel,
16
- TaggedContributor,
17
- TypingFromStartRange,
18
- } from './lib';
19
- import {
20
- addTaggedContributor,
21
- addUntaggedContributor,
22
- convertBylineModelToNode,
23
- convertNodeToBylineModel,
24
- getCurrentText,
25
- hasHitContributorLimit,
26
- } from './lib';
27
- import { createPlaceholderPlugin } from './placeholder';
28
- import { bylinePlugin, clipboardPlugin, keybindings } from './plugins';
29
- import { Preview } from './Preview';
30
- import { bylineEditorSchema } from './schema';
31
- import {
32
- bylineContainerStyles,
33
- bylineEditorStyles,
34
- dropdownContainerStyles,
35
- dropdownLiStyles,
36
- dropdownUlStyles,
37
- selectedDropdownLiStyles,
38
- } from './styles';
39
- import type { PartialBylineTheme } from './theme';
40
-
41
- type BylineProps = {
42
- theme?: PartialBylineTheme;
43
- allowUntaggedContributors?: boolean;
44
- contributorLimit?: number;
45
- enablePreview?: boolean;
46
- placeholder?: string;
47
- initialValue?: BylineModel;
48
- readOnly?: boolean;
49
- handleSave: (newValue: BylineModel) => void;
50
- searchContributors?: (selectedText: string) => Promise<TaggedContributor[]>;
51
- onBlur?: FocusEventHandler<HTMLDivElement>;
52
- };
53
-
54
- export const Byline = ({
55
- theme,
56
- allowUntaggedContributors,
57
- contributorLimit,
58
- enablePreview,
59
- placeholder,
60
- initialValue,
61
- readOnly,
62
- handleSave,
63
- searchContributors,
64
- onBlur,
65
- }: BylineProps) => {
66
- const editorRef = useRef<HTMLDivElement>(null);
67
- const viewRef = useRef<EditorView | null>(null);
68
- const dropdownRef = useRef<HTMLDivElement>(null);
69
- const [currentText, setCurrentText] = useState('');
70
- const [currentOptionIndex, setCurrentOptionIndex] = useState(0);
71
- const [taggedContributors, setTaggedContributors] = useState<
72
- TaggedContributor[]
73
- >([]);
74
- const [currentDoc, setCurrentDoc] = useState<Node | null>(null);
75
- const [showDropdown, setShowDropdown] = useState(false);
76
-
77
- // we have to useRef for these as they are read/modified during prosemirror transactions
78
- // rather than react lifecycle
79
- const isTypingFromStartRange = useRef<TypingFromStartRange | undefined>(
80
- undefined,
81
- );
82
-
83
- // track the range of positions typed from the start of the input field
84
- const trackTypingFromStart = (tr: Transaction) => {
85
- const isCursorSelection = tr.selection.from === tr.selection.to;
86
- const currentPosition = tr.selection.from;
87
- const hasChanges = tr.steps.length > 0;
88
-
89
- // early return and reset if not a cursor selection (single cursor, no range selection)
90
- if (!isCursorSelection) {
91
- isTypingFromStartRange.current = undefined;
92
- return;
93
- }
94
-
95
- // starting to type from the beginning, currentPosition is 1 as thats the index
96
- // where the cursor ends up
97
- if (
98
- !isTypingFromStartRange.current &&
99
- currentPosition === 1 &&
100
- hasChanges
101
- ) {
102
- isTypingFromStartRange.current = {
103
- start: 1,
104
- maxReached: 1,
105
- lastPosition: 1,
106
- };
107
- return;
108
- }
109
-
110
- // check if we're continuing to type within or extending the range
111
- if (isTypingFromStartRange.current) {
112
- const { start, maxReached, lastPosition } =
113
- isTypingFromStartRange.current;
114
-
115
- // check if current position is within our tracked range (allows cursor movement without changes)
116
- // or adjacent to extend the range (requires changes)
117
- const isWithinRange =
118
- currentPosition >= start && currentPosition <= maxReached;
119
- const isExtendingRange =
120
- hasChanges && currentPosition === maxReached + 1;
121
-
122
- if (isWithinRange || isExtendingRange) {
123
- // update the maximum reached position based on changes
124
- if (hasChanges) {
125
- // determine what happened based on position change from last transaction
126
- const positionDelta = currentPosition - lastPosition;
127
-
128
- if (positionDelta < 0) {
129
- // backspace/delete: content was removed, decrease maxReached by 1
130
- isTypingFromStartRange.current.maxReached =
131
- maxReached - 1;
132
- } else if (positionDelta > 0) {
133
- // content was added: increase maxReached by 1
134
- isTypingFromStartRange.current.maxReached =
135
- maxReached + 1;
136
- }
137
- // if positionDelta === 0, content was replaced at same position, no maxReached change needed
138
-
139
- // update lastPosition for next transaction
140
- isTypingFromStartRange.current.lastPosition =
141
- currentPosition;
142
- }
143
- return;
144
- }
145
- }
146
-
147
- // reset if none of the above conditions are met, no longer typing from start
148
- isTypingFromStartRange.current = undefined;
149
- };
150
-
151
- const getShouldShowDropdown = (
152
- view: EditorView,
153
- selectedText: string,
154
- ): boolean => {
155
- if (readOnly) {
156
- return false;
157
- }
158
-
159
- /**
160
- * Determines whether to hide or show the contributor dropdown based on component props,
161
- * and should be used in combination with the selected/current text when calling setShowDropdown
162
- * The logic evaluates to true when either:
163
- * - There are search contributors available OR
164
- * - Untagged contributors are allowed
165
- * - The document state is under any contributor limit
166
- * - The selected text is not empty
167
- */
168
- const showDropdownBasedOnProps =
169
- !!searchContributors || !!allowUntaggedContributors;
170
-
171
- if (hasHitContributorLimit(view.state.doc, contributorLimit)) {
172
- return false;
173
- }
174
-
175
- return selectedText.trim() !== '' && showDropdownBasedOnProps;
176
- };
177
-
178
- const [enterHit, setEnterHit] = useState(false);
179
-
180
- // We don't have access to the React state in the dom events handler
181
- const checkDropdownVisibility = () => {
182
- // This function is only newly available since 2024
183
- if (dropdownRef.current?.checkVisibility) {
184
- return dropdownRef.current.checkVisibility();
185
- } else {
186
- return dropdownRef.current?.offsetParent !== null;
187
- }
188
- };
189
-
190
- // Setup the prosemirror editor
191
- useEffect(() => {
192
- if (!editorRef.current) {
193
- return;
194
- }
195
-
196
- const initialDoc = convertBylineModelToNode(initialValue);
197
-
198
- const state = EditorState.create({
199
- schema: bylineEditorSchema,
200
- plugins: [
201
- dropCursor(),
202
- clipboardPlugin(allowUntaggedContributors, contributorLimit),
203
- history(),
204
- keybindings(),
205
- createInvisiblesPlugin([space, nbSpace], {
206
- displayLineEndSelection: true,
207
- shouldShowInvisibles: true,
208
- }),
209
- bylinePlugin(),
210
- createPlaceholderPlugin(placeholder ?? 'Enter a byline...'),
211
- ],
212
- doc: initialDoc,
213
- });
214
-
215
- // Set the initial document in the preview
216
- setCurrentDoc(initialDoc);
217
-
218
- viewRef.current = new EditorView(editorRef.current, {
219
- state,
220
- editable: () => !readOnly,
221
- attributes: {
222
- role: 'combobox',
223
- 'aria-label': 'byline',
224
- 'aria-controls': 'byline-dropdown',
225
- 'aria-expanded': 'false',
226
- 'data-testid': 'byline-input',
227
- ...(readOnly && { 'aria-readonly': 'true' }),
228
- },
229
- handleDOMEvents: {
230
- keydown: (view, event) => {
231
- if (readOnly) {
232
- return false;
233
- }
234
-
235
- // Handle escape key for dropdown
236
- if (event.key === 'Escape') {
237
- setShowDropdown(false);
238
-
239
- // Always return true to prevent other escape handlers
240
- return true;
241
- }
242
- if (event.key === 'ArrowDown') {
243
- if (checkDropdownVisibility()) {
244
- event.preventDefault();
245
- setCurrentOptionIndex((currentOptionIndex) => {
246
- return currentOptionIndex + 1;
247
- });
248
-
249
- return true;
250
- }
251
- return false;
252
- }
253
- if (event.key === 'ArrowUp') {
254
- if (checkDropdownVisibility()) {
255
- event.preventDefault();
256
-
257
- setCurrentOptionIndex((currentOptionIndex) => {
258
- return currentOptionIndex - 1;
259
- });
260
- return true;
261
- }
262
- return false;
263
- }
264
- if (event.key === 'Enter') {
265
- if (checkDropdownVisibility()) {
266
- event.preventDefault();
267
- setEnterHit(true);
268
- }
269
- return false;
270
- }
271
-
272
- return false;
273
- },
274
- blur: (_view, event) => {
275
- if (readOnly) {
276
- return false;
277
- }
278
-
279
- if (
280
- !dropdownRef.current?.contains(
281
- event.relatedTarget as HTMLElement,
282
- )
283
- ) {
284
- setShowDropdown(false);
285
- }
286
- return false;
287
- },
288
- },
289
- dispatchTransaction(tr) {
290
- if (readOnly) {
291
- // In readOnly mode, only allow selection, not document changes
292
- if (tr.docChanged) {
293
- // Block any transaction that changes the document
294
- return;
295
- }
296
- // Allow selection-only changes for text selection
297
- if (viewRef.current) {
298
- const newState = viewRef.current.state.apply(tr);
299
- viewRef.current.updateState(newState);
300
- }
301
- return;
302
- }
303
-
304
- if (viewRef.current?.hasFocus()) {
305
- trackTypingFromStart(tr);
306
-
307
- const newState = viewRef.current.state.apply(tr);
308
- viewRef.current.updateState(newState);
309
- const { selectedText } = getCurrentText(
310
- newState.doc,
311
- newState.selection.from,
312
- newState.selection.to,
313
- isTypingFromStartRange.current,
314
- );
315
-
316
- setCurrentText(selectedText);
317
-
318
- const shouldShowDropdown = getShouldShowDropdown(
319
- viewRef.current,
320
- selectedText,
321
- );
322
-
323
- if (shouldShowDropdown && searchContributors) {
324
- // Fetch contributors based on the selected text
325
- void searchContributors(selectedText)
326
- .then((contributors) => {
327
- setTaggedContributors(contributors);
328
- })
329
- .catch((error) => {
330
- console.error(
331
- 'Error fetching tagged contributors:',
332
- error,
333
- );
334
- setTaggedContributors([]);
335
- });
336
- } else {
337
- setTaggedContributors([]);
338
- }
339
- setShowDropdown(shouldShowDropdown);
340
-
341
- // Update the current document state after each transaction
342
- // if a transform step has happened
343
- if (tr.docChanged) {
344
- setCurrentDoc(newState.doc);
345
- handleSave(convertNodeToBylineModel(newState.doc));
346
- }
347
- }
348
- },
349
- });
350
-
351
- return () => {
352
- viewRef.current?.destroy();
353
- };
354
- // eslint-disable-next-line react-hooks/exhaustive-deps -- We only want to run this once
355
- }, []);
356
-
357
- // Handle dropdown functionality
358
- useEffect(() => {
359
- const numberOfOptions =
360
- taggedContributors.length + (allowUntaggedContributors ? 1 : 0);
361
- if (numberOfOptions) {
362
- // Going up from the first option
363
- if (currentOptionIndex < 0) {
364
- setCurrentOptionIndex(numberOfOptions - 1);
365
- } else {
366
- setCurrentOptionIndex(currentOptionIndex % numberOfOptions);
367
- }
368
- }
369
- if (showDropdown) {
370
- const editor = document.querySelector('[role=combobox]');
371
- editor?.setAttribute(
372
- 'aria-activedescendant',
373
- `contributor-option-${currentOptionIndex}`,
374
- );
375
- editor?.setAttribute('aria-expanded', 'true');
376
- }
377
- }, [
378
- currentOptionIndex,
379
- showDropdown,
380
- taggedContributors.length,
381
- allowUntaggedContributors,
382
- ]);
383
-
384
- // Handle dropdown scrolling when changing index
385
- useEffect(() => {
386
- if (showDropdown && currentOptionIndex >= 0) {
387
- const selectedOption = document.getElementById(
388
- `contributor-option-${currentOptionIndex}`,
389
- );
390
- if (selectedOption && dropdownRef.current) {
391
- selectedOption.scrollIntoView({
392
- behavior: 'smooth',
393
- block: 'nearest',
394
- });
395
- }
396
- }
397
- }, [currentOptionIndex, showDropdown]);
398
-
399
- // Handle Enter key press in the dropdown
400
- useEffect(() => {
401
- if (enterHit) {
402
- if (
403
- allowUntaggedContributors &&
404
- currentOptionIndex === taggedContributors.length
405
- ) {
406
- addUntaggedContributor(
407
- viewRef,
408
- setShowDropdown,
409
- contributorLimit,
410
- isTypingFromStartRange.current,
411
- );
412
- } else {
413
- const contributorToAdd = taggedContributors[currentOptionIndex];
414
-
415
- if (contributorToAdd) {
416
- addTaggedContributor(
417
- contributorToAdd,
418
- viewRef,
419
- setShowDropdown,
420
- contributorLimit,
421
- isTypingFromStartRange.current,
422
- );
423
- }
424
- }
425
-
426
- setEnterHit(false);
427
- }
428
- }, [
429
- currentOptionIndex,
430
- enterHit,
431
- taggedContributors,
432
- contributorLimit,
433
- allowUntaggedContributors,
434
- ]);
435
-
436
- return (
437
- <div css={bylineContainerStyles}>
438
- <div css={bylineEditorStyles(theme?.editor)} ref={editorRef} onBlur={onBlur} />
439
- <div
440
- ref={dropdownRef}
441
- tabIndex={0}
442
- css={dropdownContainerStyles(
443
- showDropdown &&
444
- // show the dropdown if there are tagged contributors to select or untagged contributors are allowed
445
- (taggedContributors.length > 0 ||
446
- !!allowUntaggedContributors),
447
- theme?.dropdown,
448
- )}
449
- >
450
- <ul id="byline-dropdown" role="listbox" css={dropdownUlStyles}>
451
- {taggedContributors.map((contributor, i) => (
452
- <li
453
- key={contributor.tagId}
454
- id={`contributor-option-${i}`}
455
- role="option"
456
- aria-selected={i === currentOptionIndex}
457
- css={[
458
- dropdownLiStyles(theme),
459
- i === currentOptionIndex &&
460
- selectedDropdownLiStyles(theme),
461
- ]}
462
- onMouseMove={() => {
463
- if (currentOptionIndex !== i) {
464
- setCurrentOptionIndex(i);
465
- }
466
- }}
467
- onMouseDown={(e) => {
468
- e.preventDefault(); // Prevent focus loss
469
- addTaggedContributor(
470
- contributor,
471
- viewRef,
472
- setShowDropdown,
473
- contributorLimit,
474
- isTypingFromStartRange.current,
475
- );
476
- }}
477
- >
478
- {contributor.internalLabel ?? contributor.label}
479
- </li>
480
- ))}
481
- {allowUntaggedContributors && (
482
- <li
483
- role="option"
484
- id={`contributor-option-${taggedContributors.length}`}
485
- aria-selected={
486
- currentOptionIndex === taggedContributors.length
487
- }
488
- css={[
489
- dropdownLiStyles(theme),
490
- currentOptionIndex ===
491
- taggedContributors.length &&
492
- selectedDropdownLiStyles(theme),
493
- ]}
494
- onMouseMove={() => {
495
- if (
496
- currentOptionIndex !==
497
- taggedContributors.length
498
- ) {
499
- setCurrentOptionIndex(
500
- taggedContributors.length,
501
- );
502
- }
503
- }}
504
- onMouseDown={(e) => {
505
- e.preventDefault(); // Prevent focus loss
506
- addUntaggedContributor(
507
- viewRef,
508
- setShowDropdown,
509
- contributorLimit,
510
- isTypingFromStartRange.current,
511
- );
512
- }}
513
- >
514
- Add &quot;{currentText}&quot; as untagged
515
- contributor
516
- </li>
517
- )}
518
- </ul>
519
- </div>
520
-
521
- {enablePreview && <Preview doc={currentDoc} />}
522
- </div>
523
- );
524
- };
@@ -1,59 +0,0 @@
1
- import type { Node } from 'prosemirror-model';
2
- import {
3
- previewContributorStyles,
4
- previewFreeTextStyles,
5
- previewStyles,
6
- } from './styles';
7
-
8
- export const Preview = ({ doc }: { doc: Node | null }) => {
9
- if (doc?.childCount === undefined || doc.childCount === 0) {
10
- return null;
11
- }
12
-
13
- const parts: Node[] = [];
14
-
15
- doc.descendants((node) => {
16
- parts.push(node);
17
- });
18
-
19
- return (
20
- <div css={previewStyles} data-testid="byline-preview">
21
- {parts.map((node, i) => {
22
- if (node.isText) {
23
- return (
24
- <span
25
- key={`${node.text}${i}`}
26
- css={previewFreeTextStyles}
27
- >
28
- {node.text}
29
- </span>
30
- );
31
- } else if (node.type.name === 'chip') {
32
- if (node.attrs.path) {
33
- return (
34
- <a
35
- key={`${node.text}${i}`}
36
- css={previewContributorStyles(node)}
37
- href={`https://theguardian.com/${node.attrs.path}`}
38
- target="_blank"
39
- rel="noopener noreferrer"
40
- >
41
- {node.attrs.label as string}
42
- </a>
43
- );
44
- } else {
45
- return (
46
- <span
47
- key={`${node.text}${i}`}
48
- css={previewContributorStyles(node)}
49
- >
50
- {node.attrs.label as string}
51
- </span>
52
- );
53
- }
54
- }
55
- return null;
56
- })}
57
- </div>
58
- );
59
- };