diff --git a/frontend/website/cypress/e2e/set-layout.cy.ts b/frontend/website/cypress/e2e/set-layout.cy.ts index aad6b24..0e5a04b 100644 --- a/frontend/website/cypress/e2e/set-layout.cy.ts +++ b/frontend/website/cypress/e2e/set-layout.cy.ts @@ -51,7 +51,7 @@ describe('set element layout', () => { interceptAuthenticatedUser() }) - it('opens a set from the dashboard and shows its full hierarchy', () => { + it('opens a set with a foldable element hierarchy', () => { cy.intercept('GET', '**/api/sets', { statusCode: 200, body: { sets: [{ id: 41, name: 'Bible' }] }, @@ -79,13 +79,35 @@ describe('set element layout', () => { cy.get('[data-element-id="1"] > .element-node__card') .should('contain.text', 'Genesis') .and('contain.text', 'book') + .and('have.attr', 'aria-expanded', 'false') + .and('have.attr', 'aria-controls', 'element-children-1') + cy.get('[data-element-id="2"] > .element-node__card').should( + 'not.match', + 'button', + ) + cy.get('[data-element-id="3"]').should('not.exist') + + cy.get('[data-element-id="1"] > button.element-node__card').click() + cy.get('[data-element-id="1"] > .element-node__card').should( + 'have.attr', + 'aria-expanded', + 'true', + ) cy.get('[data-element-id="1"] > ol > li').then(($nodes) => { expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ '3', '5', ]) }) - cy.get('[data-element-id="3"] > ol > li') + cy.get('[data-element-id="4"]').should('not.exist') + + cy.get('[data-element-id="3"] > button.element-node__card').click() + cy.get('[data-element-id="3"] > .element-node__card').should( + 'have.attr', + 'aria-expanded', + 'true', + ) + cy.get('#element-children-3 > li') .should('have.length', 1) .and('have.attr', 'data-element-id', '4') cy.contains('.element-node__card', 'Chapter 1') @@ -93,6 +115,15 @@ describe('set element layout', () => { .find('.element-node__kind') .should('have.text', 'Chapter_sections-v2') .and('have.css', 'text-transform', 'none') + + cy.get('[data-element-id="1"] > button.element-node__card').click() + cy.get('[data-element-id="3"]').should('not.exist') + + cy.get('[data-element-id="1"] > button.element-node__card').click() + cy.get('[data-element-id="4"] > .element-node__card').should( + 'contain.text', + 'Chapter 1', + ) }) it('shows loading and empty layout states', () => { @@ -183,6 +214,8 @@ describe('set element layout', () => { cy.wait('@me') cy.wait('@layout') + cy.get('[data-element-id="1"] > button.element-node__card').click() + cy.get('[data-element-id="3"] > button.element-node__card').click() cy.contains('.element-node__card', 'Chapter 1').should('be.visible') cy.document().then((document) => { expect(document.documentElement.scrollWidth).to.be.at.most( diff --git a/frontend/website/src/components/ElementTree.vue b/frontend/website/src/components/ElementTree.vue index 54d7e39..e0ca8a4 100644 --- a/frontend/website/src/components/ElementTree.vue +++ b/frontend/website/src/components/ElementTree.vue @@ -1,10 +1,23 @@ @@ -69,6 +109,23 @@ defineProps<{ box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%); } +.element-node__card--toggle { + width: 100%; + color: inherit; + text-align: left; + cursor: pointer; +} + +.element-node__card--toggle:hover { + border-color: rgb(40 92 78 / 34%); + background: #fffdf7; +} + +.element-node__card--toggle:focus-visible { + outline: 3px solid rgb(86 127 112 / 34%); + outline-offset: 0.2rem; +} + .element-node__name { min-width: 0; color: #183029; @@ -78,6 +135,13 @@ defineProps<{ overflow-wrap: anywhere; } +.element-node__metadata { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.8rem; +} + .element-node__kind { flex: 0 0 auto; padding: 0.35rem 0.55rem; @@ -90,6 +154,18 @@ defineProps<{ text-transform: none; } +.element-node__chevron { + width: 0.55rem; + height: 0.55rem; + border-right: 2px solid #5e7067; + border-bottom: 2px solid #5e7067; + transform: rotate(-45deg); +} + +.element-node__chevron--expanded { + transform: rotate(45deg); +} + @media (max-width: 37.5rem) { .element-tree { margin-left: 0.45rem; @@ -106,6 +182,10 @@ defineProps<{ padding: 0.75rem 0.7rem; } + .element-node__metadata { + gap: 0.6rem; + } + .element-node__kind { padding-inline: 0.42rem; font-size: 0.56rem; diff --git a/frontend/website/src/views/SetLayoutView.vue b/frontend/website/src/views/SetLayoutView.vue index 6edf2b9..9b86551 100644 --- a/frontend/website/src/views/SetLayoutView.vue +++ b/frontend/website/src/views/SetLayoutView.vue @@ -11,6 +11,7 @@ const route = useRoute() const setLayoutStore = useSetLayoutStore() const { layout, loading, error, notFound } = storeToRefs(setLayoutStore) const currentSetId = ref(null) +const expandedElementIds = ref(new Set()) const EMPTY_MESSAGE = 'This set does not have any elements yet.' watch( @@ -19,6 +20,7 @@ watch( const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter const setId = Number(rawSetId) currentSetId.value = setId + expandedElementIds.value = new Set() await setLayoutStore.fetchSetLayout(setId) }, { immediate: true }, @@ -29,8 +31,21 @@ async function retry(): Promise { return } + expandedElementIds.value = new Set() await setLayoutStore.fetchSetLayout(currentSetId.value) } + +function toggleElement(elementId: number): void { + const nextExpandedElementIds = new Set(expandedElementIds.value) + + if (nextExpandedElementIds.has(elementId)) { + nextExpandedElementIds.delete(elementId) + } else { + nextExpandedElementIds.add(elementId) + } + + expandedElementIds.value = nextExpandedElementIds +}