Skip to content

Commit

Permalink
feat: implement fallthrough attribute forwarding (#2566)
Browse files Browse the repository at this point in the history
Relates to #2308 

- update `useRootAttrs` docs and make generic
- implement `useRootAttrs` for relevant components
- add vitepress docs for `useRootAttrs`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: JoCa96 <[email protected]>
Co-authored-by: Christian Bußhoff <[email protected]>
  • Loading branch information
4 people authored Jan 24, 2025
1 parent bb83bf7 commit 08c0057
Show file tree
Hide file tree
Showing 14 changed files with 136 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/sour-shrimps-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sit-onyx": minor
---

feat: implement fallthrough attribute forwarding
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.
1 change: 1 addition & 0 deletions apps/docs/src/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export const CONFIG = {
{ text: "Stories", link: "/stories" },
{ text: "Styling", link: "/styling" },
{ text: "Testing", link: "/testing" },
{ text: "Patterns", link: "/patterns" },
],
},
{
Expand Down
16 changes: 16 additions & 0 deletions apps/docs/src/principles/contributing/patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Patterns

This page explains which common patterns we follow when developing onyx and how to use them.
These patterns are implemented through [**composables**](https://vuejs.org/guide/reusability/composables.html) and enforced through [**linting rules**](https://eslint.org/docs/latest/extend/custom-rules), where possible.

## Root Attribute Forwarding

For implementing necessary layout, styling and ARIA requirements, it is often necessary to wrap interactive HTML elements.
To enable the developers to be able to set custom attributes and event-listeners on these, we forward most attributes to the relevant (e.g. input or button) element.
The only attributes that are not forwarded are `style` and `class` with the assumption being, that these are only useful and intended to be set on the root element of the component.

<<< ../../../../../packages/sit-onyx/src/utils/attrs.ts#docs

::: info
Your use-case is not covered? Head over to our GitHub [discussion page](https://github.com/SchwarzIT/onyx/discussions) to make suggestions or ask questions!
:::
13 changes: 11 additions & 2 deletions packages/sit-onyx/src/components/OnyxCheckbox/OnyxCheckbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useAutofocus } from "../../composables/useAutoFocus";
import { useCustomValidity } from "../../composables/useCustomValidity";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import type { SelectOptionValue } from "../../types";
import { useRootAttrs } from "../../utils/attrs";
import OnyxErrorTooltip from "../OnyxErrorTooltip/OnyxErrorTooltip.vue";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.vue";
Expand All @@ -31,6 +32,9 @@ const emit = defineEmits<{
validityChange: [validity: ValidityState];
}>();
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
const isChecked = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
Expand All @@ -53,12 +57,16 @@ useAutofocus(input, props);
</script>

<template>
<div v-if="skeleton" :class="['onyx-component', 'onyx-checkbox-skeleton', densityClass]">
<div
v-if="skeleton"
:class="['onyx-component', 'onyx-checkbox-skeleton', densityClass]"
v-bind="rootAttrs"
>
<OnyxSkeleton class="onyx-checkbox-skeleton__input" />
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-checkbox-skeleton__label" />
</div>

<OnyxErrorTooltip v-else :disabled="disabled" :error-messages="errorMessages">
<OnyxErrorTooltip v-else :disabled="disabled" :error-messages="errorMessages" v-bind="rootAttrs">
<label
class="onyx-component onyx-checkbox"
:class="[requiredTypeClass, densityClass]"
Expand All @@ -79,6 +87,7 @@ useAutofocus(input, props);
:required="props.required"
:value="props.value"
:autofocus="props.autofocus"
v-bind="restAttrs"
/>
</div>

Expand Down
16 changes: 14 additions & 2 deletions packages/sit-onyx/src/components/OnyxDatePicker/OnyxDatePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAutofocus } from "../../composables/useAutoFocus";
import { getFormMessages, useCustomValidity } from "../../composables/useCustomValidity";
import { useErrorClass } from "../../composables/useErrorClass";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import { useRootAttrs } from "../../utils/attrs";
import { isValidDate } from "../../utils/date";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxFormElement from "../OnyxFormElement/OnyxFormElement.vue";
Expand Down Expand Up @@ -33,6 +34,8 @@ const emit = defineEmits<{
validityChange: [validity: ValidityState];
}>();
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit });
const successMessages = computed(() => getFormMessages(props.success));
const messages = computed(() => getFormMessages(props.message));
Expand Down Expand Up @@ -79,12 +82,20 @@ useAutofocus(input, props);
</script>

<template>
<div v-if="skeleton" :class="['onyx-component', 'onyx-datepicker-skeleton', densityClass]">
<div
v-if="skeleton"
:class="['onyx-component', 'onyx-datepicker-skeleton', densityClass]"
v-bind="rootAttrs"
>
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-datepicker-skeleton__label" />
<OnyxSkeleton class="onyx-datepicker-skeleton__input" />
</div>

