Skip to content

Commit

Permalink
feat: implement OnyxMoreList component (#2022)
Browse files Browse the repository at this point in the history
Relates to #986

Implement a generic `OnyxMoreList` component that can be used to render
any list of components with a "+ more" indicator.
I will integrate it in following PRs into the nav bar as well as
implementing the positioning of the more indicator which is currently
not visible at all.

## Checklist

- [x] The added / edited code has been documented with
[JSDoc](https://jsdoc.app/about-getting-started)
- [ ] If a new component is added, at least one [Playwright screenshot
test](https://github.com/SchwarzIT/onyx/actions/workflows/playwright-screenshots.yml)
is added
- [ ] A changeset is added with `npx changeset add` if your changes
should be released as npm package (because they affect the library
usage)
  • Loading branch information
larsrickert authored Nov 6, 2024
1 parent 1a17bb0 commit 4db5e6d
Show file tree
Hide file tree
Showing 13 changed files with 484 additions and 9 deletions.
4 changes: 2 additions & 2 deletions apps/demo-app/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
OnyxUserMenu,
type OnyxNavButtonProps,
} from "sit-onyx";
import { ref, watch } from "vue";
import { ref, watch, type ComponentInstance } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import onyxLogo from "./assets/onyx-logo.svg";
import { useGridStore } from "./stores/grid-store";
Expand All @@ -30,7 +30,7 @@ const navItems = [
const { store: colorScheme } = useColorMode();
const navBarRef = ref<InstanceType<typeof OnyxNavBar>>();
const navBarRef = ref<ComponentInstance<typeof OnyxNavBar>>();
watch(
() => route.path,
Expand Down
6 changes: 3 additions & 3 deletions apps/playground/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { Repl } from "@vue/repl";
import Monaco from "@vue/repl/monaco-editor";
import { useDark } from "@vueuse/core";
import { OnyxAppLayout } from "sit-onyx";
import { computed, ref } from "vue";
import { computed, ref, type ComponentInstance } from "vue";
import TheHeader from "./components/TheHeader.vue";
import { useStore } from "./composables/useStore";
const { store, onyxVersion, isLoadingOnyxVersions } = useStore();
const replRef = ref<InstanceType<typeof Repl>>();
const replRef = ref<ComponentInstance<typeof Repl>>();
const reloadPage = () => {
replRef.value?.reload();
store.reloadLanguageTools?.();
Expand All @@ -18,7 +18,7 @@ const reloadPage = () => {
const isDark = useDark();
const theme = computed(() => (isDark.value ? "dark" : "light"));
const previewOptions = computed<InstanceType<typeof Repl>["previewOptions"]>(() => {
const previewOptions = computed<ComponentInstance<typeof Repl>["previewOptions"]>(() => {
return {
headHTML: `<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/sit-onyx@${onyxVersion.value}/dist/style.css' />
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/sit-onyx@${onyxVersion.value}/src/styles/global.css' />
Expand Down
58 changes: 58 additions & 0 deletions packages/sit-onyx/src/components/OnyxMoreList/OnyxMoreList.ct.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test } from "../../playwright/a11y";
import TestWrapperCt from "./TestWrapper.ct.vue";
import type { MoreListSlotBindings } from "./types";

test("should render", async ({ mount, makeAxeBuilder }) => {
const events: MoreListSlotBindings[] = [];

const eventHandlers = {
onVisibilityChange: (data: MoreListSlotBindings) => events.push(data),
};

// ARRANGE
const component = await mount(TestWrapperCt, {
props: {
count: 2.5,
},
on: eventHandlers,
});

const expectVisible = (label: string) => {
return expect(component.getByLabel(label, { exact: true })).toBeInViewport({
ratio: 1,
});
};

const expectHidden = (label: string) => {
return expect(component.getByLabel(label, { exact: true })).toBeHidden();
};

// ACT
const accessibilityScanResults = await makeAxeBuilder().analyze();

// ASSERT
expect(accessibilityScanResults.violations).toEqual([]);
await expectVisible("Element 1");
await expectVisible("Element 2");
await expectHidden("Element 3");
expect(events.at(-1)).toStrictEqual({ visibleElements: 2, hiddenElements: 22 });

// ACT
await component.update({ props: { count: 1 }, on: eventHandlers });

// ASSERT
await expectVisible("Element 1");
await expectHidden("Element 2");
expect(events.at(-1)).toStrictEqual({ visibleElements: 1, hiddenElements: 23 });

// ACT
await component.update({ props: { count: 4.25 }, on: eventHandlers });

// ASSERT
await expectVisible("Element 1");
await expectVisible("Element 2");
await expectVisible("Element 3");
await expectVisible("Element 4");
await expectHidden("Element 5");
expect(events.at(-1)).toStrictEqual({ visibleElements: 4, hiddenElements: 20 });
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import { h } from "vue";
import OnyxNavButton from "../OnyxNavBar/modules/OnyxNavButton/OnyxNavButton.vue";
import { NAV_BAR_MORE_LIST_INJECTION_KEY } from "../OnyxNavBar/types";
import OnyxMoreList from "./OnyxMoreList.vue";

/**
* Support component for rendering a horizontal list of components with a "+ more" indicator.
* If using custom or not natively supported components, make sure to implement the `useMoreChild()` composable in all child components.
*/
const meta: Meta<typeof OnyxMoreList> = {
title: "Support/MoreList",
component: OnyxMoreList,
argTypes: {
default: { control: { disable: true } },
more: { control: { disable: true } },
},
};

export default meta;
type Story = StoryObj<typeof OnyxMoreList>;

export const Default = {
args: {
is: "div",
injectionKey: NAV_BAR_MORE_LIST_INJECTION_KEY,
default: () =>
Array.from({ length: 24 }, (_, index) => h(OnyxNavButton, { label: `Element ${index + 1}` })),
more: ({ hiddenElements }) => h("div", `+${hiddenElements} more`),
},
} satisfies Story;
70 changes: 70 additions & 0 deletions packages/sit-onyx/src/components/OnyxMoreList/OnyxMoreList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script lang="ts" setup>
import { provide, reactive, ref, toRef, watch, type Ref } from "vue";
import { useMoreList, type HTMLOrInstanceRef } from "../../composables/useMoreList";
import type { MoreListSlotBindings, OnyxMoreListProps } from "./types";
const props = defineProps<OnyxMoreListProps>();
const emit = defineEmits<{
/**
* Emitted when the number of visible elements changes.
*/
visibilityChange: [MoreListSlotBindings];
}>();
defineSlots<{
/**
* List of components to render. Each child must implement the `useMoreChild()` composable.
*/
default(): unknown;
/**
* Slot to display at the end if not all default slot elements fit in the available width.
*/
more(props: MoreListSlotBindings): unknown;
}>();
const parentRef = ref<HTMLOrInstanceRef>();
const componentRefs = reactive(new Map<string, Ref<HTMLOrInstanceRef>>());
const disabled = toRef(props, "disabled");
const more = useMoreList({ parentRef, componentRefs, disabled });
// eslint-disable-next-line vue/no-setup-props-reactivity-loss -- provide does not support reactive symbols, this reactivity loss is mentioned in the property docs
provide(props.injectionKey, {
components: componentRefs,
visibleElements: more.visibleElements,
disabled,
});
watch([more.visibleElements, more.hiddenElements], ([visibleElements, hiddenElements]) => {
emit("visibilityChange", {
visibleElements: visibleElements.length,
hiddenElements: hiddenElements.length,
});
});
</script>

<template>
<component :is="props.is" ref="parentRef" class="onyx-more">
<slot></slot>
<slot
v-if="more.hiddenElements.value.length > 0"
name="more"
:hidden-elements="more.hiddenElements.value.length"
:visible-elements="more.visibleElements.value.length"
></slot>
</component>
</template>

<style lang="scss">
@use "../../styles/mixins/layers.scss";
.onyx-more {
@include layers.component() {
display: flex;
align-items: center;
gap: var(--onyx-spacing-4xs);
overflow-x: clip;
}
}
</style>
47 changes: 47 additions & 0 deletions packages/sit-onyx/src/components/OnyxMoreList/TestWrapper.ct.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script lang="ts" setup>
import OnyxNavButton from "../OnyxNavBar/modules/OnyxNavButton/OnyxNavButton.vue";
import { NAV_BAR_MORE_LIST_INJECTION_KEY } from "../OnyxNavBar/types";
import OnyxMoreList from "./OnyxMoreList.vue";
import type { MoreListSlotBindings } from "./types";
const props = defineProps<{
/**
* Number of components to show. Can also be decimal.
*/
count?: number;
}>();
const emit = defineEmits<{
visibilityChange: [value: MoreListSlotBindings];
}>();
const COMPONENT_WIDTH = "8rem";
</script>

<template>
<OnyxMoreList
is="ul"
class="list"
role="menu"
:injection-key="NAV_BAR_MORE_LIST_INJECTION_KEY"
:style="{
width: props.count ? `calc(${props.count} * ${COMPONENT_WIDTH})` : undefined,
}"
@visibility-change="emit('visibilityChange', $event)"
>
<OnyxNavButton
v-for="i in 24"
:key="i"
:label="`Element ${i}`"
:style="{ minWidth: COMPONENT_WIDTH }"
/>
</OnyxMoreList>
</template>

<!-- eslint-disable-next-line vue-scoped-css/enforce-style-type -->
<style scoped>
.list {
padding: 0;
gap: 0;
}
</style>
29 changes: 29 additions & 0 deletions packages/sit-onyx/src/components/OnyxMoreList/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { MoreListInjectionKey } from "../../composables/useMoreList";

export type OnyxMoreListProps = {
/**
* Component to render (e.g. `<ul>` or `<div>`).
*/
is: string;
/**
* Injection key to use. Must match the one used in the child components.
* Will not be reactive so it must not be changed.
*/
injectionKey: MoreListInjectionKey;
/**
* Whether the intersection observer should be disabled (e.g. when more feature is currently not needed due to mobile layout).
* Can increase performance.
*/
disabled?: boolean;
};

export type MoreListSlotBindings = {
/**
* Number of currently fully visible elements.
*/
visibleElements: number;
/**
* Number of currently completely or partially hidden elements.
*/
hiddenElements: number;
};
2 changes: 1 addition & 1 deletion packages/sit-onyx/src/components/OnyxNavBar/OnyxNavBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ defineExpose({
*
* ```ts
* const route = useRoute();
* const navBarRef = ref<InstanceType<typeof OnyxNavBar>>();
* const navBarRef = ref<ComponentInstance<typeof OnyxNavBar>>();
*
* watch(() => route.path, () => navBarRef.value?.closeMobileMenus());
* ```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import chevronRightSmall from "@sit-onyx/icons/chevron-right-small.svg?raw";
import { computed, inject, toRef } from "vue";
import { MANAGED_SYMBOL, useManagedState } from "../../../../composables/useManagedState";
import { useMoreListChild } from "../../../../composables/useMoreList";
import OnyxExternalLinkIcon from "../../../OnyxExternalLinkIcon/OnyxExternalLinkIcon.vue";
import OnyxIcon from "../../../OnyxIcon/OnyxIcon.vue";
import { MOBILE_NAV_BAR_INJECTION_KEY } from "../../types";
import { MOBILE_NAV_BAR_INJECTION_KEY, NAV_BAR_MORE_LIST_INJECTION_KEY } from "../../types";
import NavButtonLayout from "./NavButtonLayout.vue";
import type { OnyxNavButtonProps } from "./types";
Expand Down Expand Up @@ -38,6 +39,7 @@ const slots = defineSlots<{
const isMobile = inject(MOBILE_NAV_BAR_INJECTION_KEY);
const hasChildren = computed(() => !!slots.children);
const { componentRef, isVisible } = useMoreListChild(NAV_BAR_MORE_LIST_INJECTION_KEY);
const { state: mobileChildrenOpen } = useManagedState(
toRef(() => props.mobileChildrenOpen),
Expand All @@ -56,12 +58,14 @@ const handleParentClick = (event: MouseEvent) => {

<template>
<NavButtonLayout
ref="componentRef"
v-bind="props"
v-model:mobile-children-open="mobileChildrenOpen"
class="onyx-nav-button"
:class="{
'onyx-nav-button--mobile': isMobile,
'onyx-nav-button--active': props.active,
'onyx-nav-button--hidden': !isMobile && !isVisible,
}"
:is-mobile="isMobile ?? false"
>
Expand Down Expand Up @@ -105,6 +109,10 @@ $border-radius: var(--onyx-radius-sm);
$gap: var(--onyx-spacing-2xs);
list-style: none;
&--hidden {
visibility: hidden;
}
&__trigger {
display: inline-flex;
position: relative;
Expand Down
3 changes: 3 additions & 0 deletions packages/sit-onyx/src/components/OnyxNavBar/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ComputedRef, InjectionKey } from "vue";
import type { MoreListInjectionKey } from "../../composables/useMoreList";
import type { OnyxBreakpoint } from "../../types";
import type { OnyxNavAppAreaProps } from "../OnyxNavAppArea/types";

Expand Down Expand Up @@ -29,3 +30,5 @@ export type OnyxNavBarProps = Omit<OnyxNavAppAreaProps, "label"> & {
* @returns `true` if mobile, `false` otherwise
*/
export const MOBILE_NAV_BAR_INJECTION_KEY = Symbol() as InjectionKey<ComputedRef<boolean>>;

export const NAV_BAR_MORE_LIST_INJECTION_KEY = Symbol() as MoreListInjectionKey;
14 changes: 12 additions & 2 deletions packages/sit-onyx/src/components/OnyxSelect/OnyxSelect.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<script lang="ts" setup generic="TValue extends SelectOptionValue = SelectOptionValue">
import { createComboBox, type ComboboxAutoComplete } from "@sit-onyx/headless";
import { computed, nextTick, ref, toRef, toRefs, useId, watch, watchEffect } from "vue";
import {
computed,
nextTick,
ref,
toRef,
toRefs,
useId,
watch,
watchEffect,
type ComponentInstance,
} from "vue";
import type { ComponentExposed } from "vue-component-type-helpers";
import { useCheckAll } from "../../composables/checkAll";
import { useDensity } from "../../composables/density";
Expand Down Expand Up @@ -124,7 +134,7 @@ const selectionLabels = computed(() => {
}, []);
});
const miniSearch = ref<InstanceType<typeof OnyxMiniSearch>>();
const miniSearch = ref<ComponentInstance<typeof OnyxMiniSearch>>();
const selectInput = ref<ComponentExposed<typeof OnyxSelectInput>>();
const filteredOptions = computed(() => {
Expand Down
Loading

0 comments on commit 4db5e6d

Please sign in to comment.