feat: standardize class names (#873)

This commit is contained in:
Daniel Lautzenheiser
2023-09-14 09:49:51 -04:00
committed by GitHub
parent 7e1734aab6
commit 1338ad2f57
305 changed files with 8639 additions and 5405 deletions

View File

@ -2,14 +2,26 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import Field from '@staticcms/core/components/common/field/Field';
import Switch from '@staticcms/core/components/common/switch/Switch';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { BooleanField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react';
const classes = generateClassNames('WidgetBoolean', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'input',
]);
const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
value,
label,
errors,
hasErrors,
disabled,
field,
forSingleList,
@ -41,8 +53,21 @@ const BooleanControl: FC<WidgetControlProps<boolean, BooleanField>> = ({
hint={field.hint}
forSingleList={forSingleList}
disabled={disabled}
rootClassName={classNames(
classes.root,
disabled && classes.disabled,
field.required !== false && classes.required,
hasErrors && classes.error,
forSingleList && classes['for-single-list'],
)}
>
<Switch ref={ref} value={internalValue} disabled={disabled} onChange={handleChange} />
<Switch
ref={ref}
value={internalValue}
disabled={disabled}
onChange={handleChange}
rootClassName={classes.input}
/>
</Field>
);
};

View File

@ -19,30 +19,6 @@ describe(BooleanControl.name, () => {
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('inline-field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('inline-field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Boolean Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// Boolean Widget uses inline label layout, with bottom padding on field
expect(label).not.toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('switch-input')).toBeInTheDocument();
const fieldWrapper = getByTestId('inline-field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
@ -101,12 +77,6 @@ describe(BooleanControl.name, () => {
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('inline-field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('should focus input on field click', async () => {

View File

@ -0,0 +1,82 @@
.CMS_WidgetCode_root {
@apply relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100;
&.CMS_WidgetCode_for-single-list {
& .CMS_WidgetCode_field-wrapper {
@apply mr-14;
}
}
&.CMS_WidgetCode_disabled {
& .CMS_WidgetCode_expand-button {
@apply cursor-default;
}
& .CMS_WidgetCode_expand-button-icon {
@apply text-slate-300
dark:text-slate-600;
}
}
&.CMS_WidgetCode_expanded {
& .CMS_WidgetCode_expand-button-icon {
@apply rotate-90
transform;
}
}
&:not(.CMS_WidgetCode_error) {
&:not(.CMS_WidgetCode_disabled) {
&:hover,
&:focus {
& .CMS_WidgetCode_label {
@apply text-blue-500;
}
& .CMS_WidgetCode_expand-button-icon {
@apply text-blue-500;
}
}
}
}
}
.CMS_WidgetCode_field-wrapper {
@apply relative
flex
flex-col
w-full;
}
.CMS_WidgetCode_expand-button {
@apply flex
w-full
justify-between
px-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center;
}
.CMS_WidgetCode_expand-button-icon {
@apply transition-transform
h-5
w-5;
}
.CMS_WidgetCode_error-message {
@apply pt-2
pb-3;
}

View File

@ -10,11 +10,12 @@ import Label from '@staticcms/core/components/common/field/Label';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import languages from './data/languages';
import SettingsButton from './SettingsButton';
import SettingsPane from './SettingsPane';
import languages from './data/languages';
import type {
CodeField,
@ -24,6 +25,22 @@ import type {
import type { LanguageName } from '@uiw/codemirror-extensions-langs';
import type { FC, MouseEvent } from 'react';
import './CodeControl.css';
export const classes = generateClassNames('WidgetCode', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'field-wrapper',
'expand-button',
'expand-button-icon',
'label',
'error-message',
'expanded',
]);
function valueToOption(val: string | { name: string; label?: string }): {
value: string;
label: string;
@ -164,62 +181,24 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
<div
data-testid="list-field"
className={classNames(
`
relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
!hasErrors && 'group/active-list',
classes.root,
disabled && classes.disabled,
hasErrors && classes.error,
forSingleList && classes['for-single-list'],
field.required !== false && classes.required,
open && classes.expanded,
)}
>
<div
data-testid="field-wrapper"
className={classNames(
`
relative
flex
flex-col
w-full
`,
forSingleList && 'mr-14',
)}
>
<div data-testid="field-wrapper" className={classes['field-wrapper']}>
<button
data-testid="list-expand-button"
className={classNames(
`
flex
w-full
justify-between
px-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center
`,
disabled && 'cursor-default',
)}
className={classes['expand-button']}
onClick={handleOpenToggle}
>
<Label
key="label"
hasErrors={hasErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
className={classes.label}
cursor="pointer"
variant="inline"
disabled={disabled}
@ -229,25 +208,7 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
{open && allowLanguageSelection ? (
<SettingsButton onClick={toggleSettings} disabled={disabled} />
) : null}
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
<ChevronRightIcon className={classes['expand-button-icon']} />
</button>
{open && allowLanguageSelection && settingsVisible ? (
<SettingsPane
@ -279,7 +240,7 @@ const CodeControl: FC<WidgetControlProps<string | { [key: string]: string }, Cod
{field.hint}
</Hint>
) : null}
<ErrorMessage errors={errors} className="pt-2 pb-3" />
<ErrorMessage errors={errors} className={classes['error-message']} />
</div>
</div>
);

View File

@ -1,9 +1,13 @@
import isString from 'lodash/isString';
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { CodeField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const classes = generateClassNames('WidgetCodePreview', ['root']);
function toValue(value: string | Record<string, string> | undefined | null, field: CodeField) {
if (isString(value)) {
return value;
@ -21,7 +25,7 @@ const CodePreview: FC<WidgetPreviewProps<string | Record<string, string>, CodeFi
field,
}) => {
return (
<pre>
<pre className={classes.root}>
<code>{toValue(value, field)}</code>
</pre>
);

View File

@ -0,0 +1,7 @@
.CMS_WidgetCode_SettingsButton_root {
}
.CMS_WidgetCode_SettingsButton_icon {
@apply w-5
h-5;
}

View File

@ -3,9 +3,14 @@ import { Settings as SettingsIcon } from '@styled-icons/material/Settings';
import React from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC, MouseEvent } from 'react';
import './SettingsButton.css';
const classes = generateClassNames('WidgetCode_SettingsButton', ['root', 'icon']);
export interface SettingsButtonProps {
showClose?: boolean;
disabled: boolean;
@ -14,8 +19,18 @@ export interface SettingsButtonProps {
const SettingsButton: FC<SettingsButtonProps> = ({ showClose = false, disabled, onClick }) => {
return (
<IconButton onClick={onClick} size="small" variant="text" disabled={disabled}>
{showClose ? <CloseIcon className="w-5 h-5" /> : <SettingsIcon className="w-5 h-5" />}
<IconButton
onClick={onClick}
size="small"
variant="text"
disabled={disabled}
className={classes.root}
>
{showClose ? (
<CloseIcon className={classes.icon} />
) : (
<SettingsIcon className={classes.icon} />
)}
</IconButton>
);
};

View File

@ -0,0 +1,19 @@
.CMS_WidgetCodeSettings_root {
@apply absolute
top-10
bottom-0
right-0
w-40
flex
flex-col
gap-2
z-10
shadow-sm
bg-gray-100
dark:bg-slate-800
border-l
border-l-slate-400
border-t
border-t-slate-300
dark:border-t-slate-700;
}

View File

@ -3,9 +3,14 @@ import React from 'react';
import Label from '@staticcms/core/components/common/field/Label';
import Select from '@staticcms/core/components/common/select/Select';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react';
import './SettingsPane.css';
export const classes = generateClassNames('WidgetCodeSettings', ['root']);
interface SettingsSelectProps {
type: 'language';
label: string;
@ -76,28 +81,7 @@ const SettingsPane: FC<SettingsPaneProps> = ({
onChangeLanguage,
}) => {
return (
<div
onKeyDown={e => isHotkey('esc', e) && hideSettings()}
className="
absolute
top-10
bottom-0
right-0
w-40
flex
flex-col
gap-2
z-10
shadow-sm
bg-gray-100
dark:bg-slate-800
border-l
border-l-slate-400
border-t
border-t-slate-300
dark:border-t-slate-700
"
>
<div onKeyDown={e => isHotkey('esc', e) && hideSettings()} className={classes.root}>
<SettingsSelect
type="language"
label="Language"

View File

@ -0,0 +1,65 @@
.CMS_WidgetColor_root {
&.CMS_WidgetColor_disabled {
& .CMS_WidgetColor_content {
@apply cursor-default;
}
& .CMS_WidgetColor_color-swatch {
@apply cursor-default;
}
}
&.CMS_WidgetColor_allow-input {
& .CMS_WidgetColor_content {
@apply cursor-text;
}
}
}
.CMS_WidgetColor_content {
@apply flex
items-center
pt-2
px-3
cursor-pointer;
}
.CMS_WidgetColor_color-swatch-wrapper {
}
.CMS_WidgetColor_color-swatch {
@apply w-8
h-8
flex
items-center
justify-center
cursor-pointer;
}
.CMS_WidgetColor_color-picker-wrapper {
@apply absolute
bottom-0;
}
.CMS_WidgetColor_color-picker-backdrop {
@apply fixed
inset-0
z-10;
}
.CMS_WidgetColor_color-picker {
@apply absolute
z-20
-top-3;
}
.CMS_WidgetColor_input {
}
.CMS_WidgetColor_clear-button {
}
.CMS_WidgetColor_clear-button-icon {
@apply w-5
h-5;
}

View File

@ -7,17 +7,39 @@ import IconButton from '@staticcms/core/components/common/button/IconButton';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ColorField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, MouseEvent, MouseEventHandler } from 'react';
import type { ColorResult } from 'react-color';
import './ColorControl.css';
export const classes = generateClassNames('WidgetColor', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'allow-input',
'content',
'color-swatch-wrapper',
'color-swatch',
'color-picker-wrapper',
'color-picker-backdrop',
'color-picker',
'input',
'clear-button',
'clear-button-icon',
]);
const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
field,
duplicate,
onChange,
value,
errors,
hasErrors,
label,
forSingleList,
disabled,
@ -86,19 +108,17 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
cursor={allowInput ? 'text' : 'pointer'}
disabled={disabled}
disableClick={showColorPicker}
rootClassName={classNames(
classes.root,
disabled && classes.disabled,
field.required !== false && classes.required,
hasErrors && classes.error,
forSingleList && classes['for-single-list'],
allowInput && classes['allow-input'],
)}
>
<div
className={classNames(
`
flex
items-center
pt-2
px-3
`,
disabled ? 'cursor-default' : allowInput ? 'cursor-text' : 'cursor-pointer',
)}
>
<div>
<div className={classes.content}>
<div className={classes['color-swatch-wrapper']}>
<div
ref={swatchRef}
key="color-swatch"
@ -108,47 +128,24 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
background: validateColor(internalValue) ? internalValue : '#fff',
color: validateColor(internalValue) ? 'rgba(255, 255, 255, 0)' : 'rgb(150, 150, 150)',
}}
className={classNames(
`
w-8
h-8
flex
items-center
justify-center
`,
disabled ? 'cursor-default' : 'cursor-pointer',
)}
className={classes['color-swatch']}
>
?
</div>
</div>
{showColorPicker && (
<div
key="color-swatch-wrapper"
className="
absolute
bottom-0
"
>
<div key="color-picker-wrapper" className={classes['color-picker-wrapper']}>
<div
key="click-outside"
onClick={handleClose}
className="
fixed
inset-0
z-10
"
className={classes['color-picker-backdrop']}
/>
<ChromePicker
key="color-picker"
color={internalValue}
onChange={handlePickerChange}
disableAlpha={!(field.enable_alpha ?? false)}
className="
absolute
z-20
-top-3
"
className={classes['color-picker']}
/>
</div>
)}
@ -163,10 +160,16 @@ const ColorControl: FC<WidgetControlProps<string, ColorField>> = ({
disabled={disabled}
readonly={!allowInput}
cursor={allowInput ? 'text' : 'pointer'}
rootClassName={classes.input}
/>
{showClearButton ? (
<IconButton variant="text" onClick={handleClear} disabled={disabled}>
<CloseIcon className="w-5 h-5" />
<IconButton
variant="text"
onClick={handleClear}
disabled={disabled}
className={classes['clear-button']}
>
<CloseIcon className={classes['clear-button-icon']} />
</IconButton>
) : null}
</div>

View File

@ -1,10 +1,14 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ColorField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const classes = generateClassNames('WidgetColorPreview', ['root']);
const ColorPreview: FC<WidgetPreviewProps<string, ColorField>> = ({ value }) => {
return <div>{value}</div>;
return <div className={classes.root}>{value}</div>;
};
export default ColorPreview;

View File

@ -24,30 +24,6 @@ describe(ColorControl.name, () => {
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Color Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// Color Widget uses default label layout, with bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('text-input')).toBeInTheDocument();
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
@ -103,12 +79,6 @@ describe(ColorControl.name, () => {
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('should disable input if disabled', async () => {

View File

@ -0,0 +1,16 @@
.CMS_WidgetDateTime_root {
}
.CMS_WidgetDateTime_wrapper {
@apply !w-date-widget;
}
.CMS_WidgetDateTime_date-input {
}
.CMS_WidgetDateTime_time-input {
}
.CMS_WidgetDateTime_datetime-input {
@apply truncate;
}

View File

@ -10,7 +10,9 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import NowButton from './components/NowButton';
import {
DEFAULT_DATETIME_FORMAT,
@ -25,6 +27,20 @@ import type { TextFieldProps } from '@staticcms/core/components/common/text-fiel
import type { DateTimeField, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import './DateTimeControl.css';
export const classes = generateClassNames('WidgetDateTime', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'wrapper',
'date-input',
'time-input',
'datetime-input',
]);
function convertMuiTextFieldProps({
inputProps,
disabled,
@ -49,6 +65,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
disabled,
duplicate,
errors,
hasErrors,
forSingleList,
t,
onChange,
@ -186,6 +203,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
{...convertMuiTextFieldProps(props)}
inputRef={ref}
cursor="pointer"
inputClassName={classes['date-input']}
/>
<NowButton
key="mobile-date-now"
@ -218,6 +236,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
{...convertMuiTextFieldProps(props)}
inputRef={ref}
cursor="pointer"
inputClassName={classes['time-input']}
/>
<NowButton
key="mobile-date-now"
@ -249,7 +268,7 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
{...convertMuiTextFieldProps(props)}
inputRef={ref}
cursor="pointer"
inputClassName="truncate"
inputClassName={classes['datetime-input']}
/>
<NowButton
key="mobile-date-now"
@ -283,7 +302,14 @@ const DateTimeControl: FC<WidgetControlProps<string | Date, DateTimeField>> = ({
forSingleList={forSingleList}
cursor="pointer"
disabled={disabled}
wrapperClassName="!w-date-widget"
rootClassName={classNames(
classes.root,
disabled && classes.disabled,
field.required !== false && classes.required,
hasErrors && classes.error,
forSingleList && classes['for-single-list'],
)}
wrapperClassName={classes.wrapper}
>
<LocalizationProvider key="localization-provider" dateAdapter={AdapterDateFns}>
{dateTimePicker}

View File

@ -1,10 +1,14 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { DateTimeField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const classes = generateClassNames('WidgetDateTimePreview', ['root']);
const DatePreview: FC<WidgetPreviewProps<string | Date, DateTimeField>> = ({ value }) => {
return <div>{value ? value.toString() : null}</div>;
return <div className={classes.root}>{value ? value.toString() : null}</div>;
};
export default DatePreview;

View File

@ -154,31 +154,6 @@ describe(DateTimeControl.name, () => {
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Date Time Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// Date Time Widget uses default label layout, with bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('date-time-input')).toBeInTheDocument();
expect(getByTestId('datetime-now')).toBeInTheDocument();
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should show error', async () => {
@ -188,12 +163,6 @@ describe(DateTimeControl.name, () => {
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
describe('datetime', () => {

View File

@ -0,0 +1,7 @@
.CMS_WidgetDateTime_NowButton_root {
@apply absolute
inset-y-1
right-3
flex
items-center;
}

View File

@ -1,10 +1,15 @@
import React, { useCallback } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { TranslatedProps } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import './NowButton.css';
const classes = generateClassNames('WidgetDateTime_NowButton', ['root', 'button']);
export interface NowButtonProps {
handleChange: (value: Date) => void;
disabled: boolean;
@ -20,17 +25,14 @@ const NowButton: FC<TranslatedProps<NowButtonProps>> = ({ disabled, t, handleCha
);
return (
<div
key="now-button-wrapper"
className="absolute inset-y-1 right-3 flex items-center
"
>
<div key="now-button-wrapper" className={classes.root}>
<Button
key="now-button"
data-testid="datetime-now"
onClick={handleClick}
disabled={disabled}
variant="outlined"
className={classes.button}
>
{t('editor.editorWidgets.datetime.now')}
</Button>

View File

@ -0,0 +1,21 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const widgetFileImageClasses = generateClassNames('WidgetFileImage', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'drag-over-active',
'for-image',
'multiple',
'wrapper',
'drop-area',
'for-image',
'image-grid',
'empty-content',
'content',
'actions',
]);
export default widgetFileImageClasses;

View File

@ -0,0 +1,79 @@
.CMS_WidgetFileImage_root {
@apply relative
border-2
transition-colors
border-transparent;
&.CMS_WidgetFileImage_drag-over-active {
@apply border-blue-500;
& .CMS_WidgetFileImage_drop-area {
@apply opacity-100;
}
}
&.CMS_WidgetFileImage_for-image {
& .CMS_WidgetFileImage_content {
@apply pr-3;
}
}
&:not(.CMS_WidgetFileImage_multiple) {
& .CMS_WidgetFileImage_content {
@apply pr-3;
}
}
}
.CMS_WidgetFileImage_wrapper {
@apply -m-0.5;
}
.CMS_WidgetFileImage_drop-area {
@apply absolute
inset-0
flex
items-center
justify-center
pointer-events-none
font-bold
text-blue-500
bg-white/75
dark:text-blue-400
dark:bg-slate-800/75
transition-opacity
opacity-0;
}
.CMS_WidgetFileImage_for-image {
}
.CMS_WidgetFileImage_image-grid {
@apply grid
grid-cols-images
gap-2;
}
.CMS_WidgetFileImage_empty-content {
@apply flex
flex-col
gap-2
px-3
pt-2
pb-4;
}
.CMS_WidgetFileImage_content {
@apply flex
flex-col
gap-4
pl-3
pt-2
pb-4;
}
.CMS_WidgetFileImage_actions {
@apply flex
gap-2
flex-col
xs:flex-row;
}

View File

@ -92,21 +92,6 @@ describe('File Control', () => {
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// String Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
expect(field).toHaveClass('cursor-pointer');
// String Widget uses default label layout, without bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).not.toHaveClass('pb-3');
});
it('should show only the choose upload button by default', () => {
@ -159,13 +144,6 @@ describe('File Control', () => {
expect(getByTestId('remove-upload')).toBeInTheDocument();
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
const { rerender, getByTestId } = renderControl({ value: 'https://example.com/file.pdf' });
@ -224,12 +202,6 @@ describe('File Control', () => {
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
describe('disabled', () => {

View File

@ -0,0 +1,88 @@
.CMS_WidgetFileImage_SortableImage_root {
@apply relative
w-image-card
h-image-card;
}
.CMS_WidgetFileImage_SortableImage_card {
@apply w-image-card
h-image-card
rounded-md
shadow-sm
overflow-hidden
cursor-pointer
border
bg-gray-50/75
border-gray-200/75
dark:bg-slate-800
dark:border-slate-600/75;
&:hover {
& .CMS_WidgetFileImage_SortableImage_controls-wrapper {
@apply visible
bg-blue-200/25
dark:bg-blue-400/60;
}
}
}
.CMS_WidgetFileImage_SortableImage_handle {
@apply absolute
inset-0
rounded-md
z-20
overflow-visible
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
focus-visible:outline-none;
}
.CMS_WidgetFileImage_SortableImage_controls-wrapper {
@apply absolute
inset-0
invisible
transition-all
rounded-md
z-20;
}
.CMS_WidgetFileImage_SortableImage_controls {
@apply absolute
top-2
right-2
flex
gap-1;
}
.CMS_WidgetFileImage_SortableImage_replace-button {
@apply text-white
dark:text-white
bg-gray-900/25
dark:hover:text-blue-100
dark:hover:bg-blue-800/80;
}
.CMS_WidgetFileImage_SortableImage_remove-button {
@apply relative
text-red-400
bg-gray-900/25
dark:hover:text-red-600
dark:hover:bg-red-800/40
z-30;
}
.CMS_WidgetFileImage_SortableImage_button-icon {
@apply w-5
h-5;
}
.CMS_WidgetFileImage_SortableImage_content {
@apply relative;
}
.CMS_WidgetFileImage_SortableImage_image {
@apply w-image-card
h-image-card
rounded-md;
}

View File

@ -6,10 +6,26 @@ import React, { useCallback, useMemo } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Image from '@staticcms/core/components/common/image/Image';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { Collection, FileOrImageField } from '@staticcms/core/interface';
import type { FC, MouseEventHandler } from 'react';
import './SortableImage.css';
const classes = generateClassNames('WidgetFileImage_SortableImage', [
'root',
'card',
'handle',
'controls-wrapper',
'controls',
'replace-button',
'remove-button',
'button-icon',
'content',
'image',
]);
export interface SortableImageProps {
id: string;
itemValue: string;
@ -67,84 +83,27 @@ const SortableImage: FC<SortableImageProps> = ({
style={style}
{...attributes}
{...listeners}
className="
relative
w-image-card
h-image-card
"
className={classes.root}
tabIndex={-1}
title={itemValue}
>
<div
onClick={handleClick}
data-testid={`image-card-${itemValue}`}
className="
w-image-card
h-image-card
rounded-md
shadow-sm
overflow-hidden
group/image-card
cursor-pointer
border
bg-gray-50/75
border-gray-200/75
dark:bg-slate-800
dark:border-slate-600/75
"
>
<div onClick={handleClick} data-testid={`image-card-${itemValue}`} className={classes.card}>
<div
key="handle"
data-testid={`image-card-handle-${itemValue}`}
tabIndex={0}
className="
absolute
inset-0
rounded-md
z-20
overflow-visible
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
focus-visible:outline-none
"
className={classes.handle}
/>
<div
className="
absolute
inset-0
invisible
transition-all
rounded-md
group-hover/image-card:visible
group-hover/image-card:bg-blue-200/25
dark:group-hover/image-card:bg-blue-400/60
z-20
"
>
<div
className="
absolute
top-2
right-2
flex
gap-1
"
>
<div className={classes['controls-wrapper']}>
<div className={classes.controls}>
{onReplace ? (
<IconButton
key="replace"
variant="text"
onClick={handleReplace}
className="
text-white
dark:text-white
bg-gray-900/25
dark:hover:text-blue-100
dark:hover:bg-blue-800/80
"
className={classes['replace-button']}
>
<CameraAltIcon className="w-5 h-5" />
<CameraAltIcon className={classes['button-icon']} />
</IconButton>
) : null}
{onRemove ? (
@ -153,27 +112,15 @@ const SortableImage: FC<SortableImageProps> = ({
variant="text"
color="error"
onClick={handleRemove}
className="
position: relative;
text-red-400
bg-gray-900/25
dark:hover:text-red-600
dark:hover:bg-red-800/40
z-30
"
className={classes['remove-button']}
>
<DeleteIcon className="w-5 h-5" />
<DeleteIcon className={classes['button-icon']} />
</IconButton>
) : null}
</div>
</div>
<div className="relative">
<Image
src={itemValue}
className="w-image-card h-image-card rounded-md"
collection={collection}
field={field}
/>
<div className={classes.content}>
<Image src={itemValue} className={classes.image} collection={collection} field={field} />
</div>
</div>
</div>

View File

@ -0,0 +1,48 @@
.CMS_WidgetFileImage_SortableLink_root {
@apply relative
w-full;
}
.CMS_WidgetFileImage_SortableLink_card {
@apply w-full
shadow-sm
overflow-hidden
cursor-pointer
border-l-2
border-b
border-solid
border-l-slate-400
p-2;
}
.CMS_WidgetFileImage_SortableLink_content {
@apply relative
flex
items-center
justify-between;
}
.CMS_WidgetFileImage_SortableLink_controls {
@apply flex
gap-1;
}
.CMS_WidgetFileImage_SortableLink_replace-button {
@apply text-white
dark:text-white
dark:hover:text-blue-100
dark:hover:bg-blue-800/80;
}
.CMS_WidgetFileImage_SortableLink_remove-button {
@apply relative
text-red-400
dark:hover:text-red-600
dark:hover:bg-red-800/40
z-30;
}
.CMS_WidgetFileImage_SortableLink_button-icon {
@apply w-5
h-5;
}

View File

@ -1,13 +1,26 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ModeEdit as ModeEditIcon } from '@styled-icons/material/ModeEdit';
import { Delete as DeleteIcon } from '@styled-icons/material/Delete';
import { ModeEdit as ModeEditIcon } from '@styled-icons/material/ModeEdit';
import React, { useCallback, useMemo } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC, MouseEventHandler } from 'react';
import './SortableLink.css';
const classes = generateClassNames('WidgetFileImage_SortableLink', [
'root',
'card',
'content',
'controls',
'replace-button',
'remove-button',
'button-icon',
]);
const MAX_DISPLAY_LENGTH = 100;
export interface SortableLinkProps {
@ -65,50 +78,22 @@ const SortableLink: FC<SortableLinkProps> = ({ id, itemValue, onRemove, onReplac
style={style}
{...attributes}
{...listeners}
className="
relative
w-full
"
className={classes.root}
tabIndex={-1}
title={itemValue}
>
<div
onClick={handleClick}
data-testid={`image-card-${itemValue}`}
className="
w-full
shadow-sm
overflow-hidden
group/image-card
cursor-pointer
border-l-2
border-b
border-solid
border-l-slate-400
p-2
"
>
<div className="relative flex items-center justify-between">
<div onClick={handleClick} data-testid={`image-card-${itemValue}`} className={classes.card}>
<div className={classes.content}>
<span>{text}</span>
<div
className="
flex
gap-1
"
>
<div className={classes.controls}>
{onReplace ? (
<IconButton
key="replace"
variant="text"
onClick={handleReplace}
className="
text-white
dark:text-white
dark:hover:text-blue-100
dark:hover:bg-blue-800/80
"
className={classes['replace-button']}
>
<ModeEditIcon className="w-5 h-5" />
<ModeEditIcon className={classes['button-icon']} />
</IconButton>
) : null}
{onRemove ? (
@ -117,15 +102,9 @@ const SortableLink: FC<SortableLinkProps> = ({ id, itemValue, onRemove, onReplac
variant="text"
color="error"
onClick={handleRemove}
className="
position: relative;
text-red-400
dark:hover:text-red-600
dark:hover:bg-red-800/40
z-30
"
className={classes['remove-button']}
>
<DeleteIcon className="w-5 h-5" />
<DeleteIcon className={classes['button-icon']} />
</IconButton>
) : null}
</div>

View File

@ -23,6 +23,7 @@ import { KeyboardSensor, PointerSensor } from '@staticcms/core/lib/util/dnd.util
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import widgetFileImageClasses from './FileImageControl.classes';
import SortableImage from './components/SortableImage';
import SortableLink from './components/SortableLink';
@ -31,6 +32,8 @@ import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { FC, MouseEvent } from 'react';
import './FileImageControl.css';
const MAX_DISPLAY_LENGTH = 50;
function isMultiple(value: string | string[] | null | undefined): value is string[] {
@ -137,13 +140,12 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
const handlePersistCallback = useCallback(
(_files: File[], assetProxies: (AssetProxy | null)[]) => {
const newPath =
assetProxies.length > 1 && allowsMultiple
? [
...(Array.isArray(internalValue) ? internalValue : [internalValue]),
...assetProxies.filter(f => f).map(f => f!.path),
]
: assetProxies[0]?.path;
const newPath = allowsMultiple
? [
...(Array.isArray(internalValue) ? internalValue : [internalValue]),
...assetProxies.filter(f => f).map(f => f!.path),
]
: assetProxies[0]?.path;
if ((Array.isArray(newPath) && newPath.length === 0) || !newPath) {
return;
@ -275,7 +277,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
onDragEnd={onSortEnd}
>
<SortableContext items={keys} strategy={rectSortingStrategy}>
<div className="grid grid-cols-images gap-2">
<div className={widgetFileImageClasses['image-grid']}>
{internalValue.map((itemValue, index) => {
const key = keys[index];
return (
@ -349,8 +351,8 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
if (Array.isArray(internalValue) ? internalValue.length === 0 : isEmpty(internalValue)) {
return (
<div key="selection" className="flex flex-col gap-2 px-3 pt-2 pb-4">
<div key="controls" className="flex gap-2 flex-col xs:flex-row">
<div key="selection" className={widgetFileImageClasses['empty-content']}>
<div key="controls" className={widgetFileImageClasses.actions}>
<Button
buttonRef={uploadButtonRef}
color="primary"
@ -380,15 +382,9 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
}
return (
<div
key="selection"
className={classNames(
`flex flex-col gap-4 pl-3 pt-2 pb-4`,
(forImage || !allowsMultiple) && 'pr-3',
)}
>
<div key="selection" className={widgetFileImageClasses.content}>
{renderedImagesLinks}
<div key="controls" className="flex gap-2 flex-col xs:flex-row">
<div key="controls" className={widgetFileImageClasses.actions}>
<Button
buttonRef={uploadButtonRef}
color="primary"
@ -463,15 +459,17 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
className={classNames(
`
relative
border-2
transition-colors
`,
dragOverActive ? 'border-blue-500' : 'border-transparent',
widgetFileImageClasses.root,
disabled && widgetFileImageClasses.disabled,
field.required !== false && widgetFileImageClasses.required,
hasErrors && widgetFileImageClasses.error,
forSingleList && widgetFileImageClasses['for-single-list'],
dragOverActive && widgetFileImageClasses['drag-over-active'],
forImage && widgetFileImageClasses['for-image'],
isMultiple(value) && widgetFileImageClasses.multiple,
)}
>
<div className="-m-0.5">
<div className={widgetFileImageClasses.wrapper}>
<Field
inputRef={allowsMultiple ? undefined : uploadButtonRef}
label={label}
@ -484,25 +482,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
>
{content}
</Field>
<div
className={classNames(
`
absolute
inset-0
flex
items-center
justify-center
pointer-events-none
font-bold
text-blue-500
bg-white/75
dark:text-blue-400
dark:bg-slate-800/75
transition-opacity
`,
dragOverActive ? 'opacity-100' : 'opacity-0',
)}
>
<div className={widgetFileImageClasses['drop-area']}>
{t(`mediaLibrary.mediaLibraryModal.${forImage ? 'dropImages' : 'dropFiles'}`)}
</div>
</div>
@ -513,14 +493,16 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => {
handleDragEnter,
handleDragLeave,
handleDragOver,
disabled,
field.required,
field.hint,
hasErrors,
forSingleList,
dragOverActive,
value,
allowsMultiple,
label,
errors,
hasErrors,
field.hint,
forSingleList,
disabled,
content,
t,
],

View File

@ -0,0 +1,51 @@
.CMS_WidgetKeyValue_root {
}
.CMS_WidgetKeyValue_header {
@apply flex
gap-2
px-3
mt-2
w-full;
}
.CMS_WidgetKeyValue_header-cell {
@apply w-full
text-sm;
}
.CMS_WidgetKeyValue_header-action-cell {
@apply flex;
}
.CMS_WidgetKeyValue_header-action-cell-content {
@apply w-[24px];
}
.CMS_WidgetKeyValue_row {
@apply flex
gap-2
px-3
mt-2
w-full
items-center;
}
.CMS_WidgetKeyValue_delete-button {
@apply h-6
w-6;
}
.CMS_WidgetKeyValue_delete-button-icon {
@apply h-5
w-5;
}
.CMS_WidgetKeyValue_actions {
@apply px-3
mt-3;
}
.CMS_WidgetKeyValue_add-button {
@apply w-full;
}

View File

@ -6,16 +6,38 @@ import IconButton from '@staticcms/core/components/common/button/IconButton';
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { createEmptyPair } from './util';
import type { KeyValueField, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC, MouseEvent } from 'react';
import type { Pair } from './types';
import './KeyValueControl.css';
const classes = generateClassNames('WidgetKeyValue', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'header',
'header-cell',
'header-action-cell',
'header-action-cell-content',
'row',
'delete-button',
'delete-button-icon',
'actions',
'add-button',
]);
const StringControl: FC<WidgetControlProps<Pair[], KeyValueField>> = ({
value,
label,
errors,
hasErrors,
disabled,
field,
forSingleList,
@ -100,16 +122,23 @@ const StringControl: FC<WidgetControlProps<Pair[], KeyValueField>> = ({
forSingleList={forSingleList}
cursor="text"
disabled={disabled}
rootClassName={classNames(
classes.root,
disabled && classes.disabled,
field.required !== false && classes.required,
hasErrors && classes.error,
forSingleList && classes['for-single-list'],
)}
>
<div className="flex gap-2 px-3 mt-2 w-full">
<div className="w-full text-sm">{keyLabel}</div>
<div className="w-full text-sm">{valueLabel}</div>
<div className="flex">
<div className="w-[24px]"></div>
<div className={classes.header}>
<div className={classes['header-cell']}>{keyLabel}</div>
<div className={classes['header-cell']}>{valueLabel}</div>
<div className={classes['header-action-cell']}>
<div className={classes['header-action-cell-content']}></div>
</div>
</div>
{internalValue.map((pair, index) => (
<div key={`keyvalue-${index}`} className="flex gap-2 px-3 mt-2 w-full items-center">
<div key={`keyvalue-${index}`} className={classes.row}>
<TextField
type="text"
data-testid={`key-${index}`}
@ -135,25 +164,17 @@ const StringControl: FC<WidgetControlProps<Pair[], KeyValueField>> = ({
variant="text"
onClick={handleRemove(index)}
disabled={disabled}
className="
h-6
w-6
"
className={classes['delete-button']}
>
<CloseIcon
className="
h-5
w-5
"
/>
<CloseIcon className={classes['delete-button-icon']} />
</IconButton>
</div>
))}
<div className="px-3 mt-3">
<div className={classes.actions}>
<Button
variant="outlined"
onClick={handleAdd}
className="w-full"
className={classes['add-button']}
data-testid="key-value-add"
disabled={disabled}
>

View File

@ -1,12 +1,16 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { KeyValueField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { Pair } from './types';
const classes = generateClassNames('WidgetKeyValuePreview', ['root']);
const StringPreview: FC<WidgetPreviewProps<Pair[], KeyValueField>> = ({ value }) => {
return (
<ul>
<ul className={classes.root}>
{(value ?? []).map((pair, index) => (
<li key={`preview-keyvalue-${index}`}>
<b>{pair.key ?? ''}</b> - {pair.value ?? ''}

View File

@ -20,31 +20,6 @@ describe(KeyValueControl.name, () => {
const label = getByTestId('label');
expect(label.textContent).toBe('I am a label');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('field');
expect(field).toHaveClass('group/active');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// Key Value Widget uses text cursor
expect(label).toHaveClass('cursor-text');
expect(field).toHaveClass('cursor-text');
// Key Value Widget uses default label layout, with bottom padding on field
expect(label).toHaveClass('px-3', 'pt-3');
expect(field).toHaveClass('pb-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ label: 'I am a label', forSingleList: true });
expect(getByTestId('key-0')).toBeInTheDocument();
expect(getByTestId('value-0')).toBeInTheDocument();
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should only use prop value as initial value', async () => {
@ -213,12 +188,6 @@ describe(KeyValueControl.name, () => {
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('should focus input on field click', async () => {

View File

@ -3,6 +3,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Field from '@staticcms/core/components/common/field/Field';
import TextField from '@staticcms/core/components/common/text-field/TextField';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import classNames from '@staticcms/core/lib/util/classNames.util';
import widgetListClasses from './ListControl.classes';
import type { ListField, ValueOrNestedValue, WidgetControlProps } from '@staticcms/core/interface';
import type { ChangeEvent, FC } from 'react';
@ -14,6 +16,7 @@ const DelimitedListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListFiel
duplicate,
value,
errors,
hasErrors,
forSingleList,
controlled,
onChange,
@ -54,6 +57,14 @@ const DelimitedListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListFiel
forSingleList={forSingleList}
cursor="text"
disabled={disabled}
rootClassName={classNames(
widgetListClasses.root,
widgetListClasses.delimited,
disabled && widgetListClasses.disabled,
field.required !== false && widgetListClasses.required,
hasErrors && widgetListClasses.error,
forSingleList && widgetListClasses['for-single-list'],
)}
>
<TextField
type="text"
@ -61,6 +72,7 @@ const DelimitedListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListFiel
value={internalValue}
disabled={disabled}
onChange={handleChange}
inputClassName={widgetListClasses['delimited-input']}
/>
</Field>
);

View File

@ -0,0 +1,27 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const widgetListClasses = generateClassNames('WidgetList', [
'root',
'disabled',
'error',
'required',
'for-single-list',
'open',
'summary',
'field-wrapper',
'field',
'expand-button',
'expand-button-icon',
'content',
'error-message',
'delimited',
'delimited-input',
'fields',
'actions',
'add-types-button',
'add-button',
'sortable-item',
'multi-field-item',
]);
export default widgetListClasses;

View File

@ -0,0 +1,114 @@
.CMS_WidgetList_root {
&.CMS_WidgetList_disabled {
& .CMS_WidgetList_expand-button-icon {
@apply text-slate-300
dark:text-slate-600;
}
}
&.CMS_WidgetList_error {
& .CMS_WidgetList_content {
@apply border-l-red-500;
}
}
&:not(.CMS_WidgetList_disabled) {
&:not(.CMS_WidgetList_error) {
&:hover,
&:focus-with {
& .CMS_WidgetList_summary,
& .CMS_WidgetList_expand-button-icon {
@apply text-blue-500;
}
}
}
}
&.CMS_WidgetList_for-single-list {
& .CMS_WidgetList_field {
@apply mr-14;
}
}
&.CMS_WidgetList_open {
& .CMS_WidgetList_expand-button-icon {
@apply rotate-90
transform;
}
}
}
.CMS_WidgetList_field-wrapper {
@apply relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100;
}
.CMS_WidgetList_field {
@apply relative
flex
flex-col
w-full;
}
.CMS_WidgetList_expand-button {
@apply flex
w-full
justify-between
px-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center;
}
.CMS_WidgetList_expand-button-icon {
@apply transition-transform
h-5
w-5;
}
.CMS_WidgetList_content {
@apply text-sm
text-gray-500;
}
.CMS_WidgetList_error-message {
@apply pb-3;
}
.CMS_WidgetList_fields {
@apply overflow-hidden;
}
.CMS_WidgetList_actions {
@apply py-3
px-4
w-full;
}
.CMS_WidgetList_add-types-button {
@apply w-full
z-20;
}
.CMS_WidgetList_add-button {
@apply w-full;
}
.CMS_WidgetList_sortable-item {
@apply first:pt-0;
}
.CMS_WidgetList_multi-field-item {
@apply pt-1;
}

View File

@ -14,6 +14,7 @@ import classNames from '@staticcms/core/lib/util/classNames.util';
import ListFieldWrapper from './components/ListFieldWrapper';
import ListItem from './components/ListItem';
import DelimitedListControl from './DelimitedListControl';
import widgetListClasses from './ListControl.classes';
import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers';
import type { DragEndEvent } from '@dnd-kit/core';
@ -29,6 +30,8 @@ import type {
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import './ListControl.css';
function arrayMoveImmutable<T>(array: T[], oldIndex: number, newIndex: number): T[] {
const newArray = [...array];
@ -91,10 +94,8 @@ const SortableItem: FC<SortableItemProps> = ({
style={style}
{...(disabled ? {} : attributes)}
className={classNames(
`
first:pt-0
`,
field.fields?.length !== 1 && 'pt-1',
widgetListClasses['sortable-item'],
field.fields?.length !== 1 && widgetListClasses['multi-field-item'],
)}
>
<ListItem
@ -309,92 +310,90 @@ const ListControl: FC<WidgetControlProps<ValueOrNestedValue[], ListField>> = pro
}
return (
<div key="list-widget">
<ListFieldWrapper
key="list-control-wrapper"
field={field}
openLabel={label}
closedLabel={listLabel}
errors={errors}
hasChildErrors={hasChildErrors}
hint={field.hint}
forSingleList={forSingleList}
disabled={disabled}
>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" id="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<div data-testid="list-widget-children" className="overflow-hidden">
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
<ListFieldWrapper
key="list-control-wrapper"
field={field}
openLabel={label}
closedLabel={listLabel}
errors={errors}
hasChildErrors={hasChildErrors}
hint={field.hint}
forSingleList={forSingleList}
disabled={disabled}
>
{internalValue.length > 0 ? (
<DndContext key="dnd-context" id="dnd-context" onDragEnd={handleDragEnd}>
<SortableContext items={keys}>
<div data-testid="list-widget-children" className={widgetListClasses.fields}>
{internalValue.map((item, index) => {
const key = keys[index];
if (!key) {
return null;
}
return (
<SortableItem
index={index}
key={key}
id={key}
item={item}
valueType={valueType}
handleRemove={handleRemove}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
disabled={disabled}
duplicate={duplicate}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
);
})}
</div>
</SortableContext>
</DndContext>
) : null}
{field.allow_add !== false ? (
<div className="py-3 px-4 w-full">
{types && types.length ? (
<Menu
label={t('editor.editorWidgets.list.addType', { item: label })}
variant="outlined"
buttonClassName="w-full z-20"
data-testid="list-type-add"
disabled={disabled}
>
<MenuGroup>
{types.map((type, idx) =>
type ? (
<MenuItemButton
key={idx}
onClick={() => handleAddType(type.name, resolveFieldKeyType(field))}
data-testid={`list-type-add-item-${type.name}`}
>
{type.label ?? type.name}
</MenuItemButton>
) : null,
)}
</MenuGroup>
</Menu>
) : (
<Button
variant="outlined"
onClick={handleAdd}
className="w-full"
data-testid="list-add"
disabled={disabled}
>
{t('editor.editorWidgets.list.add', { item: labelSingular })}
</Button>
)}
</div>
) : null}
</ListFieldWrapper>
</div>
return (
<SortableItem
index={index}
key={key}
id={key}
item={item}
valueType={valueType}
handleRemove={handleRemove}
entry={entry}
field={field}
fieldsErrors={fieldsErrors}
submitted={submitted}
disabled={disabled}
duplicate={duplicate}
locale={locale}
path={path}
value={item as Record<string, ObjectValue>}
i18n={i18n}
/>
);
})}
</div>
</SortableContext>
</DndContext>
) : null}
{field.allow_add !== false ? (
<div className={widgetListClasses.actions}>
{types && types.length ? (
<Menu
label={t('editor.editorWidgets.list.addType', { item: label })}
variant="outlined"
buttonClassName={widgetListClasses['add-types-button']}
data-testid="list-type-add"
disabled={disabled}
>
<MenuGroup>
{types.map((type, idx) =>
type ? (
<MenuItemButton
key={idx}
onClick={() => handleAddType(type.name, resolveFieldKeyType(field))}
data-testid={`list-type-add-item-${type.name}`}
>
{type.label ?? type.name}
</MenuItemButton>
) : null,
)}
</MenuGroup>
</Menu>
) : (
<Button
variant="outlined"
onClick={handleAdd}
className={widgetListClasses['add-button']}
data-testid="list-add"
disabled={disabled}
>
{t('editor.editorWidgets.list.add', { item: labelSingular })}
</Button>
)}
</div>
) : null}
</ListFieldWrapper>
);
};

View File

@ -1,10 +1,13 @@
import React from 'react';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ListField, ValueOrNestedValue, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC, ReactNode } from 'react';
const classes = generateClassNames('WidgetListPreview', ['root']);
function renderNestedList(
value: ValueOrNestedValue[] | ValueOrNestedValue | null | undefined,
): ReactNode {
@ -43,7 +46,7 @@ const ListPreview: FC<WidgetPreviewProps<ValueOrNestedValue[], ListField>> = ({
}
return (
<div style={{ marginTop: '12px' }}>
<div style={{ marginTop: '12px' }} className={classes.root}>
<label>
<strong>{field.label ?? field.name}:</strong>
</label>

View File

@ -207,26 +207,6 @@ describe(ListControl.name, () => {
const label = getByTestId('label');
expect(label.textContent).toBe('singleton');
expect(label).toHaveClass('text-slate-500');
const field = getByTestId('list-field');
expect(field).toHaveClass('group/active-list');
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).not.toHaveClass('mr-14');
// List Widget uses pointer cursor
expect(label).toHaveClass('cursor-pointer');
// List Widget uses inline label layout
expect(label).not.toHaveClass('px-3', 'pt-3');
});
it('should render as single list item', () => {
const { getByTestId } = renderControl({ forSingleList: true });
const fieldWrapper = getByTestId('field-wrapper');
expect(fieldWrapper).toHaveClass('mr-14');
});
it('should show error', async () => {
@ -236,12 +216,6 @@ describe(ListControl.name, () => {
const error = getByTestId('error');
expect(error.textContent).toBe('i am an error');
const field = getByTestId('list-field');
expect(field).not.toHaveClass('group/active');
const label = getByTestId('label');
expect(label).toHaveClass('text-red-500');
});
it('uses singular label if provided when there is only one value', async () => {

View File

@ -6,6 +6,7 @@ import ErrorMessage from '@staticcms/core/components/common/field/ErrorMessage';
import Hint from '@staticcms/core/components/common/field/Hint';
import Label from '@staticcms/core/components/common/field/Label';
import classNames from '@staticcms/core/lib/util/classNames.util';
import widgetListClasses from '../ListControl.classes';
import type { FieldError, ListField } from '@staticcms/core/interface';
import type { FC, ReactNode } from 'react';
@ -45,95 +46,34 @@ const ListFieldWrapper: FC<ListFieldWrapperProps> = ({
<div
data-testid="list-field"
className={classNames(
`
relative
flex
flex-col
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
!(hasErrors || hasChildErrors) && 'group/active-list',
widgetListClasses.root,
disabled && widgetListClasses.disabled,
field.required !== false && widgetListClasses.required,
hasErrors && widgetListClasses.error,
forSingleList && widgetListClasses['for-single-list'],
open && widgetListClasses.open,
)}
>
<div
data-testid="field-wrapper"
className={classNames(
`
relative
flex
flex-col
w-full
`,
forSingleList && 'mr-14',
)}
>
<div data-testid="field-wrapper" className={widgetListClasses['field-wrapper']}>
<button
data-testid="list-expand-button"
className="
flex
w-full
justify-between
px-3
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center
"
className={widgetListClasses['expand-button']}
onClick={handleOpenToggle}
>
<Label
key="label"
hasErrors={hasErrors || hasChildErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
className={widgetListClasses.summary}
cursor="pointer"
variant="inline"
disabled={disabled}
>
{open ? openLabel : closedLabel}
</Label>
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
<ChevronRightIcon className={widgetListClasses['expand-button-icon']} />
</button>
<Collapse in={open} appear={false}>
<div
className={classNames(
`
text-sm
text-gray-500
`,
(hasErrors || hasChildErrors) && 'border-l-red-500',
)}
>
<div className={widgetListClasses.content}>
<div data-testid="object-fields">{children}</div>
</div>
</Collapse>
@ -142,7 +82,7 @@ const ListFieldWrapper: FC<ListFieldWrapperProps> = ({
{hint}
</Hint>
) : null}
<ErrorMessage errors={errors} className="pb-3" />
<ErrorMessage errors={errors} className={widgetListClasses['error-message']} />
</div>
</div>
);

View File

@ -1,8 +1,10 @@
import partial from 'lodash/partial';
import React, { useMemo } from 'react';
import { useTranslate } from 'react-polyglot';
import EditorControl from '@staticcms/core/components/entry-editor/editor-control-pane/EditorControl';
import useHasChildErrors from '@staticcms/core/lib/hooks/useHasChildErrors';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import {
addFileTemplateFields,
compileStringTemplate,
@ -22,12 +24,14 @@ import type {
WidgetControlProps,
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import type { t } from 'react-polyglot';
function handleSummary(
summary: string,
entry: Entry,
label: string,
item: ValueOrNestedValue,
t: t,
): string {
if (typeof item === 'object' && !(item instanceof Date) && !Array.isArray(item)) {
const labeledItem: EntryData = {
@ -40,7 +44,7 @@ function handleSummary(
return compileStringTemplate(summary, null, '', data);
}
return String(item);
return isNotNullish(item) ? String(item) : t('editor.editorWidgets.list.noValue');
}
function validateItem(field: ListField, item: ValueOrNestedValue) {
@ -98,6 +102,8 @@ const ListItem: FC<ListItemProps> = ({
listeners,
handleRemove,
}) => {
const t = useTranslate() as t;
const [summary, objectField] = useMemo((): [string, ListField | ObjectField] => {
const childObjectField: ObjectField = {
name: `${index}`,
@ -132,10 +138,10 @@ const ListItem: FC<ListItemProps> = ({
const summary =
'summary' in itemType && itemType.summary ? itemType.summary : field.summary;
const labelReturn = summary
? `${label} - ${handleSummary(summary, entry, label, mixedObjectValue)}`
? `${label} - ${handleSummary(summary, entry, label, mixedObjectValue, t)}`
: label;
return [labelReturn, itemType];
return [labelReturn ?? t('editor.editorWidgets.list.noValue'), itemType];
}
case ListValueType.MULTIPLE: {
childObjectField.fields = field.fields ?? [];
@ -159,13 +165,15 @@ const ListItem: FC<ListItemProps> = ({
const summary = field.summary;
const labelReturn = summary
? handleSummary(summary, entry, String(labelFieldValue), objectValue)
: String(labelFieldValue);
? handleSummary(summary, entry, String(labelFieldValue), objectValue, t)
: labelFieldValue
? String(labelFieldValue)
: undefined;
return [labelReturn, childObjectField];
return [labelReturn ?? t('editor.editorWidgets.list.noValue'), childObjectField];
}
}
}, [entry, field, index, value, valueType]);
}, [entry, field, index, t, value, valueType]);
const hasChildErrors = useHasChildErrors(path, fieldsErrors, i18n, false);

View File

@ -0,0 +1,155 @@
.CMS_WidgetList_ListItem_root {
@apply relative
flex
flex-col;
&.CMS_WidgetList_ListItem_error {
& .CMS_WidgetList_ListItem_content {
@apply border-l-red-500;
}
}
&:not(.CMS_WidgetList_ListItem_error) {
&:not(.CMS_WidgetList_ListItem_disabled) {
&:hover {
& .CMS_WidgetList_ListItem_summary-label,
& .CMS_WidgetList_ListItem_expand-button-icon {
@apply text-blue-500;
}
}
&:focus-within {
& .CMS_WidgetList_ListItem_summary-label,
& .CMS_WidgetList_ListItem_expand-button-icon {
@apply text-blue-500;
}
& .CMS_WidgetList_ListItem_content {
@apply border-l-blue-500;
}
}
}
}
&.CMS_WidgetList_ListItem_disabled {
& .CMS_WidgetList_ListItem_expand-button-icon {
@apply text-slate-300
dark:text-slate-600;
}
}
&.CMS_WidgetList_ListItem_open {
& .CMS_WidgetList_ListItem_expand-button-icon {
@apply rotate-90
transform;
}
}
}
.CMS_WidgetList_ListItem_single-field-root {
@apply relative
flex
flex-col;
&.CMS_WidgetList_ListItem_error {
& .CMS_WidgetList_ListItem_content {
@apply border-l-red-500;
}
}
&:not(.CMS_WidgetList_ListItem_error) {
&:not(.CMS_WidgetList_ListItem_disabled) {
&:focus-within {
& .CMS_WidgetList_ListItem_content {
@apply border-l-blue-500;
}
}
}
}
}
.CMS_WidgetList_ListItem_header {
@apply flex
w-full
pr-3
text-left
text-sm
gap-2
items-center;
}
.CMS_WidgetList_ListItem_expand-button {
@apply flex
w-full
pl-2
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center;
}
.CMS_WidgetList_ListItem_expand-button-icon {
@apply transition-transform
h-5
w-5;
}
.CMS_WidgetList_ListItem_summary {
@apply flex-grow;
}
.CMS_WidgetList_ListItem_controls {
@apply flex
gap-2
items-center;
}
.CMS_WidgetList_ListItem_remove-button {
}
.CMS_WidgetList_ListItem_button-icon {
@apply h-5
w-5;
}
.CMS_WidgetList_ListItem_not-open-placeholder {
@apply ml-8
border-b
border-slate-400;
}
.CMS_WidgetList_ListItem_content {
@apply relative
ml-4
text-sm
text-gray-500
border-l-2
border-solid
border-l-slate-400;
}
.CMS_WidgetList_ListItem_single-field-controls {
@apply absolute
right-3
top-0
h-full
flex
items-center
justify-end
z-10;
}
.CMS_WidgetList_ListItem_drag-handle {
@apply flex
items-center;
}
.CMS_WidgetList_ListItem_drag-handle-icon {
@apply h-3
w-3;
}

View File

@ -7,10 +7,34 @@ import React, { useCallback, useMemo, useState } from 'react';
import IconButton from '@staticcms/core/components/common/button/IconButton';
import Label from '@staticcms/core/components/common/field/Label';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
import type { MouseEvent, ReactNode } from 'react';
import './ListItemWrapper.css';
const classes = generateClassNames('WidgetList_ListItem', [
'root',
'single-field-root',
'error',
'disabled',
'open',
'header',
'expand-button',
'expand-button-icon',
'summary',
'summary-label',
'controls',
'remove-button',
'button-icon',
'not-open-placeholder',
'content',
'single-field-controls',
'drag-handle',
'drag-handle-icon',
]);
export interface DragHandleProps {
listeners: SyntheticListenerMap | undefined;
disabled: boolean;
@ -18,13 +42,12 @@ export interface DragHandleProps {
const DragHandle = ({ listeners, disabled }: DragHandleProps) => {
return (
<span data-testid="drag-handle" className="flex items-center" {...(disabled ? {} : listeners)}>
<MenuIcon
className="
h-3
w-3
"
/>
<span
data-testid="drag-handle"
className={classes['drag-handle']}
{...(disabled ? {} : listeners)}
>
<MenuIcon className={classes['drag-handle-icon']} />
</span>
);
};
@ -61,7 +84,7 @@ const ListItemWrapper = ({
const renderedControls = useMemo(
() => (
<div className="flex gap-2 items-center">
<div className={classes.controls}>
{onRemove ? (
<IconButton
data-testid="remove-button"
@ -69,13 +92,9 @@ const ListItemWrapper = ({
variant="text"
onClick={onRemove}
disabled={disabled}
className={classes['remove-button']}
>
<CloseIcon
className="
h-5
w-5
"
/>
<CloseIcon className={classes['button-icon']} />
</IconButton>
) : null}
{listeners ? <DragHandle listeners={listeners} disabled={disabled} /> : null}
@ -89,34 +108,14 @@ const ListItemWrapper = ({
<div
data-testid="list-item-field"
className={classNames(
`
relative
flex
flex-col
`,
!hasErrors && 'group/active-list-item',
classes['single-field-root'],
hasErrors && classes.error,
disabled && classes.disabled,
)}
>
<div
data-testid="list-item-objects"
className={classNames(
`
relative
ml-4
text-sm
text-gray-500
border-l-2
border-solid
border-l-slate-400
`,
!disabled && 'group-focus-within/active-list-item:border-l-blue-500',
hasErrors && 'border-l-red-500',
)}
>
<div data-testid="list-item-objects" className={classes.content}>
{children}
<div className="absolute right-3 top-0 h-full flex items-center justify-end z-10">
{renderedControls}
</div>
<div className={classes['single-field-controls']}>{renderedControls}</div>
</div>
</div>
);
@ -126,73 +125,24 @@ const ListItemWrapper = ({
<div
data-testid="list-item-field"
className={classNames(
`
relative
flex
flex-col
`,
!hasErrors && 'group/active-list-item',
classes.root,
hasErrors && classes.error,
disabled && classes.disabled,
open && classes.open,
)}
>
<div
className="
flex
w-full
pr-3
text-left
text-sm
gap-2
items-center
"
>
<div className={classes.header}>
<button
data-testid="list-item-expand-button"
className="
flex
w-full
pl-2
py-2
text-left
text-sm
font-medium
focus:outline-none
focus-visible:ring
gap-2
focus-visible:ring-opacity-75
items-center
"
className={classes['expand-button']}
onClick={handleOpenToggle}
>
<ChevronRightIcon
className={classNames(
open && 'rotate-90 transform',
`
transition-transform
h-5
w-5
`,
disabled
? `
text-slate-300
dark:text-slate-600
`
: `
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
/>
<div className="flex-grow">
<ChevronRightIcon className={classes['expand-button-icon']} />
<div className={classes.summary}>
<Label
key="label"
hasErrors={hasErrors}
className={classNames(
!disabled &&
`
group-focus-within/active-list-item:text-blue-500
group-hover/active-list-item:text-blue-500
`,
)}
className={classes['summary-label']}
cursor="pointer"
variant="inline"
data-testid="item-label"
@ -205,34 +155,9 @@ const ListItemWrapper = ({
</button>
{renderedControls}
</div>
{!open ? (
<div
className="
ml-8
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
"
></div>
) : null}
{!open ? <div className={classes['not-open-placeholder']}></div> : null}
<Collapse in={open} appear={false}>
<div
className={classNames(
`
ml-4
text-sm
text-gray-500
border-l-2
border-solid
border-l-slate-400
`,
!disabled && 'group-focus-within/active-list-item:border-l-blue-500',
hasErrors && 'border-l-red-500',
)}
>
{children}
</div>
<div className={classes.content}>{children}</div>
</Collapse>
</div>
);

View File

@ -0,0 +1,8 @@
.CMS_WidgetMap_root {
}
.CMS_WidgetMap_map {
@apply relative
w-full
mt-2;
}

View File

@ -1,10 +1,14 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MapField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
const classes = generateClassNames('WidgetMapPreview', ['root']);
const MapPreview: FC<WidgetPreviewProps<string, MapField>> = ({ value }) => {
return <div>{value}</div>;
return <div className={classes.root}>{value}</div>;
};
export default MapPreview;

View File

@ -9,11 +9,24 @@ import View from 'ol/View.js';
import React, { useLayoutEffect, useRef } from 'react';
import Field from '@staticcms/core/components/common/field/Field';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MapField, WidgetControlProps } from '@staticcms/core/interface';
import type { Geometry } from 'ol/geom';
import type { FC } from 'react';
import './MapControl.css';
const classes = generateClassNames('WidgetMap', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'map',
]);
const formatOptions = {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
@ -42,6 +55,7 @@ const withMapControl = ({ getFormat, getMap }: WithMapControlProps = {}) => {
field,
onChange,
errors,
hasErrors,
forSingleList,
label,
disabled,
@ -94,16 +108,15 @@ const withMapControl = ({ getFormat, getMap }: WithMapControlProps = {}) => {
forSingleList={forSingleList}
noPadding
disabled={disabled}
rootClassName={classNames(
classes.root,
disabled && classes.disabled,
field.required !== false && classes.required,
hasErrors && classes.error,
forSingleList && classes['for-single-list'],
)}
>
<div
ref={mapContainer}
className="
relative
w-full
mt-2
"
style={{ height }}
/>
<div ref={mapContainer} className={classes.map} style={{ height }} />
</Field>
);
};

View File

@ -0,0 +1,16 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const widgetMarkdownClasses = generateClassNames('WidgetMarkdown', [
'root',
'error',
'required',
'disabled',
'for-single-list',
'raw-editor',
'rich-editor',
'plate-editor-wrapper',
'plate-editor',
'controls',
]);
export default widgetMarkdownClasses;

View File

@ -0,0 +1,28 @@
.CMS_WidgetMarkdown_root {
}
.CMS_WidgetMarkdown_raw-editor {
@apply mt-2;
}
.CMS_WidgetMarkdown_rich-editor {
@apply relative
px-3
py-5
pb-0;
}
.CMS_WidgetMarkdown_plate-editor-wrapper {
@apply w-full;
}
.CMS_WidgetMarkdown_plate-editor {
@apply !outline-none;
}
.CMS_WidgetMarkdown_controls {
@apply px-3
mt-2
flex
gap-2;
}

View File

@ -4,6 +4,7 @@ import { VFileMessage } from 'vfile-message';
import { withMdxImage } from '@staticcms/core/components/common/image/Image';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { getShortcodes } from '../../lib/registry';
import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx';
@ -13,6 +14,8 @@ import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interfac
import type { FC } from 'react';
import type { UseMdxState } from './plate/hooks/useMdx';
const classes = generateClassNames('WidgetUUIDPreview', ['root']);
interface MdxComponentProps {
state: UseMdxState;
}
@ -64,7 +67,7 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewPr
}, [value]);
return (
<div key="markdown-preview">
<div key="markdown-preview" className={classes.root}>
<MDXProvider components={components}>
<MdxComponent state={state} />{' '}
</MDXProvider>

View File

@ -46,16 +46,17 @@ import {
MARK_SUPERSCRIPT,
MARK_UNDERLINE,
Plate,
PlateLeaf,
PlateProvider,
withProps,
} from '@udecode/plate';
import { StyledLeaf } from '@udecode/plate-styled-components';
import React, { useMemo, useRef } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { useTranslate } from 'react-polyglot';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import widgetMarkdownClasses from '../MarkdownControl.classes';
import { CodeBlockElement, withShortcodeElement } from './components';
import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote';
@ -167,18 +168,18 @@ const PlateEditor: FC<PlateEditorProps> = ({
[ELEMENT_LI]: ListItemElement,
[ELEMENT_LIC]: ListItemContentElement,
[ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
[MARK_BOLD]: withProps(StyledLeaf, { as: 'strong' }),
[MARK_ITALIC]: withProps(StyledLeaf, { as: 'em' }),
[MARK_STRIKETHROUGH]: withProps(StyledLeaf, { as: 's' }),
[MARK_BOLD]: withProps(PlateLeaf, { as: 'strong' }),
[MARK_ITALIC]: withProps(PlateLeaf, { as: 'em' }),
[MARK_STRIKETHROUGH]: withProps(PlateLeaf, { as: 's' }),
};
if (useMdx) {
// MDX Widget
return {
...baseComponents,
[MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }),
[MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }),
[MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }),
[MARK_SUBSCRIPT]: withProps(PlateLeaf, { as: 'sub' }),
[MARK_SUPERSCRIPT]: withProps(PlateLeaf, { as: 'sup' }),
[MARK_UNDERLINE]: withProps(PlateLeaf, { as: 'u' }),
};
}
@ -257,7 +258,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
return useMemo(
() => (
<div className="relative px-3 py-5 pb-0">
<div className={widgetMarkdownClasses['rich-editor']}>
<DndProvider backend={HTML5Backend}>
<PlateProvider<MdValue>
id={id}
@ -276,7 +277,11 @@ const PlateEditor: FC<PlateEditorProps> = ({
disabled={disabled}
/>
<div key="editor-wrapper" ref={editorContainerRef} className="w-full">
<div
key="editor-wrapper"
ref={editorContainerRef}
className={widgetMarkdownClasses['plate-editor-wrapper']}
>
<Plate
key="editor"
id={id}
@ -285,7 +290,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
placeholder: t('editor.editorWidgets.markdown.type'),
onFocus,
onBlur,
className: '!outline-none',
className: widgetMarkdownClasses['plate-editor'],
}}
>
<div key="editor-inner-wrapper" ref={innerEditorContainerRef}>

View File

@ -0,0 +1,28 @@
.CMS_WidgetMarkdown_BalloonToolbar_root {
@apply fixed;
}
.CMS_WidgetMarkdown_BalloonToolbar_popper {
@apply absolute
max-h-60
overflow-hidden
rounded-md
bg-white
p-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-[100]
dark:bg-slate-700
dark:shadow-lg;
}
.CMS_WidgetMarkdown_BalloonToolbar_content {
@apply flex
gap-0.5
items-center;
}

View File

@ -1,4 +1,4 @@
import PopperUnstyled from '@mui/base/PopperUnstyled';
import { Popper } from '@mui/base/Popper';
import {
ELEMENT_LINK,
ELEMENT_TD,
@ -15,12 +15,6 @@ import {
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { selectVisible } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import { getToolbarButtons } from '../../hooks/useToolbarButtons';
import {
BOLD_TOOLBAR_BUTTON,
CODE_TOOLBAR_BUTTON,
@ -36,14 +30,24 @@ import {
SHORTCODE_TOOLBAR_BUTTON,
STRIKETHROUGH_TOOLBAR_BUTTON,
} from '@staticcms/core/constants/toolbar_buttons';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { selectVisible } from '@staticcms/core/reducers/selectors/mediaLibrary';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import { getToolbarButtons } from '../../hooks/useToolbarButtons';
import type {
Collection,
MarkdownField,
MarkdownToolbarButtonType,
} from '@staticcms/core/interface';
import type { ClientRectObject } from '@udecode/plate';
import type { FC, ReactNode } from 'react';
import type { ClientRectObject } from '@udecode/plate';
import './BalloonToolbar.css';
const classes = generateClassNames('WidgetMarkdown_BalloonToolbar', ['root', 'popper', 'content']);
const DEFAULT_EMPTY_BUTTONS: MarkdownToolbarButtonType[] = [];
@ -103,7 +107,6 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
const editor = useMdPlateEditorState();
const selection = usePlateSelection();
const [hasFocus, setHasFocus] = useState(false);
const debouncedHasFocus = useDebounce(hasFocus, 150);
const isMediaLibraryOpen = useAppSelector(selectVisible);
@ -116,9 +119,21 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
}, []);
const anchorEl = useRef<HTMLDivElement | null>(null);
const [selectionBoundingClientRect, setSelectionBoundingClientRect] =
useState<ClientRectObject | null>(null);
const rect = getSelectionBoundingClientRect();
useEffect(() => {
if (rect.x === 0 && rect.y === 0) {
return;
}
setSelectionBoundingClientRect(rect);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect.x, rect.y]);
const [selectionExpanded, selectionText] = useMemo(() => {
if (!editor) {
return [undefined, undefined, undefined];
@ -130,25 +145,12 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
const node = getNode(editor, editor.selection?.anchor.path ?? []);
useEffect(() => {
if (!editor || !hasEditorFocus) {
return;
}
setTimeout(() => {
setSelectionBoundingClientRect(getSelectionBoundingClientRect());
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, debouncedHasFocus]);
const isInTableCell = useMemo(() => {
return Boolean(
selection && someNode(editor, { match: { type: ELEMENT_TD }, at: selection?.anchor }),
);
}, [editor, selection]);
const debouncedEditorFocus = useDebounce(hasEditorFocus, 150);
const [groups, setGroups] = useState<ReactNode[]>([]);
useEffect(() => {
@ -156,7 +158,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
return;
}
if (!debouncedEditorFocus && !hasFocus && !debouncedHasFocus) {
if (!hasEditorFocus && !hasFocus) {
setGroups([]);
return;
}
@ -202,9 +204,8 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
setGroups([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
debouncedEditorFocus,
hasFocus,
debouncedHasFocus,
hasEditorFocus,
selection,
editor,
selectionText,
@ -218,75 +219,39 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
isMediaLibraryOpen,
]);
const [prevSelectionBoundingClientRect, setPrevSelectionBoundingClientRect] = useState(
selectionBoundingClientRect,
);
const debouncedGroups = useDebounce(
groups,
prevSelectionBoundingClientRect !== selectionBoundingClientRect ? 0 : 150,
);
const open = useMemo(
() => groups.length > 0 || debouncedGroups.length > 0 || isMediaLibraryOpen,
[debouncedGroups.length, groups.length, isMediaLibraryOpen],
() => Boolean(selectionBoundingClientRect && (groups.length > 0 || isMediaLibraryOpen)),
[groups.length, isMediaLibraryOpen, selectionBoundingClientRect],
);
const debouncedOpen = useDebounce(
open,
prevSelectionBoundingClientRect !== selectionBoundingClientRect ? 0 : 50,
);
useEffect(() => {
setPrevSelectionBoundingClientRect(selectionBoundingClientRect);
}, [selectionBoundingClientRect]);
return (
<>
<div
ref={anchorEl}
className="fixed"
className={classes.root}
style={{
top: `${selectionBoundingClientRect?.y ?? 0}px`,
left: `${selectionBoundingClientRect?.x}px`,
left: `${selectionBoundingClientRect?.x ?? 0}px`,
width: 1,
height: 1,
}}
/>
{groups.length > 0 || debouncedGroups.length > 0 ? (
<PopperUnstyled
open={Boolean(debouncedOpen && anchorEl.current)}
component="div"
{selectionBoundingClientRect && open && anchorEl.current && groups.length > 0 ? (
<Popper
open
placement="top"
anchorEl={anchorEl.current ?? null}
onFocus={handleFocus}
onBlur={handleBlur}
tabIndex={0}
className="
absolute
max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
dark:shadow-lg
"
slots={{ root: 'div' }}
className={classes.popper}
keepMounted
>
<div
data-testid="balloon-toolbar"
className="
flex
gap-0.5
"
>
{groups.length > 0 ? groups : debouncedGroups}
<div data-testid="balloon-toolbar" className={classes.content}>
{groups}
</div>
</PopperUnstyled>
</Popper>
) : null}
</>
);

View File

@ -8,6 +8,7 @@ import {
findNodePath,
getNode,
getParentNode,
getSelectionBoundingClientRect,
getSelectionText,
isElement,
isElementEmpty,
@ -73,6 +74,7 @@ describe(BalloonToolbar.name, () => {
const mockGetParentNode = getParentNode as jest.Mock;
const mockGetSelectionText = getSelectionText as jest.Mock;
const mockIsSelectionExpanded = isSelectionExpanded as jest.Mock;
const mockGetSelectionBoundingClientRect = getSelectionBoundingClientRect as jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
@ -84,6 +86,13 @@ describe(BalloonToolbar.name, () => {
} as unknown as MdEditor;
mockUseEditor.mockReturnValue(mockEditor);
mockGetSelectionBoundingClientRect.mockReturnValue({
x: 1,
y: 1,
width: 1,
height: 1,
});
});
afterAll(() => {
@ -221,7 +230,7 @@ describe(BalloonToolbar.name, () => {
it('renders selected table node toolbar when text is selected in table', () => {
emptyNodeToolbarSetup({ inTable: true, selectedText: 'Test Text' });
expect(screen.queryByTestId('balloon-toolbar')).toBeInTheDocument();
expect(screen.getByTestId('balloon-toolbar')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-bold')).toBeInTheDocument();
expect(screen.queryByTestId('toolbar-button-italic')).toBeInTheDocument();

View File

@ -0,0 +1,82 @@
.CMS_WidgetMarkdown_FontTypeSelect_root {
@apply w-28
h-6
mx-1;
&.CMS_WidgetMarkdown_FontTypeSelect_disabled {
& .CMS_WidgetMarkdown_FontTypeSelect_select {
@apply text-gray-300
border-gray-200
dark:border-gray-600
dark:text-gray-500;
}
}
}
.CMS_WidgetMarkdown_FontTypeSelect_select {
@apply flex
items-center
justify-between
text-sm
font-medium
relative
px-1.5
py-0.5
w-full
h-6
border
rounded-sm
text-gray-800
border-gray-600
dark:border-gray-400
dark:text-gray-100;
}
.CMS_WidgetMarkdown_FontTypeSelect_popper {
@apply max-h-40
overflow-auto
rounded-md
bg-white
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
dark:bg-slate-800
dark:shadow-lg
z-[101];
}
.CMS_WidgetMarkdown_FontTypeSelect_option {
@apply relative
select-none
py-2
px-4
cursor-pointer
hover:bg-blue-500
hover:text-white
dark:hover:bg-blue-500;
&.CMS_WidgetMarkdown_FontTypeSelect_option-selected {
@apply bg-blue-500/25
dark:bg-blue-700/20;
& .CMS_WidgetMarkdown_FontTypeSelect_option-label {
@apply font-medium;
}
}
}
.CMS_WidgetMarkdown_FontTypeSelect_option-label {
@apply block
truncate
font-normal;
}
.CMS_WidgetMarkdown_FontTypeSelect_more-button-icon {
@apply w-4
h-4
absolute
right-0;
}

View File

@ -1,5 +1,5 @@
import OptionUnstyled from '@mui/base/OptionUnstyled';
import SelectUnstyled from '@mui/base/SelectUnstyled';
import { Option } from '@mui/base/Option';
import { Select } from '@mui/base/Select';
import { UnfoldMore as UnfoldMoreIcon } from '@styled-icons/material/UnfoldMore';
import {
ELEMENT_H1,
@ -9,7 +9,6 @@ import {
ELEMENT_H5,
ELEMENT_H6,
ELEMENT_PARAGRAPH,
focusEditor,
someNode,
toggleNodeType,
} from '@udecode/plate';
@ -17,11 +16,26 @@ import React, { useCallback, useMemo, useState } from 'react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { SelectUnstyledRootSlotProps } from '@mui/base/SelectUnstyled';
import type { SelectRootSlotProps } from '@mui/base/Select';
import type { FC, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import './FontTypeSelect.css';
const classes = generateClassNames('WidgetMarkdown_FontTypeSelect', [
'root',
'disabled',
'select',
'popper',
'option',
'option-selected',
'option-label',
'more-button',
'more-button-icon',
]);
type Option = {
value: string;
label: string;
@ -59,14 +73,14 @@ const types: Option[] = [
];
const Button = React.forwardRef(function Button<TValue extends {}, Multiple extends boolean>(
props: SelectUnstyledRootSlotProps<TValue, Multiple>,
props: SelectRootSlotProps<TValue, Multiple>,
ref: React.ForwardedRef<HTMLButtonElement>,
) {
const { ownerState: _, children, ...other } = props;
return (
<button type="button" {...other} ref={ref}>
<button type="button" {...other} ref={ref} className={classes.select}>
{children}
<UnfoldMoreIcon className="w-4 h-4 absolute right-0" />
<UnfoldMoreIcon className={classes['more-button-icon']} />
</button>
);
});
@ -103,23 +117,13 @@ const FontTypeSelect: FC<FontTypeSelectProps> = ({ disabled = false }) => {
});
setVersion(oldVersion => oldVersion + 1);
setTimeout(() => {
focusEditor(editor);
});
},
[editor, value?.value],
);
return (
<div
className="
w-28
h-6
mx-1
"
>
<SelectUnstyled
<div className={classNames(classes.root, disabled && classes.disabled)}>
<Select
value={value?.value ?? ELEMENT_PARAGRAPH}
onChange={handleChange}
disabled={disabled}
@ -127,54 +131,9 @@ const FontTypeSelect: FC<FontTypeSelectProps> = ({ disabled = false }) => {
root: Button,
}}
slotProps={{
root: {
className: classNames(
`
flex
items-center
justify-between
text-sm
font-medium
relative
px-1.5
py-0.5
w-full
h-6
border
rounded-sm
`,
disabled
? `
text-gray-300
border-gray-200
dark:border-gray-600
dark:text-gray-500
`
: `
text-gray-800
border-gray-600
dark:border-gray-200
dark:text-gray-100
`,
),
},
popper: {
disablePortal: false,
className: `
max-h-40
w-50
overflow-auto
rounded-md
bg-white
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
dark:bg-slate-800
dark:shadow-lg
`,
className: classNames(classes.popper, 'CMS_Scrollbar_root', 'CMS_Scrollbar_secondary'),
},
}}
data-testid="font-type-select"
@ -183,40 +142,20 @@ const FontTypeSelect: FC<FontTypeSelectProps> = ({ disabled = false }) => {
const selected = (value?.value ?? ELEMENT_PARAGRAPH) === type.value;
return (
<OptionUnstyled
<Option
key={type.value}
value={type.value}
slotProps={{
root: {
className: classNames(
`
relative
select-none
py-2
px-4
cursor-pointer
hover:bg-blue-500
hover:text-white
dark:hover:bg-blue-500
`,
selected &&
`
bg-blue-500/25
dark:bg-blue-700/20
`,
),
className: classNames(classes.option, selected && classes['option-selected']),
},
}}
>
<span
className={classNames('block truncate', selected ? 'font-medium' : 'font-normal')}
>
{type.label}
</span>
</OptionUnstyled>
<span className={classes['option-label']}>{type.label}</span>
</Option>
);
})}
</SelectUnstyled>
</Select>
</div>
);
};

View File

@ -0,0 +1,11 @@
.CMS_WidgetMarkdown_ShortcodeToolbarButton_button {
@apply py-0.5
px-0.5
h-6
w-6;
}
.CMS_WidgetMarkdown_ShortcodeToolbarButton_label-icon {
@apply h-5
w-5;
}

View File

@ -7,10 +7,19 @@ import MenuGroup from '@staticcms/core/components/common/menu/MenuGroup';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import { getShortcodes } from '@staticcms/core/lib/registry';
import { toTitleCase } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { ELEMENT_SHORTCODE, useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { FC } from 'react';
import './ShortcodeToolbarButton.css';
const classes = generateClassNames('WidgetMarkdown_ShortcodeToolbarButton', [
'root',
'label-icon',
'button',
]);
export interface ShortcodeToolbarButtonProps {
disabled: boolean;
}
@ -35,17 +44,13 @@ const ShortcodeToolbarButton: FC<ShortcodeToolbarButtonProps> = ({ disabled }) =
return (
<Menu
label={<DataArrayIcon className="h-5 w-5" aria-hidden="true" />}
label={<DataArrayIcon className={classes['label-icon']} aria-hidden="true" />}
data-testid="toolbar-button-shortcode"
keepMounted
hideDropdownIcon
variant="text"
buttonClassName="
py-0.5
px-0.5
h-6
w-6
"
rootClassName={classes.root}
buttonClassName={classes.button}
disabled={disabled}
>
<MenuGroup>

View File

@ -1,23 +1,16 @@
import {
DEFAULT_COLORS,
DEFAULT_CUSTOM_COLORS,
getMark,
getPluginType,
removeMark,
setMarks,
usePlateEditorRef,
} from '@udecode/plate';
import { getMark, getPluginType, removeMark, setMarks, usePlateEditorRef } from '@udecode/plate';
import React, { useCallback, useEffect, useState } from 'react';
import { Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import ColorPicker from '../../color-picker/ColorPicker';
import { DEFAULT_COLORS, DEFAULT_CUSTOM_COLORS } from '../../color-picker/constants';
import ToolbarDropdown from './dropdown/ToolbarDropdown';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
import type { BaseEditor } from 'slate';
import type { ColorType } from '../../color-picker/types';
import type { ToolbarButtonProps } from './ToolbarButton';
export interface ColorPickerToolbarDropdownProps extends Omit<ToolbarButtonProps, 'onClick'> {

View File

@ -0,0 +1,20 @@
.CMS_Button_root {
&.CMS_WidgetMarkdown_ToolbarButton_root {
@apply py-0.5
px-0.5;
&.CMS_WidgetMarkdown_ToolbarButton_active {
&:not(.CMS_WidgetMarkdown_ToolbarButton_custom-active-color) {
@apply text-blue-500
bg-gray-100
dark:text-blue-500
dark:bg-slate-800;
}
}
}
}
.CMS_WidgetMarkdown_ToolbarButton_icon {
@apply w-5
h-5;
}

View File

@ -4,10 +4,20 @@ import React, { useCallback } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import MenuItemButton from '@staticcms/core/components/common/menu/MenuItemButton';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { useMdPlateEditorState } from '@staticcms/markdown/plate/plateTypes';
import type { CSSProperties, FC, MouseEvent } from 'react';
import './ToolbarButton.css';
const classes = generateClassNames('WidgetMarkdown_ToolbarButton', [
'root',
'active',
'custom-active-color',
'icon',
]);
export interface ToolbarButtonProps {
label?: string;
tooltip: string;
@ -73,23 +83,14 @@ const ToolbarButton: FC<ToolbarButtonProps> = ({
data-testid={`toolbar-button-${label ?? tooltip}`.replace(' ', '-').toLowerCase()}
onClick={handleOnClick}
className={classNames(
`
py-0.5
px-0.5
`,
active &&
!activeColor &&
`
text-blue-500
bg-gray-100
dark:text-blue-500
dark:bg-slate-800
`,
classes.root,
active && classes.active,
activeColor && classes['custom-active-color'],
)}
style={style}
disabled={disabled}
>
{<Icon className="w-5 h-5" />}
{<Icon className={classes.icon} />}
</Button>
);
};

View File

@ -0,0 +1,23 @@
.CMS_WidgetMarkdown_ColorButton_root {
&.CMS_WidgetMarkdown_ColorButton_is-bright-color {
& .CMS_WidgetMarkdown_ColorButton_avatar {
border: 1px solid rgba(209, 213, 219, 1);
}
& .CMS_WidgetMarkdown_ColorButton_check-icon {
@apply text-black;
}
}
}
.CMS_WidgetMarkdown_ColorButton_avatar {
@apply w-8
h-8
border-transparent;
}
.CMS_WidgetMarkdown_ColorButton_check-icon {
@apply h-5
w-5
text-white;
}

View File

@ -5,9 +5,17 @@ import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useCallback } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react';
const classes = generateClassNames('WidgetMarkdown_ColorButton', [
'root',
'avatar',
'is-bright-color',
'check-icon',
]);
export type ColorButtonProps = {
name: string;
value: string;
@ -29,23 +37,19 @@ const ColorButton: FC<ColorButtonProps> = ({
return (
<Tooltip title={name} disableInteractive>
<IconButton onClick={handleOnClick} sx={{ p: 0 }}>
<IconButton
onClick={handleOnClick}
sx={{ p: 0 }}
className={classNames(classes.root, isBrightColor && classes['is-bright-color'])}
>
<Avatar
alt={name}
className={classes.avatar}
sx={{
background: value,
width: 32,
height: 32,
border: isBrightColor ? '1px solid rgba(209,213,219, 1)' : 'transparent',
}}
>
{isSelected ? (
<CheckIcon
className={classNames('h-5 w-5', isBrightColor ? 'text-black' : 'text-white')}
/>
) : (
<>&nbsp;</>
)}
{isSelected ? <CheckIcon className={classes['check-icon']} /> : <>&nbsp;</>}
</Avatar>
</IconButton>
</Tooltip>

View File

@ -4,8 +4,8 @@ import React, { memo } from 'react';
import Colors from './Colors';
import CustomColors from './CustomColors';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
import type { ColorType } from './types';
export type ColorPickerProps = {
color?: string;

View File

@ -2,8 +2,8 @@ import React from 'react';
import ColorButton from './ColorButton';
import type { ColorType } from '@udecode/plate';
import type { FC } from 'react';
import type { ColorType } from './types';
export type ColorsProps = {
color?: string;

View File

@ -4,8 +4,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import ColorInput from './ColorInput';
import Colors from './Colors';
import type { ColorType } from '@udecode/plate';
import type { ChangeEvent, FC } from 'react';
import type { ColorType } from './types';
export type CustomColorsProps = {
color?: string;

View File

@ -0,0 +1,438 @@
import type { ColorType } from './types';
export const DEFAULT_COLORS: ColorType[] = [
{
name: 'black',
value: '#000000',
isBrightColor: false,
},
{
name: 'dark grey 4',
value: '#434343',
isBrightColor: false,
},
{
name: 'dark grey 3',
value: '#666666',
isBrightColor: false,
},
{
name: 'dark grey 2',
value: '#999999',
isBrightColor: false,
},
{
name: 'dark grey 1',
value: '#B7B7B7',
isBrightColor: false,
},
{
name: 'grey',
value: '#CCCCCC',
isBrightColor: false,
},
{
name: 'light grey 1',
value: '#D9D9D9',
isBrightColor: false,
},
{
name: 'light grey 2',
value: '#EFEFEF',
isBrightColor: true,
},
{
name: 'light grey 3',
value: '#F3F3F3',
isBrightColor: true,
},
{
name: 'white',
value: '#FFFFFF',
isBrightColor: true,
},
{
name: 'red berry',
value: '#980100',
isBrightColor: false,
},
{
name: 'red',
value: '#FE0000',
isBrightColor: false,
},
{
name: 'orange',
value: '#FE9900',
isBrightColor: false,
},
{
name: 'yellow',
value: '#FEFF00',
isBrightColor: true,
},
{
name: 'green',
value: '#00FF00',
isBrightColor: false,
},
{
name: 'cyan',
value: '#00FFFF',
isBrightColor: false,
},
{
name: 'cornflower blue',
value: '#4B85E8',
isBrightColor: false,
},
{
name: 'blue',
value: '#1300FF',
isBrightColor: false,
},
{
name: 'purple',
value: '#9900FF',
isBrightColor: false,
},
{
name: 'magenta',
value: '#FF00FF',
isBrightColor: false,
},
{
name: 'light red berry 3',
value: '#E6B8AF',
isBrightColor: false,
},
{
name: 'light red 3',
value: '#F4CCCC',
isBrightColor: false,
},
{
name: 'light orange 3',
value: '#FCE4CD',
isBrightColor: true,
},
{
name: 'light yellow 3',
value: '#FFF2CC',
isBrightColor: true,
},
{
name: 'light green 3',
value: '#D9EAD3',
isBrightColor: true,
},
{
name: 'light cyan 3',
value: '#D0DFE3',
isBrightColor: false,
},
{
name: 'light cornflower blue 3',
value: '#C9DAF8',
isBrightColor: false,
},
{
name: 'light blue 3',
value: '#CFE1F3',
isBrightColor: true,
},
{
name: 'light purple 3',
value: '#D9D2E9',
isBrightColor: true,
},
{
name: 'light magenta 3',
value: '#EAD1DB',
isBrightColor: true,
},
{
name: 'light red berry 2',
value: '#DC7E6B',
isBrightColor: false,
},
{
name: 'light red 2',
value: '#EA9999',
isBrightColor: false,
},
{
name: 'light orange 2',
value: '#F9CB9C',
isBrightColor: false,
},
{
name: 'light yellow 2',
value: '#FFE598',
isBrightColor: true,
},
{
name: 'light green 2',
value: '#B7D6A8',
isBrightColor: false,
},
{
name: 'light cyan 2',
value: '#A1C4C9',
isBrightColor: false,
},
{
name: 'light cornflower blue 2',
value: '#A4C2F4',
isBrightColor: false,
},
{
name: 'light blue 2',
value: '#9FC5E8',
isBrightColor: false,
},
{
name: 'light purple 2',
value: '#B5A7D5',
isBrightColor: false,
},
{
name: 'light magenta 2',
value: '#D5A6BD',
isBrightColor: false,
},
{
name: 'light red berry 1',
value: '#CC4125',
isBrightColor: false,
},
{
name: 'light red 1',
value: '#E06666',
isBrightColor: false,
},
{
name: 'light orange 1',
value: '#F6B26B',
isBrightColor: false,
},
{
name: 'light yellow 1',
value: '#FFD966',
isBrightColor: false,
},
{
name: 'light green 1',
value: '#93C47D',
isBrightColor: false,
},
{
name: 'light cyan 1',
value: '#76A5AE',
isBrightColor: false,
},
{
name: 'light cornflower blue 1',
value: '#6C9EEB',
isBrightColor: false,
},
{
name: 'light blue 1',
value: '#6FA8DC',
isBrightColor: false,
},
{
name: 'light purple 1',
value: '#8D7CC3',
isBrightColor: false,
},
{
name: 'light magenta 1',
value: '#C27BA0',
isBrightColor: false,
},
{
name: 'dark red berry 1',
value: '#A61B00',
isBrightColor: false,
},
{
name: 'dark red 1',
value: '#CC0000',
isBrightColor: false,
},
{
name: 'dark orange 1',
value: '#E59138',
isBrightColor: false,
},
{
name: 'dark yellow 1',
value: '#F1C231',
isBrightColor: false,
},
{
name: 'dark green 1',
value: '#6AA74F',
isBrightColor: false,
},
{
name: 'dark cyan 1',
value: '#45818E',
isBrightColor: false,
},
{
name: 'dark cornflower blue 1',
value: '#3B78D8',
isBrightColor: false,
},
{
name: 'dark blue 1',
value: '#3E84C6',
isBrightColor: false,
},
{
name: 'dark purple 1',
value: '#664EA6',
isBrightColor: false,
},
{
name: 'dark magenta 1',
value: '#A64D78',
isBrightColor: false,
},
{
name: 'dark red berry 2',
value: '#84200D',
isBrightColor: false,
},
{
name: 'dark red 2',
value: '#990001',
isBrightColor: false,
},
{
name: 'dark orange 2',
value: '#B45F05',
isBrightColor: false,
},
{
name: 'dark yellow 2',
value: '#BF9002',
isBrightColor: false,
},
{
name: 'dark green 2',
value: '#38761D',
isBrightColor: false,
},
{
name: 'dark cyan 2',
value: '#124F5C',
isBrightColor: false,
},
{
name: 'dark cornflower blue 2',
value: '#1155CB',
isBrightColor: false,
},
{
name: 'dark blue 2',
value: '#0C5394',
isBrightColor: false,
},
{
name: 'dark purple 2',
value: '#351C75',
isBrightColor: false,
},
{
name: 'dark magenta 2',
value: '#741B47',
isBrightColor: false,
},
{
name: 'dark red berry 3',
value: '#5B0F00',
isBrightColor: false,
},
{
name: 'dark red 3',
value: '#660000',
isBrightColor: false,
},
{
name: 'dark orange 3',
value: '#783F04',
isBrightColor: false,
},
{
name: 'dark yellow 3',
value: '#7E6000',
isBrightColor: false,
},
{
name: 'dark green 3',
value: '#274E12',
isBrightColor: false,
},
{
name: 'dark cyan 3',
value: '#0D343D',
isBrightColor: false,
},
{
name: 'dark cornflower blue 3',
value: '#1B4487',
isBrightColor: false,
},
{
name: 'dark blue 3',
value: '#083763',
isBrightColor: false,
},
{
name: 'dark purple 3',
value: '#1F124D',
isBrightColor: false,
},
{
name: 'dark magenta 3',
value: '#4C1130',
isBrightColor: false,
},
];
export const DEFAULT_CUSTOM_COLORS: ColorType[] = [
{
name: 'dark orange 3',
value: '#783F04',
isBrightColor: false,
},
{
name: 'dark grey 3',
value: '#666666',
isBrightColor: false,
},
{
name: 'dark grey 2',
value: '#999999',
isBrightColor: false,
},
{
name: 'light cornflower blue 1',
value: '#6C9EEB',
isBrightColor: false,
},
{
name: 'dark magenta 3',
value: '#4C1130',
isBrightColor: false,
},
];

View File

@ -0,0 +1,5 @@
export type ColorType = {
name: string;
value: string;
isBrightColor: boolean;
};

View File

@ -0,0 +1,35 @@
.CMS_WidgetMarkdown_MediaPopover_root {
@apply absolute
max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
dark:shadow-lg;
}
.CMS_WidgetMarkdown_MediaPopover_content {
@apply flex
gap-0.5;
}
.CMS_WidgetMarkdown_MediaPopover_icon {
@apply w-4
h-4;
}
.CMS_WidgetMarkdown_MediaPopover_divider {
@apply w-[1px]
border
border-gray-100
dark:border-slate-600;
}

View File

@ -1,10 +1,11 @@
import PopperUnstyled from '@mui/base/PopperUnstyled';
import { Popper } from '@mui/base/Popper';
import { DeleteForever as DeleteForeverIcon } from '@styled-icons/material/DeleteForever';
import { OpenInNew as OpenInNewIcon } from '@styled-icons/material/OpenInNew';
import React, { useCallback, useMemo } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import useMediaInsert from '@staticcms/core/lib/hooks/useMediaInsert';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import type {
@ -15,6 +16,15 @@ import type {
} from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import './MediaPopover.css';
const classes = generateClassNames('WidgetMarkdown_MediaPopover', [
'root',
'content',
'icon',
'divider',
]);
export interface MediaPopoverProps<T extends FileOrImageField | MarkdownField> {
anchorEl: HTMLElement | null;
url: string;
@ -78,64 +88,33 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
const id = open ? 'edit-popover' : undefined;
return (
<PopperUnstyled
<Popper
id={id}
open={open}
component="div"
placement="top"
anchorEl={anchorEl}
onFocus={handleFocus}
onBlur={handleBlur}
disablePortal
tabIndex={0}
className="
absolute
max-h-60
overflow-auto
rounded-md
bg-white
p-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-40
dark:bg-slate-700
dark:shadow-lg
"
slots={{ root: 'div' }}
className={classes.root}
>
<div
key="edit-content"
contentEditable={false}
className="
flex
gap-0.5
"
>
<div key="edit-content" contentEditable={false} className={classes.content}>
<Button onClick={handleOpenMediaLibrary} variant="text" size="small">
{forImage ? 'Edit Image' : 'Edit Link'}
</Button>
<div
className="
w-[1px]
border
border-gray-100
dark:border-slate-600
"
/>
<div className={classes.divider} />
{!forImage ? (
<Button href={url} variant="text" size="small" onClick={noop}>
<OpenInNewIcon className="w-4 h-4" title="Open In New Tab" />
<OpenInNewIcon className={classes.icon} title="Open In New Tab" />
</Button>
) : null}
<Button onClick={onRemove} variant="text" size="small">
<DeleteForeverIcon className="w-4 h-4" title="Delete" />
<DeleteForeverIcon className={classes.icon} title="Delete" />
</Button>
</div>
</PopperUnstyled>
</Popper>
);
};

View File

@ -0,0 +1,6 @@
.CMS_WidgetMarkdown_Blockquote_root {
@apply border-l-2
border-gray-400
ml-2
pl-2;
}

View File

@ -1,21 +1,19 @@
import Box from '@mui/system/Box';
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdBlockquoteElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './BlockquoteElement.css';
const classes = generateClassNames('WidgetMarkdown_Blockquote', ['root']);
const BlockquoteElement: FC<PlateRenderElementProps<MdValue, MdBlockquoteElement>> = ({
children,
}) => {
return (
<Box
component="blockquote"
sx={{ borderLeft: '2px solid rgba(209,213,219,0.5)', marginLeft: '8px', paddingLeft: '8px' }}
>
{children}
</Box>
);
return <blockquote className={classes.root}>{children}</blockquote>;
};
export default BlockquoteElement;

View File

@ -0,0 +1,18 @@
.CMS_WidgetMarkdown_CodeBlock_root {
@apply my-2;
}
.CMS_WidgetMarkdown_CodeBlock_language-input {
@apply w-full
rounded-t-md
border
border-gray-100
border-b-white
px-2
py-1
h-6
dark:border-slate-700
dark:border-b-slate-800
dark:bg-slate-800
outline-none;
}

View File

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Frame from 'react-frame-component';
import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
@ -12,6 +13,10 @@ import type { MdCodeBlockElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps, TCodeBlockElement } from '@udecode/plate';
import type { FC, MutableRefObject, RefObject } from 'react';
import './CodeBlockElement.css';
const classes = generateClassNames('WidgetMarkdown_CodeBlock', ['root', 'language-input']);
const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>> = props => {
const { attributes, nodeProps, element, editor, children } = props;
const id = useUUID();
@ -90,9 +95,7 @@ const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>>
{...attributes}
{...nodeProps}
contentEditable={false}
className="
my-2
"
className={classes.root}
>
<input
id={id}
@ -102,20 +105,7 @@ const CodeBlockElement: FC<PlateRenderElementProps<MdValue, MdCodeBlockElement>>
const path = findNodePath(editor, element);
path && setNodes<TCodeBlockElement>(editor, { lang: value }, { at: path });
}}
className="
w-full
rounded-t-md
border
border-gray-100
border-b-white
px-2
py-1
h-6
dark:border-slate-700
dark:border-b-slate-800
dark:bg-slate-800
outline-none
"
className={classes['language-input']}
/>
<div>
<Frame

View File

@ -0,0 +1,6 @@
.CMS_WidgetMarkdown_Heading1_root {
@apply text-[2em]
leading-[1.5em]
font-bold
my-[0.67em];
}

View File

@ -1,25 +1,22 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdH1Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './Heading1.css';
const classes = generateClassNames('WidgetMarkdown_Heading1', ['root']);
const Heading1: FC<PlateRenderElementProps<MdValue, MdH1Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h1
{...attributes}
{...nodeProps}
className="
text-[2em]
leading-[1.5em]
font-bold
my-[0.67em]
"
>
<h1 {...attributes} {...nodeProps} className={classes.root}>
{children}
</h1>
);

View File

@ -0,0 +1,6 @@
.CMS_WidgetMarkdown_Heading2_root {
@apply text-[1.5em]
leading-[1.25em]
font-bold
my-[0.83em];
}

View File

@ -1,8 +1,14 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdH2Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import type { MdH2Element, MdValue } from '@staticcms/markdown';
import './Heading2.css';
const classes = generateClassNames('WidgetMarkdown_Heading2', ['root']);
const Heading2: FC<PlateRenderElementProps<MdValue, MdH2Element>> = ({
attributes,
@ -10,16 +16,7 @@ const Heading2: FC<PlateRenderElementProps<MdValue, MdH2Element>> = ({
nodeProps,
}) => {
return (
<h2
{...attributes}
{...nodeProps}
className="
text-[1.5em]
leading-[1.25em]
font-bold
my-[0.83em]
"
>
<h2 {...attributes} {...nodeProps} className={classes.root}>
{children}
</h2>
);

View File

@ -0,0 +1,6 @@
.CMS_WidgetMarkdown_Heading3_root {
@apply text-[1.17em]
leading-[1.25em]
font-bold
my-[1em];
}

View File

@ -1,25 +1,22 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdH3Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './Heading3.css';
const classes = generateClassNames('WidgetMarkdown_Heading3', ['root']);
const Heading3: FC<PlateRenderElementProps<MdValue, MdH3Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h3
{...attributes}
{...nodeProps}
className="
text-[1.17em]
leading-[1.25em]
font-bold
my-[1em]
"
>
<h3 {...attributes} {...nodeProps} className={classes.root}>
{children}
</h3>
);

View File

@ -0,0 +1,5 @@
.CMS_WidgetMarkdown_Heading4_root {
@apply leading-[1.25em]
font-bold
my-[1.33em];
}

View File

@ -1,24 +1,22 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdH4Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './Heading4.css';
const classes = generateClassNames('WidgetMarkdown_Heading4', ['root']);
const Heading4: FC<PlateRenderElementProps<MdValue, MdH4Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h4
{...attributes}
{...nodeProps}
className="
leading-[1.25em]
font-bold
my-[1.33em]
"
>
<h4 {...attributes} {...nodeProps} className={classes.root}>
{children}
</h4>
);

View File

@ -0,0 +1,6 @@
.CMS_WidgetMarkdown_Heading5_root {
@apply text-[0.83em]
leading-[1.25em]
font-bold
my-[1.67em];
}

View File

@ -1,25 +1,22 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdH5Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './Heading5.css';
const classes = generateClassNames('WidgetMarkdown_Heading5', ['root']);
const Heading5: FC<PlateRenderElementProps<MdValue, MdH5Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h5
{...attributes}
{...nodeProps}
className="
text-[0.83em]
leading-[1.25em]
font-bold
my-[1.67em]
"
>
<h5 {...attributes} {...nodeProps} className={classes.root}>
{children}
</h5>
);

View File

@ -0,0 +1,6 @@
.CMS_WidgetMarkdown_Heading6_root {
@apply text-[0.67em]
leading-[1.25em]
font-bold
my-[2.33em];
}

View File

@ -1,25 +1,22 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdH6Element, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './Heading6.css';
const classes = generateClassNames('WidgetMarkdown_Heading6', ['root']);
const Heading6: FC<PlateRenderElementProps<MdValue, MdH6Element>> = ({
attributes,
children,
nodeProps,
}) => {
return (
<h6
{...attributes}
{...nodeProps}
className="
text-[0.67em]
leading-[1.25em]
font-bold
my-[2.33em]
"
>
<h6 {...attributes} {...nodeProps} className={classes.root}>
{children}
</h6>
);

View File

@ -0,0 +1,5 @@
.CMS_WidgetMarkdown_Link_root {
@apply text-blue-500
cursor-pointer
hover:underline;
}

View File

@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useFocused } from 'slate-react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import MediaPopover from '../../common/MediaPopover';
import type { Collection, MarkdownField, MediaPath } from '@staticcms/core/interface';
@ -19,6 +20,10 @@ import type { MdLinkElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps, TText } from '@udecode/plate';
import type { FC, MouseEvent } from 'react';
import './LinkElement.css';
const classes = generateClassNames('WidgetMarkdown_Link', ['root']);
export interface WithLinkElementProps {
collection: Collection<MarkdownField>;
field: MarkdownField;
@ -201,11 +206,7 @@ const withLinkElement = ({ collection, field }: WithLinkElementProps) => {
href={url}
{...nodeProps}
onClick={handleClick}
className="
text-blue-500
cursor-pointer
hover:underline
"
className={classes.root}
>
{children}
</a>

View File

@ -0,0 +1,4 @@
.CMS_WidgetMarkdown_ListItem_checkbox {
@apply m-[5px]
mr-2;
}

View File

@ -2,11 +2,17 @@ import { findNodePath, setNodes } from '@udecode/plate';
import React, { useCallback } from 'react';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import type { MdListItemElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { ChangeEvent, FC } from 'react';
import './ListItemElement.css';
const classes = generateClassNames('WidgetMarkdown_ListItem', ['root', 'checked', 'checkbox']);
const ListItemElement: FC<PlateRenderElementProps<MdValue, MdListItemElement>> = ({
children,
editor,
@ -24,9 +30,15 @@ const ListItemElement: FC<PlateRenderElementProps<MdValue, MdListItemElement>> =
);
return (
<li>
<li className={classNames(classes.root, checked && classes.checked)}>
{isNotNullish(checked) ? (
<input type="checkbox" checked={checked} onChange={handleChange} className="m-[5px] mr-2" />
<input
key={`checkbox-${checked}`}
type="checkbox"
checked={checked ?? false}
onChange={handleChange}
className={classes.checkbox}
/>
) : null}
{children}
</li>

View File

@ -0,0 +1,4 @@
.CMS_WidgetMarkdown_OrderedList_root {
@apply list-decimal
pl-10;
}

View File

@ -1,22 +1,19 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdNumberedListElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './OrderedListElement.css';
const classes = generateClassNames('WidgetMarkdown_OrderedList', ['root']);
const OrderedListElement: FC<PlateRenderElementProps<MdValue, MdNumberedListElement>> = ({
children,
}) => {
return (
<ol
className="
list-decimal
pl-10
"
>
{children}
</ol>
);
return <ol className={classes.root}>{children}</ol>;
};
export default OrderedListElement;

View File

@ -0,0 +1,4 @@
.CMS_WidgetMarkdown_UnorderedList_root {
@apply list-disc
pl-10;
}

View File

@ -1,22 +1,19 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdBulletedListElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './UnorderedListElement.css';
const classes = generateClassNames('WidgetMarkdown_UnorderedList', ['root']);
const UnorderedListElement: FC<PlateRenderElementProps<MdValue, MdBulletedListElement>> = ({
children,
}) => {
return (
<ul
className="
list-disc
pl-10
"
>
{children}
</ul>
);
return <ul className={classes.root}>{children}</ul>;
};
export default UnorderedListElement;

View File

@ -0,0 +1,4 @@
.CMS_WidgetMarkdown_Paragraph_root {
@apply block
my-[1em];
}

View File

@ -1,21 +1,21 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { MdParagraphElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import './ParagraphElement.css';
const classes = generateClassNames('WidgetMarkdown_Paragraph', ['root']);
const ParagraphElement: FC<PlateRenderElementProps<MdValue, MdParagraphElement>> = ({
children,
element: { align },
}) => {
return (
<p
style={{ textAlign: align }}
className="
block
my-[1em]
"
>
<p style={{ textAlign: align }} className={classes.root}>
{children}
</p>
);

View File

@ -0,0 +1,12 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const widgetMarkdownTableClasses = generateClassNames('WidgetMarkdown_Table', [
'root',
'header',
'body',
'row',
'header-cell',
'body-cell',
]);
export default widgetMarkdownTableClasses;

View File

@ -0,0 +1,51 @@
.CMS_WidgetMarkdown_Table_root {
@apply border-collapse
border
border-gray-200
dark:border-slate-700
rounded-md
my-4;
}
.CMS_WidgetMarkdown_Table_header {
@apply border-r
border-b
bg-slate-300
border-gray-200
dark:bg-slate-700
dark:border-gray-800;
}
.CMS_WidgetMarkdown_Table_body {
}
.CMS_WidgetMarkdown_Table_row {
@apply border-b
border-gray-200
last:border-0
dark:border-gray-800;
}
.CMS_WidgetMarkdown_Table_header-cell {
@apply px-2
py-1
[&>div>p]:m-0
text-left
bg-slate-300
text-sm
border-r
border-gray-200
dark:bg-slate-700
dark:border-gray-800;
}
.CMS_WidgetMarkdown_Table_body-cell {
@apply px-2
py-1
[&>div>p]:m-0
border-r
border-gray-200
last:border-0
dark:border-gray-800
text-sm;
}

View File

@ -1,5 +1,7 @@
import React from 'react';
import widgetMarkdownTableClasses from '../Table.classes';
import type { MdTableCellElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
@ -10,20 +12,7 @@ const TableHeaderCellElement: FC<PlateRenderElementProps<MdValue, MdTableCellEle
nodeProps,
}) => {
return (
<td
{...attributes}
{...nodeProps}
className="
px-2
py-1
[&>div>p]:m-0
border-r
border-gray-200
last:border-0
dark:border-gray-800
text-sm
"
>
<td {...attributes} {...nodeProps} className={widgetMarkdownTableClasses['body-cell']}>
<div>{children}</div>
</td>
);

View File

@ -1,5 +1,7 @@
import React from 'react';
import widgetMarkdownTableClasses from '../Table.classes';
import type { MdTableCellElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
@ -10,22 +12,7 @@ const TableHeaderCellElement: FC<PlateRenderElementProps<MdValue, MdTableCellEle
nodeProps,
}) => {
return (
<th
{...attributes}
{...nodeProps}
className="
px-2
py-1
[&>div>p]:m-0
text-left
bg-slate-300
text-sm
border-r
border-gray-200
dark:bg-slate-700
dark:border-gray-800
"
>
<th {...attributes} {...nodeProps} className={widgetMarkdownTableClasses['header-cell']}>
<div>{children}</div>
</th>
);

View File

@ -1,10 +1,14 @@
import { useSelectedCells } from '@udecode/plate';
import React from 'react';
import widgetMarkdownTableClasses from '../Table.classes';
import type { MdTableElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import '../Table.css';
const TableElement: FC<PlateRenderElementProps<MdValue, MdTableElement>> = ({
attributes,
children,
@ -13,34 +17,15 @@ const TableElement: FC<PlateRenderElementProps<MdValue, MdTableElement>> = ({
useSelectedCells();
return (
<table
{...attributes}
{...nodeProps}
className="
border-collapse
border
border-gray-200
dark:border-slate-700
rounded-md
my-4
"
>
<table {...attributes} {...nodeProps} className={widgetMarkdownTableClasses.root}>
{children ? (
<>
<thead
key="thead"
className="
border-r
border-b
bg-slate-300
border-gray-200
dark:bg-slate-700
dark:border-gray-800
"
>
<thead key="thead" className={widgetMarkdownTableClasses.header}>
{children[0]}
</thead>
<tbody key="tbody">{children.slice(1)}</tbody>
<tbody key="tbody" className={widgetMarkdownTableClasses.body}>
{children.slice(1)}
</tbody>
</>
) : null}
</table>

View File

@ -1,8 +1,10 @@
import React from 'react';
import widgetMarkdownTableClasses from '../Table.classes';
import type { MdTableRowElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
import type { MdTableRowElement, MdValue } from '@staticcms/markdown';
const TableRowElement: FC<PlateRenderElementProps<MdValue, MdTableRowElement>> = ({
attributes,
@ -10,16 +12,7 @@ const TableRowElement: FC<PlateRenderElementProps<MdValue, MdTableRowElement>> =
nodeProps,
}) => {
return (
<tr
{...attributes}
{...nodeProps}
className="
border-b
border-gray-200
last:border-0
dark:border-gray-800
"
>
<tr {...attributes} {...nodeProps} className={widgetMarkdownTableClasses.row}>
{children}
</tr>
);

View File

@ -0,0 +1,23 @@
.CMS_WidgetMarkdown_Toolbar_root {
@apply flex
flex-wrap
items-center
select-none
min-h-markdown-toolbar
-mx-3
-my-5
px-2
pt-2
pb-1.5
mb-1.5
border-b
border-gray-400/10
gap-0.5
shadow-sm
bg-slate-50
dark:bg-slate-900
dark:shadow-md
sticky
top-0
z-10;
}

Some files were not shown because too many files have changed in this diff Show More