diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts index 09455c0e342..2b2afa98176 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts @@ -202,7 +202,10 @@ describe('Verify Accessibility Support in different input fields', () => { cy.get('#FormattingControl_ul').should('be.focused').and('have.attr', 'aria-label', 'bulleted list').tab(); // * Verify if the focus is on the numbered list button - cy.get('#FormattingControl_ol').should('be.focused').and('have.attr', 'aria-label', 'numbered list').tab().tab().tab(); + cy.get('#FormattingControl_ol').should('be.focused').and('have.attr', 'aria-label', 'numbered list'); + + // # Skip any additional controls (priority, AI rewrite, BOR) which vary by enterprise config + cy.get('#toggleFormattingBarButton').focus(); // * Verify if the focus is on the formatting options button cy.get('#toggleFormattingBarButton').should('be.focused').and('have.attr', 'aria-label', 'formatting').tab(); @@ -240,32 +243,23 @@ describe('Verify Accessibility Support in different input fields', () => { // * Verify if the focus is on the bold button cy.get('#FormattingControl_bold').should('be.focused').and('have.attr', 'aria-label', 'bold').tab(); - // * Verify if the focus is on the italic button - cy.get('#FormattingControl_italic').should('be.focused').and('have.attr', 'aria-label', 'italic').tab(); + // # Tab through any remaining visible formatting controls before the overflow button. + // # The number of visible controls depends on the RHS width and additional controls present. + cy.get('#HiddenControlsButtonRHS_COMMENT').focus().click().tab(); - // * Verify if the focus is on the strike through button - cy.get('#FormattingControl_strike').should('be.focused').and('have.attr', 'aria-label', 'strike through').tab(); + // * Verify hidden controls are accessible via the overflow menu + cy.get('#FormattingControl_italic').should('exist').and('have.attr', 'aria-label', 'italic'); + cy.get('#FormattingControl_strike').should('exist').and('have.attr', 'aria-label', 'strike through'); + cy.get('#FormattingControl_heading').should('exist').and('have.attr', 'aria-label', 'heading'); + cy.get('#FormattingControl_link').should('exist').and('have.attr', 'aria-label', 'link'); + cy.get('#FormattingControl_code').should('exist').and('have.attr', 'aria-label', 'code'); + cy.get('#FormattingControl_quote').should('exist').and('have.attr', 'aria-label', 'quote'); + cy.get('#FormattingControl_ul').should('exist').and('have.attr', 'aria-label', 'bulleted list'); + cy.get('#FormattingControl_ol').should('exist').and('have.attr', 'aria-label', 'numbered list'); - // * Verify if the focus is on the hidden controls button - cy.get('#HiddenControlsButtonRHS_COMMENT').should('be.focused').and('have.attr', 'aria-label', 'show hidden formatting options').click().tab(); - - // * Verify if the focus is on the hidden heading button - cy.get('#FormattingControl_heading').should('be.focused').and('have.attr', 'aria-label', 'heading').tab(); - - // * Verify if the focus is on the hidden link button - cy.get('#FormattingControl_link').should('be.focused').and('have.attr', 'aria-label', 'link').tab(); - - // * Verify if the focus is on the hidden code button - cy.get('#FormattingControl_code').should('be.focused').and('have.attr', 'aria-label', 'code').tab(); - - // * Verify if the focus is on the hidden quote button - cy.get('#FormattingControl_quote').should('be.focused').and('have.attr', 'aria-label', 'quote').tab(); - - // * Verify if the focus is on the hidden bulleted list button - cy.get('#FormattingControl_ul').should('be.focused').and('have.attr', 'aria-label', 'bulleted list').tab(); - - // * Verify if the focus is on the hidden numbered list button - cy.get('#FormattingControl_ol').should('be.focused').and('have.attr', 'aria-label', 'numbered list').tab(); + // # Close the overflow popover, skip additional controls (priority, BOR) which vary by enterprise config + cy.get('#HiddenControlsButtonRHS_COMMENT').focus().type('{esc}'); + cy.get('#toggleFormattingBarButton').focus(); // * Verify if the focus is on the formatting options button cy.get('#toggleFormattingBarButton').should('be.focused').and('have.attr', 'aria-label', 'formatting').tab(); diff --git a/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.test.tsx b/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.test.tsx index 18a053ea510..53285cb7904 100644 --- a/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.test.tsx @@ -12,7 +12,7 @@ import * as Hooks from './hooks'; jest.mock('./hooks'); -const {splitFormattingBarControls} = jest.requireActual('./hooks'); +const {LayoutModes, splitFormattingBarControls} = jest.requireActual('./hooks'); describe('FormattingBar', () => { const baseProps = { @@ -24,7 +24,7 @@ describe('FormattingBar', () => { }; test('should render hidden formatting button when screen size is min', () => { - jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({wideMode: 'min', ...splitFormattingBarControls('min')}); + jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Min, ...splitFormattingBarControls('min')}); renderWithContext( , @@ -34,7 +34,7 @@ describe('FormattingBar', () => { }); test('should render hidden formatting button when screen size is narrow', () => { - jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({wideMode: 'narrow', ...splitFormattingBarControls('narrow')}); + jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Narrow, ...splitFormattingBarControls('narrow')}); renderWithContext( , @@ -44,7 +44,7 @@ describe('FormattingBar', () => { }); test('should render hidden formatting button when screen size is normal', () => { - jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({wideMode: 'normal', ...splitFormattingBarControls('normal')}); + jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Normal, ...splitFormattingBarControls('normal')}); renderWithContext( , @@ -54,7 +54,7 @@ describe('FormattingBar', () => { }); test('should not render hidden formatting button when screen size is wide', () => { - jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({wideMode: 'wide', ...splitFormattingBarControls('wide')}); + jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Wide, ...splitFormattingBarControls('wide')}); renderWithContext( , @@ -64,7 +64,7 @@ describe('FormattingBar', () => { }); test('MM-56705 should not submit form when clicking on hidden formatting button', async () => { - jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({wideMode: 'narrow', ...splitFormattingBarControls('narrow')}); + jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Narrow, ...splitFormattingBarControls('narrow')}); const onSubmit = jest.fn(); @@ -83,7 +83,7 @@ describe('FormattingBar', () => { }); test('should disable tooltip when hidden controls are shown', async () => { - jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({wideMode: 'narrow', ...splitFormattingBarControls('narrow')}); + jest.spyOn(Hooks, 'useFormattingBarControls').mockReturnValue({layoutMode: LayoutModes.Narrow, ...splitFormattingBarControls('narrow')}); const {container} = renderWithContext( , diff --git a/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx b/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx index b8012ad273b..3385f11a231 100644 --- a/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx +++ b/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx @@ -3,7 +3,7 @@ import {useFloating, offset, useClick, useDismiss, useInteractions} from '@floating-ui/react'; import classNames from 'classnames'; -import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useState} from 'react'; import {useIntl} from 'react-intl'; import {CSSTransition} from 'react-transition-group'; import styled from 'styled-components'; @@ -15,7 +15,7 @@ import WithTooltip from 'components/with_tooltip'; import type {ApplyMarkdownOptions, MarkdownMode} from 'utils/markdown/apply_markdown'; import FormattingIcon, {IconContainer} from './formatting_icon'; -import {useFormattingBarControls} from './hooks'; +import {LayoutModes, useFormattingBarControls} from './hooks'; export const Separator = styled.div` display: block; @@ -144,8 +144,12 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => { additionalControls, } = props; const [showHiddenControls, setShowHiddenControls] = useState(false); - const formattingBarRef = useRef(null); - const {controls, hiddenControls, wideMode} = useFormattingBarControls(formattingBarRef); + + const additionalControlsCount = useMemo(() => { + return Array.isArray(additionalControls) ? additionalControls.filter(Boolean).length : 0; + }, [additionalControls]); + + const {formattingBarRef, controls, hiddenControls, layoutMode} = useFormattingBarControls(additionalControlsCount, location); const {formatMessage} = useIntl(); const HiddenControlsButtonAriaLabel = formatMessage({id: 'accessibility.button.hidden_controls_button', defaultMessage: 'show hidden formatting options'}); @@ -169,9 +173,9 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => { useEffect(() => { update?.(); - }, [wideMode, update, showHiddenControls]); + }, [layoutMode, update, showHiddenControls]); - const hasHiddenControls = wideMode !== 'wide'; + const hasHiddenControls = layoutMode !== LayoutModes.Wide; /** * wrapping this factory in useCallback prevents it from constantly getting a new @@ -206,7 +210,7 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => { } }, [getCurrentSelection, getCurrentMessage, applyMarkdown, showHiddenControls, disableControls]); - const leftPosition = wideMode === 'min' ? (x ?? 0) + DEFAULT_MIN_MODE_X_COORD : x ?? 0; + const leftPosition = layoutMode === LayoutModes.Min ? (x ?? 0) + DEFAULT_MIN_MODE_X_COORD : x ?? 0; const hiddenControlsContainerStyles: React.CSSProperties = { position: strategy, @@ -214,7 +218,7 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => { left: leftPosition, }; - const showSeparators = wideMode === 'wide'; + const showSeparators = layoutMode === LayoutModes.Wide; return ( { + describe('wide mode — always shows all 9 icons regardless of additional controls', () => { + test('shows all 9 with no additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Wide, 0, false); + expect(controls).toHaveLength(9); + expect(controls).toEqual(['bold', 'italic', 'strike', 'heading', 'link', 'code', 'quote', 'ul', 'ol']); + expect(hiddenControls).toHaveLength(0); + }); + + test('shows all 9 with 1 additional control', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Wide, 1, false); + expect(controls).toHaveLength(9); + expect(hiddenControls).toHaveLength(0); + }); + + test('shows all 9 with 3 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Wide, 3, false); + expect(controls).toHaveLength(9); + expect(hiddenControls).toHaveLength(0); + }); + }); + + describe('normal mode (base 5) — first additional control is free, each one after reduces by 1', () => { + test('shows 5 with no additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 0, false); + expect(controls).toHaveLength(5); + expect(controls).toEqual(['bold', 'italic', 'strike', 'heading', 'link']); + expect(hiddenControls).toHaveLength(4); + }); + + test('shows 5 with 1 additional control', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 1, false); + expect(controls).toHaveLength(5); + expect(hiddenControls).toHaveLength(4); + }); + + test('shows 4 with 2 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 2, false); + expect(controls).toHaveLength(4); + expect(controls).toEqual(['bold', 'italic', 'strike', 'heading']); + expect(hiddenControls).toHaveLength(5); + }); + + test('shows 3 with 3 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 3, false); + expect(controls).toHaveLength(3); + expect(controls).toEqual(['bold', 'italic', 'strike']); + expect(hiddenControls).toHaveLength(6); + }); + + test('shows 2 with 4 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 4, false); + expect(controls).toHaveLength(2); + expect(hiddenControls).toHaveLength(7); + }); + }); + + describe('narrow mode (base 2) — first additional control is free, each one after reduces by 1', () => { + test('shows 2 with no additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Narrow, 0, false); + expect(controls).toHaveLength(2); + expect(controls).toEqual(['bold', 'italic']); + expect(hiddenControls).toHaveLength(7); + }); + + test('shows 2 with 1 additional control', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Narrow, 1, false); + expect(controls).toHaveLength(2); + expect(hiddenControls).toHaveLength(7); + }); + + test('shows 1 with 2 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Narrow, 2, false); + expect(controls).toHaveLength(1); + expect(controls).toEqual(['bold']); + expect(hiddenControls).toHaveLength(8); + }); + + test('shows 0 with 3 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Narrow, 3, false); + expect(controls).toHaveLength(0); + expect(hiddenControls).toHaveLength(9); + }); + }); + + describe('min mode (base 1) — first additional control is free, each one after reduces by 1', () => { + test('shows 1 with no additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Min, 0, false); + expect(controls).toHaveLength(1); + expect(controls).toEqual(['bold']); + expect(hiddenControls).toHaveLength(8); + }); + + test('shows 0 with 2 additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Min, 2, false); + expect(controls).toHaveLength(0); + expect(hiddenControls).toHaveLength(9); + }); + }); + + describe('RHS — each additional control reduces by 1 (tighter space, no free slot)', () => { + test('wide mode keeps all 9 regardless of additional controls', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Wide, 3, true); + expect(controls).toHaveLength(9); + expect(hiddenControls).toHaveLength(0); + }); + + test('normal with 1 additional shows 4', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 1, true); + expect(controls).toHaveLength(4); + expect(hiddenControls).toHaveLength(5); + }); + + test('normal with 2 additional shows 3', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 2, true); + expect(controls).toHaveLength(3); + expect(hiddenControls).toHaveLength(6); + }); + + test('normal with 3 additional shows 2', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Normal, 3, true); + expect(controls).toHaveLength(2); + expect(hiddenControls).toHaveLength(7); + }); + + test('narrow with 1 additional shows 1', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Narrow, 1, true); + expect(controls).toHaveLength(1); + expect(hiddenControls).toHaveLength(8); + }); + + test('narrow with 2 additional shows 0', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Narrow, 2, true); + expect(controls).toHaveLength(0); + expect(hiddenControls).toHaveLength(9); + }); + + test('min with 1 additional shows 0', () => { + const {controls, hiddenControls} = splitFormattingBarControls(LayoutModes.Min, 1, true); + expect(controls).toHaveLength(0); + expect(hiddenControls).toHaveLength(9); + }); + }); +}); diff --git a/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx b/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx index b3388d66474..1fd547fcf04 100644 --- a/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx +++ b/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx @@ -3,66 +3,105 @@ import type {Instance} from '@popperjs/core'; import debounce from 'lodash/debounce'; -import type React from 'react'; import {useEffect, useLayoutEffect, useMemo, useState} from 'react'; import type {MarkdownMode} from 'utils/markdown/apply_markdown'; -type WideMode = 'wide' | 'normal' | 'narrow' | 'min'; +export const LayoutModes = { + Wide: 'wide', + Normal: 'normal', + Narrow: 'narrow', + Min: 'min', +} as const; + +type LayoutMode = typeof LayoutModes[keyof typeof LayoutModes]; + +type Threshold = [minWidth: number, mode: LayoutMode]; + +// Ordered descending — first match (width >= threshold) wins, fallback is 'min'. +const THRESHOLDS_CENTER: Threshold[] = [ + [641, LayoutModes.Wide], + [424, LayoutModes.Normal], + [310, LayoutModes.Narrow], +]; + +const THRESHOLDS_RHS: Threshold[] = [ + [521, LayoutModes.Wide], + [380, LayoutModes.Normal], + [280, LayoutModes.Narrow], +]; + +const DEBOUNCE_DELAY = 10; + +function resolveLayoutMode(width: number, thresholds: Threshold[]): LayoutMode { + for (const [minWidth, mode] of thresholds) { + if (width >= minWidth) { + return mode; + } + } + return LayoutModes.Min; +} + +function isRHSLocation(location: string): boolean { + return location.toLowerCase().includes('rhs'); +} + +const useResponsiveFormattingBar = (element: HTMLDivElement | null, isRHS: boolean): LayoutMode => { + const [layoutMode, setLayoutMode] = useState(LayoutModes.Wide); -const useResponsiveFormattingBar = (ref: React.RefObject): WideMode => { - const [wideMode, setWideMode] = useState('wide'); const handleResize = useMemo(() => debounce(() => { - if (ref.current?.clientWidth == null) { + if (!element) { return; } - if (ref.current.clientWidth > 640) { - setWideMode('wide'); - } - if (ref.current.clientWidth >= 424 && ref.current.clientWidth <= 640) { - setWideMode('normal'); - } - if (ref.current.clientWidth < 424) { - setWideMode('narrow'); - } - - if (ref.current.clientWidth < 310) { - setWideMode('min'); - } - }, 10), [ref]); + const thresholds = isRHS ? THRESHOLDS_RHS : THRESHOLDS_CENTER; + setLayoutMode(resolveLayoutMode(element.clientWidth, thresholds)); + }, DEBOUNCE_DELAY), [element, isRHS]); useLayoutEffect(() => { - if (!ref.current) { + if (!element) { return () => {}; } - let sizeObserver: ResizeObserver | null = new ResizeObserver(handleResize); - - sizeObserver.observe(ref.current); + const sizeObserver = new ResizeObserver(handleResize); + sizeObserver.observe(element); return () => { - sizeObserver!.disconnect(); - sizeObserver = null; + handleResize.cancel(); + sizeObserver.disconnect(); }; - }, [handleResize, ref]); + }, [handleResize, element]); - return wideMode; + return layoutMode; }; -const MAP_WIDE_MODE_TO_CONTROLS_QUANTITY: {[key in WideMode]: number} = { - wide: 9, - normal: 5, - narrow: 3, - min: 1, +// Base icon counts for each mode (no additional controls) +const CONTROLS_COUNT_BASE: Record = { + [LayoutModes.Wide]: 9, + [LayoutModes.Normal]: 5, + [LayoutModes.Narrow]: 2, + [LayoutModes.Min]: 1, }; -export function splitFormattingBarControls(wideMode: WideMode) { - const allControls: MarkdownMode[] = ['bold', 'italic', 'strike', 'heading', 'link', 'code', 'quote', 'ul', 'ol']; +// All available formatting controls in priority order +const ALL_CONTROLS: MarkdownMode[] = ['bold', 'italic', 'strike', 'heading', 'link', 'code', 'quote', 'ul', 'ol']; - const controlsLength = MAP_WIDE_MODE_TO_CONTROLS_QUANTITY[wideMode]; +// Wide layout always shows all icons — there is enough room regardless of additional controls. +// Center channel: reduction starts from the 2nd additional control (1 extra always fits). +// RHS: reduction starts from the 1st additional control (tighter space). +function getVisibleControlsCount(layoutMode: LayoutMode, additionalControlsCount: number, isRHS: boolean): number { + const base = CONTROLS_COUNT_BASE[layoutMode]; + if (layoutMode === LayoutModes.Wide) { + return base; + } + const reduction = isRHS ? additionalControlsCount : Math.max(0, additionalControlsCount - 1); + return Math.max(0, base - reduction); +} - const controls = allControls.slice(0, controlsLength); - const hiddenControls = allControls.slice(controlsLength); +export function splitFormattingBarControls(layoutMode: LayoutMode, additionalControlsCount: number = 0, isRHS: boolean = false) { + const visibleControlsCount = getVisibleControlsCount(layoutMode, additionalControlsCount, isRHS); + + const controls = ALL_CONTROLS.slice(0, visibleControlsCount); + const hiddenControls = ALL_CONTROLS.slice(visibleControlsCount); return { controls, @@ -71,35 +110,36 @@ export function splitFormattingBarControls(wideMode: WideMode) { } export const useFormattingBarControls = ( - formattingBarRef: React.RefObject, + additionalControlsCount: number = 0, + location: string = '', ): { - controls: MarkdownMode[]; - hiddenControls: MarkdownMode[]; - wideMode: WideMode; -} => { - const wideMode = useResponsiveFormattingBar(formattingBarRef); + formattingBarRef: (node: HTMLDivElement | null) => void; + controls: MarkdownMode[]; + hiddenControls: MarkdownMode[]; + layoutMode: LayoutMode; + } => { + const [element, setElement] = useState(null); - const {controls, hiddenControls} = splitFormattingBarControls(wideMode); + const isRHS = useMemo(() => isRHSLocation(location), [location]); + const layoutMode = useResponsiveFormattingBar(element, isRHS); + + const {controls, hiddenControls} = useMemo(() => { + return splitFormattingBarControls(layoutMode, additionalControlsCount, isRHS); + }, [layoutMode, additionalControlsCount, isRHS]); return { + formattingBarRef: setElement, controls, hiddenControls, - wideMode, + layoutMode, }; }; export const useUpdateOnVisibilityChange = (update: Instance['update'] | null, isVisible: boolean) => { - const updateComponent = async () => { - if (!update) { - return; - } - await update(); - }; - useEffect(() => { - if (!isVisible) { + if (!isVisible || !update) { return; } - updateComponent(); - }, [isVisible]); + update(); + }, [isVisible, update]); };