Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ninety-starfishes-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Use url hash to open Expandable and scroll to anchor
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import React from 'react';

import { useHash } from '@/components/hooks';
import { ClassValue, tcls } from '@/lib/tailwind';

/**
* Details component rendered on client so it can expand dependent on url hash changes.
*/
export function Details(props: {
children: React.ReactNode;
id: string;
contentIds?: string[];
open?: boolean;
className?: ClassValue;
}) {
const { children, id, open, className } = props;

const detailsRef = React.useRef<HTMLDetailsElement>(null);

const [anchorElement, setAnchorElement] = React.useState<Element | null | undefined>();

const hash = useHash();
/**
* Open the details element if the url hash refers to the id of the details element
* or the id of some element contained within the details element.
*/
React.useEffect(() => {
if (!hash || !detailsRef.current) {
return;
}
if (hash === id) {
setAnchorElement(detailsRef.current);
} else {
const activeElement = document.getElementById(hash);
if (activeElement && detailsRef.current?.contains(activeElement)) {
setAnchorElement(activeElement);
}
}
}, [hash, id]);

return (
<details
ref={detailsRef}
id={id}
open={open || Boolean(anchorElement)}
className={tcls(
className,
'group/expandable',
'shadow-dark/1',
'bg-gradient-to-t',
'from-light-1',
'to-light-1',
'border',
'border-b-0',
'border-dark-3/3',
//all
'[&]:mt-[0px]',
//select first child
'[&:first-child]:mt-5',
'[&:first-child]:rounded-t-lg',
//select first in group
'[:not(&)_+&]:mt-5',
'[:not(&)_+&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b',
/* '[&:not(:has(+_&))]:shadow-1xs', */

'dark:border-light-2/[0.06]',
'dark:from-dark-2',
'dark:to-dark-2',
'dark:shadow-none',

'group open:dark:to-dark-2/8',
'group open:to-light-1/6',
)}
>
{children}
</details>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Icon } from '@gitbook/icons';
import { getNodeFragmentByType } from '@/lib/document';
import { tcls } from '@/lib/tailwind';

import { Details } from './Details';
import { BlockProps } from '../Block';
import { Blocks } from '../Blocks';
import { Inlines } from '../Inlines';
Expand All @@ -24,42 +25,7 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
id = context.getId ? context.getId(id) : id;

return (
<details
id={id}
open={context.mode === 'print'}
className={tcls(
style,
'group/expandable',
'shadow-dark/1',
'bg-gradient-to-t',
'from-light-1',
'to-light-1',
'border',
'border-b-0',
'border-dark-3/3',
//all
'[&]:mt-[0px]',
//select first child
'[&:first-child]:mt-5',
'[&:first-child]:rounded-t-lg',
//select first in group
'[:not(&)_+&]:mt-5',
'[:not(&)_+&]:rounded-t-lg',
//select last in group
'[&:not(:has(+_&))]:mb-5',
'[&:not(:has(+_&))]:rounded-b-lg',
'[&:not(:has(+_&))]:border-b',
/* '[&:not(:has(+_&))]:shadow-1xs', */

'dark:border-light-2/[0.06]',
'dark:from-dark-2',
'dark:to-dark-2',
'dark:shadow-none',

'group open:dark:to-dark-2/8',
'group open:to-light-1/6',
)}
>
<Details id={id} open={context.mode === 'print'} className={style}>
<summary
className={tcls(
'cursor-pointer',
Expand Down Expand Up @@ -130,6 +96,6 @@ export function Expandable(props: BlockProps<DocumentBlockExpandable>) {
context={context}
style={['px-10', 'pb-5', 'space-y-4']}
/>
</details>
</Details>
);
}
1 change: 1 addition & 0 deletions packages/gitbook/src/components/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './useScrollActiveId';
export * from './useScrollToHash';
export * from './useHash';
19 changes: 19 additions & 0 deletions packages/gitbook/src/components/hooks/useHash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useParams } from 'next/navigation';
import React from 'react';

export function useHash() {
const params = useParams();
const [hash, setHash] = React.useState<string>(global.location?.hash?.slice(1));
React.useEffect(() => {
function updateHash() {
setHash(global.location?.hash?.slice(1));
}
global.addEventListener('hashchange', updateHash);
updateHash();
return () => global.removeEventListener('hashchange', updateHash);
// With next.js, the hashchange event is not triggered when the hash changes
// Instead a hack is to use the `useParams` hook to listen to changes in the hash
// https://github.com/vercel/next.js/discussions/49465#discussioncomment-5845312
}, [params]);
return hash;
}
20 changes: 6 additions & 14 deletions packages/gitbook/src/components/hooks/useScrollToHash.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
import { useParams } from 'next/navigation';
import React from 'react';

import { useHash } from './useHash';

/**
* Scroll to the current URL hash everytime the URL changes.
*/
export function useScrollToHash() {
const params = useParams();

const scrollToHash = React.useCallback(() => {
const hash = window.location.hash;
const hash = useHash();
React.useLayoutEffect(() => {
if (hash) {
const element = document.getElementById(hash.slice(1));
const element = document.getElementById(hash);
if (element) {
element.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
}
}
}, []);

// With next.js, the hashchange event is not triggered when the hash changes
// Instead a hack is to use the `useParams` hook to listen to changes in the hash
// https://github.com/vercel/next.js/discussions/49465#discussioncomment-5845312
React.useEffect(() => {
scrollToHash();
}, [params, scrollToHash]);
}, [hash]);
}