1+ import { TextSelection } from "prosemirror-state" ;
12import { afterEach , beforeEach , describe , expect , it } from "vite-plus/test" ;
23
3- import type { PartialBlock } from "../../blocks/defaultBlocks.js" ;
4+ import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js" ;
5+ import {
6+ defaultBlockSpecs ,
7+ type PartialBlock ,
8+ } from "../../blocks/defaultBlocks.js" ;
49import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js" ;
10+ import { createBlockSpec } from "../../schema/index.js" ;
511import { CollapsibleExtension } from "./Collapsible.js" ;
612
713/**
@@ -26,8 +32,16 @@ afterEach(() => {
2632 window . localStorage . clear ( ) ;
2733} ) ;
2834
29- function createEditor ( initialContent : PartialBlock < any , any , any > [ ] ) {
30- const editor = BlockNoteEditor . create ( { initialContent } ) ;
35+ function createEditor (
36+ initialContent : PartialBlock < any , any , any > [ ] ,
37+ schema ?: BlockNoteSchema < any , any , any > ,
38+ ) : BlockNoteEditor < any , any , any > {
39+ // Spread rather than passed as `undefined`, which isn't the same as absent
40+ // here — it overrides the default schema with nothing.
41+ const editor = BlockNoteEditor . create ( {
42+ initialContent,
43+ ...( schema ? { schema } : { } ) ,
44+ } as any ) ;
3145 editor . mount ( document . createElement ( "div" ) ) ;
3246 activeEditors . push ( editor ) ;
3347
@@ -67,18 +81,44 @@ function addChildButton(editor: BlockNoteEditor<any, any, any>, id: string) {
6781 * equivalent: it replays only the *steps* a handler dispatched, so a selection
6882 * the handler set is dropped.
6983 */
70- function pressEnter ( editor : BlockNoteEditor < any , any , any > ) {
84+ function pressEnter (
85+ editor : BlockNoteEditor < any , any , any > ,
86+ { shift = false } : { shift ?: boolean } = { } ,
87+ ) {
7188 const view = editor . prosemirrorView ;
7289 const event = new KeyboardEvent ( "keydown" , {
7390 key : "Enter" ,
7491 code : "Enter" ,
92+ shiftKey : shift ,
7593 bubbles : true ,
7694 cancelable : true ,
7795 } ) ;
7896
7997 view . someProp ( "handleKeyDown" , ( handler ) => handler ( view , event ) ) ;
8098}
8199
100+ /** A collapsible block that takes no hard breaks, so Shift-Enter falls through. */
101+ const NO_HARD_BREAK_SCHEMA = BlockNoteSchema . create ( {
102+ blockSpecs : {
103+ ...defaultBlockSpecs ,
104+ collapsibleNoHardBreak : createBlockSpec (
105+ {
106+ type : "collapsibleNoHardBreak" ,
107+ propSchema : { } ,
108+ content : "inline" ,
109+ } ,
110+ {
111+ meta : { collapsible : true , hardBreakShortcut : "none" } ,
112+ render : ( ) => {
113+ const dom = document . createElement ( "p" ) ;
114+
115+ return { dom, contentDOM : dom } ;
116+ } ,
117+ } ,
118+ ) ( ) ,
119+ } ,
120+ } ) ;
121+
82122const TOGGLE_HEADING : PartialBlock < any , any , any > = {
83123 id : "toggle-heading" ,
84124 type : "heading" ,
@@ -171,6 +211,26 @@ describe("CollapsibleExtension", () => {
171211 expect ( chevron ( editor , "toggle-heading" ) ) . not . toBe ( null ) ;
172212 } ) ;
173213
214+ it ( "points the chevron at the group it discloses, for screen readers" , ( ) => {
215+ const editor = createEditor ( TOGGLE_HEADING_WITH_CHILD ) ;
216+ const button = chevron ( editor , "toggle-heading" ) ! ;
217+
218+ const controls = button . getAttribute ( "aria-controls" ) ;
219+ expect ( controls ) . toBe ( "bn-collapse-children-toggle-heading" ) ;
220+ // The link has to actually resolve, or it's worse than saying nothing.
221+ expect (
222+ editor . prosemirrorView . dom . querySelector ( `#${ controls } ` ) ?. className ,
223+ ) . toContain ( "bn-block-group" ) ;
224+
225+ // Nothing to point at while the block has no children.
226+ const childless = createEditor ( [
227+ { id : "toggle" , type : "toggleListItem" , content : "Toggle" } ,
228+ ] ) ;
229+ expect ( chevron ( childless , "toggle" ) ! . hasAttribute ( "aria-controls" ) ) . toBe (
230+ false ,
231+ ) ;
232+ } ) ;
233+
174234 // #2124
175235 it ( "drops the chevron when a toggle heading is converted to a plain heading" , ( ) => {
176236 const editor = createEditor ( TOGGLE_HEADING_WITH_CHILD ) ;
@@ -397,11 +457,66 @@ describe("CollapsibleExtension", () => {
397457 expect ( editor . document [ 0 ] . type ) . toBe ( "paragraph" ) ;
398458 expect ( editor . document [ 0 ] . children ) . toHaveLength ( 0 ) ;
399459
400- // A cursor part-way through the title splits it, keeping the children.
460+ // A cursor part-way through the title splits it, keeping the children on
461+ // the half that keeps the title.
401462 editor . setTextCursorPosition ( "toggle" , "start" ) ;
463+ editor . transact ( ( tr ) =>
464+ tr . setSelection ( TextSelection . create ( tr . doc , tr . selection . from + 3 ) ) ,
465+ ) ;
402466 pressEnter ( editor ) ;
403467 const toggle = editor . document . find ( ( block ) => block . id === "toggle" ) ! ;
404468 expect ( toggle . children . map ( ( child ) => child . id ) ) . toEqual ( [ "child" ] ) ;
405469 } ) ;
470+
471+ // At the very start there's no title left on the original half to keep the
472+ // children company, so they go with the text instead of being stranded on an
473+ // empty block. Notion likewise leaves the toggle intact and puts the new
474+ // block above it.
475+ it ( "moves the children with the title when splitting at the start" , ( ) => {
476+ const editor = createEditor ( [
477+ {
478+ id : "toggle" ,
479+ type : "toggleListItem" ,
480+ content : "Toggle" ,
481+ children : [ { id : "child" , type : "paragraph" , content : "Child" } ] ,
482+ } ,
483+ ] ) ;
484+ collapsible ( editor ) . setCollapsed ( { id : "toggle" } , false ) ;
485+
486+ editor . setTextCursorPosition ( "toggle" , "start" ) ;
487+ pressEnter ( editor ) ;
488+
489+ const [ empty , withTitle ] = editor . document ;
490+ expect ( empty . content ) . toEqual ( [ ] ) ;
491+ expect ( empty . children ) . toHaveLength ( 0 ) ;
492+ expect ( withTitle . content ) . toEqual ( [
493+ { type : "text" , text : "Toggle" , styles : { } } ,
494+ ] ) ;
495+ expect ( withTitle . children . map ( ( child ) => child . id ) ) . toEqual ( [ "child" ] ) ;
496+ } ) ;
497+
498+ // A block that opts out of hard breaks lets Shift-Enter fall through to the
499+ // default Enter chain. It should still split there rather than start a
500+ // child, the way it did before this handler existed. Needs a block with
501+ // `hardBreakShortcut: "none"`: with the default, the hard-break handler runs
502+ // first and this never gets the chance to go wrong.
503+ it ( "ignores Shift-Enter" , ( ) => {
504+ const editor = createEditor (
505+ [ { id : "toggle" , type : "collapsibleNoHardBreak" , content : "Toggle" } ] ,
506+ NO_HARD_BREAK_SCHEMA ,
507+ ) ;
508+ collapsible ( editor ) . setCollapsed ( { id : "toggle" } , false ) ;
509+ editor . setTextCursorPosition ( "toggle" , "end" ) ;
510+
511+ pressEnter ( editor , { shift : true } ) ;
512+
513+ expect ( editor . document ) . toHaveLength ( 2 ) ;
514+ expect ( editor . document [ 0 ] . children ) . toHaveLength ( 0 ) ;
515+
516+ // ...whereas plain Enter still starts a child.
517+ editor . setTextCursorPosition ( "toggle" , "end" ) ;
518+ pressEnter ( editor ) ;
519+ expect ( editor . document [ 0 ] . children ) . toHaveLength ( 1 ) ;
520+ } ) ;
406521 } ) ;
407522} ) ;
0 commit comments