<div v-else :class="['onyx-component', 'onyx-datepicker', densityClass, errorClass]">
<div
v-else
:class="['onyx-component', 'onyx-datepicker', densityClass, errorClass]"
v-bind="rootAttrs"
>
<OnyxFormElement
v-bind="props"
:error-messages="errorMessages"
Expand Down Expand Up @@ -117,6 +128,7 @@ useAutofocus(input, props);
:title="props.hideLabel ? props.label : undefined"
:min="getNormalizedDate(props.min)"
:max="getNormalizedDate(props.max)"
v-bind="restAttrs"
/>
</div>
</template>
Expand Down
17 changes: 15 additions & 2 deletions packages/sit-onyx/src/components/OnyxInput/OnyxInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useErrorClass } from "../../composables/useErrorClass";
import { useLenientMaxLengthValidation } from "../../composables/useLenientMaxLengthValidation";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import { injectI18n } from "../../i18n";
import { useRootAttrs } from "../../utils/attrs";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxFormElement from "../OnyxFormElement/OnyxFormElement.vue";
import OnyxIcon from "../OnyxIcon/OnyxIcon.vue";
Expand Down Expand Up @@ -54,6 +55,9 @@ const slots = defineSlots<{
*/
const modelValue = defineModel<string>({ default: "" });
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
const { t } = injectI18n();
const { maxLength, maxLengthError } = useLenientMaxLengthValidation({ modelValue, props });
const customError = computed(() => props.customError ?? maxLengthError.value);
Expand All @@ -78,12 +82,20 @@ useAutofocus(input, props);
</script>

<template>
<div v-if="skeleton" :class="['onyx-component', 'onyx-input-skeleton', densityClass]">
<div
v-if="skeleton"
:class="['onyx-component', 'onyx-input-skeleton', densityClass]"
v-bind="rootAttrs"
>
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-input-skeleton__label" />
<OnyxSkeleton class="onyx-input-skeleton__input" />
</div>

<div v-else :class="['onyx-component', 'onyx-input', densityClass, errorClass]">
<div
v-else
:class="['onyx-component', 'onyx-input', densityClass, errorClass]"
v-bind="rootAttrs"
>
<OnyxFormElement
v-bind="props"
:error-messages="errorMessages"
Expand Down Expand Up @@ -115,6 +127,7 @@ useAutofocus(input, props);
:minlength="props.minlength"
:aria-label="props.hideLabel ? props.label : undefined"
:title="props.hideLabel ? props.label : undefined"
v-bind="restAttrs"
/>

<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAutofocus } from "../../composables/useAutoFocus";
import { useCustomValidity } from "../../composables/useCustomValidity";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import type { SelectOptionValue } from "../../types";
import { useRootAttrs } from "../../utils/attrs";
import OnyxErrorTooltip from "../OnyxErrorTooltip/OnyxErrorTooltip.vue";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.vue";
Expand All @@ -27,6 +28,9 @@ const emit = defineEmits<{
validityChange: [validity: ValidityState];
}>();
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit });
const { densityClass } = useDensity(props);
const { disabled } = useFormContext(props);
Expand All @@ -38,12 +42,16 @@ useAutofocus(input, props);
</script>

<template>
<div v-if="skeleton" :class="['onyx-component', 'onyx-radio-button-skeleton', densityClass]">
<div
v-if="skeleton"
:class="['onyx-component', 'onyx-radio-button-skeleton', densityClass]"
v-bind="rootAttrs"
>
<OnyxSkeleton class="onyx-radio-button-skeleton__input" />
<OnyxSkeleton class="onyx-radio-button-skeleton__label" />
</div>

<OnyxErrorTooltip v-else :disabled="disabled" :error-messages="errorMessages">
<OnyxErrorTooltip v-else :disabled="disabled" :error-messages="errorMessages" v-bind="rootAttrs">
<label :class="['onyx-component', 'onyx-radio-button', densityClass]">
<OnyxLoadingIndicator v-if="props.loading" class="onyx-radio-button__loading" type="circle" />
<!-- TODO: accessible error: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-errormessage -->
Expand All @@ -59,6 +67,7 @@ useAutofocus(input, props);
:checked="props.checked"
:disabled="disabled"
:autofocus="props.autofocus"
v-bind="restAttrs"
/>
<span class="onyx-radio-button__label" :class="[`onyx-truncation-${props.truncation}`]">
{{ props.label }}
Expand Down
20 changes: 17 additions & 3 deletions packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getFormMessages, useCustomValidity } from "../../composables/useCustomV
import { useErrorClass } from "../../composables/useErrorClass";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import { injectI18n } from "../../i18n";
import { useRootAttrs } from "../../utils/attrs";
import { applyLimits, roundToPrecision } from "../../utils/numbers";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxFormElement from "../OnyxFormElement/OnyxFormElement.vue";
Expand Down Expand Up @@ -43,8 +44,12 @@ const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit });
const successMessages = computed(() => getFormMessages(props.success));
const messages = computed(() => getFormMessages(props.message));
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
/**
* Used to detect user interaction to simulate the behavior of :user-invalid for the native input
* Used to detect user interaction to simulate the behimport { useRootAttrs } from "../../utils/attrs";
avior of :user-invalid for the native input
* because the native browser :user-invalid does not trigger when the value is changed via Arrow up/down or increase/decrease buttons
*/
const wasTouched = ref(false);
Expand Down Expand Up @@ -97,11 +102,19 @@ useAutofocus(input, props);
</script>

