Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16
19
61 changes: 61 additions & 0 deletions create-component.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const readline = require('readline')
const fs = require('fs')
const path = require('path')

const readlineInterface = readline.createInterface({
input: process.stdin,
output: process.stdout
})

const getComponentName = () => {
return new Promise((resolve) =>
readlineInterface.question('Enter the component name: ', (name) => {
readlineInterface.close()
resolve(name)
})
)
}

const getFileNames = (componentName) => [
`${componentName}.tsx`,
`${componentName}.children.tsx`,
`${componentName}.types.ts`,
`${componentName}.stories.tsx`
]

const createFileStructure = (componentName, fileNames) => {
console.log(`\nCreating the file structure for ${componentName} component...\n`)

const componentPath = path.resolve('src', 'components', componentName)

fs.mkdir(componentPath, (err) => {
if (!err) {
return
}
console.error('Error occurred while creating component directory:', err)
})

for (const fileName of fileNames) {
const filePath = path.resolve(componentPath, fileName)
fs.writeFile(filePath, '', (err) => {
if (!err) {
return
}
console.log(`Error occurred while creating component file ${fileName}: `, err)
})
console.log(`File ${fileName} has been created.`)
}

console.log(
`\nThe component structure for ${componentName} has been created successfully and is located at ${componentPath}.`
)
}

const init = async () => {
const componentName = await getComponentName()
const fileNames = getFileNames(componentName)

createFileStructure(componentName, fileNames)
}

