Skip to content
Merged
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
49 changes: 49 additions & 0 deletions app/components/demo/WorkExperienceDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ExternalLink } from "lucide-vue-next";
import { WorkExperience, type WorkExperienceItem } from "~/registry/blocks/work-experience";

const items: WorkExperienceItem[] = [
{
id: "stackhacker",
dateLabel: "2024 - Present",
role: "Product Engineer",
organization: "Stackhacker UI",
href: "https://ui.stackhacker.io",
target: "_blank",
description: "Designs and ships reusable Nuxt and shadcn-vue blocks for product teams.",
},
{
id: "northstar",
dateLabel: "2022 - 2024",
role: "Design Systems Lead",
organization: "Northstar Labs",
href: "https://example.com",
target: "_blank",
description: "Led a component platform across marketing, docs, and dashboard surfaces.",
},
{
id: "freelance",
dateLabel: "2020 - 2022",
role: "Independent Interface Designer",
organization: "Freelance",
description: "Partnered with early-stage teams on brand systems and product launches.",
},
];
</script>

<template>
<WorkExperience
title="Work experience"
description="A compact career-history list with app-owned organization visuals."
:items="items"
class="py-0"
>
<template #organization="{ item }">
<ExternalLink
v-if="item.href"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
</template>
</WorkExperience>
</template>
142 changes: 142 additions & 0 deletions app/registry/blocks/work-experience/WorkExperience.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";

export interface WorkExperienceItem {
/** Stable id for rendering. Falls back to role, organization, and index. */
id?: string;
/** App-formatted date or date range. */
dateLabel: string;
/** Role, title, or short position label. */
role: string;
/** Organization or team name. */
organization: string;
/** Optional organization URL. */
href?: string;
/** Anchor target used when href is supplied. */
target?: HTMLAnchorElement["target"];
/** Anchor rel used when href is supplied. */
rel?: string;
/** Optional supporting detail for the role. */
description?: string;
}

export interface WorkExperienceProps {
/** Optional section title. */
title?: string;
/** Optional supporting section copy. */
description?: string;
/** Work-history items supplied by the consuming app. */
items?: WorkExperienceItem[] | null;
/** Additional CSS classes for the section. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<WorkExperienceProps>(), {
items: () => [],
});

const experienceItems = computed(() => props.items ?? []);
const hasHeader = computed(() => Boolean(props.title || props.description));
const hasContent = computed(() => hasHeader.value || experienceItems.value.length > 0);

function itemKey(item: WorkExperienceItem, index: number) {
return item.id ?? `${item.dateLabel}-${item.role}-${item.organization}-${index}`;
}

function linkRel(item: WorkExperienceItem) {
if (item.rel) return item.rel;
if (item.target === "_blank") return "noopener noreferrer";
return undefined;
}
</script>

<template>
<section
v-if="hasContent"
data-slot="work-experience"
:class="cn('py-12 md:py-16', props.class)"
>
<div class="mx-auto w-full max-w-3xl px-4 sm:px-6 lg:px-8">
<div
v-if="hasHeader"
data-slot="work-experience-header"
>
<h2
v-if="title"
data-slot="work-experience-title"
class="text-balance text-3xl font-semibold tracking-tight sm:text-4xl"
>
{{ title }}
</h2>
<p
v-if="description"
data-slot="work-experience-description"
class="mt-4 text-pretty text-base/7 text-muted-foreground"
>
{{ description }}
</p>
</div>

<div
v-if="experienceItems.length"
data-slot="work-experience-items"
:class="cn('mt-8 grid gap-4', !hasHeader && 'mt-0')"
>
<article
v-for="(item, index) in experienceItems"
:key="itemKey(item, index)"
data-slot="work-experience-item"
class="grid gap-3 rounded-2xl border bg-card/70 p-5 text-card-foreground shadow-sm sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-6 sm:p-6"
>
<time
data-slot="work-experience-date"
class="font-mono text-xs/6 text-muted-foreground sm:pt-1"
>
{{ item.dateLabel }}
</time>

<div class="min-w-0">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h3
data-slot="work-experience-role"
class="text-base font-semibold tracking-tight"
>
{{ item.role }}
</h3>
<component
:is="item.href ? 'a' : 'p'"
data-slot="work-experience-organization"
:href="item.href"
:target="item.href ? item.target : undefined"
:rel="item.href ? linkRel(item) : undefined"
:class="cn(
'mt-1 inline-flex max-w-full items-center gap-2 text-sm text-muted-foreground',
item.href && 'rounded-sm underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
)"
>
<span class="truncate">{{ item.organization }}</span>
<slot
name="organization"
:item="item"
:index="index"
/>
</component>
</div>
</div>

<p
v-if="item.description"
data-slot="work-experience-item-description"
class="mt-3 text-sm/6 text-muted-foreground"
>
{{ item.description }}
</p>
</div>
</article>
</div>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/work-experience/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as WorkExperience, type WorkExperienceItem, type WorkExperienceProps } from "./WorkExperience.vue";
98 changes: 98 additions & 0 deletions content/docs/2.components/17.work-experience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: WorkExperience
description: Render compact work-history and resume lists from app-owned experience data.
category: content
---

::component-preview
---
name: WorkExperienceDemo
---
::

## Installation

```bash
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/work-experience.json"
```

## Usage

```vue
<script setup lang="ts">
import { ExternalLink } from 'lucide-vue-next'
import { WorkExperience, type WorkExperienceItem } from '@/components/work-experience'

const items: WorkExperienceItem[] = [
{
id: 'stackhacker',
dateLabel: '2024 - Present',
role: 'Product Engineer',
organization: 'Stackhacker UI',
href: 'https://ui.stackhacker.io',
target: '_blank',
description: 'Designs and ships reusable Nuxt and shadcn-vue blocks.'
}
]
</script>

<template>
<WorkExperience title="Work experience" :items="items">
<template #organization="{ item }">
<ExternalLink v-if="item.href" class="size-3.5" aria-hidden="true" />
</template>
</WorkExperience>
</template>
```

## WorkExperience vs ChangelogTimeline

`WorkExperience` is for compact career-history and resume surfaces: dates, roles, organizations, and optional organization links. It does not render release bodies, latest markers, loading states, or retry states.

Use `ChangelogTimeline` for content timelines and release histories. Use `WorkExperience` when the job is career history and your app already owns display-ready dates, organization data, and any brand visuals.

## App-Owned Organization Data

Your app owns date formatting, organization links, logo/icon rendering, brand colors, localization, and profile data. Pass organization visuals through the scoped `organization` slot instead of relying on Nuxt Icon strings or inline brand colors.

## Examples

### Default

::component-preview
---
name: WorkExperienceDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | — | Optional section title. |
| `description` | `string` | — | Optional supporting section copy. |
| `items` | `WorkExperienceItem[] \| null` | `[]` | Work-history items supplied by your app. |
| `class` | `string` | — | Additional CSS classes for the section. |

### Types

```ts
interface WorkExperienceItem {
id?: string
dateLabel: string
role: string
organization: string
href?: string
target?: HTMLAnchorElement['target']
rel?: string
description?: string
}
```

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `organization` | `{ item: WorkExperienceItem, index: number }` | Optional organization logo, icon, or external-link affordance. |
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: [],
registryDependencies: [],
},
"work-experience": {
dependencies: [],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading