Laravel + Inertia (Vue 3) uygulamaları geliştiriyorsanız, UI bileşenlerinizi hızlı bir şekilde göndermeye çalışıyorsunuz demektir. Ancak bu süreçte arada kaynamış bazı durumlarla karşılaşabilirsiniz (boşluklar, durumlar, “sadece bir sayfada gerçekleşen” garip durumlar gibi).
<p>Bu yüzden <strong>Storybook</strong> eklemeyi tercih ediyorum: UI bileşenlerini <strong>izole</strong> bir ortamda oluşturmak, önizlemek, belgelerini hazırlamak ve test etmek için özel bir atölye sunuyor, böylece Laravel arka uçta odaklanabilir.</p>
<p>Bu yazıda, <strong>her şeyin <code>/stories</code></strong> içinde saklandığı temiz bir yapı göstereceğim (bileşenler + CSS + hikayeler) ve ayrıca <strong>per-bileşen CSS</strong> ve <strong>bir etkileşim testi</strong> ile birlikte bir <strong>aydınlık tema modali</strong> örneği vereceğim.</p>
<hr/>
<h2>
<a name="why-storybook-even-in-a-laravel-project" href="#why-storybook-even-in-a-laravel-project"></a>
Neden Storybook (bir Laravel projesinde bile)?
</h2>
<ul>
<li>Bileşenleri uygulamanızda gezmeden geliştirin.</li>
<li>“Erişilmesi zor” UI durumlarını kapsayın (boş durumlar, uzun içerikler, hatalar).</li>
<li>Etkileşim testlerini doğrudan hikayeye ekleyin (<code>play</code> fonksiyonu).</li>
</ul>
<p>Storybook’un <code>play</code> fonksiyonu hikaye render edildikten sonra çalışır ve <code>canvas</code> ve <code>userEvent</code> gibi yardımcıları alır, bu da UI davranışını test etmek için mükemmeldir.</p>
<hr/>
<h2>
<a name="1-install-storybook-vue-3-vite" href="#1-install-storybook-vue-3-vite"></a>
1) Storybook’u kurun (Vue 3 + Vite)
</h2>
<p>Laravel projenizin kök dizininden:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight shell"><code>npx storybook@latest init
<p>Storybook Vite'yi tespit ettiğinde, Vite derleyicisini kullanır ve gerektiğinde <code>viteFinal</code> ile özelleştirilebilir.</p>
<p>Başlatın:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight shell"><code>npm run storybook
<hr/>
<h2>
<a name="2-keep-everything-in-raw-stories-endraw-" href="#2-keep-everything-in-raw-stories-endraw-"></a>
2) Her şeyi <code>/stories</code> içinde saklayın
</h2>
<p>Kullanacağımız yapı şöyle olacak:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code>.storybook/
main.ts
preview.ts
stories/
ui/
Button/
Button.vue
Button.css
Input/
Input.vue
Input.css
Modal/
Modal.vue
Modal.css
Modal.stories.ts
<p>Daha sonra Storybook’u bu dizinden hikayeleri yükleyecek şekilde yapılandırın.</p>
<h3>
<a name="-raw-storybookmaints-endraw-" href="#-raw-storybookmaints-endraw-"></a>
<code>.storybook/main.ts</code>
</h3>
<div class="highlight js-code-highlight">
<pre class="highlight typescript"><code>import type { StorybookConfig } from "@storybook/vue3-vite";
const config: StorybookConfig = {
stories: [“../stories/*/.stories.@(js|jsx|ts|tsx|mdx)”],
addons: [“@storybook/addon-essentials”],
framework: {
name: “@storybook/vue3-vite”,
options: {},
},
};
export default config;
<p>Hikaye globu, Storybook’un <code>.stories.*</code> dosyalarını keşfetmesini sağlar ve istediğiniz herhangi bir klasöre yönlendirebilirsiniz.</p>
<h3>
<a name="-raw-storybookpreviewts-endraw-recommended-for-modalsoverlays" href="#-raw-storybookpreviewts-endraw-recommended-for-modalsoverlays"></a>
<code>.storybook/preview.ts</code> (modal/geçişler için önerilir)
</h3>
<p>Geçişler, <code>fullscreen</code> düzeninde en iyi görünür:<br/></p>
<div class="highlight js-code-highlight">
<pre class="highlight typescript"><code>export default {
parameters: {
layout: “fullscreen”,
},
};
<hr/>
<h2>
<a name="3-the-component-a-light-modal-with-percomponent-css" href="#3-the-component-a-light-modal-with-percomponent-css"></a>
3) Bileşen: Per-bileşen CSS ile aydınlık Modal
</h2>
<h3>
<a name="-raw-storiesuimodalmodalvue-endraw-" href="#-raw-storiesuimodalmodalvue-endraw-"></a>
<code>stories/ui/Modal/Modal.vue</code>
</h3>
<p>Bu modal:</p>
<ul>
<li><code>Teleport</code> kullanarak her şeyin üstünde render edilmesini sağlar.</li>
<li>ESC ile kapatma ve geçiş ile kapatma desteği sunar.</li>
<li><code>update:open</code> gönderir böylece üst bileşen durumu kontrol eder.</li>
<li>Opsiyonel bir alt bilgi slotuna sahiptir.</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight vue"><code><span class="nt">script setup lang="ts"></span>
import { computed, nextTick, onBeforeUnmount, watch as vueWatch } from “vue”;
type CloseReason = “overlay” | “esc” | “button”;
const props = withDefaults(defineProps({
open: boolean;
title?: string;
closeOnOverlay?: boolean;
closeOnEsc?: boolean;
showClose?: boolean;
lockScroll?: boolean;
maxWidth?: “sm” | “md” | “lg” | “xl”;
}), {
closeOnOverlay: true,
closeOnEsc: true,
showClose: true,
lockScroll: true,
maxWidth: “md”,
});
const emit = defineEmits<{
(e: “update:open”, value: boolean): void;
(e: “close”, reason: CloseReason): void;
}>();
let previousActiveEl: HTMLElement | null = null;
let previousBodyOverflow: string | null = null;
const titleId = ui-modal-title-${Math.random().toString(36).slice(2)};
const widthClass = computed(() => {
switch (props.maxWidth) {
case “sm”:
return “ui-modal–sm”;
case “md”:
return “ui-modal–md”;
case “lg”:
return “ui-modal–lg”;
case “xl”:
return “ui-modal–xl”;
default:
return “ui-modal–md”;
}
});
function close(reason: CloseReason) {
emit(“update:open”, false);
emit(“close”, reason);
}
function onOverlayClick() {
if (!props.closeOnOverlay) return;
close(“overlay”);
}
function onKeydown(e: KeyboardEvent) {
if (!props.open) return;
if (e.key === “Escape” && props.closeOnEsc) {
e.preventDefault();
close(“esc”);
}
}
function lockBodyScroll() {
if (!props.lockScroll) return;
previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = “hidden”;
}
function unlockBodyScroll() {
if (!props.lockScroll) return;
document.body.style.overflow = previousBodyOverflow ?? “”;
previousBodyOverflow = null;
}
async function focusPanel() {
await nextTick();
const panel = document.querySelector(“.ui-modal__panel”) as HTMLElement | null;
panel?.focus();
}
vueWatch(() => props.open, async (isOpen) => {
if (isOpen) {
previousActiveEl = document.activeElement as HTMLElement | null;
window.addEventListener(“keydown”, onKeydown);
lockBodyScroll();
await focusPanel();
} else {
window.removeEventListener(“keydown”, onKeydown);
unlockBodyScroll();
previousActiveEl?.focus?.();
previousActiveEl = null;
}
});
onBeforeUnmount(() => {
window.removeEventListener(“keydown”, onKeydown);
unlockBodyScroll();
});
script>
template>
<teleport to=”body”>
<transition name=”ui-fade”>
<div v-if=”open” class=”ui-modal” aria-hidden=”false”>
<div class=”ui-modal__overlay” data-testid=”modal-overlay” @click=”onOverlayClick” />
<span class="nt"><transition name="ui-pop">
<span class="nt"><p>
<span class="na">class="ui-modal__panel" :class="widthClass" role="dialog" aria-modal="true" :aria-labelledby="title ? titleId : undefined" tabindex="-1"></span>
<span class="nt"><header class="ui-modal__header">
<span class="nt"><h2 v-if="title" :id="titleId" class="ui-modal__title"></span>{{ title }}<span class="nt"/></h2></span>
<span class="nt"><button v-if="showClose" class="ui-modal__close" type="button" aria-label="Close dialog" @click="close('button')"></span>✕<span class="nt"/></button></span>
<span class="nt"/></header></span>
<span class="nt"><section class="ui-modal__body"></span>
<span class="nt"><slot /> <span class="nt">/<slot>></span></span>
</span></section></span>
<span class="nt"><footer v-if="$slots.footer" class="ui-modal__footer"></span>
<span class="nt"><slot name="footer" /> <span class="nt">/<slot>></span></span>
</span></footer></span>
<span class="nt"/></p></span>
<span class="nt"/></transition></span>
<span class="nt"/></div></span>
<span class="nt"/>
<style scoped src=”./Modal.css”><style>
<div class="highlight__panel js-actions-panel">
<div class="highlight__panel-action js-fullscreen-code-action">
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-on"><title>Enter fullscreen mode</title>
<path d="M16 3h6v6h-2V5h-4V3zM2 3h6v2H4v4H2V3zm18 16v-4h2v6h-6v-2h4zM4 19h4v2H2v-6h2v4z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewbox="0 0 24 24" class="highlight-action crayons-icon highlight-action--fullscreen-off"><title>Exit fullscreen mode</title>
<path d="M18 7h4v2h-6V3h2v4zM8 9H2V7h4V3h2v6zm10 8v4h-2v-6h6v2h-4zM8 15v6H6v-4H2v-2h6z"/>
</svg>
</div>
</div>
</div>
<blockquote>
<p><strong>TypeScript hatası</strong>: Eğer TypeScript sizlere <code>Expected 0 arguments, but got 3</code> hatası veriyorsa, bu genelde IDE'nizin yanlış bir <code>watch</code> import etmesindendir. Import'u <code>watch as vueWatch</code> olarak yeniden adlandırmak bu durumu netleştirir.</p>
</blockquote>
<h3>
<a name="-raw-storiesuimodalmodalcss-endraw-light-theme-nicer-typography" href="#-raw-storiesuimodalmodalcss-endraw-light-theme-nicer-typography"></a>
<code>stories/ui/Modal/Modal.css</code> (aydınlık tema + daha güzel tipografi)
</h3>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code>.ui-modal {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
padding: 24px;
}
.ui-modal__overlay {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.42);
}
.ui-modal__panel {
position: relative;
width: min(720px, 100%);
max-height: min(84vh, 900px);
background: #ffffff;
color: #0f172a;
border: 1px solid rgba(15, 23, 42, 0.10);
border-radius: 16px;
box-shadow: 0 18px 70px rgba(15, 23, 42, 0.18);
overflow: hidden;
outline: none;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ui-modalpanel button, .ui-modalpanel input {
font: inherit;
}
/ Boyutlar /
.ui-modal–sm {
width: min(440px, 100%);
}
.ui-modal–md {
width: min(640px, 100%);
}
.ui-modal–lg {
width: min(820px, 100%);
}
.ui-modal–xl {
width: min(1020px, 100%);
}
/ Başlık /
.ui-modal__header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
padding: 18px 18px 10px 18px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.ui-modal__title {
margin: 0;
font-size: 18px;
line-height: 1.2;
letter-spacing: 0.2px;
}
/ Kapatma butonu /
.ui-modal__close {
appearance: none;
border: 1px solid rgba(15, 23, 42, 0.10);
background: rgba(15, 23, 42, 0.04);
border-radius: 10px;
padding: 8px 10px;
cursor: pointer;
line-height: 1;
}
.ui-modal__close:hover {
background: rgba(15, 23, 42, 0.08);
}
/ Body / footer /
.ui-modal__body {
padding: 16px 18px;
overflow: auto;
}
.ui-modal__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
/ Geçişler /
.ui-fade-enter-active, .ui-fade-leave-active {
transition: opacity 160ms ease;
}
.ui-fade-enter-from, .ui-fade-leave-to {
opacity: 0;
}
.ui-pop-enter-active {
transition: transform 160ms ease, opacity 160ms ease;
}
.ui-pop-leave-active {
transition: transform 130ms ease, opacity 130ms ease;
}
.ui-pop-enter-from {
transform: translateY(10px) scale(0.98);
opacity: 0;
}
.ui-pop-leave-to {
transform: translateY(6px) scale(0.99);
opacity: 0;
}