Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/form-field-hint-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopware-ag/meteor-component-library": minor
---

Add an optional `hint` prop to all form fields for rendering a caption below the field. The existing `#hint` slot still works and takes precedence. Hint content now renders with a consistent info-icon style, which also applies to existing `#hint` slot usage.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/vue";
import { describe, it, expect } from "vitest";
import MtFieldHint from "./mt-field-hint.vue";

describe("mt-field-hint", () => {
it("renders markup passed into the default slot", () => {
// ARRANGE
render(MtFieldHint, {
slots: {
default: '<span data-testid="custom-hint">Some <strong>hint</strong></span>',
},
});

// ASSERT
const slotContent = screen.getByTestId("custom-hint");
expect(slotContent).toBeVisible();
expect(slotContent.querySelector("strong")).toHaveTextContent("hint");
});

it("renders a decorative info icon that is hidden from assistive technology", () => {
// ARRANGE
const { container } = render(MtFieldHint, {
slots: {
default: "Some hint",
},
});

// ASSERT
// the icon is purely decorative, so it must be hidden from screen readers
const icon = container.querySelector(".mt-field-hint__icon");
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("aria-hidden", "true");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<span class="mt-field-hint">
<mt-icon
v-if="!hideIcon"
class="mt-field-hint__icon"
name="solid-info-circle"
size="var(--scale-size-12)"
color="var(--color-icon-secondary-default)"
decorative
/>
<span class="mt-field-hint__text">
<slot />
</span>
</span>
</template>

<script setup lang="ts">
import MtIcon from "@/components/mt-icon/mt-icon.vue";

withDefaults(
defineProps<{
/**
* Hides the default info icon. Used when the hint content is fully custom
* (e.g. provided via the `#hint` slot with its own icon).
*/
hideIcon?: boolean;
}>(),
{
hideIcon: false,
},
);
</script>

<style scoped>
.mt-field-hint {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--scale-size-4);
padding: var(--scale-size-3);
min-width: 0;
}

.mt-field-hint__icon {
flex-shrink: 0;
}

.mt-field-hint__text {
color: var(--color-text-secondary-default);
font-family: var(--font-family-body);
font-size: var(--font-size-xs, 14px);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-xs);
}
</style>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { within, userEvent, fireEvent } from "@storybook/test";
import { expect } from "@storybook/test";

import MtColorpicker from "./mt-colorpicker.vue";
import meta, { type MtColorpickerMeta, type MtColorpickerStory } from "./mt-colorpicker.stories";
import { waitUntil } from "@/_internal/test-helper";

Expand Down Expand Up @@ -598,3 +599,34 @@ export const VisualTestColorpickerWithHelpText: MtColorpickerStory = {
expect(canvas.getByRole("tooltip")).toBeInTheDocument();
},
};

export const VisualTestHintProp: MtColorpickerStory = {
name: "Should display hint via prop",
args: {
hint: "Hint via prop",
},
render: (args) => ({
components: { MtColorpicker },
setup: () => ({ args }),
template: `<mt-colorpicker :label="args.label" model-value="#0fcff5" :hint="args.hint" />`,
}),
play: ({ canvasElement }) => {
const canvas = within(canvasElement);

expect(canvas.getByText("Hint via prop")).toBeDefined();
},
};

export const VisualTestHintSlot: MtColorpickerStory = {
name: "Should display hint via slot",
render: (args) => ({
components: { MtColorpicker },
setup: () => ({ args }),
template: `<mt-colorpicker :label="args.label" model-value="#0fcff5"><template #hint>Hint via slot</template></mt-colorpicker>`,
}),
play: ({ canvasElement }) => {
const canvas = within(canvasElement);

expect(canvas.getByText("Hint via slot")).toBeDefined();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,45 @@ describe("mt-colorpicker", () => {
}),
);
});

it("displays a hint passed via the hint prop", () => {
// ARRANGE
render(MtColorpicker, {
props: {
modelValue: "#0fcff5",
hint: "Hint from prop",
},
});

// ASSERT
expect(screen.getByText("Hint from prop")).toBeVisible();
});

it("renders markup passed via the hint slot", () => {
// ARRANGE
render(MtColorpicker, {
props: {
modelValue: "#0fcff5",
},
slots: {
hint: '<span data-testid="custom-hint">Hint from slot</span>',
},
});

// ASSERT
expect(screen.getByTestId("custom-hint")).toBeVisible();
expect(screen.getByTestId("custom-hint")).toHaveTextContent("Hint from slot");
});

