Skip to content

Commit

Permalink
feat(OnyxHeadline): support hash property (#2434)
Browse files Browse the repository at this point in the history
Relates to #569

<!-- Briefly describe the changes of this PR. -->

## Checklist

- [x] The added / edited code has been documented with
[JSDoc](https://jsdoc.app/about-getting-started)
- [x] All changes are documented in the documentation app (folder
`apps/docs`)
- [x] If a new component is added, at least one [Playwright screenshot
test](https://github.com/SchwarzIT/onyx/actions/workflows/playwright-screenshots.yml)
is added
- [x] A changeset is added with `npx changeset add` if your changes
should be released as npm package (because they affect the library
usage)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
larsrickert and github-actions[bot] authored Jan 8, 2025
1 parent b9d21f5 commit 8beb853
Show file tree
Hide file tree
Showing 17 changed files with 200 additions and 14 deletions.
5 changes: 5 additions & 0 deletions .changeset/sweet-needles-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sit-onyx": minor
---

feat(OnyxHeadline): support `hash` property
2 changes: 1 addition & 1 deletion apps/demo-app/src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ const selectedDate = ref<DateValue>();

<OnyxEmpty v-if="show('OnyxEmpty')">No data available</OnyxEmpty>

<OnyxHeadline is="h1" v-if="show('OnyxHeadline')">Headline</OnyxHeadline>
<OnyxHeadline is="h1" v-if="show('OnyxHeadline')" hash="headline">Headline</OnyxHeadline>

<OnyxIcon v-if="show('OnyxIcon')" :icon="emojiHappy2" />

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/.vitepress/components/ComponentRoadmap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const shouldShowAllButton = computed(() => {

<template>
<section class="components">
<OnyxHeadline is="h2" class="components__headline">Components</OnyxHeadline>
<OnyxHeadline is="h2" class="components__headline" hash="components">Components</OnyxHeadline>

<p class="components__description">
onyx is currently in beta version and early / active development. Below you can find a list of
Expand Down
17 changes: 14 additions & 3 deletions apps/docs/src/.vitepress/components/DesignVariableHeader.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { OnyxButton, OnyxHeadline } from "sit-onyx";
import { OnyxButton, OnyxHeadline, normalizeUrlHash } from "sit-onyx";
const props = defineProps<{
/** Headline to show on the left side of the header. */
Expand All @@ -18,7 +18,14 @@ const emit = defineEmits<{

<template>
<div class="header vp-raw">
<OnyxHeadline is="h3" class="header__headline">{{ props.headline }}</OnyxHeadline>
<OnyxHeadline
is="h3"
v-if="props.headline"
class="header__headline"
:hash="normalizeUrlHash(props.headline)"
>
{{ props.headline }}
</OnyxHeadline>

<div>
<OnyxButton
Expand All @@ -38,11 +45,15 @@ const emit = defineEmits<{
.header {
margin-bottom: var(--onyx-spacing-2xs);
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
&__headline {
margin: 0;
}
&:has(&__headline) {
justify-content: space-between;
}
}
</style>
4 changes: 3 additions & 1 deletion apps/docs/src/.vitepress/components/OnyxHomePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ const storybookHost = "https://storybook.onyx.schwarz" as const;
<ComponentRoadmap :components="props.data.components" />

<section>
<OnyxHeadline is="h2" class="roadmap__headline">Facts and figures</OnyxHeadline>
<OnyxHeadline is="h2" class="roadmap__headline" hash="facts">
Facts and figures
</OnyxHeadline>
<p class="roadmap__timestamp">Last updated on: {{ kpiTimestamp }}</p>

<div class="roadmap__facts">
Expand Down
6 changes: 4 additions & 2 deletions apps/docs/src/.vitepress/components/OnyxIconLibrary.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { OnyxHeadline, OnyxInput } from "sit-onyx";
import { OnyxHeadline, OnyxInput, normalizeUrlHash } from "sit-onyx";
import { computed, ref } from "vue";
import { getEnrichedIconCategoryList } from "../utils-icons";
import IconLibraryItem from "./IconLibraryItem.vue";
Expand Down Expand Up @@ -48,7 +48,9 @@ const filteredCategories = computed(() => {
/>

<section v-for="category in filteredCategories" :key="category.name" class="category">
<OnyxHeadline is="h3" class="category__headline">{{ category.name }}</OnyxHeadline>
<OnyxHeadline is="h3" class="category__headline" :hash="normalizeUrlHash(category.name)">
{{ category.name }}
</OnyxHeadline>

<div class="category__icons">
<IconLibraryItem v-for="icon in category.icons" :key="icon.iconName" :icon="icon" />
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/src/.vitepress/theme/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@ html.onyx-transition-active * {
transition-property: color, background-color, border-color !important;
transition-timing-function: ease-in-out;
}

.onyx-headline {
// adjust headline scroll margin to consider VitePress nav/header
scroll-margin-top: calc(var(--onyx-headline-scroll-margin) + var(--vp-nav-height));
}

// fix conflicting VitePress styles for onyx headline hash
.vp-doc a.onyx-headline__hash {
font-weight: inherit;
color: inherit;
text-decoration: none;
}
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.
45 changes: 43 additions & 2 deletions packages/sit-onyx/src/components/OnyxHeadline/OnyxHeadline.ct.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,51 @@ test.describe("Screenshot tests", () => {
columns: ["default"],
rows: HEADLINE_TYPES,
component: (column, row) => <OnyxHeadline is={row}>Hello World</OnyxHeadline>,
});
});

test.describe("Screenshot tests (hash)", () => {
executeMatrixScreenshotTest({
name: "Headline (hash)",
columns: HEADLINE_TYPES,
rows: ["default", "hover", "focus-visible"],
component: (column) => (
<OnyxHeadline is={column} hash="example" style={{ marginLeft: "1rem" }}>
Hello World
</OnyxHeadline>
),
hooks: {
beforeEach: async (component) => {
await expect(component).toContainText("Hello World");
beforeEach: async (component, page, column, row) => {
if (row === "hover") await component.hover();
if (row === "focus-visible") await page.keyboard.press("Tab");
},
},
});
});

test("should copy hash", async ({ mount, page, context, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName !== "chromium",
"clipboard permission granting is only supported in chromium",
);

await context.grantPermissions(["clipboard-read", "clipboard-write"]);

// ARRANGE
const component = await mount(
<OnyxHeadline is="h1" hash="example-hash" style={{ marginLeft: "1rem" }}>
Example
</OnyxHeadline>,
);

// ACT
await component.getByRole("link", { name: "Example" }).click();

// ASSERT
const expectedUrl = "http://localhost:3100/#example-hash";
await expect(page).toHaveURL(expectedUrl);

const copiedValue = await page.evaluate(() => navigator.clipboard.readText());
expect(copiedValue).toEqual(expectedUrl);
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import OnyxHeadline from "./OnyxHeadline.vue";
* Headline that can e.g. be used to structure the page content.
*/
const meta: Meta<typeof OnyxHeadline> = {
title: "Basic/Headline",
title: "Navigation/Headline",
component: OnyxHeadline,
argTypes: {
default: {
control: { type: "text" },
},
},
decorators: [
(story) => ({
components: { story },
template: '<div class="onyx-grid-container"> <story /> </div>',
}),
],
};

export default meta;
Expand All @@ -26,3 +32,14 @@ export const Default = {
default: "Lorem ipsum dolor sit amet",
},
} satisfies Story;

/**
* This example shows a default h1 headline with a hash URL that can be copied by clicking the headline.
*/
export const WithHash = {
args: {
is: "h1",
default: "Click me to copy URL",
hash: "section-1",
},
} satisfies Story;
67 changes: 66 additions & 1 deletion packages/sit-onyx/src/components/OnyxHeadline/OnyxHeadline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,31 @@ defineSlots<{
*/
default(): unknown;
}>();
const copyHash = async (hash: string) => {
const { origin, pathname, search } = window.location;
const fullUrl = `${origin}${pathname}${search}#${hash}`;
await navigator.clipboard.writeText(fullUrl);
};
</script>

<template>
<component
:is="props.is"
:id="props.hash"
:class="['onyx-component', 'onyx-headline', `onyx-headline--${props.is}`]"
>
<slot></slot>
<a
v-if="props.hash"
:href="`#${props.hash}`"
target="_self"
class="onyx-headline__hash"
@click="copyHash(props.hash)"
>
<slot />
</a>

<slot v-else />
</component>
</template>

Expand All @@ -26,11 +43,59 @@ defineSlots<{
.onyx-headline {
@include layers.component() {
--onyx-headline-scroll-margin: var(--onyx-spacing-xl);
--border-radius: var(--onyx-radius-sm);
font-weight: 600;
font-family: var(--onyx-font-family);
color: var(--onyx-color-text-icons-neutral-intense);
position: relative;
border-radius: var(--border-radius);
scroll-margin-top: var(--onyx-headline-scroll-margin);
@include sizes.define-headline-sizes();
&__hash {
color: inherit;
text-decoration: none;
border-radius: 0 var(--border-radius) var(--border-radius) 0;
outline: none;
display: inline-block;
&:hover,
&:focus-visible {
background-color: var(--onyx-color-base-neutral-200);
padding-right: var(--onyx-density-xs);
&::before {
$width: 1.5rem;
content: "#";
position: absolute;
width: $width;
left: -$width;
text-align: center;
color: var(--onyx-color-text-icons-primary-intense);
cursor: pointer;
background-color: inherit;
line-height: inherit;
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
}
&:focus-visible {
outline: var(--onyx-outline-width) solid var(--onyx-color-component-focus-primary);
outline-offset: 0;
&::before {
// we apply the visual outline as box-shadow here instead of native outline because we need to cut off the right side
box-shadow: 0 0 0 var(--onyx-outline-width) var(--onyx-color-component-focus-primary);
// remove right box shadow since its applied by the parent outline
$offset: calc(-1 * var(--onyx-outline-width));
clip-path: inset($offset 0 $offset $offset);
}
}
}
}
}
</style>
9 changes: 9 additions & 0 deletions packages/sit-onyx/src/components/OnyxHeadline/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ export type OnyxHeadlineProps = {
* h5 and h6 will have the same styles as h4 and should only be used for semantic reasons.
*/
is: HeadlineType;
/**
* Unique headline hash/ID (without "#") that is used to show a "#" icon on hover. Makes the headline clickable and a URL that points to this headline
* is copied to the users clipboard. Must be URL-safe, e.g. not containing whitespaces etc.
*
* If your headline content is dynamic, you can use our `normalizeUrlHash()` utility to generate it.
*
* @example "about-us"
*/
hash?: string;
};

export const HEADLINE_TYPES = ["h1", "h2", "h3", "h4", "h5", "h6"] as const;
Expand Down
2 changes: 1 addition & 1 deletion packages/sit-onyx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,6 @@ export { provideI18n, type TranslationFunction } from "./i18n";
export type { OnyxTranslations, ProvideI18nOptions } from "./i18n";
export * from "./types";
export { createOnyx } from "./utils/plugin";
export { normalizedIncludes } from "./utils/strings";
export { normalizeUrlHash, normalizedIncludes } from "./utils/strings";

export * from "./composables/themeTransition";
15 changes: 14 additions & 1 deletion packages/sit-onyx/src/utils/strings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "vitest";
import { normalizedIncludes } from "./strings";
import { normalizedIncludes, normalizeUrlHash } from "./strings";

test.each([
// ARRANGE
Expand All @@ -26,3 +26,16 @@ test.each([
expect(result).toBe(expected);
},
);

test.each([
// ARRANGE
{ text: "Hello World", expected: "hello-world" },
{ text: " Hello World ", expected: "hello-world" },
{ text: "hello-world", expected: "hello-world" },
])("should transform $text to $expected when normalizing as hash", ({ text, expected }) => {
// ACT
const result = normalizeUrlHash(text);

// ASSERT
expect(result).toBe(expected);
});
9 changes: 9 additions & 0 deletions packages/sit-onyx/src/utils/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,12 @@ export const normalizedIncludes = (haystack: string, needle: string): boolean =>
};

const removeDiacritics = (str: string) => str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");

/**
* Normalizes the given text (e.g. from a headline) to a URL-safe string that can be used as URL hash.
*
* @example "Hello World" => "hello-world", e.g. used as "https://example.com/my-page#hello-world"
*/
export const normalizeUrlHash = (text: string) => {
return text.trim().toLowerCase().replace(/\W/gi, "-");
};

0 comments on commit 8beb853

Please sign in to comment.