-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat(ui): implement deep linking in CodeTabs via URL hash #9159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
55de7aa
9b3d739
6707e8c
15d5e2a
aaba3ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| 'use client'; | ||
|
|
||
| import { useSyncExternalStore } from 'react'; | ||
|
|
||
| const listeners = new Set<() => void>(); | ||
| let isListening = false; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: can we not use a top level let? Use React APIs such a |
||
|
|
||
| const onHashChange = () => { | ||
| listeners.forEach(rerender => rerender()); | ||
| }; | ||
|
|
||
| const subscribe = (callback: () => void) => { | ||
| listeners.add(callback); | ||
| if (!isListening && typeof window !== 'undefined') { | ||
| window.addEventListener('hashchange', onHashChange); | ||
| isListening = true; | ||
| } | ||
| return () => { | ||
| listeners.delete(callback); | ||
| if (listeners.size === 0 && typeof window !== 'undefined') { | ||
| window.removeEventListener('hashchange', onHashChange); | ||
| isListening = false; | ||
| } | ||
| }; | ||
| }; | ||
|
|
||
| const getSnapshot = () => | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We shouldn't use
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah you already have a Provider, have the Provider have a prop of
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (and ensure doc-kit and Next.js can pass that with their Navigation/Router APIs natively, or instead of Location they pass History, idk
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BTW, I thought of it, but the const [hash, setHash] = useState('');
useEffect(() => {
setHash(location.hash);
}, [location.hash]);and that causes a not needed rerendering. That's why I used the new hook
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't need to be in the server. But location must come from downstream, not from ui-components.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, you don't need to use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I think there is a small confusion. Both The |
||
| typeof window !== 'undefined' ? window.location.hash.slice(1) : ''; | ||
| const getServerSnapshot = () => ''; | ||
|
|
||
| const useHash = () => { | ||
| // Works in both client and server environments, provides the initial value for the store during server rendering and hydration avoiding hydration mismatches | ||
| const hash = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | ||
|
|
||
| const setHash = (newHash: string) => { | ||
| if (typeof window !== 'undefined') { | ||
| window.history.replaceState(null, '', `#${newHash}`); | ||
| onHashChange(); | ||
| } | ||
| }; | ||
|
|
||
| return [hash, setHash] as const; | ||
| }; | ||
|
|
||
| export default useHash; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| 'use client'; | ||
|
|
||
| import { HashContext } from '@node-core/ui-components/contexts/HashContext'; | ||
|
|
||
| import useHash from '#site/hooks/useHash'; | ||
|
|
||
| import type { FC, PropsWithChildren } from 'react'; | ||
|
|
||
| export const HashProvider: FC<PropsWithChildren> = ({ children }) => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Have Location be passed to the HashProvider ;)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replied your other comment |
||
| const [hash, setHash] = useHash(); | ||
|
|
||
| return <HashContext value={{ hash, setHash }}>{children}</HashContext>; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| 'use client'; | ||
|
|
||
| import { useCallback, useState } from 'react'; | ||
|
|
||
| import CodeTabs from '#ui/Common/CodeTabs'; | ||
| import { useHashContext } from '#ui/contexts/HashContext'; | ||
|
|
||
| import type { ComponentProps, FC } from 'react'; | ||
|
|
||
| type CodeTabsWithHashProps = Omit<ComponentProps<typeof CodeTabs>, 'tabs'> & { | ||
| tabs: Array<{ key: string; label: string; id?: string }>; | ||
| }; | ||
|
|
||
| const CodeTabsWithHash: FC<CodeTabsWithHashProps> = ({ | ||
| tabs, | ||
| defaultValue, | ||
| ...props | ||
| }) => { | ||
| const { hash, setHash } = useHashContext(); | ||
| const [activeTab, setActiveTab] = useState(defaultValue); | ||
| const [prevHash, setPrevHash] = useState(hash); | ||
|
|
||
| if (hash !== prevHash) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't be on the root of a Component, that's a big no-go. Use an Effect. |
||
| setPrevHash(hash); | ||
| const matched = tabs.find(t => t.id === hash); | ||
| if (matched && matched.key !== activeTab) { | ||
| setActiveTab(matched.key); | ||
| } | ||
| } | ||
|
|
||
| const handleValueChange = useCallback( | ||
| (value: string) => { | ||
| setActiveTab(value); | ||
| const matched = tabs.find(t => t.key === value); | ||
| if (matched?.id) { | ||
| setHash(matched.id); | ||
| } | ||
| }, | ||
| [tabs, setHash] | ||
| ); | ||
|
|
||
| return ( | ||
| <CodeTabs | ||
| {...props} | ||
| tabs={tabs} | ||
| value={activeTab} | ||
| onValueChange={handleValueChange} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| export default CodeTabsWithHash; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,11 +5,14 @@ import CodeTabs from '#ui/Common/CodeTabs'; | |
|
|
||
| import type { FC, ReactElement } from 'react'; | ||
|
|
||
| import CodeTabsWithHash from './CodeTabsWithHash'; | ||
|
|
||
| type MDXCodeTabsProps = { | ||
| children: Array<ReactElement<unknown>>; | ||
| languages: string; | ||
| displayNames?: string; | ||
| defaultTab?: string; | ||
| groupId?: string; | ||
| }; | ||
|
|
||
| const NAME_OVERRIDES: Record<string, string | undefined> = { | ||
|
|
@@ -21,6 +24,7 @@ const MDXCodeTabs: FC<MDXCodeTabsProps> = ({ | |
| displayNames: rawDisplayNames, | ||
| children: codes, | ||
| defaultTab = '0', | ||
| groupId, | ||
| ...props | ||
| }) => { | ||
| const { tabs, languages } = useMemo(() => { | ||
|
|
@@ -44,14 +48,19 @@ const MDXCodeTabs: FC<MDXCodeTabsProps> = ({ | |
| return { | ||
| key: `${language}-${index}`, | ||
| label, | ||
| id: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, no (as in this is a no-go) |
||
| groupId && | ||
| `${groupId}-${language}-${index}`.replace(/[^a-zA-Z0-9-_]/g, '-'), | ||
| }; | ||
| }); | ||
|
|
||
| return { tabs, languages }; | ||
| }, [rawLanguages, rawDisplayNames]); | ||
| }, [rawLanguages, rawDisplayNames, groupId]); | ||
|
|
||
| const Component = groupId ? CodeTabsWithHash : CodeTabs; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This shouldn't be decided here IMO, but on our rehype shiki plugin. The name of the component to be used (CodeTabs) is passed there, so we can do the right pass directly from there, which reduces verbosity. This also removes the need of regular CodeTabs having an optional Group ID param
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WIP |
||
|
|
||
| return ( | ||
| <CodeTabs | ||
| <Component | ||
| tabs={tabs} | ||
| defaultValue={tabs[Number(defaultTab)].key} | ||
| {...props} | ||
|
|
@@ -65,7 +74,7 @@ const MDXCodeTabs: FC<MDXCodeTabsProps> = ({ | |
| {codes[index]} | ||
| </TabsPrimitive.Content> | ||
| ))} | ||
| </CodeTabs> | ||
| </Component> | ||
| ); | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 'use client'; | ||
|
|
||
| import { createContext, use } from 'react'; | ||
|
|
||
| type HashContextType = { | ||
| hash: string; | ||
| setHash: (newHash: string) => void; | ||
| }; | ||
|
|
||
| export const HashContext = createContext<HashContextType>({ | ||
| hash: '', | ||
| setHash: () => {}, | ||
| }); | ||
|
|
||
| export const useHashContext = () => use(HashContext); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here, listeners itself should be a top-level cache, not a top level const
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I just want to make this hook as a general hook. In our senario, we don't need them as it'll contain only the provider. So I could remove them and just dispatch an event like this:
Instead of using a function that iterates over the set listeners, this keeps the hook clean and focused only on returning
hashandsetHashwithout any top-level vars, WDUT?