Implement components with native HTML counterparts except datetime-localmaster
@@ -59,9 +59,9 @@ | |||||
- [ ] DurationInput | - [ ] DurationInput | ||||
- [ ] MonthInput | - [ ] MonthInput | ||||
- [ ] MonthDayInput | - [ ] MonthDayInput | ||||
- [ ] TimeSpinner | |||||
- [ ] YearMonthInput | |||||
- [ ] YearWeekInput | |||||
- [X] TimeSpinner | |||||
- [-] YearMonthInput | |||||
- [-] YearWeekInput | |||||
- [ ] YearInput | - [ ] YearInput | ||||
# Others | # Others | ||||
@@ -0,0 +1,263 @@ | |||||
import * as React from 'react'; | |||||
import { | |||||
cleanup, | |||||
render, | |||||
screen, | |||||
} from '@testing-library/react'; | |||||
import userEvent from '@testing-library/user-event'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | |||||
import { | |||||
vi, | |||||
expect, | |||||
describe, | |||||
it, | |||||
afterEach, | |||||
} from 'vitest'; | |||||
import matchers from '@testing-library/jest-dom/matchers'; | |||||
import { | |||||
TextInput, | |||||
TextInputDerivedElement, | |||||
} from '.'; | |||||
expect.extend(matchers); | |||||
describe('TextInput', () => { | |||||
afterEach(() => { | |||||
cleanup(); | |||||
}); | |||||
it('renders a textbox', () => { | |||||
render( | |||||
<TextInput />, | |||||
); | |||||
const textbox = screen.getByRole('textbox'); | |||||
expect(textbox).toBeInTheDocument(); | |||||
expect(textbox).toHaveProperty('type', 'text'); | |||||
}); | |||||
it('renders a border', () => { | |||||
render( | |||||
<TextInput | |||||
border | |||||
/>, | |||||
); | |||||
const border = screen.getByTestId('border'); | |||||
expect(border).toBeInTheDocument(); | |||||
}); | |||||
it('renders a label', () => { | |||||
render( | |||||
<TextInput | |||||
label="foo" | |||||
/>, | |||||
); | |||||
const textbox = screen.getByLabelText('foo'); | |||||
expect(textbox).toBeInTheDocument(); | |||||
const label = screen.getByTestId('label'); | |||||
expect(label).toHaveTextContent('foo'); | |||||
}); | |||||
it('renders a hidden label', () => { | |||||
render( | |||||
<TextInput | |||||
label="foo" | |||||
hiddenLabel | |||||
/>, | |||||
); | |||||
const textbox = screen.getByLabelText('foo'); | |||||
expect(textbox).toBeInTheDocument(); | |||||
const label = screen.queryByTestId('label'); | |||||
expect(label).toBeInTheDocument(); | |||||
expect(label).toHaveClass('sr-only'); | |||||
}); | |||||
it('renders a hint', () => { | |||||
render( | |||||
<TextInput | |||||
hint="foo" | |||||
/>, | |||||
); | |||||
const hint = screen.getByTestId('hint'); | |||||
expect(hint).toBeInTheDocument(); | |||||
}); | |||||
it('renders an indicator', () => { | |||||
render( | |||||
<TextInput | |||||
indicator={ | |||||
<div /> | |||||
} | |||||
/>, | |||||
); | |||||
const indicator = screen.getByTestId('indicator'); | |||||
expect(indicator).toBeInTheDocument(); | |||||
}); | |||||
describe.each` | |||||
size | inputClassName | hintClassName | indicatorClassName | |||||
${'small'} | ${'h-10'} | ${'pr-10'} | ${'w-10'} | |||||
${'medium'} | ${'h-12'} | ${'pr-12'} | ${'w-12'} | |||||
${'large'} | ${'h-16'} | ${'pr-16'} | ${'w-16'} | |||||
`('on $size size', ({ | |||||
size, | |||||
inputClassName, | |||||
hintClassName, | |||||
indicatorClassName, | |||||
}: { | |||||
size: TextControl.Size, | |||||
inputClassName: string, | |||||
hintClassName: string, | |||||
indicatorClassName: string, | |||||
}) => { | |||||
it('renders input styles', () => { | |||||
render( | |||||
<TextInput | |||||
size={size} | |||||
/>, | |||||
); | |||||
const input = screen.getByTestId('input'); | |||||
expect(input).toHaveClass(inputClassName); | |||||
}); | |||||
it('renders label styles with indicator', () => { | |||||
render( | |||||
<TextInput | |||||
size={size} | |||||
label="foo" | |||||
indicator={<div />} | |||||
/>, | |||||
); | |||||
const label = screen.getByTestId('label'); | |||||
expect(label).toHaveClass(hintClassName); | |||||
}); | |||||
it('renders hint styles when indicator is present', () => { | |||||
render( | |||||
<TextInput | |||||
size={size} | |||||
hint="hint" | |||||
indicator={<div />} | |||||
/>, | |||||
); | |||||
const hint = screen.getByTestId('hint'); | |||||
expect(hint).toHaveClass(hintClassName); | |||||
}); | |||||
it('renders indicator styles', () => { | |||||
render( | |||||
<TextInput | |||||
size={size} | |||||
indicator={ | |||||
<div /> | |||||
} | |||||
/>, | |||||
); | |||||
const indicator = screen.getByTestId('indicator'); | |||||
expect(indicator).toHaveClass(indicatorClassName); | |||||
}); | |||||
}); | |||||
it('renders a block textbox', () => { | |||||
render( | |||||
<TextInput | |||||
block | |||||
/>, | |||||
); | |||||
const base = screen.getByTestId('base'); | |||||
expect(base).toHaveClass('block'); | |||||
}); | |||||
it.each(TextControl.AVAILABLE_INPUT_TYPES)('renders a textbox with type %s', (inputType) => { | |||||
render( | |||||
<TextInput | |||||
type={inputType} | |||||
/>, | |||||
); | |||||
const textbox = screen.getByTestId('input'); | |||||
expect(textbox).toHaveProperty('type', inputType); | |||||
}); | |||||
it('falls back to text input mode when it clashes with the input type', () => { | |||||
render( | |||||
<TextInput | |||||
type="text" | |||||
inputMode="search" | |||||
/>, | |||||
); | |||||
const textbox = screen.getByTestId('input'); | |||||
expect(textbox).toHaveProperty('inputMode', 'text'); | |||||
}); | |||||
describe.each` | |||||
variant | inputClassName | hintClassName | |||||
${'default'} | ${'pl-4'} | ${'bottom-0 pl-4 pb-1'} | |||||
${'alternate'} | ${'pl-1.5 pt-4'} | ${'top-0.5'} | |||||
`('on $variant style', ({ | |||||
variant, | |||||
inputClassName, | |||||
hintClassName, | |||||
}: { | |||||
variant: TextControl.Variant, | |||||
inputClassName: string, | |||||
hintClassName: string, | |||||
}) => { | |||||
it('renders input styles', () => { | |||||
render( | |||||
<TextInput | |||||
variant={variant} | |||||
/>, | |||||
); | |||||
const input = screen.getByTestId('input'); | |||||
expect(input).toHaveClass(inputClassName); | |||||
}); | |||||
it('renders hint styles', () => { | |||||
render( | |||||
<TextInput | |||||
variant={variant} | |||||
hint="hint" | |||||
/>, | |||||
); | |||||
const hint = screen.getByTestId('hint'); | |||||
expect(hint).toHaveClass(hintClassName); | |||||
}); | |||||
}); | |||||
it('handles change events', async () => { | |||||
const onChange = vi.fn().mockImplementationOnce( | |||||
(e: React.ChangeEvent<TextInputDerivedElement>) => { | |||||
e.preventDefault(); | |||||
}, | |||||
); | |||||
render( | |||||
<TextInput | |||||
onChange={onChange} | |||||
/>, | |||||
); | |||||
const textbox: HTMLInputElement = screen.getByRole('textbox'); | |||||
await userEvent.type(textbox, 'foobar'); | |||||
expect(onChange).toBeCalled(); | |||||
}); | |||||
it('handles input events', async () => { | |||||
const onInput = vi.fn().mockImplementationOnce( | |||||
(e: React.SyntheticEvent<TextInputDerivedElement>) => { | |||||
e.preventDefault(); | |||||
}, | |||||
); | |||||
render( | |||||
<TextInput | |||||
onInput={onInput} | |||||
/>, | |||||
); | |||||
const textbox: HTMLInputElement = screen.getByTestId('input'); | |||||
await userEvent.type(textbox, 'foobar'); | |||||
expect(onInput).toBeCalled(); | |||||
}); | |||||
}); |
@@ -0,0 +1,232 @@ | |||||
import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | |||||
import clsx from 'clsx'; | |||||
export type PatternTextInputDerivedElement = HTMLInputElement; | |||||
export interface PatternTextInputProps extends Omit<React.HTMLProps<PatternTextInputDerivedElement>, 'size' | 'type' | 'label' | 'list' | 'inputMode'> { | |||||
/** | |||||
* Short textual description indicating the nature of the component's value. | |||||
*/ | |||||
label?: React.ReactNode, | |||||
/** | |||||
* Short textual description as guidelines for valid input values. | |||||
*/ | |||||
hint?: React.ReactNode, | |||||
/** | |||||
* Size of the component. | |||||
*/ | |||||
size?: TextControl.Size, | |||||
/** | |||||
* Additional description, usually graphical, indicating the nature of the component's value. | |||||
*/ | |||||
indicator?: React.ReactNode, | |||||
/** | |||||
* Should the component display a border? | |||||
*/ | |||||
border?: boolean, | |||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean, | |||||
/** | |||||
* Type of the component value. | |||||
*/ | |||||
type?: TextControl.InputType, | |||||
/** | |||||
* Style of the component. | |||||
*/ | |||||
variant?: TextControl.Variant, | |||||
/** | |||||
* Is the label hidden? | |||||
*/ | |||||
hiddenLabel?: boolean, | |||||
/** | |||||
* Input mode of the component. | |||||
*/ | |||||
inputMode?: TextControl.InputMode, | |||||
} | |||||
/** | |||||
* Component for inputting textual values. | |||||
* | |||||
* This component supports multiline input and adjusts its layout accordingly. | |||||
*/ | |||||
export const PatternTextInput = React.forwardRef<PatternTextInputDerivedElement, PatternTextInputProps>(( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
type = 'text' as const, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
id: idProp, | |||||
style, | |||||
inputMode = type, | |||||
...etcProps | |||||
}, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
let resultInputMode = inputMode as React.HTMLProps<PatternTextInputDerivedElement>['inputMode']; | |||||
if (type === 'text' && resultInputMode === 'search') { | |||||
resultInputMode = 'text'; | |||||
} else if (type === 'search' && resultInputMode === 'text') { | |||||
resultInputMode = 'search'; | |||||
} | |||||
return ( | |||||
<div | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50 overflow-hidden', | |||||
'focus-within:ring-4', | |||||
{ | |||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
}, | |||||
className, | |||||
)} | |||||
style={style} | |||||
data-testid="base" | |||||
> | |||||
<input | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
aria-labelledby={labelId} | |||||
id={id} | |||||
type={type} | |||||
inputMode={resultInputMode} | |||||
data-testid="input" | |||||
className={clsx( | |||||
'bg-negative rounded-inherit w-full peer block font-inherit', | |||||
'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', | |||||
}, | |||||
{ | |||||
'h-10': size === 'small', | |||||
'h-12': size === 'medium', | |||||
'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 | |||||
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 | |||||
data-testid="indicator" | |||||
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> | |||||
); | |||||
}); | |||||
PatternTextInput.displayName = 'PatternTextInput'; | |||||
PatternTextInput.defaultProps = { | |||||
label: undefined, | |||||
hint: undefined, | |||||
size: 'medium', | |||||
indicator: undefined, | |||||
border: false, | |||||
block: false, | |||||
type: 'text', | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
inputMode: 'text', | |||||
}; |
@@ -1,3 +1,4 @@ | |||||
export * from './components/EmailInput'; | export * from './components/EmailInput'; | ||||
export * from './components/PatternTextInput'; | |||||
export * from './components/PhoneNumberInput'; | export * from './components/PhoneNumberInput'; | ||||
export * from './components/UrlInput'; | export * from './components/UrlInput'; |
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||||
export type MaskedTextInputDerivedElement = HTMLInputElement; | export type MaskedTextInputDerivedElement = HTMLInputElement; | ||||
export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'size' | 'type' | 'label'> { | |||||
export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'size' | 'type' | 'label' | 'pattern'> { | |||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
*/ | */ | ||||
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||||
export type MultilineTextInputDerivedElement = HTMLTextAreaElement; | export type MultilineTextInputDerivedElement = HTMLTextAreaElement; | ||||
export interface MultilineTextInputProps extends Omit<React.HTMLProps<MultilineTextInputDerivedElement>, 'size' | 'label' | 'inputMode'> { | |||||
export interface MultilineTextInputProps extends Omit<React.HTMLProps<MultilineTextInputDerivedElement>, 'size' | 'label' | 'inputMode' | 'pattern'> { | |||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
*/ | */ | ||||
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||||
export type TextInputDerivedElement = HTMLInputElement; | export type TextInputDerivedElement = HTMLInputElement; | ||||
export interface TextInputProps extends Omit<React.HTMLProps<TextInputDerivedElement>, 'size' | 'type' | 'label' | 'list' | 'inputMode'> { | |||||
export interface TextInputProps extends Omit<React.HTMLProps<TextInputDerivedElement>, 'size' | 'type' | 'label' | 'list' | 'inputMode' | 'pattern'> { | |||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
*/ | */ | ||||
@@ -15,8 +15,11 @@ | |||||
"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", | ||||
"@types/testing-library__jest-dom": "^5.14.7", | |||||
"@vitest/coverage-v8": "^0.33.0", | |||||
"eslint": "^8.35.0", | "eslint": "^8.35.0", | ||||
"eslint-config-lxsmnsyc": "^0.5.0", | "eslint-config-lxsmnsyc": "^0.5.0", | ||||
"jsdom": "^21.1.0", | "jsdom": "^21.1.0", | ||||
@@ -27,7 +30,7 @@ | |||||
"tslib": "^2.5.0", | "tslib": "^2.5.0", | ||||
"tsx": "^3.12.7", | "tsx": "^3.12.7", | ||||
"typescript": "^4.9.5", | "typescript": "^4.9.5", | ||||
"vitest": "^0.28.1" | |||||
"vitest": "^0.33.0" | |||||
}, | }, | ||||
"peerDependencies": { | "peerDependencies": { | ||||
"react": "^16.8 || ^17.0 || ^18.0", | "react": "^16.8 || ^17.0 || ^18.0", | ||||
@@ -76,7 +79,7 @@ | |||||
"types": "./dist/types/index.d.ts" | "types": "./dist/types/index.d.ts" | ||||
}, | }, | ||||
"./dist/Slider.css": "./dist/Slider.css", | "./dist/Slider.css": "./dist/Slider.css", | ||||
"./dist/Spinner.css": "./dist/Spinner.css" | |||||
"./dist/NumberSpinner.css": "./dist/NumberSpinner.css" | |||||
}, | }, | ||||
"typesVersions": { | "typesVersions": { | ||||
"*": {} | "*": {} | ||||
@@ -17,4 +17,4 @@ const doCopy = (src: string, dest: string) => { | |||||
} | } | ||||
doCopy('./src/components/Slider/Slider.css', './dist/Slider.css'); | doCopy('./src/components/Slider/Slider.css', './dist/Slider.css'); | ||||
doCopy('./src/components/Spinner/Spinner.css', './dist/Spinner.css'); | |||||
doCopy('./src/components/NumberSpinner/NumberSpinner.css', './dist/NumberSpinner.css'); |
@@ -1,8 +1,8 @@ | |||||
.tesseract-design-spinner { | |||||
.tesseract-design-number-spinner { | |||||
position: relative; | position: relative; | ||||
} | } | ||||
.tesseract-design-spinner::-webkit-inner-spin-button { | |||||
.tesseract-design-number-spinner::-webkit-inner-spin-button { | |||||
position: absolute; | position: absolute; | ||||
top: 0; | top: 0; | ||||
right: 0; | right: 0; |
@@ -0,0 +1,242 @@ | |||||
import * as React from 'react'; | |||||
import { | |||||
cleanup, | |||||
render, | |||||
screen, | |||||
} from '@testing-library/react'; | |||||
import userEvent from '@testing-library/user-event'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | |||||
import { | |||||
vi, | |||||
expect, | |||||
describe, | |||||
it, | |||||
afterEach, | |||||
} from 'vitest'; | |||||
import matchers from '@testing-library/jest-dom/matchers'; | |||||
import { | |||||
NumberSpinner, | |||||
NumberSpinnerDerivedElement, | |||||
} from '.'; | |||||
expect.extend(matchers); | |||||
describe('NumberSpinner', () => { | |||||
afterEach(() => { | |||||
cleanup(); | |||||
}); | |||||
it('renders a number input', () => { | |||||
render( | |||||
<NumberSpinner />, | |||||
); | |||||
const textbox = screen.getByTestId('input'); | |||||
expect(textbox).toBeInTheDocument(); | |||||
expect(textbox).toHaveProperty('type', 'number'); | |||||
}); | |||||
it('renders a border', () => { | |||||
render( | |||||
<NumberSpinner | |||||
border | |||||
/>, | |||||
); | |||||
const border = screen.getByTestId('border'); | |||||
expect(border).toBeInTheDocument(); | |||||
}); | |||||
it('renders a label', () => { | |||||
render( | |||||
<NumberSpinner | |||||
label="foo" | |||||
/>, | |||||
); | |||||
const textbox = screen.getByLabelText('foo'); | |||||
expect(textbox).toBeInTheDocument(); | |||||
const label = screen.getByTestId('label'); | |||||
expect(label).toHaveTextContent('foo'); | |||||
}); | |||||
it('renders a hidden label', () => { | |||||
render( | |||||
<NumberSpinner | |||||
label="foo" | |||||
hiddenLabel | |||||
/>, | |||||
); | |||||
const textbox = screen.getByLabelText('foo'); | |||||
expect(textbox).toBeInTheDocument(); | |||||
const label = screen.queryByTestId('label'); | |||||
expect(label).toBeInTheDocument(); | |||||
expect(label).toHaveClass('sr-only'); | |||||
}); | |||||
it('renders a hint', () => { | |||||
render( | |||||
<NumberSpinner | |||||
hint="foo" | |||||
/>, | |||||
); | |||||
const hint = screen.getByTestId('hint'); | |||||
expect(hint).toBeInTheDocument(); | |||||
}); | |||||
it('renders an indicator', () => { | |||||
render( | |||||
<NumberSpinner | |||||
indicator={ | |||||
<div /> | |||||
} | |||||
/>, | |||||
); | |||||
const indicator = screen.getByTestId('indicator'); | |||||
expect(indicator).toBeInTheDocument(); | |||||
}); | |||||
describe.each` | |||||
size | inputClassName | hintClassName | indicatorClassName | |||||
${'small'} | ${'h-10'} | ${'pr-10'} | ${'w-10'} | |||||
${'medium'} | ${'h-12'} | ${'pr-12'} | ${'w-12'} | |||||
${'large'} | ${'h-16'} | ${'pr-16'} | ${'w-16'} | |||||
`('on $size size', ({ | |||||
size, | |||||
inputClassName, | |||||
hintClassName, | |||||
indicatorClassName, | |||||
}: { | |||||
size: TextControl.Size, | |||||
inputClassName: string, | |||||
hintClassName: string, | |||||
indicatorClassName: string, | |||||
}) => { | |||||
it('renders input styles', () => { | |||||
render( | |||||
<NumberSpinner | |||||
size={size} | |||||
/>, | |||||
); | |||||
const input = screen.getByTestId('input'); | |||||
expect(input).toHaveClass(inputClassName); | |||||
}); | |||||
it('renders label styles with indicator', () => { | |||||
render( | |||||
<NumberSpinner | |||||
size={size} | |||||
label="foo" | |||||
indicator={<div />} | |||||
/>, | |||||
); | |||||
const label = screen.getByTestId('label'); | |||||
expect(label).toHaveClass(hintClassName); | |||||
}); | |||||
it('renders hint styles when indicator is present', () => { | |||||
render( | |||||
<NumberSpinner | |||||
size={size} | |||||
hint="hint" | |||||
indicator={<div />} | |||||
/>, | |||||
); | |||||
const hint = screen.getByTestId('hint'); | |||||
expect(hint).toHaveClass(hintClassName); | |||||
}); | |||||
it('renders indicator styles', () => { | |||||
render( | |||||
<NumberSpinner | |||||
size={size} | |||||
indicator={ | |||||
<div /> | |||||
} | |||||
/>, | |||||
); | |||||
const indicator = screen.getByTestId('indicator'); | |||||
expect(indicator).toHaveClass(indicatorClassName); | |||||
}); | |||||
}); | |||||
it('renders a block textbox', () => { | |||||
render( | |||||
<NumberSpinner | |||||
block | |||||
/>, | |||||
); | |||||
const base = screen.getByTestId('base'); | |||||
expect(base).toHaveClass('block'); | |||||
}); | |||||
describe.each` | |||||
variant | inputClassName | hintClassName | |||||
${'default'} | ${'pl-4'} | ${'bottom-0 pl-4 pb-1'} | |||||
${'alternate'} | ${'pl-1.5 pt-4'} | ${'top-0.5'} | |||||
`('on $variant style', ({ | |||||
variant, | |||||
inputClassName, | |||||
hintClassName, | |||||
}: { | |||||
variant: TextControl.Variant, | |||||
inputClassName: string, | |||||
hintClassName: string, | |||||
}) => { | |||||
it('renders input styles', () => { | |||||
render( | |||||
<NumberSpinner | |||||
variant={variant} | |||||
/>, | |||||
); | |||||
const input = screen.getByTestId('input'); | |||||
expect(input).toHaveClass(inputClassName); | |||||
}); | |||||
it('renders hint styles', () => { | |||||
render( | |||||
<NumberSpinner | |||||
variant={variant} | |||||
hint="hint" | |||||
/>, | |||||
); | |||||
const hint = screen.getByTestId('hint'); | |||||
expect(hint).toHaveClass(hintClassName); | |||||
}); | |||||
}); | |||||
it('handles change events', async () => { | |||||
const onChange = vi.fn().mockImplementationOnce( | |||||
(e: React.ChangeEvent<NumberSpinnerDerivedElement>) => { | |||||
e.preventDefault(); | |||||
}, | |||||
); | |||||
render( | |||||
<NumberSpinner | |||||
onChange={onChange} | |||||
/>, | |||||
); | |||||
const textbox = screen.getByTestId('input'); | |||||
await userEvent.type(textbox, '69420'); | |||||
expect(onChange).toBeCalled(); | |||||
}); | |||||
it('handles input events', async () => { | |||||
const onInput = vi.fn().mockImplementationOnce( | |||||
(e: React.SyntheticEvent<NumberSpinnerDerivedElement>) => { | |||||
e.preventDefault(); | |||||
}, | |||||
); | |||||
render( | |||||
<NumberSpinner | |||||
onInput={onInput} | |||||
/>, | |||||
); | |||||
const textbox = screen.getByTestId('input'); | |||||
await userEvent.type(textbox, '69420'); | |||||
expect(onInput).toBeCalled(); | |||||
}); | |||||
}); |
@@ -2,9 +2,9 @@ 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'; | ||||
export type SpinnerDerivedElement = HTMLInputElement; | |||||
export type NumberSpinnerDerivedElement = HTMLInputElement; | |||||
export interface SpinnerProps extends Omit<React.HTMLProps<SpinnerDerivedElement>, 'size' | 'type' | 'label'> { | |||||
export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDerivedElement>, 'size' | 'type' | 'label'> { | |||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
*/ | */ | ||||
@@ -42,7 +42,7 @@ export interface SpinnerProps extends Omit<React.HTMLProps<SpinnerDerivedElement | |||||
/** | /** | ||||
* Component for inputting numeric values. | * Component for inputting numeric values. | ||||
*/ | */ | ||||
export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||||
export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, NumberSpinnerProps>(( | |||||
{ | { | ||||
label, | label, | ||||
hint, | hint, | ||||
@@ -54,8 +54,9 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||||
hiddenLabel = false, | hiddenLabel = false, | ||||
className, | className, | ||||
id: idProp, | id: idProp, | ||||
style, | |||||
...etcProps | ...etcProps | ||||
}: SpinnerProps, | |||||
}: NumberSpinnerProps, | |||||
forwardedRef, | forwardedRef, | ||||
) => { | ) => { | ||||
const labelId = React.useId(); | const labelId = React.useId(); | ||||
@@ -73,6 +74,8 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||||
}, | }, | ||||
className, | className, | ||||
)} | )} | ||||
style={style} | |||||
data-testid="base" | |||||
> | > | ||||
<input | <input | ||||
{...etcProps} | {...etcProps} | ||||
@@ -85,7 +88,7 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||||
'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit', | 'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit', | ||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | 'disabled:opacity-50 disabled:cursor-not-allowed', | ||||
'tesseract-design-spinner', | |||||
'tesseract-design-number-spinner', | |||||
{ | { | ||||
'text-xxs': size === 'small', | 'text-xxs': size === 'small', | ||||
'text-xs': size === 'medium', | 'text-xs': size === 'medium', | ||||
@@ -171,6 +174,7 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||||
)} | )} | ||||
{indicator && ( | {indicator && ( | ||||
<div | <div | ||||
data-testid="indicator" | |||||
className={clsx( | 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', | 'text-center flex items-center justify-center peer-disabled:opacity-50 aspect-square absolute bottom-0 right-0 pointer-events-none select-none', | ||||
{ | { | ||||
@@ -193,9 +197,9 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||||
); | ); | ||||
}); | }); | ||||
Spinner.displayName = 'Spinner'; | |||||
NumberSpinner.displayName = 'NumberSpinner'; | |||||
Spinner.defaultProps = { | |||||
NumberSpinner.defaultProps = { | |||||
label: undefined, | label: undefined, | ||||
hint: undefined, | hint: undefined, | ||||
indicator: undefined, | indicator: undefined, |
@@ -1,2 +1,2 @@ | |||||
export * from './components/Slider'; | export * from './components/Slider'; | ||||
export * from './components/Spinner'; | |||||
export * from './components/NumberSpinner'; |
@@ -0,0 +1,11 @@ | |||||
import { describe, it, expect } from 'vitest'; | |||||
import * as WebNumberReact from '.'; | |||||
describe('web-number-react', () => { | |||||
it.each([ | |||||
'NumberSpinner', | |||||
'Slider', | |||||
])('exports %s', (namedExport) => { | |||||
expect(WebNumberReact).toHaveProperty(namedExport); | |||||
}); | |||||
}); |
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||||
export type DateDropdownDerivedElement = HTMLInputElement; | export type DateDropdownDerivedElement = HTMLInputElement; | ||||
export interface DateDropdownProps extends Omit<React.HTMLProps<DateDropdownDerivedElement>, 'size' | 'type' | 'label'> { | |||||
export interface DateDropdownProps extends Omit<React.HTMLProps<DateDropdownDerivedElement>, 'size' | 'type' | 'label' | 'pattern'> { | |||||
/** | /** | ||||
* Short textual description indicating the nature of the component's value. | * Short textual description indicating the nature of the component's value. | ||||
*/ | */ | ||||
@@ -58,8 +58,6 @@ export const DateDropdown = React.forwardRef< | |||||
className, | className, | ||||
id: idProp, | id: idProp, | ||||
style, | style, | ||||
onFocus, | |||||
onClick, | |||||
...etcProps | ...etcProps | ||||
}: DateDropdownProps, | }: DateDropdownProps, | ||||
forwardedRef, | forwardedRef, | ||||
@@ -68,16 +66,6 @@ export const DateDropdown = React.forwardRef< | |||||
const defaultId = React.useId(); | const defaultId = React.useId(); | ||||
const id = idProp ?? defaultId; | const id = idProp ?? defaultId; | ||||
const handleFocus: React.FocusEventHandler<DateDropdownDerivedElement> = (e) => { | |||||
e.currentTarget.showPicker(); | |||||
onFocus?.(e); | |||||
}; | |||||
const handleClick: React.MouseEventHandler<DateDropdownDerivedElement> = (e) => { | |||||
e.currentTarget.showPicker(); | |||||
onClick?.(e); | |||||
}; | |||||
return ( | return ( | ||||
<div | <div | ||||
className={clsx( | className={clsx( | ||||
@@ -90,6 +78,7 @@ export const DateDropdown = React.forwardRef< | |||||
className, | className, | ||||
)} | )} | ||||
style={style} | style={style} | ||||
data-testid="base" | |||||
> | > | ||||
<input | <input | ||||
{...etcProps} | {...etcProps} | ||||
@@ -98,10 +87,9 @@ export const DateDropdown = React.forwardRef< | |||||
aria-labelledby={labelId} | aria-labelledby={labelId} | ||||
type="date" | type="date" | ||||
data-testid="input" | data-testid="input" | ||||
onFocus={handleFocus} | |||||
onClick={handleClick} | |||||
pattern="\d{4}-\d{2}-\d{2}" | |||||
className={clsx( | className={clsx( | ||||
'bg-negative rounded-inherit w-full peer block font-inherit', | |||||
'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums', | |||||
'focus:outline-0', | 'focus:outline-0', | ||||
'disabled:opacity-50 disabled:cursor-not-allowed', | 'disabled:opacity-50 disabled:cursor-not-allowed', | ||||
{ | { | ||||
@@ -189,6 +177,7 @@ export const DateDropdown = React.forwardRef< | |||||
)} | )} | ||||
{indicator && ( | {indicator && ( | ||||
<div | <div | ||||
data-testid="indicator" | |||||
className={clsx( | 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', | 'text-center flex items-center justify-center peer-disabled:opacity-50 aspect-square absolute bottom-0 right-0 pointer-events-none select-none', | ||||
{ | { | ||||
@@ -34,3 +34,5 @@ | |||||
elements may have different markup and appearance. Tesseract | elements may have different markup and appearance. Tesseract | ||||
ensures components are still usable on most assistive technologies given | ensures components are still usable on most assistive technologies given | ||||
both versions of the user interface. | both versions of the user interface. | ||||
- All components are named in order to depict the purpose, the nature of data | |||||
being operated, and UX expectations. |
@@ -579,12 +579,21 @@ 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 | ||||
'@types/react': | '@types/react': | ||||
specifier: ^18.0.27 | specifier: ^18.0.27 | ||||
version: 18.2.14 | version: 18.2.14 | ||||
'@types/testing-library__jest-dom': | |||||
specifier: ^5.14.7 | |||||
version: 5.14.7 | |||||
'@vitest/coverage-v8': | |||||
specifier: ^0.33.0 | |||||
version: 0.33.0(vitest@0.33.0) | |||||
eslint: | eslint: | ||||
specifier: ^8.35.0 | specifier: ^8.35.0 | ||||
version: 8.43.0 | version: 8.43.0 | ||||
@@ -616,8 +625,8 @@ importers: | |||||
specifier: ^4.9.5 | specifier: ^4.9.5 | ||||
version: 4.9.5 | version: 4.9.5 | ||||
vitest: | vitest: | ||||
specifier: ^0.28.1 | |||||
version: 0.28.1(jsdom@21.1.0) | |||||
specifier: ^0.33.0 | |||||
version: 0.33.0(jsdom@21.1.0) | |||||
categories/temporal/react: | categories/temporal/react: | ||||
dependencies: | dependencies: | ||||
@@ -0,0 +1,237 @@ | |||||
import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | |||||
import clsx from 'clsx'; | |||||
export type TimeSpinnerDerivedElement = HTMLInputElement; | |||||
type Digit = (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9); | |||||
type Segment = `${Digit}${Digit}`; | |||||
type StepHhMm = `${Segment}:${Segment}`; | |||||
type StepHhMmSs = `${StepHhMm}:${Segment}`; | |||||
type Step = StepHhMm | StepHhMmSs; | |||||
export interface TimeSpinnerProps extends Omit<React.HTMLProps<TimeSpinnerDerivedElement>, 'size' | 'type' | 'label' | 'step' | 'pattern'> { | |||||
/** | |||||
* Short textual description indicating the nature of the component's value. | |||||
*/ | |||||
label?: React.ReactNode, | |||||
/** | |||||
* Short textual description as guidelines for valid input values. | |||||
*/ | |||||
hint?: React.ReactNode, | |||||
/** | |||||
* Size of the component. | |||||
*/ | |||||
size?: TextControl.Size, | |||||
/** | |||||
* Additional description, usually graphical, indicating the nature of the component's value. | |||||
*/ | |||||
indicator?: React.ReactNode, | |||||
/** | |||||
* Should the component display a border? | |||||
*/ | |||||
border?: boolean, | |||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean, | |||||
/** | |||||
* Style of the component. | |||||
*/ | |||||
variant?: TextControl.Variant, | |||||
/** | |||||
* Is the label hidden? | |||||
*/ | |||||
hiddenLabel?: boolean, | |||||
/** | |||||
* Should the component display seconds? | |||||
*/ | |||||
displaySeconds?: boolean, | |||||
/** | |||||
* Step size for the component. | |||||
*/ | |||||
step?: Step, | |||||
} | |||||
/** | |||||
* Component for inputting textual values. | |||||
*/ | |||||
export const TimeSpinner = React.forwardRef< | |||||
TimeSpinnerDerivedElement, | |||||
TimeSpinnerProps | |||||
>(( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
id: idProp, | |||||
style, | |||||
displaySeconds = false, | |||||
step = '00:01', | |||||
...etcProps | |||||
}: TimeSpinnerProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
const [hh, mm, ss = 0] = step.split(':').map((s: string) => parseInt(s, 10)); | |||||
const stepValue = ss + (mm * 60) + (hh * 3600); | |||||
return ( | |||||
<div | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50 overflow-hidden', | |||||
'focus-within:ring-4', | |||||
{ | |||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
}, | |||||
className, | |||||
)} | |||||
style={style} | |||||
data-testid="base" | |||||
> | |||||
<input | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
type="time" | |||||
data-testid="input" | |||||
step={displaySeconds && stepValue > 60 ? 1 : stepValue} | |||||
pattern="\d{2}:\d{2}(:\d{2})?" | |||||
className={clsx( | |||||
'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums', | |||||
'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', | |||||
}, | |||||
{ | |||||
'h-10': size === 'small', | |||||
'h-12': size === 'medium', | |||||
'h-16': size === 'large', | |||||
}, | |||||
)} | |||||
/> | |||||
{label && ( | |||||
<label | |||||
data-testid="label" | |||||
id={labelId} | |||||
htmlFor={id} | |||||
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 | |||||
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> | |||||
); | |||||
}); | |||||
TimeSpinner.displayName = 'TimeSpinner'; | |||||
TimeSpinner.defaultProps = { | |||||
label: undefined, | |||||
hint: undefined, | |||||
size: 'medium', | |||||
indicator: undefined, | |||||
border: false, | |||||
block: false, | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
}; |
@@ -0,0 +1,215 @@ | |||||
import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | |||||
import clsx from 'clsx'; | |||||
export type YearMonthInputDerivedElement = HTMLInputElement; | |||||
export interface YearMonthInputProps extends Omit<React.HTMLProps<YearMonthInputDerivedElement>, 'size' | 'type' | 'label' | 'pattern'> { | |||||
/** | |||||
* Short textual description indicating the nature of the component's value. | |||||
*/ | |||||
label?: React.ReactNode, | |||||
/** | |||||
* Short textual description as guidelines for valid input values. | |||||
*/ | |||||
hint?: React.ReactNode, | |||||
/** | |||||
* Size of the component. | |||||
*/ | |||||
size?: TextControl.Size, | |||||
/** | |||||
* Additional description, usually graphical, indicating the nature of the component's value. | |||||
*/ | |||||
indicator?: React.ReactNode, | |||||
/** | |||||
* Should the component display a border? | |||||
*/ | |||||
border?: boolean, | |||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean, | |||||
/** | |||||
* Style of the component. | |||||
*/ | |||||
variant?: TextControl.Variant, | |||||
/** | |||||
* Is the label hidden? | |||||
*/ | |||||
hiddenLabel?: boolean, | |||||
} | |||||
/** | |||||
* Component for inputting textual values. | |||||
*/ | |||||
export const YearMonthInput = React.forwardRef< | |||||
YearMonthInputDerivedElement, | |||||
YearMonthInputProps | |||||
>(( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
id: idProp, | |||||
style, | |||||
...etcProps | |||||
}: YearMonthInputProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
// TODO render enhanced version | |||||
return ( | |||||
<div | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50 overflow-hidden', | |||||
'focus-within:ring-4', | |||||
{ | |||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
}, | |||||
className, | |||||
)} | |||||
style={style} | |||||
data-testid="base" | |||||
> | |||||
<input | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
type="month" | |||||
data-testid="input" | |||||
pattern="\d{4}-\d{2}" | |||||
className={clsx( | |||||
'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums', | |||||
'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', | |||||
}, | |||||
{ | |||||
'h-10': size === 'small', | |||||
'h-12': size === 'medium', | |||||
'h-16': size === 'large', | |||||
}, | |||||
)} | |||||
/> | |||||
{label && ( | |||||
<label | |||||
data-testid="label" | |||||
id={labelId} | |||||
htmlFor={id} | |||||
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 | |||||
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> | |||||
); | |||||
}); | |||||
YearMonthInput.displayName = 'YearMonthInput'; | |||||
YearMonthInput.defaultProps = { | |||||
label: undefined, | |||||
hint: undefined, | |||||
size: 'medium', | |||||
indicator: undefined, | |||||
border: false, | |||||
block: false, | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
}; |
@@ -0,0 +1,215 @@ | |||||
import * as React from 'react'; | |||||
import { TextControl } from '@tesseract-design/web-base'; | |||||
import clsx from 'clsx'; | |||||
export type YearWeekInputDerivedElement = HTMLInputElement; | |||||
export interface YearWeekInputProps extends Omit<React.HTMLProps<YearWeekInputDerivedElement>, 'size' | 'type' | 'label' | 'pattern'> { | |||||
/** | |||||
* Short textual description indicating the nature of the component's value. | |||||
*/ | |||||
label?: React.ReactNode, | |||||
/** | |||||
* Short textual description as guidelines for valid input values. | |||||
*/ | |||||
hint?: React.ReactNode, | |||||
/** | |||||
* Size of the component. | |||||
*/ | |||||
size?: TextControl.Size, | |||||
/** | |||||
* Additional description, usually graphical, indicating the nature of the component's value. | |||||
*/ | |||||
indicator?: React.ReactNode, | |||||
/** | |||||
* Should the component display a border? | |||||
*/ | |||||
border?: boolean, | |||||
/** | |||||
* Should the component occupy the whole width of its parent? | |||||
*/ | |||||
block?: boolean, | |||||
/** | |||||
* Style of the component. | |||||
*/ | |||||
variant?: TextControl.Variant, | |||||
/** | |||||
* Is the label hidden? | |||||
*/ | |||||
hiddenLabel?: boolean, | |||||
} | |||||
/** | |||||
* Component for inputting textual values. | |||||
*/ | |||||
export const YearWeekInput = React.forwardRef< | |||||
YearWeekInputDerivedElement, | |||||
YearWeekInputProps | |||||
>(( | |||||
{ | |||||
label, | |||||
hint, | |||||
indicator, | |||||
size = 'medium' as const, | |||||
border = false, | |||||
block = false, | |||||
variant = 'default' as const, | |||||
hiddenLabel = false, | |||||
className, | |||||
id: idProp, | |||||
style, | |||||
...etcProps | |||||
}: YearWeekInputProps, | |||||
forwardedRef, | |||||
) => { | |||||
const labelId = React.useId(); | |||||
const defaultId = React.useId(); | |||||
const id = idProp ?? defaultId; | |||||
// TODO render enhanced version | |||||
return ( | |||||
<div | |||||
className={clsx( | |||||
'relative rounded ring-secondary/50 overflow-hidden', | |||||
'focus-within:ring-4', | |||||
{ | |||||
'block': block, | |||||
'inline-block align-middle': !block, | |||||
}, | |||||
className, | |||||
)} | |||||
style={style} | |||||
data-testid="base" | |||||
> | |||||
<input | |||||
{...etcProps} | |||||
ref={forwardedRef} | |||||
id={id} | |||||
aria-labelledby={labelId} | |||||
type="week" | |||||
data-testid="input" | |||||
pattern="\d{4}-W\d{,2}" | |||||
className={clsx( | |||||
'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums', | |||||
'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', | |||||
}, | |||||
{ | |||||
'h-10': size === 'small', | |||||
'h-12': size === 'medium', | |||||
'h-16': size === 'large', | |||||
}, | |||||
)} | |||||
/> | |||||
{label && ( | |||||
<label | |||||
data-testid="label" | |||||
id={labelId} | |||||
htmlFor={id} | |||||
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 | |||||
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> | |||||
); | |||||
}); | |||||
YearWeekInput.displayName = 'YearWeekInput'; | |||||
YearWeekInput.defaultProps = { | |||||
label: undefined, | |||||
hint: undefined, | |||||
size: 'medium', | |||||
indicator: undefined, | |||||
border: false, | |||||
block: false, | |||||
variant: 'default', | |||||
hiddenLabel: false, | |||||
}; |
@@ -0,0 +1,3 @@ | |||||
export * from './TimeSpinner'; | |||||
export * from './YearMonthInput'; | |||||
export * from './YearWeekInput'; |
@@ -12,8 +12,8 @@ import '@tesseract-design/web-multichoice-react/dist/ToggleButton.css' | |||||
import '@tesseract-design/web-multichoice-react/dist/ToggleSwitch.css' | import '@tesseract-design/web-multichoice-react/dist/ToggleSwitch.css' | ||||
import '@tesseract-design/web-multichoice-react/dist/ToggleTickBox.css' | import '@tesseract-design/web-multichoice-react/dist/ToggleTickBox.css' | ||||
import '@tesseract-design/web-number-react/dist/NumberSpinner.css' | |||||
import '@tesseract-design/web-number-react/dist/Slider.css' | import '@tesseract-design/web-number-react/dist/Slider.css' | ||||
import '@tesseract-design/web-number-react/dist/Spinner.css' | |||||
import '@modal-sh/react-refractor/dist/Refractor.css' | import '@modal-sh/react-refractor/dist/Refractor.css' | ||||
@@ -12,6 +12,7 @@ const TemporalPage: NextPage = () => { | |||||
label="Phone" | label="Phone" | ||||
name="phone" | name="phone" | ||||
enhanced | enhanced | ||||
border | |||||
onFocus={(e) => { console.log('focus', e.currentTarget)}} | onFocus={(e) => { console.log('focus', e.currentTarget)}} | ||||
onBlur={(e) => { console.log('blur', e.currentTarget)}} | onBlur={(e) => { console.log('blur', e.currentTarget)}} | ||||
onChange={(e) => { console.log('change', e.currentTarget.name, e.currentTarget, e.currentTarget.value)}} | onChange={(e) => { console.log('change', e.currentTarget.name, e.currentTarget, e.currentTarget.value)}} | ||||
@@ -6,9 +6,9 @@ import {Section, Subsection} from '@/components/Section'; | |||||
const NumberPage: NextPage = () => { | const NumberPage: NextPage = () => { | ||||
return ( | return ( | ||||
<main className="my-16 md:my-32"> | <main className="my-16 md:my-32"> | ||||
<Section title="Spinner"> | |||||
<Section title="NumberSpinner"> | |||||
<Subsection title="Default"> | <Subsection title="Default"> | ||||
<TesseractNumber.Spinner | |||||
<TesseractNumber.NumberSpinner | |||||
min={-100} | min={-100} | ||||
max={100} | max={100} | ||||
step="any" | step="any" | ||||
@@ -46,6 +46,27 @@ const NumberPage: NextPage = () => { | |||||
</TesseractNumber.Slider> | </TesseractNumber.Slider> | ||||
</Subsection> | </Subsection> | ||||
<Subsection title="Vertical"> | <Subsection title="Vertical"> | ||||
<TesseractNumber.Slider | |||||
min={-100} | |||||
max={100} | |||||
orient="vertical" | |||||
> | |||||
<optgroup label="Test Values"> | |||||
<option value={-100}> | |||||
Lowest | |||||
</option> | |||||
<option value={25}> | |||||
日本語 | |||||
</option> | |||||
<option value={50} /> | |||||
<option value={100}> | |||||
Highest | |||||
</option> | |||||
<option value={200}> | |||||
Out of bounds | |||||
</option> | |||||
</optgroup> | |||||
</TesseractNumber.Slider> | |||||
<TesseractNumber.Slider | <TesseractNumber.Slider | ||||
min={-100} | min={-100} | ||||
max={100} | max={100} | ||||
@@ -2,6 +2,7 @@ import {NextPage} from 'next'; | |||||
import {DefaultLayout} from '@/components/DefaultLayout'; | import {DefaultLayout} from '@/components/DefaultLayout'; | ||||
import {Section, Subsection} from '@/components/Section'; | import {Section, Subsection} from '@/components/Section'; | ||||
import * as Temporal from '@tesseract-design/web-temporal-react'; | import * as Temporal from '@tesseract-design/web-temporal-react'; | ||||
import * as TemporalWip from '@/components/temporal'; | |||||
const TemporalPage: NextPage = () => { | const TemporalPage: NextPage = () => { | ||||
return ( | return ( | ||||
@@ -10,6 +11,51 @@ const TemporalPage: NextPage = () => { | |||||
<Subsection title="Default"> | <Subsection title="Default"> | ||||
<Temporal.DateDropdown | <Temporal.DateDropdown | ||||
label="Birthday" | label="Birthday" | ||||
border | |||||
/> | |||||
</Subsection> | |||||
</Section> | |||||
<Section title="TimeSpinner"> | |||||
<Subsection title="Default"> | |||||
<TemporalWip.TimeSpinner | |||||
label="Time" | |||||
variant="default" | |||||
border | |||||
/> | |||||
</Subsection> | |||||
<Subsection title="Step"> | |||||
<TemporalWip.TimeSpinner | |||||
label="Time" | |||||
variant="default" | |||||
border | |||||
step="00:15:00" | |||||
/> | |||||
</Subsection> | |||||
<Subsection title="Step + Display Seconds"> | |||||
<TemporalWip.TimeSpinner | |||||
label="Time" | |||||
variant="default" | |||||
border | |||||
step="00:00:05" | |||||
displaySeconds | |||||
/> | |||||
</Subsection> | |||||
</Section> | |||||
<Section title="YearMonthInput"> | |||||
<Subsection title="Default"> | |||||
<TemporalWip.YearMonthInput | |||||
label="Month" | |||||
variant="default" | |||||
border | |||||
/> | |||||
</Subsection> | |||||
</Section> | |||||
<Section title="YearWeekInput"> | |||||
<Subsection title="Default"> | |||||
<TemporalWip.YearWeekInput | |||||
label="Vacation" | |||||
variant="default" | |||||
border | |||||
/> | /> | ||||
</Subsection> | </Subsection> | ||||
</Section> | </Section> | ||||
@@ -6,7 +6,7 @@ module.exports = { | |||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', | ||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}', | './src/components/**/*.{js,ts,jsx,tsx,mdx}', | ||||
'./src/categories/**/*.{js,ts,jsx,tsx,mdx}', | './src/categories/**/*.{js,ts,jsx,tsx,mdx}', | ||||
'./node_modules/@tesseract-design/**/*.{js,ts,jsx,tsx,mdx}', | |||||
'./node_modules/@tesseract-design/web-*-react/dist/**/*.js', | |||||
], | ], | ||||
theme: { | theme: { | ||||
fontFamily: { | fontFamily: { | ||||