@@ -13,6 +13,10 @@ | |||||
- Color | - Color | ||||
- [ ] ColorPicker | - [ ] ColorPicker | ||||
- [X] Swatch | - [X] Swatch | ||||
- Code | |||||
- [ ] CodeInput (extract to own package) | |||||
- [X] CodeBlock (`react-refractor`) | |||||
- [ ] VerifyCodeInput (for OTP inputs) | |||||
- Formatted | - Formatted | ||||
- [X] EmailInput | - [X] EmailInput | ||||
- [X] PhoneNumberInput | - [X] PhoneNumberInput | ||||
@@ -61,4 +65,4 @@ | |||||
- [ ] YearInput | - [ ] YearInput | ||||
# Others | # Others | ||||
- [ ] Add `select-none` to input labels, etc. | |||||
- [X] Add `select-none` to input labels, etc. |
@@ -105,3 +105,4 @@ dist | |||||
.tern-port | .tern-port | ||||
.npmrc | .npmrc | ||||
types/ |
@@ -15,6 +15,7 @@ | |||||
"devDependencies": { | "devDependencies": { | ||||
"@testing-library/jest-dom": "^5.16.5", | "@testing-library/jest-dom": "^5.16.5", | ||||
"@testing-library/react": "^13.4.0", | "@testing-library/react": "^13.4.0", | ||||
"@testing-library/user-event": "^14.4.3", | |||||
"@types/node": "^18.14.1", | "@types/node": "^18.14.1", | ||||
"@types/react": "^18.0.27", | "@types/react": "^18.0.27", | ||||
"eslint": "^8.35.0", | "eslint": "^8.35.0", | ||||
@@ -58,8 +59,8 @@ | |||||
"access": "public" | "access": "public" | ||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"clsx": "^1.2.1", | |||||
"@tesseract-design/web-base": "workspace:*" | |||||
"@tesseract-design/web-base": "workspace:*", | |||||
"clsx": "^1.2.1" | |||||
}, | }, | ||||
"types": "./dist/types/index.d.ts", | "types": "./dist/types/index.d.ts", | ||||
"main": "./dist/cjs/production/index.js", | "main": "./dist/cjs/production/index.js", | ||||
@@ -0,0 +1,4 @@ | |||||
import matchers from '@testing-library/jest-dom/matchers'; | |||||
import { expect } from 'vitest'; | |||||
expect.extend(matchers); |
@@ -0,0 +1,186 @@ | |||||
import * as React from 'react'; | |||||
import { | |||||
render, | |||||
screen, | |||||
cleanup, | |||||
} from '@testing-library/react'; | |||||
import userEvent from '@testing-library/user-event'; | |||||
import { Button } from '@tesseract-design/web-base'; | |||||
import { | |||||
vi, | |||||
describe, | |||||
it, | |||||
expect, afterEach, | |||||
} from 'vitest'; | |||||
import { | |||||
ActionButton, | |||||
} from '.'; | |||||
import matchers from '@testing-library/jest-dom/matchers'; | |||||
expect.extend(matchers); | |||||
vi.mock('@tesseract-design/web-base-button'); | |||||
describe('ActionButton', () => { | |||||
afterEach(() => { | |||||
cleanup(); | |||||
}); | |||||
it('renders a button', () => { | |||||
render( | |||||
<ActionButton />, | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
expect(button).toBeInTheDocument(); | |||||
expect(button).toHaveProperty('type', 'button'); | |||||
}); | |||||
it('renders a subtext', () => { | |||||
render( | |||||
<ActionButton | |||||
subtext="subtext" | |||||
/>, | |||||
); | |||||
const subtext: HTMLElement = screen.getByTestId('subtext'); | |||||
expect(subtext).toBeInTheDocument(); | |||||
}); | |||||
it('renders a badge', () => { | |||||
render( | |||||
<ActionButton | |||||
badge="badge" | |||||
/>, | |||||
); | |||||
const badge: HTMLElement = screen.getByTestId('badge'); | |||||
expect(badge).toBeInTheDocument(); | |||||
}); | |||||
it('renders as a menu item', () => { | |||||
render( | |||||
<ActionButton | |||||
menuItem | |||||
/>, | |||||
); | |||||
const menuItemIndicator: HTMLElement = screen.getByTestId('menuItemIndicator'); | |||||
expect(menuItemIndicator).toBeInTheDocument(); | |||||
}); | |||||
it('handles click events', async () => { | |||||
const onClick = vi.fn().mockImplementationOnce((e) => { e.preventDefault() }); | |||||
render( | |||||
<ActionButton | |||||
onClick={onClick} | |||||
/>, | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
await userEvent.click(button); | |||||
expect(onClick).toBeCalled(); | |||||
}); | |||||
it('renders a compact button', () => { | |||||
render( | |||||
<ActionButton | |||||
compact | |||||
/>, | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
expect(button).toHaveClass('pl-2'); | |||||
expect(button).toHaveClass('gap-2'); | |||||
expect(button).toHaveClass('pr-2'); | |||||
}); | |||||
describe.each` | |||||
size | className | |||||
${'small'} | ${'h-10'} | |||||
${'medium'} | ${'h-12'} | |||||
${'large'} | ${'h-16'} | |||||
`('on %s size', ({ | |||||
size, | |||||
className, | |||||
}: { size: Button.Size, className: string }) => { | |||||
it('renders button styles', () => { | |||||
render( | |||||
<ActionButton | |||||
size={size} | |||||
/>, | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
expect(button).toHaveClass(className); | |||||
}); | |||||
it('renders badge styles', () => { | |||||
render( | |||||
<ActionButton | |||||
size={size} | |||||
badge="badge" | |||||
/>, | |||||
); | |||||
const badge = screen.getByTestId('badge'); | |||||
expect(badge).toBeInTheDocument(); | |||||
}); | |||||
}); | |||||
it.each` | |||||
variant | className | |||||
${'bare'} | ${'bg-negative'} | |||||
${'outline'} | ${'border-2'} | |||||
${'filled'} | ${'bg-primary'} | |||||
`('renders a button with variant %s', ({ | |||||
variant, | |||||
className, | |||||
}: { variant: Button.Variant, className: string }) => { | |||||
render( | |||||
<ActionButton | |||||
variant={variant} | |||||
/>, | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
expect(button).toHaveClass(className); | |||||
}); | |||||
it('renders a block button', () => { | |||||
render( | |||||
<ActionButton | |||||
block | |||||
/>, | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
expect(button).toHaveClass('w-full'); | |||||
expect(button).toHaveClass('flex'); | |||||
}); | |||||
it('renders children', () => { | |||||
render( | |||||
<ActionButton> | |||||
Foo | |||||
</ActionButton> | |||||
); | |||||
const children: HTMLElement = screen.getByTestId('children'); | |||||
expect(children).toHaveTextContent('Foo'); | |||||
}); | |||||
it.each(['button', 'submit'] as const)('renders a button with type %s', (buttonType) => { | |||||
render( | |||||
<ActionButton | |||||
type={buttonType} | |||||
/> | |||||
); | |||||
const button: HTMLButtonElement = screen.getByRole('button'); | |||||
expect(button).toHaveProperty('type', buttonType); | |||||
}); | |||||
it('renders a disabled button', () => { | |||||
render( | |||||
<ActionButton | |||||
disabled | |||||
/> | |||||
); | |||||
const button: HTMLButtonElement = screen.getByTestId('button'); | |||||
expect(button).toBeDisabled(); | |||||
}); | |||||
}); |
@@ -2,37 +2,73 @@ import * as React from 'react'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import { Button } from '@tesseract-design/web-base'; | import { Button } from '@tesseract-design/web-base'; | ||||
/** | |||||
* Derived HTML element of the {@link ActionButton} component. | |||||
*/ | |||||
export type ActionButtonDerivedElement = HTMLButtonElement; | export type ActionButtonDerivedElement = HTMLButtonElement; | ||||
/** | |||||
* Props of the {@link ActionButton} component. | |||||
*/ | |||||
export interface ActionButtonProps extends Omit<React.HTMLProps<ActionButtonDerivedElement>, 'type' | 'size'> { | export interface ActionButtonProps extends Omit<React.HTMLProps<ActionButtonDerivedElement>, 'type' | 'size'> { | ||||
/** | |||||
* Type of the component. | |||||
*/ | |||||
type?: Button.Type; | type?: Button.Type; | ||||
/** | |||||
* Variant of the component. | |||||
*/ | |||||
variant?: Button.Variant; | variant?: Button.Variant; | ||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean; | block?: boolean; | ||||
/** | |||||
* Complementary content of the component. | |||||
*/ | |||||
subtext?: React.ReactNode; | subtext?: React.ReactNode; | ||||
/** | |||||
* Short complementary content displayed at the edge of the component. | |||||
*/ | |||||
badge?: React.ReactNode; | badge?: React.ReactNode; | ||||
/** | |||||
* Is this component part of a menu? | |||||
*/ | |||||
menuItem?: boolean; | menuItem?: boolean; | ||||
/** | |||||
* Size of the component. | |||||
*/ | |||||
size?: Button.Size; | size?: Button.Size; | ||||
/** | |||||
* Should the component's content use minimal space? | |||||
*/ | |||||
compact?: boolean; | compact?: boolean; | ||||
} | } | ||||
/** | |||||
* Component for performing an action upon activation (e.g. when clicked). | |||||
* | |||||
* This component functions as a regular button. | |||||
*/ | |||||
export const ActionButton = React.forwardRef<ActionButtonDerivedElement, ActionButtonProps>(( | export const ActionButton = React.forwardRef<ActionButtonDerivedElement, ActionButtonProps>(( | ||||
{ | { | ||||
type = 'button' as const, | type = 'button' as const, | ||||
variant = 'bare' as const, | variant = 'bare' as const, | ||||
subtext, | subtext, | ||||
badge, | badge, | ||||
menuItem = false, | |||||
menuItem = false as const, | |||||
children, | children, | ||||
size = 'medium' as const, | size = 'medium' as const, | ||||
compact = false, | |||||
compact = false as const, | |||||
className, | className, | ||||
block = false, | |||||
block = false as const, | |||||
...etcProps | ...etcProps | ||||
}, | }, | ||||
forwardedRef, | forwardedRef, | ||||
) => ( | ) => ( | ||||
<button | <button | ||||
{...etcProps} | {...etcProps} | ||||
data-testid="button" | |||||
type={type} | type={type} | ||||
ref={forwardedRef} | ref={forwardedRef} | ||||
className={clsx( | className={clsx( | ||||
@@ -120,15 +156,15 @@ export const ActionButton = React.forwardRef<ActionButtonDerivedElement, ActionB | |||||
</button> | </button> | ||||
)); | )); | ||||
ActionButton.displayName = 'ActionButton'; | |||||
ActionButton.displayName = 'ActionButton' as const; | |||||
ActionButton.defaultProps = { | ActionButton.defaultProps = { | ||||
type: 'button', | |||||
variant: 'bare', | |||||
block: false, | |||||
type: 'button' as const, | |||||
variant: 'bare' as const, | |||||
block: false as const, | |||||
subtext: undefined, | subtext: undefined, | ||||
badge: undefined, | badge: undefined, | ||||
menuItem: false, | |||||
size: 'medium', | |||||
compact: false, | |||||
menuItem: false as const, | |||||
size: 'medium' as const, | |||||
compact: false as const, | |||||
}; | }; |
@@ -0,0 +1,10 @@ | |||||
import { describe, it, expect } from 'vitest'; | |||||
import * as WebActionReact from '.'; | |||||
describe('web-action-react', () => { | |||||
it.each([ | |||||
'ActionButton', | |||||
])('exports %s', (namedExport) => { | |||||
expect(WebActionReact).toHaveProperty(namedExport); | |||||
}); | |||||
}); |
@@ -2,8 +2,14 @@ import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | import { TextControl } from '@tesseract-design/web-base'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
/** | |||||
* Derived HTML element of the {@link ComboBox} component. | |||||
*/ | |||||
export type ComboBoxDerivedElement = HTMLInputElement; | export type ComboBoxDerivedElement = HTMLInputElement; | ||||
/** | |||||
* Props of the {@link ComboBox} component. | |||||
*/ | |||||
export interface ComboBoxProps extends Omit<React.HTMLProps<ComboBoxDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list' | 'inputMode'> { | export interface ComboBoxProps extends Omit<React.HTMLProps<ComboBoxDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list' | 'inputMode'> { | ||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
@@ -58,11 +64,11 @@ export const ComboBox = React.forwardRef<ComboBoxDerivedElement, ComboBoxProps>( | |||||
hint, | hint, | ||||
indicator, | indicator, | ||||
size = 'medium' as const, | size = 'medium' as const, | ||||
border = false, | |||||
block = false, | |||||
border = false as const, | |||||
block = false as const, | |||||
type = 'text' as const, | type = 'text' as const, | ||||
variant = 'default' as const, | variant = 'default' as const, | ||||
hiddenLabel = false, | |||||
hiddenLabel = false as const, | |||||
className, | className, | ||||
children, | children, | ||||
inputMode = 'text' as const, | inputMode = 'text' as const, | ||||
@@ -226,17 +232,17 @@ export const ComboBox = React.forwardRef<ComboBoxDerivedElement, ComboBoxProps>( | |||||
); | ); | ||||
}); | }); | ||||
ComboBox.displayName = 'ComboBox'; | |||||
ComboBox.displayName = 'ComboBox' as const; | |||||
ComboBox.defaultProps = { | ComboBox.defaultProps = { | ||||
label: undefined, | label: undefined, | ||||
hint: undefined, | hint: undefined, | ||||
indicator: undefined, | indicator: undefined, | ||||
size: 'medium', | |||||
border: false, | |||||
block: false, | |||||
type: 'text', | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
inputMode: 'text', | |||||
size: 'medium' as const, | |||||
border: false as const, | |||||
block: false as const, | |||||
type: 'text' as const, | |||||
variant: 'default' as const, | |||||
hiddenLabel: false as const, | |||||
inputMode: 'text' as const, | |||||
}; | }; |
@@ -2,8 +2,14 @@ import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | import { TextControl } from '@tesseract-design/web-base'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
/** | |||||
* Derived HTML element of the {@link DropdownSelect} component. | |||||
*/ | |||||
export type DropdownSelectDerivedElement = HTMLSelectElement; | export type DropdownSelectDerivedElement = HTMLSelectElement; | ||||
/** | |||||
* Props of the {@link DropdownSelect} component. | |||||
*/ | |||||
export interface DropdownSelectProps extends Omit<React.HTMLProps<DropdownSelectDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list' | 'multiple'> { | export interface DropdownSelectProps extends Omit<React.HTMLProps<DropdownSelectDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list' | 'multiple'> { | ||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
@@ -40,177 +46,173 @@ export interface DropdownSelectProps extends Omit<React.HTMLProps<DropdownSelect | |||||
} | } | ||||
/** | /** | ||||
* Component for inputting textual values. | |||||
* | |||||
* This component supports multiline input and adjusts its layout accordingly. | |||||
* Component for selecting a single value from a dropdown. | |||||
*/ | */ | ||||
export const DropdownSelect = React.forwardRef<DropdownSelectDerivedElement, DropdownSelectProps>( | |||||
( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
children, | |||||
id: idProp, | |||||
...etcProps | |||||
}: DropdownSelectProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
export const DropdownSelect = React.forwardRef<DropdownSelectDerivedElement, DropdownSelectProps>(( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false as const, | |||||
block = false as const, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false as const, | |||||
className, | |||||
children, | |||||
id: idProp, | |||||
...etcProps | |||||
}: DropdownSelectProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
return ( | |||||
<div | |||||
return ( | |||||
<div | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50 min-w-48', | |||||
'focus-within:ring-4', | |||||
{ | |||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
}, | |||||
className, | |||||
)} | |||||
> | |||||
<select | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
data-testid="input" | |||||
className={clsx( | className={clsx( | ||||
'relative rounded ring-secondary/50 min-w-48', | |||||
'focus-within:ring-4', | |||||
'tesseract-design-dropdown-select bg-negative rounded-inherit w-full peer block appearance-none cursor-pointer select-none', | |||||
'focus:outline-0', | |||||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||||
{ | |||||
'text-xxs': size === 'small', | |||||
'text-xs': size === 'medium', | |||||
}, | |||||
{ | |||||
'pl-4': variant === 'default', | |||||
'pl-1.5': variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pt-4': variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pr-4': variant === 'default' && !indicator, | |||||
'pr-1.5': variant === 'alternate' && !indicator, | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
{ | { | ||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
'h-10': size === 'small', | |||||
'h-12': size === 'medium', | |||||
'h-16': size === 'large', | |||||
}, | }, | ||||
className, | |||||
)} | )} | ||||
> | > | ||||
<select | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
data-testid="input" | |||||
{children} | |||||
</select> | |||||
{ | |||||
label && ( | |||||
<label | |||||
htmlFor={id} | |||||
data-testid="label" | |||||
id={labelId} | |||||
className={clsx( | |||||
'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold peer-disabled:opacity-50 peer-focus:text-secondary text-primary leading-none bg-negative select-none', | |||||
{ | |||||
'sr-only': hiddenLabel, | |||||
}, | |||||
{ | |||||
'pr-1': !indicator, | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
<span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis"> | |||||
{label} | |||||
</span> | |||||
</label> | |||||
) | |||||
} | |||||
{hint && ( | |||||
<div | |||||
data-testid="hint" | |||||
className={clsx( | className={clsx( | ||||
'tesseract-design-dropdown-select bg-negative rounded-inherit w-full peer block appearance-none cursor-pointer select-none', | |||||
'focus:outline-0', | |||||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||||
'absolute left-0 px-1 pointer-events-none text-xxs peer-disabled:opacity-50 leading-none w-full bg-negative select-none', | |||||
{ | { | ||||
'text-xxs': size === 'small', | |||||
'text-xs': size === 'medium', | |||||
'bottom-0 pl-4 pb-1': variant === 'default', | |||||
'top-0.5': variant === 'alternate', | |||||
}, | }, | ||||
{ | { | ||||
'pl-4': variant === 'default', | |||||
'pl-1.5': variant === 'alternate', | |||||
'pt-2': variant === 'alternate' && size === 'small', | |||||
'pt-3': variant === 'alternate' && size !== 'small', | |||||
}, | }, | ||||
{ | { | ||||
'pt-4': variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pr-4': variant === 'default' && !indicator, | |||||
'pr-1.5': variant === 'alternate' && !indicator, | |||||
'pr-4': !indicator && variant === 'default', | |||||
'pr-1': !indicator && variant === 'alternate', | |||||
}, | }, | ||||
{ | { | ||||
'pr-10': indicator && size === 'small', | 'pr-10': indicator && size === 'small', | ||||
'pr-12': indicator && size === 'medium', | 'pr-12': indicator && size === 'medium', | ||||
'pr-16': indicator && size === 'large', | 'pr-16': indicator && size === 'large', | ||||
}, | }, | ||||
{ | |||||
'h-10': size === 'small', | |||||
'h-12': size === 'medium', | |||||
'h-16': size === 'large', | |||||
}, | |||||
)} | )} | ||||
> | > | ||||
{children} | |||||
</select> | |||||
{ | |||||
label && ( | |||||
<label | |||||
htmlFor={id} | |||||
data-testid="label" | |||||
id={labelId} | |||||
className={clsx( | |||||
'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold peer-disabled:opacity-50 peer-focus:text-secondary text-primary leading-none bg-negative select-none', | |||||
{ | |||||
'sr-only': hiddenLabel, | |||||
}, | |||||
{ | |||||
'pr-1': !indicator, | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
<span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis"> | |||||
{label} | |||||
</span> | |||||
</label> | |||||
) | |||||
} | |||||
{hint && ( | |||||
<div | |||||
data-testid="hint" | |||||
className={clsx( | |||||
'absolute left-0 px-1 pointer-events-none text-xxs peer-disabled:opacity-50 leading-none w-full bg-negative select-none', | |||||
{ | |||||
'bottom-0 pl-4 pb-1': variant === 'default', | |||||
'top-0.5': variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pt-2': variant === 'alternate' && size === 'small', | |||||
'pt-3': variant === 'alternate' && size !== 'small', | |||||
}, | |||||
{ | |||||
'pr-4': !indicator && variant === 'default', | |||||
'pr-1': !indicator && variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
<div | |||||
className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis" | |||||
> | |||||
{hint} | |||||
</div> | |||||
</div> | |||||
)} | |||||
{indicator && ( | |||||
<div | <div | ||||
className={clsx( | |||||
'text-center flex items-center justify-center peer-disabled:opacity-50 aspect-square absolute bottom-0 right-0 pointer-events-none select-none', | |||||
{ | |||||
'w-10': size === 'small', | |||||
'w-12': size === 'medium', | |||||
'w-16': size === 'large', | |||||
}, | |||||
)} | |||||
className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis" | |||||
> | > | ||||
{indicator} | |||||
{hint} | |||||
</div> | </div> | ||||
)} | |||||
{ | |||||
border && ( | |||||
<span | |||||
data-testid="border" | |||||
className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary" | |||||
/> | |||||
) | |||||
} | |||||
</div> | |||||
); | |||||
}, | |||||
); | |||||
</div> | |||||
)} | |||||
{indicator && ( | |||||
<div | |||||
className={clsx( | |||||
'text-center flex items-center justify-center peer-disabled:opacity-50 aspect-square absolute bottom-0 right-0 pointer-events-none select-none', | |||||
{ | |||||
'w-10': size === 'small', | |||||
'w-12': size === 'medium', | |||||
'w-16': size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
{indicator} | |||||
</div> | |||||
)} | |||||
{ | |||||
border && ( | |||||
<span | |||||
data-testid="border" | |||||
className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary" | |||||
/> | |||||
) | |||||
} | |||||
</div> | |||||
); | |||||
}); | |||||
DropdownSelect.displayName = 'DropdownSelect'; | |||||
DropdownSelect.displayName = 'DropdownSelect' as const; | |||||
DropdownSelect.defaultProps = { | DropdownSelect.defaultProps = { | ||||
label: undefined, | label: undefined, | ||||
hint: undefined, | hint: undefined, | ||||
indicator: undefined, | indicator: undefined, | ||||
size: 'medium', | |||||
border: false, | |||||
block: false, | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
size: 'medium' as const, | |||||
border: false as const, | |||||
block: false as const, | |||||
variant: 'default' as const, | |||||
hiddenLabel: false as const, | |||||
}; | }; |
@@ -2,8 +2,14 @@ import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | import { TextControl } from '@tesseract-design/web-base'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
/** | |||||
* Derived HTML element of the {@link MenuSelect} component. | |||||
*/ | |||||
export type MenuSelectDerivedElement = HTMLSelectElement; | export type MenuSelectDerivedElement = HTMLSelectElement; | ||||
/** | |||||
* Props of the {@link MenuSelect} component. | |||||
*/ | |||||
export interface MenuSelectProps extends Omit<React.HTMLProps<MenuSelectDerivedElement>, 'size' | 'style' | 'label' | 'multiple'> { | export interface MenuSelectProps extends Omit<React.HTMLProps<MenuSelectDerivedElement>, 'size' | 'style' | 'label' | 'multiple'> { | ||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
@@ -44,191 +50,187 @@ export interface MenuSelectProps extends Omit<React.HTMLProps<MenuSelectDerivedE | |||||
} | } | ||||
/** | /** | ||||
* Component for inputting textual values. | |||||
* | |||||
* This component supports multiline input and adjusts its layout accordingly. | |||||
* Component for selecting a single value from a menu. | |||||
*/ | */ | ||||
export const MenuSelect = React.forwardRef<MenuSelectDerivedElement, MenuSelectProps>( | |||||
( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
startingHeight = '15rem', | |||||
id: idProp, | |||||
...etcProps | |||||
}: MenuSelectProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
export const MenuSelect = React.forwardRef<MenuSelectDerivedElement, MenuSelectProps>(( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
startingHeight = '15rem', | |||||
id: idProp, | |||||
...etcProps | |||||
}: MenuSelectProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
return ( | |||||
<div | |||||
return ( | |||||
<div | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50', | |||||
'focus-within:ring-4', | |||||
{ | |||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
}, | |||||
className, | |||||
)} | |||||
> | |||||
<select | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
data-testid="input" | |||||
size={2} | |||||
style={{ | |||||
height: startingHeight, | |||||
}} | |||||
className={clsx( | className={clsx( | ||||
'relative rounded ring-secondary/50', | |||||
'focus-within:ring-4', | |||||
'tesseract-design-menu-select bg-negative rounded-inherit w-full peer block overflow-auto cursor-pointer', | |||||
'focus:outline-0', | |||||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||||
{ | |||||
'resize': !block, | |||||
'resize-y': block, | |||||
}, | |||||
{ | |||||
'text-xxs': size === 'small', | |||||
'text-xs': size === 'medium', | |||||
}, | |||||
{ | |||||
'pl-4': variant === 'default', | |||||
'pl-1.5': variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pt-4': variant === 'alternate' && size === 'small', | |||||
'pt-5': variant === 'alternate' && size === 'medium', | |||||
'pt-8': variant === 'alternate' && size === 'large', | |||||
}, | |||||
{ | |||||
'py-2.5': variant === 'default' && size === 'small', | |||||
'py-3': variant === 'default' && size === 'medium', | |||||
'py-5': variant === 'default' && size === 'large', | |||||
}, | |||||
{ | |||||
'pr-4': variant === 'default' && !indicator, | |||||
'pr-1.5': variant === 'alternate' && !indicator, | |||||
}, | |||||
{ | { | ||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
{ | |||||
'min-h-10': size === 'small', | |||||
'min-h-12': size === 'medium', | |||||
'min-h-16': size === 'large', | |||||
}, | }, | ||||
className, | |||||
)} | )} | ||||
> | |||||
<select | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
data-testid="input" | |||||
size={2} | |||||
style={{ | |||||
height: startingHeight, | |||||
}} | |||||
/> | |||||
{ | |||||
label && ( | |||||
<label | |||||
data-testid="label" | |||||
htmlFor={id} | |||||
id={labelId} | |||||
className={clsx( | |||||
'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold peer-disabled:opacity-50 peer-focus:text-secondary text-primary leading-none bg-negative select-none', | |||||
{ | |||||
'sr-only': hiddenLabel, | |||||
}, | |||||
{ | |||||
'pr-1': !indicator, | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
<span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis"> | |||||
{label} | |||||
</span> | |||||
</label> | |||||
) | |||||
} | |||||
{hint && ( | |||||
<div | |||||
data-testid="hint" | |||||
className={clsx( | className={clsx( | ||||
'tesseract-design-menu-select bg-negative rounded-inherit w-full peer block overflow-auto cursor-pointer', | |||||
'focus:outline-0', | |||||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||||
{ | |||||
'resize': !block, | |||||
'resize-y': block, | |||||
}, | |||||
'absolute left-0 px-1 pointer-events-none text-xxs peer-disabled:opacity-50 leading-none w-full bg-negative select-none', | |||||
{ | { | ||||
'text-xxs': size === 'small', | |||||
'text-xs': size === 'medium', | |||||
'bottom-0 pl-4 pb-1': variant === 'default', | |||||
'top-0.5': variant === 'alternate', | |||||
}, | }, | ||||
{ | { | ||||
'pl-4': variant === 'default', | |||||
'pl-1.5': variant === 'alternate', | |||||
'pt-2': variant === 'alternate' && size === 'small', | |||||
'pt-3': variant === 'alternate' && size !== 'small', | |||||
}, | }, | ||||
{ | { | ||||
'pt-4': variant === 'alternate' && size === 'small', | |||||
'pt-5': variant === 'alternate' && size === 'medium', | |||||
'pt-8': variant === 'alternate' && size === 'large', | |||||
}, | |||||
{ | |||||
'py-2.5': variant === 'default' && size === 'small', | |||||
'py-3': variant === 'default' && size === 'medium', | |||||
'py-5': variant === 'default' && size === 'large', | |||||
}, | |||||
{ | |||||
'pr-4': variant === 'default' && !indicator, | |||||
'pr-1.5': variant === 'alternate' && !indicator, | |||||
'pr-4': !indicator && variant === 'default', | |||||
'pr-1': !indicator && variant === 'alternate', | |||||
}, | }, | ||||
{ | { | ||||
'pr-10': indicator && size === 'small', | 'pr-10': indicator && size === 'small', | ||||
'pr-12': indicator && size === 'medium', | 'pr-12': indicator && size === 'medium', | ||||
'pr-16': indicator && size === 'large', | 'pr-16': indicator && size === 'large', | ||||
}, | }, | ||||
{ | |||||
'min-h-10': size === 'small', | |||||
'min-h-12': size === 'medium', | |||||
'min-h-16': size === 'large', | |||||
}, | |||||
)} | )} | ||||
/> | |||||
{ | |||||
label && ( | |||||
<label | |||||
data-testid="label" | |||||
htmlFor={id} | |||||
id={labelId} | |||||
className={clsx( | |||||
'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold peer-disabled:opacity-50 peer-focus:text-secondary text-primary leading-none bg-negative select-none', | |||||
{ | |||||
'sr-only': hiddenLabel, | |||||
}, | |||||
{ | |||||
'pr-1': !indicator, | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
<span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis"> | |||||
{label} | |||||
</span> | |||||
</label> | |||||
) | |||||
} | |||||
{hint && ( | |||||
> | |||||
<div | <div | ||||
data-testid="hint" | |||||
className={clsx( | |||||
'absolute left-0 px-1 pointer-events-none text-xxs peer-disabled:opacity-50 leading-none w-full bg-negative select-none', | |||||
{ | |||||
'bottom-0 pl-4 pb-1': variant === 'default', | |||||
'top-0.5': variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pt-2': variant === 'alternate' && size === 'small', | |||||
'pt-3': variant === 'alternate' && size !== 'small', | |||||
}, | |||||
{ | |||||
'pr-4': !indicator && variant === 'default', | |||||
'pr-1': !indicator && variant === 'alternate', | |||||
}, | |||||
{ | |||||
'pr-10': indicator && size === 'small', | |||||
'pr-12': indicator && size === 'medium', | |||||
'pr-16': indicator && size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
<div | |||||
className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis" | |||||
> | |||||
{hint} | |||||
</div> | |||||
</div> | |||||
)} | |||||
{indicator && ( | |||||
<div | |||||
className={clsx( | |||||
'text-center flex items-center justify-center peer-disabled:opacity-50 aspect-square absolute bottom-0 right-0 pointer-events-none select-none', | |||||
{ | |||||
'w-10': size === 'small', | |||||
'w-12': size === 'medium', | |||||
'w-16': size === 'large', | |||||
}, | |||||
)} | |||||
className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis" | |||||
> | > | ||||
{indicator} | |||||
{hint} | |||||
</div> | </div> | ||||
)} | |||||
{ | |||||
border && ( | |||||
<span | |||||
data-testid="border" | |||||
className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary" | |||||
/> | |||||
) | |||||
} | |||||
</div> | |||||
); | |||||
}, | |||||
); | |||||
</div> | |||||
)} | |||||
{indicator && ( | |||||
<div | |||||
className={clsx( | |||||
'text-center flex items-center justify-center peer-disabled:opacity-50 aspect-square absolute bottom-0 right-0 pointer-events-none select-none', | |||||
{ | |||||
'w-10': size === 'small', | |||||
'w-12': size === 'medium', | |||||
'w-16': size === 'large', | |||||
}, | |||||
)} | |||||
> | |||||
{indicator} | |||||
</div> | |||||
)} | |||||
{ | |||||
border && ( | |||||
<span | |||||
data-testid="border" | |||||
className="absolute z-[1] peer-disabled:opacity-50 inset-0 rounded-inherit border-2 border-primary pointer-events-none peer-focus:border-secondary" | |||||
/> | |||||
) | |||||
} | |||||
</div> | |||||
); | |||||
}); | |||||
MenuSelect.displayName = 'MenuSelect'; | |||||
MenuSelect.displayName = 'MenuSelect' as const; | |||||
MenuSelect.defaultProps = { | MenuSelect.defaultProps = { | ||||
label: undefined, | label: undefined, | ||||
hint: undefined, | hint: undefined, | ||||
indicator: undefined, | indicator: undefined, | ||||
size: 'medium', | |||||
border: false, | |||||
block: false, | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
startingHeight: '15rem', | |||||
size: 'medium' as const, | |||||
border: false as const, | |||||
block: false as const, | |||||
variant: 'default' as const, | |||||
hiddenLabel: false as const, | |||||
startingHeight: '15rem' as const, | |||||
}; | }; |
@@ -2,22 +2,51 @@ import * as React from 'react'; | |||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
import { Button } from '@tesseract-design/web-base'; | import { Button } from '@tesseract-design/web-base'; | ||||
/** | |||||
* Derived HTML element of the {@link RadioButton} component. | |||||
*/ | |||||
export type RadioButtonDerivedElement = HTMLInputElement; | export type RadioButtonDerivedElement = HTMLInputElement; | ||||
/** | |||||
* Props of the {@link RadioButton} component. | |||||
*/ | |||||
export interface RadioButtonProps extends Omit<React.InputHTMLAttributes<RadioButtonDerivedElement>, 'type' | 'size'> { | export interface RadioButtonProps extends Omit<React.InputHTMLAttributes<RadioButtonDerivedElement>, 'type' | 'size'> { | ||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean; | block?: boolean; | ||||
/** | |||||
* Should the component's content use minimal space? | |||||
*/ | |||||
compact?: boolean; | compact?: boolean; | ||||
/** | |||||
* Size of the component. | |||||
*/ | |||||
size?: Button.Size; | size?: Button.Size; | ||||
/** | |||||
* Complementary content of the component. | |||||
*/ | |||||
subtext?: React.ReactNode; | subtext?: React.ReactNode; | ||||
/** | |||||
* Short complementary content displayed at the edge of the component. | |||||
*/ | |||||
badge?: React.ReactNode; | badge?: React.ReactNode; | ||||
/** | |||||
* Variant of the component. | |||||
*/ | |||||
variant?: Button.Variant; | variant?: Button.Variant; | ||||
} | } | ||||
/** | |||||
* Component for selecting a single value from an array of choices grouped by name. | |||||
* | |||||
* This component is displayed as a regular button. | |||||
*/ | |||||
export const RadioButton = React.forwardRef<RadioButtonDerivedElement, RadioButtonProps>(( | export const RadioButton = React.forwardRef<RadioButtonDerivedElement, RadioButtonProps>(( | ||||
{ | { | ||||
children, | children, | ||||
block = false, | |||||
compact = false, | |||||
block = false as const, | |||||
compact = false as const, | |||||
size = 'medium' as const, | size = 'medium' as const, | ||||
id: idProp, | id: idProp, | ||||
className, | className, | ||||
@@ -141,13 +170,13 @@ export const RadioButton = React.forwardRef<RadioButtonDerivedElement, RadioButt | |||||
); | ); | ||||
}); | }); | ||||
RadioButton.displayName = 'RadioButton'; | |||||
RadioButton.displayName = 'RadioButton' as const; | |||||
RadioButton.defaultProps = { | RadioButton.defaultProps = { | ||||
badge: undefined, | badge: undefined, | ||||
block: false, | |||||
compact: false, | |||||
block: false as const, | |||||
compact: false as const, | |||||
subtext: undefined, | subtext: undefined, | ||||
size: 'medium', | |||||
variant: 'filled', | |||||
size: 'medium' as const, | |||||
variant: 'bare' as const, | |||||
}; | }; |
@@ -1,17 +1,34 @@ | |||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import clsx from 'clsx'; | import clsx from 'clsx'; | ||||
/** | |||||
* Derived HTML element of the {@link RadioTickBox} component. | |||||
*/ | |||||
export type RadioTickBoxDerivedElement = HTMLInputElement; | export type RadioTickBoxDerivedElement = HTMLInputElement; | ||||
/** | |||||
* Props of the {@link RadioTickBox} component. | |||||
*/ | |||||
export interface RadioTickBoxProps extends Omit<React.InputHTMLAttributes<RadioTickBoxDerivedElement>, 'type' | 'size'> { | export interface RadioTickBoxProps extends Omit<React.InputHTMLAttributes<RadioTickBoxDerivedElement>, 'type' | 'size'> { | ||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean; | block?: boolean; | ||||
/** | |||||
* Complementary content of the component. | |||||
*/ | |||||
subtext?: React.ReactNode; | subtext?: React.ReactNode; | ||||
} | } | ||||
/** | |||||
* Component for selecting a single value from an array of choices grouped by name. | |||||
* | |||||
* This component is displayed as a tick box, i.e. a typical radio button. | |||||
*/ | |||||
export const RadioTickBox = React.forwardRef<RadioTickBoxDerivedElement, RadioTickBoxProps>(( | export const RadioTickBox = React.forwardRef<RadioTickBoxDerivedElement, RadioTickBoxProps>(( | ||||
{ | { | ||||
children, | children, | ||||
block = false, | |||||
block = false as const, | |||||
id: idProp, | id: idProp, | ||||
className, | className, | ||||
subtext, | subtext, | ||||
@@ -84,9 +101,9 @@ export const RadioTickBox = React.forwardRef<RadioTickBoxDerivedElement, RadioTi | |||||
); | ); | ||||
}); | }); | ||||
RadioTickBox.displayName = 'RadioTickBox'; | |||||
RadioTickBox.displayName = 'RadioTickBox' as const; | |||||
RadioTickBox.defaultProps = { | RadioTickBox.defaultProps = { | ||||
block: false, | |||||
block: false as const, | |||||
subtext: undefined, | subtext: undefined, | ||||
}; | }; |
@@ -45,6 +45,9 @@ importers: | |||||
'@testing-library/react': | '@testing-library/react': | ||||
specifier: ^13.4.0 | specifier: ^13.4.0 | ||||
version: 13.4.0(react-dom@18.2.0)(react@18.2.0) | version: 13.4.0(react-dom@18.2.0)(react@18.2.0) | ||||
'@testing-library/user-event': | |||||
specifier: ^14.4.3 | |||||
version: 14.4.3(@testing-library/dom@8.20.1) | |||||
'@types/node': | '@types/node': | ||||
specifier: ^18.14.1 | specifier: ^18.14.1 | ||||
version: 18.14.1 | version: 18.14.1 | ||||
@@ -1926,6 +1929,15 @@ packages: | |||||
react-dom: 18.2.0(react@18.2.0) | react-dom: 18.2.0(react@18.2.0) | ||||
dev: true | dev: true | ||||
/@testing-library/user-event@14.4.3(@testing-library/dom@8.20.1): | |||||
resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} | |||||
engines: {node: '>=12', npm: '>=6'} | |||||
peerDependencies: | |||||
'@testing-library/dom': '>=7.21.4' | |||||
dependencies: | |||||
'@testing-library/dom': 8.20.1 | |||||
dev: true | |||||
/@theoryofnekomata/formxtra@1.0.3: | /@theoryofnekomata/formxtra@1.0.3: | ||||
resolution: {integrity: sha512-xOzE07Slttpx7vbOWqXfatJ+k44TN4zUjI57A5/sNqUDtHzp3pz94A+AVPGVoBY0QXiwzMjeN4DPMp6U1qlkyg==} | resolution: {integrity: sha512-xOzE07Slttpx7vbOWqXfatJ+k44TN4zUjI57A5/sNqUDtHzp3pz94A+AVPGVoBY0QXiwzMjeN4DPMp6U1qlkyg==} | ||||
engines: {node: '>=10'} | engines: {node: '>=10'} | ||||
@@ -1,45 +0,0 @@ | |||||
import { NextPage } from 'next'; | |||||
import { DefaultLayout } from '@/components/DefaultLayout'; | |||||
const CodePage: NextPage = () => { | |||||
return ( | |||||
<DefaultLayout | |||||
title="Code" | |||||
> | |||||
<main className="mt-8 mb-16 md:mt-16 md:mb-32"> | |||||
<section> | |||||
<div className="container mx-auto px-4"> | |||||
<h2> | |||||
VerifyCodeInput | |||||
</h2> | |||||
<div> | |||||
TODO | |||||
</div> | |||||
</div> | |||||
</section> | |||||
<section> | |||||
<div className="container mx-auto px-4"> | |||||
<h2> | |||||
CodeBlock | |||||
</h2> | |||||
<div> | |||||
TODO | |||||
</div> | |||||
</div> | |||||
</section> | |||||
<section> | |||||
<div className="container mx-auto px-4"> | |||||
<h2> | |||||
CodeInput | |||||
</h2> | |||||
<div> | |||||
TODO | |||||
</div> | |||||
</div> | |||||
</section> | |||||
</main> | |||||
</DefaultLayout> | |||||
) | |||||
} | |||||
export default CodePage; |