diff --git a/packages/dnb-eufemia-sandbox/stories/components/GlobalStatus.js b/packages/dnb-eufemia-sandbox/stories/components/GlobalStatus.js index fcd19d2d2b5..10522f3b7e4 100644 --- a/packages/dnb-eufemia-sandbox/stories/components/GlobalStatus.js +++ b/packages/dnb-eufemia-sandbox/stories/components/GlobalStatus.js @@ -18,6 +18,7 @@ import { FormRow, FormSet, Autocomplete, + DatePicker, } from '@dnb/eufemia/src/components' import { H2, @@ -63,6 +64,11 @@ export const ComponentAsLabel = () => { label={} status={status ? status + '3' : undefined} /> + } + show_input + status={status ? status + '4' : undefined} + /> ) diff --git a/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js b/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js index 11cb6bfd776..0e70938dd50 100644 --- a/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js +++ b/packages/dnb-eufemia/src/components/autocomplete/Autocomplete.js @@ -1839,7 +1839,7 @@ class AutocompleteInstance extends React.PureComponent { icon_size || (size === 'large' ? 'medium' : 'default') } size={size} - status={!opened && status ? status_state : null} + status={status ? status_state : null} status_state={status_state} type={null} submit_element={submitButton} diff --git a/packages/dnb-eufemia/src/components/autocomplete/__tests__/__snapshots__/Autocomplete.test.js.snap b/packages/dnb-eufemia/src/components/autocomplete/__tests__/__snapshots__/Autocomplete.test.js.snap index 1aeb60e01e1..124cbfa5dc8 100644 --- a/packages/dnb-eufemia/src/components/autocomplete/__tests__/__snapshots__/Autocomplete.test.js.snap +++ b/packages/dnb-eufemia/src/components/autocomplete/__tests__/__snapshots__/Autocomplete.test.js.snap @@ -474,7 +474,7 @@ exports[`Autocomplete markup have to match snapshot 1`] = ` size={null} skeleton="skeleton" spellCheck="false" - status={null} + status="error" status_no_animation={null} status_props={null} status_state="error" @@ -506,7 +506,7 @@ exports[`Autocomplete markup have to match snapshot 1`] = ` value="CC cc" > @@ -532,13 +532,13 @@ exports[`Autocomplete markup have to match snapshot 1`] = ` label={null} no_animation={null} role={null} - show={null} + show={false} size="default" skeleton="skeleton" state="error" status="error" stretch={null} - text={null} + text="error" text_id="autocomplete-id-status" title={null} variant={null} diff --git a/packages/dnb-eufemia/src/components/form-status/FormStatus.js b/packages/dnb-eufemia/src/components/form-status/FormStatus.js index 11ee51b0778..03df367e7de 100644 --- a/packages/dnb-eufemia/src/components/form-status/FormStatus.js +++ b/packages/dnb-eufemia/src/components/form-status/FormStatus.js @@ -179,14 +179,17 @@ export default class FormStatus extends React.PureComponent { state = { id: null, keepContentInDom: null } - constructor(props) { + constructor(props, context) { super(props) // we do not use a random ID here, as we don't need it for now this.state.id = props.id || makeUniqueId() this._globalStatus = GlobalStatusProvider.init( - props.global_status_id || 'main', + props?.global_status_id || + context?.FormStatus?.global_status_id || + context?.FormRow?.global_status_id || + 'main', (provider) => { // gets called once ready if (this.props.state === 'error' && this.isReadyToGetVisible()) { @@ -268,25 +271,30 @@ export default class FormStatus extends React.PureComponent { // ensure we update the content this.setState({ keepContentInDom: false }) - const status_id = this.getStatusId() - - if (state === 'error' && isTrue(show)) { - this._globalStatus.update( - status_id, - { - state, - // status_id, - item: { - status_id: this.state.id, - text, - status_anchor_label: label, - status_anchor_url: true, + if (state === 'error') { + const status_id = this.getStatusId() + + if (isTrue(show)) { + this._globalStatus.update( + status_id, + { + state, + status_id, + item: { + item_id: this.state.id, + text, + status_anchor_label: label, + status_anchor_url: true, + }, }, - }, - { - preventRestack: true, // because of the internal "close" - } - ) + { + preventRestack: true, // because of the internal "close" + } + ) + } else if (!FormStatus.getContent(this.props)) { + const status_id = this.getStatusId() + this._globalStatus.remove(status_id) + } } if (this.isReadyToGetVisible()) { @@ -295,10 +303,6 @@ export default class FormStatus extends React.PureComponent { this._heightAnim.open() } else { this._heightAnim.close() - if (state === 'error') { - const status_id = this.getStatusId() - this._globalStatus.remove(status_id) - } } } } diff --git a/packages/dnb-eufemia/src/components/global-status/GlobalStatus.js b/packages/dnb-eufemia/src/components/global-status/GlobalStatus.js index 9732e82f104..7cb62b1d55c 100644 --- a/packages/dnb-eufemia/src/components/global-status/GlobalStatus.js +++ b/packages/dnb-eufemia/src/components/global-status/GlobalStatus.js @@ -280,6 +280,11 @@ export default class GlobalStatus extends React.PureComponent { this._globalStatus = globalStatus } + let height + if (this.state.keepContentVisible) { + height = this.anim.adjustFrom() + } + // force re-render this.setState({ globalStatus, @@ -297,7 +302,7 @@ export default class GlobalStatus extends React.PureComponent { // make sure to show the new status, inc. scroll if ( (isTrue(this.props.autoclose) && - this.hadContent && + this._hadContent && !this.hasContent(globalStatus) && !isTrue(this.props.show)) || (typeof globalStatus.show !== 'undefined' && @@ -309,8 +314,13 @@ export default class GlobalStatus extends React.PureComponent { (typeof globalStatus.show !== 'undefined' && isTrue(globalStatus.show)) ) { - this.hadContent = this.hasContent(globalStatus) - this.setVisible({ delay: 0 }) + this._hadContent = this.hasContent(globalStatus) + + if (this.state.keepContentVisible) { + this.anim.adjustTo(height) + } else { + this.setVisible({ delay: 0 }) + } } }) @@ -320,8 +330,7 @@ export default class GlobalStatus extends React.PureComponent { componentDidMount() { this.anim.setElement(this._shellRef.current) - const isActive = isTrue(this.props.show) - if (isActive) { + if (isTrue(this.props.show)) { this.setVisible() } } @@ -353,9 +362,9 @@ export default class GlobalStatus extends React.PureComponent { globalStatus, }) } + if (prevProps.show !== this.props.show) { - const isActive = isTrue(this.props.show) - if (isActive) { + if (isTrue(this.props.show)) { this.setVisible() } else { this.setHidden() @@ -364,7 +373,7 @@ export default class GlobalStatus extends React.PureComponent { } hasContent(globalStatus) { - return globalStatus.items?.length > 0 || globalStatus.text + return Boolean(globalStatus.items?.length > 0 || globalStatus.text) } correctStatus(state) { @@ -385,31 +394,10 @@ export default class GlobalStatus extends React.PureComponent { return // stop here } - const { isActive, initialOpened } = this.state - - if (isActive === true && initialOpened) { - if (!this.adjustHeight) { - this.adjustHeight = this.anim.adjustFrom() - } - - // just because we want to run "adjust" after the content has been set - this.setState( - { - keepContentVisible: true, - }, - () => { - this.anim.adjustTo(this.adjustHeight, null, {}) - } - ) - - return // stop here - } - const run = () => { this.setState( { isActive: true, - initialOpened: true, }, () => { this.anim.open() @@ -434,15 +422,17 @@ export default class GlobalStatus extends React.PureComponent { return // stop here } + this.setState({ + isClosing: true, + }) + const run = () => { this.setState( { + isClosing: false, isActive: false, - initialOpened: false, }, - () => { - this.anim.close() - } + () => this.anim.close() ) } @@ -538,7 +528,7 @@ export default class GlobalStatus extends React.PureComponent { event.persist() const keyCode = keycode(event) if ( - (item.status_id && + (item.item_id && typeof document !== 'undefined' && typeof window !== 'undefined' && keyCode === 'space') || @@ -548,7 +538,7 @@ export default class GlobalStatus extends React.PureComponent { event.preventDefault() try { // find the element - const element = document.getElementById(item.status_id) + const element = document.getElementById(item.item_id) if (!element) { return @@ -607,9 +597,7 @@ export default class GlobalStatus extends React.PureComponent { } const id = - item.id || item.status_id - ? `${item.status_id}-${i}` - : makeUniqueId() + item.id || item.item_id ? `${item.item_id}-${i}` : makeUniqueId() let anchorText = status_anchor_text @@ -628,7 +616,7 @@ export default class GlobalStatus extends React.PureComponent { .replace(/[: ]$/g, '') } - const useAutolink = item.status_id && isTrue(item.status_anchor_url) + const useAutolink = item.item_id && isTrue(item.status_anchor_url) return (
  • @@ -642,7 +630,7 @@ export default class GlobalStatus extends React.PureComponent { aria-describedby={id} lang={lang} href={ - useAutolink ? `#${item.status_id}` : item.status_anchor_url + useAutolink ? `#${item.item_id}` : item.status_anchor_url } onClick={(e) => this.gotoItem(e, item)} onKeyDown={(e) => this.gotoItem(e, item)} diff --git a/packages/dnb-eufemia/src/components/global-status/GlobalStatusProvider.js b/packages/dnb-eufemia/src/components/global-status/GlobalStatusProvider.js index cc48fe6b5c8..f1453c6d741 100644 --- a/packages/dnb-eufemia/src/components/global-status/GlobalStatusProvider.js +++ b/packages/dnb-eufemia/src/components/global-status/GlobalStatusProvider.js @@ -55,8 +55,8 @@ class GlobalStatusProvider { if (typeof item === 'string') { item = { text: item } } - if (!item.status_id) { - item.status_id = + if (!item.item_id) { + item.item_id = status_id && status_id !== 'status-main' // same as defaultProps.status_id ? status_id : slugify(JSON.stringify(item)) @@ -65,9 +65,9 @@ class GlobalStatusProvider { } static combineMessages(stack) { - const globalStatus = stack.reduce((acc, _cur) => { + const globalStatus = stack.reduce((acc, cur) => { // make a copy, because items are read-only - const cur = { ..._cur } + cur = { ...cur } if (typeof cur.items === 'string' && cur.items[0] === '[') { cur.items = JSON.parse(cur.items) @@ -85,20 +85,20 @@ class GlobalStatusProvider { // merge items from prev stack into the current if (cur.items) { - cur.items = cur.items.reduce((acc, item) => { + cur.items = cur.items.reduce((_acc, item) => { // only a fallback and to make sure we have item = GlobalStatusProvider.prepareItemWithStatusId(item) - const foundAtIndex = acc.findIndex( - ({ status_id }) => status_id === item.status_id + const foundAtIndex = _acc.findIndex( + ({ item_id }) => item_id === item.item_id ) if (foundAtIndex > -1) { - acc[foundAtIndex] = item + _acc[foundAtIndex] = item } else { - acc.push(item) + _acc.push(item) } - return acc + return _acc }, acc.items || []) // here we use the items from the prev stack } @@ -202,37 +202,33 @@ class GlobalStatusProviderItem { } update(status_id, newProps, opts = {}) { - if (status_id) { - const item = this.get(status_id) - if (!item) { - this.add(newProps) - } - } - - this.stack = this.stack.map((cur, i, arr) => { - if ( - !status_id ? i === arr.length - 1 : cur.status_id === status_id - ) { - if (!status_id) { - newProps = { ...newProps } - delete newProps.status_id + const item = this.get(status_id) + if (!item) { + this.add(newProps, { preventRerender: true }) + } else { + this.stack = this.stack.map((cur, i, arr) => { + if ( + status_id ? cur.status_id === status_id : i === arr.length - 1 + ) { + // if (!status_id) { + // // newProps = { ...newProps } + // delete newProps.status_id + // } + return { ...cur, ...newProps } } - return { ...cur, ...newProps } - } - - return cur - }) - if (!opts?.preventRestack) { - this.restack(status_id) + return cur + }) } + this.restack(status_id) + const globalStatus = GlobalStatusProvider.combineMessages(this.stack) if (!opts?.preventRerender) { this.forceRerender(globalStatus, null, { buffer_delay: - newProps?.buffer_delay > -1 ? newProps.buffer_delay : 0, + newProps?.buffer_delay > -1 ? newProps.buffer_delay : 1, }) } } @@ -258,7 +254,7 @@ class GlobalStatusProviderItem { if (!opts?.preventRerender) { this.forceRerender(globalStatus, null, { - buffer_delay: opts?.buffer_delay > -1 ? opts.buffer_delay : 10, + buffer_delay: opts?.buffer_delay > -1 ? opts.buffer_delay : 1, }) } } diff --git a/packages/dnb-eufemia/src/components/global-status/__tests__/GlobalStatus.test.js b/packages/dnb-eufemia/src/components/global-status/__tests__/GlobalStatus.test.js index 3ebf86d6a46..3d6367e59a9 100644 --- a/packages/dnb-eufemia/src/components/global-status/__tests__/GlobalStatus.test.js +++ b/packages/dnb-eufemia/src/components/global-status/__tests__/GlobalStatus.test.js @@ -12,7 +12,9 @@ import { loadScss, } from '../../../core/jest/jestSetup' import Component from '../GlobalStatus' +import FormSet from '../../form-set/FormSet' import Switch from '../../switch/Switch' +import Autocomplete from '../../autocomplete/Autocomplete' const id = 'main' const status_id = null @@ -53,9 +55,8 @@ const props = { } describe('GlobalStatus component', () => { - const Comp = mount() - it('has to have a text value as defined in the prop', () => { + const Comp = mount() expect( Comp.find('div.dnb-global-status__message') .find('.dnb-p') @@ -65,6 +66,7 @@ describe('GlobalStatus component', () => { }) it('has to have list items as defined in the prop', () => { + const Comp = mount() expect(Comp.find('.dnb-ul').text()).toBe( props.items.map(({ text }) => text).join('') ) @@ -241,11 +243,6 @@ describe('GlobalStatus component', () => { Comp.find('div.dnb-global-status__message p.dnb-p').at(0).text() ).toBe(startupText) expect(Comp.exists('div.dnb-global-status__message')).toBe(true) - expect( - Comp.find('div.dnb-global-status__shell') - .instance() - .getAttribute('style') - ).toBe('height: auto;') mount( { ).toBe('height: 0px; visibility: hidden;') }) + it('have to handle delayed interactions ', async () => { + const FormField1 = () => { + const [status, setStatus] = React.useState() + return ( + { + setStatus(checked ? 'error-message-1' : null) + }} + /> + ) + } + + const FormField2 = () => { + const [status, setStatus] = React.useState() + return ( + { + setStatus(checked ? 'error-message-2' : null) + }} + /> + ) + } + + const FormField3 = () => { + const [status, setStatus] = React.useState() + return ( + { + setStatus('error-message-3') + }} + on_blur={() => { + setStatus(null) + }} + /> + ) + } + + const Comp = mount( + <> + + + + + + + + ) + + await wait(1) + Comp.find('input#switch-1').simulate('change') + + await wait(1) + Comp.find('input#switch-2').simulate('change') + + await wait(1) + Comp.find('input#autocomplete-3').simulate('focus') + + // FormStatus content + expect(Comp.find('.dnb-form-status__text').at(0).text()).toBe( + 'error-message-1' + ) + expect(Comp.find('.dnb-form-status__text').at(1).text()).toBe( + 'error-message-2' + ) + expect( + Comp.find('.dnb-autocomplete') + .at(0) + .find('.dnb-form-status__text') + .text() + ).toBe('error-message-3') + + await refresh(Comp) + + // GlobalStatus content + expect(Comp.find('.dnb-global-status__message p').at(0).text()).toBe( + 'error-message-1' + ) + expect(Comp.find('.dnb-global-status__message p').at(1).text()).toBe( + 'error-message-2' + ) + expect(Comp.find('.dnb-global-status__message p').at(2).text()).toBe( + 'error-message-3' + ) + + await wait(1) + Comp.find('input#switch-1').simulate('change') + + await wait(1) + Comp.find('input#switch-2').simulate('change') + + await wait(1) + Comp.find('input#autocomplete-3').simulate('blur') + + expect(Comp.exists('.dnb-form-status__text')).toBe(false) + + await refresh(Comp) + + expect(Comp.exists('.dnb-global-status__message p')).toBe(false) + expect(Comp.exists('.dnb-form-status__text')).toBe(false) + const inst = Comp.find('div.dnb-global-status__shell').instance() + expect(inst.innerHTML).toBe('') + expect(inst.getAttribute('style')).toBe( + 'height: 0px; visibility: hidden;' + ) + }) + + it('have to scroll to GlobalStatus ', async () => { + const scrollTo = jest.fn() + jest.spyOn(window, 'scrollTo').mockImplementation(scrollTo) + const offsetTop = 1000 + + const ToggleStatus = () => { + const [status, setStatus] = React.useState(null) + + return ( + { + setStatus(checked ? 'error-message' : null) + }} + /> + ) + } + const Comp = mount( + <> + + + + ) + + // Open + Comp.find('input#switch').simulate('change') + await refresh(Comp) + + expect(scrollTo).toBeCalledTimes(1) + expect(scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top: 0, + }) + + jest + .spyOn( + Comp.find('.dnb-global-status__wrapper').instance(), + 'offsetTop', + 'get' + ) + .mockImplementation(() => offsetTop) + + // Close + Comp.find('input#switch').simulate('change') + await refresh(Comp) + + expect(scrollTo).toBeCalledTimes(1) + + // Open + Comp.find('input#switch').simulate('change') + await refresh(Comp) + + expect(scrollTo).toBeCalledTimes(2) + expect(scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top: offsetTop, + }) + }) + + it('have to close when esc key is pressed ', async () => { + const on_close = jest.fn() + const on_hide = jest.fn() + + const ToggleStatus = () => { + const [status, setStatus] = React.useState(null) + + return ( + { + setStatus(checked ? 'error-message' : null) + }} + /> + ) + } + const Comp = mount( + <> + + + + ) + + // Open + Comp.find('input#switch').simulate('change') + await refresh(Comp) + + expect(on_close).toBeCalledTimes(0) + + // Close with key + keydown(Comp, 27) // esc + + expect(on_hide).toBeCalledTimes(1) + expect(on_close).toBeCalledTimes(1) + }) + + it('have to have height of auto value', async () => { + const ToggleStatus = () => { + const [status, setStatus] = React.useState(null) + + return ( + { + setStatus(checked ? 'error-message' : null) + }} + /> + ) + } + const Comp = mount( + <> + + + + ) + + Comp.find('input#switch').simulate('change') + await refresh(Comp) + + expect( + Comp.find('div.dnb-global-status__shell') + .instance() + .getAttribute('style') + ).toBe('height: auto;') + }) + it('have to be hidden after all messages are removed ', async () => { const ToggleStatus = () => { const [status, setStatus] = React.useState(null) @@ -338,23 +596,19 @@ describe('GlobalStatus component', () => { ) Comp.find('input#switch').simulate('change') + await refresh(Comp) expect(Comp.find('.dnb-form-status__text').text()).toBe( 'error-message' ) - expect(Comp.find('.dnb-global-status__message p').at(0).text()).toBe( + + expect(Comp.exists('.dnb-global-status__content')).toBe(true) + expect(Comp.find('.dnb-global-status__message p').text()).toBe( 'error-message' ) - expect( - Comp.find('div.dnb-global-status__shell') - .instance() - .getAttribute('style') - ).toBe('height: auto;') - Comp.find('input#switch').simulate('change') - - await wait(10) + await refresh(Comp) expect(Comp.exists('.dnb-form-status__text')).toBe(false) const inst = Comp.find('div.dnb-global-status__shell').instance() @@ -400,6 +654,8 @@ describe('GlobalStatus component', () => { Comp.find('input#switch').simulate('change') + await refresh(Comp) + expect(Comp.find('.dnb-global-status__message p').at(0).text()).toBe( 'error-message' ) @@ -558,6 +814,7 @@ describe('GlobalStatus component', () => { }) it('should validate with ARIA rules', async () => { + const Comp = mount() expect(await axeComponent(Comp)).toHaveNoViolations() }) }) @@ -606,3 +863,16 @@ describe('GlobalStatus scss', () => { }) const wait = (t) => new Promise((r) => setTimeout(r, t)) + +const refresh = async (Comp) => { + await wait(1) + Comp.update() +} + +const keydown = (Comp, keyCode) => { + document.dispatchEvent(new KeyboardEvent('keydown', { keyCode })) + + Comp.find('.dnb-global-status__wrapper').simulate('keydown', { + keyCode, + }) +} diff --git a/packages/dnb-eufemia/src/components/global-status/__tests__/__snapshots__/GlobalStatus.test.js.snap b/packages/dnb-eufemia/src/components/global-status/__tests__/__snapshots__/GlobalStatus.test.js.snap index 1fa9d4c3a59..ef90c83d4a6 100644 --- a/packages/dnb-eufemia/src/components/global-status/__tests__/__snapshots__/GlobalStatus.test.js.snap +++ b/packages/dnb-eufemia/src/components/global-status/__tests__/__snapshots__/GlobalStatus.test.js.snap @@ -768,12 +768,12 @@ exports[`GlobalStatus snapshot have to match component snapshot 1`] = ` Array [ Object { "id": "id-1", - "status_id": "idid-1textitem-1", + "item_id": "idid-1textitem-1", "text": "item #1", }, Object { "id": "id-2", - "status_id": "idid-2textitem-2", + "item_id": "idid-2textitem-2", "text": "item #2", }, ] @@ -1467,14 +1467,14 @@ Array [ >

    error-message