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">>() => {
INFO
Your use-case is not covered? Head over to our GitHub discussion page to make suggestions or ask questions!
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 ref
s 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 = <
TValue extends TProps[TKey],
TKey extends keyof TProps & string,
TProps extends object,
TDefaultValue extends (TValue extends PrimitiveType ? TValue : () => TValue) | undefined,
TEmit extends (evt: `update:${TKey}`, value: TValue) => void = (
evt: `update:${TKey}`,
value: TValue,
) => void,
TComputed = TDefaultValue extends undefined ? TValue : NonNullable<TValue>,
>(
options: UseVModelOptions<TKey, TProps, TValue, TEmit, TDefaultValue>,
) => {