Skip to content

Commit

Permalink
feat: expose internal inputs (#2439)
Browse files Browse the repository at this point in the history
Relates to #2308

Expose input elements of form elements, so that the native functions
like `focus` or `select` can be used.
  • Loading branch information
JoCa96 authored Jan 7, 2025
1 parent 51ed1d4 commit c7c3296
Show file tree
Hide file tree
Showing 14 changed files with 165 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/few-needles-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sit-onyx": minor
---

feat: Expose input elements of form elements
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup generic="TValue extends SelectOptionValue = SelectOptionValue">
import { computed } from "vue";
import { computed, useTemplateRef } from "vue";
import { useDensity } from "../../composables/density";
import { useRequired } from "../../composables/required";
import { useCustomValidity } from "../../composables/useCustomValidity";
Expand Down Expand Up @@ -45,6 +45,9 @@ const skeleton = useSkeletonContext(props);
const title = computed(() => {
return props.hideLabel ? props.label : undefined;
});
const input = useTemplateRef("input");
defineExpose({ input });
</script>

<template>
Expand All @@ -63,6 +66,7 @@ const title = computed(() => {
<OnyxLoadingIndicator v-if="props.loading" class="onyx-checkbox__loading" type="circle" />
<input
v-else
ref="input"
v-model="isChecked"
v-custom-validity
:aria-label="props.hideLabel ? props.label : undefined"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup generic="TValue extends SelectOptionValue">
import { computed } from "vue";
import { computed, useTemplateRef } from "vue";
import { useCheckAll } from "../../composables/checkAll";
import { useDensity } from "../../composables/density";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
Expand Down Expand Up @@ -56,6 +56,15 @@ const checkAllLabel = computed(() => {
if (typeof props.withCheckAll === "boolean") return defaultText;
return props.withCheckAll?.label ?? defaultText;
});
const checkboxes = useTemplateRef("checkboxes");
defineExpose({
inputs: computed<HTMLInputElement[]>(() => {
const array = Array.isArray(checkboxes.value) ? checkboxes.value : [checkboxes.value];
return array.filter(Boolean).flatMap((checkbox) => checkbox.input);
}),
});
</script>

<template>
Expand Down Expand Up @@ -90,6 +99,7 @@ const checkAllLabel = computed(() => {
v-for="option in props.options"
:key="option.value.toString()"
v-bind="option"
ref="checkboxes"
:truncation="option.truncation ?? props.truncation"
:model-value="props.modelValue.includes(option.value)"
class="onyx-checkbox-group__option"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useTemplateRef, type Component } from "vue";
const props = defineProps<{
/**
* Name of the component
*/
name: string;
/**
* Which component to wrap
*/
is: Component;
}>();
const formElement = useTemplateRef<{ input: { focus: () => void } }>("fromElement");
</script>

<template>
<!-- eslint-disable-next-line sitOnyx/require-root-class -->
<button type="button" @click="formElement?.input.focus()">
form-element-test-wrapper-focus-button-{{ props.name }}
</button>
<component
:is="props.is"
ref="formElement"
:label="`form-element-test-wrapper-label-${props.name}`"
:options="[]"
/>
</template>
42 changes: 42 additions & 0 deletions packages/sit-onyx/src/components/OnyxForm/OnyxForm.core.spec-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,49 @@
import { expectTypeOf, it } from "vitest";
import type { ComponentExposed, ComponentProps } from "vue-component-type-helpers";
import type { CustomMessageType } from "../../composables/useCustomValidity";
import type OnyxCheckbox from "../OnyxCheckbox/OnyxCheckbox.vue";
import type OnyxCheckboxGroup from "../OnyxCheckboxGroup/OnyxCheckboxGroup.vue";
import type OnyxInput from "../OnyxInput/OnyxInput.vue";
import type OnyxRadioButton from "../OnyxRadioButton/OnyxRadioButton.vue";
import type OnyxRadioGroup from "../OnyxRadioGroup/OnyxRadioGroup.vue";
import type OnyxSelect from "../OnyxSelect/OnyxSelect.vue";
import type OnyxStepper from "../OnyxStepper/OnyxStepper.vue";
import OnyxSwitch from "../OnyxSwitch/OnyxSwitch.vue";
import type OnyxTextarea from "../OnyxTextarea/OnyxTextarea.vue";
import { type __DONT_USE_VUE_FIX_KeyOfFormProps, type FormProps } from "./OnyxForm.core";

it("should be ensured that _KeyofFormProps includes all keys of FormProps", async () => {
expectTypeOf<keyof FormProps>().toMatchTypeOf<__DONT_USE_VUE_FIX_KeyOfFormProps>();
expectTypeOf<__DONT_USE_VUE_FIX_KeyOfFormProps>().toMatchTypeOf<keyof FormProps>();
});

type AllOnyxFormElements =
| typeof OnyxSelect
| typeof OnyxInput
| typeof OnyxTextarea
| typeof OnyxRadioButton
| typeof OnyxCheckbox
| typeof OnyxStepper
| typeof OnyxSwitch;

it("should be ensured that all onyx form elements support the basic input props", async () => {
expectTypeOf<ComponentProps<AllOnyxFormElements>>().toMatchTypeOf<{
modelValue?: unknown;
label: string;
customError?: CustomMessageType;
}>();
});

it("should be ensured that all onyx form elements expose the internal input", async () => {
expectTypeOf<ComponentExposed<AllOnyxFormElements>>().toMatchTypeOf<{
input: (HTMLInputElement | HTMLTextAreaElement) | null | undefined;
}>();
});

type AllOnyxFormGroups = typeof OnyxCheckboxGroup | typeof OnyxRadioGroup;

it("should be ensured that all onyx form element groups expose the internal inputs", async () => {
expectTypeOf<ComponentExposed<AllOnyxFormGroups>>().toMatchTypeOf<{
inputs: HTMLInputElement[];
}>();
});
28 changes: 28 additions & 0 deletions packages/sit-onyx/src/components/OnyxForm/OnyxForm.ct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import OnyxButton from "../OnyxButton/OnyxButton.vue";
import OnyxCheckbox from "../OnyxCheckbox/OnyxCheckbox.vue";
import OnyxCheckboxGroup from "../OnyxCheckboxGroup/OnyxCheckboxGroup.vue";
import OnyxInput from "../OnyxInput/OnyxInput.vue";
import OnyxRadioButton from "../OnyxRadioButton/OnyxRadioButton.vue";
import OnyxRadioGroup from "../OnyxRadioGroup/OnyxRadioGroup.vue";
import OnyxSelect from "../OnyxSelect/OnyxSelect.vue";
import type { SelectOption } from "../OnyxSelect/types";
import OnyxStepper from "../OnyxStepper/OnyxStepper.vue";
import OnyxSwitch from "../OnyxSwitch/OnyxSwitch.vue";
import OnyxTextarea from "../OnyxTextarea/OnyxTextarea.vue";
import FormElementTestWrapper from "./FormElementTestWrapper.vue";
import OnyxForm from "./OnyxForm.vue";

const inferProps = <TComp extends Component, TProps extends ComponentProps<TComp>>(
Expand Down Expand Up @@ -79,3 +81,29 @@ test("OnyxForm should inject disabled state", async ({ mount, page }) => {
// ASSERT
await expectForAll((element) => expect(element).toBeDisabled());
});

test("FormElementTestWrapper", async ({ mount, page }) => {
const allFormComponents = Object.entries({
OnyxInput,
OnyxStepper,
OnyxTextarea,
OnyxCheckbox,
OnyxRadioButton,
OnyxSwitch,
OnyxSelect,
});

// ARRANGE
const jsx = allFormComponents.map(([name, c]) => (
<FormElementTestWrapper name={name} is={c}></FormElementTestWrapper>
));

await mount(<div>{jsx}</div>);

for (const [name] of allFormComponents) {
await page
.getByRole("button", { name: `form-element-test-wrapper-focus-button-${name}` })
.click();
await expect(page.getByLabel(`form-element-test-wrapper-label-${name}`)).toBeFocused();
}
});
6 changes: 5 additions & 1 deletion packages/sit-onyx/src/components/OnyxInput/OnyxInput.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import checkSmall from "@sit-onyx/icons/check-small.svg?raw";
import xSmall from "@sit-onyx/icons/x-small.svg?raw";
import { computed } from "vue";
import { computed, useTemplateRef } from "vue";
import { useDensity } from "../../composables/density";
import { getFormMessages, useCustomValidity } from "../../composables/useCustomValidity";
import { useErrorClass } from "../../composables/useErrorClass";
Expand Down Expand Up @@ -55,6 +55,9 @@ const patternSource = computed(() => {
return props.pattern;
});
const input = useTemplateRef("input");
defineExpose({ input });
const { disabled, showError } = useFormContext(props);
const skeleton = useSkeletonContext(props);
const errorClass = useErrorClass(showError);
Expand All @@ -78,6 +81,7 @@ const errorClass = useErrorClass(showError);
<OnyxLoadingIndicator v-if="props.loading" class="onyx-input__loading" type="circle" />
<input
:id="inputId"
ref="input"
v-model="value"
v-custom-validity
:placeholder="props.placeholder"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts" setup generic="TValue extends SelectOptionValue = SelectOptionValue">
import { useTemplateRef } from "vue";
import { useDensity } from "../../composables/density";
import { useCustomValidity } from "../../composables/useCustomValidity";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
Expand Down Expand Up @@ -29,6 +30,9 @@ const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit });
const { densityClass } = useDensity(props);
const { disabled } = useFormContext(props);
const skeleton = useSkeletonContext(props);
const input = useTemplateRef("input");
defineExpose({ input });
</script>

<template>
Expand All @@ -43,6 +47,7 @@ const skeleton = useSkeletonContext(props);
<!-- TODO: accessible error: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-errormessage -->
<input
v-else
ref="input"
v-custom-validity
class="onyx-radio-button__selector"
type="radio"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup generic="TValue extends SelectOptionValue">
import { useId } from "vue";
import { computed, useId, useTemplateRef } from "vue";
import { useDensity } from "../../composables/density";
import { useRequired } from "../../composables/required";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
Expand Down Expand Up @@ -36,6 +36,15 @@ const handleChange = (selected: boolean, value: TValue) => {
if (!selected) return;
emit("update:modelValue", value);
};
const radiobuttons = useTemplateRef("radiobuttons");
defineExpose({
inputs: computed<HTMLInputElement[]>(() => {
const array = Array.isArray(radiobuttons.value) ? radiobuttons.value : [radiobuttons.value];
return array.filter(Boolean).flatMap((radiobutton) => radiobutton.input);
}),
});
</script>

<template>
Expand All @@ -61,6 +70,7 @@ const handleChange = (selected: boolean, value: TValue) => {
v-for="(option, index) in props.options"
:key="option.value.toString()"
v-bind="option"
ref="radiobuttons"
:name="props.name"
:custom-error="props.customError"
:checked="option.value === props.modelValue"
Expand Down
11 changes: 6 additions & 5 deletions packages/sit-onyx/src/components/OnyxSelect/OnyxSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import {
toRef,
toRefs,
useId,
useTemplateRef,
watch,
watchEffect,
type ComponentInstance,
} from "vue";
import type { ComponentExposed } from "vue-component-type-helpers";
import { useCheckAll } from "../../composables/checkAll";
import { useDensity } from "../../composables/density";
import { useScrollEnd } from "../../composables/scrollEnd";
Expand Down Expand Up @@ -134,8 +133,8 @@ const selectionLabels = computed(() => {
}, []);
});
const miniSearch = ref<ComponentInstance<typeof OnyxMiniSearch>>();
const selectInput = ref<ComponentExposed<typeof OnyxSelectInput>>();
const miniSearch = useTemplateRef("miniSearch");
const selectInput = useTemplateRef("selectInput");
const filteredOptions = computed(() => {
// if onyx does not manage the search, we don't filter the options further
Expand Down Expand Up @@ -190,7 +189,7 @@ const onToggle = async (preventFocus?: boolean) => {
if (wasOpen !== open.value) {
if (wasOpen) {
if (searchTerm.value) searchTerm.value = "";
if (!preventFocus) selectInput.value?.focus();
if (!preventFocus) selectInput.value?.input?.focus();
} else {
// make sure search is focused after the flyout opened
miniSearch.value?.focus();
Expand Down Expand Up @@ -328,6 +327,8 @@ const selectInputProps = computed(() => {
if (props.withSearch) return { ...baseProps, onKeydown: input.value.onKeydown };
return { ...baseProps, ...input.value };
});
defineExpose({ input: computed(() => selectInput.value?.input) });
</script>

<template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { CLOSING_KEYS, OPENING_KEYS } from "@sit-onyx/headless";
import checkSmall from "@sit-onyx/icons/check-small.svg?raw";
import chevronDownUp from "@sit-onyx/icons/chevron-down-up.svg?raw";
import { computed, ref, watch } from "vue";
import { computed, ref, useTemplateRef, watch } from "vue";
import { useDensity } from "../../composables/density";
import { getFormMessages, useCustomValidity } from "../../composables/useCustomValidity";
import { useErrorClass } from "../../composables/useErrorClass";
Expand Down Expand Up @@ -83,9 +83,8 @@ const wasTouched = ref(false);
const { densityClass } = useDensity(props);
const input = ref<HTMLInputElement>();
defineExpose({ focus: () => input.value?.focus() });
const input = useTemplateRef("input");
defineExpose({ input });
/**
* As the native input has to be readonly, the :user-invalid will never appear.
Expand Down
6 changes: 5 additions & 1 deletion packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import minus from "@sit-onyx/icons/minus.svg?raw";
import plus from "@sit-onyx/icons/plus.svg?raw";
import { computed, ref, watchEffect } from "vue";
import { computed, ref, useTemplateRef, watchEffect } from "vue";
import { useDensity } from "../../composables/density";
import { getFormMessages, useCustomValidity } from "../../composables/useCustomValidity";
import { useErrorClass } from "../../composables/useErrorClass";
Expand Down Expand Up @@ -32,6 +32,7 @@ const emit = defineEmits<{
}>();
const { t } = injectI18n();
const input = useTemplateRef("input");
const { disabled, showError } = useFormContext(props);
const skeleton = useSkeletonContext(props);
Expand Down Expand Up @@ -89,6 +90,8 @@ const handleChange = () => {
const incrementLabel = computed(() => t.value("stepper.increment", { stepSize: props.stepSize }));
const decrementLabel = computed(() => t.value("stepper.decrement", { stepSize: props.stepSize }));
defineExpose({ input });
</script>

<template>
Expand Down Expand Up @@ -122,6 +125,7 @@ const decrementLabel = computed(() => t.value("stepper.decrement", { stepSize: p
<OnyxLoadingIndicator v-if="props.loading" class="onyx-stepper__loading" type="circle" />
<input
v-else
ref="input"
v-model="inputValue"
v-custom-validity
class="onyx-stepper__native"
Expand Down
6 changes: 5 additions & 1 deletion packages/sit-onyx/src/components/OnyxSwitch/OnyxSwitch.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import checkSmall from "@sit-onyx/icons/check-small.svg?raw";
import xSmall from "@sit-onyx/icons/x-small.svg?raw";
import { computed } from "vue";
import { computed, useTemplateRef } from "vue";
import { useDensity } from "../../composables/density";
import { useRequired } from "../../composables/required";
import { useCustomValidity } from "../../composables/useCustomValidity";
Expand Down Expand Up @@ -48,6 +48,9 @@ const isChecked = computed({
emit("update:modelValue", value);
},
});
const input = useTemplateRef("input");
defineExpose({ input });
</script>

<template>
Expand All @@ -65,6 +68,7 @@ const isChecked = computed({
:title="title"
>
<input
ref="input"
v-model="isChecked"
v-custom-validity
type="checkbox"
Expand Down
Loading

0 comments on commit c7c3296

Please sign in to comment.