Skip to content

Commit

Permalink
feat(RisCopyableLabel, RisExpandableText): add text utility components
Browse files Browse the repository at this point in the history
  • Loading branch information
andreasphil committed Oct 15, 2024
1 parent c93e26d commit ac7a014
Show file tree
Hide file tree
Showing 8 changed files with 356 additions and 0 deletions.
47 changes: 47 additions & 0 deletions src/components/RisCopyableLabel/RisCopyableLabel.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { html } from "@/lib/tags";
import { Meta, StoryObj } from "@storybook/vue3";
import RisCopyableLabel from ".";

const meta: Meta<typeof RisCopyableLabel> = {
component: RisCopyableLabel,

tags: ["autodocs"],

args: {
text: "Copy to clipboard",
value: undefined,
name: undefined,
},

argTypes: {
value: String,
name: String,
},
};

export default meta;

export const Default: StoryObj<typeof meta> = {
render: (args) => ({
components: { RisCopyableLabel },
setup() {
return { args };
},
template: html`<RisCopyableLabel v-bind="args" />`,
}),
};

export const CustomValue: StoryObj<typeof meta> = {
args: {
text: 'Copy "test" to clipboard',
value: "test",
},

render: (args) => ({
components: { RisCopyableLabel },
setup() {
return { args };
},
template: html`<RisCopyableLabel v-bind="args" />`,
}),
};
51 changes: 51 additions & 0 deletions src/components/RisCopyableLabel/RisCopyableLabel.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { userEvent } from "@testing-library/user-event";
import { render, screen } from "@testing-library/vue";
import { describe, expect, test, vi } from "vitest";
import RisCopyableLabel from ".";

describe("RisCopyableLabel", () => {
test("renders", () => {
render(RisCopyableLabel, { props: { text: "Foo" } });
expect(screen.getByText("Foo")).toBeInTheDocument();
});

test("renders an accessible label with the default value", () => {
render(RisCopyableLabel, { props: { text: "Foo" } });

expect(
screen.getByRole("button", {
name: "Wert in die Zwischenablage kopieren",
}),
).toBeInTheDocument();
});

test("renders an accessible label with a custom value", () => {
render(RisCopyableLabel, { props: { text: "Foo", name: "Bar" } });

expect(
screen.getByRole("button", {
name: "Bar in die Zwischenablage kopieren",
}),
).toBeInTheDocument();
});

test("copies the text if no value is provided", async () => {
const user = userEvent.setup();
const spy = vi.spyOn(navigator.clipboard, "writeText");
render(RisCopyableLabel, { props: { text: "Foo" } });

await user.click(screen.getByRole("button"));

expect(spy).toHaveBeenCalledWith("Foo");
});

test("copies the value if provided", async () => {
const user = userEvent.setup();
const spy = vi.spyOn(navigator.clipboard, "writeText");
render(RisCopyableLabel, { props: { text: "Foo", value: "Bar" } });

await user.click(screen.getByRole("button"));

expect(spy).toHaveBeenCalledWith("Bar");
});
});
58 changes: 58 additions & 0 deletions src/components/RisCopyableLabel/RisCopyableLabel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script setup lang="ts">
import MdiContentCopy from "~icons/mdi/content-copy";
import MdiCheck from "~icons/mdi/check";
import { ref } from "vue";
const props = withDefaults(
defineProps<{
/** Visible text. */
text: string;
/**
* Value that should be copied. If no value is provided, copying will
* copy the `text` by default.
*/
value?: string;
/**
* Human-readable description of the value that should be copied. This
* will be used to provide an accessible label for the control.
*
* @default "Wert"
*/
name?: string;
}>(),
{
value: undefined,
name: "Wert",
},
);
const copySuccess = ref(false);
async function copy() {
try {
await navigator.clipboard.writeText(props.value ?? props.text);
copySuccess.value = true;
setTimeout(() => {
copySuccess.value = false;
}, 1000);
} catch (err) {
console.error(err);
}
}
</script>

<template>
<button
:aria-label="`${name} in die Zwischenablage kopieren`"
:title="`${name} in die Zwischenablage kopieren`"
class="ris-link2-regular inline-flex items-center gap-4 text-left"
type="button"
@click="copy()"
>
{{ text }}
<MdiContentCopy v-if="!copySuccess" class="flex-none" />
<MdiCheck v-else class="flex-none" />
</button>
</template>
3 changes: 3 additions & 0 deletions src/components/RisCopyableLabel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import RisCopyableLabel from "./RisCopyableLabel.vue";

