UNPKG

@atlaskit/editor-plugin-status

Version:

Status plugin for @atlaskit/editor-core

544 lines (536 loc) 22.9 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; /** * @jsxRuntime classic * @jsx jsx */ import React from 'react'; // eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766 import { css, jsx } from '@emotion/react'; import { injectIntl } from 'react-intl'; import withAnalyticsEvents from '@atlaskit/analytics-next/withAnalyticsEvents'; import { getDocument } from '@atlaskit/browser-apis'; import { statusMessages as messages } from '@atlaskit/editor-common/messages'; import { Popup } from '@atlaskit/editor-common/ui'; import { OutsideClickTargetRefContext, withReactEditorViewOuterListeners as withOuterListeners } from '@atlaskit/editor-common/ui-react'; import { UserIntentPopupWrapper } from '@atlaskit/editor-common/user-intent'; import { akEditorFloatingDialogZIndex } from '@atlaskit/editor-shared-styles'; import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled'; import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure'; import { fg } from '@atlaskit/platform-feature-flags/fg'; import { Status } from '@atlaskit/status/element'; import { StatusPicker as AkStatusPicker } from '@atlaskit/status/picker'; import VisuallyHidden from '@atlaskit/visually-hidden/visually-hidden'; import { DEFAULT_STATUS } from '../utils/createStatusNode'; import { analyticsState, createStatusAnalyticsAndFire } from './analytics'; const PopupWithListeners = withOuterListeners(Popup); export let InputMethod = /*#__PURE__*/function (InputMethod) { InputMethod["blur"] = "blur"; InputMethod["escKey"] = "escKey"; InputMethod["enterKey"] = "enterKey"; return InputMethod; }({}); export let closingMethods = /*#__PURE__*/function (closingMethods) { closingMethods["ArrowLeft"] = "arrowLeft"; closingMethods["ArrowRight"] = "arrowRight"; return closingMethods; }({}); const pickerContainerStyles = css({ background: "var(--ds-surface-overlay, #FFFFFF)", padding: `${"var(--ds-space-100, 8px)"} 0`, borderRadius: "var(--ds-radius-small, 3px)", boxShadow: "var(--ds-shadow-overlay, 0px 8px 12px #1E1F2126, 0px 0px 1px #1E1F214f)", // eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-selectors -- Ignored via go/DSP-18766 ':focus': { outline: 'none' }, // eslint-disable-next-line @atlaskit/design-system/no-nested-styles, @atlaskit/ui-styling-standard/no-nested-selectors -- Ignored via go/DSP-18766 input: { // eslint-disable-next-line @atlaskit/design-system/use-tokens-typography textTransform: 'uppercase' } }); const pickerContainerStylesTeam26 = css({ background: "var(--ds-surface-overlay, #FFFFFF)", padding: `${"var(--ds-space-100, 8px)"} 0`, borderRadius: "var(--ds-radius-small, 3px)", boxShadow: "var(--ds-shadow-overlay, 0 0 1px rgba(9, 30, 66, 0.31), 0 4px 8px -2px rgba(9, 30, 66, 0.25))", // eslint-disable-next-line @atlaskit/ui-styling-standard/no-unsafe-selectors ':focus': { outline: 'none' } }); const suggestedStatusesContainerStyles = css({ display: 'flex', flexDirection: 'column', gap: "var(--ds-space-100, 8px)", margin: 0, padding: `${"var(--ds-space-100, 8px)"} ${"var(--ds-space-150, 12px)"} ${"var(--ds-space-050, 4px)"}` }); // Fixed input plus capped scrolling color controls and suggestions. The ten-color picker is // taller because it carries a second swatch row. const STATUS_PICKER_FIT_HEIGHT = 288; const STATUS_PICKER_TEN_COLOR_FIT_HEIGHT = 292; // Seven suggestions fit below the color palette before the content starts scrolling. const MAX_SUGGESTIONS_WITHOUT_SCROLLBAR = 7; // ColorPalette: swatches per row * 32px slot + 16px inline margin. const STATUS_PICKER_CONTENT_WIDTH = 208; // 6 * 32 + 16 const STATUS_PICKER_TEN_COLOR_CONTENT_WIDTH = 176; // 5 * 32 + 16 const STATUS_PICKER_SCROLLBAR_WIDTH = 16; const statusPickerWidthStylesOld = css({ width: `${STATUS_PICKER_CONTENT_WIDTH}px` }); const statusPickerWidthStyles = css({ width: `${STATUS_PICKER_TEN_COLOR_CONTENT_WIDTH}px` }); const statusPickerWithScrollbarWidthStylesOld = css({ // Reserve space for the native scrollbar without reducing the picker content width. width: `${STATUS_PICKER_CONTENT_WIDTH + STATUS_PICKER_SCROLLBAR_WIDTH}px` }); // When cleaning up `platform_editor_status_popup_suggestions_patch_1`, merge this into // `suggestedStatusesContainerStyles` and remove its conditional style array. const suggestedStatusesContainerPatchStyles = css({ gap: "var(--ds-space-050, 4px)", paddingLeft: "var(--ds-space-100, 8px)" }); const suggestedStatusButtonStyles = css({ background: 'transparent', border: 0, borderRadius: "var(--ds-radius-small, 3px)", cursor: 'pointer', justifyContent: 'flex-start', padding: 0, '&:focus-visible': { outline: `2px solid ${"var(--ds-border-focused, #0C66E4)"}`, outlineOffset: "var(--ds-space-025, 2px)" } }); // Remove when cleaning up `platform_editor_status_popup_suggestions_patch_1`. const suggestedStatusButtonOldStyles = css({ display: 'flex', width: '100%' }); const suggestedStatusButtonWrapperStyles = css({ alignSelf: 'flex-start', borderWidth: "var(--ds-border-width, 1px)", borderStyle: 'solid', borderColor: 'transparent', borderRadius: "var(--ds-space-075, 6px)", // Keeps the border and padding inside `maxWidth`, so a full-width suggestion ends level // with the last swatch. boxSizing: 'border-box', display: 'flex', marginLeft: "var(--ds-space-025, 2px)", maxWidth: '100%', paddingBlock: "var(--ds-border-width, 1px)", paddingInline: "var(--ds-border-width, 1px)", '&:hover': { borderColor: "var(--ds-border, #091E4224)" } }); // When cleaning up `platform_editor_status_popup_suggestions_patch_1`, merge this into // `suggestedStatusButtonStyles` and remove its conditional style array. const suggestedStatusButtonPatchStyles = css({ // Allow this flex item to shrink so long statuses can truncate. minWidth: 0 }); // eslint-disable-next-line @repo/internal/react/no-class-components class StatusPickerWithIntl extends React.Component { constructor(props) { super(props); _defineProperty(this, "handleClickOutside", event => { event.preventDefault(); this.inputMethod = InputMethod.blur; const selectedText = window.getSelection(); if (!selectedText) { this.props.closeStatusPicker(); } }); _defineProperty(this, "handleEscapeKeydown", event => { event.preventDefault(); this.inputMethod = InputMethod.escKey; this.props.onEnter(this.state); }); _defineProperty(this, "handleTabPress", event => { var _getDocument2; if (!UNSAFE_expValNoExposure('platform_editor_status_popup_suggestions', 'isEnabled', false)) { var _getDocument; /* original tab-navigation behaviour: cycle between color buttons and the input field */ const colorButtons = event.currentTarget.querySelectorAll('button'); const inputField = event.currentTarget.querySelector('input'); const activeElement = (_getDocument = getDocument()) === null || _getDocument === void 0 ? void 0 : _getDocument.activeElement; const isInputFocussed = activeElement === inputField; const isButtonFocussed = Array.from(colorButtons).some(buttonElement => { return activeElement === buttonElement; }); if (event !== null && event !== void 0 && event.shiftKey) { /* shift + tab */ if (isInputFocussed) { colorButtons[0].focus(); event.preventDefault(); } /* After the user presses shift + tab the color-palette component updates tab index for the first color to be 0. To correctly set focus to the input field instead of the first color button we need to set focus manually */ if (isButtonFocussed) { inputField === null || inputField === void 0 ? void 0 : inputField.focus(); event.preventDefault(); } } else { /* tab */ if (isButtonFocussed) { inputField === null || inputField === void 0 ? void 0 : inputField.focus(); event.preventDefault(); } } return; } const colorButtons = event.currentTarget.querySelectorAll('button:not([data-suggested-status-button])'); const suggestedStatusButtons = event.currentTarget.querySelectorAll('button[data-suggested-status-button]'); const inputField = event.currentTarget.querySelector('input'); const activeElement = (_getDocument2 = getDocument()) === null || _getDocument2 === void 0 ? void 0 : _getDocument2.activeElement; const isInputFocussed = activeElement === inputField; const isButtonFocussed = Array.from(colorButtons).some(buttonElement => { return activeElement === buttonElement; }); const isSuggestedStatusButtonFocussed = Array.from(suggestedStatusButtons).some(buttonElement => { return activeElement === buttonElement; }); if (event !== null && event !== void 0 && event.shiftKey) { /* shift + tab */ if (isInputFocussed) { if (suggestedStatusButtons.length > 0) { suggestedStatusButtons[suggestedStatusButtons.length - 1].focus(); } else { var _colorButtons$; (_colorButtons$ = colorButtons[0]) === null || _colorButtons$ === void 0 ? void 0 : _colorButtons$.focus(); } event.preventDefault(); } /* After the user presses shift + tab the color-palette component updates tab index for the first color to be 0. To correctly set focus to the input field instead of the first color button we need to set focus manually */ if (isButtonFocussed) { inputField === null || inputField === void 0 ? void 0 : inputField.focus(); event.preventDefault(); } if (isSuggestedStatusButtonFocussed) { var _colorButtons; (_colorButtons = colorButtons[colorButtons.length - 1]) === null || _colorButtons === void 0 ? void 0 : _colorButtons.focus(); event.preventDefault(); } } else { /* tab */ if (isButtonFocussed) { if (suggestedStatusButtons.length > 0) { suggestedStatusButtons[0].focus(); } else { inputField === null || inputField === void 0 ? void 0 : inputField.focus(); } event.preventDefault(); } if (isSuggestedStatusButtonFocussed) { inputField === null || inputField === void 0 ? void 0 : inputField.focus(); event.preventDefault(); } } }); _defineProperty(this, "handleArrow", (event, closingMethod) => { var _getDocument3; const activeElement = (_getDocument3 = getDocument()) === null || _getDocument3 === void 0 ? void 0 : _getDocument3.activeElement; if (activeElement === this.popupBodyWrapper.current) { var _this$popupBodyWrappe, _this$popupBodyWrappe2; event.preventDefault(); (_this$popupBodyWrappe = this.popupBodyWrapper) === null || _this$popupBodyWrappe === void 0 ? void 0 : (_this$popupBodyWrappe2 = _this$popupBodyWrappe.current) === null || _this$popupBodyWrappe2 === void 0 ? void 0 : _this$popupBodyWrappe2.blur(); this.props.closeStatusPicker({ closingMethod }); } }); _defineProperty(this, "onKeyDown", event => { const isTabPressed = event.key === 'Tab'; if (isTabPressed) { return this.handleTabPress(event); } if (event.key in closingMethods) { return this.handleArrow(event, closingMethods[event.key]); } }); _defineProperty(this, "onColorHover", color => { this.createStatusAnalyticsAndFireFunc({ action: 'hovered', actionSubject: 'statusColorPicker', attributes: { color, localId: this.state.localId, state: analyticsState(this.props.isNew) } }); }); _defineProperty(this, "onColorClick", color => { const { text, localId } = this.state; if (color === this.state.color) { this.createStatusAnalyticsAndFireFunc({ action: 'clicked', actionSubject: 'statusColorPicker', attributes: { color, localId, state: analyticsState(this.props.isNew) } }); // closes status box and commits colour this.onEnter(); } else { this.setState({ color }); this.props.onSelect({ text, color, localId }); } }); _defineProperty(this, "onTextChanged", text => { const { color, localId } = this.state; this.setState({ text }); this.props.onTextChanged({ text, color, localId }, !!this.props.isNew); }); _defineProperty(this, "onEnter", () => { this.inputMethod = InputMethod.enterKey; this.props.onEnter(this.state); }); _defineProperty(this, "onSuggestedStatusClick", (status, position) => { this.fireSuggestedStatusClickedAnalytics(status, position); this.props.onSuggestedStatusClick(status); }); _defineProperty(this, "handleSuggestedStatusKeyDown", event => { // Selection (Enter/Space) is handled natively via the button's onClick. // Keydown only drives ArrowUp/ArrowDown navigation between suggestions. if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); event.stopPropagation(); this.focusAdjacentSuggestedStatus(event.currentTarget, event.key === 'ArrowDown' ? 1 : -1); } }); // cancel bubbling to fix clickOutside logic: // popup re-renders its content before the click event bubbles up to the document // therefore click target element would be different from the popup content _defineProperty(this, "handlePopupClick", event => event.nativeEvent.stopImmediatePropagation()); this.state = this.extractStateFromProps(props); this.createStatusAnalyticsAndFireFunc = createStatusAnalyticsAndFire(props.createAnalyticsEvent); this.popupBodyWrapper = /*#__PURE__*/React.createRef(); } fireStatusPopupOpenedAnalytics(state) { const { color, text, localId, isNew } = state; this.startTime = Date.now(); this.createStatusAnalyticsAndFireFunc({ action: 'opened', actionSubject: 'statusPopup', attributes: { textLength: text ? text.length : 0, selectedColor: color, localId, state: analyticsState(isNew) } }); } fireStatusPopupClosedAnalytics(state) { const { color, text, localId, isNew } = state; this.createStatusAnalyticsAndFireFunc({ action: 'closed', actionSubject: 'statusPopup', attributes: { inputMethod: this.inputMethod, duration: Date.now() - this.startTime, textLength: text ? text.length : 0, selectedColor: color, localId, state: analyticsState(isNew) } }); } reset() { this.startTime = Date.now(); this.inputMethod = InputMethod.blur; } componentDidMount() { this.reset(); this.fireStatusPopupOpenedAnalytics(this.state); } componentWillUnmount() { this.focusTimeout && cancelAnimationFrame(this.focusTimeout); this.fireStatusPopupClosedAnalytics(this.state); this.startTime = 0; } componentDidUpdate(prevProps, prevState) { const element = this.props.target; if (prevProps.target !== element) { const newState = this.extractStateFromProps(this.props); this.setState(newState); this.fireStatusPopupClosedAnalytics(prevState); this.reset(); this.fireStatusPopupOpenedAnalytics(newState); } } extractStateFromProps(props) { const { defaultColor, defaultText, defaultLocalId, isNew } = props; return { color: defaultColor || DEFAULT_STATUS.color, text: defaultText || DEFAULT_STATUS.text, localId: defaultLocalId, isNew }; } setRef(setOutsideClickTargetRef) { return ref => { setOutsideClickTargetRef(ref); this.popupBodyWrapper.current = ref; }; } renderWithSetOutsideClickTargetRef(setOutsideClickTargetRef) { var _suggestedStatuses$le; const { isNew, focusStatusInput, api, suggestedStatuses } = this.props; const { color, text } = this.state; const suggestionsPatchEnabled = isExperimentEnabled('platform_editor_status_popup_suggestions') && fg('platform_editor_status_popup_suggestions_patch_1'); const isUpdateStatusColorsEnabled = isExperimentEnabled('platform_editor_update_status_colors'); const suggestedStatusList = suggestedStatuses !== null && suggestedStatuses !== void 0 && suggestedStatuses.length && isExperimentEnabled('platform_editor_status_popup_suggestions') ? jsx("div", { css: [suggestedStatusesContainerStyles, suggestionsPatchEnabled ? suggestedStatusesContainerPatchStyles : undefined], "data-suggested-status-list": true }, suggestedStatuses.map((suggestedStatus, index) => jsx("div", { css: suggestionsPatchEnabled ? suggestedStatusButtonWrapperStyles : undefined, key: `${suggestedStatus.color}:${suggestedStatus.text}` }, jsx("button", { type: "button", css: [suggestedStatusButtonStyles, suggestionsPatchEnabled ? suggestedStatusButtonPatchStyles : suggestedStatusButtonOldStyles], onClick: () => this.onSuggestedStatusClick(suggestedStatus, index + 1), onKeyDown: this.handleSuggestedStatusKeyDown, "aria-label": suggestedStatus.displayText, "data-suggested-status-button": true }, jsx(Status, { color: suggestedStatus.color, isConstrainedToParent: true, text: suggestedStatus.displayText }))))) : null; const pickerContent = jsx(React.Fragment, null, jsx(AkStatusPicker, { autoFocus: isNew || focusStatusInput, selectedColor: color, text: text, onColorClick: this.onColorClick, onColorHover: this.onColorHover, onTextChanged: this.onTextChanged, onEnter: this.onEnter, palette: isUpdateStatusColorsEnabled ? 'extended' : 'default', scrollableContent: suggestionsPatchEnabled ? suggestedStatusList : undefined }), suggestionsPatchEnabled ? null : suggestedStatusList); return jsx(UserIntentPopupWrapper, { api: api, userIntent: "statusPickerOpen" }, jsx("div", { css: [fg('platform-dst-lozenge-tag-badge-visual-uplifts') ? pickerContainerStylesTeam26 : pickerContainerStyles, suggestionsPatchEnabled && isUpdateStatusColorsEnabled ? statusPickerWidthStyles : undefined, suggestionsPatchEnabled && !isUpdateStatusColorsEnabled ? statusPickerWidthStylesOld : undefined, suggestionsPatchEnabled && !fg('platform_editor_status_popup_suggestions_patch_2') && !isUpdateStatusColorsEnabled && ((_suggestedStatuses$le = suggestedStatuses === null || suggestedStatuses === void 0 ? void 0 : suggestedStatuses.length) !== null && _suggestedStatuses$le !== void 0 ? _suggestedStatuses$le : 0) > MAX_SUGGESTIONS_WITHOUT_SCROLLBAR ? statusPickerWithScrollbarWidthStylesOld : undefined], role: "none", ref: this.setRef(setOutsideClickTargetRef), onClick: this.handlePopupClick, onKeyDown: this.onKeyDown }, pickerContent)); } render() { const { target, mountTo, boundariesElement, scrollableElement, editorView, intl } = this.props; if (!(editorView !== null && editorView !== void 0 && editorView.editable)) { return null; } const fitHeight = isExperimentEnabled('platform_editor_update_status_colors') ? STATUS_PICKER_TEN_COLOR_FIT_HEIGHT : STATUS_PICKER_FIT_HEIGHT; return target && jsx(PopupWithListeners, { ariaLabel: intl.formatMessage(messages.statusEditorLabel), target: target // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , offset: [0, 8], handleClickOutside: this.handleClickOutside, handleEscapeKeydown: this.handleEscapeKeydown, zIndex: akEditorFloatingDialogZIndex, fitHeight: isExperimentEnabled('platform_editor_status_popup_suggestions') && fg('platform_editor_status_popup_suggestions_patch_1') ? fitHeight : 40, mountTo: mountTo, boundariesElement: boundariesElement, scrollableElement: scrollableElement, closeOnTab: false }, jsx(VisuallyHidden, { "aria-atomic": true, role: "alert" }, intl.formatMessage(messages.statusPickerOpenedAlert)), jsx(OutsideClickTargetRefContext.Consumer, null, this.renderWithSetOutsideClickTargetRef.bind(this))); } fireSuggestedStatusClickedAnalytics(status, position) { var _this$props$suggested, _this$props$suggested2; this.createStatusAnalyticsAndFireFunc({ action: 'clicked', actionSubject: 'statusSuggestion', attributes: { color: status.color, localId: this.state.localId, position, state: analyticsState(this.props.isNew), textLength: status.text ? status.text.length : 0, totalSuggestions: (_this$props$suggested = (_this$props$suggested2 = this.props.suggestedStatuses) === null || _this$props$suggested2 === void 0 ? void 0 : _this$props$suggested2.length) !== null && _this$props$suggested !== void 0 ? _this$props$suggested : 0 } }); } focusAdjacentSuggestedStatus(currentButton, direction) { var _this$popupBodyWrappe3, _suggestedStatusButto; const suggestedStatusButtons = (_this$popupBodyWrappe3 = this.popupBodyWrapper.current) === null || _this$popupBodyWrappe3 === void 0 ? void 0 : _this$popupBodyWrappe3.querySelectorAll('button[data-suggested-status-button]'); if (!(suggestedStatusButtons !== null && suggestedStatusButtons !== void 0 && suggestedStatusButtons.length)) { return; } const currentIndex = Array.from(suggestedStatusButtons).indexOf(currentButton); if (currentIndex < 0) { return; } const nextIndex = (currentIndex + direction + suggestedStatusButtons.length) % suggestedStatusButtons.length; (_suggestedStatusButto = suggestedStatusButtons[nextIndex]) === null || _suggestedStatusButto === void 0 ? void 0 : _suggestedStatusButto.focus(); } } // eslint-disable-next-line @typescript-eslint/no-restricted-types export const StatusPickerWithoutAnalytcs = injectIntl(StatusPickerWithIntl); const _default_1 = withAnalyticsEvents()(StatusPickerWithoutAnalytcs); export default _default_1;