diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65a640d..36495ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format of this document is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [unreleased]
+
+### Added
+- Collapse / expand functionality to tree view
+- Keyboard and screen reader support for collapse/expand, editing actions, and detail links
+
## [2.2.2] - 2023-06-06
### Added
diff --git a/src/components/ChildConcepts.tsx b/src/components/ChildConcepts.tsx
index 2f1b8aa..2d3b5bb 100644
--- a/src/components/ChildConcepts.tsx
+++ b/src/components/ChildConcepts.tsx
@@ -1,10 +1,11 @@
/**
* Child Concepts
* This component renders a list of child concepts for a given concept.
+ * - provides the
wrapper for each level of nesting
*/
-import {StyledChildConcepts} from '../styles'
import {ChildConceptTerm} from '../types'
+import {StyledChildConcepts} from '../styles'
import {Children} from './Children'
export const ChildConcepts = ({concepts}: {concepts: ChildConceptTerm[]}) => {
diff --git a/src/components/Children.tsx b/src/components/Children.tsx
index 85fcc6e..e015ef5 100644
--- a/src/components/Children.tsx
+++ b/src/components/Children.tsx
@@ -1,60 +1,103 @@
/**
* Child Concept Component
* Renders a list of child concepts for a given concept.
+ * @todo consider modularizing add and remove buttons
* @todo Add dialogue explaining max depth
+ * @todo Improve accessibility of hidden children and max depth disclosures
* @todo Handle childConcept and level definition checks more elegantly
*/
-import {useCallback, useContext} from 'react'
+import {useCallback, useContext, useState} from 'react'
import {Inline, Tooltip, Box, Stack, Text} from '@sanity/ui'
-import {ErrorOutlineIcon, InfoOutlineIcon, AddCircleIcon, TrashIcon} from '@sanity/icons'
+import {
+ ErrorOutlineIcon,
+ InfoOutlineIcon,
+ AddCircleIcon,
+ TrashIcon,
+ ToggleArrowRightIcon,
+ SquareIcon,
+} from '@sanity/icons'
import {useCreateConcept, useRemoveConcept} from '../hooks'
-import {StyledChildConcept} from '../styles'
import {ChildConceptTerm} from '../types'
+import {StyledChildConcept, StyledTreeButton, StyledTreeToggle} from '../styles'
import {SchemeContext} from './TreeView'
+import {TreeContext} from './Hierarchy'
import {ChildConcepts} from './ChildConcepts'
import {ConceptDetailLink} from './ConceptDetailLink'
import {ConceptDetailDialogue} from './ConceptDetailDialogue'
export const Children = ({concept}: {concept: ChildConceptTerm}) => {
const document: any = useContext(SchemeContext) || {}
+ //@ts-expect-error — This is part of the same complaint as in Hierarchy.tsx
+ const {treeVisibility} = useContext(TreeContext) || {}
const createConcept = useCreateConcept(document)
const removeConcept = useRemoveConcept(document)
const handleAddChild = useCallback(() => {
- if (document.displayed?._id === concept?.id) {
- // eslint-disable-next-line no-console
- console.log('Concept and document ids are the same.')
- return
- }
createConcept('concept', concept?.id, concept?.prefLabel)
- }, [concept?.id, concept?.prefLabel, createConcept, document.displayed?._id])
+ }, [concept?.id, concept?.prefLabel, createConcept])
const handleRemoveConcept = useCallback(() => {
removeConcept(concept.id, 'concept', concept?.prefLabel)
}, [concept.id, concept?.prefLabel, removeConcept])
+ const [levelVisibility, setLevelVisibility] = useState(treeVisibility)
+
+ const handleToggle = useCallback(() => {
+ if (levelVisibility == 'open') {
+ setLevelVisibility('closed')
+ } else if (levelVisibility == 'closed') {
+ setLevelVisibility('open')
+ }
+ }, [levelVisibility])
+
return (
-
+
-
+
+ {concept?.childConcepts && concept.childConcepts.length > 0 && (
+
+
+
+ )}
+ {concept?.childConcepts && concept.childConcepts.length == 0 && (
+
+ )}
{!concept?.prefLabel && [new concept]}
-
+
{!document.displayed?.controls && }
{document.displayed?.controls && concept?.level && concept.level < 5 && (
-
-
-
+
+
+
+
+
+
+
)}
{document.displayed?.controls &&
concept.childConcepts?.length == 0 &&
concept.level == 5 && (
-
+
@@ -68,9 +111,16 @@ export const Children = ({concept}: {concept: ChildConceptTerm}) => {
fallbackPlacements={['right', 'left']}
placement="top"
>
-
+
-
+
+
+
)}
@@ -92,10 +142,17 @@ export const Children = ({concept}: {concept: ChildConceptTerm}) => {
fallbackPlacements={['right', 'left']}
placement="top"
>
-
+
{document.displayed?.controls && (
-
+
+
+
)}
)}
diff --git a/src/components/ConceptDetailDialogue.tsx b/src/components/ConceptDetailDialogue.tsx
index 77ab6d0..0a595d0 100644
--- a/src/components/ConceptDetailDialogue.tsx
+++ b/src/components/ConceptDetailDialogue.tsx
@@ -1,51 +1,26 @@
/**
* Information Icon and Dialogue with Concept Details
- * Affords Tree View access to Definition, Examples, and Scope Notes
+ * - affords Tree View access to Definition, Examples, and Scope Notes
+ * - is rendered only when concept details are present
*/
import {useCallback, useState} from 'react'
import {Dialog, Box, Text, Stack, Label} from '@sanity/ui'
import {InfoOutlineIcon} from '@sanity/icons'
-import {InfoDialog} from '../styles'
+import {StyledTreeButton} from '../styles'
export const ConceptDetailDialogue = ({concept}: {concept: any}) => {
const [open, setOpen] = useState(false)
const onClose = useCallback(() => setOpen(false), [])
const onOpen = useCallback(() => setOpen(true), [])
- if (!concept) return null
- else if (!concept.definition && !concept.example && !concept.scopeNote) {
- return null
-
- // To Investigate: Showing disabled icons in the absence of explanatory notes.
- // The goal is to encourage editors to provide descriptions, but the better
- // practice is likely simply not to show the icon if there is no content there.
- // For the moment, defaulting to showing no icon.
-
- // return (
- //
- //
- //
- //
- // This concept has no explanatory notes.
- //
- //
- //
- // }
- // fallbackPlacements={['right', 'left']}
- // placement="top"
- // >
- //
- //
- //
- // )
- }
+ if (!concept || (!concept.definition && !concept.example && !concept.scopeNote)) return null
return (
-
-
+ <>
+
+
+
{open && (
)}
-
+ >
)
}
diff --git a/src/components/ConceptDetailLink.tsx b/src/components/ConceptDetailLink.tsx
index 02e7ea2..c708f1d 100644
--- a/src/components/ConceptDetailLink.tsx
+++ b/src/components/ConceptDetailLink.tsx
@@ -1,14 +1,13 @@
/**
* Concept Detail Link
* Renders a link to a concept in the hierarchy tree that opens in a new pane.
- * @todo Adapt to use the New Concept Pane hook (it's death spiraling at the moment)
*/
import {useCallback, useContext} from 'react'
import {usePaneRouter} from 'sanity/desk'
import {RouterContext} from 'sanity/router'
-import {StyledConceptLink} from '../styles'
import {ChildConceptTerm} from '../types'
+import {StyledConceptLink} from '../styles'
export function ConceptDetailLink({concept}: {concept: ChildConceptTerm}) {
const routerContext = useContext(RouterContext)
@@ -33,5 +32,9 @@ export function ConceptDetailLink({concept}: {concept: ChildConceptTerm}) {
routerContext.navigateUrl({path: href})
}, [id, routerContext, routerPanesState, groupIndex])
- return {prefLabel}
+ return (
+
+ {prefLabel}
+
+ )
}
diff --git a/src/components/Hierarchy.tsx b/src/components/Hierarchy.tsx
index 3b59e7a..c5a4be8 100644
--- a/src/components/Hierarchy.tsx
+++ b/src/components/Hierarchy.tsx
@@ -1,21 +1,31 @@
/**
- * Concept Scheme Tree View
+ * Hierarchy Component
+ * - Provides a frame for global controls and tree structure
* - Fetches the complete tree of concepts in a concept scheme.
* - Displays the tree in a nested list.
* @todo type document, likely via extended SanityDocument type.
*/
-import {useCallback, useContext} from 'react'
-import {Flex, Spinner, Stack, Box, Text, Inline, Button} from '@sanity/ui'
-import {AddIcon} from '@sanity/icons'
+import {createContext, useCallback, useContext, useState} from 'react'
+import {Flex, Spinner, Stack, Box, Text, Inline} from '@sanity/ui'
+import {AddCircleIcon} from '@sanity/icons'
+import {randomKey} from '@sanity/util/content'
import {useListeningQuery} from 'sanity-plugin-utils'
import {useCreateConcept} from '../hooks'
import {trunkBuilder} from '../queries'
import {DocumentConcepts} from '../types'
+import {HierarchyButton} from '../styles'
import {SchemeContext} from './TreeView'
import {TreeStructure} from './TreeStructure'
import {NewScheme} from './guides'
+export const TreeContext = createContext(null)
+
+type GlobalVisibility = {
+ treeId: string
+ treeVisibility: string
+}
+
export const Hierarchy = () => {
const document: any = useContext(SchemeContext) || {}
const documentId = document.displayed?._id
@@ -30,6 +40,22 @@ export const Hierarchy = () => {
createConcept('concept')
}, [createConcept])
+ // `randomKey` is used on treeId to initiate a re-rendering of all child
+ // elements on expand/collapse and re-initialize any local toggle state
+ // that had been set.
+ const [globalVisibility, setGlobalVisibility] = useState({
+ treeId: randomKey(3),
+ treeVisibility: 'open',
+ })
+
+ const handleExpand = useCallback(() => {
+ setGlobalVisibility({treeId: randomKey(3), treeVisibility: 'open'})
+ }, [])
+
+ const handleCollapse = useCallback(() => {
+ setGlobalVisibility({treeId: randomKey(3), treeVisibility: 'closed'})
+ }, [])
+
const {data, loading, error} = useListeningQuery(
{
fetch: trunkBuilder(),
@@ -61,37 +87,59 @@ export const Hierarchy = () => {
return
}
return (
-
-
-
-
- Hierarchy Tree
-
-
- Hierarchy is determined by the 'Broader' relationships assigned to each concept.
-
-
- {document.displayed?.controls && (
-
-
-
+ // @ts-expect-error — The compiler complains about this being null.
+ // I suspect this is an error.
+
+
+
+
+
+ Hierarchy Tree
+
+
+ Hierarchy is determined by the 'Broader' relationships assigned to each concept.
+
+
+
+ {(data.topConcepts?.filter((concept) => (concept?.childConcepts?.length ?? 0) > 0)
+ .length > 0 ||
+ data.orphans?.filter((concept) => (concept?.childConcepts?.length ?? 0) > 0).length >
+ 0) && (
+
+
+
+ Collapse
+
+
+
+ |
+
+
+
+ Expand
+
+
+
+ )}
+ {document.displayed?.controls && (
+
+
+
+ Add Top Concept
+
+
+
+
+ Add Concept
+
+
+
+ )}
- )}
-
-
-
+
+
+
+
)
}
diff --git a/src/components/Orphans.tsx b/src/components/Orphans.tsx
index c6ab99f..af298ea 100644
--- a/src/components/Orphans.tsx
+++ b/src/components/Orphans.tsx
@@ -1,24 +1,40 @@
/**
* Orphan Concept Component
* Renders a list of orphan concepts for a given schema.
+ * @todo consider modularizing add and remove buttons
*/
-import {useCallback, useContext} from 'react'
+import {useCallback, useContext, useState} from 'react'
import {Text, Inline, Tooltip, Box, Stack} from '@sanity/ui'
-import {AddCircleIcon, TrashIcon} from '@sanity/icons'
+import {AddCircleIcon, SquareIcon, ToggleArrowRightIcon, TrashIcon} from '@sanity/icons'
import {useCreateConcept, useRemoveConcept} from '../hooks'
-import {StyledOrphan} from '../styles'
import {ChildConceptTerm} from '../types'
+import {StyledOrphan, StyledTreeButton, StyledTreeToggle} from '../styles'
import {SchemeContext} from './TreeView'
import {ChildConcepts} from './ChildConcepts'
import {ConceptDetailLink} from './ConceptDetailLink'
import {ConceptDetailDialogue} from './ConceptDetailDialogue'
-export const Orphans = ({concept}: {concept: ChildConceptTerm}) => {
+type OrphanProps = {
+ concept: ChildConceptTerm
+ treeVisibility: string
+}
+
+export const Orphans = ({concept, treeVisibility}: OrphanProps) => {
const document: any = useContext(SchemeContext) || {}
const createConcept = useCreateConcept(document)
const removeConcept = useRemoveConcept(document)
+ const [levelVisibility, setLevelVisibility] = useState(treeVisibility)
+
+ const handleToggle = useCallback(() => {
+ if (levelVisibility == 'open') {
+ setLevelVisibility('closed')
+ } else if (levelVisibility == 'closed') {
+ setLevelVisibility('open')
+ }
+ }, [levelVisibility])
+
const handleAddChild = useCallback(() => {
createConcept('concept', concept?.id, concept?.prefLabel)
}, [concept?.id, concept?.prefLabel, createConcept])
@@ -28,8 +44,20 @@ export const Orphans = ({concept}: {concept: ChildConceptTerm}) => {
}, [concept.id, concept?.prefLabel, removeConcept])
return (
-
+
+ {concept?.childConcepts && concept.childConcepts.length > 0 && (
+
+
+
+ )}
+ {concept?.childConcepts && concept.childConcepts.length == 0 && (
+
+ )}
{!concept?.prefLabel && [new concept]}
{document.displayed?.topConcepts?.length > 0 && (
@@ -39,7 +67,7 @@ export const Orphans = ({concept}: {concept: ChildConceptTerm}) => {
)}
{!document.displayed?.controls && }
{document.displayed?.controls && (
- <>
+
@@ -53,8 +81,14 @@ export const Orphans = ({concept}: {concept: ChildConceptTerm}) => {
fallbackPlacements={['right', 'left']}
placement="top"
>
- {/* Pass props to identify this element to an event handler */}
-
+
+
+ {
fallbackPlacements={['right', 'left']}
placement="top"
>
-
+
+
+
- >
+
)}
{concept?.childConcepts && concept.childConcepts.length > 0 && (
diff --git a/src/components/TopConcepts.tsx b/src/components/TopConcepts.tsx
index ba2c445..d57585e 100644
--- a/src/components/TopConcepts.tsx
+++ b/src/components/TopConcepts.tsx
@@ -1,24 +1,40 @@
/**
* Top Concept Component
* Renders a list of top concepts for a given schema.
+ * @todo consider modularizing add and remove buttons
*/
-import {useCallback, useContext} from 'react'
+import {useCallback, useContext, useState} from 'react'
import {Text, Inline, Tooltip, Box, Stack} from '@sanity/ui'
-import {AddCircleIcon, TrashIcon} from '@sanity/icons'
+import {AddCircleIcon, TrashIcon, ToggleArrowRightIcon, SquareIcon} from '@sanity/icons'
import {useCreateConcept, useRemoveConcept} from '../hooks'
-import {StyledTopConcept} from '../styles'
import {TopConceptTerm} from '../types'
-import {ChildConcepts} from './ChildConcepts'
+import {StyledTopConcept, StyledTreeToggle, StyledTreeButton} from '../styles'
import {SchemeContext} from './TreeView'
+import {ChildConcepts} from './ChildConcepts'
import {ConceptDetailLink} from './ConceptDetailLink'
import {ConceptDetailDialogue} from './ConceptDetailDialogue'
-export const TopConcepts = ({concept}: {concept: TopConceptTerm}) => {
+type TopConceptsProps = {
+ concept: TopConceptTerm
+ treeVisibility: string
+}
+
+export const TopConcepts = ({concept, treeVisibility}: TopConceptsProps) => {
const document: any = useContext(SchemeContext) || {}
const createConcept = useCreateConcept(document)
const removeConcept = useRemoveConcept(document)
+ const [levelVisibility, setLevelVisibility] = useState(treeVisibility)
+
+ const handleToggle = useCallback(() => {
+ if (levelVisibility == 'open') {
+ setLevelVisibility('closed')
+ } else if (levelVisibility == 'closed') {
+ setLevelVisibility('open')
+ }
+ }, [levelVisibility])
+
const handleAddChild = useCallback(() => {
createConcept('concept', concept?.id, concept?.prefLabel)
}, [concept?.id, concept?.prefLabel, createConcept])
@@ -28,16 +44,30 @@ export const TopConcepts = ({concept}: {concept: TopConceptTerm}) => {
}, [concept?.id, concept?.prefLabel, removeConcept])
return (
-
+
- {!concept?.prefLabel && [new concept]}
-
+
+ {concept?.childConcepts && concept.childConcepts.length > 0 && (
+
+
+
+ )}
+ {concept?.childConcepts && concept.childConcepts.length == 0 && (
+
+ )}
+ {!concept?.prefLabel && [new concept]}
+
+
top concept
{!document.displayed?.controls && }
{document.displayed?.controls && (
- <>
+
@@ -51,7 +81,14 @@ export const TopConcepts = ({concept}: {concept: TopConceptTerm}) => {
fallbackPlacements={['right', 'left']}
placement="top"
>
-
+
+
+ {
fallbackPlacements={['right', 'left']}
placement="top"
>
-
+
+
+
- >
+
)}
{concept?.childConcepts && concept.childConcepts.length > 0 && (
diff --git a/src/components/TreeStructure.tsx b/src/components/TreeStructure.tsx
index 9385188..2ce6da6 100644
--- a/src/components/TreeStructure.tsx
+++ b/src/components/TreeStructure.tsx
@@ -1,27 +1,41 @@
/**
- * Concept Scheme Tree View
- * - Fetches the complete tree of concepts in a concept scheme.
+ * Tree View
+ * - Fetches the complete tree of concepts in a concept scheme, stemming
+ * from Top Concepts or Orphans
* - Displays the tree in a nested list.
- * @todo Add functionality to expand/collapse the tree.
*/
-import {StyledTree} from '../styles'
+import {useContext} from 'react'
import {DocumentConcepts, TopConceptTerm, ChildConceptTerm} from '../types'
+import {StyledTree} from '../styles'
+import {TreeContext} from './Hierarchy'
import {TopConcepts} from './TopConcepts'
import {Orphans} from './Orphans'
import {NoConcepts} from './guides'
export const TreeStructure = ({concepts}: {concepts: DocumentConcepts}) => {
+ // @ts-expect-error — I think this is the same complier issue as Hierarchy.tsx
+ // To investigate.
+ const {treeId, treeVisibility} = useContext(TreeContext)
+
if (concepts.topConcepts === null && concepts.orphans.length === 0) return
return (
{concepts.topConcepts &&
concepts.topConcepts.map((concept: TopConceptTerm) => {
- return
+ return (
+
+ )
})}
{concepts.orphans.map((concept: ChildConceptTerm) => {
- return
+ return (
+
+ )
})}
)
diff --git a/src/components/TreeView.tsx b/src/components/TreeView.tsx
index e24f2c3..8d5e5ea 100644
--- a/src/components/TreeView.tsx
+++ b/src/components/TreeView.tsx
@@ -1,11 +1,11 @@
/**
- * Tree View
+ * Tree View Component Wrapper
* This is the view component for the hierarchy tree. It is the
* top level of concept scheme views and is passed into Desk
* structure to render the primary view for taxonomy documents.
* @todo Extend SanityDocument type to include display properties.
- * What is the type of the document object returned by the Desk
- * structure?
+ * What is the type of the document object returned by the Desk
+ * structure?
*/
import {createContext, CSSProperties} from 'react'
diff --git a/src/styles.ts b/src/styles.ts
index de929db..ae6cbe5 100644
--- a/src/styles.ts
+++ b/src/styles.ts
@@ -9,84 +9,140 @@ export const InlineHelp = styled.div`
margin-top: 2rem;
`
-export const InfoDialog = styled.div`
- svg {
+export const StyledTree = styled.ul`
+ list-style: none;
+ padding-left: 0;
+ margin-block-start: 0;
+ li svg.info {
height: 1.2rem;
width: 1.2rem;
color: ${hues.gray[800].hex};
border-radius: 3px;
transition: all 0.1s ease-in-out;
- &.brand:hover {
+ &.warning:hover {
color: ${hues.gray[100].hex};
- background-color: ${hues.blue[400].hex};
+ background-color: ${hues.yellow[500].hex};
}
- &.default {
- color: ${hues.gray[300].hex};
+ &.error {
+ color: ${hues.red[500].hex};
&:hover {
color: ${hues.gray[100].hex};
- background-color: ${hues.gray[600].hex};
+ background-color: ${hues.red[500].hex};
}
}
}
+ li svg.spacer {
+ height: 1.5rem;
+ width: 1.5rem;
+ visibility: hidden;
+ }
`
-
-export const StyledTree = styled.ul`
- list-style: none;
- padding-left: 0;
- margin-block-start: 0;
- li svg {
- height: 1.2rem;
- width: 1.2rem;
+export const HierarchyButton = styled.button`
+ background: none;
+ border: none;
+ padding: 0.5rem;
+ border-radius: 3px;
+ cursor: pointer;
+ svg {
+ padding-right: 0.25rem;
+ }
+ &:hover {
+ background-color: ${hues.gray[50].hex};
+ }
+ &.add:hover {
+ background-color: ${hues.green[500].hex};
+ span {
+ color: white;
+ }
+ }
+`
+export const StyledTreeButton = styled.button`
+ background: none;
+ border: none;
+ padding: 0.2rem 0 0;
+ cursor: pointer;
+ svg {
color: ${hues.gray[800].hex};
border-radius: 3px;
transition: all 0.1s ease-in-out;
- &.normal:hover {
+ }
+ &.toggle svg {
+ height: 1.5rem;
+ width: 1.5rem;
+ }
+ &.action svg {
+ height: 1.2rem;
+ height: 1.2rem;
+ width: 1.2rem;
+ &:hover {
color: ${hues.gray[100].hex};
- background-color: ${hues.green[500].hex};
}
- &.warning:hover {
- color: ${hues.gray[100].hex};
- background-color: ${hues.yellow[500].hex};
+ &.info:hover {
+ background-color: ${hues.blue[500].hex};
}
- &.error {
- color: ${hues.red[500].hex};
+ &.add:hover {
+ background-color: ${hues.green[500].hex};
}
- &.error:hover,
- &.critical:hover {
- color: ${hues.gray[100].hex};
+ &.remove:hover {
background-color: ${hues.red[500].hex};
}
}
`
+export const StyledTreeToggle = styled.button`
+ background: none;
+ border: none;
+ padding: 0.2rem 0 0;
+ cursor: pointer;
+ svg {
+ height: 1.5rem;
+ width: 1.5rem;
+ }
+`
+
export const StyledTopConcept = styled.li`
- padding-top: 0.5rem;
font-weight: bold;
- margin-top: 1.2rem;
+ margin-top: 1rem;
.untitled {
color: ${hues.gray[400].hex};
font-weight: normal;
}
+ button[aria-expanded='true'] svg {
+ rotate: 90deg;
+ }
+ &.closed ul {
+ display: none;
+ }
`
export const StyledOrphan = styled.li`
padding-top: 0.5rem;
font-weight: normal;
- margin-top: 1.2rem;
+ margin-top: 1rem;
.untitled {
color: ${hues.gray[400].hex};
}
+ button[aria-expanded='true'] svg {
+ rotate: 90deg;
+ }
+ &.closed ul {
+ display: none;
+ }
`
export const StyledChildConcepts = styled.ul`
list-style: none;
`
export const StyledChildConcept = styled.li`
font-weight: normal;
- margin-top: 1.5rem;
- div {
- // padding-top: 0;
+ margin-top: 1rem;
+ button[aria-expanded='true'] svg {
+ rotate: 90deg;
+ }
+ &.closed ul {
+ display: none;
}
`
-export const StyledConceptLink = styled.span`
- cursor: pointer;
+export const StyledConceptLink = styled.a`
+ color: ${hues.gray[800].hex};
+ text-decoration: none;
&:hover {
text-decoration: underline;
}