vue 3 - how to create slots for every item in a list component so consumer components can render content as they like?

203 Views Asked by At

I've built a list component that renders items in a tree-like outline. It receives an array of items. Every item is an object with multiple properties. The text property of the item is used to render the item's content on the user screen.

So today, if I want to render a list in a page-items components, I can do this:

  <list-base :items="[{id: 'a', text: 'a'}, {id: 'b', text: 'b', parentId: 'a'}]" />

I want to upgrade the experience so the item content can be freely customized using vue slots. like this:

  <list-base :items="[{id: 'a', text: 'a'}, {id: 'b', text: 'b', parentId: 'a', meta: new Date()}]">
    <template v-slot:item={item}>
       {{ item.text }} <fancy-component v-if="item.meta">{{ item.meta.toISOString()}}</fancy-component>
    </template>
  </list-base>

I'm familiar with the fancy list approach covered in the official docs and also in this question but I can't make that work in my case. I suppose because of the recursive tree structure of components.

Can anyone help?

Below is the current code for the list-base component. It is based on tree components: list-base, list-tree and list-item.

list-base.vue

<script setup>
import ListTree from "./list-tree.vue";

defineProps({
  items: {
    type: Array,
    default: () => [],
  },
  draggable: {
    type: Boolean,
    default: false,
  },
  editable: {
    type: Boolean,
    default: false,
  },
});
defineEmits(["action", "drag", "edit"]);
</script>
<template>
  <div>
    <template v-for="item in items" :key="item.value">
      <list-tree
        :root="item"
        :draggable="draggable"
        :editable="editable"
        @action="$emit('action', $event)"
        @drag="$emit('drag', $event)"
        @edit="$emit('edit', $event)"
      />
    </template>
  </div>
</template>

list-tree.vue

<script setup>
import { toRef } from "vue";
import { hasElements, ActionsMenu } from "@lib";
import { COLLAPSE_STATUSES, useCollapse } from "./collapse";
import CollapseSwitch from "./collapse-switch.vue";
import ListItem from "./list-item.vue";

const props = defineProps({
  root: {
    type: Object,
    default: () => ({}),
  },
  draggable: {
    type: Boolean,
    default: false,
  },
  editable: {
    type: Boolean,
    default: false,
  },
});
defineEmits(["action", "drag", "edit"]);

const { collapse, handleStart, handleEnd } = useCollapse(toRef(props, "root"));
</script>
<template>
  <div class="list-tree">
    <div class="list-root-wrapper">
      <collapse-switch v-model="collapse" />
      <list-item
        :item="root"
        :draggable="draggable"
        :editable="editable"
        @drag-start="handleStart"
        @drag-end="handleEnd"
        @action="$emit('action', $event)"
        @drag="$emit('drag', $event)"
        @edit="$emit('edit', $event)"
      />
      <template v-if="hasElements(root.actions)">
        <actions-menu
          :actions="root.actions"
          @action="$emit('action', { item: root.value, action: $event })"
        />
      </template>
    </div>
    <template v-if="collapse === COLLAPSE_STATUSES.OPEN">
      <div class="list-tree-children">
        <template v-for="child in root.children" :key="child.value">
          <list-tree
            :root="child"
            :draggable="draggable"
            :editable="editable"
            @action="$emit('action', $event)"
            @drag="$emit('drag', $event)"
            @edit="$emit('edit', $event)"
          />
        </template>
      </div>
    </template>
  </div>
</template>
<style scoped>
.list-root-wrapper {
  display: flex;
  align-items: center;
}

.list-tree-children {
  padding-inline-start: var(--size-20);
}
</style>

list-item.vue

<script setup>
import { computed } from "vue";
import { TooltipBase } from "../tooltip-base";
import { useDrag } from "./use-drag";

const props = defineProps({
  item: {
    type: Object,
    default: () => ({}),
  },
  draggable: {
    type: Boolean,
    default: false,
  },
  editable: {
    type: Boolean,
    default: false,
  },
});
const emit = defineEmits(["drag", "drag-start", "drag-end", "edit"]);

const { handlers, dragClasses } = useDrag({ value: props.item.value, emit });

const classes = computed(() => ({
  ...dragClasses,
  inactive: props.item.inactive,
}));

const handleBlur = (e) => {
  const text = e.target.textContent;
  if (text === props.item.text) return;
  emit("edit", { item: props.item.value, text });
};
const handleEnter = (e) => e.target.blur();
</script>
<template>
  <div
    v-bind="$attrs"
    :id="item.value"
    class="list-item"
    :class="classes"
    :draggable="draggable"
    :contenteditable="editable ? 'plaintext-only' : 'false'"
    @dragstart="handlers.start"
    @dragenter="handlers.enter"
    @dragleave="handlers.leave"
    @dragover="handlers.over"
    @drop="handlers.drop"
    @dragend="handlers.end"
    @blur="handleBlur"
    @keydown.enter.prevent="handleEnter"
  >
    {{ item.text }}
  </div>
  <tooltip-base v-if="item.tooltip" :anchor="item.value" :text="item.tooltip" />
</template>
<style scoped>
.list-item {
  --list-item-drag-border-color: var(--color-neutral-40);
}

.list-item {
  display: flex;
  flex-grow: 1;
  padding-block: var(--size-15);
  padding-inline-start: var(--size-10);
  border: var(--border-size-20) solid transparent;
}

.list-item.inactive {
  color: var(--color-neutral-40);
  text-decoration: line-through;
}

.list-item:hover,
.list-item:focus-within,
.list-item.over {
  background-color: var(--background-color-highlight);
}

.list-item.over.top {
  border-top-color: var(--list-item-drag-border-color);
}

.list-item.over.middle {
  border-color: var(--list-item-drag-border-color);
}

.list-item.over.bottom {
  border-bottom-color: var(--list-item-drag-border-color);
}
</style>
1

There are 1 best solutions below

0
Alexander Nenashev On

Just make nested slots. Use any slot names you like and bind data to the intermediate slots if needed.

list-item:

<slot v-bind="{item}">{{ item.text }}</slot>

list-tree:

<list-item ...><slot name="item"/></list-item>

list-base:

<list-tree ...><template #item><slot name="item"/></template></list-tree>