@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,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import FormSelect from './FormSelect.vue'
4
+
5
+ const meta = {
6
+ title: 'Form/FormSelect',
7
+ component: FormSelect,
8
+ tags: ['autodocs'],
9
+ args: { options: [] }, // required prop; each story supplies real options via render
10
+ } satisfies Meta<typeof FormSelect>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ export const Default: Story = {
16
+ render: () => ({
17
+ components: { FormSelect },
18
+ setup() {
19
+ return {
20
+ value: ref('green'),
21
+ options: [
22
+ { value: 'green', label: 'antfu green' },
23
+ { value: 'blue', label: 'GitHub blue' },
24
+ { value: 'purple', label: 'Vite purple' },
25
+ ],
26
+ }
27
+ },
28
+ template: `<FormSelect v-model="value" :options="options" placeholder="Pick a theme" />`,
29
+ }),
30
+ }
31
+
32
+ export const Empty: Story = {
33
+ render: () => ({
34
+ components: { FormSelect },
35
+ setup() {
36
+ return {
37
+ value: ref<string>(),
38
+ options: [
39
+ { value: 'a', label: 'Option A' },
40
+ { value: 'b', label: 'Option B' },
41
+ { value: 'c', label: 'Disabled', disabled: true },
42
+ ],
43
+ }
44
+ },
45
+ template: `<FormSelect v-model="value" :options="options" placeholder="Select…" />`,
46
+ }),
47
+ }
@@ -0,0 +1,56 @@
1
+ <script setup lang="ts">
2
+ import { SelectContent, SelectIcon, SelectItem, SelectItemIndicator, SelectItemText, SelectPortal, SelectRoot, SelectTrigger, SelectValue, SelectViewport } from 'reka-ui'
3
+
4
+ export interface SelectOption {
5
+ value: string
6
+ label?: string
7
+ disabled?: boolean
8
+ }
9
+
10
+ defineProps<{
11
+ options: SelectOption[]
12
+ placeholder?: string
13
+ disabled?: boolean
14
+ }>()
15
+
16
+ const model = defineModel<string>()
17
+ </script>
18
+
19
+ <template>
20
+ <SelectRoot v-model="model" :disabled="disabled">
21
+ <SelectTrigger
22
+ class="text-sm px-2.5 outline-none border border-base rounded bg-base inline-flex gap-2 h-9 min-w-40 transition items-center justify-between data-[disabled]:op50 data-[disabled]:pointer-events-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
23
+ >
24
+ <SelectValue :placeholder="placeholder ?? 'Select…'" />
25
+ <SelectIcon class="op-fade">
26
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
27
+ <path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="m6 9l6 6l6-6" />
28
+ </svg>
29
+ </SelectIcon>
30
+ </SelectTrigger>
31
+ <SelectPortal>
32
+ <SelectContent
33
+ position="popper"
34
+ :side-offset="6"
35
+ class="border border-base rounded-lg bg-base min-w-[--reka-select-trigger-width] shadow-lg z-dropdown overflow-hidden"
36
+ >
37
+ <SelectViewport class="p-1">
38
+ <SelectItem
39
+ v-for="opt in options"
40
+ :key="opt.value"
41
+ :value="opt.value"
42
+ :disabled="opt.disabled"
43
+ class="text-sm color-base py-1.5 pl-7 pr-2 outline-none rounded-md flex gap-2 cursor-pointer select-none transition items-center relative data-[highlighted]:bg-active data-[disabled]:op50 data-[disabled]:pointer-events-none"
44
+ >
45
+ <SelectItemIndicator class="color-active inline-flex items-center left-1.5 absolute">
46
+ <svg width="0.85em" height="0.85em" viewBox="0 0 24 24" aria-hidden="true">
47
+ <path fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M20 6L9 17l-5-5" />
48
+ </svg>
49
+ </SelectItemIndicator>
50
+ <SelectItemText>{{ opt.label ?? opt.value }}</SelectItemText>
51
+ </SelectItem>
52
+ </SelectViewport>
53
+ </SelectContent>
54
+ </SelectPortal>
55
+ </SelectRoot>
56
+ </template>
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import FormSwitch from './FormSwitch.vue'
4
+
5
+ const meta = {
6
+ title: 'Form/FormSwitch',
7
+ component: FormSwitch,
8
+ tags: ['autodocs'],
9
+ } satisfies Meta<typeof FormSwitch>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ export const Default: Story = {
15
+ render: () => ({
16
+ components: { FormSwitch },
17
+ setup() {
18
+ return { on: ref(true) }
19
+ },
20
+ template: `<FormSwitch v-model="on" label="Dark surfaces" />`,
21
+ }),
22
+ }
23
+
24
+ export const States: Story = {
25
+ render: () => ({
26
+ components: { FormSwitch },
27
+ setup() {
28
+ return { a: ref(true), b: ref(false), c: ref(false) }
29
+ },
30
+ template: `<div class="flex flex-col gap-3">
31
+ <FormSwitch v-model="a" label="On" />
32
+ <FormSwitch v-model="b" label="Off" />
33
+ <FormSwitch v-model="c" label="Disabled" disabled />
34
+ </div>`,
35
+ }),
36
+ }
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { SwitchRoot, SwitchThumb } from 'reka-ui'
3
+
4
+ defineProps<{
5
+ label?: string
6
+ disabled?: boolean
7
+ }>()
8
+
9
+ const model = defineModel<boolean>({ default: false })
10
+ </script>
11
+
12
+ <template>
13
+ <label
14
+ class="text-sm inline-flex gap-2 cursor-pointer select-none items-center"
15
+ :class="{ 'op50 pointer-events-none': disabled }"
16
+ >
17
+ <SwitchRoot
18
+ v-model="model"
19
+ :disabled="disabled"
20
+ class="outline-none rounded-full bg-active h-5 w-9 transition relative data-[state=checked]:bg-primary-500 focus-visible:ring-2 focus-visible:ring-primary-500/40"
21
+ >
22
+ <SwitchThumb class="rounded-full bg-white h-4 w-4 block shadow translate-x-0.5 transition-transform data-[state=checked]:translate-x-4" />
23
+ </SwitchRoot>
24
+ <span v-if="label || $slots.default"><slot>{{ label }}</slot></span>
25
+ </label>
26
+ </template>
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import FormTextInput from './FormTextInput.vue'
4
+
5
+ const meta = {
6
+ title: 'Form/FormTextInput',
7
+ component: FormTextInput,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ size: { control: 'inline-radio', options: ['sm', 'md'] },
11
+ },
12
+ } satisfies Meta<typeof FormTextInput>
13
+
14
+ export default meta
15
+ type Story = StoryObj<typeof meta>
16
+
17
+ export const Default: Story = {
18
+ render: () => ({
19
+ components: { FormTextInput },
20
+ setup() {
21
+ return { text: ref('') }
22
+ },
23
+ template: `<div class="w-72"><FormTextInput v-model="text" placeholder="Your name" icon="i-ph:folder" clearable /></div>`,
24
+ }),
25
+ }
26
+
27
+ export const States: Story = {
28
+ render: () => ({
29
+ components: { FormTextInput },
30
+ setup() {
31
+ return { a: ref('Hello'), b: ref(''), c: ref('Locked') }
32
+ },
33
+ template: `<div class="flex flex-col gap-3 w-72">
34
+ <FormTextInput v-model="a" placeholder="Default" clearable />
35
+ <FormTextInput v-model="b" placeholder="Small" size="sm" />
36
+ <FormTextInput v-model="c" placeholder="Disabled" disabled />
37
+ </div>`,
38
+ }),
39
+ }
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ icon?: string
5
+ clearable?: boolean
6
+ placeholder?: string
7
+ type?: string
8
+ size?: 'sm' | 'md'
9
+ disabled?: boolean
10
+ /** Render the invalid (red) state. */
11
+ invalid?: boolean
12
+ }>(),
13
+ { type: 'text', size: 'md' },
14
+ )
15
+
16
+ const model = defineModel<string>({ default: '' })
17
+ </script>
18
+
19
+ <template>
20
+ <label
21
+ class="px-2 border rounded bg-base inline-flex gap-2 transition items-center focus-within:ring-2"
22
+ :class="[
23
+ size === 'sm' ? 'h-7 text-sm' : 'h-9',
24
+ invalid ? 'border-red-500/60 focus-within:ring-red-500/40' : 'border-base focus-within:ring-primary-500/40',
25
+ { 'op50 pointer-events-none': disabled },
26
+ ]"
27
+ >
28
+ <span v-if="icon" :class="icon" class="op-fade shrink-0" aria-hidden="true" />
29
+ <slot name="prefix" />
30
+ <input
31
+ v-model="model"
32
+ :type="type"
33
+ :placeholder="placeholder"
34
+ :disabled="disabled"
35
+ :aria-invalid="invalid || undefined"
36
+ class="color-base outline-none bg-transparent flex-1 min-w-0 placeholder:op-mute"
37
+ >
38
+ <button
39
+ v-if="clearable && model"
40
+ type="button"
41
+ class="op-fade shrink-0 transition hover:op100"
42
+ aria-label="Clear"
43
+ @click="model = ''"
44
+ >
45
+ <svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
46
+ <path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
47
+ </svg>
48
+ </button>
49
+ <slot name="suffix" />
50
+ </label>
51
+ </template>
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import FormTextarea from './FormTextarea.vue'
4
+
5
+ const meta = {
6
+ title: 'Form/FormTextarea',
7
+ component: FormTextarea,
8
+ tags: ['autodocs'],
9
+ args: { placeholder: 'Write something…', rows: 4, resize: true },
10
+ } satisfies Meta<typeof FormTextarea>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ export const Default: Story = {
16
+ render: args => ({
17
+ components: { FormTextarea },
18
+ setup() {
19
+ return { args, text: ref('') }
20
+ },
21
+ template: `<div class="w-72"><FormTextarea v-bind="args" v-model="text" /></div>`,
22
+ }),
23
+ }
24
+
25
+ export const Invalid: Story = {
26
+ render: args => ({
27
+ components: { FormTextarea },
28
+ setup() {
29
+ return { args, text: ref('Too short') }
30
+ },
31
+ template: `<div class="w-72"><FormTextarea v-bind="args" v-model="text" invalid /></div>`,
32
+ }),
33
+ }
34
+
35
+ export const States: Story = {
36
+ render: () => ({
37
+ components: { FormTextarea },
38
+ setup() {
39
+ return { a: ref('Resizable by default'), b: ref('No resize handle'), c: ref('Disabled') }
40
+ },
41
+ template: `<div class="flex flex-col gap-3 w-72">
42
+ <FormTextarea v-model="a" placeholder="Default" />
43
+ <FormTextarea v-model="b" placeholder="Fixed" :resize="false" />
44
+ <FormTextarea v-model="c" placeholder="Disabled" disabled />
45
+ </div>`,
46
+ }),
47
+ }
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ placeholder?: string
5
+ /** Visible rows. */
6
+ rows?: number
7
+ disabled?: boolean
8
+ /** Render the invalid (red) state. */
9
+ invalid?: boolean
10
+ /** Allow the user to resize the textarea. */
11
+ resize?: boolean
12
+ }>(),
13
+ { rows: 4, resize: true },
14
+ )
15
+
16
+ const model = defineModel<string>({ default: '' })
17
+ </script>
18
+
19
+ <template>
20
+ <textarea
21
+ v-model="model"
22
+ :rows="rows"
23
+ :placeholder="placeholder"
24
+ :disabled="disabled"
25
+ :aria-invalid="invalid || undefined"
26
+ class="color-base px-2 py-1.5 outline-none border rounded bg-base w-full transition placeholder:op-mute focus:ring-2"
27
+ :class="[
28
+ invalid ? 'border-red-500/60 focus:ring-red-500/40' : 'border-base focus:ring-primary-500/40',
29
+ { 'resize-none': !resize, 'op50 pointer-events-none': disabled },
30
+ ]"
31
+ />
32
+ </template>
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import LayoutBreadcrumb from './LayoutBreadcrumb.vue'
3
+
4
+ const meta = {
5
+ title: 'Layout/LayoutBreadcrumb',
6
+ component: LayoutBreadcrumb,
7
+ tags: ['autodocs'],
8
+ args: {
9
+ items: [
10
+ { label: 'Home', href: '#' },
11
+ { label: 'Projects', href: '#' },
12
+ { label: 'design', href: '#' },
13
+ { label: 'LayoutBreadcrumb.vue' },
14
+ ],
15
+ },
16
+ } satisfies Meta<typeof LayoutBreadcrumb>
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof meta>
20
+
21
+ export const Default: Story = {}
22
+
23
+ export const ChevronSeparator: Story = {
24
+ render: args => ({
25
+ components: { LayoutBreadcrumb },
26
+ setup() {
27
+ return { args }
28
+ },
29
+ template: `<LayoutBreadcrumb v-bind="args">
30
+ <template #separator>
31
+ <svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
32
+ <path fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M6 3l5 5-5 5" />
33
+ </svg>
34
+ </template>
35
+ </LayoutBreadcrumb>`,
36
+ }),
37
+ }
38
+
39
+ export const Truncated: Story = {
40
+ args: {
41
+ items: [
42
+ { label: 'Home', href: '#' },
43
+ { label: 'A very long intermediate section name that should truncate', href: '#' },
44
+ { label: 'Another long current page title that is also quite verbose' },
45
+ ],
46
+ },
47
+ render: args => ({
48
+ components: { LayoutBreadcrumb },
49
+ setup() {
50
+ return { args }
51
+ },
52
+ template: `<div class="w-72"><LayoutBreadcrumb v-bind="args" /></div>`,
53
+ }),
54
+ }
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ export interface Crumb {
3
+ label: string
4
+ /** Link target; the last crumb is rendered as plain text regardless. */
5
+ href?: string
6
+ /** Optional leading icon class. */
7
+ icon?: string
8
+ }
9
+
10
+ withDefaults(
11
+ defineProps<{
12
+ items: Crumb[]
13
+ /** Text rendered between crumbs; overridden by the `#separator` slot. */
14
+ separator?: string
15
+ }>(),
16
+ { separator: '/' },
17
+ )
18
+
19
+ defineSlots<{
20
+ separator?: () => any
21
+ }>()
22
+ </script>
23
+
24
+ <template>
25
+ <nav aria-label="Breadcrumb">
26
+ <ol class="text-sm flex flex-wrap min-w-0 items-center">
27
+ <li
28
+ v-for="(item, i) in items"
29
+ :key="i"
30
+ class="flex min-w-0 items-center"
31
+ >
32
+ <span v-if="i > 0" class="mx-1 op-mute shrink-0" aria-hidden="true">
33
+ <slot name="separator">{{ separator }}</slot>
34
+ </span>
35
+ <a
36
+ v-if="item.href && i < items.length - 1"
37
+ :href="item.href"
38
+ class="color-muted outline-none rounded inline-flex gap-1 min-w-0 transition items-center hover:color-active focus-visible:ring-2 focus-visible:ring-primary-500/40"
39
+ >
40
+ <span v-if="item.icon" :class="item.icon" class="shrink-0" aria-hidden="true" />
41
+ <span class="truncate">{{ item.label }}</span>
42
+ </a>
43
+ <span
44
+ v-else
45
+ class="color-base inline-flex gap-1 min-w-0 items-center"
46
+ :aria-current="i === items.length - 1 ? 'page' : undefined"
47
+ >
48
+ <span v-if="item.icon" :class="item.icon" class="shrink-0" aria-hidden="true" />
49
+ <span class="truncate">{{ item.label }}</span>
50
+ </span>
51
+ </li>
52
+ </ol>
53
+ </nav>
54
+ </template>
@@ -0,0 +1,31 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import LayoutCard from './LayoutCard.vue'
3
+
4
+ const meta = {
5
+ title: 'Layout/LayoutCard',
6
+ component: LayoutCard,
7
+ tags: ['autodocs'],
8
+ } satisfies Meta<typeof LayoutCard>
9
+
10
+ export default meta
11
+ type Story = StoryObj<typeof meta>
12
+
13
+ export const Default: Story = {
14
+ render: () => ({
15
+ components: { LayoutCard },
16
+ template: `<LayoutCard class="w-64">
17
+ <div class="font-medium">Card title</div>
18
+ <p class="text-sm color-muted mt-1">A bordered, token-driven surface.</p>
19
+ </LayoutCard>`,
20
+ }),
21
+ }
22
+
23
+ export const Elevated: Story = {
24
+ render: () => ({
25
+ components: { LayoutCard },
26
+ template: `<LayoutCard class="w-64" elevated>
27
+ <div class="font-medium">Elevated card</div>
28
+ <p class="text-sm color-muted mt-1">With a subtle drop shadow.</p>
29
+ </LayoutCard>`,
30
+ }),
31
+ }
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ as?: string
5
+ padding?: boolean
6
+ /** Subtle drop shadow. */
7
+ elevated?: boolean
8
+ }>(),
9
+ { padding: true },
10
+ )
11
+ </script>
12
+
13
+ <template>
14
+ <component
15
+ :is="as || 'div'"
16
+ class="border border-base rounded-xl bg-base"
17
+ :class="[{ 'p-4': padding }, { 'shadow-sm': elevated }]"
18
+ >
19
+ <slot />
20
+ </component>
21
+ </template>
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import { ref } from 'vue'
3
+ import LayoutDataTable from './LayoutDataTable.vue'
4
+
5
+ // `component` is omitted: this is a generic SFC, which doesn't fit Storybook's
6
+ // `Meta<typeof Component>` typing. The render below uses it directly.
7
+ const meta = {
8
+ title: 'Layout/LayoutDataTable',
9
+ tags: ['autodocs'],
10
+ } satisfies Meta
11
+
12
+ export default meta
13
+ type Story = StoryObj
14
+
15
+ const columns = [
16
+ { key: 'name', label: 'Name', sortable: true },
17
+ { key: 'type', label: 'Type' },
18
+ { key: 'size', label: 'Size', align: 'right' as const, sortable: true, width: '6rem' },
19
+ { key: 'time', label: 'Modified', align: 'right' as const, sortable: true, width: '8rem' },
20
+ ]
21
+
22
+ const rows = [
23
+ { name: 'index.ts', type: 'file', size: 1240, time: '2026-06-20' },
24
+ { name: 'components', type: 'dir', size: 8, time: '2026-06-25' },
25
+ { name: 'README.md', type: 'file', size: 3072, time: '2026-06-18' },
26
+ { name: 'package.json', type: 'file', size: 856, time: '2026-06-26' },
27
+ { name: 'utils', type: 'dir', size: 12, time: '2026-06-22' },
28
+ ]
29
+
30
+ export const Default: Story = {
31
+ render: () => ({
32
+ components: { LayoutDataTable },
33
+ setup() {
34
+ return { columns, rows, sortBy: ref<string | undefined>('name') }
35
+ },
36
+ template: `<div class="border border-base rounded-lg max-w-2xl overflow-hidden">
37
+ <LayoutDataTable v-model:sortBy="sortBy" :columns="columns" :rows="rows" />
38
+ </div>`,
39
+ }),
40
+ }
41
+
42
+ export const CustomCells: Story = {
43
+ render: () => ({
44
+ components: { LayoutDataTable },
45
+ setup() {
46
+ return { columns, rows }
47
+ },
48
+ template: `<div class="border border-base rounded-lg max-w-2xl overflow-hidden">
49
+ <LayoutDataTable :columns="columns" :rows="rows" :sticky-header="false">
50
+ <template #cell="{ column, value }">
51
+ <span v-if="column.key === 'name'" class="font-mono">{{ value }}</span>
52
+ <span v-else-if="column.key === 'type'" class="badge-muted">{{ value }}</span>
53
+ <span v-else-if="column.key === 'size'" class="tabular-nums">{{ value }} B</span>
54
+ <span v-else class="color-muted tabular-nums">{{ value }}</span>
55
+ </template>
56
+ </LayoutDataTable>
57
+ </div>`,
58
+ }),
59
+ }
60
+
61
+ export const StickyHeader: Story = {
62
+ render: () => ({
63
+ components: { LayoutDataTable },
64
+ setup() {
65
+ const many = Array.from({ length: 30 }, (_, i) => ({
66
+ name: `file-${i + 1}.ts`,
67
+ type: i % 3 === 0 ? 'dir' : 'file',
68
+ size: (i + 1) * 128,
69
+ time: `2026-06-${String((i % 28) + 1).padStart(2, '0')}`,
70
+ }))
71
+ return { columns, rows: many, sortBy: ref<string | undefined>('size'), sortDir: ref<'asc' | 'desc'>('desc') }
72
+ },
73
+ template: `<div class="border border-base rounded-lg max-w-2xl h-64 overflow-auto">
74
+ <LayoutDataTable v-model:sortBy="sortBy" v-model:sortDir="sortDir" :columns="columns" :rows="rows" />
75
+ </div>`,
76
+ }),
77
+ }