Skip to content
On this page

TypeScript usage

Composable

With internal ref list generator

Code
<script setup lang="ts">
import { useIncrementalList } from "../index";
import ResultBox from "../ResultBox.vue";

interface Example {
  value: string;
}

const model = (): Example => ({ value: "" });

const list = useIncrementalList<Example>({
  model,
  initialValue: [{ value: "Hello" }, { value: "World" }],
});
const { shownItems, clearEmpty } = list;
</script>

<template>
  <ResultBox>
    <div v-for="(item, index) in shownItems" :key="index">
      <label>
        <input v-model="item.value" @blur="clearEmpty()" />
      </label>
    </div>
    <template #debug="{ format }">{{ format(list) }}</template>
  </ResultBox>
</template>
Demo
Debug
{
  "shownItems": [
    {
      "value": "Hello"
    },
    {
      "value": "World"
    },
    {
      "value": ""
    }
  ],
  "clearEmpty": "[Function: clearEmpty]",
  "empty": [],
  "newItem": {
    "value": ""
  },
  "removeItems": "[Function: removeItems]",
  "items": [
    {
      "value": "Hello"
    },
    {
      "value": "World"
    }
  ]
}

With externally created ref list

Code
<script setup lang="ts">
import { ref } from "vue";
import { useIncrementalList } from "../index";
import ResultBox from "../ResultBox.vue";

interface Example {
  value: string;
}

const model = (): Example => ({ value: "" });
const items = ref<Example[]>([{ value: "Hello" }, { value: "World" }]);

const list = useIncrementalList<Example>({
  model,
  items,
});
const { shownItems, clearEmpty } = list;
</script>

<template>
  <ResultBox>
    <div v-for="(item, index) in shownItems" :key="index">
      <label>
        <input v-model="item.value" @blur="clearEmpty()" />
      </label>
    </div>
    <template #debug="{ format }">{{ format({ items, list }) }}</template>
  </ResultBox>
</template>
Demo
Debug
{
  "items": [
    {
      "value": "Hello"
    },
    {
      "value": "World"
    }
  ],
  "list": {
    "shownItems": [
      {
        "value": "Hello"
      },
      {
        "value": "World"
      },
      {
        "value": ""
      }
    ],
    "empty": [],
    "newItem": {
      "value": ""
    }
  }
}

Component

Simple usage

Code
<script setup lang="ts">
import { ref } from "vue";
import { useIncrementalListComponent } from "../index";
import ResultBox from "../ResultBox.vue";

interface Example {
  value: string;
}

const model = (): Example => ({ value: "" });
const value = ref<Example[]>([]);

const ExampleList = useIncrementalListComponent<Example>(model);
</script>

<template>
  <ResultBox>
    <ExampleList v-model="value">
      <template #item="{ item, isNew, remove, clearEmpty }">
        <div>
          <label>
            <input v-model="item.value" @blur="clearEmpty()" />
          </label>
          <button tabindex="-1" v-if="!isNew" @click="remove()">Remove</button>
        </div>
      </template>
    </ExampleList>
    <template #debug="{ format }">{{ format(value) }}</template>
  </ResultBox>
</template>
Demo
Debug
{}

Using different template for new items

Code
<script setup lang="ts">
import { ref } from "vue";
import { useIncrementalListComponent } from "../index";
import ResultBox from "../ResultBox.vue";

interface Example {
  name: string;
  nickname: string;
  type: "Person" | "Company";
}

const model = (): Example => ({ file: "", label: "" });
const value = ref<Example[]>([]);

const ExampleList = useIncrementalListComponent<Example>(model);

const addFile = (item: Example, event: Event) => {
  console.log(event);
  const { files } = event.target as HTMLInputElement;
  if (!files.length) return;
  const [file] = files;
  Object.assign(item, {
    file: file.name,
    label: file.name,
  });
};
</script>

<template>
  <ResultBox>
    <ExampleList v-model="value">
      <template #item="{ item, isNew, remove }">
        <div>
          <label>{{ item.type }} name: <input v-model="item.name" /></label>
          <label v-if="item.type === 'Person'">
            Nickname: <input v-model="item.nickname" />
          </label>
          <button tabindex="-1" v-if="!isNew" @click="remove()">Remove</button>
        </div>
      </template>
      <template #newItem="{ item }">
        <div>
          <label>
            Add:
            <select placeholder="select" v-model="item.type">
              <option disabled value="">Select</option>
              <option value="Person">Person</option>
              <option value="Company">Company</option>
            </select>
          </label>
        </div>
      </template>
    </ExampleList>
    <template #debug="{ format }">{{ format({ value }) }}</template>
  </ResultBox>
</template>
Demo
Debug
{
  "value": []
}

Nested lists

Code
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { useIncrementalListComponent } from "../index";
import ResultBox from "../ResultBox.vue";

type PetType = "cat" | "dog" | "bird";
interface Pet {
  name: string;
  type: PetType | null;
}
interface Person {
  name: string;
  pets: Pet[];
}

