Destiner's notes

Adding File Selector to Sandpack

8 February 2022

This guide assumes that you already set up Sandpack to work with a text editor of your choice. If that’s not the case, you can check out this article for guidance on that.

Code samples here are written for Vue, although the general principle applies to other frameworks as well.

Starting point

We will start with this component. It provides a Sandpack instance updated by the changes in the textarea:

<template>
  <div class="sandbox">
    <textarea v-model="code" />
    <iframe ref="iframeEl" />
  </div>
</template>

<script setup>
  import { SandpackClient } from '@codesandbox/sandpack-client';
  import { onMounted, ref, watch } from 'vue';

  const iframeEl = ref(null);
  const code = ref(`
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Demo</title>
  </head>
  <body>
    <h1>Hello, Sandpack</h1>
  </body>
</html>`);

  let client;

  onMounted(() => {
    client = new SandpackClient(iframeEl.value, {
      files: {
        '/index.html': {
          code: code.value,
        },
      },
      dependencies: {},
      entry: '/index.html',
    });
  });

  watch(code, (newCode) => {
    client.updatePreview({
      files: {
        '/index.html': {
          code: newCode,
        },
      },
      dependencies: {},
      entry: '/index.html',
    });
  });
</script>

<style scoped>
  .sandbox {
    display: flex;
  }

  textarea {
    width: 500px;
    height: 300px;
    border: 1px solid #333;
  }

  iframe {
    width: 500px;
    height: 300px;
    border: 1px solid #333;
  }
</style>

Layout

First, update the component template by adding a file tab list above the textarea:

<template>
  <div class="sandbox">
    <div class="editor">
      <div class="file-tabs">
        <button class="file-tab active">File</button>
      </div>
      <textarea v-model="code" />
    </div>
    <iframe ref="iframeEl" />
  </div>
</template>

Styling

Provide some styling for the file tabs:

.editor {
  border: 1px solid #333;
}

.file-tabs {
  width: 500px;
  height: 30px;
  display: flex;
  border-bottom: 1px solid #ccc;
  margin-right: 0px;
}

.file-tab {
  border: none;
  background: white;
  width: 80px;
  font-size: 14px;
  color: #999;
}

.file-tab:hover {
  background: #eee;
}

.file-tab.active {
  border-bottom: 1px solid darkgreen;
  margin-bottom: -1px;
  color: black;
}

textarea {
  width: 500px;
  height: 262px;
  border: none;
  padding: 0;
  font-family: 'Courier New', Monaco, Courier, monospace;
  font-size: 14px;
}

Data

Now, let’s update the way we store the file source so that we can support the multi-file feature.

const files = ref([
  {
    name: `/index.html`,
    value: `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Demo</title>
  </head>
  <body>
    <h1>Hello, Sandpack</h1>
  </body>
</html>`,
  },
]);
const activeFile = ref(0);
const code = computed(() => files.value[activeFile.value].value);

Update the Client initialization and the update function:

onMounted(() => {
  client = new SandpackClient(iframeEl.value, {
    files: Object.fromEntries(
      files.value.map((file) => [
        file.name,
        {
          code: file.value,
        },
      ]),
    ),
    dependencies: {},
    entry: '/index.html',
  });
});

watch(code, (newCode) => {
  client.updatePreview({
    files: Object.fromEntries(
      files.value.map((file) => [
        file.name,
        {
          code: file.value,
        },
      ]),
    ),
    dependencies: {},
    entry: '/index.html',
  });
});

Add an event handler for the textarea to update the files variable:

function handleCodeChange(e) {
  const newCode = e.target.value;
  files.value[activeFile.value].value = newCode;
}

Update the textarea element:

<textarea :value="code" @input="handleCodeChange" />

Finally, update the file tab template to render and apply active class based on the state:

<div class="file-tabs">
  <button
    v-for="(file, index) in files"
    class="file-tab"
    :class="{ active: index === activeFile }"
  >
    {{ file.name }}
  </button>
</div>

Switching between files

Now that we have everything ready, let’s finalize by making it possible to switch between files.

Add another file for test purposes:

const files = ref([
  {
    name: `/index.html`,
    value: `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Demo</title>
  </head>
  <body>
    <h1>Hello, Sandpack</h1>
  </body>
</html>`,
  },
  {
    name: '/demo.html',
    value: `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Demo</title>
  </head>
  <body>
    <h1>Hello, Demo</h1>
  </body>
</html>`,
  },
]);

Add the file index change handler:

function handleFileChange(i: number) {
  activeFile.value = i;
}

Add this handler to the button template:

<button
  v-for="(file, index) in files"
  class="file-tab"
  :class="{ active: index === activeFile }"
  @click="(_e) => handleFileChange(index)"
>
  {{ file.name }}
</button>

Congrats! You should have a working file selector by now.

Here’s the full source code of our component:

<template>
  <div class="sandbox">
    <div class="editor">
      <div class="file-tabs">
        <button
          v-for="(file, index) in files"
          class="file-tab"
          :class="{ active: index === activeFile }"
          @click="(_e) => handleFileChange(index)"
        >
          {{ file.name }}
        </button>
      </div>
      <textarea :value="code" @input="handleCodeChange" />
    </div>
    <iframe ref="iframeEl" />
  </div>
</template>

<script setup>
  import { SandpackClient } from '@codesandbox/sandpack-client';
  import { onMounted, ref, watch, computed } from 'vue';

  const iframeEl = ref(null);

  const files = ref([
    {
      name: `/index.html`,
      value: `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Demo</title>
  </head>
  <body>
    <h1>Hello, Sandpack</h1>
  </body>
</html>`,
    },
    {
      name: '/demo.html',
      value: `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Demo</title>
  </head>
  <body>
    <h1>Hello, Demo</h1>
  </body>
</html>`,
    },
  ]);
  const activeFile = ref(0);
  const code = computed(() => files.value[activeFile.value].value);

  let client;

  onMounted(() => {
    client = new SandpackClient(iframeEl.value, {
      files: Object.fromEntries(
        files.value.map((file) => [
          file.name,
          {
            code: file.value,
          },
        ]),
      ),
      dependencies: {},
      entry: '/index.html',
    });
  });

  watch(code, (newCode) => {
    client.updatePreview({
      files: Object.fromEntries(
        files.value.map((file) => [
          file.name,
          {
            code: file.value,
          },
        ]),
      ),
      dependencies: {},
      entry: '/index.html',
    });
  });

  function handleCodeChange(e) {
    const newCode = e.target.value;
    files.value[activeFile.value].value = newCode;
  }

  function handleFileChange(i) {
    activeFile.value = i;
  }
</script>

<style scoped>
  .sandbox {
    display: flex;
  }

  .editor {
    border: 1px solid #333;
  }

  .file-tabs {
    width: 500px;
    height: 30px;
    display: flex;
    border-bottom: 1px solid #ccc;
    margin-right: 0px;
  }

  .file-tab {
    border: none;
    background: white;
    width: 80px;
    font-size: 14px;
    color: #999;
  }

  .file-tab:hover {
    background: #eee;
  }

  .file-tab.active {
    border-bottom: 1px solid darkgreen;
    margin-bottom: -1px;
    color: black;
  }

  textarea {
    width: 500px;
    height: 262px;
    border: none;
    padding: 0;
    font-family: 'Courier New', Monaco, Courier, monospace;
    font-size: 14px;
  }

  iframe {
    width: 500px;
    height: 300px;
    border: 1px solid #333;
  }
</style>

Next steps

From here, you can add more file-related features, like hidden files, being able to close files, making files read-only, and more.