diff --git a/.bundlewatchrc.json b/.bundlewatchrc.json index b6236abfb..eaa03c635 100644 --- a/.bundlewatchrc.json +++ b/.bundlewatchrc.json @@ -6,11 +6,11 @@ "files": [ { "path": "./packages/core/dist/castor.css", - "maxSize": "5KB" + "maxSize": "8KB" }, { "path": "./packages/*/prototype.js", - "maxSize": "60KB" + "maxSize": "70KB" } ] } diff --git a/.storybook/styles.scss b/.storybook/styles.scss index 6668a99df..1884fde4f 100644 --- a/.storybook/styles.scss +++ b/.storybook/styles.scss @@ -11,7 +11,7 @@ background-color: castor.color('background-main'); color: castor.color('content-main'); font-family: 'Helvetica Neue', Tahoma, sans-serif; - font-size: 16px; + font-size: 1rem; } // unfortunate hack to override background color of the Docs panel diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..6e6ed2d8c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,8 @@ +# Migration guides + +## 1.x to 2.x + +### Select native (CSS) - React not impacted + +- `.-empty` class on `.ods-select-native` should be now on `.ods-select` instead +- (NON-BREAKING) `.ods-select-native` no longer needs `.-borderless` class, only its parent `.ods-select` does diff --git a/docs/helpers/html.ts b/docs/helpers/html.ts index 021d63338..fa5afdd37 100644 --- a/docs/helpers/html.ts +++ b/docs/helpers/html.ts @@ -22,8 +22,8 @@ const content = (nodes: unknown[]) => nodes.filter(Boolean).join('\n'); function toAttribute([key, value]: [string, unknown]) { if (key === 'style' && typeof value === 'object') value = styleFor(value); - if (value === '' || value === true) return key; - if (!value) return ''; + if (value === true) return key; + if (value === false || value == null) return ''; return `${key}="${value}"`; } diff --git a/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Progress/All Combinations.snap.png b/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Progress/All Combinations.snap.png index 477aadd17..47389f5bc 100644 Binary files a/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Progress/All Combinations.snap.png and b/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Progress/All Combinations.snap.png differ diff --git a/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Select/All Combinations.snap.png b/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Select/All Combinations.snap.png index 62e38a19a..d1387f01b 100644 Binary files a/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Select/All Combinations.snap.png and b/e2e/.snapshots/visual-regression/all.e2e.ts/Core/Select/All Combinations.snap.png differ diff --git a/e2e/.snapshots/visual-regression/all.e2e.ts/React/Select/All Combinations.snap.png b/e2e/.snapshots/visual-regression/all.e2e.ts/React/Select/All Combinations.snap.png index bf04a9005..5d5ec1925 100644 Binary files a/e2e/.snapshots/visual-regression/all.e2e.ts/React/Select/All Combinations.snap.png and b/e2e/.snapshots/visual-regression/all.e2e.ts/React/Select/All Combinations.snap.png differ diff --git a/packages/core/src/components/popover/popover.scss b/packages/core/src/components/popover/popover.scss index 54f6b6dec..cbf6658e1 100644 --- a/packages/core/src/components/popover/popover.scss +++ b/packages/core/src/components/popover/popover.scss @@ -12,69 +12,87 @@ position: absolute; visibility: hidden; z-index: 9999999; + + &.-overlay { + /* stylelint-disable-next-line max-nesting-depth */ + &::before { + $inset: 0; + + background-color: helpers.color('background-overlay'); + bottom: $inset; + content: ''; + display: block; + left: $inset; + position: fixed; + right: $inset; + top: $inset; + visibility: visible; + } + + /* stylelint-disable-next-line max-nesting-depth */ + .ods-popover { + bottom: initial; + height: fit-content; + left: 50%; + max-height: 75%; + max-width: 75%; + position: fixed; + right: initial; + top: 50%; + transform: translate(-50%, -50%); + width: fit-content; + } + } } .ods-popover { height: fit-content; - max-width: helpers.space(50); + overflow: auto; + overscroll-behavior: contain; position: absolute; visibility: initial; width: max-content; // position ==========> - &.-top--center, - &.-top--start, - &.-top--end { + &:where(.-top--center, .-top--start, .-top--end) { @include _position(bottom); } - &.-bottom--center, - &.-bottom--start, - &.-bottom--end { + &:where(.-bottom--center, .-bottom--start, .-bottom--end) { @include _position(top); } - &.-left--center, - &.-left--start, - &.-left--end { + &:where(.-left--center, .-left--start, .-left--end) { @include _position(right); } - &.-right--center, - &.-right--start, - &.-right--end { + &:where(.-right--center, .-right--start, .-right--end) { @include _position(left); } // <========== position // alignment ==========> - &.-top--center, - &.-bottom--center { + &:where(.-top--center, .-bottom--center) { @include _center(X); } - &.-left--center, - &.-right--center { + &:where(.-left--center, .-right--center) { @include _center(Y); } - &.-top--start, - &.-bottom--start { + &:where(.-top--start, .-bottom--start) { left: 0; } - &.-left--start, - &.-right--start { + &:where(.-left--start, .-right--start) { top: 0; } - &.-top--end, - &.-bottom--end { + &:where(.-top--end, .-bottom--end) { right: 0; } - &.-left--end, - &.-right--end { + &:where(.-left--end, .-right--end) { bottom: 0; } // <========== alignment @@ -83,7 +101,7 @@ @mixin _position($side) { #{$side}: 100%; - margin-#{$side}: helpers.space(1); + margin-#{$side}: var(--popover-gap, helpers.space(0.125)); } @mixin _center($axis) { diff --git a/packages/core/src/components/select/select.scss b/packages/core/src/components/select/select.scss index 446e8923d..cf61a8232 100644 --- a/packages/core/src/components/select/select.scss +++ b/packages/core/src/components/select/select.scss @@ -1,9 +1,12 @@ +@use 'sass:map'; @use '../../helpers'; @use '../../mixins'; @use '../icon/icon'; +@use '../popover/popover'; @mixin Select() { @include icon.Icon(); + @include popover.Popover(); @if not mixins.includes('Select') { @include _Select(); @@ -12,69 +15,206 @@ @mixin _Select() { .ods-select { - align-items: center; + box-sizing: border-box; display: inline-flex; + flex-direction: column; + justify-content: center; + max-width: helpers.space(50); position: relative; - width: helpers.space(50); + width: 100%; + + > * { + box-sizing: inherit; + } &.-borderless { width: fit-content; } + + &.-empty > * { + color: helpers.color('content-placeholder'); + } + + > .ods-icon { + transition: transform helpers.time(1) ease; + } + + &.-open > .ods-icon { + transform: rotateX(180deg); + } } .ods-select-native { @include mixins.as-text-field(); @include mixins.interactive(); + @include display(); @include with-padding(1px); // border width set on `as-text-field` mixin appearance: none; // removes native arrow that opens the select - &.-empty { - color: helpers.color('content-placeholder'); + &.-absolute { + position: absolute; } - + .ods-icon { - color: helpers.color('content-main'); + ~ .ods-icon { pointer-events: none; position: absolute; right: helpers.space(2); } - &.-invalid, - &.-touched:invalid, - &.-invalid + .ods-icon, - &.-touched:invalid + .ods-icon { + &.-invalid ~ *, + &.-touched:invalid ~ * { color: helpers.color('content-negative'); } &:disabled { &, - + .ods-icon { + ~ * { color: helpers.color('content-disabled'); } } + } - &.-borderless { - @include mixins.with-inner-focus('action'); - @include with-padding(); // no border width because border will be unset + :where(.ods-select.-borderless) > .ods-select-native { + @include mixins.with-inner-focus('action'); + @include with-padding(); // no border width because border will be unset - background-color: transparent; - border: 0; + background-color: transparent; + border: 0; + + &:hover { + background-color: helpers.color('background-action-subtle-hover'); + } + + &.-invalid, + &.-touched:invalid { + @include mixins.with-inner-focus('negative'); &:hover { - background-color: helpers.color('background-action-subtle-hover'); + background-color: helpers.color('background-negative-subtle-hover'); + } + } + + &:disabled, + &:disabled:hover { + background-color: helpers.color('background-disabled'); + } + } + + .ods-select-output { + @include helpers.font('300-regular'); + @include display(); + @include with-padding(); + pointer-events: none; + } + + .ods-select-dropdown { + --popover-gap: #{helpers.space(0.125)}; + + background-color: helpers.color('border-focus-inner'); + border: helpers.space(0.125) solid helpers.color('border-input'); + border-radius: helpers.border-radius('medium'); + box-shadow: 0 helpers.space(0.25) helpers.space(0.5) rgb(0 0 0 / 0.25); + box-sizing: border-box; + display: flex; + flex-direction: column; + max-height: helpers.space(30); + max-width: helpers.space(50); + padding: helpers.space(0.5) 0; + pointer-events: initial; + width: 100%; + + &:where(.-borderless) { + width: max-content; + } + + > * { + box-sizing: inherit; + } + } + + .ods-select-option { + @include helpers.font('300-regular'); + @include mixins.interactive(); + + align-items: center; + color: helpers.color('content-main'); + display: grid; + position: relative; + text-align: left; + } + + .ods-select-option-input { + height: 0; + margin: 0; + opacity: 0; + position: absolute; + width: 0; + + &:active, + &:focus { + + .ods-select-option-content { + @include mixins.inner-focus('action'); } + } - &:disabled { + &:checked { + + .ods-select-option-content { + background-color: helpers.color('background-action-subtle'); + } + + ~ .ods-select-option-icon { + opacity: 1; + } + } + + &:disabled { + + .ods-select-option-content { background-color: helpers.color('background-disabled'); + box-shadow: initial; + color: helpers.color('content-disabled'); + cursor: not-allowed; } } + } - &.-borderless.-invalid, - &.-borderless.-touched:invalid { - @include mixins.with-inner-focus('negative'); + .ods-select-option-content { + @include with-padding(); + background-color: helpers.color('background-input'); + display: flex; + position: relative; + transition: background-color ease helpers.time(1); + + &:hover { + background-color: helpers.color('background-action-subtle-hover'); } } + + .ods-select-option-icon { + color: helpers.color('content-action'); + opacity: 0; + position: absolute; + right: helpers.space(1.5); + transition: opacity ease helpers.time(1); + } + + .ods-select-option-group { + @include helpers.font('200-allcaps'); + + background-color: helpers.color('background-main'); + border: 0 solid helpers.color('border-separator'); + border-width: helpers.space(0.125) 0; + color: helpers.color('content-placeholder'); + padding: helpers.space(0.5) helpers.space(2); + text-transform: uppercase; + } +} + +@mixin display() { + overflow: hidden; + position: relative; + text-overflow: ellipsis; + white-space: nowrap; } @mixin with-padding($border-width: 0) { @@ -86,5 +226,5 @@ $padding-vertical: helpers.space(1.5) - $border-width; padding: $padding-vertical $padding-horizontal; - padding-right: $padding-vertical + helpers.space(3.5); // extra space for icon + padding-right: $padding-horizontal + helpers.space(4); // extra space for icon } diff --git a/packages/core/src/components/select/select.stories.ts b/packages/core/src/components/select/select.stories.ts index f30b9cca9..3d50ba1d3 100644 --- a/packages/core/src/components/select/select.stories.ts +++ b/packages/core/src/components/select/select.stories.ts @@ -21,13 +21,27 @@ export default { ].join('\n\n'), control: false, }, + borderless: { + table: { type: { summary: 'boolean' } }, + }, + disabled: { + table: { type: { summary: 'boolean' } }, + }, + invalid: { + table: { type: { summary: 'boolean' } }, + }, }, args: { children: [ - html('option', { children: 'Select an option...', selected: true }), - html('option', { children: 'Option 1' }), - html('option', { children: 'Option 2' }), - html('option', { children: 'Option 3' }), + html('option', { + children: 'Select an option...', + disabled: true, + selected: true, + value: '', + }), + html('option', { children: 'Option 1', value: 1 }), + html('option', { children: 'Option 2', value: 2 }), + html('option', { children: 'Option 3', value: 3 }), ], borderless: false, disabled: false, @@ -55,21 +69,23 @@ AsRequired.args = { children: 'Select an option...', disabled: true, selected: true, + value: '', }), - html('option', { children: 'Option 1' }), - html('option', { children: 'Option 2' }), - html('option', { children: 'Option 3' }), + html('option', { children: 'Option 1', value: 1 }), + html('option', { children: 'Option 2', value: 2 }), + html('option', { children: 'Option 3', value: 3 }), ], }; export const WithEmptyModifier: Story = (props) => - Select({ ...props, class: classy(m({ empty: true })) }); + Select({ ...props, class: classy(m('empty')) }); WithEmptyModifier.args = { children: [ html('option', { children: 'Select an option...', disabled: true, selected: true, + value: '', }), ], }; @@ -105,7 +121,12 @@ export const AllCombinations = htmlMatrix( { borderless, disabled, invalid }, (props) => Select({ ...props, children: children(props) }) ); -AllCombinations.argTypes = omit('children'); +AllCombinations.argTypes = omit( + 'children', + 'borderless', + 'disabled', + 'invalid' +); AllCombinations.args = { children: null, }; @@ -114,13 +135,17 @@ AllCombinations.parameters = { columns: 'repeat(2, 1fr)', }; -const children = ({ borderless, disabled, invalid }: SelectProps) => [ - html('option', { - children: [ - invalid ? 'invalid' : 'valid', - borderless ? 'borderless' : '', - disabled ? 'disabled' : '', - ].join(' '), - selected: true, - }), -]; +const children = ({ borderless, disabled, invalid }: SelectProps) => { + const variation = [ + invalid ? 'invalid' : 'valid', + borderless ? 'borderless' : '', + disabled ? 'disabled' : '', + ]; + return [ + html('option', { + children: variation.join(' '), + selected: true, + value: variation.join('-'), + }), + ]; +}; diff --git a/packages/core/src/components/select/select.story.tsx b/packages/core/src/components/select/select.story.tsx index f9b3dcfcd..204d4c11e 100644 --- a/packages/core/src/components/select/select.story.tsx +++ b/packages/core/src/components/select/select.story.tsx @@ -29,11 +29,7 @@ export const Select = ({ children: [ html('select', { ...props, - class: classy( - c('select-native'), - m({ borderless, invalid }), - className - ), + class: classy(c('select-native'), m({ invalid }), className), children, id, }), diff --git a/packages/core/src/components/tooltip/tooltip.scss b/packages/core/src/components/tooltip/tooltip.scss index 7fe4185c9..e15f86f3d 100755 --- a/packages/core/src/components/tooltip/tooltip.scss +++ b/packages/core/src/components/tooltip/tooltip.scss @@ -13,6 +13,9 @@ @mixin _Tooltip() { .ods-tooltip { @include helpers.font('200-regular'); + + --popover-gap: #{helpers.space(1)}; + background-color: helpers.color('background-inverse-surface'); border-radius: helpers.border-radius('medium'); color: helpers.color('content-inverse-main'); diff --git a/packages/core/src/mixins/as-text-field.scss b/packages/core/src/mixins/as-text-field.scss index e8cc807e9..fb3172d90 100644 --- a/packages/core/src/mixins/as-text-field.scss +++ b/packages/core/src/mixins/as-text-field.scss @@ -11,6 +11,7 @@ border-radius: helpers.border-radius('medium'); box-sizing: border-box; color: helpers.color('content-main'); + opacity: 1; padding: helpers.space(1.5) - $border-width helpers.space(2) - $border-width; width: 100%; diff --git a/packages/core/src/mixins/with-inner-focus.scss b/packages/core/src/mixins/with-inner-focus.scss index 805554e8f..d27f25906 100644 --- a/packages/core/src/mixins/with-inner-focus.scss +++ b/packages/core/src/mixins/with-inner-focus.scss @@ -3,40 +3,39 @@ @use './wrap-if' as mixins; $_allowed-types: ('action', 'negative'); +$_size: helpers.space(0.5); +$_inner-size: helpers.space(0.125); -$_spread: 4px; -$_inner-size: 1px; -$_color-size: $_spread - $_inner-size; - -$_max-border-width: $_color-size; // when max, border used instead of color line - -@mixin with-inner-focus($type, $border-width: 0, $target: null) { +@mixin inner-focus($type, $border-width: 0) { @if not list.index($_allowed-types, $type) { @error '$type must be one of (#{$_allowed-types})'; } - @if type-of($border-width) != number { @error '$border-width must be a number'; } - @if $border-width < 0 or $border-width > $_max-border-width { - @error '$border-width must be between 0 and #{$_max-border-width}'; + @if $border-width < 0 or $border-width > $_size { + @error '$border-width must be between 0 and #{$_size}'; } - $inner-spread: $_spread - $border-width; - $color-spread: $inner-spread - $_inner-size; + $spread: $_size - $border-width; + $outer-spread: $spread - $_inner-size; + $color: helpers.color('border-#{$type}-focus'); - $inner-line: inset 0 0 0 $inner-spread helpers.color('border-focus-inner'); - $color-line: inset 0 0 0 $color-spread helpers.color('border-#{$type}-focus'); + @if $border-width > 0 { + border-color: $color; + } + box-shadow: // + inset 0 0 0 $outer-spread $color, + inset 0 0 0 $spread helpers.color('border-focus-inner'); +} + +@mixin with-inner-focus($type, $border-width: 0, $target: null) { @include mixins.wrap-if($target) { outline: none; } &:focus #{$target} { - box-shadow: $color-line, $inner-line; - - @if $border-width > 0 { - border-color: helpers.color('border-#{$type}-focus'); - } + @include inner-focus($type, $border-width); } } diff --git a/packages/core/src/mixins/with-outer-focus.scss b/packages/core/src/mixins/with-outer-focus.scss index 9f1a06718..f898ee447 100644 --- a/packages/core/src/mixins/with-outer-focus.scss +++ b/packages/core/src/mixins/with-outer-focus.scss @@ -3,24 +3,21 @@ @use './wrap-if' as mixins; $_allowed-types: ('action', 'negative'); - -$_color-spread: 4px; -$_color-size: 2px; -$_inner-spread: $_color-spread - $_color-size; +$_size: helpers.space(0.5); +$_color-size: $_size - helpers.space(0.25); @mixin with-outer-focus($type, $target: null) { @if not list.index($_allowed-types, $type) { @error '$type must be one of (#{$_allowed-types})'; } - $color-line: 0 0 0 $_color-spread helpers.color('border-#{$type}-focus'); - $inner-line: 0 0 0 $_inner-spread helpers.color('border-focus-inner'); - @include mixins.wrap-if($target) { outline: none; } &:focus #{$target} { - box-shadow: $inner-line, $color-line; + box-shadow: // + 0 0 0 $_color-size helpers.color('border-focus-inner'), + 0 0 0 $_size helpers.color('border-#{$type}-focus'); } } diff --git a/packages/react/src/components/field-label/field-label.stories.tsx b/packages/react/src/components/field-label/field-label.stories.tsx index fd6738a8f..9f70dd44d 100644 --- a/packages/react/src/components/field-label/field-label.stories.tsx +++ b/packages/react/src/components/field-label/field-label.stories.tsx @@ -6,6 +6,7 @@ import { FieldLabelProps, HelperText, Input, + Option, Select, Textarea, } from '@onfido/castor-react'; @@ -106,11 +107,11 @@ export const WithSelect: Story = ({ {label} - + + + + ); diff --git a/packages/react/src/components/form/form.stories.tsx b/packages/react/src/components/form/form.stories.tsx index 6ad0415c3..7472dc6af 100644 --- a/packages/react/src/components/form/form.stories.tsx +++ b/packages/react/src/components/form/form.stories.tsx @@ -97,8 +97,8 @@ export const Playground: Story> = (props) => ( Gender - + diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 433f613ff..0a17e9d88 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -13,6 +13,8 @@ export * from './popover/popover'; export * from './progress/progress'; export * from './radio/radio'; export * from './search/search'; +export * from './select/option'; +export * from './select/option-group'; export * from './select/select'; export * from './spinner/spinner'; export * from './textarea/textarea'; diff --git a/packages/react/src/components/popover/popover-base.tsx b/packages/react/src/components/popover/popover-base.tsx new file mode 100644 index 000000000..4584928d0 --- /dev/null +++ b/packages/react/src/components/popover/popover-base.tsx @@ -0,0 +1,23 @@ +import { c, classy, m, PopoverProps as BaseProps } from '@onfido/castor'; +import React from 'react'; +import { withRef } from '../../utils'; + +export const PopoverBase = withRef(function Popover( + { + align, + className, + position, + ...props + }: BaseProps & Div & Required>, + ref?: Div['ref'] +) { + return ( +
+ ); +}); + +type Div = JSX.IntrinsicElements['div']; diff --git a/packages/react/src/components/popover/popover-props.ts b/packages/react/src/components/popover/popover-props.ts new file mode 100644 index 000000000..b6e02ca8c --- /dev/null +++ b/packages/react/src/components/popover/popover-props.ts @@ -0,0 +1,31 @@ +import { PopoverProps as BaseProps } from '@onfido/castor'; +import { ReactNode, RefObject } from 'react'; + +export interface PopoverProps extends BaseProps, Omit { + children?: ReactNode; + /** + * Whether the Popover should paint an overlay and centralize when the + * screen is too small. + */ + overlay?: boolean; + /** + * An event that notifies the intention to close the Popover, usually from an + * outside click. + * + * Only triggers when target is specified. + */ + onClose?: () => void; + /** + * An event that notifies the initial rendering of the Popover has completed, + * provides the Popover element as argument. + */ + onRender?: (popover: HTMLDivElement | null) => void; + /** + * Ref to an element which the Popover should target for placement. + * + * Will Portal the Popover into `document.body`. + */ + target?: RefObject; +} + +type Div = JSX.IntrinsicElements['div']; diff --git a/packages/react/src/components/popover/popover-with-portal.tsx b/packages/react/src/components/popover/popover-with-portal.tsx new file mode 100644 index 000000000..840481efa --- /dev/null +++ b/packages/react/src/components/popover/popover-with-portal.tsx @@ -0,0 +1,66 @@ +import { c, classy, m } from '@onfido/castor'; +import React, { ReactNode, RefObject, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + useIntersectionObserver, + useNoScroll, + useOnClickOutside, + useResizeObserver, +} from '../../utils'; +import { PopoverBase } from './popover-base'; +import type { PopoverProps } from './popover-props'; + +export function PopoverWithPortal({ + onClose, + overlay: allowOverlay, + popover, + target, + ...props +}: PopoverProps & { popover: RefObject } & Required< + Pick + >) { + const domBody = useRef(document.body); + const [anchor, setAnchor] = useState(at(target)); + + useOnClickOutside(onClose, [target, popover]); + + useResizeObserver(() => setAnchor(at(target)), [domBody]); + + useIntersectionObserver(() => setAnchor(at(target)), [target]); + + const overlay = allowOverlay && screen.width < breakpoint('small'); + useNoScroll(overlay); + + return ( + +
+ +
+
+ ); +} + +const Portal = ({ children }: { children: ReactNode }) => + createPortal(children, document.body); + +function at(anchor: RefObject) { + if (!anchor.current) return; + + const anchorRect = anchor.current.getBoundingClientRect(); + let { left, top } = anchorRect; + const { height, width } = anchorRect; + + if (document.scrollingElement) { + const scroll = document.scrollingElement.getBoundingClientRect(); + + if (scroll.top < 0) top -= scroll.top; + if (scroll.left < 0) left -= scroll.left; + } + + return { left, top, height, width }; +} + +const breakpoint = (_name: 'small') => 600; diff --git a/packages/react/src/components/popover/popover.stories.tsx b/packages/react/src/components/popover/popover.stories.tsx index c96cd95b6..310819db9 100644 --- a/packages/react/src/components/popover/popover.stories.tsx +++ b/packages/react/src/components/popover/popover.stories.tsx @@ -15,6 +15,18 @@ export default { title: 'React/Popover', component: Popover, argTypes: { + show: { + description: 'Show or hide the Popover. This example uses HTML presence.', + name: '[story only] show', + table: { control: 'boolean' }, + }, + withPortal: { + description: [ + 'Toggle between an example with and without `target` ref.', + 'Specific to `react-dom`.', + ].join('\n\n'), + name: '[Story only] with portal', + }, align: { control: { type: 'inline-radio', options: align }, description: 'Preferred value. Will readjust to avoid screen clipping.', @@ -24,19 +36,9 @@ export default { }, }, children: { table: { type: { summary: 'ReactNode' } } }, - onClose: { - description: [ - 'When `target` is specified, this event will notify of clicks outside', - 'the Popover, which have the intention to close/hide it.', - ].join(' '), - }, - target: { - control: { disable: true }, - description: [ - 'A React `ref` of the element to use for placement.', - 'If specified will use a React Portal to avoid overflow issues.', - ].join('\n\n'), - }, + onClose: {}, + onRender: {}, + overlay: { control: { disable: true } }, position: { control: { type: 'inline-radio', options: position }, description: 'Preferred value. Will readjust to avoid screen clipping.', @@ -45,14 +47,12 @@ export default { type: { summary: optionsToSummary(position) }, }, }, - show: { - description: 'Show or hide the Popover. This example uses HTML presence.', - name: '[story only] show', - table: { control: 'boolean' }, - }, - withPortal: { - description: 'Toggle between an example with and without `target` ref.', - name: '[story only] with portal', + target: { + control: { disable: true }, + description: [ + 'A React `ref` of the element to use for placement.', + 'If specified will use a React Portal to avoid overflow issues.', + ].join('\n\n'), }, }, args: { diff --git a/packages/react/src/components/popover/popover.tsx b/packages/react/src/components/popover/popover.tsx index 05e339114..acd44d520 100644 --- a/packages/react/src/components/popover/popover.tsx +++ b/packages/react/src/components/popover/popover.tsx @@ -1,34 +1,17 @@ -import { - Alignment, - c, - classy, - m, - PopoverProps as BaseProps, - Position, -} from '@onfido/castor'; -import React, { ReactNode, RefObject, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { - useIntersectionObserver, - useOnClickOutside, - useResizeObserver, - withRef, -} from '../../utils'; - -export interface PopoverProps extends BaseProps, Omit { - children?: ReactNode; - onClose?: () => void; - /** - * Ref to an element which the Popover should target for placement. - * - * Will Portal the Popover into `document.body`. - */ - target?: RefObject; -} +import { Alignment, Position } from '@onfido/castor'; +import React, { useEffect, useRef, useState } from 'react'; +import { useIntersectionObserver } from '../../utils'; +import { PopoverBase } from './popover-base'; +import type { PopoverProps } from './popover-props'; +import { PopoverWithPortal } from './popover-with-portal'; + +export { PopoverProps }; export function Popover({ align = 'center', onClose, + onRender, + overlay, position = 'top', target, ...props @@ -36,6 +19,8 @@ export function Popover({ const popover = useRef(null); const [placement, setPlacement] = useState([position, align] as const); + useEffect(() => onRender?.(popover.current), []); + useIntersectionObserver( (entry) => setPlacement((placement) => optimalPlacement(entry, placement)), [popover] @@ -47,6 +32,7 @@ export function Popover({ >, - ref?: Div['ref'] -) { - return ( -
- ); -}); - -function PopoverWithPortal({ - onClose, - popover, - target, - ...props -}: PopoverProps & { popover: RefObject } & Required< - Pick - >) { - const domBody = useRef(document.body); - const [anchor, setAnchor] = useState(at(target)); - - useOnClickOutside(onClose, [target, popover]); - - useResizeObserver(() => setAnchor(at(target)), [domBody]); - - useIntersectionObserver(() => setAnchor(at(target)), [target]); - - return ( - -
- -
-
- ); -} - -const Portal = ({ children }: { children: ReactNode }) => - createPortal(children, document.body); - -function at(anchor: RefObject) { - if (!anchor.current) return; - - const anchorRect = anchor.current.getBoundingClientRect(); - let { left, top } = anchorRect; - const { height, width } = anchorRect; - - if (document.scrollingElement) { - const scroll = document.scrollingElement.getBoundingClientRect(); - - if (scroll.top < 0) top -= scroll.top; - if (scroll.left < 0) left -= scroll.left; - } - - return { left, top, height, width }; -} - function optimalPlacement( entry: IntersectionObserverEntry, currentPlacement: readonly [Position, Alignment] @@ -203,5 +125,3 @@ const align = { bottom: sides.end, right: sides.end, } as const; - -type Div = JSX.IntrinsicElements['div']; diff --git a/packages/react/src/components/select/custom/custom-option-group.tsx b/packages/react/src/components/select/custom/custom-option-group.tsx new file mode 100644 index 000000000..d51121e84 --- /dev/null +++ b/packages/react/src/components/select/custom/custom-option-group.tsx @@ -0,0 +1,22 @@ +import { c, classy } from '@onfido/castor'; +import React from 'react'; + +export interface CustomOptionGroupProps extends JsxDiv { + label: string; +} + +export const CustomOptionGroup = ({ + children, + className, + label, + ...restProps +}: CustomOptionGroupProps) => ( + <> +
+ {label} +
+ {children} + +); + +type JsxDiv = JSX.IntrinsicElements['div']; diff --git a/packages/react/src/components/select/custom/custom-option.tsx b/packages/react/src/components/select/custom/custom-option.tsx new file mode 100644 index 000000000..52457a9df --- /dev/null +++ b/packages/react/src/components/select/custom/custom-option.tsx @@ -0,0 +1,67 @@ +import { c, classy } from '@onfido/castor'; +import { Icon } from '@onfido/castor-react'; +import React, { ReactNode, useEffect } from 'react'; +import { useCustomSelect } from './useCustomSelect'; + +export interface CustomOptionProps extends JsxLabel { + children?: ReactNode; + disabled?: boolean; + value: string | number | readonly string[]; +} + +export function CustomOption({ + children, + className, + disabled, + value: optionValue, + onClick, + onKeyUp, + ...restProps +}: CustomOptionProps) { + const { initialize, name, select, value } = useCustomSelect(); + + useEffect(() => initialize(children, optionValue), []); + + const selectOption = () => disabled || select(children, optionValue); + + const selected = value == optionValue; + + return ( + + ); +} + +const selectOptionKeys = new Set([' ', 'Enter']); + +type JsxLabel = JSX.IntrinsicElements['label']; diff --git a/packages/react/src/components/select/custom/custom-select.tsx b/packages/react/src/components/select/custom/custom-select.tsx new file mode 100644 index 000000000..a749676e4 --- /dev/null +++ b/packages/react/src/components/select/custom/custom-select.tsx @@ -0,0 +1,147 @@ +import { c, classy, m, PopoverProps, SelectProps } from '@onfido/castor'; +import React, { + ReactNode, + SyntheticEvent, + useMemo, + useRef, + useState, +} from 'react'; +import { Popover } from '../../popover/popover'; +import { NativeSelect, NativeSelectProps } from '../native'; +import { CustomSelectProvider } from './useCustomSelect'; + +export interface CustomSelectProps + extends SelectProps, + NativeSelectProps, + PopoverProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + onSelectOption?: (value: string | number | readonly string[]) => void; +} + +export function CustomSelect({ + align = 'start', + borderless, + children, + className, + defaultValue, + name: initialName, + open: isOpen, + position = 'bottom', + onBlur, + onClick, + onKeyUp, + onOpenChange, + onSelectOption, + ...restProps +}: CustomSelectProps) { + const selectRef = useRef(null); + const [selectedOption, setSelectedOption] = useState(); + const [value, setValue] = useState(restProps.value ?? defaultValue); + + const name = useMemo( + () => initialName || `castor-select-${++id}`, + [initialName] + ); + + const open = () => onOpenChange?.(true); + + const close = () => { + onOpenChange?.(false); + focus(selectRef.current); + }; + + return ( + current ?? option); + }, + select(option, value) { + setSelectedOption(option); + setValue(value); + close(); + onSelectOption?.(value); + }, + }} + > + { + // if the Popover is open, focus is still inside Select + // we don't want Field/Form validation to trigger + if (isOpen) event.stopPropagation(); + onBlur?.(event); + }} + onClick={(event) => { + isOpen ? close() : open(); + onClick?.(event); + }} + onKeyUp={(event) => { + if (openSelectKeys.has(event.key)) open(); + onKeyUp?.(event); + }} + > + {!value || + + {selectedOption} + + {isOpen && ( + { + if (closeSelectKeys.has(event.key)) close(); + }} + onKeyDown={(event) => { + // close if focus moves outside + if (event.key === 'Tab') { + setTimeout(close); + event.preventDefault(); + } + }} + onRender={(element) => + // focus :checked option if :enabled, otherwise first :enabled + focus( + element?.querySelector(':checked:enabled') ?? + element?.querySelector('input:enabled') + ) + } + // stop bubbling so that Field validation isn't affected + onBlur={stopPropagation} + onChange={stopPropagation} + onInvalid={stopPropagation} + > + {children} + + )} + + {/* render once to find output */} + {!selectedOption &&
{children}
} +
+ ); +} + +const closeSelectKeys = new Set(['Escape']); +const openSelectKeys = new Set([' ', 'ArrowDown', 'ArrowUp']); + +const focus = (element: HTMLElement | null | undefined) => + element?.focus({ preventScroll: true }); + +const stopPropagation = (event: SyntheticEvent) => event.stopPropagation(); + +let id = 0; diff --git a/packages/react/src/components/select/custom/index.ts b/packages/react/src/components/select/custom/index.ts new file mode 100644 index 000000000..fe98adbd5 --- /dev/null +++ b/packages/react/src/components/select/custom/index.ts @@ -0,0 +1,3 @@ +export * from './custom-option'; +export * from './custom-option-group'; +export * from './custom-select'; diff --git a/packages/react/src/components/select/custom/useCustomSelect.spec.ts b/packages/react/src/components/select/custom/useCustomSelect.spec.ts new file mode 100644 index 000000000..d0a2f633f --- /dev/null +++ b/packages/react/src/components/select/custom/useCustomSelect.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from '@jest/globals'; +import { useContext } from 'react'; +import { CustomSelectProvider, useCustomSelect } from './useCustomSelect'; + +jest.mock('react', () => { + const context = { Provider: {} }; + return { + createContext: () => context, + useContext: () => context, + }; +}); + +describe('CustomSelectProvider', () => { + it('should be the context.Provider', () => { + const context = useContext(null as any); + + expect(CustomSelectProvider).toBe(context.Provider); + }); +}); + +describe('useCustomSelect', () => { + it('should simply wrap useContext', () => { + const context = useContext(null as any); + + const result = useCustomSelect(); + + expect(result).toBe(context); + }); +}); diff --git a/packages/react/src/components/select/custom/useCustomSelect.ts b/packages/react/src/components/select/custom/useCustomSelect.ts new file mode 100644 index 000000000..df1ef4ac4 --- /dev/null +++ b/packages/react/src/components/select/custom/useCustomSelect.ts @@ -0,0 +1,17 @@ +import { createContext, ReactNode, useContext } from 'react'; + +const CustomSelectContext = createContext({} as CustomSelectState); + +export const useCustomSelect = () => useContext(CustomSelectContext); + +export const CustomSelectProvider = CustomSelectContext.Provider; + +export interface CustomSelectState { + initialize: (option: ReactNode, value: Value) => void; + name?: string; + select: (option: ReactNode, value: Value) => void; + selectedOption?: ReactNode; + value?: Value; +} + +type Value = string | number | readonly string[]; diff --git a/packages/react/src/components/select/native/index.ts b/packages/react/src/components/select/native/index.ts new file mode 100644 index 000000000..cb486e592 --- /dev/null +++ b/packages/react/src/components/select/native/index.ts @@ -0,0 +1,3 @@ +export * from './native-option'; +export * from './native-option-group'; +export * from './native-select'; diff --git a/packages/react/src/components/select/native/native-option-group.tsx b/packages/react/src/components/select/native/native-option-group.tsx new file mode 100644 index 000000000..1b1220485 --- /dev/null +++ b/packages/react/src/components/select/native/native-option-group.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export interface NativeOptionGroupProps extends JsxOptGroup { + label: string; +} + +export const NativeOptionGroup = (props: NativeOptionGroupProps) => ( + +); + +type JsxOptGroup = JSX.IntrinsicElements['optgroup']; diff --git a/packages/react/src/components/select/native/native-option.tsx b/packages/react/src/components/select/native/native-option.tsx new file mode 100644 index 000000000..d15549be5 --- /dev/null +++ b/packages/react/src/components/select/native/native-option.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export type NativeOptionProps = JSX.IntrinsicElements['option']; + +export const NativeOption = (props: NativeOptionProps) =>