const petModel = (): Pet => ({ name: "", type: null });
const personModel = (): Person => ({ name: "", pets: [] });

interface PetTypeOption {
  value: PetType;
  label: string;
}
const petTypes: PetTypeOption[] = [
  { value: "cat", label: "Gato" },
  { value: "dog", label: "Cachorro" },
  { value: "bird", label: "Ave" },
];

const people = ref<Person[]>([
  {
    name: "Magali",
    pets: [{ name: "Mingau", type: "cat" }],
  },
  {
    name: "Cebolinha",
    pets: [{ name: "Floquinho", type: "dog" }],
  },
  {
    name: "Mônica",
    pets: [{ name: "Monicão", type: "dog" }],
  },
]);

const PersonIncrementalList = useIncrementalListComponent<Person>(personModel);
const PetIncrementalList = useIncrementalListComponent<Pet>(petModel);
</script>

<template>
  <ResultBox>
    <PersonIncrementalList v-model="people">
      <template #item="personRow">
        <div class="row">
          <div class="col">
            <div>
              <label>
                Nome:
                <input
                  v-model="personRow.item.name"
                  @blur="personRow.clearEmpty()"
                />
              </label>
            </div>
            <div class="text-right" v-if="!personRow.isNew">
              <button tabindex="-1" @click="personRow.remove()">Remover</button>
            </div>
          </div>
          <template v-if="!personRow.isNew">
            <div class="col pets">
              <PetIncrementalList
                v-model="personRow.item.pets"
                @itemsRemoved="personRow.clearEmpty()"
              >
                <template #item="petRow">
                  <div>
                    <label>
                      Pet:
                      <input
                        v-model="petRow.item.name"
                        @blur="petRow.clearEmpty()"
                      />
                    </label>
                    <label>
                      Type:
                      <select v-model="petRow.item.type">
                        <option
                          v-for="{ value, label } in petTypes"
                          :key="value"
                          :value="value"
                        >
                          {{ label }}
                        </option>
                      </select>
                    </label>
                    <button
                      tabindex="-1"
                      @click="
                        async () => {
                          petRow.remove();
                          await nextTick();
                          personRow.clearEmpty();
                        }
                      "
                    >
                      Remover
                    </button>
                  </div>
                </template>
                <template #newItem="petRow">
                  <div>
                    <label>Pet: <input v-model="petRow.item.name" /></label>
                  </div>
                </template>
              </PetIncrementalList>
            </div>
          </template>
        </div>
        <hr />
      </template>
    </PersonIncrementalList>
    <template #debug="{ format }">{{ format({ people }) }}</template>
  </ResultBox>
</template>

<style scoped>
.row {
  padding: 8px 0;
  display: flex;
  flex-wrap: nowrap;
}
.col.pets {
  flex: 1;
  padding-left: 8px;
}
.text-right {
  text-align: right;
  padding-right: 16px;
}
label {
  display: inline-block;
  padding: 8px;
  padding-right: 16px;
}
input,
select {
  display: block;
}
hr.vl {
  flex: 0 1 1px;
}
.index {
  display: inline-block;
  font-weight: bold;
  white-space: nowrap;
  width: 48px;
}
</style>
Demo




Debug
{
  "people": [
    {
      "name": "Magali",
      "pets": [
        {
          "name": "Mingau",
          "type": "cat"
        }
      ]
    },
    {
      "name": "Cebolinha",
      "pets": [
        {
          "name": "Floquinho",
          "type": "dog"
        }
      ]
    },
    {
      "name": "Mônica",
      "pets": [
        {
          "name": "Monicão",
          "type": "dog"
        }
      ]
    }
  ]
}

Extra

ResultBox

All examples above use this component to facilitate the demo exhibition. For reference, this is it's code:

Code
<script setup lang="ts">
const format = (payload: object): object =>
  Object.fromEntries(
    Object.entries(payload).map(([key, item]) => {
      if (typeof item === "function") return [key, `[Function: ${item.name}]`];
      return [key, item];
    })
  );
</script>

<template>
  <details>
    <summary>Demo</summary>
    <div class="box">
      <slot></slot>
      <details>
        <summary>Debug</summary>
        <pre align="left"><slot name="debug" :format="format"></slot></pre>
      </details>
    </div>
  </details>
</template>

<style scoped>
.box {
  margin-top: 16px;
  padding: 16px;
  border-radius: 8px;
  border: 1px solid currentColor;
}

.box:deep(> *) {
  padding: 4px 0px;
}

.box:deep(label) {
  padding: 8px;
  margin: 8px 0px;
}

.box:deep(input),
.box:deep(select),
.box:deep(button) {
  border: 1px solid currentColor;
  border-radius: 4px;
  padding: 0px 8px;
}
.box:deep(button) {
  margin-left: 8px;
  color: red;
  border: 1px solid currentColor;
  padding: 0px 8px;
}
</style>