@antfu/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/a11y/cli.ts +73 -0
  4. package/a11y/index.ts +13 -0
  5. package/a11y/scan.ts +127 -0
  6. package/components/Action/ActionButton.stories.ts +56 -0
  7. package/components/Action/ActionButton.vue +57 -0
  8. package/components/Action/ActionDarkToggle.stories.ts +31 -0
  9. package/components/Action/ActionDarkToggle.vue +87 -0
  10. package/components/Action/ActionIconButton.stories.ts +47 -0
  11. package/components/Action/ActionIconButton.vue +47 -0
  12. package/components/Display/DisplayAvatar.stories.ts +36 -0
  13. package/components/Display/DisplayAvatar.vue +58 -0
  14. package/components/Display/DisplayBadge.stories.ts +31 -0
  15. package/components/Display/DisplayBadge.vue +98 -0
  16. package/components/Display/DisplayBytes.stories.ts +28 -0
  17. package/components/Display/DisplayBytes.vue +30 -0
  18. package/components/Display/DisplayDate.stories.ts +37 -0
  19. package/components/Display/DisplayDate.vue +29 -0
  20. package/components/Display/DisplayDonut.stories.ts +26 -0
  21. package/components/Display/DisplayDonut.vue +46 -0
  22. package/components/Display/DisplayDuration.stories.ts +28 -0
  23. package/components/Display/DisplayDuration.vue +28 -0
  24. package/components/Display/DisplayFileIcon.stories.ts +27 -0
  25. package/components/Display/DisplayFileIcon.vue +30 -0
  26. package/components/Display/DisplayFilePath.stories.ts +30 -0
  27. package/components/Display/DisplayFilePath.vue +61 -0
  28. package/components/Display/DisplayKbd.stories.ts +26 -0
  29. package/components/Display/DisplayKbd.vue +27 -0
  30. package/components/Display/DisplayKeyValue.stories.ts +56 -0
  31. package/components/Display/DisplayKeyValue.vue +51 -0
  32. package/components/Display/DisplayLabel.stories.ts +27 -0
  33. package/components/Display/DisplayLabel.vue +33 -0
  34. package/components/Display/DisplayNumber.stories.ts +27 -0
  35. package/components/Display/DisplayNumber.vue +24 -0
  36. package/components/Display/DisplayNumberBadge.stories.ts +26 -0
  37. package/components/Display/DisplayNumberBadge.vue +22 -0
  38. package/components/Display/DisplayPackageName.stories.ts +26 -0
  39. package/components/Display/DisplayPackageName.vue +49 -0
  40. package/components/Display/DisplayProgressBar.stories.ts +29 -0
  41. package/components/Display/DisplayProgressBar.vue +90 -0
  42. package/components/Display/DisplayProportionBar.stories.ts +40 -0
  43. package/components/Display/DisplayProportionBar.vue +43 -0
  44. package/components/Display/DisplaySafeImage.stories.ts +43 -0
  45. package/components/Display/DisplaySafeImage.vue +30 -0
  46. package/components/Display/DisplayStatusPill.stories.ts +34 -0
  47. package/components/Display/DisplayStatusPill.vue +42 -0
  48. package/components/Display/DisplayTree.stories.ts +76 -0
  49. package/components/Display/DisplayTree.vue +102 -0
  50. package/components/Display/DisplayVersion.stories.ts +25 -0
  51. package/components/Display/DisplayVersion.vue +21 -0
  52. package/components/Feedback/FeedbackEmptyState.stories.ts +38 -0
  53. package/components/Feedback/FeedbackEmptyState.vue +21 -0
  54. package/components/Feedback/FeedbackLoading.stories.ts +23 -0
  55. package/components/Feedback/FeedbackLoading.vue +21 -0
  56. package/components/Feedback/FeedbackSpinner.stories.ts +25 -0
  57. package/components/Feedback/FeedbackSpinner.vue +22 -0
  58. package/components/Feedback/FeedbackTip.stories.ts +34 -0
  59. package/components/Feedback/FeedbackTip.vue +29 -0
  60. package/components/Feedback/FeedbackToasts.stories.ts +40 -0
  61. package/components/Feedback/FeedbackToasts.vue +105 -0
  62. package/components/Form/FormCheckbox.stories.ts +36 -0
  63. package/components/Form/FormCheckbox.vue +30 -0
  64. package/components/Form/FormCombobox.stories.ts +35 -0
  65. package/components/Form/FormCombobox.vue +83 -0
  66. package/components/Form/FormField.stories.ts +56 -0
  67. package/components/Form/FormField.vue +36 -0
  68. package/components/Form/FormNumberInput.stories.ts +47 -0
  69. package/components/Form/FormNumberInput.vue +85 -0
  70. package/components/Form/FormRadioGroup.stories.ts +47 -0
  71. package/components/Form/FormRadioGroup.vue +43 -0
  72. package/components/Form/FormSearchField.stories.ts +22 -0
  73. package/components/Form/FormSearchField.vue +32 -0
  74. package/components/Form/FormSelect.stories.ts +47 -0
  75. package/components/Form/FormSelect.vue +56 -0
  76. package/components/Form/FormSwitch.stories.ts +36 -0
  77. package/components/Form/FormSwitch.vue +26 -0
  78. package/components/Form/FormTextInput.stories.ts +39 -0
  79. package/components/Form/FormTextInput.vue +51 -0
  80. package/components/Form/FormTextarea.stories.ts +47 -0
  81. package/components/Form/FormTextarea.vue +32 -0
  82. package/components/Layout/LayoutBreadcrumb.stories.ts +54 -0
  83. package/components/Layout/LayoutBreadcrumb.vue +54 -0
  84. package/components/Layout/LayoutCard.stories.ts +31 -0
  85. package/components/Layout/LayoutCard.vue +21 -0
  86. package/components/Layout/LayoutDataTable.stories.ts +77 -0
  87. package/components/Layout/LayoutDataTable.vue +145 -0
  88. package/components/Layout/LayoutExpandableList.stories.ts +28 -0
  89. package/components/Layout/LayoutExpandableList.vue +94 -0
  90. package/components/Layout/LayoutPanelGrids.stories.ts +28 -0
  91. package/components/Layout/LayoutPanelGrids.vue +26 -0
  92. package/components/Layout/LayoutSectionBlock.stories.ts +37 -0
  93. package/components/Layout/LayoutSectionBlock.vue +37 -0
  94. package/components/Layout/LayoutSideNav.stories.ts +33 -0
  95. package/components/Layout/LayoutSideNav.vue +48 -0
  96. package/components/Layout/LayoutSplitPane.stories.ts +44 -0
  97. package/components/Layout/LayoutSplitPane.vue +30 -0
  98. package/components/Layout/LayoutTabs.stories.ts +43 -0
  99. package/components/Layout/LayoutTabs.vue +56 -0
  100. package/components/Layout/LayoutToolbar.stories.ts +60 -0
  101. package/components/Layout/LayoutToolbar.vue +28 -0
  102. package/components/Layout/LayoutVirtualList.stories.ts +30 -0
  103. package/components/Layout/LayoutVirtualList.vue +82 -0
  104. package/components/Overlay/OverlayDrawer.stories.ts +47 -0
  105. package/components/Overlay/OverlayDrawer.vue +58 -0
  106. package/components/Overlay/OverlayDropdown.stories.ts +25 -0
  107. package/components/Overlay/OverlayDropdown.vue +30 -0
  108. package/components/Overlay/OverlayDropdownItem.stories.ts +26 -0
  109. package/components/Overlay/OverlayDropdownItem.vue +31 -0
  110. package/components/Overlay/OverlayDropdownLabel.vue +9 -0
  111. package/components/Overlay/OverlayDropdownSeparator.vue +7 -0
  112. package/components/Overlay/OverlayModal.stories.ts +33 -0
  113. package/components/Overlay/OverlayModal.vue +48 -0
  114. package/components/Overlay/OverlayTooltip.stories.ts +33 -0
  115. package/components/Overlay/OverlayTooltip.vue +38 -0
  116. package/composables/colorScheme.ts +58 -0
  117. package/composables/toast.ts +81 -0
  118. package/package.json +99 -0
  119. package/skills/antfu-design/SKILL.md +65 -0
  120. package/skills/antfu-design/references/advanced-patterns.md +39 -0
  121. package/skills/antfu-design/references/best-practices.md +54 -0
  122. package/skills/antfu-design/references/core-components.md +72 -0
  123. package/skills/antfu-design/references/core-setup.md +56 -0
  124. package/skills/antfu-design/references/core-tokens.md +100 -0
  125. package/skills/antfu-design/references/features-data-presentation.md +27 -0
  126. package/splitpanes.d.ts +70 -0
  127. package/styles/animations.css +47 -0
  128. package/styles/base.css +31 -0
  129. package/styles/floating-vue.css +28 -0
  130. package/styles/index.css +7 -0
  131. package/styles/reka-ui.css +112 -0
  132. package/styles/scrollbar.css +24 -0
  133. package/styles/splitpanes.css +61 -0
  134. package/unocss/colors.ts +127 -0
  135. package/unocss/index.ts +99 -0
  136. package/unocss/options.ts +31 -0
  137. package/unocss/patterns.ts +38 -0
  138. package/unocss/rules.ts +26 -0
  139. package/unocss/severity.ts +16 -0
  140. package/unocss/shortcuts.ts +68 -0
  141. package/utils/color.ts +328 -0
  142. package/utils/contrast.ts +118 -0
  143. package/utils/format.ts +389 -0
  144. package/utils/icon.ts +200 -0
  145. package/utils/index.ts +13 -0
  146. package/utils/keybinding.ts +199 -0
  147. package/utils/misc.ts +141 -0
  148. package/utils/path.ts +243 -0
  149. package/utils/semver.ts +147 -0
  150. package/utils/tree.ts +89 -0
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts" generic="T">
2
+ import { computed } from 'vue'
3
+
4
+ export interface Column {
5
+ /** Property key on the row used for value lookup and sorting. */
6
+ key: string
7
+ /** Header label; falls back to `key`. */
8
+ label?: string
9
+ /** Cell + header text alignment. */
10
+ align?: 'left' | 'right' | 'center'
11
+ /** Allow clicking the header to sort by this column. */
12
+ sortable?: boolean
13
+ /** Fixed column width (any CSS length). */
14
+ width?: string
15
+ }
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ columns: Column[]
20
+ rows: T[]
21
+ /** Stable key per row; defaults to the row index. */
22
+ rowKey?: (row: T, index: number) => string | number
23
+ /** Emit `sort` only and leave row ordering to the parent. */
24
+ manualSort?: boolean
25
+ /** Pin the header to the top while the body scrolls. */
26
+ stickyHeader?: boolean
27
+ }>(),
28
+ { manualSort: false, stickyHeader: true },
29
+ )
30
+
31
+ const emit = defineEmits<{
32
+ sort: [payload: { key: string, dir: 'asc' | 'desc' }]
33
+ }>()
34
+
35
+ defineSlots<{
36
+ cell?: (props: { row: T, column: Column, value: unknown }) => any
37
+ header?: (props: { column: Column }) => any
38
+ }>()
39
+
40
+ const sortBy = defineModel<string | undefined>('sortBy')
41
+ const sortDir = defineModel<'asc' | 'desc'>('sortDir', { default: 'asc' })
42
+
43
+ const alignClass = { left: 'text-left', right: 'text-right', center: 'text-center' }
44
+
45
+ function valueOf(row: T, key: string): unknown {
46
+ return (row as Record<string, unknown>)[key]
47
+ }
48
+
49
+ const sortedRows = computed<T[]>(() => {
50
+ const key = sortBy.value
51
+ if (props.manualSort || !key)
52
+ return props.rows
53
+ const dir = sortDir.value === 'desc' ? -1 : 1
54
+ return [...props.rows].sort((a, b) => {
55
+ const av = valueOf(a, key)
56
+ const bv = valueOf(b, key)
57
+ if (av == null && bv == null)
58
+ return 0
59
+ if (av == null)
60
+ return -1 * dir
61
+ if (bv == null)
62
+ return 1 * dir
63
+ if (typeof av === 'number' && typeof bv === 'number')
64
+ return (av - bv) * dir
65
+ return String(av).localeCompare(String(bv)) * dir
66
+ })
67
+ })
68
+
69
+ function toggleSort(col: Column): void {
70
+ if (!col.sortable)
71
+ return
72
+ if (sortBy.value === col.key) {
73
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
74
+ }
75
+ else {
76
+ sortBy.value = col.key
77
+ sortDir.value = 'asc'
78
+ }
79
+ emit('sort', { key: col.key, dir: sortDir.value })
80
+ }
81
+
82
+ function rowKeyOf(row: T, index: number): string | number {
83
+ return props.rowKey ? props.rowKey(row, index) : index
84
+ }
85
+ </script>
86
+
87
+ <template>
88
+ <table class="text-sm w-full border-collapse">
89
+ <thead :class="stickyHeader ? 'sticky top-0 z-nav bg-secondary' : ''">
90
+ <tr>
91
+ <th
92
+ v-for="col in columns"
93
+ :key="col.key"
94
+ scope="col"
95
+ class="color-muted font-medium px-3 py-2 border-b border-base"
96
+ :class="alignClass[col.align ?? 'left']"
97
+ :style="col.width ? { width: col.width } : undefined"
98
+ :aria-sort="sortBy === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined"
99
+ >
100
+ <slot name="header" :column="col">
101
+ <button
102
+ v-if="col.sortable"
103
+ type="button"
104
+ class="outline-none rounded inline-flex gap-1 max-w-full items-center hover:color-base focus-visible:ring-2 focus-visible:ring-primary-500/40"
105
+ :class="[alignClass[col.align ?? 'left'], col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : '']"
106
+ @click="toggleSort(col)"
107
+ >
108
+ <span class="truncate">{{ col.label ?? col.key }}</span>
109
+ <svg
110
+ viewBox="0 0 16 16"
111
+ width="12"
112
+ height="12"
113
+ aria-hidden="true"
114
+ class="shrink-0 transition"
115
+ :class="sortBy === col.key ? 'color-active' : 'op-mute'"
116
+ :style="sortBy === col.key && sortDir === 'desc' ? 'transform: rotate(180deg)' : undefined"
117
+ >
118
+ <path fill="currentColor" d="M8 4l3.5 5h-7z" />
119
+ </svg>
120
+ </button>
121
+ <span v-else class="max-w-full inline-block truncate">{{ col.label ?? col.key }}</span>
122
+ </slot>
123
+ </th>
124
+ </tr>
125
+ </thead>
126
+ <tbody>
127
+ <tr
128
+ v-for="(row, i) in sortedRows"
129
+ :key="rowKeyOf(row, i)"
130
+ class="border-b border-mute hover:bg-active"
131
+ >
132
+ <td
133
+ v-for="col in columns"
134
+ :key="col.key"
135
+ class="px-3 py-1.5"
136
+ :class="alignClass[col.align ?? 'left']"
137
+ >
138
+ <slot name="cell" :row="row" :column="col" :value="valueOf(row, col.key)">
139
+ {{ String(valueOf(row, col.key)) }}
140
+ </slot>
141
+ </td>
142
+ </tr>
143
+ </tbody>
144
+ </table>
145
+ </template>
@@ -0,0 +1,28 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import LayoutExpandableList from './LayoutExpandableList.vue'
3
+
4
+ // `component` is omitted: this is a generic SFC, which doesn't fit Storybook's
5
+ // `Meta<typeof Component>` typing. The render below uses it directly.
6
+ const meta = {
7
+ title: 'Layout/LayoutExpandableList',
8
+ tags: ['autodocs'],
9
+ } satisfies Meta
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ export const Default: Story = {
15
+ render: () => ({
16
+ components: { LayoutExpandableList },
17
+ setup() {
18
+ return { items: Array.from({ length: 12 }, (_, i) => `Item ${i + 1}`) }
19
+ },
20
+ template: `<div class="w-64">
21
+ <LayoutExpandableList :items="items" :max="4">
22
+ <template #default="{ item }">
23
+ <div class="text-sm py-1 border-b border-base">{{ item }}</div>
24
+ </template>
25
+ </LayoutExpandableList>
26
+ </div>`,
27
+ }),
28
+ }
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts" generic="T">
2
+ import { computed, ref } from 'vue'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ items: T[]
7
+ /** How many to show before collapsing. */
8
+ max?: number
9
+ /** Reveal this many more per "Show more" click; omit to reveal all at once. */
10
+ step?: number
11
+ /** Show a control to reverse the list order. */
12
+ reversible?: boolean
13
+ }>(),
14
+ { max: 5 },
15
+ )
16
+
17
+ const slots = defineSlots<{
18
+ default?: (props: { item: T, index: number }) => any
19
+ title?: () => any
20
+ }>()
21
+
22
+ const shown = ref(props.max)
23
+ const reversed = ref(false)
24
+
25
+ const ordered = computed(() => (reversed.value ? [...props.items].reverse() : props.items))
26
+ const visible = computed(() => ordered.value.slice(0, shown.value))
27
+ const hidden = computed(() => Math.max(0, props.items.length - shown.value))
28
+ const expanded = computed(() => shown.value >= props.items.length)
29
+ const hasHeader = computed(() => props.reversible || !!slots.title)
30
+
31
+ function more(): void {
32
+ shown.value = props.step ? Math.min(props.items.length, shown.value + props.step) : props.items.length
33
+ }
34
+ function showAll(): void {
35
+ shown.value = props.items.length
36
+ }
37
+ function collapse(): void {
38
+ shown.value = props.max
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <div>
44
+ <div v-if="hasHeader" class="text-sm mb-1 flex items-center justify-between">
45
+ <slot name="title" />
46
+ <button
47
+ v-if="reversible"
48
+ type="button"
49
+ class="btn-action-sm"
50
+ :aria-pressed="reversed"
51
+ @click="reversed = !reversed"
52
+ >
53
+ <span aria-hidden="true">⇅</span>
54
+ Reverse
55
+ </button>
56
+ </div>
57
+ <div class="relative">
58
+ <template v-for="(item, i) in visible" :key="i">
59
+ <slot :item="item" :index="i" />
60
+ </template>
61
+ <div
62
+ v-if="!expanded"
63
+ class="h-8 pointer-events-none inset-x-0 bottom-0 absolute bg-gradient-more"
64
+ />
65
+ </div>
66
+ <div v-if="hidden > 0 || expanded" class="text-sm mt-1 flex gap-3 items-center">
67
+ <button
68
+ v-if="hidden > 0"
69
+ type="button"
70
+ class="color-active outline-none rounded inline-flex gap-1 items-center hover:underline focus-visible:ring-2 focus-visible:ring-primary-500/40"
71
+ @click="more()"
72
+ >
73
+ Show more
74
+ <span class="text-micro px-1.5 rounded-full bg-active inline-flex h-4 items-center tabular-nums">+{{ hidden }}</span>
75
+ </button>
76
+ <button
77
+ v-if="step && hidden > step"
78
+ type="button"
79
+ class="outline-none rounded op-fade hover:op100 hover:underline focus-visible:ring-2 focus-visible:ring-primary-500/40"
80
+ @click="showAll()"
81
+ >
82
+ Show all
83
+ </button>
84
+ <button
85
+ v-if="expanded && items.length > max"
86
+ type="button"
87
+ class="outline-none rounded op-fade hover:op100 hover:underline focus-visible:ring-2 focus-visible:ring-primary-500/40"
88
+ @click="collapse()"
89
+ >
90
+ Show less
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </template>
@@ -0,0 +1,28 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import LayoutPanelGrids from './LayoutPanelGrids.vue'
3
+
4
+ const meta = {
5
+ title: 'Layout/LayoutPanelGrids',
6
+ component: LayoutPanelGrids,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ variant: { control: 'inline-radio', options: ['dots', 'grid'] },
10
+ },
11
+ args: { variant: 'dots', size: 16 },
12
+ } satisfies Meta<typeof LayoutPanelGrids>
13
+
14
+ export default meta
15
+ type Story = StoryObj<typeof meta>
16
+
17
+ export const Dots: Story = {
18
+ render: args => ({
19
+ components: { LayoutPanelGrids },
20
+ setup: () => ({ args }),
21
+ template: `<LayoutPanelGrids v-bind="args" class="rounded-lg border border-base">
22
+ <span class="text-sm color-muted">No data yet</span>
23
+ </LayoutPanelGrids>`,
24
+ }),
25
+ }
26
+
27
+ export const Grid: Story = { ...Dots, args: { variant: 'grid' } }
28
+ export const LargeCells: Story = { ...Dots, args: { variant: 'grid', size: 32 } }
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ /** Background pattern style. */
7
+ variant?: 'dots' | 'grid'
8
+ /** Pattern cell size in px. */
9
+ size?: number
10
+ }>(),
11
+ { variant: 'dots', size: 16 },
12
+ )
13
+
14
+ // Use the static `bg-dots` / `bg-grid` classes (statically extractable by
15
+ // UnoCSS) for the pattern, and override the cell size via inline
16
+ // `background-size` — a runtime-built `bg-grid-${size}` class would not be
17
+ // generated, so the pattern wouldn't render.
18
+ const patternClass = computed(() => (props.variant === 'grid' ? 'bg-grid' : 'bg-dots'))
19
+ const sizeStyle = computed(() => ({ backgroundSize: `${props.size}px ${props.size}px` }))
20
+ </script>
21
+
22
+ <template>
23
+ <div class="flex min-h-40 items-center justify-center relative" :class="patternClass" :style="sizeStyle">
24
+ <slot />
25
+ </div>
26
+ </template>
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import LayoutSectionBlock from './LayoutSectionBlock.vue'
3
+
4
+ const meta = {
5
+ title: 'Layout/LayoutSectionBlock',
6
+ component: LayoutSectionBlock,
7
+ tags: ['autodocs'],
8
+ args: { title: 'Section', icon: 'i-ph:folder' },
9
+ } satisfies Meta<typeof LayoutSectionBlock>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Open: Story = {
15
+ render: () => ({
16
+ components: { LayoutSectionBlock },
17
+ template: `<div class="w-80">
18
+ <LayoutSectionBlock title="Open by default" icon="i-ph:folder">
19
+ <p class="text-sm color-muted">Collapsible content.</p>
20
+ </LayoutSectionBlock>
21
+ </div>`,
22
+ }),
23
+ }
24
+
25
+ export const Collapsed: Story = {
26
+ render: () => ({
27
+ components: { LayoutSectionBlock },
28
+ template: `<div class="w-80 flex flex-col gap-2">
29
+ <LayoutSectionBlock title="Open by default" icon="i-ph:folder">
30
+ <p class="text-sm color-muted">Collapsible content.</p>
31
+ </LayoutSectionBlock>
32
+ <LayoutSectionBlock title="Collapsed" :open="false">
33
+ <p class="text-sm color-muted">Hidden until expanded.</p>
34
+ </LayoutSectionBlock>
35
+ </div>`,
36
+ }),
37
+ }
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
3
+
4
+ defineProps<{
5
+ title?: string
6
+ icon?: string
7
+ }>()
8
+
9
+ const open = defineModel<boolean>('open', { default: true })
10
+ </script>
11
+
12
+ <template>
13
+ <CollapsibleRoot v-model:open="open" class="border border-base rounded-lg overflow-hidden">
14
+ <CollapsibleTrigger
15
+ class="group text-sm font-medium px-3 py-2 outline-none bg-secondary flex gap-2 w-full transition items-center hover:bg-active focus-visible:ring-2 focus-visible:ring-primary-500/40"
16
+ >
17
+ <svg
18
+ width="1em"
19
+ height="1em"
20
+ viewBox="0 0 24 24"
21
+ class="op-fade transition-transform group-data-[state=open]:rotate-90"
22
+ aria-hidden="true"
23
+ >
24
+ <path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m9 6l6 6l-6 6" />
25
+ </svg>
26
+ <span v-if="icon" :class="icon" aria-hidden="true" />
27
+ <slot name="title">
28
+ {{ title }}
29
+ </slot>
30
+ </CollapsibleTrigger>
31
+ <CollapsibleContent>
32
+ <div class="p-3">
33
+ <slot />
34
+ </div>
35
+ </CollapsibleContent>
36
+ </CollapsibleRoot>
37
+ </template>
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import LayoutSideNav from './LayoutSideNav.vue'
4
+
5
+ // `component`/`typeof` omitted: the `v-model` + header/footer slots don't fit
6
+ // Storybook's args typing for a render-based story. The render uses it directly.
7
+ const meta = {
8
+ title: 'Layout/LayoutSideNav',
9
+ tags: ['autodocs'],
10
+ } satisfies Meta
11
+
12
+ export default meta
13
+ type Story = StoryObj
14
+
15
+ const items = [
16
+ { value: 'modules', label: 'Modules', icon: 'i-catppuccin:folder' },
17
+ { value: 'graph', label: 'Graph', icon: 'i-catppuccin:typescript' },
18
+ { value: 'issues', label: 'Issues', icon: 'i-catppuccin:json', badge: 12 },
19
+ { value: 'settings', label: 'Settings', icon: 'i-catppuccin:toml', disabled: true },
20
+ ]
21
+
22
+ export const Default: Story = {
23
+ render: () => ({
24
+ components: { LayoutSideNav },
25
+ setup() {
26
+ const active = ref('modules')
27
+ return { active, items }
28
+ },
29
+ template: `<div class="w-56 border border-base rounded-lg">
30
+ <LayoutSideNav v-model="active" :items="items" />
31
+ </div>`,
32
+ }),
33
+ }
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ export interface NavItem {
3
+ value: string
4
+ label?: string
5
+ /** Leading icon class (e.g. `i-catppuccin:folder`). */
6
+ icon?: string
7
+ /** Render the row as a link instead of a button. */
8
+ href?: string
9
+ /** Optional trailing count/label chip. */
10
+ badge?: string | number
11
+ disabled?: boolean
12
+ }
13
+
14
+ defineProps<{
15
+ items: NavItem[]
16
+ }>()
17
+
18
+ /** Currently active item `value`. */
19
+ const model = defineModel<string>()
20
+ </script>
21
+
22
+ <template>
23
+ <nav class="p-2 flex flex-col gap-0.5">
24
+ <slot name="header" />
25
+ <component
26
+ :is="item.href ? 'a' : 'button'"
27
+ v-for="item in items"
28
+ :key="item.value"
29
+ :href="item.href"
30
+ :type="item.href ? undefined : 'button'"
31
+ :aria-current="item.value === model ? 'page' : undefined"
32
+ class="text-sm px-2 py-1.5 text-left outline-none rounded-md flex gap-2 w-full transition items-center focus-visible:ring-2 focus-visible:ring-primary-500/40"
33
+ :class="[
34
+ item.value === model ? 'bg-active color-active font-medium' : 'color-muted hover:bg-active hover:color-base',
35
+ item.disabled ? 'op50 pointer-events-none' : '',
36
+ ]"
37
+ @click="!item.href && (model = item.value)"
38
+ >
39
+ <span v-if="item.icon" :class="item.icon" aria-hidden="true" />
40
+ <span class="flex-1 truncate">{{ item.label ?? item.value }}</span>
41
+ <span
42
+ v-if="item.badge != null"
43
+ class="text-micro px-1.5 rounded-full bg-active"
44
+ >{{ item.badge }}</span>
45
+ </component>
46
+ <slot name="footer" />
47
+ </nav>
48
+ </template>
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { Pane } from 'splitpanes'
3
+ import LayoutSplitPane from './LayoutSplitPane.vue'
4
+
5
+ const meta = {
6
+ title: 'Layout/LayoutSplitPane',
7
+ component: LayoutSplitPane,
8
+ tags: ['autodocs'],
9
+ } satisfies Meta<typeof LayoutSplitPane>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Horizontal: Story = {
15
+ render: () => ({
16
+ components: { LayoutSplitPane, Pane },
17
+ template: `<div class="border border-base rounded-lg h-64 w-full overflow-hidden">
18
+ <LayoutSplitPane>
19
+ <Pane :size="40">
20
+ <div class="text-sm color-muted h-full p-3">Left pane</div>
21
+ </Pane>
22
+ <Pane :size="60">
23
+ <div class="text-sm color-muted h-full p-3">Right pane</div>
24
+ </Pane>
25
+ </LayoutSplitPane>
26
+ </div>`,
27
+ }),
28
+ }
29
+
30
+ export const Vertical: Story = {
31
+ render: () => ({
32
+ components: { LayoutSplitPane, Pane },
33
+ template: `<div class="border border-base rounded-lg h-64 w-full overflow-hidden">
34
+ <LayoutSplitPane horizontal>
35
+ <Pane>
36
+ <div class="text-sm color-muted h-full p-3">Top pane</div>
37
+ </Pane>
38
+ <Pane>
39
+ <div class="text-sm color-muted h-full p-3">Bottom pane</div>
40
+ </Pane>
41
+ </LayoutSplitPane>
42
+ </div>`,
43
+ }),
44
+ }
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import type { SplitpanesResizedPayload } from 'splitpanes'
3
+ import { useLocalStorage } from '@vueuse/core'
4
+ import { Splitpanes } from 'splitpanes'
5
+ import { ref } from 'vue'
6
+ // Base layout + theming ship together in `@antfu/design/styles/splitpanes.css`.
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ horizontal?: boolean
11
+ /** Persist pane sizes under this localStorage key (via VueUse). */
12
+ storageKey?: string
13
+ }>(),
14
+ {},
15
+ )
16
+
17
+ const sizes = props.storageKey
18
+ ? useLocalStorage<number[]>(props.storageKey, [])
19
+ : ref<number[]>([])
20
+
21
+ function onResized(payload: SplitpanesResizedPayload): void {
22
+ sizes.value = payload.panes.map(p => p.size)
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <Splitpanes :horizontal="horizontal" @resized="onResized">
28
+ <slot :sizes="sizes" />
29
+ </Splitpanes>
30
+ </template>
@@ -0,0 +1,43 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import LayoutTabs from './LayoutTabs.vue'
4
+
5
+ const meta = {
6
+ title: 'Layout/LayoutTabs',
7
+ component: LayoutTabs,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ variant: { control: 'inline-radio', options: ['underline', 'segment'] },
11
+ },
12
+ args: { tabs: [] }, // required prop; each story supplies real tabs via render
13
+ } satisfies Meta<typeof LayoutTabs>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ export const Underline: Story = {
19
+ render: () => ({
20
+ components: { LayoutTabs },
21
+ setup() {
22
+ return {
23
+ tab: ref('overview'),
24
+ tabs: [
25
+ { value: 'overview', label: 'Overview', count: 12 },
26
+ { value: 'deps', label: 'Dependencies', count: 48 },
27
+ { value: 'settings', label: 'Settings' },
28
+ ],
29
+ }
30
+ },
31
+ template: `<LayoutTabs v-model="tab" :tabs="tabs" class="w-96" />`,
32
+ }),
33
+ }
34
+
35
+ export const Segment: Story = {
36
+ render: () => ({
37
+ components: { LayoutTabs },
38
+ setup() {
39
+ return { tab: ref('list'), tabs: [{ value: 'list', label: 'List' }, { value: 'grid', label: 'Grid' }] }
40
+ },
41
+ template: `<LayoutTabs v-model="tab" :tabs="tabs" variant="segment" />`,
42
+ }),
43
+ }
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { TabsIndicator, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
3
+
4
+ export interface TabItem {
5
+ value: string
6
+ label?: string
7
+ icon?: string
8
+ /** Optional count chip. */
9
+ count?: number
10
+ /** Render this trigger as a link (nav-style tabs). */
11
+ href?: string
12
+ disabled?: boolean
13
+ }
14
+
15
+ withDefaults(
16
+ defineProps<{
17
+ tabs: TabItem[]
18
+ /** `underline` (default) or `segment` (pill switcher). */
19
+ variant?: 'underline' | 'segment'
20
+ }>(),
21
+ { variant: 'underline' },
22
+ )
23
+
24
+ const model = defineModel<string>()
25
+ </script>
26
+
27
+ <template>
28
+ <TabsRoot v-model="model" :class="variant === 'segment' ? '' : 'border-b border-base'">
29
+ <TabsList
30
+ class="flex items-center relative"
31
+ :class="variant === 'segment' ? 'gap-1 p-1 rounded-lg bg-secondary w-max' : 'gap-1'"
32
+ >
33
+ <TabsTrigger
34
+ v-for="tab in tabs"
35
+ :key="tab.value"
36
+ :value="tab.value"
37
+ :as="tab.href ? 'a' : 'button'"
38
+ :href="tab.href"
39
+ :disabled="tab.disabled"
40
+ class="text-sm color-muted outline-none flex gap-1.5 transition items-center relative disabled:op50 focus-visible:ring-2 focus-visible:ring-primary-500/40"
41
+ :class="variant === 'segment'
42
+ ? 'px-3 py-1 rounded-md data-[state=active]:bg-base data-[state=active]:color-base data-[state=active]:shadow-sm'
43
+ : 'px-3 py-2 -mb-px border-b-2 border-transparent hover:color-base data-[state=active]:color-active data-[state=active]:border-primary-500 dark:data-[state=active]:border-primary-400'"
44
+ >
45
+ <span v-if="tab.icon" :class="tab.icon" aria-hidden="true" />
46
+ {{ tab.label ?? tab.value }}
47
+ <span
48
+ v-if="tab.count != null"
49
+ class="text-micro font-mono px-1.5 rounded-full bg-active inline-flex h-5 min-w-5 items-center justify-center tabular-nums"
50
+ >{{ tab.count }}</span>
51
+ </TabsTrigger>
52
+ <TabsIndicator v-if="variant === 'underline'" class="rounded-full bg-primary-500 h-0.5 w-[--reka-tabs-indicator-size] translate-x-[--reka-tabs-indicator-position] transition-all duration-200 bottom-0 left-0 absolute" />
53
+ </TabsList>
54
+ <slot />
55
+ </TabsRoot>
56
+ </template>