Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .specs/divider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
name: divider
category: layout
structure: monolithic
status: approved
spec_version: 1
figma:
url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=479-886
node_id: 479:886
checksum: abadc01cd4dc1c015b0b91cd69604d389744f3f06fcd52eb00dc2e7fbad08570
created: 2026-06-25
last_updated: 2026-06-26
---

# Divider — Component Spec

## Purpose

Thin separator line that visually splits content into groups. Renders as a full-width hairline (`horizontal`) or full-height hairline (`vertical`), and can carry centered content (an "Or"-style label) when the default slot or `label` prop is set. Unlike `ScrollArea` it adds no scrolling behaviour; it is a purely decorative-yet-semantic boundary exposing `role="separator"`.

## Usage

```vue
<script setup>
import Divider from '@aziontech/webkit/divider'
</script>

<template>
<Divider />
<Divider orientation="vertical" />
<Divider label="Or" />
</template>
```

## Props

| Prop | Type | Default | Required | JSDoc |
|---|---|---|---|---|
| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | no | Layout axis of the separator line. |
| `label` | `string` | `''` | no | Fallback centered text shown when the default slot is empty. |

## Events

| _none_ | — | — |

## Slots

| Slot | Scope | Notes |
|---|---|---|
| `default` | — | Centered content; overrides `label` when provided. |

## States

- Visual states: `default`
- `data-orientation` mirrors the `orientation` prop: `horizontal` | `vertical`

## Motion & Animations

_none_

## Tokens

| Region | Token (DESIGN.md) |
|---|---|
| line | `var(--border-default)` |
| typography (label) | `.text-label-sm` |
| text (label) | `var(--text-muted)` |
| spacing (gap around label) | `var(--spacing-sm)` |

## Theme gaps

| Figma variable | Temporary primitive | Follow-up |
|---|---|---|
| _none_ | — | — |

## Accessibility (WCAG 2.1 AA)

- Visible focus: not applicable — the separator is non-interactive and not in the tab order.
- Keyboard map: not applicable — no interactive controls.
- ARIA: root carries `role="separator"` and `aria-orientation="{orientation}"`. A labelled divider keeps the `separator` role; the label is exposed as the separator's accessible content.
- Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including the disabled state — the muted label text meets the body-text ratio against canvas/surface.
- `motion-reduce:transition-none motion-reduce:transform-none` on animated states — not applicable; the component declares no motion.
- Touch target ≥40×40 px — not applicable; the component is non-interactive.

## Stories (Storybook)

All stories share one reactive render that binds both `orientation` and `label`, so every control updates the canvas live.

- Default — reactive playground; the `orientation` and `label` controls drive a single divider.
- WithLabel — horizontal divider with a centered `label` ("OR") in the middle. Exercises the `label` prop / default slot.
- Vertical — vertical divider with a centered `label` ("OR") in the middle (the parent supplies the height); covers the `vertical` orientation with a label.

## Constraints — DO NOT

<!-- This block is injected VERBATIM into every sub-agent prompt.
spec-validator rejects the spec if this block is missing or shorter than the template. -->

