Implement components with native HTML counterparts except datetime-localmaster
@@ -59,9 +59,9 @@ | |||
- [ ] DurationInput | |||
- [ ] MonthInput | |||
- [ ] MonthDayInput | |||
- [ ] TimeSpinner | |||
- [ ] YearMonthInput | |||
- [ ] YearWeekInput | |||
- [X] TimeSpinner | |||
- [-] YearMonthInput | |||
- [-] YearWeekInput | |||
- [ ] YearInput | |||
# 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/PatternTextInput'; | |||
export * from './components/PhoneNumberInput'; | |||
export * from './components/UrlInput'; |
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||
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. | |||
*/ | |||
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||
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. | |||
*/ | |||
@@ -4,7 +4,7 @@ import clsx from 'clsx'; | |||
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. | |||
*/ | |||
@@ -15,8 +15,11 @@ | |||
"devDependencies": { | |||
"@testing-library/jest-dom": "^5.16.5", | |||
"@testing-library/react": "^13.4.0", | |||
"@testing-library/user-event": "^14.4.3", | |||
"@types/node": "^18.14.1", | |||
"@types/react": "^18.0.27", | |||
"@types/testing-library__jest-dom": "^5.14.7", | |||
"@vitest/coverage-v8": "^0.33.0", | |||
"eslint": "^8.35.0", | |||
"eslint-config-lxsmnsyc": "^0.5.0", | |||
"jsdom": "^21.1.0", | |||
@@ -27,7 +30,7 @@ | |||
"tslib": "^2.5.0", | |||
"tsx": "^3.12.7", | |||
"typescript": "^4.9.5", | |||
"vitest": "^0.28.1" | |||
"vitest": "^0.33.0" | |||
}, | |||
"peerDependencies": { | |||
"react": "^16.8 || ^17.0 || ^18.0", | |||
@@ -76,7 +79,7 @@ | |||
"types": "./dist/types/index.d.ts" | |||
}, | |||
"./dist/Slider.css": "./dist/Slider.css", | |||
"./dist/Spinner.css": "./dist/Spinner.css" | |||
"./dist/NumberSpinner.css": "./dist/NumberSpinner.css" | |||
}, | |||
"typesVersions": { | |||
"*": {} | |||
@@ -17,4 +17,4 @@ const doCopy = (src: string, dest: string) => { | |||
} | |||
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; | |||
} | |||
.tesseract-design-spinner::-webkit-inner-spin-button { | |||
.tesseract-design-number-spinner::-webkit-inner-spin-button { | |||
position: absolute; | |||
top: 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 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. | |||
*/ | |||
@@ -42,7 +42,7 @@ export interface SpinnerProps extends Omit<React.HTMLProps<SpinnerDerivedElement | |||
/** | |||
* Component for inputting numeric values. | |||
*/ | |||
export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||
export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, NumberSpinnerProps>(( | |||
{ | |||
label, | |||
hint, | |||
@@ -54,8 +54,9 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||
hiddenLabel = false, | |||
className, | |||
id: idProp, | |||
style, | |||
...etcProps | |||
}: SpinnerProps, | |||
}: NumberSpinnerProps, | |||
forwardedRef, | |||
) => { | |||
const labelId = React.useId(); | |||
@@ -73,6 +74,8 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||
}, | |||
className, | |||
)} | |||
style={style} | |||
data-testid="base" | |||
> | |||
<input | |||
{...etcProps} | |||
@@ -85,7 +88,7 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||
'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit', | |||
'focus:outline-0', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
'tesseract-design-spinner', | |||
'tesseract-design-number-spinner', | |||
{ | |||
'text-xxs': size === 'small', | |||
'text-xs': size === 'medium', | |||
@@ -171,6 +174,7 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||
)} | |||
{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', | |||
{ | |||
@@ -193,9 +197,9 @@ export const Spinner = React.forwardRef<SpinnerDerivedElement, SpinnerProps>(( | |||
); | |||
}); | |||
Spinner.displayName = 'Spinner'; | |||
NumberSpinner.displayName = 'NumberSpinner'; | |||
Spinner.defaultProps = { | |||
NumberSpinner.defaultProps = { | |||
label: undefined, | |||
hint: undefined, | |||
indicator: undefined, |
@@ -1,2 +1,2 @@ | |||
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 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. | |||
*/ | |||
@@ -58,8 +58,6 @@ export const DateDropdown = React.forwardRef< | |||
className, | |||
id: idProp, | |||
style, | |||
onFocus, | |||
onClick, | |||
...etcProps | |||
}: DateDropdownProps, | |||
forwardedRef, | |||
@@ -68,16 +66,6 @@ export const DateDropdown = React.forwardRef< | |||
const defaultId = React.useId(); | |||
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 ( | |||
<div | |||
className={clsx( | |||
@@ -90,6 +78,7 @@ export const DateDropdown = React.forwardRef< | |||
className, | |||
)} | |||
style={style} | |||
data-testid="base" | |||
> | |||
<input | |||
{...etcProps} | |||
@@ -98,10 +87,9 @@ export const DateDropdown = React.forwardRef< | |||
aria-labelledby={labelId} | |||
type="date" | |||
data-testid="input" | |||
onFocus={handleFocus} | |||
onClick={handleClick} | |||
pattern="\d{4}-\d{2}-\d{2}" | |||
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', | |||
'disabled:opacity-50 disabled:cursor-not-allowed', | |||
{ | |||
@@ -189,6 +177,7 @@ export const DateDropdown = React.forwardRef< | |||
)} | |||
{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', | |||
{ | |||
@@ -34,3 +34,5 @@ | |||
elements may have different markup and appearance. Tesseract | |||
ensures components are still usable on most assistive technologies given | |||
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': | |||
specifier: ^13.4.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': | |||
specifier: ^18.14.1 | |||
version: 18.14.1 | |||
'@types/react': | |||
specifier: ^18.0.27 | |||
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: | |||
specifier: ^8.35.0 | |||
version: 8.43.0 | |||
@@ -616,8 +625,8 @@ importers: | |||
specifier: ^4.9.5 | |||
version: 4.9.5 | |||
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: | |||
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/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/Spinner.css' | |||
import '@modal-sh/react-refractor/dist/Refractor.css' | |||
@@ -12,6 +12,7 @@ const TemporalPage: NextPage = () => { | |||
label="Phone" | |||
name="phone" | |||
enhanced | |||
border | |||
onFocus={(e) => { console.log('focus', e.currentTarget)}} | |||
onBlur={(e) => { console.log('blur', e.currentTarget)}} | |||
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 = () => { | |||
return ( | |||
<main className="my-16 md:my-32"> | |||
<Section title="Spinner"> | |||
<Section title="NumberSpinner"> | |||
<Subsection title="Default"> | |||
<TesseractNumber.Spinner | |||
<TesseractNumber.NumberSpinner | |||
min={-100} | |||
max={100} | |||
step="any" | |||
@@ -46,6 +46,27 @@ const NumberPage: NextPage = () => { | |||
</TesseractNumber.Slider> | |||
</Subsection> | |||
<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 | |||
min={-100} | |||
max={100} | |||
@@ -2,6 +2,7 @@ import {NextPage} from 'next'; | |||
import {DefaultLayout} from '@/components/DefaultLayout'; | |||
import {Section, Subsection} from '@/components/Section'; | |||
import * as Temporal from '@tesseract-design/web-temporal-react'; | |||
import * as TemporalWip from '@/components/temporal'; | |||
const TemporalPage: NextPage = () => { | |||
return ( | |||
@@ -10,6 +11,51 @@ const TemporalPage: NextPage = () => { | |||
<Subsection title="Default"> | |||
<Temporal.DateDropdown | |||
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> | |||
</Section> | |||
@@ -6,7 +6,7 @@ module.exports = { | |||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}', | |||
'./src/components/**/*.{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: { | |||
fontFamily: { | |||