Grouping examples into categories (#2585)

This PR adds collapsible groups to the examples app.

it's not finished, but I'd like a review before continuing as I've made
a few decisions I'd like feedback on. I'd like to make a separate issue
for abstracting the accordion component, as I wasn't sure how I would do
it and I thought it would be best to prioritise the functionality first.
Especially considering there are more pressing issues to be getting on
with.

### Change Type

- [ ] `patch` — Bug fix
- [ ] `minor` — New feature
- [ ] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [x] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know

[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version

### Release Notes

- Add collapsible categories to the examples app

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Taha 2024-01-29 10:04:41 +00:00 committed by GitHub
parent 3a3248a636
commit f25f92a46d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 326 additions and 111 deletions

View file

@ -35,6 +35,7 @@
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0",
"@playwright/test": "^1.38.1",
"@radix-ui/react-accordion": "^1.1.2",
"@tldraw/assets": "workspace:*",
"@tldraw/tldraw": "workspace:*",
"@vercel/analytics": "^1.1.1",

View file

@ -1,8 +1,10 @@
import * as Accordion from '@radix-ui/react-accordion'
import { assert, assertExists } from '@tldraw/tldraw'
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { ExamplesLink } from './components/ExamplesLink'
import ExamplesTldrawLogo from './components/ExamplesTldrawLogo'
import { ListLink } from './components/ListLink'
import { Chevron } from './components/Icons'
import { Example, examples } from './examples'
export function ExamplePage({
@ -12,7 +14,7 @@ export function ExamplePage({
example: Example
children: React.ReactNode
}) {
const scrollElRef = useRef<HTMLUListElement>(null)
const scrollElRef = useRef<HTMLDivElement>(null)
const activeElRef = useRef<HTMLLIElement>(null)
const isFirstScroll = useRef(true)
@ -39,6 +41,8 @@ export function ExamplePage({
return () => cancelAnimationFrame(frame)
}, [example])
const categories = examples.map((e) => e.id)
return (
<div className="example">
<div className="example__info">
@ -70,33 +74,38 @@ export function ExamplePage({
</a>
</div>
</div>
<ul className="example__info__list scroll-light" ref={scrollElRef}>
{examples
.filter((e) => !e.hide)
.filter((e) => e.order !== null)
.map((e) => (
<ListLink
key={e.path}
ref={e.path === example.path ? activeElRef : undefined}
example={e}
isActive={e.path === example.path}
/>
))}
<li>
<hr />
</li>
{examples
.filter((e) => !e.hide)
.filter((e) => e.order === null)
.map((e) => (
<ListLink
key={e.path}
ref={e.path === example.path ? activeElRef : undefined}
example={e}
isActive={e.path === example.path}
/>
))}
</ul>
<Accordion.Root
type="multiple"
defaultValue={categories}
className="example__info__list scroll-light"
ref={scrollElRef}
>
{categories.map((currentCategory) => (
<Accordion.Item key={currentCategory} value={currentCategory}>
<Accordion.Trigger className="accordion__trigger">
<div className="examples__list__item accordion__trigger__container">
<h3 className="accordion__trigger__heading">{currentCategory}</h3>
<Chevron />
</div>
</Accordion.Trigger>
<Accordion.Content className="accordion__content">
<span className="accordion__content__separator"></span>
<div className="accordion__content__examples">
{examples
.find((category) => category.id === currentCategory)
?.value.map((sidebarExample) => (
<ExamplesLink
key={sidebarExample.path}
example={sidebarExample}
isActive={sidebarExample.path === example.path}
ref={sidebarExample.path === example.path ? activeElRef : undefined}
/>
))}
</div>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion.Root>
<div className="example__info__list__link">
<a
className="link__button link__button--grey"

View file

@ -1,5 +1,5 @@
import { ExamplesLink } from './components/ExamplesLink'
import ExamplesTldrawLogo from './components/ExamplesTldrawLogo'
import { ListLink } from './components/ListLink'
import { examples } from './examples'
export function HomePage() {
@ -14,22 +14,11 @@ export function HomePage() {
</p>
</div>
<ul className="examples__list">
{examples
.filter((example) => !example.hide)
.filter((example) => example.order !== null)
.map((example) => (
<ListLink key={example.path} example={example} showDescriptionWhenInactive />
))}
{examples.map((e) =>
e.value.map((e) => <ExamplesLink key={e.path} example={e} showDescriptionWhenInactive />)
)}
</ul>
<hr />
<ul className="examples__list">
{examples
.filter((example) => !example.hide)
.filter((example) => example.order === null)
.map((example) => (
<ListLink key={example.path} example={example} showDescriptionWhenInactive />
))}
</ul>
</div>
)
}

View file

@ -6,7 +6,7 @@ import { useMergedRefs } from '../hooks/useMergedRefs'
import { StandaloneIcon } from './Icons'
import { Markdown } from './Markdown'
export const ListLink = forwardRef(function ListLink(
export const ExamplesLink = forwardRef(function ListLink(
{
example,
isActive,
@ -40,7 +40,7 @@ export const ListLink = forwardRef(function ListLink(
const mainDetails = (
<>
<h3 id={id}>
<h3 className="examples__list__item__heading" id={id}>
{example.title}
{isActive && (
<Link
@ -81,7 +81,7 @@ export const ListLink = forwardRef(function ListLink(
)
return (
<li
<span
ref={useMergedRefs(ref, containerRef)}
className={classNames('examples__list__item', isActive && 'examples__list__item__active')}
>
@ -90,6 +90,6 @@ export const ListLink = forwardRef(function ListLink(
)}
{mainDetails}
{extraDetails}
</li>
</span>
)
})

View file

@ -11,3 +11,24 @@ export function StandaloneIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
)
}
export function Chevron() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="accordion__trigger__chevron"
>
<path
d="M4 6L8 10L12 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -7,22 +7,32 @@ export type Example = {
path: string
codeUrl: string
hide: boolean
order: number | null
category: Category
priority: number
componentFile: string
loadComponent: () => Promise<ComponentType>
}
export const examples = (
Object.values(import.meta.glob('./examples/*/README.md', { eager: true })) as Example[]
).sort((a, b) => {
// sort by order then title:
if (a.order === b.order) {
return a.title.localeCompare(b.title)
} else if (a.order === null) {
return 1
} else if (b.order === null) {
return -1
} else {
return a.order - b.order
}
})
type Category = 'basic' | 'editor' | 'ui' | 'collaboration' | 'data/assets' | 'shapes/tools'
const getExamplesForCategory = (category: Category) =>
(Object.values(import.meta.glob('./examples/*/README.md', { eager: true })) as Example[])
.filter((e) => e.category === category)
.sort((a, b) => {
if (a.priority === b.priority) return a.title.localeCompare(b.title)
return a.priority - b.priority
})
const categories: Record<Category, string> = {
basic: 'Getting Started',
ui: 'UI/Theming',
'shapes/tools': 'Shapes & Tools',
'data/assets': 'Data & Assets',
editor: 'Editor API',
collaboration: 'Collaboration',
}
export const examples = Object.entries(categories).map(([category, title]) => ({
id: title,
value: getExamplesForCategory(category as Category),
}))

View file

@ -1,7 +1,8 @@
---
title: Editor API
component: ./APIExample.tsx
order: 2
category: editor
priority: 1
---
Manipulate the contents of the canvas using the editor API.

View file

@ -1,6 +1,8 @@
---
title: Asset props
component: ./AssetPropsExample.tsx
category: data/assets
priority: 1
---
Control the assets (images, videos, etc.) that can be added to the canvas.

View file

@ -1,6 +1,8 @@
---
title: Canvas events
component: ./CanvasEventsExample.tsx
category: editor
priority: 2
---
Listen to events from tldraw's canvas.

View file

@ -1,6 +1,8 @@
---
title: Changing default colors
component: ./ChangingDefaultColorsExample.tsx
category: ui
priority: 1
---
Change the tldraw theme colors.

View file

@ -1,6 +1,8 @@
---
title: Context Toolbar
component: ./ContextToolbar.tsx
category: UI
priority: 2
---
Show a contextual toolbar above the shapes when they are selected.

View file

@ -1,6 +1,8 @@
---
title: Canvas components
component: ./CustomComponentsExample.tsx
category: ui
priority: 2
---
Replace tldraw's on-canvas UI with your own.

View file

@ -1,7 +1,8 @@
---
title: Custom shapes / tools
component: ./CustomConfigExample.tsx
order: 3
category: shapes/tools
priority: 1
---
Create custom shapes / tools

View file

@ -1,6 +1,8 @@
---
title: Custom styles
component: ./CustomStylesExample.tsx
category: shapes/tools
priority: 2
---
Styles are special properties that can be set on many shapes at once.

View file

@ -1,6 +1,8 @@
---
title: Custom UI
component: ./CustomUiExample.tsx
category: ui
priority: 1
---
Replace tldraw's UI with your own.

View file

@ -1,7 +1,8 @@
---
title: Basic
component: ./BasicExample.tsx
order: 1
category: basic
priority: 1
---
The easiest way to get started with tldraw.

View file

@ -1,6 +1,8 @@
---
title: Error boundary
component: ./ErrorBoundaryExample.tsx
category: ui
priority: 2
---
Catch errors in shapes.

View file

@ -1,6 +1,8 @@
---
title: Sublibraries
component: ./ExplodedExample.tsx
category: editor
priority: 3
---
Tldraw is built from several sublibraries - like the editor, default shapes & tools, and UI.

View file

@ -1,6 +1,8 @@
---
title: External content sources
component: ./ExternalContentSourcesExample.tsx
category: data/assets
priority: 2
---
Control what happens when the user pastes content into the editor.

View file

@ -2,4 +2,6 @@
title: Floaty window
hide: true
component: ./FloatyExample.tsx
category: UI
priority: 3
---

View file

@ -1,6 +1,8 @@
---
title: Force mobile breakpoint
component: ./ForceBreakpointExample
category: basic
priority: 3
---
Force the editor UI to render as if it were on a mobile device.

View file

@ -1,6 +1,8 @@
---
title: Hide UI
component: ./HideUiExample.tsx
category: basic
priority: 2
---
Hide tldraw's UI with the `hideUi` prop.

View file

@ -1,5 +1,7 @@
---
title: Hosted images
component: ./HostedImagesExample.tsx
category: data/assets
priority: 2
hide: true
---

View file

@ -1,6 +1,8 @@
---
title: Keyboard Shortcuts
component: ./KeyboardShortcuts.tsx
category: ui
priority: 2
---
Override default keyboard shortcuts.

View file

@ -1,7 +1,8 @@
---
title: Local images
component: ./LocalImagesExample.tsx
hide: false
category: data/assets
priority: 2
---
How to use local images in the built-in `ImageShape` shape.

View file

@ -1,6 +1,8 @@
---
title: Shape Meta (on change)
component: ./OnChangeShapeMetaExample.tsx
category: data/assets
priority: 3
---
Add custom metadata to shapes when they're changed.

View file

@ -1,6 +1,8 @@
---
title: Shape Meta (on create)
component: ./OnCreateShapeMetaExample.tsx
category: data/assets
priority: 3
---
Add custom metadata to shapes when they're created.

View file

@ -1,6 +1,8 @@
---
title: Multiple editors
component: ./MultipleExample.tsx
category: basic
priority: 3
---
Use multiple <Tldraw/> components on the same page.

View file

@ -1,6 +1,8 @@
---
title: Minimal
component: ./OnlyEditor.tsx
category: editor
priority: 3
---
Use the `<TldrawEditor/>` component to render a bare-bones editor with minimal built-in shapes and tools.

View file

@ -1,7 +1,8 @@
---
title: Persistence
component: ./PersistenceExample.tsx
order: 5
category: collaboration
priority: 1
---
Save the contents of the editor

View file

@ -1,6 +1,8 @@
---
title: Readonly
component: ./ReadOnlyExample
category: basic
priority: 2
---
Use the editor in readonly mode.

View file

@ -1,6 +1,8 @@
---
title: Custom tool (screenshot)
component: ./ScreenshotToolExample.tsx
category: shapes/tools
priority: 2
---
Draw a box on the canvas to capture a screenshot of that area.

View file

@ -1,6 +1,8 @@
---
title: Scrolling container
component: ./ScrollExample.tsx
category: basic
priority: 1
---
Use the editor inside a scrollable container.

View file

@ -1,6 +1,8 @@
---
title: Shape meta
component: ./ShapeMetaExample.tsx
category: data/assets
priority: 3
---
Add a label to shapes with the meta property.

View file

@ -1,6 +1,8 @@
---
title: Snapshots
component: ./SnapshotExample.tsx
category: editor
priority: 1
---
Load a snapshot of the editor's contents.

View file

@ -1,6 +1,8 @@
---
title: Speech bubble
component: ./CustomShapeWithHandles.tsx
category: shapes/tools
priority: 2
---
A custom shape with handles

View file

@ -1,6 +1,8 @@
---
title: Store events
component: ./StoreEventsExample.tsx
category: editor
priority: 2
---
Listen to changes from tldraw's store.

View file

@ -1,6 +1,8 @@
---
title: Things on the canvas
component: ./OnTheCanvas.tsx
category: ui
priority: 2
---
Add custom components to the editor

View file

@ -1,6 +1,8 @@
---
title: UI events
component: ./UiEventsExample.tsx
category: editor
priority: 2
---
Listen to events from tldraw's UI.

View file

@ -1,6 +1,8 @@
---
title: User Presence
component: ./UserPresenceExample.tsx
category: collaboration
priority: 2
---
Show other users editing the same document.

View file

@ -1,6 +1,8 @@
---
title: UI zones
component: ./ZonesExample.tsx
category: ui
priority: 1
---
Inject custom components into tldraw's UI.

View file

@ -29,32 +29,33 @@ const router = createBrowserRouter([
path: 'end-to-end',
element: <EndToEnd />,
},
...examples.flatMap((example) => [
{
path: example.path,
lazy: async () => {
const Component = await example.loadComponent()
return {
element: (
<ExamplePage example={example}>
<Component />
</ExamplePage>
),
}
...examples.flatMap((exampleArray) =>
exampleArray.value.flatMap((example) => [
{
path: example.path,
lazy: async () => {
const Component = await example.loadComponent()
return {
element: (
<ExamplePage example={example}>
<Component />
</ExamplePage>
),
}
},
},
},
{
path: `${example.path}/full`,
lazy: async () => {
const Component = await example.loadComponent()
return {
element: <Component />,
}
{
path: `${example.path}/full`,
lazy: async () => {
const Component = await example.loadComponent()
return {
element: <Component />,
}
},
},
},
]),
])
),
])
document.addEventListener('DOMContentLoaded', () => {
const rootElement = document.getElementById('root')!
const root = createRoot(rootElement!)

View file

@ -1,5 +1,12 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap');
:root {
--gray-light: #f5f5f5;
--gray-dark: #e8e8e8;
--black-transparent-light: rgba(0, 0, 0, 0.3);
--black-transparent-dark: rgba(0, 0, 0, 0.5);
}
html,
body {
padding: 0;
@ -39,7 +46,7 @@ html,
.examples hr {
border: none;
border-top: 1px solid #e8e8e8;
border-top: 1px solid var(--gray-dark);
margin: 0;
}
@ -72,8 +79,6 @@ html,
grid-template-columns: repeat(1, 1fr);
padding: 0;
margin: 0 -1.5rem 0 -1.5rem;
list-style: none;
gap: 0rem;
}
@media screen and (max-width: 800px) {
@ -88,12 +93,57 @@ html,
}
}
.accordion__trigger {
border: none;
background: none;
cursor: pointer;
width: 100%;
text-align: left;
font-size: 10px;
}
.accordion__trigger__chevron {
color:var(--black-transparent-light);
transition: transform 300ms;
}
.accordion__trigger[data-state='closed'] > div > .accordion__trigger__chevron {
transform: rotate(-90deg);
}
.accordion__trigger__heading {
font-size: 14px;
font-weight: 500;
color: var(--black-transparent-dark);
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.accordion__content {
display: flex;
flex-direction: row;
}
.accordion__content__separator {
border-right: 1px solid var(--gray-dark);
opacity: 0.5;
padding-right: 1.5rem;
}
.accordion__content__examples {
width: 100%;
}
.examples__list__item {
overflow: hidden;
position: relative;
padding: 1.5rem;
display: block;
width: 100%;
}
.examples__list__item__link {
position: absolute;
inset: 0;
@ -105,8 +155,8 @@ html,
padding: 1.5rem;
text-decoration: none;
color: inherit;
width: 100%;
position: relative;
width: 100%;
}
.examples__list__item::before {
@ -114,7 +164,7 @@ html,
position: absolute;
inset: 0.5rem;
border-radius: 6px;
background: #f5f5f5;
background: var(--gray-light);
z-index: -1;
opacity: 0;
transition: opacity 0.12s ease-in-out;
@ -128,10 +178,10 @@ html,
}
}
.examples__list__item h3 {
.examples__list__item__heading {
all: unset;
font-weight: 700;
font-size: 1rem;
font-size: 14px;
margin-top: 0.25rem;
display: flex;
align-items: center;
@ -200,11 +250,10 @@ html,
align-items: stretch;
}
.example__info {
width: 25vw;
max-width: 300px;
width: 300px;
position: relative;
z-index: 1;
border-right: 1px solid #e8e8e8;
border-right: 1px solid var(--gray-dark);
display: flex;
flex-direction: column;
height: 100%;
@ -246,6 +295,7 @@ html,
position: relative;
font-size: 13px;
}
.example__info__list .examples__list__item {
padding: 0.75rem 1rem;
}
@ -253,9 +303,7 @@ html,
inset-block: 0.25rem;
inset-inline: 0.5rem;
}
.example__info__list h3 {
font-size: 14px;
}
.example__content {
flex: 1 1 auto;
position: relative;
@ -297,15 +345,23 @@ html,
background-color: rgba(0, 0, 0, 0.25);
}
.scroll-light::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
background-color: var(--black-transparent-light);
}
.example__info__list hr {
border: none;
border-top: 1px solid #e8e8e8;
border-top: 1px solid var(--gray-dark);
margin: 0;
}
.accordion__trigger__container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
color: var(--black-transparent-dark);
}
.example__info__list__link {
display: flex;
flex-direction: column;

View file

@ -51,7 +51,8 @@ function exampleReadmePlugin(): PluginOption {
const result = [
`export const title = ${JSON.stringify(frontmatter.title)};`,
`export const order = ${JSON.stringify(frontmatter.order)};`,
`export const priority = ${JSON.stringify(frontmatter.priority)};`,
`export const category = ${JSON.stringify(frontmatter.category)};`,
`export const hide = ${JSON.stringify(frontmatter.hide)};`,
`export const description = ${JSON.stringify(description)};`,
`export const details = ${JSON.stringify(details)};`,
@ -82,9 +83,14 @@ function parseFrontMatter(data: unknown, fileName: string) {
throw new Error(`Frontmatter key 'component' must be string in ${fileName}`)
}
const order = 'order' in data ? data.order : null
if (order !== null && typeof order !== 'number') {
throw new Error(`Frontmatter key 'order' must be number in ${fileName}`)
const priority = 'priority' in data ? data.priority : null
if (typeof priority !== 'number') {
throw new Error(`Frontmatter key 'priority' must be number in ${fileName}`)
}
const category = 'category' in data ? data.category : null
if (typeof category !== 'string') {
throw new Error(`Frontmatter key 'category' must be string in ${fileName}`)
}
const hide = 'hide' in data ? data.hide : false
@ -95,7 +101,8 @@ function parseFrontMatter(data: unknown, fileName: string) {
return {
title: data.title,
component: data.component,
order,
priority,
category,
hide,
}
}

View file

@ -4604,6 +4604,34 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-accordion@npm:^1.1.2":
version: 1.1.2
resolution: "@radix-ui/react-accordion@npm:1.1.2"
dependencies:
"@babel/runtime": "npm:^7.13.10"
"@radix-ui/primitive": "npm:1.0.1"
"@radix-ui/react-collapsible": "npm:1.0.3"
"@radix-ui/react-collection": "npm:1.0.3"
"@radix-ui/react-compose-refs": "npm:1.0.1"
"@radix-ui/react-context": "npm:1.0.1"
"@radix-ui/react-direction": "npm:1.0.1"
"@radix-ui/react-id": "npm:1.0.1"
"@radix-ui/react-primitive": "npm:1.0.3"
"@radix-ui/react-use-controllable-state": "npm:1.0.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 3c2b8fc686b3c6bc6f81e567c1d3933b8ffb35c060eeee113237ee69121b5e4d7c48bb354dbd2626bd101c1f6a1b6612e8cde2de8f72519732fb6c1a1d4cac28
languageName: node
linkType: hard
"@radix-ui/react-alert-dialog@npm:^1.0.0":
version: 1.0.5
resolution: "@radix-ui/react-alert-dialog@npm:1.0.5"
@ -4649,6 +4677,33 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-collapsible@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-collapsible@npm:1.0.3"
dependencies:
"@babel/runtime": "npm:^7.13.10"
"@radix-ui/primitive": "npm:1.0.1"
"@radix-ui/react-compose-refs": "npm:1.0.1"
"@radix-ui/react-context": "npm:1.0.1"
"@radix-ui/react-id": "npm:1.0.1"
"@radix-ui/react-presence": "npm:1.0.1"
"@radix-ui/react-primitive": "npm:1.0.3"
"@radix-ui/react-use-controllable-state": "npm:1.0.1"
"@radix-ui/react-use-layout-effect": "npm:1.0.1"
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: e9c90f9c9f4bcf8beac7d57cf09d5bf3eb99f868b17bd53025b7c81ffcf500efbba9cd92d137661efd7d191f29609e986e3b0577b11a6000e6b513e4403ebb09
languageName: node
linkType: hard
"@radix-ui/react-collection@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-collection@npm:1.0.3"
@ -13018,6 +13073,7 @@ __metadata:
dependencies:
"@babel/plugin-proposal-decorators": "npm:^7.21.0"
"@playwright/test": "npm:^1.38.1"
"@radix-ui/react-accordion": "npm:^1.1.2"
"@tldraw/assets": "workspace:*"
"@tldraw/tldraw": "workspace:*"
"@vercel/analytics": "npm:^1.1.1"