useControlStateByUrl

This composable is used to control the state of a component based on the URL hash. It can be used to activate or deactivate a state based on the URL hash. For example, you can use it to activate a modal when the URL hash is #modal and deactivate it when the URL hash is empty.

Installation

To use the ControlStateByUrl component in your project, you can install it via npm.

npx rosalana-dev@latest add ControlStateByUrl

API

This composable returns an object with the following properties:

  • isActive: A computed ref that determines if the current state is active. It is active when the URL matches options.url or starts with options.prefix.
  • currentURL: A computed ref that returns the current URL without the prefix if it exists.
  • currentHash: A computed ref that returns the complete current URL hash.
  • activate(url?: string): A function that activates the state by updating the URL hash. It accepts an optional url parameter, which defaults to the URL provided in the options.
  • deactivate(): A function that deactivates the state by removing the URL hash.
  • go(url: string): A function that goes to a specific URL when the state is activated. It should not be used to activate or deactivate the state.

The composable accepts the following parameters:

  • options: An object with the following properties:
    • url: The URL to activate the state. When prefix is provided, this URL will be used as the default suffix.
    • prefix: The prefix to add to the URL. When provided, the state will be active when the URL starts with this prefix.
    • history: Use history mode to navigate between each state change in the browser history.

Hash can be made in two different variations:

  • #modal
  • #modal-tab1, #modal-tab2

When prefix is provided, the state will be active when the URL starts with this prefix. This is useful when you have a modal with multiple tabs, and you want to activate a specific tab.

Usage

We take the modal option as an example. We have a modal that we want to activate when the URL hash is #modal-${someTab}. We start the event by clicking a button that activates the hashState.

Example of the parent component:

<script setup lang="ts">
import { useControlStateByURL } from '@/composables';

const { isActive, activate, deactivate } = useControlStateByURL({
    url: 'tab1',
    prefix: 'modal'
});
</script>
<template>
    <div>
        <button @click="activate()">Open Modal</button>

        <MyModal v-if="isActive" @close="deactivate" />
    </div>
</template>

Example of the MyModal component:

<script setup lang="ts">

const emit = defineEmits(['close']);

const { isActive, go, currentURL } = useControlStateByURL({
    url: 'tab1',
    prefix: 'modal'
});

const close = () => {
    emit('close');
}

const handleTabChange = (tab: string) => {
    go(tab);
}
</script>
<template>
    <div v-if="isActive">
        <button @click="close">Close Modal</button>

        <button @click="handleTabChange('tab1')">Tab 1</button>
        <button @click="handleTabChange('tab2')">Tab 2</button>
        <button @click="handleTabChange('tab3')">Tab 3</button>
    </div>
</template>

In this example, we have a parent component that activates the modal when the button is clicked. The modal component listens to the state change and shows the modal when the state is active. It also has buttons to change the tab of the modal, which updates the URL hash.

Example

Here is an example of UiDialog from Ui-thing component that uses useControlStateByURL to control the state of the dialog based on the URL hash:

<template>
  <div class="flex w-full items-center justify-center">
    <UiDialog :open="isActive" @update:open="updateOpen">
      <UiDialogContent
        class="sm:max-w-[625px]"
        :title="`Server Settings - ${textToLabel(currentURL)}`"
        description="Manage your server settings here."
      >
        <template #content>
          <UiTabs
            orientation="vertical"
            :modelValue="currentURL"
            @update:modelValue="handleTabChange"
            class="w-full"
          >
            <div class="flex gap-3 h-full">
              <UiTabsList class="flex h-full flex-col w-32 p-2 justify-start bg-slate-100/50" :pill="false">
                <UiTabsTrigger class="data-[state=active]:bg-slate-200/80 w-full justify-start" :pill="false" value="general">General</UiTabsTrigger>
                <UiTabsTrigger class="data-[state=active]:bg-slate-200/80 w-full justify-start" :pill="false" value="network">Network</UiTabsTrigger>
                <UiTabsTrigger class="data-[state=active]:bg-slate-200/80 w-full justify-start" :pill="false" value="teams">Teams</UiTabsTrigger>
                <UiTabsTrigger class="data-[state=active]:bg-slate-200/80 w-full justify-start" :pill="false" value="billing">Billing</UiTabsTrigger>
                <UiTabsTrigger class="data-[state=active]:bg-slate-200/80 w-full justify-start" :pill="false" value="advanced">Advanced</UiTabsTrigger>
              </UiTabsList>
              <UiTabsContent class="flex-grow" value="general" id="settings-general">
                <div class="grid gap-4">
                  <div class="space-y-1">
                    <UiLabel for="serverTags" class="text-right">Tags</UiLabel>
                    <p class="text-xs text-slate-500">Tags are used to group servers together.</p>
                    <UiInput id="serverTags" :modelValue="server?.tags" class="col-span-3" />
                  </div>
                  <!-- Další pole pro obecná nastavení -->
                </div>
              </UiTabsContent>
              <UiTabsContent class="flex-grow" value="network" id="settings-network">
                <div class="grid gap-4">
                  <div class="space-y-1">
                    <UiLabel for="ipAddress" class="text-right">IP Address</UiLabel>
                    <p class="text-xs text-slate-500">Add an IP address or hostname to exisiting one.</p>
                    <UiInput class="col-span-3" placeholder="IP Address..." />
                  </div>
                  <!-- Další pole pro síťová nastavení -->
                </div>
              </UiTabsContent>
              <UiTabsContent class="flex-grow" value="teams" id="settings-teams">
                <div class="grid gap-4">
                  <div class="space-y-1">
                    <UiLabel for="Teams" class="text-right">Teams</UiLabel>
                    <p class="text-xs text-slate-500">Add teams to the server.</p>
                    <UiInput id="Teas" class="col-span-3" placeholder="TeamList..." />
                  </div>
                  <!-- Další pole pro nastavení -->
                </div>
              </UiTabsContent>
              <UiTabsContent class="flex-grow" value="billing" id="settings-billing">
                <div class="grid gap-4">
                  <div class="space-y-1">
                    <UiLabel for="billing" class="text-right">Billing</UiLabel>
                    <p class="text-xs text-slate-500">Add billing information.</p>
                    <UiInput id="billing" class="col-span-3" placeholder="Billing..." />
                  </div>
                  <!-- Další pole pro nastavení -->
                </div>
              </UiTabsContent>
              <UiTabsContent class="flex-grow" value="advanced" id="settings-advanced">
                <div class="grid gap-4">
                  <div class="space-y-1">
                    <UiButton variant="destructive">Detete Server</UiButton>
                  </div>
                  <!-- Další pole pro nastavení -->
                </div>
              </UiTabsContent>
            </div>
          </UiTabs>
        </template>
        <template #footer>
          <UiDialogFooter>
            <UiButton
              @click="closeDialog(false)"
              variant="outline"
              type="button"
              class="mt-2 sm:mt-0"
            >
              Cancel
            </UiButton>
            <UiButton @click="closeDialog(true)" type="submit">Save</UiButton>
          </UiDialogFooter>
        </template>
      </UiDialogContent>
    </UiDialog>
  </div>