- Do not add props beyond the Props table above. If you need a prop that is not listed, emit `BLOCKED: missing prop <name>` and stop — do not invent.
- Do not add events beyond the Events table above. Same rule for slots and sub-components.
- Do not invent imports. Every `@aziontech/webkit/*` path must exist in `packages/webkit/package.json#exports`. Every relative import must resolve to a real file. Every npm package must be installed.
- Do not use HEX/RGB/HSL colors, Tailwind palette names (e.g. `bg-blue-500`), raw typography classes (e.g. `text-sm`), `any`, `@ts-ignore`, or `class` inside `defineProps`.
- Do not install or import positioning/animation libraries (`@floating-ui/*`, `popper.js`, `tippy.js`, `gsap`, `framer-motion`, `motion`, `@vueuse/motion`, `@formkit/auto-animate`, drag-drop runtimes, scroll virtualization libs). Use CSS + Vue primitives (`<Teleport>`, `<Transition>`). See `.claude/rules/dependencies.md`.
- Do not improvise animations. Every `animate-*` / `transition-*` class must come from `packages/theme/src/tokens/semantic/animations.js`; every motion-bearing class pairs with `motion-reduce:*` on the same class string; no component-local `@keyframes`.
- Do not create class presets in JavaScript (`const kindClasses = {...}`, `const sharedClasses = [...]`, `const sizeClasses = {...}`, `const rootClasses = computed(...)`). Variants live on `data-*` attributes consumed by Tailwind `data-[attr=value]:`. All utilities live inline on the root element's `class` attribute. No `<style>` block, no component-local `.css`/`.scss`. See `.claude/rules/styling.md`.
- Do not inherit artifacts as-is from another design system, Figma file, library, or pre-existing `CONTRACT.md` / `README.md`. Rewrite to our conventions. See `.claude/rules/migration.md`.
- Do not add Figma references to Storybook stories. No `parameters.design`, no `parameters.figma`, no Figma URLs in `docs.description.*`, no `@storybook/addon-designs` import. The Figma link is owned by `<name>.figma.ts` (Code Connect). See `.claude/docs/COMPONENT_REQUIREMENTS.md`.
- Do not use `parameters.actions.argTypesRegex` (deprecated in Storybook 8 and silently misroutes Vue 3 emits) or `parameters.actions.handles` (DOM-only). Declare every event explicitly in `argTypes` with a camelCase `on<Event>` key and `{ action: '<emitted-name>' }`. Do not use the legacy CSF2 `Name.args = {...}` form — always object-style CSF3.
- Do not add bespoke Storybook stories beyond Default + Types + Sizes + state stories (`Loading`, `Disabled`) for the props the component actually declares, unless the spec's "Stories (Storybook)" section explicitly justifies the addition. Do not split Types/Sizes into one-story-per-variant — the composite stories are the canonical pattern.
- Do not duplicate the `## Usage` block from the spec inside the Storybook story body. The block is injected once into `parameters.docs.description.component` by the storybook-write skill; copy it nowhere else.
- Do not edit `.claude/docs/DESIGN.md`, `.claude/docs/COMPONENT_REQUIREMENTS.md`, or `.claude/docs/PRIMEVUE_ABSTRACTION.md`.
- Do not edit the root `package.json` or `.github/workflows/*`.
- Do not change `structure` after `status: approved`. To change structure, bump `spec_version` and re-author the spec.
- Do not create files outside the paths declared by your task (the orchestrator tells you exactly which files to write).
- Do not run `git` commands, `pnpm install`, or any command that changes the lockfile.
- If anything in the spec is ambiguous or contradicts the rules, emit `BLOCKED: <one-sentence reason>` and write nothing.
126 changes: 126 additions & 0 deletions apps/storybook/src/stories/components/layout/Divider.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import Divider from '@aziontech/webkit/divider'

const IMPORT = "import Divider from '@aziontech/webkit/divider'"

/** Wrap a `<template>` body in a runnable `<script setup>` SFC for "Show code". */
const sfc = (body) =>
[
'<script setup>',
IMPORT,
'</script>',
'',
'<template>',
body
.trim()
.split('\n')
.map((line) => (line ? ` ${line}` : line))
.join('\n'),
'</template>'
].join('\n')

/** @type {import('@storybook/vue3').Meta<typeof Divider>} */
const meta = {
title: 'Components/Layout/Divider',
component: Divider,
tags: ['autodocs'],
parameters: {
layout: 'padded',
backgrounds: { default: 'dark' },
a11y: {
config: {
rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'focus-order-semantics', enabled: true }
]
}
},
docs: {
description: {
component:
'Thin separator line that visually splits content into groups. Renders as a full-width hairline (`horizontal`) or full-height hairline (`vertical`), and can carry centered content (an "Or"-style label) when the default slot or `label` prop is set.'
},
canvas: { sourceState: 'shown' }
}
},
argTypes: {
orientation: {
control: { type: 'inline-radio' },
options: ['horizontal', 'vertical'],
description: 'Layout axis of the separator line.',
table: {
category: 'props',
type: { summary: "'horizontal' | 'vertical'" },
defaultValue: { summary: "'horizontal'" }
}
},
label: {
control: 'text',
description: 'Fallback centered text shown when the default slot is empty.',
table: {
category: 'props',
type: { summary: 'string' },
defaultValue: { summary: "''" }
}
},
default: {
description: 'Centered content; overrides `label` when provided.',
control: false,
table: { category: 'slots', type: { summary: 'VNode | string' } }
}
},
args: {
orientation: 'horizontal',
label: ''
}
}

export default meta

// One reactive render for every story. It binds BOTH props (`:orientation` +
// `:label`) and the wrapper gives the divider a definite width AND height, so a
// horizontal line fills the width and a vertical line fills the height — every
// control works on every story (proven Storybook 8 vue3 pattern: return { args }
// and reference args.* in the template).
const Template = (args) => ({
components: { Divider },
setup() {
return { args }
},
template: `
<div class="flex items-center justify-center w-full max-w-[360px] h-[80px]">
<Divider :orientation="args.orientation" :label="args.label" />
</div>
`
})

export const Default = {
render: Template,
parameters: {
docs: {
description: { story: 'Reactive playground — switch `orientation` and type a `label` to see it update live.' },
source: { code: sfc('<Divider orientation="horizontal" />') }
}
}
}

export const WithLabel = {
render: Template,
args: { orientation: 'horizontal', label: 'OR' },
parameters: {
docs: {
description: { story: 'Horizontal divider with a centered label ("OR") in the middle. Both `orientation` and `label` controls are live.' },
source: { code: sfc('<Divider orientation="horizontal" label="OR" />') }
}
}
}

export const Vertical = {
render: Template,
args: { orientation: 'vertical', label: 'OR' },
parameters: {
docs: {
description: { story: 'Vertical divider with a centered label ("OR") in the middle; the parent provides the height. Both controls are live.' },
source: { code: sfc('<Divider orientation="vertical" label="OR" />') }
}
}
}
1 change: 1 addition & 0 deletions packages/webkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"./field-textarea": "./src/components/inputs/field-textarea/field-textarea.vue",
"./dropdown": "./src/components/inputs/dropdown/dropdown.vue",
"./select": "./src/components/inputs/select/index.ts",
"./divider": "./src/components/layout/divider/divider.vue",
"./scroll-area": "./src/components/layout/scroll-area/scroll-area.vue",
"./global-header": "./src/components/layout/global-header/index.js",
"./sidebar": "./src/components/layout/sidebar/sidebar.vue",
Expand Down
53 changes: 53 additions & 0 deletions packages/webkit/src/components/layout/divider/divider.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, useAttrs, useSlots } from 'vue'

defineOptions({
name: 'Divider',
inheritAttrs: false
})

type DividerOrientation = 'horizontal' | 'vertical'

interface Props {
/** Layout axis of the separator line. */
orientation?: DividerOrientation
/** Fallback centered text shown when the default slot is empty. */
label?: string
}

const props = withDefaults(defineProps<Props>(), {
orientation: 'horizontal',
label: ''
})

defineSlots<{
default(): unknown
}>()

const attrs = useAttrs()
const slots = useSlots()

const testId = computed(() => (attrs['data-testid'] as string | undefined) ?? 'layout-divider')

const hasContent = computed<boolean>(() => Boolean(slots['default']) || props.label.length > 0)
</script>

<template>
<div
v-bind="$attrs"
role="separator"
:aria-orientation="orientation"
:data-testid="testId"
:data-orientation="orientation"
:data-labelled="hasContent || null"
class="flex shrink-0 items-center justify-center text-label-sm text-[var(--text-muted)] data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:h-full data-[orientation=vertical]:flex-col data-[labelled]:gap-[var(--spacing-sm)] before:content-[''] before:flex-1 before:self-center before:border-[var(--border-default)] after:content-[''] after:flex-1 after:self-center after:border-[var(--border-default)] data-[orientation=horizontal]:before:border-t data-[orientation=horizontal]:after:border-t data-[orientation=vertical]:before:border-l data-[orientation=vertical]:after:border-l"
>
<span
v-if="hasContent"
:data-testid="`${testId}__label`"
>
<slot v-if="slots['default']" />
<template v-else>{{ label }}</template>
</span>
</div>
</template>
Loading