From 957be00ccdce7abced72fca73ac7a66e8f359c94 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 23 Sep 2026 14:49:25 +0200 Subject: [PATCH] fix(details): use a single open flag Also make sure that the section is opened when the selection moves into its content (e.g. via anchor link). Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5.1 --- src/nodes/Details.js | 12 ----- src/nodes/DetailsView.vue | 49 ++++++++++++++----- .../nodes/DetailsViewAccessibility.spec.ts | 38 ++++++++++---- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/src/nodes/Details.js b/src/nodes/Details.js index 01b814f245c..f01264172d5 100644 --- a/src/nodes/Details.js +++ b/src/nodes/Details.js @@ -64,9 +64,6 @@ const Details = Node.create({ addAttributes() { return { - openDetails: { - default: false, - }, open: { default: false, parseHTML: (element) => element.hasAttribute('open'), @@ -131,9 +128,6 @@ const Details = Node.create({ }, { type: this.name, - attrs: { - openDetails: true, - }, content: [ { type: 'detailsSummary' }, { @@ -200,12 +194,6 @@ const Details = Node.create({ } const details = detailsParentInfo($from, schema) - if (!details.node.attrs.openDetails) { - editor.commands.updateAttributes('details', { - openDetails: true, - }) - } - const detailsContent = childFromNode( details.node, schema.nodes.detailsContent, diff --git a/src/nodes/DetailsView.vue b/src/nodes/DetailsView.vue index 2ec5c094d4e..9e8696ff7df 100644 --- a/src/nodes/DetailsView.vue +++ b/src/nodes/DetailsView.vue @@ -39,12 +39,17 @@ export default { }, props: { + editor: { + type: Object, + required: true, + }, + node: { type: Object, required: true, }, - updateAttributes: { + getPos: { type: Function, required: true, }, @@ -57,23 +62,18 @@ export default { }, watch: { - 'node.attrs.openDetails': function(open) { - if (open) { - this.open = true - this.updateAttributes({ openDetails: false }) - } - }, - 'node.attrs.open': function(open) { this.open = open }, }, beforeMount() { - this.open = this.node.attrs.open || this.node.attrs.openDetails - if (this.node.attrs.openDetails) { - this.updateAttributes({ openDetails: false }) - } + this.open = this.node.attrs.open || this.selectionInside(this.nodeRange()) + this.editor.on('selectionUpdate', this.onSelectionUpdate) + }, + + beforeUnmount() { + this.editor.off('selectionUpdate', this.onSelectionUpdate) }, methods: { @@ -82,6 +82,31 @@ export default { toggleOpen() { this.open = !this.open }, + + onSelectionUpdate() { + if (this.open) { + return + } + this.open = this.selectionInside(this.contentRange()) + }, + + nodeRange() { + const from = this.getPos() + return { from, to: from + this.node.nodeSize } + }, + + contentRange() { + const { from, to } = this.nodeRange() + return { + from: from + 1 + this.node.firstChild.nodeSize, + to: to - 1, + } + }, + + selectionInside({ from, to }) { + const { selection } = this.editor.state + return selection.from >= from && selection.to <= to + }, }, } diff --git a/src/tests/nodes/DetailsViewAccessibility.spec.ts b/src/tests/nodes/DetailsViewAccessibility.spec.ts index b1c9d51ab94..15f1c469b37 100644 --- a/src/tests/nodes/DetailsViewAccessibility.spec.ts +++ b/src/tests/nodes/DetailsViewAccessibility.spec.ts @@ -7,9 +7,11 @@ import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import DetailsView from '../../nodes/DetailsView.vue' -function mountDetails(attrs: { open: boolean, openDetails: boolean }, updateAttributes = vi.fn()) { - return mount(DetailsView, { - props: { node: { attrs }, updateAttributes }, +function mountDetails(attrs: { open: boolean }, selection = { from: 0, to: 0 }) { + const editor = { on: vi.fn(), off: vi.fn(), state: { selection } } + const node = { attrs, nodeSize: 8, firstChild: { nodeSize: 2 } } + const wrapper = mount(DetailsView, { + props: { editor, node, getPos: () => 10 }, global: { stubs: { NodeViewWrapper: { template: '
' }, @@ -22,11 +24,12 @@ function mountDetails(attrs: { open: boolean, openDetails: boolean }, updateAttr }, }, }) + return { wrapper, editor } } describe('DetailsView disclosure control', () => { it('names the button and toggles details from the button', async () => { - const wrapper = mountDetails({ open: false, openDetails: false }) + const { wrapper } = mountDetails({ open: false }) const button = wrapper.get('button') expect(button.attributes('aria-label')).toBe('Expand details') @@ -38,12 +41,29 @@ describe('DetailsView disclosure control', () => { expect(button.attributes('aria-expanded')).toBe('true') }) - it('opens persisted native details without clearing the stored state', () => { - const updateAttributes = vi.fn() - const wrapper = mountDetails({ open: true, openDetails: false }, updateAttributes) - + it('opens persisted native details', () => { + const { wrapper } = mountDetails({ open: true }) expect(wrapper.get('button').attributes('aria-label')).toBe('Collapse details') expect(wrapper.get('button').attributes('aria-expanded')).toBe('true') - expect(updateAttributes).not.toHaveBeenCalled() + }) + + it('starts open when mounted with the selection inside the node', () => { + const { wrapper } = mountDetails({ open: false }, { from: 12, to: 12 }) + expect(wrapper.get('button').attributes('aria-expanded')).toBe('true') + }) + + it('opens when the selection moves into the content', async () => { + const { wrapper, editor } = mountDetails({ open: false }) + const onSelectionUpdate = editor.on.mock.calls[0][1] + + editor.state.selection = { from: 12, to: 12 } + onSelectionUpdate() + await wrapper.vm.$nextTick() + expect(wrapper.get('button').attributes('aria-expanded')).toBe('false') + + editor.state.selection = { from: 14, to: 14 } + onSelectionUpdate() + await wrapper.vm.$nextTick() + expect(wrapper.get('button').attributes('aria-expanded')).toBe('true') }) })