init()
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"build": "rm -rf /dist && rollup -c",
"watch": "rollup -cw",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"create-component": "node ./create-component.cjs"
},
"keywords": [
"React",
Expand Down
130 changes: 130 additions & 0 deletions src/components/Tabs/Tabs.children.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { getFocusableChildren } from '@/utils'
import React, {
KeyboardEvent,
MouseEvent,
FocusEvent,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { TabsContext } from './Tabs'
import { ControlsListProps, ItemProps } from './Tabs.types'

export const ControlsList = (props: ControlsListProps) => {
const { render } = props
const { controls, activeTab, setActiveTab, activationMode } =
useContext(TabsContext)
const tabsListRef = useRef<HTMLUListElement>()
const [focusedTab, setFocusedTab] = useState<string | number>(undefined)

const handleKeyDown = (event: KeyboardEvent) => {
const { key, target } = event

const controlButtons = [...tabsListRef.current.children].map(
(tabsListChild) =>
[...tabsListChild.children].find(
({ localName }) => localName === 'button'
)
) as HTMLButtonElement[]

let focusCandidateIndex

switch (key) {
case 'ArrowUp':
case 'ArrowLeft':
focusCandidateIndex =
controlButtons.indexOf(target as HTMLButtonElement) - 1
break
case 'ArrowDown':
case 'ArrowRight':
focusCandidateIndex =
controlButtons.indexOf(target as HTMLButtonElement) + 1
break
default:
return
}

focusCandidateIndex =
focusCandidateIndex < 0
? controlButtons.length - 1
: focusCandidateIndex > controlButtons.length - 1
? 0
: focusCandidateIndex

controlButtons[focusCandidateIndex].focus()
event.preventDefault()
}

const handleTabToggle = (
event: MouseEvent | FocusEvent,
id: string | number
) => {
if (
(activationMode === 'auto' && event.type == 'click') ||
(activationMode === 'manual' && event.type == 'focus')
) {
event.preventDefault()
return
}
setActiveTab(id)
setFocusedTab(id)
}

return (
<ul
role="tablist"
ref={tabsListRef}
>
{controls.map(({ id, ...item }) => (
<li>
<button
type="button"
role="tab"
id={`tabs-item-control-${id}`}
aria-controls={`tabs-item-panel-${id}`}
aria-selected={activeTab === id}
onClick={(event) => handleTabToggle(event, id)}
onFocus={(event) => handleTabToggle(event, id)}
onKeyDown={handleKeyDown}
tabIndex={[activeTab, focusedTab].includes(id) ? 0 : -1}
>
{render(item)}
</button>
</li>
))}
</ul>
)
}

export const Panel = (props: ItemProps) => {
const { children, id, ...restProps } = props
const { controls, activeTab } = useContext(TabsContext)
const [hasFocusableChildren, setHasFocusableChildren] = useState(false)
const panelRef = useRef<HTMLDivElement>()

useEffect(() => {
setHasFocusableChildren(getFocusableChildren(panelRef.current).length > 0)
}, [children])

if (!controls.map(({ id: controlId }) => controlId).includes(id)) {
throw new Error(
'Given identifier does not represent any provided control item.'
)
}

return (
<div
role="tabpanel"
aria-labelledby={`tabs-item-control-${id}`}
id={`tabs-item-panel-${id}`}
hidden={activeTab !== id}
aria-hidden={activeTab !== id}
ref={panelRef}
tabIndex={hasFocusableChildren ? -1 : 0}
{...restProps}
>
{children}
</div>
)
}
70 changes: 70 additions & 0 deletions src/components/Tabs/Tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useMemo, useState } from 'react'
import type { Meta, StoryFn, StoryObj } from '@storybook/react'

import Tabs from './Tabs'

const meta: Meta<typeof Tabs> = {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/7.0/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: 'Tabs',
component: Tabs,
}

export default meta
type Story = StoryObj<typeof Tabs>

/*
*👇 Render functions are a framework specific feature to allow you control on how the component renders.
* See https://storybook.js.org/docs/7.0/react/api/csf
* to learn how to use render functions.
*/
export const Default: StoryFn = () => {
return (
<Tabs
controls={[
{ id: 'tab-1', label: 'Tab 1', icon: '😏' },
{ id: 'tab-2', label: 'Tab 2', icon: '🥹' },
{ id: 'tab-3', label: 'Tab 3', icon: '🥲' },
]}
activationMode="manual"
>
<Tabs.ControlsList
render={({ label, icon }) => (
<span>
{label} {icon}
</span>
)}
/>

<Tabs.Panel id="tab-1">
<h1>Tab 1</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Minus
repudiandae nihil, quos repellendus dolor obcaecati architecto
deserunt unde! Quam dolore enim ex inventore et provident facilis, a
fugiat maiores quidem!
</p>
</Tabs.Panel>
<Tabs.Panel id="tab-2">
<h1>Tab 2</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Minus
repudiandae nihil, quos repellendus dolor obcaecati architecto
deserunt unde! Quam dolore enim ex inventore et provident facilis, a
fugiat maiores quidem!
</p>
</Tabs.Panel>
<Tabs.Panel id="tab-3">
<h1>Tab 3</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Minus
repudiandae nihil, quos repellendus dolor obcaecati architecto
deserunt unde! Quam dolore enim ex inventore et provident facilis, a
fugiat maiores quidem!
</p>
</Tabs.Panel>
</Tabs>
)
}
22 changes: 22 additions & 0 deletions src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { createContext, useState } from 'react'
import { ControlsList, Panel } from './Tabs.children'
import { TabsContextType, TabsProps } from './Tabs.types'

export const TabsContext = createContext<TabsContextType>({})

function Tabs(props: TabsProps) {
const { children, defaultTab, controls, activationMode, ...restProps } = props
const [activeTab, setActiveTab] = useState(defaultTab || controls[0].id)

return (
<TabsContext.Provider
value={{ controls, activeTab, setActiveTab, activationMode }}
>
<div {...restProps}>{children}</div>
</TabsContext.Provider>
)
}

const TabsNamespace = Object.assign(Tabs, { ControlsList, Panel })

export default TabsNamespace
25 changes: 25 additions & 0 deletions src/components/Tabs/Tabs.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
type ControlsType = {
id: string | number
[k: string]: any
}[]

export interface TabsProps extends React.PropsWithChildren {
defaultTab?: string | number
controls: ControlsType
activationMode: 'auto' | 'manual'
}

export type ItemProps = React.PropsWithChildren & {
id: string | number
}

export type ControlsListProps = React.PropsWithChildren & {
render: (item: { [k: string]: any }) => React.ReactNode
}

export type TabsContextType = {
controls?: ControlsType
activeTab?: string | number
setActiveTab?: React.Dispatch<React.SetStateAction<string | number>>
activationMode?: 'auto' | 'manual'
}
8 changes: 8 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const getFocusableChildren = (element) =>
[
...element.querySelectorAll(
'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'
),
].filter(
(el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')
)