Patterns
This page explains which common patterns we follow when developing onyx and how to use them. These patterns are implemented through composables and enforced through linting 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.
/**
* Extension of `useAttrs` which splits root attributes from other properties.
* As root properties are considered: `style` and `class`.
*
* Make sure to call `defineOptions({ inheritAttrs: false });`.
*
* @example
* ```vue
* <script setup>
* defineOptions({ inheritAttrs: false });
* const { rootAttrs, restAttrs } = useRootAttrs();
* </script>
* <template>
* <div class="onyx-component" v-bind="rootAttrs">
* <!-- ... -->
* <input
* // some other attributes...
* v-bind="restAttrs"
* />
* <!-- ... -->
* </div>
* </template>
*/
export const useRootAttrs = <T extends Pick<HTMLAttributes, "class" | "style">>() => {(Shared) Child Props Forwarding
When a parent component is a wrapper for another (support) component, the parent usually extends all or a subset of the child's properties. The relevant child props need then to be forwarded to the child component. This can easily be achieved by using v-bind, e.g.
<script setup lang="ts">
const props = defineProps<ParentProps & ChildProps>();
</script>
<template>
<!-- ⚠️ Don't do this -->
<ChildComponent v-bind="props" />
</template>Unfortunately this has the unwanted side-effect of all extraneous props being applied as attributes. So when the parent defines props which do not exist on the child component, they are treated as fallthrough attributes. Besides cluttering the HTML with irrelevant attributes this also can have disruptive side-effects when the prop name accidentally matches a valid html attribute (e.g. inert or hidden).
Which might look like this in the DOM tree:
<div class="child-component" parent-prop-1="parent-prop-value-1" parent-prop-2="[object Object]">
<!-- ... -->
</div>To avoid this, our useForwardProps composable can be used:
/**
* The computed value is an object, that only contains properties that are defined by the target component.
* Is useful to forward only a matching subset of properties from a parent component to a wrapped child component.
*
* This is necessary when the parent defines props, which are not defined in the child component.
* Otherwise, the child component will set the extraneous props as attributes, which bloats the DOM and can lead to unexpected side-effects.
*
* @example
*
* ```vue
* <script setup lang="ts">
* import { useForwardProps } from "sit-onyx";
* import MyChildComponent from "./MyChildComponent.vue";
*
* const props = defineProps<ParentProps & ChildProps>();
* const childProps = useForwardProps(props, MyChildComponent);
* </script>
* <template>
* <!-- childProps only includes props that exist on MyChildComponent -->
* <MyChildComponent v-bind="childProps" />
* </template>
* ```
*
* @param props The reactive props object of the parent component.
* @param target Component for which the properties are to be forwarded.
* @returns computed value with properties that are also defined the target component.
*/
export const useForwardProps = <
T extends Data,
TComponent,
TProps = ComponentProps<TComponent>,
R = MaybePick<T, keyof TProps>,
>(
props: T,
target: TComponent,
) => {State Control
We want to give the user maximum control of component state, which is achieved by providing props with two-way binding support via Vue's v-model. To not require the developer to declare refs for state they do not care about, the state will be stored internally if left undefined by the user.
Unfortunately the Vue native defineModel compiler macro behaves not as expected, as it will prefer internal state updates over external, unchanged state (e.g. <Comp :open="true" /> will not be considered anymore after an update:open with value false).
Therefore we created a custom composable useVModel, which will prefer the external state:
/**
* Composable for managing the v-model behavior of a prop.
* It's behavior differs from the `defineModel` behavior, in that it will always prefer the bound value over the internal state.
* This allows for better control over the component's state, e.g. a flyout can be kept open even when it was supposed to close.
*
* There is currently no way to differentiate between an explicitly bound `undefined` prop value (e.g. `<Comp :value="undefined" />`) and a implicit `undefined` from not defined prop (e.g. `<Comp />`).
* Therefore for `null` or `undefined` values, the internal state or default value will always be used.
*
* For default values with non-primitive types, it's required to use a factory function that returns the default value to avoid mutating the former value.
*
* @example ```typescript
* const props = defineProps<{
* modelValue?: string;
* }>();
*
* const emit = defineEmits<{ "update:modelValue": [string] }>();
*
* const modelValue = useVModel({
* props,
* emit,
* key: "modelValue",
* default: "",
* });
```
*/
export const useVModel = <
TProps extends object,
TKey extends keyof TProps & string,
TValue extends TProps[TKey] = TProps[TKey],
TDefaultValue = undefined | (TValue extends Nullable<PrimitiveType> ? TValue : () => TValue),
TComputed extends Nullable<TValue> = TDefaultValue extends undefined | null
? TValue
: NonNullable<TValue>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- required for type inference
TEmit extends (...args: any[]) => void = (evt: `update:${TKey}`, value: TComputed) => void,
>(
options: UseVModelOptions<TProps, TKey, TValue, TDefaultValue, TComputed, TEmit>,
) => {