Skip to content

Commit

Permalink
feat: implement normalization using base class (#2276)
Browse files Browse the repository at this point in the history
- add onyx-component as base-class to every component
- put normalize.apply in onyx-componet class
- add linting rool
  • Loading branch information
ChristianBusshoff authored Dec 10, 2024
1 parent 5c1c4f1 commit 41c2adf
Show file tree
Hide file tree
Showing 74 changed files with 313 additions and 81 deletions.
4 changes: 2 additions & 2 deletions apps/docs/src/principles/contributing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ const { densityClass } = useDensity(props);
</script>
<template>
<div :class="['onyx-component', densityClass]">
<div :class="['onyx-component', 'onyx-component-name', densityClass]">
<!-- component HTML -->
</div>
</template>
<style lang="scss">
@use "../../styles/mixins/layers.scss";
.onyx-component {
.onyx-component-name {
@include layers.component() {
// component styles...
}
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const sitOnyxConfig = {
rules: {
"sitOnyx/import-playwright-a11y": "error",
"sitOnyx/no-shadow-native": "error",
"sitOnyx/require-root-class": "error",
"vue/require-prop-comment": "error",
// disallow scoped or module CSS for components
// see https://onyx.schwarz/principles/technical-vision.html#css
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ module.exports = {
rules: {
"import-playwright-a11y": require("./rules/import-playwright-a11y.cjs"),
"no-shadow-native": require("./rules/no-shadow-native-events.cjs"),
"require-root-class": require("./rules/require-root-class.cjs"),
},
};
190 changes: 190 additions & 0 deletions packages/eslint-plugin/src/rules/require-root-class.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"use strict";

/**
* @typedef {import('vue-eslint-parser').AST.VDirective} VDirective
* @typedef {import('vue-eslint-parser').AST.VElement} VElement
* @typedef {import('vue-eslint-parser').AST.VAttribute} VAttribute
* @typedef {import('vue-eslint-parser').AST.VIdentifier} VIdentifier
* @typedef {import('eslint').Rule.RuleContext} RuleContext
* @typedef {import('eslint').Rule.RuleListener} RuleListener
*/

/**
* Check whether the given start tag has specific directive.
* @param {VElement} node The start tag node to check.
* @param {string} name The directive name to check.
* @param {string} [argument] The directive argument to check.
* @returns {boolean} `true` if the start tag has the directive.
*/
function hasDirective(node, name, argument) {
return Boolean(getDirective(node, name, argument));
}

/**
* Get the directive which has the given name.
* @param {VElement} node The start tag node to check.
* @param {string} name The directive name to check.
* @param {string} [argument] The directive argument to check.
* @returns {VDirective | null} The found directive.
*/
function getDirective(node, name, argument) {
return (
node.startTag.attributes.find(
/**
* @param {VAttribute | VDirective} node
* @returns {node is VDirective}
*/
(node) =>
node.directive &&
node.key.name.name === name &&
(argument === undefined ||
(node.key.argument &&
node.key.argument.type === "VIdentifier" &&
node.key.argument.name) === argument),
) || null
);
}

module.exports = {
meta: {
type: "problem",
docs: {
description: "disallow adding root nodes to the template",
},
fixable: null,
schema: [],
messages: {
multipleRoot: "The template root requires exactly one element.",
textRoot: "The template root requires an element rather than texts.",
disallowedDirective: "The template root disallows 'v-for' directives.",
missingClass: "The root element is missing the 'onyx-component' class.",
},
},
/**
* @param {RuleContext} context - The rule context.
* @returns {RuleListener} AST event handlers.
*/
create(context) {
const sourceCode = context.getSourceCode();

return {
Program(program) {
const element = program.templateBody;
if (element == null) {
return;
}

const rootElements = [];
let extraElement = null;
let vIf = false;
for (const child of element.children) {
if (child.type === "VElement") {
if (rootElements.length === 0) {
rootElements.push(child);
vIf = hasDirective(child, "if");
} else if (vIf && hasDirective(child, "else-if")) {
rootElements.push(child);
} else if (vIf && hasDirective(child, "else")) {
rootElements.push(child);
vIf = false;
} else {
extraElement = child;
}
} else if (sourceCode.getText(child).trim() !== "") {
context.report({
node: child,
messageId: "textRoot",
});
return;
}
}

if (extraElement == null) {
for (const element of rootElements) {
const tag = element.startTag;
const name = element.name;

if (name === "template" || name === "slot") {
return;
}
if (element.name.startsWith("onyx")) {
return true;
}
// Check for existence of base class
/**
* @type {VIdentifier, VDirectiveKey}
*/
const has = element.startTag.attributes.some(({ key, value }) => {
const isClassAttribute =
(key.type === "VIdentifier" && key.name === "class") ||
(key.type === "VDirectiveKey" &&
key.argument?.type === "VIdentifier" &&
key.argument.name === "class");
if (!isClassAttribute) return false;

// static class: class="..."
if (value?.value) {
return value.value.includes("onyx-component");
}

// dynamic class: :class="..."
if (value?.expression) {
const expression = value.expression;

if (expression.type === "ArrayExpression") {
// :class="['class1', 'onyx-component']"
return expression.elements.some(
(element) => element.type === "Literal" && element.value === "onyx-component",
);
} else if (expression.type === "ObjectExpression") {
// :class="{ 'onyx-component': true, 'class2': false }"
return expression.properties.some((property) => {
if (property.type === "SpreadElement") {
return false;
}
return (
property.key.type === "Literal" &&
property.key.value === "onyx-component" &&
property.value.type === "Literal" &&
Boolean(property.value.value) === true
);
});
} else if (expression.type === "Literal") {
// :class="'onyx-component'"
return expression.value === "onyx-component";
}
}

return false;
});

if (!has) {
context.report({
node: tag,
loc: tag.loc,
messageId: "missingClass",
});
}

if (hasDirective(element, "for")) {
context.report({
node: tag,
loc: tag.loc,
messageId: "disallowedDirective",
});
}
}
} else if (extraElement?.name === "teleport") {
// Exclude teleport from this rule
return true;
} else {
context.report({
node: extraElement,
loc: extraElement.loc,
messageId: "multipleRoot",
});
}
},
};
},
};
2 changes: 1 addition & 1 deletion packages/nuxt/test/component-imports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("auto imports", async () => {
expect(html).toContain("--onyx-font-family");

// The rendered page should contain a h1 with the onyx classes if the component was auto imported correctly
expect(html).toContain('<h1 class="onyx-headline onyx-headline--h1">');
expect(html).toContain('<h1 class="onyx-component onyx-headline onyx-headline--h1">');

// global styles should be imported
expect(html).toContain(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const gridTemplateAreas = computed(() => {
</script>

<template>
<div class="wrapper">
<div class="onyx-component wrapper">
<div class="meta">
<h1 class="meta__name">Screenshot test: {{ props.name }}</h1>
<div>Browser: {{ props.browserName }}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ const slots = defineSlots<{
</script>

<template>
<div class="onyx-app" :class="{ 'onyx-app--horizontal': props.navBarAlignment === 'left' }">
<div
class="onyx-component onyx-app"
:class="{ 'onyx-app--horizontal': props.navBarAlignment === 'left' }"
>
<div v-if="slots.navBar" class="onyx-app__nav">
<slot name="navBar"></slot>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/sit-onyx/src/components/OnyxAvatar/OnyxAvatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ watch(

<template>
<figure
class="onyx-avatar"
class="onyx-component onyx-avatar"
:class="[`onyx-avatar--${props.size}`, slots.default ? 'onyx-avatar--custom' : '']"
:title="props.label"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defineSlots<{
</script>

<template>
<div class="onyx-avatar-stack">
<div class="onyx-component onyx-avatar-stack">
<slot></slot>
</div>
</template>
Expand Down
2 changes: 1 addition & 1 deletion packages/sit-onyx/src/components/OnyxBadge/OnyxBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ defineSlots<{

<template>
<div
class="onyx-badge"
class="onyx-component onyx-badge"
:class="[
'onyx-truncation-ellipsis',
'onyx-text',
Expand Down
1 change: 1 addition & 0 deletions packages/sit-onyx/src/components/OnyxButton/OnyxButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const rippleEvents = computed(() => rippleRef.value?.events ?? {});
<button
v-else
:class="[
'onyx-component',
'onyx-button',
`onyx-button--${props.color}`,
`onyx-button--${props.mode}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ const title = computed(() => {
</script>

<template>
<div v-if="skeleton" :class="['onyx-checkbox-skeleton', densityClass]">
<div v-if="skeleton" :class="['onyx-component', 'onyx-checkbox-skeleton', densityClass]">
<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">
<label class="onyx-checkbox" :class="[requiredTypeClass, densityClass]" :title="title">
<label
class="onyx-component onyx-checkbox"
:class="[requiredTypeClass, densityClass]"
:title="title"
>
<div class="onyx-checkbox__container">
<OnyxLoadingIndicator v-if="props.loading" class="onyx-checkbox__loading" type="circle" />
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const checkAllLabel = computed(() => {

<template>
<fieldset
:class="['onyx-checkbox-group', densityClass]"
:class="['onyx-component', 'onyx-checkbox-group', densityClass]"
:disabled="disabled"
:aria-label="props.label"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const slots = defineSlots<{
</script>

<template>
<div class="onyx-data-grid-header-cell">
<div class="onyx-component onyx-data-grid-header-cell">
<span class="onyx-data-grid-header-cell__label">{{ props.label }}</span>
<div v-if="slots.actions" class="onyx-data-grid-header-cell__actions">
<slot name="actions"></slot>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,12 @@ const value = computed({
</script>

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

<div v-else :class="['onyx-datepicker', densityClass, errorClass]">
<div v-else :class="['onyx-component', 'onyx-datepicker', densityClass, errorClass]">
<OnyxFormElement
v-bind="props"
:error-messages="errorMessages"
Expand Down
2 changes: 1 addition & 1 deletion packages/sit-onyx/src/components/OnyxDialog/OnyxDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ watch(
<dialog
v-if="props.open"
ref="dialogRef"
:class="['onyx-dialog', densityClass, 'onyx-truncation-multiline']"
:class="['onyx-component', 'onyx-dialog', densityClass, 'onyx-truncation-multiline']"
:aria-modal="props.modal"
:aria-label="props.label"
:role="props.alert ? 'alertdialog' : undefined"
Expand Down
2 changes: 1 addition & 1 deletion packages/sit-onyx/src/components/OnyxEmpty/OnyxEmpty.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const { densityClass } = useDensity(props);
</script>

<template>
<div :class="['onyx-empty', densityClass]">
<div :class="['onyx-component', 'onyx-empty', densityClass]">
<slot name="icon">
<OnyxIcon :icon="circleX" size="48px" />
</slot>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const targetRef = ref<HTMLDivElement>();
</script>

<template>
<div>
<div class="onyx-component">
<!-- component will be placed in here if no tooltip should be rendered -->
<div v-if="!tooltipError || props.disabled" ref="targetRef"></div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const isVisible = computed(() => {
<template>
<OnyxIcon
v-if="isVisible"
class="onyx-external-link-icon"
class="onyx-component onyx-external-link-icon"
:icon="arrowSmallUpRight"
size="16px"
/>
Expand Down
1 change: 1 addition & 0 deletions packages/sit-onyx/src/components/OnyxForm/OnyxForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const { densityClass } = useDensity(props);
<template>
<form
:class="{
'onyx-component': true,
'onyx-form': true,
...densityClass,
}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const props = defineProps<{
<template>
<component
:is="messages.hidden ? OnyxVisuallyHidden : 'span'"
:class="['onyx-form-message', `onyx-form-message__${props.type}`]"
:class="['onyx-component', 'onyx-form-message', `onyx-form-message__${props.type}`]"
>
<span :class="['onyx-truncation-ellipsis']">
{{ props.messages.shortMessage }}
Expand Down
Loading

0 comments on commit 41c2adf

Please sign in to comment.