SchwarzIT / onyx

🚀 A design system and Vue.js component library created by Schwarz IT
https://onyx.schwarz
Apache License 2.0
47 stars 5 forks source link

Implement "more" feature to OnyxNavBar #986

Open MajaZarkova opened 3 months ago

MajaZarkova commented 3 months ago

Open Questions/To-dos

Should the moreFlyout have the same design as navItem? Yes

Depends on

Why?

This component should be used inside the navigationBar. While resizing the navItems should be grouped inside one flyout

Design

Acceptance criteria

Definition of Done

Approval

Implementation details

Use grouped listbox when the navItems are combined into one (while resizing)

larsrickert commented 3 months ago

Reference implementation for a chip list that we can re-use:

<script lang="ts" setup>
import { ScuChip } from '@scu/vue';
import { useIntersectionObserver } from '@vueuse/core';
import { computed, onBeforeUnmount, ref, watchEffect } from 'vue';

export interface Chip {
  label: string;
  icon?: string;
}

const props = defineProps<{
  chips: Chip[];
  color?: 'dark';
  /**
   * If `true`, all chips are shown and will be wrapped to the next lines if they exceed the containers max width.
   * Otherwise only chips that fit into the container width are shown and a remaining items indicator is shown.
   */
  wrap?: boolean;
}>();

const wrapper = ref<HTMLDivElement | null>(null);

const visibleChips = ref(0);
const remainingChips = computed(() => props.chips.length - visibleChips.value);
const observer = ref<IntersectionObserver>();
onBeforeUnmount(() => observer.value?.disconnect());

watchEffect(() => {
  if (observer.value && props.chips.length) {
    observer.value.disconnect();
    const chips = Array.from(wrapper.value?.querySelectorAll('scu-chip') ?? []);
    chips.forEach((chip) => observer.value?.observe(chip));
  }
});

watchEffect(() => {
  if (!wrapper.value || props.wrap) return;
  observer.value?.disconnect();

  observer.value = new IntersectionObserver(
    (res) => {
      // res contains all changed chips (not all available chips)
      // if chip is shown, intersectionRatio is 1 so remainingItems should be decremented
      // otherwise remainingItems should be increment because chips is no longer shown
      const shownChips = res.reduce((prev, curr) => (curr.intersectionRatio === 1 ? prev + 1 : prev), 0);
      const hiddenChips = res.length - shownChips;

      if (visibleChips.value <= 0) visibleChips.value = shownChips;
      else visibleChips.value += shownChips - hiddenChips;
    },
    { root: wrapper.value, threshold: 1 }
  );
});

// the indicator is placed inside the chip-list to be positioned directly after the last shown chip.
// But this also means that the indicator will be hidden if it does not fit in the max width.
// To prevent this, we watch the indicator visibility and place it outside the chip-list if it would
// not be visible inside it.
const indicatorRef = ref<HTMLElement | null>(null);
let stopIndicatorObserver: Function | undefined;
onBeforeUnmount(() => stopIndicatorObserver?.());
const isIndicatorVisible = ref(true);

watchEffect(() => {
  if (!indicatorRef.value || props.wrap) return;
  stopIndicatorObserver?.();

  const { stop } = useIntersectionObserver(
    indicatorRef,
    (res) => {
      if (!res.length) return;
      isIndicatorVisible.value = res[0].intersectionRatio === 1;
    },
    { root: wrapper, threshold: 1 }
  );

  stopIndicatorObserver = stop;
});
</script>

<template>
  <div ref="wrapper" class="chip-list">
    <div class="chip-list__wrapper" :class="{ 'chip-list__wrapper--fixed': !wrap }">
      <template v-for="(chip, index) of chips" :key="chip.label">
        <ScuChip :icon="chip.icon" :label="chip.label" :class="{ dark: color === 'dark' }" />
        <!-- place indicator directly after the last visible chip -->
        <template v-if="chips.length - 1 - remainingChips === index">
          <!-- since the span has a v-if we would receive a proxy object if directly applying ref, so we use a function instead to get the raw html element -->
          <span
            :ref="($el) => ($el ? indicatorRef = $el as HTMLElement : undefined)"
            class="chip-list__remaining"
            v-if="!wrap && remainingChips > 0"
          >
            +{{ remainingChips }}
          </span>
        </template>
      </template>
    </div>

    <!-- display indicator here if it is not visible inside the chip-list directly -->
    <span class="chip-list__remaining" v-if="!wrap && remainingChips > 0 && !isIndicatorVisible">
      +{{ remainingChips }}
    </span>
  </div>
</template>

<style lang="scss" scoped>
@use '@/styles/variables.scss' as vars;

.chip-list {
  display: flex;
  align-items: center;
  gap: vars.$spacing-s;

  &__wrapper {
    display: flex;
    align-items: center;
    overflow: hidden;
    flex-flow: row wrap;
    gap: vars.$spacing-s;

    &--fixed {
      height: 32px;
    }

    scu-chip {
      &.dark {
        --color: #ffffff;
        --background-color: #464646;
      }
    }
  }

  &__remaining {
    font-size: 13px;
    color: var(--brand-secondary-6);
  }
}
</style>