Markdown editor toolbar (#14876)

* rough starting point, roving tabindex in toolbar, bold and italic buttons

* refactor, add link

* add ul

* add ordered list

* core formatters in place, wip

* overflow options

* add keyboard shortcuts

* tidy up some dodgy classes

* add mocks for runtime in test and storybook, use correct modifier key for tooltip

* style tweaks

* refactor tooltips

* add markdown formatters tests

* add tests for toolbar component, fix mistake in overflow menu tooltips

* undo change no longer needed to button

* fix issue accessing runtime in formatters file

* only show darkened buttons and tooltips when focus-visible is true

* mobile view

* fix for responsive buttons & roving tabindex

* tweaks from PR review

* update cursor position on link insertion

* add a new line after block selection formatting

* align icons in center

* tidy up overflow menu listeners

* small refactors

* test for new text area util

* tidy up new lines after syntaxes

* fix logic in cursor offsets for links

* prevent scroll jumps after inserting syntax

* some style tweaks

* insert level 2 heading with new lines above and below

* update icons

* use margin instead of gap
This commit is contained in:
Suzanne Aitchison 2021-10-19 09:22:54 +01:00 committed by GitHub
parent e8d04165b4
commit 5069cd681a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1197 additions and 52 deletions

View file

@ -75,9 +75,7 @@
&[href]:hover,
&[href]:focus,
&:hover:enabled,
&:active:enabled,
&:focus:enabled,
&:focus-within {
&:active:enabled {
background-color: var(--bg-hover);
border-color: var(--border-hover);
box-shadow: var(--shadow-hover);
@ -133,6 +131,15 @@
}
}
.js-focus-visible .crayons-btn.focus-visible:focus,
.js-focus-visible .crayons-btn.focus-visible:focus-within {
background-color: var(--bg-hover);
border-color: var(--border-hover);
box-shadow: var(--shadow-hover);
color: var(--color-hover);
z-index: var(--z-elevate);
}
.crayons-btn--secondary {
--bg: var(--button-secondary-bg);
--bg-hover: var(--button-secondary-bg-hover);

View file

@ -0,0 +1,4 @@
.editor-toolbar {
display: flex;
background: var(--base-0);
}

View file

@ -1,34 +1,47 @@
.crayons-tooltip[data-tooltip] {
position: relative;
&:after {
all: unset;
content: attr(data-tooltip);
.crayons-tooltip {
&__content {
position: absolute;
bottom: 100%;
left: 0;
left: 50%;
top: 100%;
transform: translate(-50%, calc(-1 * var(--su-1)));
background: var(--base-a90);
color: var(--base-inverted);
font-size: var(--fs-s);
padding: var(--su-2) var(--su-3);
max-width: 256px;
width: max-content;
border-radius: var(--radius);
font-family: var(--ff-sans-serif);
font-weight: var(--fw-normal);
line-height: var(--lh-tight);
transform: translateY(var(--su-1));
opacity: var(--opacity-0);
padding: var(--su-2) var(--su-3);
transition: all var(--transition-props);
text-align: left;
transition-delay: 250ms;
z-index: var(--z-popover);
pointer-events: none;
border-radius: var(--radius);
width: max-content;
opacity: var(--opacity-0);
}
.js-focus-visible &__activator.focus-visible:focus,
&__activator:hover {
.crayons-tooltip__content:not(.crayons-tooltip__suppressed) {
opacity: 1;
transition-delay: 0;
transform: translate(-50%, var(--su-1));
}
}
}
// Minimal version for hover tooltips not triggered by keyboard
.crayons-hover-tooltip[data-tooltip] {
position: relative;
&:after {
@extend .crayons-tooltip__content;
content: attr(data-tooltip);
left: 0;
bottom: 100%;
transform: translateY(var(--su-1));
}
&:hover {
z-index: var(--z-popover);
&:after {
opacity: 1;
transform: translateY(calc(-1 * var(--su-1)));

View file

@ -15,6 +15,7 @@
@import 'components/cards';
@import 'components/comments';
@import 'components/dropdowns';
@import 'components/editor-toolbar';
@import 'components/forms';
@import 'components/hamburger';
@import 'components/header';

View file

@ -52,7 +52,7 @@ module AuthenticationHelper
end
def tooltip_class_on_auth_provider_enablebtn
invite_only_mode_or_no_enabled_auth_options ? "crayons-tooltip" : ""
invite_only_mode_or_no_enabled_auth_options ? "crayons-hover-tooltip" : ""
end
def disabled_attr_on_auth_provider_enable_btn

View file

@ -0,0 +1,7 @@
<script>
var Runtime = {
currentOS: function () {
return 'macOS';
},
};
</script>

View file

@ -2,6 +2,7 @@ import { h } from 'preact';
import { addDecorator, addParameters } from '@storybook/preact';
import { DocsPage, DocsContainer } from '@storybook/addon-docs/blocks';
import { jsxDecorator } from 'storybook-addon-jsx';
import 'focus-visible';
import '../../assets/stylesheets/minimal.scss';
import '../../assets/stylesheets/views.scss';

View file

@ -270,7 +270,7 @@ export default class ConfigController extends Controller {
const targetAuthDisableBtn = document.querySelector(
'[data-enable-auth="true"]',
);
targetAuthDisableBtn.parentElement.classList.add('crayons-tooltip');
targetAuthDisableBtn.parentElement.classList.add('crayons-hover-tooltip');
targetAuthDisableBtn.parentElement.setAttribute(
'data-tooltip',
'To edit this, you must first enable Email address as a registration option',

View file

@ -1,4 +1,5 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import PropTypes from 'prop-types';
import { defaultChildrenPropTypes } from '../../common-prop-types';
@ -9,6 +10,7 @@ function getAdditionalClassNames({
size,
inverted,
disabled,
tooltip,
}) {
let additionalClassNames = '';
@ -36,6 +38,10 @@ function getAdditionalClassNames({
additionalClassNames += ` ${className}`;
}
if (tooltip) {
additionalClassNames += ` crayons-tooltip__activator`;
}
return additionalClassNames;
}
@ -57,11 +63,23 @@ export const Button = (props) => {
onMouseOut,
onFocus,
onBlur,
onKeyUp,
tabIndex,
title,
tooltip,
...restOfProps
} = props;
const [suppressTooltip, setSuppressTooltip] = useState(false);
const handleKeyUp = (event) => {
onKeyUp?.(event);
if (!tooltip) {
return;
}
setSuppressTooltip(event.key === 'Escape');
};
const ComponentName = tagName;
const Icon = icon;
const otherProps =
@ -80,12 +98,14 @@ export const Button = (props) => {
inverted,
disabled: tagName === 'a' && disabled,
children,
tooltip,
})}`}
onClick={onClick}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
onFocus={onFocus}
onBlur={onBlur}
onKeyUp={handleKeyUp}
tabIndex={tabIndex}
title={title}
{...otherProps}
@ -101,6 +121,15 @@ export const Button = (props) => {
{contentType !== 'text' && contentType === 'icon-right' && Icon && (
<Icon />
)}
{tooltip ? (
<span
className={`crayons-tooltip__content ${
suppressTooltip ? 'crayons-tooltip__suppressed' : ''
}`}
>
{tooltip}
</span>
) : null}
</ComponentName>
);
};
@ -162,4 +191,5 @@ Button.propTypes = {
onBlur: PropTypes.func,
tabIndex: PropTypes.number,
title: PropTypes.string,
tooltip: PropTypes.node,
};

View file

@ -181,4 +181,11 @@ describe('<Button /> component', () => {
expect(someEvent).toHaveBeenCalledTimes(1);
});
it('should render with a tooltip', () => {
const { container } = render(
<Button tooltip="tooltip text">Hello world!</Button>,
);
expect(container.innerHTML).toMatchSnapshot();
});
});

View file

@ -23,3 +23,5 @@ exports[`<Button /> component should render an enabled button when using default
exports[`<Button /> component should render an outlined button when using the variant "outlined" 1`] = `"<button class=\\"crayons-btn crayons-btn--outlined\\" type=\\"button\\">Hello world!</button>"`;
exports[`<Button /> component should render with a tabIndex 1`] = `"<button class=\\"crayons-btn\\" tabindex=\\"0\\" type=\\"button\\">Hello world!</button>"`;
exports[`<Button /> component should render with a tooltip 1`] = `"<button class=\\"crayons-btn crayons-tooltip__activator\\" type=\\"button\\">Hello world!<span class=\\"crayons-tooltip__content \\">tooltip text</span></button>"`;

View file

@ -0,0 +1,314 @@
/* global Runtime */
import { h } from 'preact';
import { useState, useLayoutEffect } from 'preact/hooks';
import {
coreSyntaxFormatters,
secondarySyntaxFormatters,
} from './markdownSyntaxFormatters';
import { Overflow, Help } from './icons';
import { Button } from '@crayons';
import { KeyboardShortcuts } from '@components/useKeyboardShortcuts';
import { BREAKPOINTS, useMediaQuery } from '@components/useMediaQuery';
import { getIndexOfLineStart } from '@utilities/textAreaUtils';
export const MarkdownToolbar = ({ textAreaId }) => {
const [textArea, setTextArea] = useState(null);
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
const smallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Medium - 1}px)`);
const keyboardShortcutModifierText =
Runtime.currentOS() === 'macOS' ? 'CMD' : 'CTRL';
const markdownSyntaxFormatters = {
...coreSyntaxFormatters,
...secondarySyntaxFormatters,
};
const keyboardShortcuts = Object.fromEntries(
Object.keys(markdownSyntaxFormatters)
.filter(
(syntaxName) => !!markdownSyntaxFormatters[syntaxName].keyboardShortcut,
)
.map((syntaxName) => {
const { keyboardShortcut } = markdownSyntaxFormatters[syntaxName];
return [keyboardShortcut, () => insertSyntax(syntaxName)];
}),
);
useLayoutEffect(() => {
setTextArea(document.getElementById(textAreaId));
}, [textAreaId]);
useLayoutEffect(() => {
// If a user resizes their screen, make sure roving tabindex continues to operate
const focusableToolbarButton = document.querySelector(
'.toolbar-btn[tabindex="0"]',
);
if (!focusableToolbarButton) {
document.querySelector('.toolbar-btn').setAttribute('tabindex', '0');
}
}, [smallScreen]);
useLayoutEffect(() => {
const clickOutsideHandler = ({ target }) => {
if (target.id !== 'overflow-menu-button') {
setOverflowMenuOpen(false);
}
};
const escapePressHandler = ({ key }) => {
if (key === 'Escape') {
setOverflowMenuOpen(false);
document.getElementById('overflow-menu-button').focus();
}
if (key === 'Tab') {
setOverflowMenuOpen(false);
}
};
if (overflowMenuOpen) {
document
.getElementById('overflow-menu')
.getElementsByClassName('overflow-menu-btn')[0]
.focus();
document.addEventListener('keyup', escapePressHandler);
document.addEventListener('click', clickOutsideHandler);
} else {
document.removeEventListener('keyup', escapePressHandler);
document.removeEventListener('click', clickOutsideHandler);
}
return () => {
document.removeEventListener('keyup', escapePressHandler);
document.removeEventListener('click', clickOutsideHandler);
};
}, [overflowMenuOpen]);
// Handles keyboard 'roving tabindex' pattern for toolbar
const handleToolbarButtonKeyPress = (event, className) => {
const { key, target } = event;
const {
nextElementSibling: nextButton,
previousElementSibling: previousButton,
} = target;
switch (key) {
case 'ArrowRight':
event.preventDefault();
target.setAttribute('tabindex', '-1');
if (nextButton) {
nextButton.setAttribute('tabindex', 0);
nextButton.focus();
} else {
const firstButton = document.querySelector(`.${className}`);
firstButton.setAttribute('tabindex', '0');
firstButton.focus();
}
break;
case 'ArrowLeft':
event.preventDefault();
target.setAttribute('tabindex', '-1');
if (previousButton) {
previousButton.setAttribute('tabindex', 0);
previousButton.focus();
} else {
const allButtons = document.getElementsByClassName(className);
const lastButton = allButtons[allButtons.length - 1];
lastButton.setAttribute('tabindex', '0');
lastButton.focus();
}
break;
case 'ArrowDown':
if (target.id === 'overflow-menu-button') {
event.preventDefault();
setOverflowMenuOpen(true);
}
break;
}
};
const getSelectionData = (syntaxName) => {
const {
selectionStart: initialSelectionStart,
selectionEnd,
value,
} = textArea;
let selectionStart = initialSelectionStart;
// The 'heading' formatter can edit a previously inserted syntax,
// so we check if we need adjust the selection to the start of the line
if (syntaxName === 'heading') {
const indexOfLineStart = getIndexOfLineStart(
textArea.value,
initialSelectionStart,
);
if (textArea.value.charAt(indexOfLineStart + 1) === '#') {
selectionStart = indexOfLineStart;
}
}
const textBeforeInsertion = value.substring(0, selectionStart);
const textAfterInsertion = value.substring(selectionEnd, value.length);
const selectedText = value.substring(selectionStart, selectionEnd);
return {
textBeforeInsertion,
textAfterInsertion,
selectedText,
selectionStart,
selectionEnd,
};
};
const insertSyntax = (syntaxName) => {
setOverflowMenuOpen(false);
const {
textBeforeInsertion,
textAfterInsertion,
selectedText,
selectionStart,
selectionEnd,
} = getSelectionData(syntaxName);
const { formattedText, cursorOffsetStart, cursorOffsetEnd } =
markdownSyntaxFormatters[syntaxName].getFormatting(selectedText);
const newTextContent = `${textBeforeInsertion}${formattedText}${textAfterInsertion}`;
textArea.value = newTextContent;
textArea.focus({ preventScroll: true });
textArea.setSelectionRange(
selectionStart + cursorOffsetStart,
selectionEnd + cursorOffsetEnd,
);
};
const getSecondaryFormatterButtons = (isOverflow) =>
Object.keys(secondarySyntaxFormatters).map((controlName, index) => {
const { icon, label, keyboardShortcutKeys } =
secondarySyntaxFormatters[controlName];
return (
<Button
key={`${controlName}-btn`}
role={isOverflow ? 'menuitem' : 'button'}
variant="ghost"
contentType="icon"
icon={icon}
className={
isOverflow
? 'overflow-menu-btn hidden m:block mr-2'
: 'toolbar-btn m:hidden mr-2'
}
tabindex={isOverflow && index === 0 ? '0' : '-1'}
onClick={() => insertSyntax(controlName)}
onKeyUp={(e) =>
handleToolbarButtonKeyPress(
e,
isOverflow ? 'overflow-menu-btn' : 'toolbar-btn',
)
}
aria-label={label}
tooltip={
smallScreen ? null : (
<span aria-hidden="true">
{label}
{keyboardShortcutKeys ? (
<span className="opacity-75">
{` ${keyboardShortcutModifierText} + ${keyboardShortcutKeys}`}
</span>
) : null}
</span>
)
}
/>
);
});
return (
<div
className="editor-toolbar relative overflow-x-auto m:overflow-visible"
aria-label="Markdown formatting toolbar"
role="toolbar"
aria-controls={textAreaId}
>
{Object.keys(coreSyntaxFormatters).map((controlName, index) => {
const { icon, label, keyboardShortcutKeys } =
coreSyntaxFormatters[controlName];
return (
<Button
key={`${controlName}-btn`}
variant="ghost"
contentType="icon"
icon={icon}
className="toolbar-btn mr-2"
tabindex={index === 0 ? '0' : '-1'}
onClick={() => insertSyntax(controlName)}
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'toolbar-btn')}
aria-label={label}
tooltip={
smallScreen ? null : (
<span aria-hidden="true">
{label}
{keyboardShortcutKeys ? (
<span className="opacity-75">
{` ${keyboardShortcutModifierText} + ${keyboardShortcutKeys}`}
</span>
) : null}
</span>
)
}
/>
);
})}
{smallScreen ? getSecondaryFormatterButtons(false) : null}
{smallScreen ? null : (
<Button
id="overflow-menu-button"
onClick={() => setOverflowMenuOpen(!overflowMenuOpen)}
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'toolbar-btn')}
aria-expanded={overflowMenuOpen ? 'true' : 'false'}
aria-haspopup="true"
variant="ghost"
contentType="icon"
icon={Overflow}
className="toolbar-btn ml-auto hidden m:block"
tabindex="-1"
aria-label="More options"
/>
)}
{overflowMenuOpen && (
<div
id="overflow-menu"
role="menu"
className="crayons-dropdown flex p-2 min-w-unset right-0 top-100"
>
{getSecondaryFormatterButtons(true)}
<Button
tagName="a"
role="menuitem"
url="/p/editor_guide"
variant="ghost"
contentType="icon"
icon={Help}
className="overflow-menu-btn"
tabindex="-1"
aria-label="Help"
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'overflow-menu-btn')}
/>
</div>
)}
{textArea && (
<KeyboardShortcuts
shortcuts={keyboardShortcuts}
eventTarget={textArea}
/>
)}
</div>
);
};

View file

@ -0,0 +1,30 @@
import { h } from 'preact';
import { MarkdownToolbar } from '@crayons';
export default {
title: 'App Components/MarkdownToolbar',
};
export const Default = () => {
return (
<div>
<div
style={{
padding: 'var(--su-2) var(--content-padding-x)',
background: 'var(--base-0)',
}}
>
<MarkdownToolbar textAreaId="text-area" />
</div>
<textarea
id="text-area"
aria-label="Editor"
className="crayons-textfield min-h-full border-0 radius-0"
/>
</div>
);
};
Default.story = {
name: 'MarkdownToolbar',
};

View file

@ -0,0 +1,55 @@
import { h } from 'preact';
import { render, waitFor } from '@testing-library/preact';
import { axe } from 'jest-axe';
import '@testing-library/jest-dom';
import { MarkdownToolbar } from '../MarkdownToolbar';
describe('<MarkdownToolbar />', () => {
beforeEach(() => {
global.Runtime = {
currentOS: jest.fn(() => 'macOS'),
};
global.window.matchMedia = jest.fn((query) => {
return {
matches: false,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
};
});
});
it('should have no a11y violations when rendered', async () => {
const { container } = render(<MarkdownToolbar />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should render core syntax formatters in main toolbar', () => {
const { getByLabelText } = render(<MarkdownToolbar />);
expect(getByLabelText('Bold')).toBeInTheDocument();
expect(getByLabelText('Italic')).toBeInTheDocument();
expect(getByLabelText('Ordered list')).toBeInTheDocument();
expect(getByLabelText('Unordered list')).toBeInTheDocument();
expect(getByLabelText('Heading')).toBeInTheDocument();
expect(getByLabelText('Quote')).toBeInTheDocument();
expect(getByLabelText('Code')).toBeInTheDocument();
expect(getByLabelText('Code block')).toBeInTheDocument();
});
it('should render an overflow menu with secondary formatters and help link', async () => {
const { getByLabelText } = render(<MarkdownToolbar />);
getByLabelText('More options').click();
await waitFor(() =>
expect(getByLabelText('Underline')).toBeInTheDocument(),
);
expect(getByLabelText('Strikethrough')).toBeInTheDocument();
expect(getByLabelText('Line divider')).toBeInTheDocument();
expect(getByLabelText('Help')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,258 @@
import {
coreSyntaxFormatters,
secondarySyntaxFormatters,
} from '../markdownSyntaxFormatters';
describe('markdownSntaxFormatters', () => {
const exampleTextSelection = 'selection';
it('formats bold text', () => {
expect(
coreSyntaxFormatters['bold'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '**selection**',
cursorOffsetStart: 2,
cursorOffsetEnd: 2,
});
});
it('formats italic text', () => {
expect(
coreSyntaxFormatters['italic'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '_selection_',
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
});
});
it('formats a link with an empty selection', () => {
expect(coreSyntaxFormatters['link'].getFormatting('')).toEqual({
formattedText: '[](url)',
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
});
});
it('formats a link with a non-URL selection', () => {
expect(
coreSyntaxFormatters['link'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '[selection](url)',
cursorOffsetStart: 12,
cursorOffsetEnd: 6,
});
});
it('formats a link with an http URL selection', () => {
expect(
coreSyntaxFormatters['link'].getFormatting('http://myurl.com'),
).toEqual({
formattedText: '[](http://myurl.com)',
cursorOffsetStart: 1,
cursorOffsetEnd: -15,
});
});
it('formats a link with an https URL selection', () => {
expect(
coreSyntaxFormatters['link'].getFormatting('https://myurl.com'),
).toEqual({
formattedText: '[](https://myurl.com)',
cursorOffsetStart: 1,
cursorOffsetEnd: -16,
});
});
it('formats an unordered list from an empty selection', () => {
expect(coreSyntaxFormatters['unorderedList'].getFormatting('')).toEqual({
formattedText: '\n- ',
cursorOffsetStart: 3,
cursorOffsetEnd: 3,
});
});
it('formats an unordered list from a single line selection', () => {
expect(
coreSyntaxFormatters['unorderedList'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '\n- selection\n',
cursorOffsetStart: 1,
cursorOffsetEnd: 4,
});
});
it('formats an unordered list from a multi-line selection', () => {
expect(
coreSyntaxFormatters['unorderedList'].getFormatting('one\ntwo'),
).toEqual({
formattedText: '\n- one\n- two\n',
cursorOffsetStart: 1,
cursorOffsetEnd: 6,
});
});
it('formats an ordered list from an empty selection', () => {
expect(coreSyntaxFormatters['orderedList'].getFormatting('')).toEqual({
formattedText: '\n1. ',
cursorOffsetStart: 4,
cursorOffsetEnd: 4,
});
});
it('formats an ordered list from a single line selection', () => {
expect(
coreSyntaxFormatters['orderedList'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '\n1. selection\n',
cursorOffsetStart: 1,
cursorOffsetEnd: 5,
});
});
it('formats an ordered list from a multi-line selection', () => {
expect(
coreSyntaxFormatters['orderedList'].getFormatting('one\ntwo'),
).toEqual({
formattedText: '\n1. one\n2. two\n',
cursorOffsetStart: 1,
cursorOffsetEnd: 8,
});
});
it('Formats a heading from an empty selection', () => {
expect(coreSyntaxFormatters['heading'].getFormatting('')).toEqual({
formattedText: '\n## \n',
cursorOffsetStart: 4,
cursorOffsetEnd: 4,
});
});
it('Formats a heading from a selection with no heading level', () => {
expect(
coreSyntaxFormatters['heading'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '\n## selection\n',
cursorOffsetStart: 4,
cursorOffsetEnd: 4,
});
});
it('Formats a heading from a selection with a heading level 2', () => {
expect(
coreSyntaxFormatters['heading'].getFormatting('## selection'),
).toEqual({
formattedText: '### selection',
cursorOffsetStart: 4,
cursorOffsetEnd: 1,
});
});
it('Formats a heading from a selection with a heading level 3', () => {
expect(
coreSyntaxFormatters['heading'].getFormatting('### selection'),
).toEqual({
formattedText: '#### selection',
cursorOffsetStart: 5,
cursorOffsetEnd: 1,
});
});
it('Formats a heading from a selection with a heading level 4 by returning same selection', () => {
expect(
coreSyntaxFormatters['heading'].getFormatting('#### selection'),
).toEqual({
formattedText: '#### selection',
cursorOffsetStart: 5,
cursorOffsetEnd: 0,
});
});
it('formats a quote with empty selection', () => {
expect(coreSyntaxFormatters['quote'].getFormatting('')).toEqual({
formattedText: '\n> \n',
cursorOffsetStart: 3,
cursorOffsetEnd: 3,
});
});
it('formats a quote on a single-line selection', () => {
expect(
coreSyntaxFormatters['quote'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '\n> selection\n',
cursorOffsetStart: 3,
cursorOffsetEnd: 4,
});
});
it('formats a quote on a multi-line selection', () => {
expect(coreSyntaxFormatters['quote'].getFormatting('one\ntwo')).toEqual({
formattedText: '\n> one\n> two\n',
cursorOffsetStart: 3,
cursorOffsetEnd: 6,
});
});
it('formats inline code', () => {
expect(
coreSyntaxFormatters['code'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '`selection`',
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
});
});
it('formats a code block', () => {
expect(
coreSyntaxFormatters['codeBlock'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: '\n```\nselection\n```\n',
cursorOffsetStart: 5,
cursorOffsetEnd: 5,
});
});
it('formats underline text', () => {
expect(
secondarySyntaxFormatters['underline'].getFormatting(
exampleTextSelection,
),
).toEqual({
formattedText: '<u>selection</u>',
cursorOffsetStart: 3,
cursorOffsetEnd: 3,
});
});
it('formats strikethrough text', () => {
expect(
secondarySyntaxFormatters['strikethrough'].getFormatting(
exampleTextSelection,
),
).toEqual({
formattedText: '~~selection~~',
cursorOffsetStart: 2,
cursorOffsetEnd: 2,
});
});
it('adds a line divider when selection is empty', () => {
expect(secondarySyntaxFormatters['divider'].getFormatting('')).toEqual({
formattedText: '\n---\n',
cursorOffsetStart: 5,
cursorOffsetEnd: 5,
});
});
it('adds a line divider after given selection ', () => {
expect(
secondarySyntaxFormatters['divider'].getFormatting(exampleTextSelection),
).toEqual({
formattedText: 'selection\n---\n',
cursorOffsetStart: 14,
cursorOffsetEnd: 14,
});
});
});

View file

@ -0,0 +1,185 @@
import { h } from 'preact';
export const Bold = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m8 11h4.5c.663 0 1.2989-.2634 1.7678-.7322.4688-.46887.7322-1.10476.7322-1.7678s-.2634-1.29893-.7322-1.76777c-.4689-.46884-1.1048-.73223-1.7678-.73223h-4.5zm10 4.5c0 .5909-.1164 1.1761-.3425 1.7221-.2262.5459-.5577 1.042-.9755 1.4599-.4179.4178-.914.7493-1.4599.9755-.546.2261-1.1312.3425-1.7221.3425h-7.5v-16h6.5c.881.00004 1.7425.25865 2.4779.74378.7353.48512 1.3121 1.17542 1.6588 1.98529s.448 1.70369.2915 2.57062c-.1566.86691-.5641 1.66881-1.1722 2.30631.6826.3953 1.2493.9632 1.6432 1.6466.3939.6835.6011 1.4586.6008 2.2474zm-10-2.5v5h5.5c.663 0 1.2989-.2634 1.7678-.7322.4688-.4689.7322-1.1048.7322-1.7678s-.2634-1.2989-.7322-1.7678c-.4689-.4688-1.1048-.7322-1.7678-.7322z" />
</svg>
);
export const Italic = () => (
<svg
className="crayons-icon"
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 20H7V18H9.927L12.043 6H9V4H17V6H14.073L11.957 18H15V20Z"
fill="black"
/>
</svg>
);
export const Link = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m18.364 15.536-1.414-1.416 1.414-1.414c.4676-.4636.8391-1.0149 1.0931-1.6224.2539-.6075.3854-1.2592.3869-1.91765.0014-.65845-.1272-1.3107-.3785-1.9193-.2513-.60861-.6204-1.16158-1.086-1.62718s-1.0186-.83464-1.6272-1.08596c-.6086-.25131-1.2608-.37994-1.9193-.37849-.6584.00144-1.3101.13292-1.9176.3869-.6075.25397-1.1589.62544-1.6224 1.09307l-1.414 1.415-1.415-1.414 1.416-1.414c1.3128-1.31282 3.0934-2.05036 4.95-2.05036s3.6372.73754 4.95 2.05036 2.0504 3.09339 2.0504 4.95-.7376 3.63721-2.0504 4.95001l-1.415 1.414zm-2.828 2.828-1.415 1.414c-1.3128 1.3128-3.0934 2.0503-4.95 2.0503-1.85661 0-3.63718-.7375-4.95-2.0503-1.31283-1.3128-2.05036-3.0934-2.05036-4.95s.73753-3.6372 2.05036-4.95001l1.415-1.414 1.414 1.416-1.414 1.41401c-.46763.4635-.8391 1.0149-1.09308 1.6224-.25397.6075-.38545 1.2592-.3869 1.9176-.00144.6585.12719 1.3107.3785 1.9193s.62036 1.1616 1.08596 1.6272c.46559.4656 1.01857.8347 1.62717 1.086.60861.2513 1.26086.3799 1.91931.3785.65845-.0015 1.31014-.133 1.91764-.3869.6075-.254 1.1588-.6255 1.6224-1.0931l1.414-1.414 1.415 1.414zm-.708-10.60701 1.415 1.415-7.071 7.07001-1.415-1.414 7.071-7.07001z" />
</svg>
);
export const OrderedList = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m8 4h13v2h-13zm-3-1v3h1v1h-3v-1h1v-2h-1v-1zm-2 11v-2.5h2v-.5h-2v-1h3v2.5h-2v.5h2v1zm2 5.5h-2v-1h2v-.5h-2v-1h3v4h-3v-1h2zm3-8.5h13v2h-13zm0 7h13v2h-13z" />
</svg>
);
export const UnorderedList = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m8 4h13v2h-13zm-3.5 2.5c-.39782 0-.77936-.15804-1.06066-.43934s-.43934-.66284-.43934-1.06066.15804-.77936.43934-1.06066.66284-.43934 1.06066-.43934.77936.15804 1.06066.43934.43934.66284.43934 1.06066-.15804.77936-.43934 1.06066-.66284.43934-1.06066.43934zm0 7c-.39782 0-.77936-.158-1.06066-.4393s-.43934-.6629-.43934-1.0607.15804-.7794.43934-1.0607.66284-.4393 1.06066-.4393.77936.158 1.06066.4393.43934.6629.43934 1.0607-.15804.7794-.43934 1.0607-.66284.4393-1.06066.4393zm0 6.9c-.39782 0-.77936-.158-1.06066-.4393s-.43934-.6629-.43934-1.0607.15804-.7794.43934-1.0607.66284-.4393 1.06066-.4393.77936.158 1.06066.4393.43934.6629.43934 1.0607-.15804.7794-.43934 1.0607-.66284.4393-1.06066.4393zm3.5-9.4h13v2h-13zm0 7h13v2h-13z" />
</svg>
);
export const Heading = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m17 11v-7h2v17h-2v-8h-10v8h-2v-17h2v7z" />
</svg>
);
export const Quote = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m4.583 17.321c-1.03-1.094-1.583-2.321-1.583-4.31 0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489 0 .9283-.3687 1.8185-1.02513 2.4749-.65637.6563-1.54661 1.0251-2.47487 1.0251-1.073 0-2.099-.49-2.748-1.179zm10 0c-1.03-1.094-1.583-2.321-1.583-4.31 0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.804.167 3.226 1.648 3.226 3.489 0 .9283-.3687 1.8185-1.0251 2.4749-.6564.6563-1.5466 1.0251-2.4749 1.0251-1.073 0-2.099-.49-2.748-1.179z" />
</svg>
);
export const Code = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m23 12-7.071 7.071-1.414-1.414 5.657-5.657-5.657-5.65704 1.414-1.414zm-19.172 0 5.657 5.657-1.414 1.414-7.071-7.071 7.071-7.07104 1.414 1.414z" />
</svg>
);
export const CodeBlock = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m3 3h18c.2652 0 .5196.10536.7071.29289.1875.18754.2929.44189.2929.70711v16c0 .2652-.1054.5196-.2929.7071s-.4419.2929-.7071.2929h-18c-.26522 0-.51957-.1054-.70711-.2929-.18753-.1875-.29289-.4419-.29289-.7071v-16c0-.26522.10536-.51957.29289-.70711.18754-.18753.44189-.29289.70711-.29289zm1 2v14h16v-14zm15 7-3.536 3.536-1.414-1.415 2.122-2.121-2.122-2.121 1.414-1.415zm-11.172 0 2.122 2.121-1.414 1.415-3.536-3.536 3.536-3.536 1.414 1.416z" />
</svg>
);
export const Overflow = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="m12 17c1.1046 0 2 .8954 2 2s-.8954 2-2 2-2-.8954-2-2 .8954-2 2-2zm0-7c1.1046 0 2 .8954 2 2s-.8954 2-2 2-2-.8954-2-2 .8954-2 2-2zm2-5c0-1.10457-.8954-2-2-2s-2 .89543-2 2 .8954 2 2 2 2-.89543 2-2z"
fill-rule="evenodd"
/>
</svg>
);
export const Underline = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m8 3v9c0 1.0609.42143 2.0783 1.17157 2.8284.75015.7502 1.76753 1.1716 2.82843 1.1716s2.0783-.4214 2.8284-1.1716c.7502-.7501 1.1716-1.7675 1.1716-2.8284v-9h2v9c0 1.5913-.6321 3.1174-1.7574 4.2426-1.1252 1.1253-2.6513 1.7574-4.2426 1.7574s-3.11742-.6321-4.24264-1.7574c-1.12522-1.1252-1.75736-2.6513-1.75736-4.2426v-9zm-4 17h16v2h-16z" />
</svg>
);
export const Strikethrough = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m17.154 14c.23.516.346 1.09.346 1.72 0 1.342-.524 2.392-1.571 3.147-1.049.755-2.496 1.133-4.343 1.133-1.64 0-3.263-.381-4.87-1.144v-2.256c1.52.877 3.075 1.316 4.666 1.316 2.551 0 3.83-.732 3.839-2.197.0053-.297-.0494-.5921-.1607-.8675-.1114-.2754-.2771-.5256-.4873-.7355l-.12-.117h-11.453v-2h18v2h-3.846zm-4.078-3h-5.447c-.17517-.1597-.33611-.3344-.481-.522-.432-.558-.648-1.232-.648-2.026 0-1.236.466-2.287 1.397-3.153.933-.866 2.374-1.299 4.325-1.299 1.471 0 2.879.328 4.222.984v2.152c-1.2-.687-2.515-1.03-3.946-1.03-2.48 0-3.719.782-3.719 2.346 0 .42.218.786.654 1.099s.974.562 1.613.75c.62.18 1.297.414 2.03.699z" />
</svg>
);
export const Divider = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path d="m2 11h6v2h-6z" />
<path d="m2 11h6v2h-6z" />
<path d="m9 11h6v2h-6z" />
<path d="m16 11h6v2h-6z" />
<g clip-rule="evenodd" fill-rule="evenodd">
<path d="m12 6.58574-2.29288-2.29289-1.41421 1.41421 3.70709 3.70711 3.7071-3.70711-1.4142-1.41421z" />
<path d="m12 17.4143-2.29288 2.2929-1.41421-1.4143 3.70709-3.7071 3.7071 3.7071-1.4142 1.4143z" />
</g>
</g>
</svg>
);
export const Help = () => (
<svg
className="crayons-icon"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="m12 22c-5.523 0-10-4.477-10-10s4.477-10 10-10 10 4.477 10 10-4.477 10-10 10zm0-2c2.1217 0 4.1566-.8429 5.6569-2.3431 1.5002-1.5003 2.3431-3.5352 2.3431-5.6569 0-2.12173-.8429-4.15656-2.3431-5.65685-1.5003-1.5003-3.5352-2.34315-5.6569-2.34315-2.12173 0-4.15656.84285-5.65685 2.34315-1.5003 1.50029-2.34315 3.53512-2.34315 5.65685 0 2.1217.84285 4.1566 2.34315 5.6569 1.50029 1.5002 3.53512 2.3431 5.65685 2.3431zm-1-5h2v2h-2zm2-1.645v.645h-2v-1.5c0-.2652.1054-.5196.2929-.7071s.4419-.2929.7071-.2929c.2841 0 .5623-.0807.8023-.2327.24-.1519.432-.3689.5535-.6257s.1676-.5428.1329-.82476c-.0347-.28195-.1487-.54826-.3289-.76793-.1801-.21967-.4189-.38368-.6886-.47294s-.5592-.1001-.8348-.03126c-.2756.06883-.526.21452-.722.42011-.1961.20558-.3297.46261-.3854.74118l-1.962-.393c.12163-.60792.40251-1.17263.81392-1.63641.41141-.46379.93858-.81001 1.52768-1.00327s1.2189-.22663 1.8251-.09671c.6062.12993 1.167.4185 1.6251.83621.4581.41772.7971.94959.9823 1.54124.1852.59166.21 1.22184.0718 1.82624s-.4344 1.1612-.8584 1.6136c-.4239.4523-.9604.784-1.5545.9611z" />
</svg>
);

View file

@ -0,0 +1 @@
export * from './MarkdownToolbar';

View file

@ -0,0 +1,201 @@
import {
Bold,
Italic,
Link,
OrderedList,
UnorderedList,
Heading,
Quote,
Code,
CodeBlock,
Underline,
Strikethrough,
Divider,
} from './icons';
const isStringStartAUrl = (string) => {
const startingText = string.substring(0, 8);
return startingText === 'https://' || startingText.startsWith('http://');
};
export const coreSyntaxFormatters = {
bold: {
icon: Bold,
label: 'Bold',
keyboardShortcut: 'ctrl+b',
keyboardShortcutKeys: `B`,
getFormatting: (selection) => ({
formattedText: `**${selection}**`,
cursorOffsetStart: 2,
cursorOffsetEnd: 2,
}),
},
italic: {
icon: Italic,
label: 'Italic',
keyboardShortcut: 'ctrl+i',
keyboardShortcutKeys: `I`,
getFormatting: (selection) => ({
formattedText: `_${selection}_`,
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
}),
},
link: {
icon: Link,
label: 'Link',
keyboardShortcut: 'ctrl+k',
keyboardShortcutKeys: `K`,
getFormatting: (selection) => {
const isUrl = isStringStartAUrl(selection);
const selectionLength = selection.length;
if (selectionLength === 0) {
return {
formattedText: '[](url)',
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
};
}
return {
formattedText: isUrl ? `[](${selection})` : `[${selection}](url)`,
cursorOffsetStart: isUrl ? 1 : selectionLength + 3,
cursorOffsetEnd: isUrl ? -1 * (selectionLength - 1) : 6,
};
},
},
orderedList: {
icon: OrderedList,
label: 'Ordered list',
getFormatting: (selection) => {
let newString = `\n${selection
.split('\n')
.map((textChunk, index) => `${index + 1}. ${textChunk}`)
.join('\n')}`;
if (selection !== '') {
newString += '\n';
}
return {
formattedText: newString,
cursorOffsetStart: selection.length === 0 ? 4 : 1,
cursorOffsetEnd: newString.length - selection.length,
};
},
},
unorderedList: {
icon: UnorderedList,
label: 'Unordered list',
getFormatting: (selection) => {
const bulletedSelection = selection.replace(/\n/g, '\n- ');
let newString = `\n- ${bulletedSelection}`;
if (selection !== '') {
newString += '\n';
}
return {
formattedText: newString,
cursorOffsetStart: selection.length === 0 ? 3 : 1,
cursorOffsetEnd: newString.length - selection.length,
};
},
},
heading: {
icon: Heading,
label: 'Heading',
getFormatting: (selection) => {
let currentHeadingIndex = 0;
while (selection.charAt(currentHeadingIndex) === '#') {
currentHeadingIndex++;
}
// Only allow up to h4
if (currentHeadingIndex === 4) {
return {
formattedText: selection,
cursorOffsetStart: 5,
cursorOffsetEnd: 0,
};
}
const adjustingHeading = currentHeadingIndex > 0;
return {
formattedText: adjustingHeading
? `#${selection}`
: `\n## ${selection}\n`,
cursorOffsetStart: adjustingHeading ? currentHeadingIndex + 2 : 4,
cursorOffsetEnd: adjustingHeading ? 1 : 4,
};
},
},
quote: {
icon: Quote,
label: 'Quote',
getFormatting: (selection) => {
const quotedSelection = selection.replace(/\n/g, '\n> ');
const newString = `\n> ${quotedSelection}\n`;
return {
formattedText: newString,
cursorOffsetStart: 3,
cursorOffsetEnd:
selection === '' ? 3 : newString.length - selection.length,
};
},
},
code: {
icon: Code,
label: 'Code',
getFormatting: (selection) => ({
formattedText: `\`${selection}\``,
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
}),
},
codeBlock: {
icon: CodeBlock,
label: 'Code block',
getFormatting: (selection) => ({
formattedText: `\n\`\`\`\n${selection}\n\`\`\`\n`,
cursorOffsetStart: 5,
cursorOffsetEnd: 5,
}),
},
};
export const secondarySyntaxFormatters = {
underline: {
icon: Underline,
label: 'Underline',
keyboardShortcut: 'ctrl+u',
keyboardShortcutKeys: `U`,
getFormatting: (selection) => ({
formattedText: `<u>${selection}</u>`,
cursorOffsetStart: 3,
cursorOffsetEnd: 3,
}),
},
strikethrough: {
icon: Strikethrough,
label: 'Strikethrough',
keyboardShortcut: 'ctrl+shift+x',
keyboardShortcutKeys: `SHIFT + X`,
getFormatting: (selection) => ({
formattedText: `~~${selection}~~`,
cursorOffsetStart: 2,
cursorOffsetEnd: 2,
}),
},
divider: {
icon: Divider,
label: 'Line divider',
getFormatting: (selection) => ({
formattedText: `${selection}\n---\n`,
cursorOffsetStart: selection.length + 5,
cursorOffsetEnd: selection.length + 5,
}),
},
};

View file

@ -6,3 +6,4 @@ export * from '@crayons/Modal';
export * from '@crayons/Spinner';
export * from '@crayons/MobileDrawer';
export * from '@crayons/navigation';
export * from '@crayons/MarkdownToolbar';

View file

@ -1,4 +1,4 @@
import { getMentionWordData } from '../textAreaUtils';
import { getMentionWordData, getIndexOfLineStart } from '../textAreaUtils';
describe('getMentionWordData', () => {
it('returns userMention false for cursor at start of input', () => {
@ -7,9 +7,8 @@ describe('getMentionWordData', () => {
value: 'text with @mention',
};
const { isUserMention, indexOfMentionStart } = getMentionWordData(
inputState,
);
const { isUserMention, indexOfMentionStart } =
getMentionWordData(inputState);
expect(isUserMention).toBe(false);
expect(indexOfMentionStart).toEqual(-1);
});
@ -20,9 +19,8 @@ describe('getMentionWordData', () => {
value: '',
};
const { isUserMention, indexOfMentionStart } = getMentionWordData(
inputState,
);
const { isUserMention, indexOfMentionStart } =
getMentionWordData(inputState);
expect(isUserMention).toBe(false);
expect(indexOfMentionStart).toEqual(-1);
});
@ -33,9 +31,8 @@ describe('getMentionWordData', () => {
value: 'text with no mention',
};
const { isUserMention, indexOfMentionStart } = getMentionWordData(
inputState,
);
const { isUserMention, indexOfMentionStart } =
getMentionWordData(inputState);
expect(isUserMention).toBe(false);
expect(indexOfMentionStart).toEqual(-1);
});
@ -46,9 +43,8 @@ describe('getMentionWordData', () => {
value: '@mention',
};
const { isUserMention, indexOfMentionStart } = getMentionWordData(
inputState,
);
const { isUserMention, indexOfMentionStart } =
getMentionWordData(inputState);
expect(isUserMention).toBe(true);
expect(indexOfMentionStart).toEqual(0);
});
@ -59,10 +55,23 @@ describe('getMentionWordData', () => {
value: 'text with @mention',
};
const { isUserMention, indexOfMentionStart } = getMentionWordData(
inputState,
);
const { isUserMention, indexOfMentionStart } =
getMentionWordData(inputState);
expect(isUserMention).toBe(true);
expect(indexOfMentionStart).toEqual(10);
});
});
describe('getIndexOfLineStart', () => {
it('returns 0 for empty text', () => {
expect(getIndexOfLineStart('', 0)).toEqual(0);
});
it('returns start index of 0 for a single line of text', () => {
expect(getIndexOfLineStart('something', 5)).toEqual(0);
});
it('returns start index of line for a multi-line text', () => {
expect(getIndexOfLineStart('one\ntwo', 6)).toEqual(4);
});
});

