diff --git a/package.json b/package.json index ea3f3c22..51de2303 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.4.8", + "version": "0.4.11", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 6f978f25..91501035 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -12,7 +12,7 @@ import { buttonCSS } from './styles'; export interface ButtonProps extends BaseButtonProps { children?: ReactNode | string; - variant: 'primary' | 'default' | 'danger'; + variant: 'primary' | 'default' | 'danger' | 'quiet'; disabled?: boolean; className?: string; onClick?: (e: SyntheticEvent) => void; diff --git a/src/button/styles.ts b/src/button/styles.ts index da70dcaf..55b0b181 100644 --- a/src/button/styles.ts +++ b/src/button/styles.ts @@ -5,7 +5,7 @@ export const buttonCSS = css` border: 1px solid ${theme.colors.dark1}; font-size: ${theme.typography.sizes.medium}; font-weight: 600; - + margin: 0; display: flex; justify-content: center; align-items: center; @@ -13,12 +13,14 @@ export const buttonCSS = css` border-radius: 4px; color: ${theme.textColors.white90}; cursor: pointer; + /* Disable outline since there are other mechanisms to show focus */ + outline: none; &:not([disabled]) { transition: all 0.2s ease-in-out; } &[disabled] { color: ${theme.textColors.white70}; - cursor: not-allowed; + cursor: default; } &[data-size='normal'][data-childless='false'] { padding: ${theme.spacing.padding8}px ${theme.spacing.padding16}px; @@ -46,6 +48,12 @@ export const buttonCSS = css` background-color: ${theme.components.button.defaultHoverBackgroundColor}; } } + &[data-variant='quiet'] { + background-color: ${theme.colors.gray500}; + &:hover:not([disabled]) { + background-color: ${theme.components.button.defaultHoverBackgroundColor}; + } + } &[data-variant='danger'] { background-color: ${theme.colors.statusDanger}; border-color: ${theme.components.button.dangerBorderColor}; diff --git a/src/icon/Icons.tsx b/src/icon/Icons.tsx index 0d968d5e..c109b9aa 100644 --- a/src/icon/Icons.tsx +++ b/src/icon/Icons.tsx @@ -1,5 +1,11 @@ import React from 'react'; +import { css, keyframes } from '@emotion/core'; +const loadingCircleKeyframes = keyframes` + 100% { + transform: rotate(360deg); + } +`; /** * Raw svg icons from eva. Easily stylizable via CSS * @src https://akveo.github.io/eva-icons/#/ @@ -405,3 +411,56 @@ export const SearchOutline = () => ( ); + +export const LoadingOutline = () => ( + + + + + + + + + + +); + +export const CloseCircleOutline = () => ( + + + + + + + + + +); diff --git a/src/index.tsx b/src/index.tsx index 09da1045..64c23ad7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,6 +18,7 @@ export * from './notification'; export * from './field'; export * from './textfield'; export * from './form'; +export * from './search'; // export interface Props extends HTMLAttributes { // /** custom content, defaults to 'the snozzberries taste like snozzberries' */ // children?: ReactChild; diff --git a/src/search/CompactSearchField.tsx b/src/search/CompactSearchField.tsx new file mode 100644 index 00000000..3c5212e5 --- /dev/null +++ b/src/search/CompactSearchField.tsx @@ -0,0 +1,99 @@ +import React, { useState, useEffect } from 'react'; +import { css } from '@emotion/core'; +import { Icon, SearchOutline, LoadingOutline } from '../icon'; +import { Button } from '../button'; +import { TextField, TextFieldRef, TextFieldProps } from '../textfield'; +import { classNames } from '../utils'; +import theme from '../theme'; + +export interface CompactSearchFieldProps + extends Omit { + /** + * Whether or not there is a search in-flight + * @default false + */ + isSearching?: boolean; +} + +export function CompactSearchField(props: CompactSearchFieldProps) { + const { isSearching = false, onFocus, onBlur } = props; + const inputRef = React.useRef(null); + const [isExpanded, setIsExpanded] = useState(false); + const [isActive, setIsActive] = useState(false); + + // Focus the input when the search button is clicked + useEffect(() => { + if (isExpanded) { + inputRef.current?.focus(); + } + }, [isExpanded]); + + return ( +
.ac-button { + flex: none; + border: none; + border-radius: 0; + } + & .ac-textfield { + border-radius: 0 ${theme.rounding.rounding4}px + ${theme.rounding.rounding4}px 0; + // The button padding provides enough space + & > input { + padding-left: 0; + } + min-width: 0; + transition: width 0.2s ease-in-out; + } + &:not(.is-expanded) { + .ac-textfield { + width: 0; + visibility: none; + } + } + &.is-expanded { + .ac-textfield { + width: 200px; + } + } + &.is-active { + border-color: ${theme.components.textField.activeBorderColor}; + } + `} + className={classNames('ac-compact-search-field', { + 'is-expanded': isExpanded, + 'is-active': isActive, + })} + > + + { + setIsActive(true); + onFocus && onFocus(e); + }} + onBlur={e => { + setIsActive(false); + onBlur && onBlur(e); + }} + /> +
+ ); +} diff --git a/src/search/index.ts b/src/search/index.ts new file mode 100644 index 00000000..049da177 --- /dev/null +++ b/src/search/index.ts @@ -0,0 +1 @@ +export * from './CompactSearchField'; diff --git a/src/textfield/TextField.tsx b/src/textfield/TextField.tsx index 679348b7..1bbc0044 100644 --- a/src/textfield/TextField.tsx +++ b/src/textfield/TextField.tsx @@ -10,7 +10,9 @@ import { useProviderProps } from '../provider'; export interface TextFieldProps extends AriaTextFieldProps, AddonableProps { className?: string; + variant?: 'default' | 'quiet'; } + function TextField(props: TextFieldProps, ref: RefObject) { // Call use provider props so the textfield can inherit from the provider // E.x. disabled, readOnly, etc. diff --git a/src/textfield/TextFieldBase.tsx b/src/textfield/TextFieldBase.tsx index 80e5184b..db8938c5 100644 --- a/src/textfield/TextFieldBase.tsx +++ b/src/textfield/TextFieldBase.tsx @@ -42,7 +42,7 @@ const appearKeyframes = keyframes` 100% { opacity: 1; } `; -export interface TextFieldProps +interface TextFieldProps extends InputBase, Validation, HelpTextProps, @@ -100,6 +100,7 @@ interface TextFieldBaseProps loadingIndicator?: ReactElement; isLoading?: boolean; className?: string; + variant?: 'default' | 'quiet'; } export interface TextFieldRef @@ -130,6 +131,7 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref) { loadingIndicator, addonBefore, className, + variant = 'default', } = props; let { hoverProps, isHovered } = useHover({ isDisabled }); let [isFocused, setIsFocused] = React.useState(false); @@ -173,22 +175,25 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref) { 'is-disabled': isDisabled, 'is-readonly': isReadOnly, })} + data-variant={variant} css={css` display: flex; flex-direction: row; align-items: center; min-width: 270px; - border: 1px solid ${theme.colors.lightGrayBorder}; - border-radius: ${theme.borderRadius.medium}px; background-color: ${theme.components.textField.backgroundColor}; transition: all 0.2s ease-in-out; overflow: hidden; font-size: ${theme.typography.sizes.medium.fontSize}px; - &.is-hovered[:not(.is-disabled)] { + &[data-variant='default'] { + border: 1px solid ${theme.colors.lightGrayBorder}; + border-radius: ${theme.borderRadius.medium}px; + } + &.is-hovered:not(.is-disabled)[data-variant='default'] { border: 1px solid ${theme.components.textField.hoverBorderColor}; background-color: ${theme.components.textField.activeBackgroundColor}; } - &.is-focused { + &.is-focused[data-variant='default'] { border: 1px solid ${theme.components.textField.activeBorderColor}; background-color: ${theme.components.textField.activeBackgroundColor}; } @@ -229,6 +234,14 @@ function TextFieldBase(props: TextFieldBaseProps, ref: Ref) { color: ${theme.colors.statusDanger}; } } + /* Style for type=search */ + input[type='search']::-webkit-search-cancel-button { + -webkit-appearance: none; + width: 16px; + height: 16px; + background-image: url("data:image/svg+xml;utf8,"); + cursor: pointer; + } `} > {addonBefore != null ? {addonBefore} : null} diff --git a/src/textfield/index.tsx b/src/textfield/index.tsx index 665fa3cb..7bb1fee1 100644 --- a/src/textfield/index.tsx +++ b/src/textfield/index.tsx @@ -1 +1,2 @@ export * from './TextField'; +export * from './TextFieldBase'; diff --git a/src/theme.ts b/src/theme.ts index b15578ed..760e9e97 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -162,7 +162,9 @@ const theme = { thin: 2, }, }, - + rounding: { + rounding4: 4, + }, animation: { global: { /** diff --git a/stories/CompactSearchField.stories.tsx b/stories/CompactSearchField.stories.tsx new file mode 100644 index 00000000..9b83033e --- /dev/null +++ b/stories/CompactSearchField.stories.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Meta, Story } from '@storybook/react'; +import { css } from '@emotion/core'; +import { + Provider, + CompactSearchField, + CompactSearchFieldProps, + Button, + Card, +} from '../src'; +import { Icon, SearchOutline, Settings } from '../src/icon'; + +const meta: Meta = { + title: 'CompactSearchField', + component: CompactSearchField, + parameters: { + controls: { expanded: true }, + }, +}; + +export default meta; + +const Template: Story = args => { + const [search, setSearch] = useState(''); + return ( + + * + * { + margin-left: 8px; + } + `} + > + + setSearch(e)} + /> + + } + > + {`Search for: ${search}`} + + + ); +}; + +// By passing using the Args format for exported stories, you can control the props for a component for reuse in a test +// https://storybook.js.org/docs/react/workflows/unit-testing +export const Default = Template.bind({});