diff --git a/libs/components/src/lib/time-picker/time-picker.spec.ts b/libs/components/src/lib/time-picker/time-picker.spec.ts
index 14e7e88871..7b1c4e2f80 100644
--- a/libs/components/src/lib/time-picker/time-picker.spec.ts
+++ b/libs/components/src/lib/time-picker/time-picker.spec.ts
@@ -196,6 +196,23 @@ describe('vwc-time-picker', () => {
});
});
+ describe('helper-text slot', () => {
+ it('should forward helper-text slot to the text field', async () => {
+ const slotted = document.createElement('div');
+ slotted.slot = 'helper-text';
+ slotted.innerHTML = 'content';
+ element.appendChild(slotted);
+ await elementUpdated(element);
+
+ const textFieldSlot = textField.shadowRoot?.querySelector(
+ 'slot[name=helper-text]'
+ ) as HTMLSlotElement;
+ const timePickerSlot =
+ textFieldSlot.assignedNodes()[0] as HTMLSlotElement;
+ expect(timePickerSlot.assignedNodes()).toEqual([slotted]);
+ });
+ });
+
describe('clock', () => {
afterEach(() => {
setLocale(enUS);
diff --git a/libs/components/src/lib/time-picker/time-picker.template.ts b/libs/components/src/lib/time-picker/time-picker.template.ts
index c0a3667866..9a47e395e2 100644
--- a/libs/components/src/lib/time-picker/time-picker.template.ts
+++ b/libs/components/src/lib/time-picker/time-picker.template.ts
@@ -1,5 +1,5 @@
import type { ViewTemplate } from '@microsoft/fast-element';
-import { html, ref, repeat, when } from '@microsoft/fast-element';
+import { html, ref, repeat, slotted, when } from '@microsoft/fast-element';
import type {
ElementDefinitionContext,
FoundationElementDefinition,
@@ -88,6 +88,12 @@ export const TimePickerTemplate: (
@input="${(x, c) => x._onTextFieldInput(c.event)}"
@change="${(x) => x._onTextFieldChange()}"
>
+
<${buttonTag}
id="clock-button"
${ref('_clockButtonEl')}
diff --git a/libs/components/src/lib/time-picker/time-picker.ts b/libs/components/src/lib/time-picker/time-picker.ts
index e653f08d6c..4d31a3a0fc 100644
--- a/libs/components/src/lib/time-picker/time-picker.ts
+++ b/libs/components/src/lib/time-picker/time-picker.ts
@@ -5,7 +5,6 @@ import {
observable,
type ValueConverter,
} from '@microsoft/fast-element';
-import { applyMixins } from '@microsoft/fast-foundation';
import {
type ErrorText,
errorText,
@@ -17,6 +16,7 @@ import {
} from '../../shared/patterns';
import type { TextField } from '../text-field/text-field';
import type { Button } from '../button/button';
+import { applyMixinsWithObservables } from '../../shared/utils/applyMixinsWithObservables';
import { FormAssociatedTimePicker } from './time-picker.form-associated';
import {
formatPresentationTime,
@@ -52,6 +52,7 @@ const ValidTimeFilter: ValueConverter = {
/**
* @public
* @component time-picker
+ * @slot helper-text - Describes how to use the time-picker. Alternative to the `helper-text` attribute.
* @event change - Emitted when the time is changed by the user.
* @vueModel modelValue current-value input `(event.target as HTMLInputElement).value`
*/
@@ -674,4 +675,9 @@ export interface TimePicker
FormElementHelperText,
Localized,
TrappedFocus {}
-applyMixins(TimePicker, Localized, FormElementHelperText, TrappedFocus);
+applyMixinsWithObservables(
+ TimePicker,
+ Localized,
+ FormElementHelperText,
+ TrappedFocus
+);
diff --git a/libs/components/src/shared/date-picker/date-picker-base.spec.ts b/libs/components/src/shared/date-picker/date-picker-base.spec.ts
index 43aee81c31..0a30fe93d4 100644
--- a/libs/components/src/shared/date-picker/date-picker-base.spec.ts
+++ b/libs/components/src/shared/date-picker/date-picker-base.spec.ts
@@ -291,6 +291,23 @@ describe.each([['vwc-date-picker'], ['vwc-date-range-picker']])(
});
});
+ describe('helper-text slot', () => {
+ it('should forward helper-text slot to the text field', async () => {
+ const slotted = document.createElement('div');
+ slotted.slot = 'helper-text';
+ slotted.innerHTML = 'content';
+ element.appendChild(slotted);
+ await elementUpdated(element);
+
+ const textFieldSlot = textField.shadowRoot?.querySelector(
+ 'slot[name=helper-text]'
+ ) as HTMLSlotElement;
+ const timePickerSlot =
+ textFieldSlot.assignedNodes()[0] as HTMLSlotElement;
+ expect(timePickerSlot.assignedNodes()).toEqual([slotted]);
+ });
+ });
+
describe('calendar button', () => {
it('should open the popup when pressed', async () => {
calendarButton.click();
diff --git a/libs/components/src/shared/date-picker/date-picker-base.template.ts b/libs/components/src/shared/date-picker/date-picker-base.template.ts
index 60904274cd..b20ad92dd7 100644
--- a/libs/components/src/shared/date-picker/date-picker-base.template.ts
+++ b/libs/components/src/shared/date-picker/date-picker-base.template.ts
@@ -1,5 +1,5 @@
import type { ViewTemplate } from '@microsoft/fast-element';
-import { html, ref, repeat, when } from '@microsoft/fast-element';
+import { html, ref, repeat, slotted, when } from '@microsoft/fast-element';
import type {
ElementDefinitionContext,
FoundationElementDefinition,
@@ -335,6 +335,12 @@ export const DatePickerBaseTemplate: (
@input="${(x, c) => x._onTextFieldInput(c.event)}"
@change="${(x) => x._onTextFieldChange()}"
>
+
<${buttonTag}
id="calendar-button"
${ref('_calendarButtonEl')}
diff --git a/libs/components/src/shared/date-picker/date-picker-base.ts b/libs/components/src/shared/date-picker/date-picker-base.ts
index ba335c64f8..273f2cb782 100644
--- a/libs/components/src/shared/date-picker/date-picker-base.ts
+++ b/libs/components/src/shared/date-picker/date-picker-base.ts
@@ -1,4 +1,3 @@
-import { applyMixins } from '@microsoft/fast-foundation';
import {
attr,
DOM,
@@ -9,6 +8,7 @@ import {
import type { TextField } from '../../lib/text-field/text-field';
import type { Button } from '../../lib/button/button';
import { FormElementHelperText, Localized, TrappedFocus } from '../patterns';
+import { applyMixinsWithObservables } from '../utils/applyMixinsWithObservables';
import {
addDays,
compareDateStr,
@@ -818,4 +818,9 @@ export interface DatePickerBase
extends Localized,
FormElementHelperText,
TrappedFocus {}
-applyMixins(DatePickerBase, Localized, FormElementHelperText, TrappedFocus);
+applyMixinsWithObservables(
+ DatePickerBase,
+ Localized,
+ FormElementHelperText,
+ TrappedFocus
+);
diff --git a/libs/components/src/shared/patterns/form-elements/form-elements.spec.ts b/libs/components/src/shared/patterns/form-elements/form-elements.spec.ts
index 7431df1cf0..7b0195f39f 100644
--- a/libs/components/src/shared/patterns/form-elements/form-elements.spec.ts
+++ b/libs/components/src/shared/patterns/form-elements/form-elements.spec.ts
@@ -1,14 +1,19 @@
import 'element-internals-polyfill';
-import { fixture } from '@vivid-nx/shared';
+import { elementUpdated, fixture } from '@vivid-nx/shared';
import { customElement, FASTElement } from '@microsoft/fast-element';
-import { FormAssociated } from '@microsoft/fast-foundation';
+import { FormAssociated, FoundationElement } from '@microsoft/fast-foundation';
+import { registerFactory } from '@vonage/vivid';
+import { applyMixinsWithObservables } from '../../utils/applyMixinsWithObservables.ts';
import {
ErrorText,
errorText,
FormElement,
FormElementCharCount,
+ FormElementHelperText,
formElements,
+ FormElementSuccessText,
+ getFeedbackTemplate,
} from './form-elements';
const VALIDATION_MESSAGE = 'Validation Message';
@@ -310,3 +315,113 @@ describe('Form Elements', function () {
});
});
});
+
+describe('getFeedbackTemplate', () => {
+ @errorText
+ @formElements
+ class Feedback extends FormAssociated(FoundationElement) {
+ proxy = document.createElement('input');
+ }
+ interface Feedback
+ extends FormElementHelperText,
+ FormElementSuccessText,
+ FormElement,
+ ErrorText,
+ FormAssociated {}
+ applyMixinsWithObservables(
+ Feedback,
+ FormElementHelperText,
+ FormElementSuccessText
+ );
+
+ const feedbackDef = Feedback.compose({
+ baseName: 'feedback',
+ template: getFeedbackTemplate,
+ });
+ registerFactory([feedbackDef()])('test');
+
+ let element: Feedback;
+ beforeEach(async () => {
+ element = (await fixture('
')) as Feedback;
+ });
+
+ const getMessage = (type: string) => {
+ const messageEl = element.shadowRoot!.querySelector(
+ `.${type}-message.message--visible`
+ );
+ if (!messageEl) {
+ return null;
+ }
+ const slot = messageEl.querySelector('slot') as HTMLSlotElement | null;
+ if (slot && slot.assignedNodes().length > 0) {
+ return slot.assignedNodes()[0].textContent!.trim();
+ } else {
+ return messageEl.textContent!.trim();
+ }
+ };
+
+ describe('helper text', () => {
+ it('should show helper text when property is set', async () => {
+ element.helperText = 'helper text';
+ await elementUpdated(element);
+
+ expect(getMessage('helper')).toBe('helper text');
+ });
+
+ it('should allow setting helper text via slot', async () => {
+ const helperText = document.createElement('span');
+ helperText.slot = 'helper-text';
+ helperText.textContent = 'helper text';
+ element.appendChild(helperText);
+ await elementUpdated(element);
+
+ expect(getMessage('helper')).toBe('helper text');
+ });
+ });
+
+ describe('error text', () => {
+ it('should show validation error when the field is invalid', async () => {
+ element.dirtyValue = true;
+ element.dispatchEvent(new Event('blur'));
+ element.proxy.setCustomValidity('error text');
+ element.validate();
+ await elementUpdated(element);
+
+ expect(getMessage('error')).toBe('error text');
+ });
+
+ it('should show error text when property is set', async () => {
+ element.errorText = 'error text';
+ await elementUpdated(element);
+
+ expect(getMessage('error')).toBe('error text');
+ });
+
+ it('should hide helper text when set', async () => {
+ element.helperText = 'helper text';
+ element.errorText = 'error text';
+ await elementUpdated(element);
+
+ expect(getMessage('helper')).toBe(null);
+ });
+ });
+
+ describe('success text', () => {
+ it('should show success text when set', async () => {
+ element.successText = 'success text';
+ await elementUpdated(element);
+
+ expect(getMessage('success')).toBe('success text');
+ });
+
+ it('should hide error and helper text when set', async () => {
+ element.helperText = 'helper text';
+ element.errorText = 'error text';
+ element.successText = 'success text';
+ await elementUpdated(element);
+
+ expect(getMessage('helper')).toBe(null);
+ expect(getMessage('error')).toBe(null);
+ });
+ });
+});
diff --git a/libs/components/src/shared/patterns/form-elements/form-elements.ts b/libs/components/src/shared/patterns/form-elements/form-elements.ts
index a56048fc70..f1ab6a6645 100644
--- a/libs/components/src/shared/patterns/form-elements/form-elements.ts
+++ b/libs/components/src/shared/patterns/form-elements/form-elements.ts
@@ -1,5 +1,6 @@
-import { attr, html, observable, when } from '@microsoft/fast-element';
+import { attr, html, observable, slotted, when } from '@microsoft/fast-element';
import type { ElementDefinitionContext } from '@microsoft/fast-foundation';
+import { classNames } from '@microsoft/fast-web-utilities';
import { Icon } from '../../../lib/icon/icon';
import messageStyles from './message.scss?inline';
@@ -12,6 +13,7 @@ export interface FormElement {
export interface FormElementHelperText {
helperText?: string;
+ _helperTextSlottedContent?: HTMLElement[];
}
export interface FormElementSuccessText {
@@ -28,6 +30,11 @@ export interface ErrorText {
export class FormElementHelperText {
@attr({ attribute: 'helper-text' }) helperText?: string;
+
+ /**
+ * @internal
+ */
+ @observable _helperTextSlottedContent?: HTMLElement[];
}
export class FormElementSuccessText {
@@ -157,69 +164,102 @@ export function formElements<
return Decorated;
}
-type FeedbackType = 'error' | 'helper' | 'success';
-type MessagePropertyType =
- | 'errorValidationMessage'
- | 'helperText'
- | 'successText';
-type MessageTypeMap = {
- [key in FeedbackType]: {
- iconType: string;
- className: string;
- messageProperty: MessagePropertyType;
+type SomeFormElement = Partial<
+ FormElement & FormElementHelperText & FormElementSuccessText & ErrorText
+>;
+
+type FeedbackConfig = {
+ iconType?: string;
+ className: string;
+ messageProperty: 'errorValidationMessage' | 'helperText' | 'successText';
+ slot?: {
+ name: string;
+ slottedContentProperty: '_helperTextSlottedContent';
};
};
-
-/**
- * @param context - element definition context
- */
-export function getFeedbackTemplate(
- messageType: FeedbackType,
- context: ElementDefinitionContext
-) {
- const MessageTypeMap: MessageTypeMap = {
- helper: {
- messageProperty: 'helperText',
- className: 'helper',
- iconType: '',
+const feedback: Record
= {
+ helper: {
+ messageProperty: 'helperText',
+ className: 'helper',
+ slot: {
+ name: 'helper-text',
+ slottedContentProperty: '_helperTextSlottedContent',
},
- error: {
- messageProperty: 'errorValidationMessage',
- className: 'error',
- iconType: 'info-line',
- },
- success: {
- messageProperty: 'successText',
- className: 'success',
- iconType: 'check-circle-line',
- },
- };
- const iconTag = context.tagFor(Icon);
- const messageTypeConfig = MessageTypeMap[messageType];
- const iconType = messageTypeConfig.iconType;
- return html`
-
- ${when(
- () => iconType,
- html`
- <${iconTag} class="message-icon" name="${iconType}">${iconTag}>`
- )}
- ${feedbackMessage({
- messageProperty: MessageTypeMap[messageType].messageProperty,
- })}
-
`;
+ ${getFeedbackTypeTemplate(
+ context,
+ feedback.helper,
+ (x) =>
+ isFeedbackAvailable(feedback.helper, x) &&
+ !isFeedbackAvailable(feedback.error, x) &&
+ !isFeedbackAvailable(feedback.success, x)
+ )}
+ ${getFeedbackTypeTemplate(
+ context,
+ feedback.error,
+ (x) =>
+ isFeedbackAvailable(feedback.error, x) &&
+ !isFeedbackAvailable(feedback.success, x)
+ )}
+ ${getFeedbackTypeTemplate(context, feedback.success, (x) =>
+ isFeedbackAvailable(feedback.success, x)
+ )}
+ `;
}
-function feedbackMessage({
- messageProperty,
-}: {
- messageProperty: MessagePropertyType;
-}) {
- return html`
- ${(x) => x[messageProperty]}
- `;
+function getFeedbackTypeTemplate(
+ context: ElementDefinitionContext,
+ config: FeedbackConfig,
+ shouldShow: (x: SomeFormElement) => boolean
+) {
+ const iconTag = context.tagFor(Icon);
+
+ const messageTemplate = html`${(x) =>
+ x[config.messageProperty]}`;
+ const innerTemplate = config.slot
+ ? html`${messageTemplate}`
+ : messageTemplate;
+
+ return html`
+ ${when(
+ (x) => shouldShow(x) && config.iconType,
+ html`<${iconTag} class="message-icon" name="${config.iconType!}">${iconTag}>`
+ )}
+ ${innerTemplate}
+
`;
}
export function errorText<
diff --git a/libs/components/src/shared/patterns/form-elements/message.scss b/libs/components/src/shared/patterns/form-elements/message.scss
index 8cddf78f93..3003991f21 100644
--- a/libs/components/src/shared/patterns/form-elements/message.scss
+++ b/libs/components/src/shared/patterns/form-elements/message.scss
@@ -3,12 +3,16 @@
$low-ink-color: --_low-ink-color;
.message {
- display: flex;
+ display: none;
contain: inline-size;
font: var(#{constants.$vvd-typography-base-condensed});
gap: 4px;
grid-column: 1 / -1;
+ &--visible {
+ display: flex;
+ }
+
&-text {
color: var(#{constants.$vvd-color-canvas-text});
diff --git a/libs/components/src/shared/utils/applyMixinsWithObservables.ts b/libs/components/src/shared/utils/applyMixinsWithObservables.ts
new file mode 100644
index 0000000000..1a52c79e5f
--- /dev/null
+++ b/libs/components/src/shared/utils/applyMixinsWithObservables.ts
@@ -0,0 +1,18 @@
+import { Observable } from '@microsoft/fast-element';
+import { applyMixins } from '@microsoft/fast-foundation';
+
+/**
+ * Extends applyMixins to also apply observables from base classes to the derived class.
+ */
+export function applyMixinsWithObservables(
+ derivedCtor: any,
+ ...baseCtors: any[]
+) {
+ applyMixins(derivedCtor, ...baseCtors);
+
+ baseCtors.forEach((baseCtor) => {
+ Observable.getAccessors(baseCtor.prototype).forEach((accessor) => {
+ Observable.defineProperty(derivedCtor.prototype, accessor.name);
+ });
+ });
+}