export default RisCopyableLabel;
49 changes: 49 additions & 0 deletions src/components/RisExpandableText/RisExpandableText.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { html } from "@/lib/tags";
import { Meta, StoryObj } from "@storybook/vue3";
import RisExpandableText from ".";

const meta: Meta<typeof RisExpandableText> = {
component: RisExpandableText,

tags: ["autodocs"],

args: {
length: 3,
},
};

export default meta;

export const Default: StoryObj<typeof meta> = {
render: (args) => ({
components: { RisExpandableText },
setup() {
return { args };
},
template: html`<div class="max-w-320">
<RisExpandableText v-bind="args">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</RisExpandableText>
</div>`,
}),
};

export const TooShortToExpand: StoryObj<typeof meta> = {
render: (args) => ({
components: { RisExpandableText },
setup() {
return { args };
},
template: html`<div class="max-w-320">
<RisExpandableText v-bind="args">
This text is so short, it doesn't need to be truncated.
</RisExpandableText>
</div>`,
}),
};
87 changes: 87 additions & 0 deletions src/components/RisExpandableText/RisExpandableText.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { userEvent } from "@testing-library/user-event";
import { render, screen } from "@testing-library/vue";
import { describe, expect, test, vi } from "vitest";
import RisExpandableText from ".";

describe("RisExpandableText", () => {
test("renders the text", () => {
render(RisExpandableText, { slots: { default: "Test" } });
expect(screen.getByText("Test")).toBeInTheDocument();
});

test("renders an expand button", async () => {
// Need to mock these properties as JSDOM doesn't implement layout so they would always be 0
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(50);
render(RisExpandableText, { slots: { default: "Test" } });

await vi.waitFor(() => {
expect(
screen.getByRole("button", { name: "Mehr anzeigen" }),
).toBeInTheDocument();
});
});

test("expands the text", async () => {
// Need to mock these properties as JSDOM doesn't implement layout so they would always be 0
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(50);
const user = userEvent.setup();
render(RisExpandableText, {
slots: { default: "Test" },
props: { expanded: false },
});

await vi.waitFor(() => screen.getByRole("button"));

await user.click(screen.getByRole("button"));

expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
});

test("collapses the text", async () => {
// Need to mock these properties as JSDOM doesn't implement layout so they would always be 0
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(50);
const user = userEvent.setup();
render(RisExpandableText, {
slots: { default: "Test" },
props: { expanded: true },
});

await vi.waitFor(() => screen.getByRole("button"));

await user.click(screen.getByRole("button"));

expect(screen.getByRole("button")).toHaveAttribute(
"aria-expanded",
"false",
);
});

test("renders a collapse button", async () => {
// Need to mock these properties as JSDOM doesn't implement layout so they would always be 0
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(50);
render(RisExpandableText, {
props: { expanded: true },
slots: { default: "Test" },
});

await vi.waitFor(() => {
expect(
screen.getByRole("button", { name: "Weniger anzeigen" }),
).toBeInTheDocument();
});
});

test("does not render the expand/collapse button if the text is not truncated", async () => {
// Need to mock these properties as JSDOM doesn't implement layout so they would always be 0
// Need to mock these properties as JSDOM doesn't implement layout so they would always be 0
vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(100);
render(RisExpandableText, { slots: { default: "Test" } });

expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions src/components/RisExpandableText/RisExpandableText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ref, useTemplateRef, watchEffect, useId } from "vue";
const { length = 3 } = defineProps<{
/**
* Specifies the maximum number of visible lines.
* @default 3
*/
length?: number;
}>();
const expanded = defineModel<boolean>("expanded", { default: false });
const canExpand = ref(false);
const textContent = useTemplateRef("textContent");
const textId = useId();
watchEffect(() => {
if (textContent.value instanceof HTMLDivElement) {
canExpand.value =
textContent.value.scrollHeight > textContent.value.clientHeight;
}
});
</script>

<template>
<div>
<div
:id="textId"
ref="textContent"
:class="{ [$style.truncate]: !expanded }"
>
<slot />
</div>

<button
v-if="canExpand"
class="ris-link1-regular"
:aria-controls="textId"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<template v-if="expanded">Weniger anzeigen</template>
<template v-if="!expanded">Mehr anzeigen</template>
</button>
</div>
</template>

<style module>
.truncate {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: v-bind(length);
}
</style>
3 changes: 3 additions & 0 deletions src/components/RisExpandableText/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import RisExpandableText from "./RisExpandableText.vue";

export default RisExpandableText;

0 comments on commit ac7a014

Please sign in to comment.