Add TSDoc to component props. Spinners now display activated up/down buttons as well as maskedtextinput toggle buttons.master
@@ -31,6 +31,9 @@ export interface FileSelectBoxProps< | |||
* Short textual description as guidelines for valid input values. | |||
*/ | |||
hint?: React.ReactNode, | |||
/** | |||
* Should the component be enhanced? | |||
*/ | |||
enhanced?: boolean, | |||
/** | |||
* Is the label hidden? | |||
@@ -18,6 +18,9 @@ export const colorPickerPlugin = plugin(({ addComponents }) => { | |||
'&::-webkit-color-swatch': { | |||
'border': '2px solid black', | |||
}, | |||
'&::-moz-color-swatch': { | |||
'border': '2px solid black', | |||
}, | |||
}, | |||
}); | |||
}); | |||
@@ -63,6 +66,7 @@ export const ColorPicker = React.forwardRef< | |||
}, | |||
)} | |||
> | |||
{/* todo add chevron down to picker */} | |||
<input | |||
{...etcProps} | |||
className={clsx( | |||
@@ -3,9 +3,15 @@ import { TextControl } from '@tesseract-design/web-base'; | |||
import clsx from 'clsx'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link EmailInput} component. | |||
*/ | |||
export type EmailInputDerivedElement = HTMLInputElement; | |||
export interface EmailInputProps extends Omit<React.HTMLProps<EmailInputDerivedElement>, 'size' | 'type' | 'label' | 'inputMode' | 'pattern'> { | |||
/** | |||
* Props of the {@link EmailInput} component. | |||
*/ | |||
export interface EmailInputProps extends Omit<React.HTMLProps<EmailInputDerivedElement>, 'size' | 'type' | 'label' | 'inputMode' | 'pattern' | 'autoComplete'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
*/ | |||
@@ -42,6 +48,10 @@ export interface EmailInputProps extends Omit<React.HTMLProps<EmailInputDerivedE | |||
* Visual length of the input. | |||
*/ | |||
length?: number, | |||
/** | |||
* Should the component display a list of suggested values? | |||
*/ | |||
autoComplete?: boolean, | |||
} | |||
/** | |||
@@ -53,15 +63,16 @@ export const EmailInput = React.forwardRef<EmailInputDerivedElement, EmailInputP | |||
label, | |||
hint, | |||
size = 'medium' as const, | |||
border = false, | |||
block = false, | |||
border = false as const, | |||
block = false as const, | |||
variant = 'default' as const, | |||
hiddenLabel = false, | |||
hiddenLabel = false as const, | |||
className, | |||
id: idProp, | |||
style, | |||
domains = [], | |||
length, | |||
autoComplete = false as const, | |||
...etcProps | |||
}: EmailInputProps, | |||
forwardedRef, | |||
@@ -121,6 +132,7 @@ export const EmailInput = React.forwardRef<EmailInputDerivedElement, EmailInputP | |||
aria-labelledby={labelId} | |||
type="email" | |||
data-testid="input" | |||
autoComplete={autoComplete ? 'email' : undefined} | |||
pattern={pattern} | |||
className={clsx( | |||
'bg-negative rounded-inherit w-full peer block font-inherit tabular-nums', | |||
@@ -200,11 +212,12 @@ EmailInput.displayName = 'EmailInput'; | |||
EmailInput.defaultProps = { | |||
label: undefined, | |||
hint: undefined, | |||
size: 'medium', | |||
border: false, | |||
block: false, | |||
variant: 'default', | |||
hiddenLabel: false, | |||
size: 'medium' as const, | |||
border: false as const, | |||
block: false as const, | |||
variant: 'default' as const, | |||
hiddenLabel: false as const, | |||
domains: [], | |||
length: undefined, | |||
autoComplete: false as const, | |||
}; |
@@ -3,8 +3,14 @@ import { TextControl } from '@tesseract-design/web-base'; | |||
import clsx from 'clsx'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link PatternTextInput} component. | |||
*/ | |||
export type PatternTextInputDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link PatternTextInput} component. | |||
*/ | |||
export interface PatternTextInputProps extends Omit<React.HTMLProps<PatternTextInputDerivedElement>, 'size' | 'type' | 'label' | 'list' | 'inputMode'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -4,9 +4,15 @@ import { useClientSide, useFallbackId, useProxyInput } from '@modal-sh/react-uti | |||
import PhoneInput, { Country, Value } from 'react-phone-number-input/input'; | |||
import clsx from 'clsx'; | |||
/** | |||
* Derived HTML element of the {@link PhoneNumberInput} component. | |||
*/ | |||
export type PhoneNumberInputDerivedElement = HTMLInputElement; | |||
export interface PhoneNumberInputProps extends Omit<React.HTMLProps<PhoneNumberInputDerivedElement>, 'size' | 'type' | 'label' | 'inputMode'> { | |||
/** | |||
* Props of the {@link PhoneNumberInput} component. | |||
*/ | |||
export interface PhoneNumberInputProps extends Omit<React.HTMLProps<PhoneNumberInputDerivedElement>, 'autoComplete' | 'size' | 'type' | 'label' | 'inputMode'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
*/ | |||
@@ -3,8 +3,14 @@ import { TextControl } from '@tesseract-design/web-base'; | |||
import clsx from 'clsx'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link UrlInput} component. | |||
*/ | |||
export type UrlInputDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link UrlInput} component. | |||
*/ | |||
export interface UrlInputProps extends Omit<React.HTMLProps<UrlInputDerivedElement>, 'size' | 'type' | 'label' | 'inputMode'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -3,9 +3,15 @@ import { TextControl } from '@tesseract-design/web-base'; | |||
import clsx from 'clsx'; | |||
import { useClientSide, useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link MaskedTextInput} component. | |||
*/ | |||
export type MaskedTextInputDerivedElement = HTMLInputElement; | |||
export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'size' | 'type' | 'label' | 'pattern'> { | |||
/** | |||
* Props of the {@link MaskedTextInput} component. | |||
*/ | |||
export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'autoComplete' | 'size' | 'type' | 'label' | 'pattern'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
*/ | |||
@@ -38,6 +44,14 @@ export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInp | |||
* Visual length of the input. | |||
*/ | |||
length?: number, | |||
/** | |||
* Should the component be enhanced? | |||
*/ | |||
enhanced?: boolean, | |||
/** | |||
* Autocomplete value type of the component. | |||
*/ | |||
autoComplete?: 'current-password' | 'new-password', | |||
} | |||
/** | |||
@@ -51,31 +65,45 @@ export const MaskedTextInput = React.forwardRef< | |||
label, | |||
hint, | |||
size = 'medium' as const, | |||
border = false, | |||
block = false, | |||
border = false as const, | |||
block = false as const, | |||
variant = 'default' as const, | |||
hiddenLabel = false, | |||
hiddenLabel = false as const, | |||
className, | |||
id: idProp, | |||
style, | |||
length, | |||
disabled, | |||
onKeyDown, | |||
onKeyUp, | |||
autoComplete, | |||
enhanced: enhancedProp = false as const, | |||
...etcProps | |||
}: MaskedTextInputProps, | |||
forwardedRef, | |||
) => { | |||
const { clientSide: indicator } = useClientSide({ clientSide: true, initial: false }); | |||
const { clientSide: enhanced } = useClientSide({ clientSide: enhancedProp }); | |||
const labelId = React.useId(); | |||
const id = useFallbackId(idProp); | |||
const [visible, setVisible] = React.useState(false); | |||
const [visibleViaKey, setVisibleViaKey] = React.useState(false); | |||
const defaultRef = React.useRef<MaskedTextInputDerivedElement>(null); | |||
const ref = forwardedRef ?? defaultRef; | |||
const handleKeyDown: React.KeyboardEventHandler< | |||
MaskedTextInputDerivedElement | |||
> = React.useCallback((e) => { | |||
if (e.ctrlKey && e.code === 'Space') { | |||
setVisibleViaKey(true); | |||
} | |||
onKeyDown?.(e); | |||
}, [onKeyDown]); | |||
const handleKeyUp: React.KeyboardEventHandler< | |||
MaskedTextInputDerivedElement | |||
> = React.useCallback((e) => { | |||
if (e.ctrlKey && e.code === 'Space') { | |||
setVisibleViaKey(false); | |||
setVisible((prev) => !prev); | |||
} | |||
onKeyUp?.(e); | |||
@@ -98,6 +126,22 @@ export const MaskedTextInput = React.forwardRef< | |||
}); | |||
}, [ref, defaultRef]); | |||
// const preserveFocus: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(() => { | |||
// const { current } = typeof ref === 'object' ? ref : defaultRef; | |||
// setVisibleViaKey(true); | |||
// setTimeout(() => { | |||
// current?.focus(); | |||
// }); | |||
// }, [ref, defaultRef]); | |||
// const handleToggleMouseUp: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(() => { | |||
// const { current } = typeof ref === 'object' ? ref : defaultRef; | |||
// setVisibleViaKey(false); | |||
// setTimeout(() => { | |||
// current?.focus(); | |||
// }); | |||
// }, [ref, defaultRef]); | |||
React.useEffect(() => { | |||
if (typeof ref === 'function') { | |||
const defaultElement = defaultRef.current as MaskedTextInputDerivedElement; | |||
@@ -131,12 +175,12 @@ export const MaskedTextInput = React.forwardRef< | |||
'sr-only': hiddenLabel, | |||
}, | |||
{ | |||
'pr-1': !indicator, | |||
'pr-1': !enhanced, | |||
}, | |||
{ | |||
'pr-10': indicator && size === 'small', | |||
'pr-12': indicator && size === 'medium', | |||
'pr-16': indicator && size === 'large', | |||
'pr-10': enhanced && size === 'small', | |||
'pr-12': enhanced && size === 'medium', | |||
'pr-16': enhanced && size === 'large', | |||
}, | |||
)} | |||
> | |||
@@ -156,7 +200,9 @@ export const MaskedTextInput = React.forwardRef< | |||
aria-labelledby={labelId} | |||
type={!visible ? 'password' : 'text'} | |||
data-testid="input" | |||
onKeyUp={handleKeyUp} | |||
autoComplete={autoComplete ?? 'off'} | |||
onKeyUp={enhanced ? handleKeyUp : undefined} | |||
onKeyDown={enhanced ? handleKeyDown : undefined} | |||
className={clsx( | |||
'bg-negative rounded-inherit w-full peer block font-inherit', | |||
'focus:outline-0', | |||
@@ -173,13 +219,13 @@ export const MaskedTextInput = React.forwardRef< | |||
'pt-4': variant === 'alternate', | |||
}, | |||
{ | |||
'pr-4': variant === 'default' && !indicator, | |||
'pr-1.5': variant === 'alternate' && !indicator, | |||
'pr-4': variant === 'default' && !enhanced, | |||
'pr-1.5': variant === 'alternate' && !enhanced, | |||
}, | |||
{ | |||
'pr-10': indicator && size === 'small', | |||
'pr-12': indicator && size === 'medium', | |||
'pr-16': indicator && size === 'large', | |||
'pr-10': enhanced && size === 'small', | |||
'pr-12': enhanced && size === 'medium', | |||
'pr-16': enhanced && size === 'large', | |||
}, | |||
{ | |||
'h-10': size === 'small', | |||
@@ -202,13 +248,13 @@ export const MaskedTextInput = React.forwardRef< | |||
'pt-3': variant === 'alternate' && size !== 'small', | |||
}, | |||
{ | |||
'pr-4': !indicator && variant === 'default', | |||
'pr-1': !indicator && variant === 'alternate', | |||
'pr-4': !enhanced && variant === 'default', | |||
'pr-1': !enhanced && variant === 'alternate', | |||
}, | |||
{ | |||
'pr-10': indicator && size === 'small', | |||
'pr-12': indicator && size === 'medium', | |||
'pr-16': indicator && size === 'large', | |||
'pr-10': enhanced && size === 'small', | |||
'pr-12': enhanced && size === 'medium', | |||
'pr-16': enhanced && size === 'large', | |||
}, | |||
)} | |||
> | |||
@@ -219,14 +265,18 @@ export const MaskedTextInput = React.forwardRef< | |||
</div> | |||
</div> | |||
)} | |||
{indicator && ( | |||
{enhanced && ( | |||
<button | |||
disabled={disabled} | |||
type="button" | |||
data-testid="indicator" | |||
tabIndex={-1} | |||
className={clsx( | |||
'text-center z-[1] focus:outline-0 flex items-center justify-center aspect-square absolute bottom-0 right-0 select-none text-primary group-focus-within:text-secondary', | |||
'text-center z-[1] focus:outline-0 flex items-center justify-center aspect-square absolute bottom-0 right-0 select-none', | |||
{ | |||
'text-primary group-focus-within:text-secondary group-focus-within:active:text-tertiary': !visibleViaKey, | |||
'text-tertiary': visibleViaKey, | |||
}, | |||
{ | |||
'w-10': size === 'small', | |||
'w-12': size === 'medium', | |||
@@ -270,10 +320,12 @@ MaskedTextInput.displayName = 'MaskedTextInput'; | |||
MaskedTextInput.defaultProps = { | |||
label: undefined, | |||
hint: undefined, | |||
size: 'medium', | |||
border: false, | |||
block: false, | |||
variant: 'default', | |||
hiddenLabel: false, | |||
size: 'medium' as const, | |||
border: false as const, | |||
block: false as const, | |||
variant: 'default' as const, | |||
hiddenLabel: false as const, | |||
length: undefined, | |||
enhanced: false as const, | |||
autoComplete: undefined, | |||
}; |
@@ -3,8 +3,14 @@ import { TextControl } from '@tesseract-design/web-base'; | |||
import clsx from 'clsx'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link MultilineTextInput} component. | |||
*/ | |||
export type MultilineTextInputDerivedElement = HTMLTextAreaElement; | |||
/** | |||
* Props of the {@link MultilineTextInput} component. | |||
*/ | |||
export interface MultilineTextInputProps extends Omit<React.HTMLProps<MultilineTextInputDerivedElement>, 'size' | 'label' | 'inputMode' | 'pattern'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -3,8 +3,14 @@ import { TextControl } from '@tesseract-design/web-base'; | |||
import clsx from 'clsx'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link TextInput} component. | |||
*/ | |||
export type TextInputDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link TextInput} component. | |||
*/ | |||
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. | |||
@@ -1,17 +1,30 @@ | |||
import * as React from 'react'; | |||
import clsx from 'clsx'; | |||
/** | |||
* Derived HTML element of the {@link Badge} component. | |||
*/ | |||
export type BadgeDerivedElement = HTMLSpanElement; | |||
/** | |||
* Props of the {@link Badge} component. | |||
*/ | |||
export interface BadgeProps extends React.HTMLProps<BadgeDerivedElement> { | |||
/** | |||
* Is the component rounded? | |||
*/ | |||
rounded?: boolean; | |||
} | |||
/** | |||
* Component for displaying textual information in a small container. | |||
*/ | |||
export const Badge = React.forwardRef<BadgeDerivedElement, BadgeProps>(( | |||
{ | |||
children, | |||
rounded = false, | |||
className, | |||
style, | |||
...etcProps | |||
}, | |||
forwardedRef, | |||
@@ -28,6 +41,7 @@ export const Badge = React.forwardRef<BadgeDerivedElement, BadgeProps>(( | |||
}, | |||
className, | |||
)} | |||
style={style} | |||
data-testid="badge" | |||
> | |||
<span className="relative w-full"> | |||
@@ -1,23 +1,52 @@ | |||
import * as React from 'react'; | |||
import clsx from 'clsx'; | |||
/** | |||
* Derived HTML element of the {@link KeyValueTable} component. | |||
*/ | |||
export type KeyValueTableDerivedElement = HTMLDListElement; | |||
interface KeyValueProperty { | |||
/** | |||
* Individual property of the {@link KeyValueTable} component. | |||
*/ | |||
export interface KeyValueTableProperty { | |||
/** | |||
* Key of the property. | |||
*/ | |||
key: string; | |||
/** | |||
* Class name of the property. | |||
*/ | |||
className?: string; | |||
/** | |||
* Value of the property. | |||
*/ | |||
valueProps?: React.HTMLProps<HTMLElement>; | |||
} | |||
/** | |||
* Props of the {@link KeyValueTable} component. | |||
*/ | |||
export interface KeyValueTableProps extends Omit<React.HTMLProps<KeyValueTableDerivedElement>, 'children'> { | |||
/** | |||
* Should the keys be hidden? | |||
*/ | |||
hiddenKeys?: boolean; | |||
properties?: (KeyValueProperty | boolean)[]; | |||
/** | |||
* Properties displayed on the component. | |||
*/ | |||
properties?: (KeyValueTableProperty | boolean | null | undefined)[]; | |||
} | |||
/** | |||
* Component for displaying key-value pairs. | |||
*/ | |||
export const KeyValueTable = React.forwardRef<KeyValueTableDerivedElement, KeyValueTableProps>(( | |||
{ | |||
hiddenKeys = false, | |||
properties = [], | |||
className, | |||
style, | |||
...etcProps | |||
}, | |||
forwardedRef, | |||
@@ -26,10 +55,12 @@ export const KeyValueTable = React.forwardRef<KeyValueTableDerivedElement, KeyVa | |||
{...etcProps} | |||
className={clsx( | |||
'grid gap-y-1 grid-cols-3', | |||
className, | |||
)} | |||
ref={forwardedRef} | |||
style={style} | |||
> | |||
{properties.map((property) => typeof property === 'object' && ( | |||
{properties.map((property) => typeof property === 'object' && property && ( | |||
<div | |||
key={property.key} | |||
className={clsx('contents', property.className)} | |||
@@ -4,9 +4,15 @@ import clsx from 'clsx'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link MenuMultiSelect} component. | |||
*/ | |||
export type MenuMultiSelectDerivedElement = HTMLSelectElement; | |||
export interface MenuMultiSelectProps extends Omit<React.HTMLProps<MenuMultiSelectDerivedElement>, 'size' | 'style' | 'label' | 'multiple'> { | |||
/** | |||
* Props of the {@link MenuMultiSelect} component. | |||
*/ | |||
export interface MenuMultiSelectProps extends Omit<React.HTMLProps<MenuMultiSelectDerivedElement>, 'size' | 'label' | 'multiple'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
*/ | |||
@@ -88,6 +94,7 @@ export const MenuMultiSelect = React.forwardRef< | |||
className, | |||
startingHeight = '15rem', | |||
id: idProp, | |||
style, | |||
...etcProps | |||
}, | |||
forwardedRef, | |||
@@ -107,6 +114,7 @@ export const MenuMultiSelect = React.forwardRef< | |||
className, | |||
)} | |||
data-testid="base" | |||
style={style} | |||
> | |||
{label && ( | |||
<> | |||
@@ -17,12 +17,24 @@ const TAG_INPUT_VALUE_SEPARATOR_MAP = { | |||
'semicolon': ';', | |||
} as const; | |||
/** | |||
* Separator for splitting the input value into multiple tags. | |||
*/ | |||
export type TagInputSeparator = keyof typeof TAG_INPUT_SEPARATOR_MAP | |||
/** | |||
* Derived HTML element of the {@link TagInput} component. | |||
*/ | |||
export type TagInputDerivedElement = HTMLTextAreaElement | HTMLInputElement; | |||
/** | |||
* Proxied HTML element of the {@link TagInput} component. | |||
*/ | |||
export type TagInputProxiedElement = HTMLTextAreaElement & HTMLInputElement; | |||
/** | |||
* Props of the {@link TagsInput} component. | |||
*/ | |||
export interface TagInputProps extends Omit<React.HTMLProps<TagInputDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -4,15 +4,42 @@ import { Button } from '@tesseract-design/web-base'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link ToggleButton} component. | |||
*/ | |||
export type ToggleButtonDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link ToggleButton} component. | |||
*/ | |||
export interface ToggleButtonProps extends Omit<React.InputHTMLAttributes<ToggleButtonDerivedElement>, 'type' | 'size'> { | |||
/** | |||
* Should the component occupy the whole width of its parent? | |||
*/ | |||
block?: boolean; | |||
/** | |||
* Should the component's content use minimal space? | |||
*/ | |||
compact?: boolean; | |||
/** | |||
* Size of the component. | |||
*/ | |||
size?: Button.Size; | |||
/** | |||
* Complementary content of the component. | |||
*/ | |||
subtext?: React.ReactNode; | |||
/** | |||
* Short complementary content displayed at the edge of the component. | |||
*/ | |||
badge?: React.ReactNode; | |||
/** | |||
* Variant of the component. | |||
*/ | |||
variant?: Button.Variant; | |||
/** | |||
* Is the component in an indeterminate state? | |||
*/ | |||
indeterminate?: boolean; | |||
} | |||
@@ -35,6 +62,9 @@ export const toggleButtonPlugin = plugin(({ addComponents, }) => { | |||
}); | |||
}); | |||
/** | |||
* Component for toggling a Boolean value. | |||
*/ | |||
export const ToggleButton = React.forwardRef<ToggleButtonDerivedElement, ToggleButtonProps>(( | |||
{ | |||
children, | |||
@@ -47,6 +77,7 @@ export const ToggleButton = React.forwardRef<ToggleButtonDerivedElement, ToggleB | |||
badge, | |||
variant = 'bare' as const, | |||
indeterminate = false, | |||
style, | |||
...etcProps | |||
}, | |||
forwardedRef, | |||
@@ -104,6 +135,7 @@ export const ToggleButton = React.forwardRef<ToggleButtonDerivedElement, ToggleB | |||
}, | |||
className, | |||
)} | |||
style={style} | |||
> | |||
<span | |||
className={clsx( | |||
@@ -3,13 +3,34 @@ import clsx from 'clsx'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link ToggleSwitch} component. | |||
*/ | |||
export type ToggleSwitchDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link ToggleSwitch} component. | |||
*/ | |||
export interface ToggleSwitchProps extends Omit<React.InputHTMLAttributes<ToggleSwitchDerivedElement>, 'type' | 'size'> { | |||
/** | |||
* Should the component occupy the whole width of its parent? | |||
*/ | |||
block?: boolean; | |||
/** | |||
* Complementary content of the component. | |||
*/ | |||
subtext?: React.ReactNode; | |||
/** | |||
* Is the component in an indeterminate state? | |||
*/ | |||
indeterminate?: boolean; | |||
/** | |||
* Label to display when the component is checked. | |||
*/ | |||
checkedLabel?: React.ReactNode; | |||
/** | |||
* Label to display when the component is unchecked. | |||
*/ | |||
uncheckedLabel?: React.ReactNode; | |||
} | |||
@@ -134,6 +155,9 @@ export const toggleSwitchPlugin = plugin(({ addComponents }) => { | |||
}); | |||
}); | |||
/** | |||
* Component for toggling a Boolean value in an appearance of a toggle switch. | |||
*/ | |||
export const ToggleSwitch = React.forwardRef<ToggleSwitchDerivedElement, ToggleSwitchProps>(( | |||
{ | |||
uncheckedLabel, | |||
@@ -3,11 +3,26 @@ import clsx from 'clsx'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link ToggleTickBox} component. | |||
*/ | |||
export type ToggleTickBoxDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link ToggleTickBox} component. | |||
*/ | |||
export interface ToggleTickBoxProps extends Omit<React.InputHTMLAttributes<ToggleTickBoxDerivedElement>, 'type' | 'size'> { | |||
/** | |||
* Should the component occupy the whole width of its parent? | |||
*/ | |||
block?: boolean; | |||
/** | |||
* Complementary content of the component. | |||
*/ | |||
subtext?: React.ReactNode; | |||
/** | |||
* Is the component in an indeterminate state? | |||
*/ | |||
indeterminate?: boolean; | |||
} | |||
@@ -30,6 +45,9 @@ export const toggleTickBoxPlugin = plugin(({ addComponents, }) => { | |||
}); | |||
}); | |||
/** | |||
* Component for toggling a Boolean value with the appearance of a tick box. | |||
*/ | |||
export const ToggleTickBox = React.forwardRef<ToggleTickBoxDerivedElement, ToggleTickBoxProps>(( | |||
{ | |||
children, | |||
@@ -2,17 +2,50 @@ import * as React from 'react'; | |||
import clsx from 'clsx'; | |||
import { Button } from '@tesseract-design/web-base'; | |||
/** | |||
* Derived HTML element of the {@link LinkButton} component. | |||
*/ | |||
export type LinkButtonDerivedElement = HTMLAnchorElement; | |||
/** | |||
* Props of the {@link LinkButton} component. | |||
*/ | |||
export interface LinkButtonProps<T = any> extends Omit<React.HTMLProps<LinkButtonDerivedElement>, 'size'> { | |||
/** | |||
* Should the component occupy the whole width of its parent? | |||
*/ | |||
block?: boolean; | |||
/** | |||
* Variant of the component. | |||
*/ | |||
variant?: Button.Variant; | |||
/** | |||
* Complementary content of the component. | |||
*/ | |||
subtext?: React.ReactNode; | |||
/** | |||
* Short complementary content displayed at the edge of the component. | |||
*/ | |||
badge?: React.ReactNode; | |||
/** | |||
* Is this component part of a menu? | |||
*/ | |||
menuItem?: boolean; | |||
/** | |||
* Size of the component. | |||
*/ | |||
size?: Button.Size; | |||
/** | |||
* Should the component's content use minimal space? | |||
*/ | |||
compact?: boolean; | |||
/** | |||
* Component to use in rendering. | |||
*/ | |||
component?: React.ElementType<T>; | |||
/** | |||
* Is the component unable to receive activation? | |||
*/ | |||
disabled?: boolean; | |||
} | |||
@@ -30,6 +63,7 @@ export const LinkButton = React.forwardRef<LinkButtonDerivedElement, LinkButtonP | |||
component: EnabledComponent = 'a', | |||
disabled = false, | |||
href, | |||
style, | |||
...etcProps | |||
}, | |||
forwardedRef, | |||
@@ -70,6 +104,7 @@ export const LinkButton = React.forwardRef<LinkButtonDerivedElement, LinkButtonP | |||
className, | |||
)} | |||
data-testid="link" | |||
style={style} | |||
> | |||
<span | |||
className={clsx( | |||
@@ -4,8 +4,14 @@ import clsx from 'clsx'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useBrowser, useClientSide, useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link NumberSpinner} component. | |||
*/ | |||
export type NumberSpinnerDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link NumberSpinner} component. | |||
*/ | |||
export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDerivedElement>, 'size' | 'type' | 'label'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -39,8 +45,17 @@ export interface NumberSpinnerProps extends Omit<React.HTMLProps<NumberSpinnerDe | |||
* Visual length of the input. | |||
*/ | |||
length?: number, | |||
/** | |||
* Should the component be enhanced? | |||
*/ | |||
enhanced?: boolean, | |||
/** | |||
* Interval between steps in milliseconds. | |||
*/ | |||
stepInterval?: number, | |||
/** | |||
* Delay before the first step in milliseconds. | |||
*/ | |||
initialStepDelay?: number, | |||
} | |||
@@ -68,7 +83,7 @@ export const numberSpinnerPlugin = plugin(({ addComponents, }) => { | |||
}); | |||
/** | |||
* Component for inputting numeric values. | |||
* Component for inputting discrete numeric values. | |||
*/ | |||
export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, NumberSpinnerProps>(( | |||
{ | |||
@@ -96,63 +111,131 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe | |||
const browser = useBrowser(); | |||
const defaultRef = React.useRef<NumberSpinnerDerivedElement>(null); | |||
const ref = forwardedRef ?? defaultRef; | |||
const intervalRef = React.useRef< | |||
number | undefined | |||
>() as React.MutableRefObject<number | undefined>; | |||
const intervalRef = React.useRef<number | undefined>(); | |||
const clickYRef = React.useRef<number>(); | |||
const spinnerYRef = React.useRef<number>(); | |||
const spinEventSource = React.useRef<'mouse' | 'keyboard'>(); | |||
const [displayStep, setDisplayStep] = React.useState<boolean>(); | |||
const handleStepUp = React.useCallback(() => { | |||
const { current } = typeof ref === 'object' ? ref : defaultRef; | |||
if (current) { | |||
const theStep = current.step ?? 'any'; | |||
const performStepMouse = ( | |||
input: NumberSpinnerDerivedElement, | |||
theStepUpMode?: boolean, | |||
) => { | |||
if (typeof theStepUpMode !== 'boolean') { | |||
return; | |||
} | |||
const current = input; | |||
const theStep = current.step ?? 'any'; | |||
current.step = theStep === 'any' ? '1' : theStep; | |||
if (theStepUpMode) { | |||
current.stepUp(); | |||
} else { | |||
current.stepDown(); | |||
} | |||
current.step = theStep; | |||
current.focus(); | |||
}; | |||
const windowMouseMove = (e: MouseEvent) => { | |||
if (spinEventSource.current !== 'mouse') { | |||
return; | |||
} | |||
clickYRef.current = e.pageY; | |||
}; | |||
const doStepUp = () => { | |||
current.step = theStep === 'any' ? '1' : theStep; | |||
current.stepUp(); | |||
current.step = theStep; | |||
current.focus(); | |||
}; | |||
const checkMouseStepUpMode = () => { | |||
if (typeof clickYRef.current !== 'number' || typeof spinnerYRef.current !== 'number') { | |||
return undefined; | |||
} | |||
return clickYRef.current < spinnerYRef.current; | |||
}; | |||
const doStepMouse: React.MouseEventHandler<HTMLButtonElement> = (e) => { | |||
if (spinEventSource.current === 'keyboard') { | |||
return; | |||
} | |||
const { current } = typeof ref === 'object' ? ref : defaultRef; | |||
if (!current) { | |||
return; | |||
} | |||
spinEventSource.current = 'mouse'; | |||
const { top, bottom } = e.currentTarget.getBoundingClientRect(); | |||
const { pageY } = e; | |||
spinnerYRef.current = top + ((bottom - top) / 2); | |||
clickYRef.current = pageY; | |||
window.addEventListener('mousemove', windowMouseMove); | |||
setTimeout(() => { | |||
clearInterval(intervalRef.current); | |||
doStepUp(); | |||
const stepUpMode = checkMouseStepUpMode(); | |||
setDisplayStep(stepUpMode); | |||
performStepMouse(current, stepUpMode); | |||
intervalRef.current = window.setTimeout(() => { | |||
doStepUp(); | |||
const stepUpMode = checkMouseStepUpMode(); | |||
setDisplayStep(stepUpMode); | |||
performStepMouse(current, stepUpMode); | |||
intervalRef.current = window.setInterval(() => { | |||
doStepUp(); | |||
const stepUpMode = checkMouseStepUpMode(); | |||
setDisplayStep(stepUpMode); | |||
performStepMouse(current, stepUpMode); | |||
}, stepInterval); | |||
}, initialStepDelay); | |||
} | |||
}, [ref, defaultRef, intervalRef, stepInterval, initialStepDelay]); | |||
const handleStepDown = React.useCallback(() => { | |||
const { current } = typeof ref === 'object' ? ref : defaultRef; | |||
if (current) { | |||
const theStep = current.step ?? 'any'; | |||
const doStepDown = () => { | |||
current.step = theStep === 'any' ? '1' : theStep; | |||
current.stepDown(); | |||
current.step = theStep; | |||
current.focus(); | |||
}; | |||
}); | |||
}; | |||
const doStepKeyboard: React.KeyboardEventHandler<NumberSpinnerDerivedElement> = (e) => { | |||
if (spinEventSource.current === 'mouse') { | |||
return; | |||
} | |||
if (!(e.code === 'ArrowUp' || e.code === 'ArrowDown')) { | |||
return; | |||
} | |||
e.preventDefault(); | |||
spinEventSource.current = 'keyboard'; | |||
const current = e.currentTarget; | |||
const theStepUpMode = e.code === 'ArrowUp'; | |||
setDisplayStep(theStepUpMode); | |||
setTimeout(() => { | |||
clearInterval(intervalRef.current); | |||
doStepDown(); | |||
performStepMouse(current, theStepUpMode); | |||
intervalRef.current = window.setTimeout(() => { | |||
doStepDown(); | |||
performStepMouse(current, theStepUpMode); | |||
intervalRef.current = window.setInterval(() => { | |||
doStepDown(); | |||
performStepMouse(current, theStepUpMode); | |||
}, stepInterval); | |||
}, initialStepDelay); | |||
} | |||
}, [ref, defaultRef, intervalRef, stepInterval, initialStepDelay]); | |||
}); | |||
}; | |||
React.useEffect(() => { | |||
const stopStep = () => { | |||
const stopStepMouse = () => { | |||
if (spinEventSource.current === 'keyboard') { | |||
return; | |||
} | |||
clearInterval(intervalRef.current); | |||
clickYRef.current = undefined; | |||
spinnerYRef.current = undefined; | |||
window.removeEventListener('mousemove', windowMouseMove); | |||
spinEventSource.current = undefined; | |||
setDisplayStep(undefined); | |||
}; | |||
window.addEventListener('mouseup', stopStep, { capture: true }); | |||
const stopStepKeyboard = (e: KeyboardEvent) => { | |||
if (spinEventSource.current === 'mouse') { | |||
return; | |||
} | |||
if (!(e.code === 'ArrowUp' || e.code === 'ArrowDown')) { | |||
return; | |||
} | |||
clearInterval(intervalRef.current); | |||
spinEventSource.current = undefined; | |||
setDisplayStep(undefined); | |||
}; | |||
window.addEventListener('mouseup', stopStepMouse, { capture: true }); | |||
window.addEventListener('keyup', stopStepKeyboard, { capture: true }); | |||
return () => { | |||
window.removeEventListener('mouseup', stopStep, { capture: true }); | |||
window.removeEventListener('mouseup', stopStepMouse, { capture: true }); | |||
window.addEventListener('keyup', stopStepKeyboard, { capture: true }); | |||
}; | |||
}, []); | |||
@@ -209,6 +292,7 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe | |||
aria-labelledby={labelId} | |||
type="number" | |||
data-testid="input" | |||
onKeyDown={doStepKeyboard} | |||
className={clsx( | |||
'bg-negative rounded-inherit w-full peer block tabular-nums font-inherit relative', | |||
'focus:outline-0', | |||
@@ -272,8 +356,9 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe | |||
</div> | |||
)} | |||
{indicator && ( | |||
<div | |||
<button | |||
data-testid="indicator" | |||
type="button" | |||
className={clsx( | |||
'text-center z-[1] flex flex-col items-stretch justify-center aspect-square absolute bottom-0 right-0 select-none text-primary group-focus-within:text-secondary', | |||
{ | |||
@@ -282,12 +367,16 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe | |||
'w-16': size === 'large', | |||
}, | |||
)} | |||
onMouseDown={doStepMouse} | |||
> | |||
<button | |||
aria-label="Step Up" | |||
type="button" | |||
className="h-0 flex-auto flex justify-center items-start text-inherit overflow-hidden" | |||
onMouseDown={handleStepUp} | |||
<span | |||
className={clsx( | |||
'h-0 flex-auto flex justify-center items-end overflow-hidden', | |||
{ | |||
'text-tertiary': displayStep === true, | |||
'text-inherit': displayStep !== true, | |||
}, | |||
)} | |||
> | |||
<svg | |||
className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round" | |||
@@ -296,12 +385,21 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe | |||
> | |||
<polyline points="18 15 12 9 6 15" /> | |||
</svg> | |||
</button> | |||
<button | |||
aria-label="Step Down" | |||
type="button" | |||
className="h-0 flex-auto flex justify-center items-end text-inherit overflow-hidden" | |||
onMouseDown={handleStepDown} | |||
<span className="sr-only"> | |||
Step Up | |||
</span> | |||
</span> | |||
<span className="sr-only"> | |||
/ | |||
</span> | |||
<span | |||
className={clsx( | |||
'h-0 flex-auto flex justify-center items-end overflow-hidden', | |||
{ | |||
'text-tertiary': displayStep === false, | |||
'text-inherit': displayStep !== false, | |||
}, | |||
)} | |||
> | |||
<svg | |||
className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round" | |||
@@ -310,8 +408,11 @@ export const NumberSpinner = React.forwardRef<NumberSpinnerDerivedElement, Numbe | |||
> | |||
<polyline points="6 9 12 15 18 9" /> | |||
</svg> | |||
</button> | |||
</div> | |||
<span className="sr-only"> | |||
Step Down | |||
</span> | |||
</span> | |||
</button> | |||
)} | |||
{border && ( | |||
<span | |||
@@ -36,13 +36,31 @@ const filterOptions = (children: React.ReactNode): React.ReactNode => { | |||
* sliders and vv. | |||
*/ | |||
/** | |||
* Orientation of the {@link Slider} component. | |||
*/ | |||
export type SliderOrientation = 'horizontal' | 'vertical'; | |||
/** | |||
* Derived HTML element of the {@link Slider} component. | |||
*/ | |||
export type SliderDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link Slider} component. | |||
*/ | |||
export interface SliderProps extends Omit<React.HTMLProps<HTMLInputElement>, 'type'> { | |||
/** | |||
* Orientation of the component. | |||
*/ | |||
orient?: SliderOrientation; | |||
/** | |||
* Options of the component. | |||
*/ | |||
children?: React.ReactNode; | |||
/** | |||
* Length of the component. | |||
*/ | |||
length?: React.CSSProperties['width']; | |||
} | |||
@@ -93,7 +111,7 @@ export const sliderPlugin = plugin(({ addComponents }) => { | |||
'aspect-ratio': '1 / 1', | |||
'z-index': '1', | |||
position: 'relative', | |||
'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-primary) / 50%)', | |||
'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-primary) / 50%)', | |||
}, | |||
'& > input:focus::-webkit-slider-container': { | |||
@@ -105,11 +123,11 @@ export const sliderPlugin = plugin(({ addComponents }) => { | |||
}, | |||
'& > input:focus::-webkit-slider-thumb': { | |||
'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-secondary) / 50%)', | |||
'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-secondary) / 50%)', | |||
}, | |||
'& > input:active::-webkit-slider-thumb': { | |||
'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%)', | |||
'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-tertiary) / 50%)', | |||
}, | |||
'&[data-orient="horizontal"]': { | |||
@@ -155,15 +173,15 @@ export const sliderPlugin = plugin(({ addComponents }) => { | |||
'aspect-ratio': '1 / 1', | |||
'z-index': '1', | |||
position: 'relative', | |||
'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-primary) / 50%)', | |||
'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-primary) / 50%)', | |||
}, | |||
'& > input:focus::-moz-range-thumb': { | |||
'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-secondary) / 50%)', | |||
'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-secondary) / 50%)', | |||
}, | |||
'& > input:active::-moz-range-thumb': { | |||
'box-shadow': '-100000.5em 0 0 100000em rgb(var(--color-tertiary) / 50%)', | |||
'box-shadow': 'calc(-200001em / 2) 0 0 100000em rgb(var(--color-tertiary) / 50%)', | |||
}, | |||
'& > input[orient="vertical"]': { | |||
@@ -179,15 +197,15 @@ export const sliderPlugin = plugin(({ addComponents }) => { | |||
'& > input[orient="vertical"]::-moz-range-thumb': { | |||
width: '100%', | |||
height: '1em', | |||
'box-shadow': '0 100000.5em 0 100000em rgb(var(--color-primary) / 50%)', | |||
'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-primary) / 50%)', | |||
}, | |||
'& > input[orient="vertical"]:focus::-moz-range-thumb': { | |||
'box-shadow': '0 100000.5em 0 100000em rgb(var(--color-secondary) / 50%)', | |||
'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-secondary) / 50%)', | |||
}, | |||
'& > input[orient="vertical"]:active::-moz-range-thumb': { | |||
'box-shadow': '0 100000.5em 0 100000em rgb(var(--color-tertiary) / 50%)', | |||
'box-shadow': '0 calc(200001em / 2) 0 100000em rgb(var(--color-tertiary) / 50%)', | |||
}, | |||
'&[data-chrome] > input + *': { | |||
@@ -208,6 +226,9 @@ export const sliderPlugin = plugin(({ addComponents }) => { | |||
}); | |||
}); | |||
/** | |||
* Component for inputting continuous numeric values. | |||
*/ | |||
export const Slider = React.forwardRef<SliderDerivedElement, SliderProps>(( | |||
{ | |||
className, | |||
@@ -4,8 +4,14 @@ import clsx from 'clsx'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link DateDropdown} component. | |||
*/ | |||
export type DateDropdownDerivedElement = HTMLInputElement; | |||
/** | |||
* Props of the {@link DateDropdown} component. | |||
*/ | |||
export interface DateDropdownProps extends Omit<React.HTMLProps<DateDropdownDerivedElement>, 'size' | 'type' | 'label' | 'pattern'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -68,7 +74,7 @@ export const dateDropdownPlugin = plugin(({ addComponents }) => { | |||
}); | |||
/** | |||
* Component for inputting textual values. | |||
* Component for inputting date values. | |||
*/ | |||
export const DateDropdown = React.forwardRef< | |||
DateDropdownDerivedElement, | |||
@@ -4,6 +4,9 @@ import clsx from 'clsx'; | |||
import plugin from 'tailwindcss/plugin'; | |||
import { useFallbackId } from '@modal-sh/react-utils'; | |||
/** | |||
* Derived HTML element of the {@link TimeSpinner} component. | |||
*/ | |||
export type TimeSpinnerDerivedElement = HTMLInputElement; | |||
type Digit = (0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9); | |||
@@ -16,6 +19,9 @@ type StepHhMmSs = `${StepHhMm}:${Segment}`; | |||
type Step = StepHhMm | StepHhMmSs; | |||
/** | |||
* Props of the {@link TimeSpinner} component. | |||
*/ | |||
export interface TimeSpinnerProps extends Omit<React.HTMLProps<TimeSpinnerDerivedElement>, 'size' | 'type' | 'label' | 'step' | 'pattern'> { | |||
/** | |||
* Short textual description indicating the nature of the component's value. | |||
@@ -82,7 +88,7 @@ export const timeSpinnerPlugin = plugin(({ addComponents }) => { | |||
}); | |||
/** | |||
* Component for inputting textual values. | |||
* Component for inputting time values. | |||
*/ | |||
export const TimeSpinner = React.forwardRef< | |||
TimeSpinnerDerivedElement, | |||
@@ -102,6 +102,7 @@ const RegistrationFormPage: NextPage = () => { | |||
label="Password" | |||
name="password" | |||
onChange={handleChange} | |||
enhanced | |||
/> | |||
</div> | |||
<div className="sm:col-span-2"> | |||
@@ -111,6 +112,7 @@ const RegistrationFormPage: NextPage = () => { | |||
label="Confirm Password" | |||
name="confirmPassword" | |||
onChange={handleChange} | |||
enhanced | |||
/> | |||
</div> | |||
<div className="sm:col-span-6 text-center"> | |||