it("does not render a hint when neither prop nor slot is provided", () => {
// ARRANGE
const { container } = render(MtColorpicker, {
props: {
modelValue: "#0fcff5",
},
});

// ASSERT
expect(container.querySelector(".mt-field-hint")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@
<template #error>
<mt-field-error v-if="error" :error="error" />
</template>

<template #field-hint>
<mt-field-hint v-if="showFieldHint" :hide-icon="!!$slots.hint">
<slot name="hint">
{{ hint }}
</slot>
</mt-field-hint>
</template>
</mt-base-field>
</template>

Expand All @@ -244,6 +252,7 @@ import type { PropType } from "vue";
import { defineComponent } from "vue";
import { debounce } from "@/utils/debounce";
import MtBaseField from "../_internal/mt-base-field/mt-base-field.vue";
import MtFieldHint from "../_internal/mt-field-hint/mt-field-hint.vue";
import MtFloatingUi from "../mt-floating-ui/mt-floating-ui.vue";
import MtText from "@/components/mt-text/mt-text.vue";
import { createFocusTrap } from "focus-trap";
Expand All @@ -257,6 +266,7 @@ export default defineComponent({

components: {
"mt-base-field": MtBaseField,
"mt-field-hint": MtFieldHint,
"mt-text": MtText,
"mt-floating-ui": MtFloatingUi,
"mt-button": MtButton,
Expand Down Expand Up @@ -291,6 +301,15 @@ export default defineComponent({
default: null,
},

/**
* Optional caption below the field. The `#hint` slot takes precedence when provided.
*/
hint: {
type: String as PropType<string | null>,
required: false,
default: null,
},

/**
* Change the output value which gets emitted and shown in the field.
* @values auto, hex, hsl, rgb
Expand Down Expand Up @@ -494,6 +513,10 @@ export default defineComponent({
},
},

showFieldHint(): boolean {
return !!this.$slots.hint || (this.hint != null && String(this.hint).trim() !== "");
},

integerAlpha: {
get(): number {
return Math.floor(this.alphaValue * 100);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { within, userEvent, fn } from "@storybook/test";
import { expect } from "@storybook/test";

import MtEmailField from "./mt-email-field.vue";
import meta, { type MtEmailFieldMeta, type MtEmailFieldStory } from "./mt-email-field.stories";

export default {
Expand Down Expand Up @@ -33,18 +34,6 @@ export const VisualTestSuffix: MtEmailFieldStory = {
},
};

export const VisualTestHint: MtEmailFieldStory = {
name: "Should display hint",
args: {
hint: "hint",
},
play: ({ canvasElement, args }) => {
const canvas = within(canvasElement);

expect(canvas.getByText(args.hint)).toBeDefined();
},
};

export const VisualTestDisabled: MtEmailFieldStory = {
name: "Should disable",
args: {
Expand Down Expand Up @@ -143,3 +132,34 @@ export const VisualTestUnlinkedInheritance: MtEmailFieldStory = {
modelValue: "test@shopware.com",
},
};

export const VisualTestHintProp: MtEmailFieldStory = {
name: "Should display hint via prop",
args: {
hint: "Hint via prop",
},
render: (args) => ({
components: { MtEmailField },
setup: () => ({ args }),
template: `<mt-email-field :label="args.label" :hint="args.hint" />`,
}),
play: ({ canvasElement }) => {
const canvas = within(canvasElement);

expect(canvas.getByText("Hint via prop")).toBeDefined();
},
};

export const VisualTestHintSlot: MtEmailFieldStory = {
name: "Should display hint via slot",
render: (args) => ({
components: { MtEmailField },
setup: () => ({ args }),
template: `<mt-email-field :label="args.label"><template #hint>Hint via slot</template></mt-email-field>`,
}),
play: ({ canvasElement }) => {
const canvas = within(canvasElement);

expect(canvas.getByText("Hint via slot")).toBeDefined();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -568,4 +568,37 @@ describe("mt-email-field", () => {
screen.getByRole("textbox").getAttribute("aria-describedby"),
);
});

it("displays a hint passed via the hint prop", () => {
// ARRANGE
render(MtEmailField, {
props: {
hint: "Hint from prop",
},
});

// ASSERT
expect(screen.getByText("Hint from prop")).toBeVisible();
});

it("renders markup passed via the hint slot", () => {
// ARRANGE
render(MtEmailField, {
slots: {
hint: '<span data-testid="custom-hint">Hint from slot</span>',
},
});

// ASSERT
expect(screen.getByTestId("custom-hint")).toBeVisible();
expect(screen.getByTestId("custom-hint")).toHaveTextContent("Hint from slot");
});

it("does not render a hint when neither prop nor slot is provided", () => {
// ARRANGE
const { container } = render(MtEmailField);

// ASSERT
expect(container.querySelector(".mt-field-hint")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -101,17 +101,20 @@
:style="{ gridArea: 'error' }"
/>

<div v-if="$slots.hint" class="mt-email-field__hint" :style="{ gridArea: 'hint' }">
<slot name="hint" />
<div v-if="showFieldHint" class="mt-email-field__hint" :style="{ gridArea: 'hint' }">
<mt-field-hint :hide-icon="!!$slots.hint">
<slot name="hint">{{ hint }}</slot>
</mt-field-hint>
</div>
</div>
</template>

<script setup lang="ts">
import { defineProps, onMounted, ref, useTemplateRef, useId } from "vue";
import { computed, onMounted, ref, useTemplateRef, useId, useSlots } from "vue";
import MtFieldError from "../_internal/mt-field-error/mt-field-error.vue";
import MtFieldLabel from "../_internal/mt-field-label/mt-field-label.vue";
import MtHelpText from "../mt-help-text/mt-help-text.vue";
import MtFieldHint from "../_internal/mt-field-hint/mt-field-hint.vue";
import MtIcon from "../mt-icon/mt-icon.vue";
import MtTooltip from "@/components/mt-tooltip/mt-tooltip.vue";
import MtFieldAffix from "../_internal/mt-field-affix/mt-field-affix.vue";
Expand All @@ -125,7 +128,7 @@ const model = defineModel({
type: String,
});

defineProps<{
const props = defineProps<{
disabled?: boolean;
required?: boolean;
modelValue?: string;
Expand All @@ -141,8 +144,18 @@ defineProps<{
small?: boolean;
isInherited?: boolean;
isInheritanceField?: boolean;
/**
* Optional caption below the field. The `#hint` slot takes precedence when provided.
*/
hint?: string | null;
}>();

const slots = useSlots();

const showFieldHint = computed(
() => !!slots.hint || (props.hint != null && String(props.hint).trim() !== ""),
);

defineEmits(["change", "blur", "focus", "inheritance-restore", "inheritance-remove"]);

const id = useId();
Expand Down
Loading
Loading