View file

@ -128,9 +128,8 @@ const getIndexOfCurrentWordAutocompleteSymbol = (content, selectionIndex) => {
*/
export const useTextAreaAutoResize = () => {
const [textArea, setTextArea] = useState(null);
const [constrainToContentHeight, setConstrainToContentHeight] = useState(
false,
);
const [constrainToContentHeight, setConstrainToContentHeight] =
useState(false);
const [additionalElements, setAdditionalElements] = useState([]);
useEffect(() => {
@ -167,3 +166,23 @@ export const useTextAreaAutoResize = () => {
return { setTextArea, setAdditionalElements, setConstrainToContentHeight };
};
/**
* Helper function to return the index of the current line's starting point
*
* @param {string} text The text value of the textArea
* @param {number} cursorStart The current position of the user's cursor
* @returns
*/
export const getIndexOfLineStart = (text, cursorStart) => {
const currentCharacter = text.charAt(cursorStart - 1);
if (currentCharacter === '\n') {
return cursorStart;
}
if (cursorStart !== 0) {
return getIndexOfLineStart(text, cursorStart - 1);
}
return 0;
};

View file

@ -15,7 +15,7 @@
General settings
</h3>
<div
class="crayons-field crayons-field--checkbox <%= invite_only_mode_or_no_enabled_auth_options ? "crayons-tooltip" : "" %>"
class="crayons-field crayons-field--checkbox <%= invite_only_mode_or_no_enabled_auth_options ? "crayons-hover-tooltip" : "" %>"
data-tooltip="Unchecking this will enable Email Registration">
<%= f.check_box :invite_only_mode,
checked: invite_only_mode_or_no_enabled_auth_options,

View file

@ -2,7 +2,7 @@
<a href="<%= URL.user(comment.user) %>" class="crayons-link crayons-link--secondary flex items-center fw-medium m:hidden">
<span class="js-comment-username"><%= comment.user.name %></span>
<% if commentable_author_is_op?(commentable, comment) %>
<span class="crayons-tooltip inline-block spec-op-author -mr-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
<span class="crayons-hover-tooltip inline-block spec-op-author -mr-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
<%= inline_svg_tag("small-medal.svg", aria: true, class: "crayons-icon", title: get_ama_or_op_banner(commentable)) %>
</span>
<% end %>
@ -13,7 +13,7 @@
</button>
<%= render "/shared/profile_preview_card", actor: comment.user, id: "comment-profile-preview-content-#{comment.id}" %>
<% if commentable_author_is_op?(commentable, comment) %>
<span class="crayons-tooltip inline-block spec-op-author -ml-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
<span class="crayons-hover-tooltip inline-block spec-op-author -ml-2" data-tooltip="<%= get_ama_or_op_banner(commentable) %>">
<%= inline_svg_tag("small-medal.svg", aria: true, class: "crayons-icon", title: get_ama_or_op_banner(commentable)) %>
</span>
<% end %>

View file

@ -52,7 +52,7 @@
<div class="crayons-field mt-2">
<%= f.label :name, class: "crayons-field__label", aria: { label: "Name" } do %>
Name
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
<% end %>
<%= f.text_field :name, class: "crayons-textfield", required: true %>
</div>
@ -60,7 +60,7 @@
<div class="crayons-field mt-2">
<%= f.label :username, class: "crayons-field__label", aria: { label: "Username" } do %>
Username
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
<% end %>
<%= f.text_field :username, class: "crayons-textfield", required: true %>
</div>
@ -68,7 +68,7 @@
<div class="crayons-field mt-2">
<%= f.label :email, class: "crayons-field__label", aria: { label: "Email" } do %>
Email
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
<% end %>
<%= f.email_field :email, autocomplete: "email", class: "crayons-textfield", required: true %>
</div>
@ -76,7 +76,7 @@
<div class="crayons-field mt-2">
<%= f.label :password, class: "crayons-field__label", aria: { label: "Password" } do %>
Password
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
<% end %>
<%= f.password_field :password, autocomplete: "current-password", class: "crayons-textfield", required: true %>
</div>
@ -84,7 +84,7 @@
<div class="crayons-field mt-2">
<%= f.label :password_confirmation, class: "crayons-field__label", aria: { label: "Password Confirmation" } do %>
Password Confirmation
<span class="crayons-field__required crayons-tooltip" data-tooltip="* - required">*</span>
<span class="crayons-field__required crayons-hover-tooltip" data-tooltip="* - required">*</span>
<% end %>
<%= f.password_field :password_confirmation, autocomplete: "current-password", class: "crayons-textfield", required: true %>
</div>

View file

@ -81,8 +81,8 @@ RSpec.describe AuthenticationHelper, type: :helper do
allow(Settings::Authentication).to receive(:allow_email_password_registration).and_return(false)
end
it "returns 'crayons-tooltip' class for relevant helpers" do
expect(tooltip_class_on_auth_provider_enablebtn).to eq("crayons-tooltip")
it "returns 'crayons-hover-tooltip' class for relevant helpers" do
expect(tooltip_class_on_auth_provider_enablebtn).to eq("crayons-hover-tooltip")
end
it "returns 'disabled' attribute for relevant helper" do