feat: add smart view with custom json (#2000)
This commit is contained in:
@@ -9,6 +9,7 @@ export const IconNameToSvgMapping = {
|
|||||||
'attachment-file': icons.AttachmentFileIcon,
|
'attachment-file': icons.AttachmentFileIcon,
|
||||||
'check-bold': icons.CheckBoldIcon,
|
'check-bold': icons.CheckBoldIcon,
|
||||||
'check-circle': icons.CheckCircleIcon,
|
'check-circle': icons.CheckCircleIcon,
|
||||||
|
'chevron-up': icons.ChevronUpIcon,
|
||||||
'chevron-down': icons.ChevronDownIcon,
|
'chevron-down': icons.ChevronDownIcon,
|
||||||
'chevron-left': icons.ChevronLeftIcon,
|
'chevron-left': icons.ChevronLeftIcon,
|
||||||
'chevron-right': icons.ChevronRightIcon,
|
'chevron-right': icons.ChevronRightIcon,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { addToast, ToastType } from '@standardnotes/toast'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CopyableCodeBlock = ({ code }: Props) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [didCopy, setDidCopy] = useState(false)
|
||||||
|
const [isCopyButtonVisible, setIsCopyButtonVisible] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group relative"
|
||||||
|
onMouseEnter={() => setIsCopyButtonVisible(true)}
|
||||||
|
onMouseLeave={() => setIsCopyButtonVisible(false)}
|
||||||
|
>
|
||||||
|
<pre className="overflow-auto rounded-md bg-default px-2.5 py-1.5">{code}</pre>
|
||||||
|
<div className="absolute top-1.5 right-1.5">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
className={classNames(
|
||||||
|
'peer rounded border border-border bg-default p-2 text-text hover:bg-contrast',
|
||||||
|
!isCopyButtonVisible && 'hidden',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(code).then(
|
||||||
|
() => {
|
||||||
|
setDidCopy(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setDidCopy(false)
|
||||||
|
buttonRef.current?.blur()
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
addToast({
|
||||||
|
type: ToastType.Error,
|
||||||
|
message: 'Failed to copy to clipboard',
|
||||||
|
})
|
||||||
|
setDidCopy(false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="copy" size="small" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
didCopy && isCopyButtonVisible ? '' : 'hidden',
|
||||||
|
'absolute top-full right-0 min-w-max translate-x-2 translate-y-1 select-none rounded border border-border bg-default py-1.5 px-3 text-left md:peer-hover:block',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{didCopy ? 'Copied!' : 'Copy example to clipboard'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyableCodeBlock
|
||||||
@@ -10,35 +10,113 @@ import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
|||||||
import Spinner from '@/Components/Spinner/Spinner'
|
import Spinner from '@/Components/Spinner/Spinner'
|
||||||
import { Platform } from '@standardnotes/snjs'
|
import { Platform } from '@standardnotes/snjs'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import { useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { AddSmartViewModalController } from './AddSmartViewModalController'
|
import { AddSmartViewModalController } from './AddSmartViewModalController'
|
||||||
|
import TabPanel from '../Tabs/TabPanel'
|
||||||
|
import { useTabState } from '../Tabs/useTabState'
|
||||||
|
import TabsContainer from '../Tabs/TabsContainer'
|
||||||
|
import CopyableCodeBlock from '../Shared/CopyableCodeBlock'
|
||||||
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controller: AddSmartViewModalController
|
controller: AddSmartViewModalController
|
||||||
platform: Platform
|
platform: Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ConflictedNotesExampleCode = `{
|
||||||
|
"keypath": "content.conflict_of.length",
|
||||||
|
"operator": ">",
|
||||||
|
"value": 0
|
||||||
|
}`
|
||||||
|
|
||||||
|
const ComplexCompoundExampleCode = `{
|
||||||
|
"operator": "and",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"operator": "not",
|
||||||
|
"value": {
|
||||||
|
"keypath": "tags",
|
||||||
|
"operator": "includes",
|
||||||
|
"value": {
|
||||||
|
"keypath": "title",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keypath": "tags",
|
||||||
|
"operator": "includes",
|
||||||
|
"value": {
|
||||||
|
"keypath": "title",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "todo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
const AddSmartViewModal = ({ controller, platform }: Props) => {
|
const AddSmartViewModal = ({ controller, platform }: Props) => {
|
||||||
const { isSaving, title, setTitle, icon, setIcon, closeModal, saveCurrentSmartView, predicateController } = controller
|
const {
|
||||||
|
isSaving,
|
||||||
|
title,
|
||||||
|
setTitle,
|
||||||
|
icon,
|
||||||
|
setIcon,
|
||||||
|
closeModal,
|
||||||
|
saveCurrentSmartView,
|
||||||
|
predicateController,
|
||||||
|
customPredicateJson,
|
||||||
|
setCustomPredicateJson,
|
||||||
|
isCustomJsonValidPredicate,
|
||||||
|
setIsCustomJsonValidPredicate,
|
||||||
|
validateAndPrettifyCustomPredicate,
|
||||||
|
} = controller
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const customJsonInputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
|
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
|
||||||
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
|
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [shouldShowJsonExamples, setShouldShowJsonExamples] = useState(false)
|
||||||
|
|
||||||
const toggleIconPicker = () => {
|
const toggleIconPicker = () => {
|
||||||
setShouldShowIconPicker((shouldShow) => !shouldShow)
|
setShouldShowIconPicker((shouldShow) => !shouldShow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tabState = useTabState({
|
||||||
|
defaultTab: 'builder',
|
||||||
|
})
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!title.length) {
|
if (!title.length) {
|
||||||
titleInputRef.current?.focus()
|
titleInputRef.current?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tabState.activeTab === 'custom' && !isCustomJsonValidPredicate) {
|
||||||
|
validateAndPrettifyCustomPredicate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
void saveCurrentSmartView()
|
void saveCurrentSmartView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canSave = tabState.activeTab === 'builder' || isCustomJsonValidPredicate
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!customJsonInputRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabState.activeTab === 'custom' && isCustomJsonValidPredicate === false) {
|
||||||
|
customJsonInputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isCustomJsonValidPredicate, tabState.activeTab])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalDialog>
|
<ModalDialog>
|
||||||
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
|
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
|
||||||
@@ -88,17 +166,68 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div className="text-sm font-semibold">Predicate:</div>
|
<div className="text-sm font-semibold">Predicate:</div>
|
||||||
<CompoundPredicateBuilder controller={predicateController} />
|
<TabsContainer
|
||||||
|
tabs={[
|
||||||
|
{
|
||||||
|
id: 'builder',
|
||||||
|
title: 'Builder',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom',
|
||||||
|
title: 'Custom (JSON)',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
state={tabState}
|
||||||
|
>
|
||||||
|
<TabPanel state={tabState} id="builder" className="flex flex-col gap-2.5 p-4">
|
||||||
|
<CompoundPredicateBuilder controller={predicateController} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel state={tabState} id="custom">
|
||||||
|
<textarea
|
||||||
|
className="h-full min-h-[10rem] w-full resize-none bg-default py-1.5 px-2.5 font-mono text-sm"
|
||||||
|
value={customPredicateJson}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCustomPredicateJson(event.target.value)
|
||||||
|
setIsCustomJsonValidPredicate(undefined)
|
||||||
|
}}
|
||||||
|
spellCheck={false}
|
||||||
|
ref={customJsonInputRef}
|
||||||
|
/>
|
||||||
|
{customPredicateJson && isCustomJsonValidPredicate === false && (
|
||||||
|
<div className="mt-2 border-t border-border px-2.5 py-1.5 text-sm text-danger">
|
||||||
|
Invalid JSON. Double check your entry and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
</TabsContainer>
|
||||||
|
{tabState.activeTab === 'custom' && (
|
||||||
|
<Disclosure open={shouldShowJsonExamples} onChange={() => setShouldShowJsonExamples((show) => !show)}>
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-md border-2 border-info-backdrop bg-info-backdrop py-3 px-4">
|
||||||
|
<DisclosureButton className="flex items-center justify-between focus:shadow-none focus:outline-none">
|
||||||
|
<div className="text-sm font-semibold">Examples</div>
|
||||||
|
<Icon type={shouldShowJsonExamples ? 'chevron-up' : 'chevron-down'} />
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel className={classNames(shouldShowJsonExamples && 'flex', 'flex-col gap-2.5')}>
|
||||||
|
<div className="text-sm font-medium">1. List notes that are conflicted copies of another note:</div>
|
||||||
|
<CopyableCodeBlock code={ConflictedNotesExampleCode} />
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
2. List notes that have the tag `todo` but not the tag `completed`:
|
||||||
|
</div>
|
||||||
|
<CopyableCodeBlock code={ComplexCompoundExampleCode} />
|
||||||
|
</DisclosurePanel>
|
||||||
|
</div>
|
||||||
|
</Disclosure>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialogDescription>
|
</ModalDialogDescription>
|
||||||
<ModalDialogButtons>
|
<ModalDialogButtons>
|
||||||
<Button disabled={isSaving} onClick={save}>
|
<Button disabled={isSaving} onClick={closeModal} className="mr-auto">
|
||||||
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
|
|
||||||
</Button>
|
|
||||||
<Button disabled={isSaving} onClick={closeModal}>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button disabled={isSaving} onClick={save} colorStyle={canSave ? 'info' : 'default'} primary={canSave}>
|
||||||
|
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : canSave ? 'Save' : 'Validate'}
|
||||||
|
</Button>
|
||||||
</ModalDialogButtons>
|
</ModalDialogButtons>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { WebApplication } from '@/Application/Application'
|
import { WebApplication } from '@/Application/Application'
|
||||||
import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController'
|
import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController'
|
||||||
import { predicateFromJson } from '@standardnotes/snjs'
|
import { predicateFromJson, PredicateJsonForm } from '@standardnotes/snjs'
|
||||||
import { action, makeObservable, observable } from 'mobx'
|
import { action, makeObservable, observable } from 'mobx'
|
||||||
|
|
||||||
export class AddSmartViewModalController {
|
export class AddSmartViewModalController {
|
||||||
@@ -13,6 +13,9 @@ export class AddSmartViewModalController {
|
|||||||
|
|
||||||
predicateController = new CompoundPredicateBuilderController()
|
predicateController = new CompoundPredicateBuilderController()
|
||||||
|
|
||||||
|
customPredicateJson: string | undefined = undefined
|
||||||
|
isCustomJsonValidPredicate: boolean | undefined = undefined
|
||||||
|
|
||||||
constructor(private application: WebApplication) {
|
constructor(private application: WebApplication) {
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
isAddingSmartView: observable,
|
isAddingSmartView: observable,
|
||||||
@@ -26,6 +29,11 @@ export class AddSmartViewModalController {
|
|||||||
|
|
||||||
icon: observable,
|
icon: observable,
|
||||||
setIcon: action,
|
setIcon: action,
|
||||||
|
|
||||||
|
customPredicateJson: observable,
|
||||||
|
isCustomJsonValidPredicate: observable,
|
||||||
|
setCustomPredicateJson: action,
|
||||||
|
setIsCustomJsonValidPredicate: action,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,12 +53,22 @@ export class AddSmartViewModalController {
|
|||||||
this.icon = icon
|
this.icon = icon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCustomPredicateJson = (customPredicateJson: string) => {
|
||||||
|
this.customPredicateJson = customPredicateJson
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCustomJsonValidPredicate = (isCustomJsonValidPredicate: boolean | undefined) => {
|
||||||
|
this.isCustomJsonValidPredicate = isCustomJsonValidPredicate
|
||||||
|
}
|
||||||
|
|
||||||
closeModal = () => {
|
closeModal = () => {
|
||||||
this.setIsAddingSmartView(false)
|
this.setIsAddingSmartView(false)
|
||||||
this.setTitle('')
|
this.setTitle('')
|
||||||
this.setIcon('')
|
this.setIcon('')
|
||||||
this.setIsSaving(false)
|
this.setIsSaving(false)
|
||||||
this.predicateController.resetState()
|
this.predicateController.resetState()
|
||||||
|
this.setCustomPredicateJson('')
|
||||||
|
this.setIsCustomJsonValidPredicate(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCurrentSmartView = async () => {
|
saveCurrentSmartView = async () => {
|
||||||
@@ -61,10 +79,36 @@ export class AddSmartViewModalController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const predicate = predicateFromJson(this.predicateController.toJson())
|
const predicateJson =
|
||||||
|
this.customPredicateJson && this.isCustomJsonValidPredicate
|
||||||
|
? JSON.parse(this.customPredicateJson)
|
||||||
|
: this.predicateController.toJson()
|
||||||
|
const predicate = predicateFromJson(predicateJson as PredicateJsonForm)
|
||||||
await this.application.items.createSmartView(this.title, predicate, this.icon)
|
await this.application.items.createSmartView(this.title, predicate, this.icon)
|
||||||
|
|
||||||
this.setIsSaving(false)
|
this.setIsSaving(false)
|
||||||
this.closeModal()
|
this.closeModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateAndPrettifyCustomPredicate = () => {
|
||||||
|
if (!this.customPredicateJson) {
|
||||||
|
this.setIsCustomJsonValidPredicate(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedPredicate: PredicateJsonForm = JSON.parse(this.customPredicateJson)
|
||||||
|
const predicate = predicateFromJson(parsedPredicate)
|
||||||
|
|
||||||
|
if (predicate) {
|
||||||
|
this.setCustomPredicateJson(JSON.stringify(parsedPredicate, null, 2))
|
||||||
|
this.setIsCustomJsonValidPredicate(true)
|
||||||
|
} else {
|
||||||
|
this.setIsCustomJsonValidPredicate(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.setIsCustomJsonValidPredicate(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const CompoundPredicateBuilder = ({ controller }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
{predicates.map((predicate, index) => (
|
{predicates.map((predicate, index) => (
|
||||||
<div className="flex flex-col gap-2.5" key={index}>
|
<div className="flex flex-col gap-2.5" key={index}>
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full flex-col gap-2 md:flex-row md:items-center">
|
||||||
{index !== 0 && <div className="mr-2 text-sm font-semibold">{operator === 'and' ? 'AND' : 'OR'}</div>}
|
{index !== 0 && <div className="mr-2 text-sm font-semibold">{operator === 'and' ? 'AND' : 'OR'}</div>}
|
||||||
<select
|
<select
|
||||||
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
|
||||||
|
|||||||
35
packages/web/src/javascripts/Components/Tabs/Tab.tsx
Normal file
35
packages/web/src/javascripts/Components/Tabs/Tab.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { ComponentPropsWithoutRef } from 'react'
|
||||||
|
import { useTabStateContext } from './useTabState'
|
||||||
|
|
||||||
|
type Props = { id: string } & ComponentPropsWithoutRef<'button'>
|
||||||
|
|
||||||
|
const Tab = ({ id, className, children, ...props }: Props) => {
|
||||||
|
const { state } = useTabStateContext()
|
||||||
|
const { activeTab, setActiveTab } = state
|
||||||
|
|
||||||
|
const isActive = activeTab === id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
id={`tab-control-${id}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(id)
|
||||||
|
}}
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls={`tab-panel-${id}`}
|
||||||
|
className={classNames(
|
||||||
|
'relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:shadow-inner',
|
||||||
|
isActive ? 'font-medium text-info' : 'text-text',
|
||||||
|
isActive && 'after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-full after:bg-info',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tab
|
||||||
25
packages/web/src/javascripts/Components/Tabs/TabList.tsx
Normal file
25
packages/web/src/javascripts/Components/Tabs/TabList.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentPropsWithoutRef, useMemo } from 'react'
|
||||||
|
import { TabStateContext, TabState } from './useTabState'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
state: TabState
|
||||||
|
} & ComponentPropsWithoutRef<'div'>
|
||||||
|
|
||||||
|
const TabList = ({ state, children, ...props }: Props) => {
|
||||||
|
const providerValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
}),
|
||||||
|
[state],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabStateContext.Provider value={providerValue}>
|
||||||
|
<div role="tablist" {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TabStateContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabList
|
||||||
22
packages/web/src/javascripts/Components/Tabs/TabPanel.tsx
Normal file
22
packages/web/src/javascripts/Components/Tabs/TabPanel.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from 'react'
|
||||||
|
import { TabState } from './useTabState'
|
||||||
|
|
||||||
|
type Props = { state: TabState; id: string } & ComponentPropsWithoutRef<'div'>
|
||||||
|
|
||||||
|
const TabPanel = ({ state, id, children, ...props }: Props) => {
|
||||||
|
const { activeTab } = state
|
||||||
|
|
||||||
|
const isActive = activeTab === id
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="tabpanel" id={`tab-panel-${id}`} aria-labelledby={`tab-control-${id}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabPanel
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Tab from './Tab'
|
||||||
|
import TabList from './TabList'
|
||||||
|
import { TabState } from './useTabState'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tabs: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}[]
|
||||||
|
state: TabState
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContainer = ({ tabs, state, children }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-md border border-border">
|
||||||
|
<TabList state={state} className="border-b border-border">
|
||||||
|
{tabs.map(({ id, title }) => (
|
||||||
|
<Tab key={id} id={id} className="first:rounded-tl-md">
|
||||||
|
{title}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</TabList>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabsContainer
|
||||||
29
packages/web/src/javascripts/Components/Tabs/useTabState.ts
Normal file
29
packages/web/src/javascripts/Components/Tabs/useTabState.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
|
||||||
|
export const useTabState = ({ defaultTab }: { defaultTab: string }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState(defaultTab)
|
||||||
|
|
||||||
|
return { activeTab, setActiveTab }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabState = ReturnType<typeof useTabState>
|
||||||
|
|
||||||
|
type TabContextValue = {
|
||||||
|
state: TabState
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabStateContext = createContext<TabContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useTabStateContext = () => {
|
||||||
|
const context = useContext(TabStateContext)
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTabStateContext must be used within a <TabList/>')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.state === undefined) {
|
||||||
|
throw new Error('Tab state must be provided to the parent <TabList/>')
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user