Skip to content

Instantly share code, notes, and snippets.

@unicornware
Created September 22, 2020 05:47
Show Gist options
  • Save unicornware/c23b0b38c59b22c162f3099363db8c63 to your computer and use it in GitHub Desktop.
Save unicornware/c23b0b38c59b22c162f3099363db8c63 to your computer and use it in GitHub Desktop.
Custom React Hooks
import {
AriaAttributes,
CSSProperties,
DOMAttributes,
RefAttributes
} from 'react'
import { ContainerProps, IconProps } from './atoms'
/**
* Aria attributes and event handlers.
*/
export type Attributes<E = HTMLElement> = AriaAttributes &
DOMAttributes<E> &
RefAttributes<E> & { forwardedRef?: RefAttributes<E>['ref'] }
/**
* Background size options.
*/
export type BackgroundSize = 'auto' | 'contain' | 'cover'
/* eslint-disable */
/**
* Boolean and string values representing `true` or `false`.
*/
export type Booleanish = boolean | 'true' | 'false'
/**
* {@link Button} component variants.
*/
export type ButtonVariant = (
| Variant
| VariantOutline
| 'link'
)
/**
* Color variants.
*/
export type Color = (
| 'accent'
| 'black'
| 'danger'
| 'dark'
| 'info'
| 'light'
| 'primary'
| 'muted'
| 'secondary'
| 'success'
| 'warning'
| 'white'
)
/* eslint-enable */
/**
* Possible {@link Container} component sizes.
*/
export type ContainerSize = 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
/**
* Common content-sectioning component properties.
*
* @see {@link https://websitesetup.org/html5-periodical-table/}
*/
export interface ContentSectionProps<E = HTMLElement>
extends TextContentProps<E> {
/**
* Background color or outline variant.
*
* @default false
*/
variant?: boolean | Variant | VariantOutline
}
/* eslint-disable */
/**
* Font weights.
*/
export type FontWeight = (
| 'hairline'
| 'thin'
| 'light'
| 'normal'
| 'medium'
| 'semibold'
| 'bold'
| 'extrabold'
| 'black'
)
/* eslint-enable */
/**
* Possible {@link Button} component sizes.
*/
export type FormControlSize = Pick<Size, 'sm' | 'lg'>
/**
* Properties common to all components.
*/
export interface GlobalProps<E = HTMLElement> extends Attributes<E> {
/**
* Content to render inside the component.
*
* If defined, `innerHTML` must be omitted.
*/
children?: DOMAttributes<E>['children']
/**
* If defined, wrap inner content in a `Container` component.
*/
container?: true | ContainerProps<E>
/**
* A space-separated list of the classes of the element.
*
* Classes allows CSS and JavaScript to select and access specific elements
* via the class selectors or functions like the method
* `Document.getElementsByClassName()`.
*/
className?: string
/**
* An enumerated attribute indicating if the element should be editable by
* the user. If so, the browser modifies its widget to allow editing.
*/
contentEditable?: Booleanish | 'inherit'
/**
* Add the class "d-flex" or "d-inline-flex".
*
* @default false
*/
flex?: boolean | 'inline'
/**
* A Boolean attribute indicates that the element is not yet, or is no longer,
* relevant.
*
* For example, it can be used to hide elements of the page that can't be
* used until the login process has been completed. The browser won't render
* such elements.
*
* This attribute must not be used to hide content that could legitimately
* be shown.
*/
hidden?: boolean
/**
* Icon to render beside the element text.
*/
icon?: IconProps
/**
* Defines a unique identifier (ID) which must be unique in the whole
* document. Its purpose is to identify the element when linking (using a
* fragment identifier), scripting, or styling (with CSS).
*/
id?: string
/**
* HTML string to render inside the component.
*
* If defined, `children` must be omitted.
*/
innerHTML?: string
/* eslint-disable prettier/prettier */
/**
* Provides a hint to browsers as to the type of virtual keyboard
* configuration to use when editing this element or its contents.
*
* Used primarily on `<input>` elements, but is usable on any element while
* in contenteditable mode.
*/
inputMode?: (
'none'
| 'text'
| 'decimal'
| 'numeric'
| 'tel'
| 'search'
| 'email'
| 'url'
)
/* eslint-enable prettier/prettier */
/**
* Specify that a standard HTML element should behave like a defined custom
* built-in element.
*
* - https://html.spec.whatwg.org/multipage/custom-elements.html#attr-is
*/
is?: string
/**
* Helps define the language of an element?: the language that non-editable
* elements are in, or the language that editable elements should be written
* in by the user.
*
* The attribute contains one “language tag” (made of hyphen-separated
* “language subtags”) in the format defined in Tags for Identifying
* Languages (BCP47). xml:lang has priority over it.
*/
lang?: string
/**
* An enumerated attribute defines whether the element may be checked for
* spelling errors.
*
* It may have the following values:
*
* - true, which indicates that the element should be, if possible, checked
* for spelling errors;
* - false, which indicates that the element should not be checked for
* spelling errors.
*/
spellCheck?: Booleanish
/**
* WAI-ARIA role.
*/
role?: string
/**
* If true, skip the logic in the `useIcon` hook.
*/
skipUseIcon?: boolean
/**
* Contains CSS styling declarations to be applied to the element. Note that
* it is recommended for styles to be defined in a separate file or files.
*
* This attribute and the <style> element have mainly the purpose of
* allowing for quick styling, for example for testing purposes.
*/
style?: CSSProperties
/**
* An integer attribute indicating if the element can take input focus (is
* focusable), if it should participate to sequential keyboard navigation,
* and if so, at what position.
*
* It can take several values:
*
* - a negative value means that the element should be focusable, but should
* not be reachable via sequential keyboard navigation;
* - 0 means that the element should be focusable and reachable via
* sequential keyboard navigation, but its relative order is defined by
* the platform convention;
* - a positive value means that the element should be focusable and
* reachable via sequential keyboard navigation; the order in which the
* elements are focused is the increasing value of the tabindex. If
* several elements share the same tabindex, their relative order follows
* their relative positions in the document.
*/
tabindex?: number
/**
* Contains a text representing advisory information related to the element
* it belongs to. Such information can typically, but not necessarily, be
* presented to the user as a tooltip.
*/
title?: string
/**
* An enumerated attribute that is used to specify whether an element's
* attribute values and the values of its Text node children are to be
* translated when the page is localized, or whether to leave them
* unchanged.
*
* It can have the following values:
*
* - empty string and yes, which indicates that the element will be
* translated.
* - no, which indicates that the element will not be translated.
*/
translate?: 'no' | 'yes'
/**
* Background color or outline variant.
*
* @default false
*/
variant?: boolean | Variant | VariantOutline
}
/* eslint-disable */
/**
* {@link GridBox} component order options.
*
* @see {@link https://react-bootstrap-v5.netlify.app/layout/grid/}
*/
export type GridBoxOrder = (
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| '10'
| '11'
| '12'
)
/**
* {@link GridBox} component span options.
*
* @see {@link https://react-bootstrap-v5.netlify.app/layout/grid/}
*/
export type GridBoxSpan = (
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| '10'
| '11'
| '12'
)
/* eslint-enable */
/**
* Ref attributes for HTML elements.
*/
export type HTMLElementRefAttributes = RefAttributes<HTMLElement>
/**
* Global properties are attributes common to all HTML elements; they can be
* used on all elements, though they may have no effect on some elements.
*
* The properties defined are the ones to be used by this application.
*
* **https://developer.mozilla.org/docs/Web/HTML/Global_attributes**
*/
export type HTMLGlobalProps = Omit<GlobalProps, 'flex' | 'icon' | 'variant'>
/**
* Pulls the common properties from both types, but only maps the values from
* the first argument.
*
* @example NativeProps<JSX.IntrinsicElements['button'], ButtonProps>
*
* @see
* {@link https://stackoverflow.com/questions/47375916/typescript-how-to-create-type-with-common-properties-of-two-types}
*/
export type NativeProps<HTMLElementProps, Props> = {
[HTMLProp in keyof HTMLElementProps & keyof Props]: typeof A[HTMLProp]
}
/**
* Common `Form` (button, input, select) element props.
*/
export interface PropsForFormElement<E = HTMLElement> extends GlobalProps<E> {
/**
* Specifies that a form control should have input focus when the page
* loads.
*
* Only one form-associated element in a document can have this attribute
* specified.
*/
autoFocus?: boolean
/**
* Indicates that the user cannot interact with the control.
*
* If this attribute is not specified, the control inherits its setting from
* the containing element, for example `<fieldset>`; if there is no containing
* element when the `disabled` attribute is set, the control is enabled.
*/
disabled?: boolean
/**
* The `id` of the `<form>` element that the element is associated with.
*
* If this attribute is not specified, the element must be a descendant of a
* form element.
*
* This attribute enables you to place elements anywhere within a document,
* not just as descendants of form elements.
*/
form?: string
/**
* The name of the control.
*/
name?: string
/**
* Current value of the form control.
*
* Submitted with the form as part of a name/value pair.
*/
value?: string | ReadonlyArray<string> | number
}
/**
* Component properties for HTML elements that do not accept inner content.
*/
export type PropsForVoidElementTag<E = HTMLElement> = Omit<
GlobalProps<E>,
'children' | 'dangerouslySetInnerHTML'
>
/**
* Text sizes.
*/
export type Size =
| 'xs'
| 'sm'
| 'md'
| 'lg'
| 'xl'
| '2xl'
| '3xl'
| '4xl'
| '5xl'
| '6xl'
| '7xl'
| '8xl'
/**
* Keys of `scss` `$spacers` map.
*/
export type SpacerKey =
| 0
| 1
| 2
| 3
| 4
| 5
| 6
| 8
| 9
| 10
| 12
| 14
| 16
| 18
| 20
| 24
| 48
| 56
| 64
/**
* Common text content component properties.
*
* @see {@link https://websitesetup.org/html5-periodical-table/}
*/
export interface TextContentProps<E = HTMLElement> extends GlobalProps<E> {
/**
* Text content color.
*
* @default false
*/
color?: Color | boolean
/**
* Text content size.
*
* @default false
*/
size?: Size | boolean
}
/**
* Color variants.
*/
export type Variant =
| 'accent'
| 'black'
| 'danger'
| 'dark'
| 'ghost'
| 'info'
| 'light'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'white'
/**
* Outline variants.
*/
export type VariantOutline =
| 'outline-danger'
| 'outline-dark'
| 'outline-info'
| 'outline-light'
| 'outline-primary'
| 'outline-secondary'
| 'outline-success'
| 'outline-warning'
import { Container, ContainerProps } from '../lib'
import { GlobalProps } from '../lib/declarations'
import { isBoolean, omit } from 'lodash'
import React, { useEffect, useState } from 'react'
/**
* @file Render props.children in a Container component
* @module hooks/useContainer
*
* @see {@link https://reactjs.org/docs/hooks-reference.html#usestate}
* @see {@link https://reactjs.org/docs/hooks-reference.html#useeffect}
*/
/**
* Renders {@param props.children} in a {@link Container} component.
*
* @param props - Component properties
* @param props.children - Inner content
* @param props.container - Boolean or Container component properties
*/
export const useContainer = (props: GlobalProps): GlobalProps => {
const { children, container: initialContainer = false } = props
const containerProps = isBoolean(initialContainer) ? {} : initialContainer
const [skip] = useState(!initialContainer)
const [mutatedChildren, setMutatedChildren] = useState(children)
const [container] = useState(JSON.stringify(containerProps))
useEffect(() => {
// Skip hook logic
if (skip) return
// Parse container state
const containerParsed: ContainerProps = JSON.parse(container)
// Update state
setMutatedChildren(<Container {...containerParsed}>{children}</Container>)
}, [children, container, skip])
return { ...omit(props, ['container']), children: mutatedChildren }
}
import { Icon, IconProps } from '../lib'
import { GlobalProps } from '../lib/declarations'
import classnames from 'classnames'
import { cloneDeep, isNull, isUndefined, omit } from 'lodash'
import React, { useEffect, useState } from 'react'
/**
* @file Render icon with props.children
* @module hooks/useIcon
*
* @see {@link https://reactjs.org/docs/hooks-reference.html#usestate}
* @see {@link https://reactjs.org/docs/hooks-reference.html#useeffect}
*/
/**
* Renders an `Icon` component with {@param props.children}.
*
* If the {@param props.icon.position} isn't defined, the icon will be rendered
* on the right.
*
* @param props - Component properties
* @param props.children - Inner content
* @param props.icon - Icon component properties
* @param props.icon.position - String indication where to position icon
*/
export const useIcon = (props: GlobalProps): GlobalProps => {
const {
children,
className: initialClassName = '',
icon: initialIcon
} = props
const [className, setClassName] = useState(initialClassName)
const [skip] = useState(!initialIcon)
const [dataAttrs, setDataAttrs] = useState<Record<string, any>>({})
const [mutatedChildren, setMutatedChildren] = useState(children)
const [icon] = useState(JSON.stringify(initialIcon || ''))
useEffect(() => {
// Skip hook logic
if (skip) return
// Parse icon props
const iconParsed: IconProps = JSON.parse(icon)
// Get icon component
const component: JSX.Element = <Icon {...iconParsed} key='icon' />
// Position icon
const { position } = iconParsed
if (!children) {
setMutatedChildren(component)
} else if (position === 'left') {
setMutatedChildren([component, children])
} else {
setMutatedChildren([children, component])
if (['bottom', 'top'].includes(position ?? '')) {
setClassName(classes => {
return classnames({
[`${classes}`]: true,
'd-flex': true,
'flex-column': position === 'bottom',
'flex-column-reverse': position === 'top'
})
})
}
}
// Set data attributes
setDataAttrs({
'data-icon': true,
'data-icon-only': isNull(children) || isUndefined(children)
})
}, [children, icon, skip])
return {
...cloneDeep(omit(props, ['icon'])),
...dataAttrs,
children: mutatedChildren,
className
}
}
import { GlobalProps } from '../lib/declarations'
import classnames from 'classnames'
import { ClassValue } from 'classnames/types'
import { isObject, isString, omit, uniq } from 'lodash'
import { HTMLAttributes } from 'react'
import { useContainer } from './useContainer'
import { useIcon } from './useIcon'
/**
* @file Add global mutations to incoming props
* @module hooks/useMutatedProps
*/
/**
* Adds global mutations to the incoming component properties.
*
* Mutations (in order, if props are defined):
*
* - {@param props.icon} will be used to render an {@link Icon} (if defined)
* - {@param props.children} will wrapped in a {@link Container} component
* - {@param props.innerHTML} will be converted into `dangerouslySetInnerHTML`
* - {@param props.flex} (if defined) will be used to update flexbox classes
* - {@param props.variant} (if defined) will be used to update the `bg-` class
* - {@param inject} will be passed to `classnames` function
* - Keys specified in {@param keys} will be removed {@param props}
*
* @param props - Component properties
* @param props.children - Component children
* @param props.flex - Append flexbox classes
* @param props.icon - Icon component properties
* @param props.variant - Append `bg-` classes
* @param inject - Classes to inject before {@param props.className}
* @param keys - Array of keys to remove from {@param props}
*/
export function useMutatedProps<
T1 = GlobalProps,
Mask = HTMLAttributes<HTMLElement>
>(props: T1, injectClass?: ClassValue, keys?: string[]): Mask {
const globalProps = props as GlobalProps
const withIcon = useIcon(globalProps)
const withContainer = useContainer(withIcon)
keys = keys || []
if (withContainer.innerHTML) {
withContainer.dangerouslySetInnerHTML = { __html: withContainer.innerHTML }
keys.push('innerHTML')
keys.push('children')
}
if (withContainer.flex) {
const { flex } = withContainer
injectClass = isObject(injectClass) ? injectClass : {}
injectClass[`d-${isString(flex) ? 'inline-' : ''}flex`] = flex
keys.push('flex')
}
if (withContainer.variant) {
const { variant } = withContainer
injectClass = isObject(injectClass) ? injectClass : {}
injectClass[`${injectClass.btn ? 'btn' : 'bg'}-${variant}`] = variant
keys.push('variant')
}
withContainer.className = classnames(injectClass, withContainer.className)
const mutatedProps = {
...withContainer,
children: globalProps.icon ? withIcon.children : withContainer.children
}
return omit(mutatedProps, uniq(keys)) as Mask
}
export default useMutatedProps
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment