diff --git a/.changeset/wise-melons-smash.md b/.changeset/wise-melons-smash.md new file mode 100644 index 000000000..a7400e456 --- /dev/null +++ b/.changeset/wise-melons-smash.md @@ -0,0 +1,11 @@ +--- +"@shopware-ag/meteor-component-library": major +--- + +# Add new Text Editor component + +This change introduces a new Text Editor component to the Meteor Component Library. + +# Updated i18n configuration + +We change the 'legacy' mode of i18n to 'false' in the Meteor Component Library configuration to use the new i18n composable. \ No newline at end of file diff --git a/packages/component-library/.storybook/preview.ts b/packages/component-library/.storybook/preview.ts index 65203d77a..0457e4741 100644 --- a/packages/component-library/.storybook/preview.ts +++ b/packages/component-library/.storybook/preview.ts @@ -18,6 +18,7 @@ import "@shopware-ag/meteor-tokens/administration/dark.css"; const i18n = createI18n({ // something vue-i18n options here ... + legacy: false, globalInjection: true, locale: "en", fallbackLocale: "en", diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-colorpicker--visual-test-open-colorpicker-with-apply-mode-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-colorpicker--visual-test-open-colorpicker-with-apply-mode-snap.png new file mode 100644 index 000000000..78ed6fa6b Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-colorpicker--visual-test-open-colorpicker-with-apply-mode-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-code-view-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-code-view-snap.png new file mode 100644 index 000000000..05258a583 Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-code-view-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-disabled-editor-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-disabled-editor-snap.png new file mode 100644 index 000000000..902e0868c Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-disabled-editor-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-inline-mode-selected-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-inline-mode-selected-snap.png new file mode 100644 index 000000000..0a71126af Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-inline-mode-selected-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-inline-mode-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-inline-mode-snap.png new file mode 100644 index 000000000..b6357bba1 Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-inline-mode-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-snap.png new file mode 100644 index 000000000..a5a93624b Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-editor-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-error-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-error-snap.png new file mode 100644 index 000000000..d79911605 Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-error-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-placeholder-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-placeholder-snap.png new file mode 100644 index 000000000..9cfbcc989 Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-render-placeholder-snap.png differ diff --git a/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-show-contextual-buttons-snap.png b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-show-contextual-buttons-snap.png new file mode 100644 index 000000000..14d4b199b Binary files /dev/null and b/packages/component-library/__snapshots__/interaction-tests-form-mt-text-editor--visual-test-show-contextual-buttons-snap.png differ diff --git a/packages/component-library/package.json b/packages/component-library/package.json index a10abd081..cb1644561 100644 --- a/packages/component-library/package.json +++ b/packages/component-library/package.json @@ -39,6 +39,7 @@ "test:storybook": "test-storybook" }, "dependencies": { + "@codemirror/lang-html": "^6.4.9", "@floating-ui/dom": "^1.4.3", "@floating-ui/vue": "^1.1.5", "@shopware-ag/meteor-icon-kit": "workspace:*", @@ -46,15 +47,38 @@ "@storybook/addon-a11y": "^8.4.5", "@testing-library/jest-dom": "^6.4.6", "@testing-library/vue": "^8.1.0", + "@tiptap/extension-bubble-menu": "^2.10.0", + "@tiptap/extension-bullet-list": "^2.10.0", + "@tiptap/extension-character-count": "^2.10.0", + "@tiptap/extension-color": "^2.9.1", + "@tiptap/extension-highlight": "^2.10.3", + "@tiptap/extension-link": "^2.10.0", + "@tiptap/extension-list-item": "^2.10.0", + "@tiptap/extension-ordered-list": "^2.10.0", + "@tiptap/extension-placeholder": "^2.10.4", + "@tiptap/extension-subscript": "^2.9.1", + "@tiptap/extension-superscript": "^2.9.1", + "@tiptap/extension-table": "^2.10.3", + "@tiptap/extension-table-cell": "^2.10.3", + "@tiptap/extension-table-header": "^2.10.3", + "@tiptap/extension-table-row": "^2.10.3", + "@tiptap/extension-text-align": "^2.9.1", + "@tiptap/extension-text-style": "^2.9.1", + "@tiptap/extension-underline": "^2.9.1", + "@tiptap/pm": "^2.9.1", + "@tiptap/starter-kit": "^2.9.1", + "@tiptap/vue-3": "^2.9.1", "@vuepic/vue-datepicker": "^10.0.0", "@vueuse/components": "^10.7.2", "@vueuse/core": "^10.7.2", + "codemirror": "^6.0.1", "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0", "focus-trap": "^7.5.4", "inter-ui": "^3.19.3", "lodash-es": "^4.17.21", "nanoid": "^5.0.7", + "vue-codemirror6": "^1.3.8", "vue-i18n": "^9.9.1", "vue-smooth-reflow": "^0.1.12" }, @@ -109,8 +133,5 @@ "vite-plugin-svgstring": "^1.0.0", "vitest": "^1.1.3", "vue-tsc": "^2.0.21" - }, - "peerDependencies": { - "vue": ">= 3.5.0" } } diff --git a/packages/component-library/src/components/form/_internal/mt-base-field/mt-base-field.vue b/packages/component-library/src/components/form/_internal/mt-base-field/mt-base-field.vue index 3afc57f8f..4954149a3 100644 --- a/packages/component-library/src/components/form/_internal/mt-base-field/mt-base-field.vue +++ b/packages/component-library/src/components/form/_internal/mt-base-field/mt-base-field.vue @@ -262,6 +262,10 @@ $mt-field-transition: margin-bottom: var(--scale-size-12); } + &.is--disabled { + cursor: not-allowed; + } + &__hint-wrapper { display: flex; justify-content: space-between; diff --git a/packages/component-library/src/components/form/mt-button/mt-button.vue b/packages/component-library/src/components/form/mt-button/mt-button.vue index 40b5e20e8..387176b56 100644 --- a/packages/component-library/src/components/form/mt-button/mt-button.vue +++ b/packages/component-library/src/components/form/mt-button/mt-button.vue @@ -289,6 +289,7 @@ const isInsideTooltip = useIsInsideTooltip(); width: 100%; display: flex; justify-content: center; + align-items: center; } .mt-button--x-small { diff --git a/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.interactive.stories.ts b/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.interactive.stories.ts index 55ec41309..13689e799 100644 --- a/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.interactive.stories.ts +++ b/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.interactive.stories.ts @@ -2,6 +2,7 @@ import { within, userEvent, fireEvent } from "@storybook/test"; import { expect } from "@storybook/test"; import meta, { type MtColorpickerMeta, type MtColorpickerStory } from "./mt-colorpicker.stories"; +import { waitUntil } from "@/_internal/test-helper"; export default { ...meta, @@ -56,6 +57,188 @@ export const VisualTestOpenColorpicker: MtColorpickerStory = { }, }; +export const VisualTestOpenColorpickerWithApplyMode: MtColorpickerStory = { + name: "Open colorpicker with apply mode", + args: { + modelValue: "rgba(72, 228, 37, 0.81)", + applyMode: true, + colorOutput: "rgb", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const pickerToggle = canvas.getByLabelText("colorpicker-toggle"); + + await userEvent.click(pickerToggle); + + // Look inside the popover + const popover = within( + document.getElementsByClassName("mt-floating-ui__content")[0] as HTMLElement, + ); + + const colorRange = popover.getByLabelText("colorpicker-color-range") as HTMLInputElement; + const alphaRange = popover.getByLabelText("colorpicker-alpha-range") as HTMLInputElement; + const hexInput = popover.getByLabelText("hex-value") as HTMLInputElement; + const redInput = popover.getByLabelText("red-value") as HTMLInputElement; + const greenInput = popover.getByLabelText("green-value") as HTMLInputElement; + const blueInput = popover.getByLabelText("blue-value") as HTMLInputElement; + const alphaInput = popover.getByLabelText("alpha-value") as HTMLInputElement; + + expect(colorRange).toBeDefined(); + expect(colorRange.value).toEqual("109"); + expect(alphaRange).toBeDefined(); + expect(alphaRange.value).toEqual("0.81"); + + expect(hexInput).toBeDefined(); + expect(hexInput.value).toEqual("#48e425cf"); + expect(redInput).toBeDefined(); + expect(redInput.value).toEqual("72"); + expect(greenInput).toBeDefined(); + expect(greenInput.value).toEqual("228"); + expect(blueInput).toBeDefined(); + expect(blueInput.value).toEqual("37"); + expect(alphaInput).toBeDefined(); + expect(alphaInput.value).toEqual("81"); + + // Check for apply button + const applyButton = popover.getByLabelText("colorpicker-apply-color") as HTMLButtonElement; + expect(applyButton).toBeDefined(); + }, +}; + +export const TestOpenColorpickerWithApplyMode: MtColorpickerStory = { + name: "Use colorpicker with apply mode", + args: { + modelValue: "rgba(72, 228, 37, 0.81)", + applyMode: true, + colorOutput: "rgb", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const pickerToggle = canvas.getByLabelText("colorpicker-toggle"); + + await userEvent.click(pickerToggle); + + // Look inside the popover + const popover = within( + document.getElementsByClassName("mt-floating-ui__content")[0] as HTMLElement, + ); + + const colorRange = popover.getByLabelText("colorpicker-color-range") as HTMLInputElement; + const alphaRange = popover.getByLabelText("colorpicker-alpha-range") as HTMLInputElement; + const hexInput = popover.getByLabelText("hex-value") as HTMLInputElement; + const redInput = popover.getByLabelText("red-value") as HTMLInputElement; + const greenInput = popover.getByLabelText("green-value") as HTMLInputElement; + const blueInput = popover.getByLabelText("blue-value") as HTMLInputElement; + const alphaInput = popover.getByLabelText("alpha-value") as HTMLInputElement; + + expect(colorRange).toBeDefined(); + expect(colorRange.value).toEqual("109"); + expect(alphaRange).toBeDefined(); + expect(alphaRange.value).toEqual("0.81"); + + expect(hexInput).toBeDefined(); + expect(hexInput.value).toEqual("#48e425cf"); + expect(redInput).toBeDefined(); + expect(redInput.value).toEqual("72"); + expect(greenInput).toBeDefined(); + expect(greenInput.value).toEqual("228"); + expect(blueInput).toBeDefined(); + expect(blueInput.value).toEqual("37"); + expect(alphaInput).toBeDefined(); + expect(alphaInput.value).toEqual("81"); + + // Check for apply button + const applyButton = popover.getByLabelText("colorpicker-apply-color") as HTMLButtonElement; + expect(applyButton).toBeDefined(); + + // Change colors + fireEvent.input(colorRange, { target: { value: 300 } }); + fireEvent.input(alphaRange, { target: { value: 0.5 } }); + + // Apply changes + await userEvent.click(applyButton); + + // Wait until the popover is closed + await waitUntil(() => { + return document.getElementsByClassName("mt-floating-ui__content").length === 0; + }); + + // Check if the color is applied + expect(args.updateModelValue).toHaveBeenCalledWith("rgba(228, 37, 228, 0.5)"); + }, +}; + +export const ResetsColorInApplyMode: MtColorpickerStory = { + name: "Resets color in apply mode when closed without applying", + args: { + modelValue: "rgba(72, 228, 37, 0.81)", + applyMode: true, + colorOutput: "rgb", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const pickerToggle = canvas.getByLabelText("colorpicker-toggle"); + + await userEvent.click(pickerToggle); + + // Look inside the popover + const popover = within( + document.getElementsByClassName("mt-floating-ui__content")[0] as HTMLElement, + ); + + const colorRange = popover.getByLabelText("colorpicker-color-range") as HTMLInputElement; + const alphaRange = popover.getByLabelText("colorpicker-alpha-range") as HTMLInputElement; + const hexInput = popover.getByLabelText("hex-value") as HTMLInputElement; + const redInput = popover.getByLabelText("red-value") as HTMLInputElement; + const greenInput = popover.getByLabelText("green-value") as HTMLInputElement; + const blueInput = popover.getByLabelText("blue-value") as HTMLInputElement; + const alphaInput = popover.getByLabelText("alpha-value") as HTMLInputElement; + + expect(colorRange).toBeDefined(); + expect(colorRange.value).toEqual("109"); + expect(alphaRange).toBeDefined(); + expect(alphaRange.value).toEqual("0.81"); + + expect(hexInput).toBeDefined(); + expect(hexInput.value).toEqual("#48e425cf"); + expect(redInput).toBeDefined(); + expect(redInput.value).toEqual("72"); + expect(greenInput).toBeDefined(); + expect(greenInput.value).toEqual("228"); + expect(blueInput).toBeDefined(); + expect(blueInput.value).toEqual("37"); + expect(alphaInput).toBeDefined(); + expect(alphaInput.value).toEqual("81"); + + // Check for apply button + const applyButton = popover.getByLabelText("colorpicker-apply-color") as HTMLButtonElement; + expect(applyButton).toBeDefined(); + + // Change colors + fireEvent.input(colorRange, { target: { value: 300 } }); + fireEvent.input(alphaRange, { target: { value: 0.5 } }); + + const colorpickerInputField = canvas.getByLabelText( + "colorpicker-color-value", + ) as HTMLInputElement; + + // Close popover without applying + await userEvent.click(colorpickerInputField); + + // Wait until the popover is closed + await waitUntil(() => { + return document.getElementsByClassName("mt-floating-ui__content").length === 0; + }); + + // Check if the color is resetted + expect(args.updateModelValue).not.toHaveBeenCalled(); + expect(colorpickerInputField.value).toEqual("rgba(72, 228, 37, 0.81)"); + }, +}; + export const VisualTestChangeColorpickerColor: MtColorpickerStory = { name: "Change colorpicker color", args: { diff --git a/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.stories.ts b/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.stories.ts index f9aaafdb1..4cee3c915 100644 --- a/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.stories.ts +++ b/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.stories.ts @@ -1,18 +1,32 @@ import MtColorpicker from "./mt-colorpicker.vue"; import type { StoryObj } from "@storybook/vue3"; import type { SlottedMeta } from "@/_internal/story-helper"; +import { ref } from "vue"; +import { fn } from "@storybook/test"; -export type MtColorpickerMeta = SlottedMeta; +export type MtColorpickerMeta = SlottedMeta; export default { title: "Components/Form/mt-colorpicker", component: MtColorpicker, render: (args) => ({ components: { MtColorpicker }, - template: '', + template: ``, setup: () => { + const currentModelValue = ref(args.modelValue); + const onUpdateModelValue = (value: string) => { + currentModelValue.value = value; + args.updateModelValue(value); + }; + return { args, + currentModelValue, + onUpdateModelValue, }; }, }), @@ -30,6 +44,8 @@ export default { isInherited: false, isInheritanceField: false, disableInheritanceToggle: false, + compact: false, + updateModelValue: fn(), }, } as MtColorpickerMeta; diff --git a/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.vue b/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.vue index c247c0e87..ff09b1269 100644 --- a/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.vue +++ b/packages/component-library/src/components/form/mt-colorpicker/mt-colorpicker.vue @@ -1,6 +1,6 @@ @@ -215,14 +226,39 @@ import { debounce } from "lodash-es"; import MtBaseField from "../_internal/mt-base-field/mt-base-field.vue"; import MtFloatingUi from "../../_internal/mt-floating-ui/mt-floating-ui.vue"; import MtText from "@/components/content/mt-text/mt-text.vue"; +import MtButton from "@/components/form/mt-button/mt-button.vue"; +import mtFieldError from "../_internal/mt-field-error/mt-field-error.vue"; +import { useI18n } from "@/composables/useI18n"; export default defineComponent({ name: "MtColorpicker", + setup() { + const { t } = useI18n({ + messages: { + en: { + "mt-colorpicker": { + apply: "Apply", + }, + }, + de: { + "mt-colorpicker": { + apply: "Anwenden", + }, + }, + }, + }); + return { + t, + }; + }, + components: { "mt-base-field": MtBaseField, "mt-text": MtText, "mt-floating-ui": MtFloatingUi, + "mt-button": MtButton, + "mt-field-error": mtFieldError, }, props: { @@ -360,6 +396,24 @@ export default defineComponent({ required: false, default: null, }, + + /** + * Show the colorpicker in a compact mode + */ + compact: { + type: Boolean, + required: false, + default: false, + }, + + /** + * Use apply-mode to apply the color value on button click + */ + applyMode: { + type: Boolean, + required: false, + default: false, + }, }, data(): { @@ -636,10 +690,20 @@ export default defineComponent({ left: this.selectorPositionX, }; }, + + componentClasses(): { + "mt-colorpicker": boolean; + "mt-colorpicker--compact": boolean; + } { + return { + "mt-colorpicker": true, + "mt-colorpicker--compact": this.compact, + }; + }, }, watch: { - value() { + modelValue() { this.colorValue = this.modelValue; }, @@ -647,7 +711,14 @@ export default defineComponent({ this.colorValue = this.convertedValue; }, - visible(visibleStatus) { + visible(visibleStatus, visibleStatusBefore) { + if (this.applyMode) { + // When colorpicker is closed, reset the color value + if (!visibleStatus && visibleStatusBefore) { + this.colorValue = this.modelValue; + } + } + if (!visibleStatus) { return; } @@ -709,6 +780,12 @@ export default defineComponent({ methods: { debounceEmitColorValue: debounce(function emitValue() { + // @ts-expect-error - this context is wrong detected + // Don't emit the value if applyMode is active + if (this.applyMode) { + return; + } + /** * Emits the selected color value * @property {string} this.colorValue the new color value @@ -758,6 +835,13 @@ export default defineComponent({ this.removeOutsideClickEvent(); }, + applyColor() { + // Manually emit the color value + this.$emit("update:modelValue", this.colorValue); + // Close the colorpicker + this.visible = false; + }, + moveSelector(event: MouseEvent) { if (!this.isDragging) { return; @@ -1253,6 +1337,7 @@ export default defineComponent({ diff --git a/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button-link.vue b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button-link.vue new file mode 100644 index 000000000..ffbc6b1cb --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button-link.vue @@ -0,0 +1,142 @@ + + + + + + + diff --git a/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button-table.vue b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button-table.vue new file mode 100644 index 000000000..5254fb0a1 --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button-table.vue @@ -0,0 +1,229 @@ + + + + + + + diff --git a/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button.vue b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button.vue new file mode 100644 index 000000000..e9c76008a --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar-button.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar.vue b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar.vue new file mode 100644 index 000000000..ec01b3b37 --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/_internal/mt-text-editor-toolbar.vue @@ -0,0 +1,483 @@ + + + + + diff --git a/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.interactive.stories.ts b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.interactive.stories.ts new file mode 100644 index 000000000..a6428a658 --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.interactive.stories.ts @@ -0,0 +1,836 @@ +import { within, userEvent } from "@storybook/test"; +import { expect } from "@storybook/test"; +import { waitUntil } from "../../../_internal/test-helper"; + +import meta, { type MtTextEditorMeta, type MtTextEditorStory } from "./mt-text-editor.stories"; +import { defineStory } from "@/_internal/story-helper"; + +export default { + ...meta, + title: "Interaction Tests/Form/mt-text-editor", +} as MtTextEditorMeta; + +/** + * Selects the text of an element. It is important to + * click on the element before calling this function + * to ensure that the element is focused. Because + * Storybook throws manual events, the element is not + * focused by default. + */ +function selectText(element: HTMLElement) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +export const VisualTestRenderEditor: MtTextEditorStory = defineStory({ + name: "Should render the text editor", + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("82 characters")).toBeDefined(); + }, +}); + +export const VisualTestRenderEditorInlineMode: MtTextEditorStory = defineStory({ + name: "Should render the text editor in inline mode", + args: { + isInlineEdit: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("82 characters")).toBeDefined(); + }, +}); + +export const VisualTestRenderDisabledEditor: MtTextEditorStory = defineStory({ + name: "Should render the disabled text editor", + args: { + disabled: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("82 characters")).toBeDefined(); + }, +}); + +export const VisualTestRenderPlaceholder: MtTextEditorStory = defineStory({ + name: "Should render the placeholder inside text editor", + args: { + placeholder: "Type something...", + modelValue: "", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("0 characters")).toBeDefined(); + }, +}); + +export const VisualTestRenderError: MtTextEditorStory = defineStory({ + name: "Should render a error in text editor", + args: { + error: { + code: 500, + detail: "Error while saving!", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("82 characters")).toBeDefined(); + }, +}); + +export const VisualTestRenderEditorInlineModeSelected: MtTextEditorStory = defineStory({ + name: "Should render the bubble menu in inline mode when text is selected", + args: { + isInlineEdit: true, + modelValue: `

Hello World

`, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Wait until the counter is rendered + await waitUntil(() => canvas.getByText("11 characters")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Wait until the bubble menu is rendered + await waitUntil(() => document.querySelector(".mt-text-editor-toolbar") !== null); + + // Expect the bubble menu to be rendered + expect(canvas.getByLabelText("Format")).toBeDefined(); + expect(canvas.getByLabelText("Bold")).toBeDefined(); + expect(canvas.getByLabelText("Italic")).toBeDefined(); + + // Expect the contextual buttons to be rendered + expect(canvas.getByLabelText("Insert row before")).toBeDefined(); + expect(canvas.getByLabelText("Insert row after")).toBeDefined(); + expect(canvas.getByLabelText("Delete row")).toBeDefined(); + }, +}); + +export const SetParagraph: MtTextEditorStory = defineStory({ + name: "Should set paragraph", + args: { + modelValue: `

Hello World

`, + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Paragraph" + await userEvent.click(screen.getByText("Paragraph")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("

Hello World

"); + }, +}); + +export const SetHeadlineH1: MtTextEditorStory = defineStory({ + name: "Should set h1 headline", + args: { + modelValue: `

Hello World

`, + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Headline 1" + await userEvent.click(screen.getByText("Headline 1")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("

Hello World

"); + }, +}); + +export const SetHeadlineH2: MtTextEditorStory = defineStory({ + name: "Should set h2 headline", + args: { + modelValue: "

Hello World

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Headline 2" + await userEvent.click(screen.getByText("Headline 2")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("

Hello World

"); + }, +}); + +export const SetHeadlineH3: MtTextEditorStory = defineStory({ + name: "Should set h3 headline", + args: { + modelValue: "

Hello World

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Headline 3" + await userEvent.click(screen.getByText("Headline 3")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("

Hello World

"); + }, +}); + +export const SetHeadlineH4: MtTextEditorStory = defineStory({ + name: "Should set h4 headline", + args: { + modelValue: "

Hello World

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Headline 4" + await userEvent.click(screen.getByText("Headline 4")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("

Hello World

"); + }, +}); + +export const SetHeadlineH5: MtTextEditorStory = defineStory({ + name: "Should set h5 headline", + args: { + modelValue: "

Hello World

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Headline 5" + await userEvent.click(screen.getByText("Headline 5")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("
Hello World
"); + }, +}); + +export const SetHeadlineH6: MtTextEditorStory = defineStory({ + name: "Should set h6 headline", + args: { + modelValue: "

Hello World

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("11 characters")).toBeDefined(); + + // Click on button with aria-label "Format" + await userEvent.click(canvas.getByLabelText("Format")); + + // Click on menuitem with text "Headline 6" + await userEvent.click(screen.getByText("Headline 6")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue.mock.calls.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith("
Hello World
"); + }, +}); + +export const SetTextColor: MtTextEditorStory = defineStory({ + name: "Should set text color", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "colorpicker-toggle" + await userEvent.click(canvas.getByLabelText("colorpicker-toggle")); + + // Set new color value + await waitUntil(() => document.querySelector("[aria-label='colorpicker-apply-color']")); + + const redInput = screen.getByLabelText("red-value") as HTMLInputElement; + const greenInput = screen.getByLabelText("green-value") as HTMLInputElement; + const blueInput = screen.getByLabelText("blue-value") as HTMLInputElement; + + await userEvent.clear(redInput); + await userEvent.type(redInput, "255"); + + await userEvent.clear(greenInput); + await userEvent.type(greenInput, "0"); + + await userEvent.clear(blueInput); + await userEvent.type(blueInput, "0"); + + // Click on button with aria-label "Apply" + await userEvent.click(screen.getByLabelText("colorpicker-apply-color")); + + // Expect the color to be applied + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + expect(args.updateModelValue).toHaveBeenCalledWith( + '

Hello World

Some text

', + ); + }, +}); + +export const MakeFontBold: MtTextEditorStory = defineStory({ + name: "Should make font bold", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "bold" + await userEvent.click(canvas.getByLabelText("Bold")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Hello World

Some text

", + ); + }, +}); + +export const MakeFontItalic: MtTextEditorStory = defineStory({ + name: "Should make font italic", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "italic" + await userEvent.click(canvas.getByLabelText("Italic")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Hello World

Some text

", + ); + }, +}); + +export const MakeFontUnderline: MtTextEditorStory = defineStory({ + name: "Should make font underline", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "underline" + await userEvent.click(canvas.getByLabelText("Underline")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Hello World

Some text

", + ); + }, +}); + +export const MakeFontStrikeThrough: MtTextEditorStory = defineStory({ + name: "Should make font strike through", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "strike-through" + await userEvent.click(canvas.getByLabelText("Strikethrough")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Hello World

Some text

", + ); + }, +}); + +export const MakeFontSuperScript: MtTextEditorStory = defineStory({ + name: "Should make font super script", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "superscript" + await userEvent.click(canvas.getByLabelText("Superscript")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Hello World

Some text

", + ); + }, +}); + +export const MakeFontSubScript: MtTextEditorStory = defineStory({ + name: "Should make font sub script", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "subscript" + await userEvent.click(canvas.getByLabelText("Subscript")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Hello World

Some text

", + ); + }, +}); + +export const SetTextAlignmentLeft: MtTextEditorStory = defineStory({ + name: "Should set text alignment left", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Text Alignment" + await userEvent.click(canvas.getByLabelText("Text Alignment")); + + // Click on menuitem with text "Align left" + await userEvent.click(screen.getByText("Align left")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content (no change expected because left is default) + expect(args.updateModelValue).toHaveBeenCalledWith("

Hello World

Some text

"); + }, +}); + +export const SetTextAlignmentCenter: MtTextEditorStory = defineStory({ + name: "Should set text alignment center", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Text Alignment" + await userEvent.click(canvas.getByLabelText("Text Alignment")); + + // Click on menuitem with text "Align center" + await userEvent.click(screen.getByText("Align center")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + '

Hello World

Some text

', + ); + }, +}); + +export const SetTextAlignmentRight: MtTextEditorStory = defineStory({ + name: "Should set text alignment right", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + expect(canvas.getByText("20 characters")).toBeDefined(); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Text Alignment" + await userEvent.click(canvas.getByLabelText("Text Alignment")); + + // Click on menuitem with text "Align right" + await userEvent.click(screen.getByText("Align right")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + '

Hello World

Some text

', + ); + }, +}); + +export const SetTextAlignmentJustify: MtTextEditorStory = defineStory({ + name: "Should set text alignment justify", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args, screen }) => { + const canvas = within(canvasElement); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Text Alignment" + await userEvent.click(canvas.getByLabelText("Text Alignment")); + + // Click on menuitem with text "Align justify" + await userEvent.click(screen.getByText("Justify")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + '

Hello World

Some text

', + ); + }, +}); + +export const SetUnorderedList: MtTextEditorStory = defineStory({ + name: "Should set unordered list", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Unordered list" + await userEvent.click(canvas.getByLabelText("Insert Unordered List")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "

Some text

", + ); + }, +}); + +export const SetOrderedList: MtTextEditorStory = defineStory({ + name: "Should set ordered list", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Ordered list" + await userEvent.click(canvas.getByLabelText("Insert Ordered List")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + "
  1. Hello World

Some text

", + ); + }, +}); + +export const SetLink: MtTextEditorStory = defineStory({ + name: "Should set link", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Click inside the editor + await userEvent.click(canvas.getByText("Hello World")); + + // Select "Hello World" text + selectText(canvas.getByText("Hello World")); + + // Click on button with aria-label "Link" + await userEvent.click(canvas.getByLabelText("Link")); + + // Get body + const body = within(document.body); + + // Set link url + const linkInput = body.getByLabelText("Link URL"); + await userEvent.clear(linkInput); + await userEvent.type(linkInput, "https://www.shopware.com"); + + // Toggle link target + const targetCheckbox = document.querySelector( + "div[aria-label='Open in new tab'] input[type='checkbox'", + ) as HTMLInputElement; + await userEvent.click(targetCheckbox); + + // Click on button with text "Apply link" + await userEvent.click(body.getByText("Apply link")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + '

Hello World

Some text

', + ); + }, +}); + +export const InsertTable: MtTextEditorStory = defineStory({ + name: "Should insert table", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Click on button with aria-label "Table" + await userEvent.click(canvas.getByLabelText("Table")); + + // Get body + const body = within(document.body); + + // Set table columns + const columnsInput = body.getByLabelText("Columns"); + await userEvent.type(columnsInput, "{selectall}{backspace}4"); + + // Set table rows + const rowsInput = body.getByLabelText("Rows"); + await userEvent.type(rowsInput, "{selectall}{backspace}2"); + + // Click on button with text "Insert table" + await userEvent.click(body.getByText("Insert table")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Check if args was triggered with new content + expect(args.updateModelValue).toHaveBeenCalledWith( + '

Hello World

Some text

', + ); + }, +}); + +export const VisualTestShowContextualButtons: MtTextEditorStory = defineStory({ + name: "Should show contextual buttons when inside a table", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Click on button with aria-label "Table" + await userEvent.click(canvas.getByLabelText("Table")); + + // Get body + const body = within(document.body); + + // Wait until modal transition is finished + await waitUntil(() => document.body.querySelector("div[role='dialog']")); + await waitUntil(() => !document.body.querySelector(".modal-enter-active")); + + // Set table columns + const columnsInput = body.getByLabelText("Columns"); + await userEvent.type(columnsInput, "{selectall}{backspace}4"); + + // Set table rows + const rowsInput = body.getByLabelText("Rows"); + await userEvent.type(rowsInput, "{selectall}{backspace}2"); + + // Click on button with text "Insert table" + await userEvent.click(body.getByText("Insert table")); + + // Wait until modal is closed + await waitUntil(() => !document.body.querySelector("div[role='dialog']")); + + // Wait until args was triggered with new content + await waitUntil(() => args.updateModelValue?.mock?.calls?.length > 0); + + // Click inside a table cell + const tableCell = document.querySelector( + ".mt-text-editor__content-editor table td", + ) as HTMLElement; + await userEvent.click(tableCell); + + // Expect the contextual buttons to be rendered + expect(canvas.getByLabelText("Insert row before")).toBeDefined(); + expect(canvas.getByLabelText("Insert row after")).toBeDefined(); + expect(canvas.getByLabelText("Delete row")).toBeDefined(); + expect(canvas.getByLabelText("Insert column before")).toBeDefined(); + expect(canvas.getByLabelText("Insert column after")).toBeDefined(); + expect(canvas.getByLabelText("Delete column")).toBeDefined(); + expect(canvas.getByLabelText("Remove table")).toBeDefined(); + }, +}); + +export const VisualTestRenderCodeView: MtTextEditorStory = defineStory({ + name: "Should render the code view", + args: { + modelValue: "

Hello World

Some text

", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Click on button with aria-label "Toggle code" + await userEvent.click(canvas.getByLabelText("Toggle code")); + + // Expect the code view to be rendered + const codeEditor = canvas.getByRole("textbox"); + expect(codeEditor).toBeDefined(); + expect(codeEditor.innerText).toBe("

Hello World

Some text

"); + }, +}); diff --git a/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.mdx b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.mdx new file mode 100644 index 000000000..48e8d06c5 --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.mdx @@ -0,0 +1,445 @@ +import { Canvas, Meta, Markdown } from "@storybook/blocks"; + +import * as TextEditorStories from "./mt-text-editor.stories"; + + + +# Text Editor + +The `mt-text-editor` component is a powerful and flexible rich text editor built with [tiptap](https://tiptap.dev/). It is designed to handle a variety of use cases, such as adding rich text editing to your application. This component is highly customizable and can be extended with additional buttons, features, or configurations to suit your needs. + +## Features + +- **Rich Text Editing**: Includes common formatting options like bold, italic, underline, and more, with support for text alignment, bullet lists, ordered lists, and more. +- **Customizable Toolbar**: Add or exclude toolbar buttons as per your requirements. +- **Code Editor Mode**: Toggle between WYSIWYG editing and raw HTML editing with CodeMirror integration. +- **Character Count**: Displays a live character count at the bottom. +- **Inline Editing**: Option to enable inline editing with a floating toolbar. +- **Contextual Buttons**: Provides custom buttons for the footer of the editor that change contextually based on the current cursor position. + +--- + +## Usage + +To use the `mt-text-editor` component in your project, import it and provide the required props. + +### Minimal Example + +```html + + + +``` + + + +### Inline Editing + +```html + + + +``` + + + +### Custom Toolbar Buttons + +```html + + + +``` + + + +### Props + + + {` +| Prop Name | Type | Default | Description | +| --------------- | ------- | ------- | ----------------------------------------------------------------------- | +| modelValue | String | '' | The HTML content of the editor. Use 'v-model' to bind it to a variable. | +| isInlineEdit | Boolean | 'false' | Enables inline editing with a floating toolbar. | +| tipTapConfig | Object | {} | Custom configuration for the tiptap editor. | +| customButtons | Array | [] | Array of custom buttons to add to the toolbar. | +| excludedButtons | Array | [] | Array of button names to exclude from the toolbar. | +`} + + +--- + +## Slots + +The `mt-text-editor` component provides several slots for customization: + +### `button_` + +Allows you to override or customize specific buttons in the toolbar. For example, to customize the `text-color` button: + +```html + + + +``` + +### `contextual-buttons` + +Provides custom buttons for the footer of the editor. These buttons can change contextually based on the editor's state. + +### `footer-left` and `footer-right` + +Customize the left or right sections of the editor's footer. + +--- + +## Toolbar Buttons + +The `mt-text-editor` includes the following built-in buttons by default: + + + {` +| Button Name | Description | Alignment | Position | +| -------------- | -------------------------------------------------- | --------- | -------- | +| format | Opens a popover with formatting options. | 'left' | 1000 | +| text-color | Allows the user to pick a text color. | 'left' | 2000 | +| bold | Toggles bold text. | 'left' | 3000 | +| italic | Toggles italic text. | 'left' | 4000 | +| underline | Toggles underlined text. | 'left' | 5000 | +| strikethrough | Toggles strikethrough text. | 'left' | 6000 | +| superscript | Toggles superscript text. | 'left' | 7000 | +| subscript | Toggles subscript text. | 'left' | 8000 | +| text-alignment | Opens a popover to set text alignment. | 'left' | 9000 | +| unordered-list | Toggles an unordered list. | 'left' | 10000 | +| numbered-list | Toggles an numbered list. | 'left' | 11000 | +| link | Opens a modal to insert or edit links. | 'left' | 12000 | +| table | Opens a modal to insert or modify tables. | 'left' | 13000 | +| undo | Undoes the last action. | 'right' | 1000 | +| redo | Redoes the last undone action. | 'right' | 2000 | +| toggle-code | Toggles between WYSIWYG mode and raw HTML editing. | 'right' | 3000 | +`} + + +You can exclude or add custom buttons using the `excludedButtons` and `customButtons` props. + +--- + +## Customizing with TipTap Extensions + +The editor uses [tiptap extensions](https://tiptap.dev/guide/extensions) for its features. +You can include custom extensions by passing them through the `tipTapConfig` prop except the hardcoded properties `content`, `editorProps` and `onUpdate`. +For example: + +```html + + + +``` + +--- + +## Customizing with Custom Buttons + +The `mt-text-editor` supports adding custom buttons to the toolbar by passing an array of `CustomButton` objects to the `customButtons` prop. + +### Key Properties of `CustomButton` + +- **`name`** (required): A unique identifier for the button. +- **`label`** (required): The visible label for the button, can be the direct text or a translation key. +- **`icon`**: An optional icon to display instead of the label. You can use an icon name from the Meteor icon set. +- **`isActive`**: A function that determines whether the button is currently active (e.g., for toggling bold or italic formatting). +- **`action`**: A function that executes when the button is clicked. This is where you can apply an editor command. +- **`children`**: An array of child buttons to create a dropdown or multi-level menu. +- **`alignment`**: Specifies whether the button should appear on the left or right side of the toolbar. +- **`position`**: Defines the order of the button in the toolbar. Buttons with lower `position` values appear first. See the table with existing buttons to see their positions. +- **`disabled`**: A function that determines whether the button should be disabled. +- **`contextualButtons`**: A function that returns additional buttons to display in the footer based on the editor's state. + +--- + +### Example: Adding a Simple Custom Button + +Here’s how you can add a simple custom button to toggle bold formatting: + +```html + + + +``` + +### Example: Adding a Dropdown Menu + +You can create a dropdown menu by using the `children` property. Each child button is defined as another `CustomButton` object. + +```html + + + +``` + +--- + +### Example: Adding Contextual Buttons in the Footer + +Contextual buttons are buttons that appear in the editor's footer and can change depending on the editor's current state. Use the `contextualButtons` property to define these. + +```html + + + +``` + +--- + +### Example: Disabling a Button Based on Editor State + +You can disable a button dynamically by providing a `disabled` function. For example, disabling the "Bold" button if the editor is empty: + +```html + + + +``` + +--- + +### Example: Using a complete custom button with the dynamic slot + +For each button a dynamic slot is rendered: `button_${name}`. You can use this slot to replace the automatic rendered button defined in the button object with your own component: + +```html + + + +``` + +--- + +### Positioning Custom Buttons in the Toolbar + +By default, buttons are sorted in the toolbar based on their `position` value. Buttons with lower `position` values appear earlier. The default buttons have predefined positions in increments of `1000`, leaving space for you to insert custom buttons at specific positions. + +For example: + +- Default buttons (like `bold`, `italic`, etc.) have `position` values starting at `1000`. +- You can insert your custom buttons between or after them by specifying values like `1500`, `3500`, or `8500`. + +```js +const customButtons = [ + { + name: "custom-highlight", + label: "Highlight", + icon: "circle-xs", + position: 3500, // Places this button between "Bold" (3000) and "Italic" (4000) + action: (editor) => editor.chain().focus().toggleHighlight().run(), + }, +]; +``` + +--- + +## Code Editor Mode + +The `mt-text-editor` includes a code editor mode for editing raw HTML. This mode is powered by [CodeMirror](https://codemirror.net/). Use the `toggle-code` button to switch between WYSIWYG mode and code mode. diff --git a/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.stories.ts b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.stories.ts new file mode 100644 index 000000000..0d3cb6e6e --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.stories.ts @@ -0,0 +1,81 @@ +import type { StoryObj } from "@storybook/vue3"; +import MtTextEditor from "./mt-text-editor.vue"; +import type { SlottedMeta } from "@/_internal/story-helper"; +import MtTextEditorToolbarButtonColor from "./_internal/mt-text-editor-toolbar-button-color.vue"; +import { ref } from "vue"; +import Highlight from "@tiptap/extension-highlight"; +import { fn } from "@storybook/test"; + +export type MtTextEditorMeta = SlottedMeta< + typeof MtTextEditor, + "default" | "click" | "updateModelValue" +>; + +export default { + title: "Components/Form/mt-text-editor", + component: MtTextEditor, + args: { + modelValue: `

Hello World

Some text

  1. Lorem

  2. Ipsum

First

Second

Third

Lorem

Ipsum

non

dolor

sit

amet

After table

`, + updateModelValue: fn(), + label: "My Text editor", + }, + render: (args) => ({ + components: { MtTextEditor, MtTextEditorToolbarButtonColor }, + setup() { + const currentModelValue = ref(args.modelValue); + const onUpdateModelValue = (value: string) => { + currentModelValue.value = value; + args.updateModelValue(value); + }; + + return { + args, + currentModelValue, + onUpdateModelValue, + }; + }, + template: ` +
+ + + +
`, + }), +} as MtTextEditorMeta; + +export type MtTextEditorStory = StoryObj; + +export const DefaultStory: MtTextEditorStory = { + name: "mt-text-editor", +}; + +export const InlineEditStory: MtTextEditorStory = { + name: "mt-text-editor (inline edit)", + args: { + isInlineEdit: true, + }, +}; + +export const CustomButtonsStory: MtTextEditorStory = { + name: "mt-text-editor (custom buttons)", + args: { + modelValue: `

Hello World

In the toolbar you see now a new highlight button.

`, + tipTapConfig: { + extensions: [Highlight], + }, + customButtons: [ + { + name: "highlight", + label: "Highlight", + icon: "regular-circle-xs", + action: (editor) => { + editor.chain().focus().toggleMark("highlight").run(); + }, + }, + ], + }, +}; diff --git a/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.vue b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.vue new file mode 100644 index 000000000..c20582b40 --- /dev/null +++ b/packages/component-library/src/components/form/mt-text-editor/mt-text-editor.vue @@ -0,0 +1,690 @@ + + + + + diff --git a/packages/component-library/src/components/form/mt-text-field/mt-text-field.vue b/packages/component-library/src/components/form/mt-text-field/mt-text-field.vue index 861293e9d..733e474cd 100644 --- a/packages/component-library/src/components/form/mt-text-field/mt-text-field.vue +++ b/packages/component-library/src/components/form/mt-text-field/mt-text-field.vue @@ -8,7 +8,7 @@ :disable-inheritance-toggle="disableInheritanceToggle" :copyable="copyable" :copyable-tooltip="copyableTooltip" - :copyable-text="currentValue" + :copyable-text="String(currentValue)" :has-focus="hasFocus" :help-text="helpText" :name="name" @@ -33,6 +33,7 @@ :value="currentValue" :placeholder="placeholder" :maxlength="maxLength" + :aria-label="label" @input="onInput" @change.stop="onChange" @focus="setFocusClass" @@ -53,13 +54,13 @@