</template>

<script lang="ts" setup>
import type { Server } from '~/types';

  const props = defineProps<{
    server: Server | null;
  }>();

  const emit = defineEmits(["close"]);

  const { isActive, go, currentURL } = useControlStateByURL({
    url: "general",
    prefix: "settings",
  });

  const handleTabChange = (val: string | number) => {
    go(String(val));
  };

  const closeDialog = (save: boolean) => {
    if (save) console.log("saving..."); // Save changes

    emit("close");
  };

  const updateOpen = (value: boolean) => {
    if (!value) {
      emit("close");
    }
  };
</script>

Code

/**
 * Controls state base on URL
 * @param options<ControlConfig>
 * @returns ControlReturn
 */

type ControlConfig = {
  /**
   * The URL to activate the state
   * When `prefix` is provided, this URL will be used as the default suffix
   */
  url: string;
  /**
   * The prefix to add to the URL
   * When provided, the state will be active when the URL starts with this prefix
   */
  prefix?: string;
  /**
   * Use history mode to navigate between each state change in browser history
   */
  history?: boolean;
};

type ControlReturn = {
    /**
   * Determine if current state is active
   * active when url match `options.url` or startsWith `options.prefix`
   */
  isActive: ComputedRef<boolean>;
  /**
   * Return the current URL without the prefix if it exists
   */
  currentURL: ComputedRef<string>;
  /**
   * Return the complete current URL hash
   */
  currentHash: ComputedRef<string>;
    /**
   * Activates the state by updating the URL hash
   * @param url - The URL to activate. Defaults to the URL provided in options.
   */
  activate: (url?: string) => void;
  /**
   * Go to a specifix URL when the state is activated (dont use to activate or deactivate the state)
   * @param url - The URL to go to. Defaults to the URL provided in options.
   */
  go: (url: string) => void;
    /**
   * Deactivates the state by removing the URL hash
   */
  deactivate: () => void;
};

export default function useControlStateByURL(options: ControlConfig): ControlReturn {
  const router = useRouter();
  const route = useRoute();

  const prefix = options.prefix ? `${options.prefix}-` : "";

  const isActive = computed(() => {
    if (prefix) {
      return route.hash.startsWith(`#${prefix}`);
    } else {
      return route.hash === `#${options.url}`;
    }
  });

  const activate = (url: string = options.url) => {
    if (!options.prefix) url = options.url;

    router.push({ hash: `#${prefix}${url}` });
  };

  const deactivate = () => {
    if (options.history) {
      router.push({ hash: "" });
    } else {
      router.back();
    }
  };

  const go = (url: string) => {
    if (!options.prefix) url = options.url;

    if (options.history) {
      router.push({ hash: `#${prefix}${url}` });
    } else {
      router.replace({ hash: `#${prefix}${url}` });
    }
  };

  return {
    isActive,
    activate,
    deactivate,
    go,
    get currentURL() {
      return computed(() => {
        if (prefix) {
          return route.hash.replace(`#${prefix}`, "");
        } else {
          return route.hash.replace("#", "");
        }
      });
    },
    get currentHash() {
      return computed(() => route.hash);
    },
  } as ControlReturn;
}