Compare commits

...
34 changed files with 425 additions and 40 deletions
+1 -2
View File
@@ -3,7 +3,7 @@
Standard Notes is an end-to-end encrypted note-taking app for digitalists and professionals. Capture your notes, files, and lifes work all in one secure place.
[![latest release version](https://img.shields.io/github/v/release/standardnotes/app)](https://github.com/standardnotes/app/releases)
[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack)
[![Discord](https://img.shields.io/badge/discord-standardnotes-CC2B5E.svg?style=flat&logo=discord)](https://standardnotes.com/discord)
[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes)
### Why Standard Notes?
@@ -38,7 +38,6 @@ Join us on
- [Discord](https://standardnotes.com/discord)
- [Twitter](https://twitter.com/StandardNotes)
- [Forum](https://forum.standardnotes.org)
- [Slack](https://standardnotes.com/slack)
Developers can create and publish their own extensions. Visit the [documentation hub](https://docs.standardnotes.com/) to learn more.
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.23.297](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.296](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/desktop
-1
View File
@@ -2,7 +2,6 @@
<div align="center">
[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack)
[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes)
</div>
@@ -238,7 +238,6 @@ const Urls = {
Support: 'mailto:[email protected]',
Website: 'https://standardnotes.com',
GitHub: 'https://github.com/standardnotes',
Slack: 'https://standardnotes.com/slack',
Discord: 'https://standardnotes.com/discord',
Twitter: 'https://twitter.com/StandardNotes',
GitHubReleases: 'https://github.com/standardnotes/app/releases',
@@ -604,12 +603,6 @@ function helpMenu(window: Electron.BrowserWindow, shell: Electron.Shell) {
void shell.openExternal(Urls.Discord)
},
},
{
label: str().slack,
click() {
void shell.openExternal(Urls.Slack)
},
},
{
label: str().twitter,
click() {
@@ -19,7 +19,6 @@ export function createEnglishStrings(): Strings {
website: 'Website',
gitHub: 'GitHub',
discord: 'Discord',
slack: 'Slack',
twitter: 'Twitter',
toggleErrorConsole: 'Toggle Error Console',
openDataDirectory: 'Open Data Directory',
@@ -24,7 +24,6 @@ interface AppMenuStrings {
website: string
gitHub: string
discord: string
slack: string
twitter: string
toggleErrorConsole: string
openDataDirectory: string
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.296",
"version": "3.23.297",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.2.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/docs
## [0.2.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-05)
### Bug Fixes
+1 -1
View File
@@ -17,7 +17,7 @@ Thank you for your interest in contributing to Standard Notes. We are very happy
1. Submit suggestions and feature ideas to our [community forum](https://forum.standardnotes.org). You can comment on the existing feature requests and subscribe to individual threads to follow their progress.
2. Report bugs or issues to our [community forum](https://forum.standardnotes.org).
3. Help others on the forum and our [Slack group](https://standardnotes.com/slack).
3. Help others on the forum and our [Discord group](https://standardnotes.com/discord).
4. Blog, tweet and share what you like about Standard Notes, what you use it for, and how it helps you.
We do things the old-fashioned way and rely on people like you telling other people like you about Standard Notes to sustain our efforts. Get a free month of Extended when your friend also joins Extended. Log into the [member dashboard](https://dashboard.standardnotes.com) to get your unique referral link. 🙂
@@ -138,6 +138,6 @@ There are an unlimited number of things you can build with components that do an
To see how we built [our own components](https://standardnotes.com/extensions), study the source code [available here](https://github.com/sn-extensions).
For questions on development, [post in the forum](https://forum.standardnotes.org) or [join our Slack](https://standardnotes.com/slack).
For questions on development, [post in the forum](https://forum.standardnotes.org) or [join our Discord](https://standardnotes.com/discord).
If you'd like to support Standard Notes and use our secure hosting to install all the components we have to offer, consider purchasing the [Extended subscription](https://standardnotes.com/extended).
+1 -1
View File
@@ -98,4 +98,4 @@ Standard Notes will ping the `latest_url`endpoint automatically to update extens
## Questions?
Join the [Slack group](https://standardnotes.com/slack) and ask away in the \#dev channel.
Join the [Discord group](https://standardnotes.com/discord) and ask away in the \#dev channel.
@@ -155,8 +155,8 @@ For every received item:
## Next Steps
Join the [Slack group](https://standardnotes.com/slack) to discuss implementation details and ask any questions you may have.
Join the [Discord group](https://standardnotes.com/discord) to discuss implementation details and ask any questions you may have.
You can also email [[email protected]](mailto:help@standardnotes.org).
You can also email [[email protected]](mailto:help@standardnotes.com).
Follow [@standardnotes on Twitter](https://twitter.com/standardnotes) for updates and announcements.
@@ -338,8 +338,8 @@ result = ['004', nonce, ciphertext, encoded_authenticated_data].join(':')
## Next Steps
Join the [Slack group](https://standardnotes.com/slack) to discuss implementation details and ask any questions you may have.
Join the [Discord group](https://standardnotes.com/Discord) to discuss implementation details and ask any questions you may have.
You can also email [[email protected]](mailto:help@standardnotes.org).
You can also email [[email protected]](mailto:help@standardnotes.com).
Follow [@standardnotes on Twitter](https://twitter.com/standardnotes) for updates and announcements.
-1
View File
@@ -31,7 +31,6 @@ This documentation explains how to get started with using Standard Notes, publis
- [GitHub](https://github.com/standardnotes)
- [Discord](https://standardnotes.com/discord)
- [Twitter](https://twitter.com/standardnotes)
- [Slack](https://standardnotes.com/slack)
- [Blog](https://blog.standardnotes.org)
## Something missing?
+3 -3
View File
@@ -111,12 +111,12 @@ module.exports = {
title: 'Community',
items: [
{
label: 'Join our Slack',
href: 'https://standardnotes.com/slack',
label: 'Join our Discord',
href: 'https://standardnotes.com/discord',
},
{
label: 'Community Forum',
href: 'https://forum.standardnotes.org',
href: 'https://standardnotes.com/forum',
},
{
label: 'Listed Blogging Platform',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/docs",
"version": "0.2.1",
"version": "0.2.2",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.46.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/mobile
-1
View File
@@ -4,7 +4,6 @@
[![Latest release version](https://img.shields.io/github/v/release/standardnotes/mobile)](https://github.com/standardnotes/mobile/releases)
[![License](https://img.shields.io/github/license/standardnotes/mobile?color=blue)](https://github.com/standardnotes/mobile/blob/master/LICENSE)
[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack)
[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes)
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.46.3",
"version": "3.46.4",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.229](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.228](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.228",
"version": "1.3.229",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.98.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
### Features
* add smart view with custom json ([#2000](https://github.com/standardnotes/app/issues/2000)) ([68991ab](https://github.com/standardnotes/app/commit/68991abba7a2b32047ec7cc6402c3a8b5cc6ec52))
# [3.97.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.97.0",
"version": "3.98.0",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
@@ -9,6 +9,7 @@ export const IconNameToSvgMapping = {
'attachment-file': icons.AttachmentFileIcon,
'check-bold': icons.CheckBoldIcon,
'check-circle': icons.CheckCircleIcon,
'chevron-up': icons.ChevronUpIcon,
'chevron-down': icons.ChevronDownIcon,
'chevron-left': icons.ChevronLeftIcon,
'chevron-right': icons.ChevronRightIcon,
@@ -65,7 +65,6 @@ const CloudLink: FunctionComponent = () => (
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your feedback with us? Join
the Standard Notes community groups for discussions on security, themes, editors and more.
</Text>
<LinkButton className="mt-3" link="https://standardnotes.com/slack" label="Join our Slack" />
<LinkButton className="mt-3" link="https://standardnotes.com/discord" label="Join our Discord" />
</PreferencesSegment>
</PreferencesGroup>
@@ -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 { Platform } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
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 = {
controller: AddSmartViewModalController
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 { 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 customJsonInputRef = useRef<HTMLTextAreaElement>(null)
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
const [shouldShowJsonExamples, setShouldShowJsonExamples] = useState(false)
const toggleIconPicker = () => {
setShouldShowIconPicker((shouldShow) => !shouldShow)
}
const tabState = useTabState({
defaultTab: 'builder',
})
const save = () => {
if (!title.length) {
titleInputRef.current?.focus()
return
}
if (tabState.activeTab === 'custom' && !isCustomJsonValidPredicate) {
validateAndPrettifyCustomPredicate()
return
}
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 (
<ModalDialog>
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
@@ -88,17 +166,68 @@ const AddSmartViewModal = ({ controller, platform }: Props) => {
</div>
<div className="flex flex-col gap-2.5">
<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>
</ModalDialogDescription>
<ModalDialogButtons>
<Button disabled={isSaving} onClick={save}>
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
</Button>
<Button disabled={isSaving} onClick={closeModal}>
<Button disabled={isSaving} onClick={closeModal} className="mr-auto">
Cancel
</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>
</ModalDialog>
)
@@ -1,6 +1,6 @@
import { WebApplication } from '@/Application/Application'
import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController'
import { predicateFromJson } from '@standardnotes/snjs'
import { predicateFromJson, PredicateJsonForm } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class AddSmartViewModalController {
@@ -13,6 +13,9 @@ export class AddSmartViewModalController {
predicateController = new CompoundPredicateBuilderController()
customPredicateJson: string | undefined = undefined
isCustomJsonValidPredicate: boolean | undefined = undefined
constructor(private application: WebApplication) {
makeObservable(this, {
isAddingSmartView: observable,
@@ -26,6 +29,11 @@ export class AddSmartViewModalController {
icon: observable,
setIcon: action,
customPredicateJson: observable,
isCustomJsonValidPredicate: observable,
setCustomPredicateJson: action,
setIsCustomJsonValidPredicate: action,
})
}
@@ -45,12 +53,22 @@ export class AddSmartViewModalController {
this.icon = icon
}
setCustomPredicateJson = (customPredicateJson: string) => {
this.customPredicateJson = customPredicateJson
}
setIsCustomJsonValidPredicate = (isCustomJsonValidPredicate: boolean | undefined) => {
this.isCustomJsonValidPredicate = isCustomJsonValidPredicate
}
closeModal = () => {
this.setIsAddingSmartView(false)
this.setTitle('')
this.setIcon('')
this.setIsSaving(false)
this.predicateController.resetState()
this.setCustomPredicateJson('')
this.setIsCustomJsonValidPredicate(undefined)
}
saveCurrentSmartView = async () => {
@@ -61,10 +79,36 @@ export class AddSmartViewModalController {
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)
this.setIsSaving(false)
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>
{predicates.map((predicate, 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>}
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
@@ -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
@@ -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
@@ -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
@@ -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
}