<template>
<div v-if="skeleton" :class="['onyx-component', 'onyx-stepper-skeleton', densityClass]">
<div
v-if="skeleton"
:class="['onyx-component', 'onyx-stepper-skeleton', densityClass]"
v-bind="rootAttrs"
>
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-stepper-skeleton__label" />
<OnyxSkeleton class="onyx-stepper-skeleton__input" />
</div>
<div v-else :class="['onyx-component', 'onyx-stepper', densityClass, errorClass]">
<div
v-else
:class="['onyx-component', 'onyx-stepper', densityClass, errorClass]"
v-bind="rootAttrs"
>
<OnyxFormElement
v-bind="props"
:message="messages"
Expand Down Expand Up @@ -144,6 +157,7 @@ useAutofocus(input, props);
:required="props.required"
:step="props.validStepSize ?? 'any'"
:title="props.hideLabel ? props.label : undefined"
v-bind="restAttrs"
@change="handleChange"
@keydown.up.prevent="handleClick('stepUp')"
@keydown.down.prevent="handleClick('stepDown')"
Expand Down
18 changes: 16 additions & 2 deletions packages/sit-onyx/src/components/OnyxSwitch/OnyxSwitch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useRequired } from "../../composables/required";
import { useAutofocus } from "../../composables/useAutoFocus";
import { useCustomValidity } from "../../composables/useCustomValidity";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import { useRootAttrs } from "../../utils/attrs";
import OnyxErrorTooltip from "../OnyxErrorTooltip/OnyxErrorTooltip.vue";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxIcon from "../OnyxIcon/OnyxIcon.vue";
Expand All @@ -31,6 +32,9 @@ const emit = defineEmits<{
validityChange: [validity: ValidityState];
}>();
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
const { requiredMarkerClass, requiredTypeClass } = useRequired(props);
const { densityClass } = useDensity(props);
Expand All @@ -56,14 +60,23 @@ useAutofocus(input, props);
</script>

<template>
<div v-if="skeleton" :class="['onyx-component', 'onyx-switch-skeleton', densityClass]">
<div
v-if="skeleton"
:class="['onyx-component', 'onyx-switch-skeleton', densityClass]"
v-bind="rootAttrs"
>
<span class="onyx-switch-skeleton__click-area">
<OnyxSkeleton class="onyx-switch-skeleton__input" />
</span>
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-switch-skeleton__label" />
</div>

<OnyxErrorTooltip v-else :disabled="disabled" :error-messages="shownErrorMessages">
<OnyxErrorTooltip
v-else
:disabled="disabled"
:error-messages="shownErrorMessages"
v-bind="rootAttrs"
>
<label
class="onyx-component onyx-switch"
:class="[requiredTypeClass, densityClass]"
Expand All @@ -80,6 +93,7 @@ useAutofocus(input, props);
:disabled="disabled || props.loading"
:required="props.required"
:autofocus="props.autofocus"
v-bind="restAttrs"
/>
<span class="onyx-switch__click-area">
<span class="onyx-switch__container">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getFormMessages, useCustomValidity } from "../../composables/useCustomV
import { useErrorClass } from "../../composables/useErrorClass";
import { useLenientMaxLengthValidation } from "../../composables/useLenientMaxLengthValidation";
import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState";
import { useRootAttrs } from "../../utils/attrs";
import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core";
import OnyxFormElement from "../OnyxFormElement/OnyxFormElement.vue";
import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue";
Expand Down Expand Up @@ -33,6 +34,9 @@ const emit = defineEmits<{
*/
const modelValue = defineModel<string>({ default: "" });
defineOptions({ inheritAttrs: false });
const { rootAttrs, restAttrs } = useRootAttrs();
const { maxLength, maxLengthError } = useLenientMaxLengthValidation({ props, modelValue });
const customError = computed(() => props.customError ?? maxLengthError.value);
const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit, customError });
Expand Down Expand Up @@ -74,6 +78,7 @@ useAutofocus(input, props);
v-if="skeleton"
:class="['onyx-component', 'onyx-textarea-skeleton', densityClass]"
:style="autosizeMinMaxStyles"
v-bind="rootAttrs"
>
<OnyxSkeleton v-if="!props.hideLabel" class="onyx-textarea-skeleton__label" />
<OnyxSkeleton class="onyx-textarea-skeleton__input" />
Expand All @@ -83,6 +88,7 @@ useAutofocus(input, props);
v-else
:class="['onyx-component', 'onyx-textarea', errorClass, densityClass]"
:style="autosizeMinMaxStyles"
v-bind="rootAttrs"
>
<OnyxFormElement
v-bind="props"
Expand Down Expand Up @@ -110,6 +116,7 @@ useAutofocus(input, props);
:maxlength="maxLength"
:aria-label="props.hideLabel ? props.label : undefined"
:title="props.hideLabel ? props.label : undefined"
v-bind="restAttrs"
@input="handleInput"
></textarea>
</div>
Expand Down
Loading

0 comments on commit 08c0057

Please sign in to comment.