@polymarbot/nuxt-layer-shadcn-ui 0.4.1 → 0.5.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 (35) hide show
  1. package/app/components/ui/Dropdown/ItemContent.vue +23 -0
  2. package/app/components/ui/Dropdown/ItemIcon.vue +41 -0
  3. package/app/components/ui/Dropdown/MenuItems.vue +160 -0
  4. package/app/components/ui/Dropdown/SlotRenderer.vue +18 -0
  5. package/app/components/ui/Dropdown/en.json +3 -0
  6. package/app/components/ui/Dropdown/index.stories.ts +91 -1
  7. package/app/components/ui/Dropdown/index.vue +43 -159
  8. package/app/components/ui/Dropdown/types.ts +47 -5
  9. package/app/components/ui/Progress/index.stories.ts +87 -0
  10. package/app/components/ui/Progress/index.vue +10 -0
  11. package/app/components/ui/Progress/types.ts +3 -0
  12. package/app/components/ui/Surface/index.stories.ts +7 -4
  13. package/app/components/ui/Surface/index.vue +33 -33
  14. package/app/components/ui/Surface/types.ts +1 -1
  15. package/i18n/messages/ar.json +3 -0
  16. package/i18n/messages/de.json +3 -0
  17. package/i18n/messages/en.json +3 -0
  18. package/i18n/messages/es.json +3 -0
  19. package/i18n/messages/fr.json +3 -0
  20. package/i18n/messages/hi.json +3 -0
  21. package/i18n/messages/id.json +3 -0
  22. package/i18n/messages/it.json +3 -0
  23. package/i18n/messages/ja.json +3 -0
  24. package/i18n/messages/ko.json +3 -0
  25. package/i18n/messages/nl.json +3 -0
  26. package/i18n/messages/pl.json +3 -0
  27. package/i18n/messages/pt.json +3 -0
  28. package/i18n/messages/ru.json +3 -0
  29. package/i18n/messages/th.json +3 -0
  30. package/i18n/messages/tr.json +3 -0
  31. package/i18n/messages/vi.json +3 -0
  32. package/i18n/messages/zh-CN.json +3 -0
  33. package/i18n/messages/zh-TW.json +3 -0
  34. package/nuxt.config.ts +9 -3
  35. package/package.json +2 -2
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import ItemIcon from './ItemIcon.vue'
3
+ import type { DropdownActionItem } from './types'
4
+
5
+ defineProps<{
6
+ item: DropdownActionItem
7
+ }>()
8
+ </script>
9
+
10
+ <template>
11
+ <ItemIcon
12
+ :icon="item.icon"
13
+ :iconColor="item.iconColor"
14
+ />
15
+ <span class="flex-1">
16
+ {{ item.label }}
17
+ </span>
18
+ <Icon
19
+ v-if="item.active"
20
+ name="check"
21
+ class="size-4"
22
+ />
23
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { cva } from 'class-variance-authority'
3
+ import type { DropdownActionItem } from './types'
4
+
5
+ const props = defineProps<{
6
+ /** Icon name (lucide kebab-case) or a Vue component. */
7
+ icon: DropdownActionItem['icon']
8
+ /** Override icon color independently of the surrounding item color. */
9
+ iconColor?: DropdownActionItem['iconColor']
10
+ }>()
11
+
12
+ const iconColorVariants = cva('', {
13
+ variants: {
14
+ color: {
15
+ default: '',
16
+ primary: 'text-primary',
17
+ success: 'text-success',
18
+ info: 'text-info',
19
+ help: 'text-help',
20
+ warn: 'text-warn',
21
+ danger: 'text-danger',
22
+ },
23
+ },
24
+ defaultVariants: { color: 'default' },
25
+ })
26
+
27
+ const colorClass = computed(() => iconColorVariants({ color: props.iconColor }))
28
+ </script>
29
+
30
+ <template>
31
+ <Icon
32
+ v-if="typeof icon === 'string'"
33
+ :name="icon"
34
+ :class="colorClass"
35
+ />
36
+ <component
37
+ :is="icon"
38
+ v-else-if="icon"
39
+ :class="cn('size-4', colorClass)"
40
+ />
41
+ </template>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ DropdownMenuItem,
4
+ DropdownMenuLabel,
5
+ DropdownMenuSeparator,
6
+ DropdownMenuSub,
7
+ DropdownMenuSubContent,
8
+ DropdownMenuSubTrigger,
9
+ } from '../../shadcn/dropdown-menu'
10
+ import { cva } from 'class-variance-authority'
11
+ import ItemContent from './ItemContent.vue'
12
+ import SlotRenderer from './SlotRenderer.vue'
13
+ import type {
14
+ DropdownActionItem,
15
+ DropdownCustomActionItem,
16
+ DropdownItem,
17
+ } from './types'
18
+
19
+ const actionColorVariants = cva('', {
20
+ variants: {
21
+ color: {
22
+ default: '',
23
+ primary: `
24
+ text-primary
25
+ focus:bg-primary/10 focus:text-primary
26
+ data-[state=open]:bg-primary/10 data-[state=open]:text-primary
27
+ `,
28
+ success: `
29
+ text-success
30
+ focus:bg-success/10 focus:text-success
31
+ data-[state=open]:bg-success/10 data-[state=open]:text-success
32
+ `,
33
+ info: `
34
+ text-info
35
+ focus:bg-info/10 focus:text-info
36
+ data-[state=open]:bg-info/10 data-[state=open]:text-info
37
+ `,
38
+ help: `
39
+ text-help
40
+ focus:bg-help/10 focus:text-help
41
+ data-[state=open]:bg-help/10 data-[state=open]:text-help
42
+ `,
43
+ warn: `
44
+ text-warn
45
+ focus:bg-warn/10 focus:text-warn
46
+ data-[state=open]:bg-warn/10 data-[state=open]:text-warn
47
+ `,
48
+ danger: `
49
+ text-danger
50
+ focus:bg-danger/10 focus:text-danger
51
+ data-[state=open]:bg-danger/10 data-[state=open]:text-danger
52
+ `,
53
+ },
54
+ },
55
+ defaultVariants: { color: 'default' },
56
+ })
57
+
58
+ defineProps<{
59
+ menus: DropdownItem[]
60
+ }>()
61
+
62
+ const ctx = inject(dropdownContextKey)
63
+
64
+ const handleItemAction = (
65
+ item: DropdownActionItem | DropdownCustomActionItem,
66
+ event?: Event,
67
+ ) => {
68
+ if (item.disabled) {
69
+ event?.preventDefault()
70
+ return
71
+ }
72
+ item.command?.()
73
+ ctx?.hide()
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <template
79
+ v-for="(menu, index) in menus"
80
+ :key="index"
81
+ >
82
+ <!-- Built-in: separator -->
83
+ <DropdownMenuSeparator v-if="menu.type === 'separator'" />
84
+ <!-- Built-in: group label -->
85
+ <DropdownMenuLabel
86
+ v-else-if="menu.type === 'label'"
87
+ class="text-xs font-normal text-muted-foreground"
88
+ >
89
+ {{ menu.label }}
90
+ </DropdownMenuLabel>
91
+ <!-- Custom label: content via named slot -->
92
+ <DropdownMenuLabel
93
+ v-else-if="menu.type === 'custom-label'"
94
+ :class="cn('p-0 font-normal', menu.class)"
95
+ >
96
+ <SlotRenderer
97
+ :slotName="menu.slot"
98
+ :item="menu"
99
+ />
100
+ </DropdownMenuLabel>
101
+ <!-- Custom action: content via named slot -->
102
+ <DropdownMenuItem
103
+ v-else-if="menu.type === 'custom-action'"
104
+ :disabled="menu.disabled"
105
+ :class="cn(actionColorVariants({ color: menu.color }), menu.class)"
106
+ @click="handleItemAction(menu, $event)"
107
+ >
108
+ <SlotRenderer
109
+ :slotName="menu.slot"
110
+ :item="menu"
111
+ />
112
+ <Icon
113
+ v-if="menu.active"
114
+ name="check"
115
+ class="size-4 ml-auto"
116
+ />
117
+ </DropdownMenuItem>
118
+ <!-- Action with sub-menu -->
119
+ <DropdownMenuSub v-else-if="menu.subMenus?.length">
120
+ <DropdownMenuSubTrigger
121
+ :disabled="menu.disabled"
122
+ :class="cn(actionColorVariants({ color: menu.color }), menu.class)"
123
+ >
124
+ <ItemContent :item="menu" />
125
+ </DropdownMenuSubTrigger>
126
+ <DropdownMenuSubContent :style="ctx?.contentStyle.value">
127
+ <MenuItems :menus="menu.subMenus" />
128
+ </DropdownMenuSubContent>
129
+ </DropdownMenuSub>
130
+ <!-- Built-in: action (default). When `href` is set, asChild merges
131
+ our props (incl. @click) onto the WebLink, so the click handler
132
+ only needs to live here. -->
133
+ <DropdownMenuItem
134
+ v-else
135
+ :disabled="menu.disabled"
136
+ :asChild="!!menu.href"
137
+ :class="cn(actionColorVariants({ color: menu.color }), menu.class)"
138
+ @click="handleItemAction(menu, $event)"
139
+ >
140
+ <WebLink
141
+ v-if="menu.href"
142
+ unstyled
143
+ :href="menu.href"
144
+ :target="menu.target"
145
+ class="gap-2 flex w-full items-center"
146
+ >
147
+ <ItemContent :item="menu" />
148
+ <Icon
149
+ v-if="isUrl(menu.href)"
150
+ name="external-link"
151
+ class="size-3.5 text-muted-foreground"
152
+ />
153
+ </WebLink>
154
+ <ItemContent
155
+ v-else
156
+ :item="menu"
157
+ />
158
+ </DropdownMenuItem>
159
+ </template>
160
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ /** Name of the slot on the root Dropdown to render. */
4
+ slotName: string
5
+ /** Forwarded to the slot scope as `{ item }`. */
6
+ item: object
7
+ }>()
8
+
9
+ const ctx = inject(dropdownContextKey)
10
+ </script>
11
+
12
+ <template>
13
+ <component
14
+ :is="ctx.slots[slotName]"
15
+ v-if="ctx?.slots[slotName]"
16
+ :item="item"
17
+ />
18
+ </template>
@@ -0,0 +1,3 @@
1
+ {
2
+ "empty": "No items"
3
+ }
@@ -23,7 +23,7 @@ const accountMenus: DropdownItem[] = [
23
23
  ]
24
24
 
25
25
  const linkMenus: DropdownItem[] = [
26
- { label: 'Documentation', icon: 'book-open', href: 'https://example.com/docs', target: '_blank' },
26
+ { label: 'Documentation', icon: 'book-open', href: 'https://example.com/docs', target: '_blank', active: true },
27
27
  { label: 'Support', icon: 'life-buoy', href: 'https://example.com/support', target: '_blank' },
28
28
  ]
29
29
 
@@ -56,6 +56,53 @@ const customMenus: DropdownItem[] = [
56
56
  },
57
57
  ]
58
58
 
59
+ const iconColorMenus: DropdownItem[] = [
60
+ { label: 'Default item', icon: 'circle' },
61
+ { label: 'Primary icon only', icon: 'star', iconColor: 'primary' },
62
+ { label: 'Success icon only', icon: 'circle-check', iconColor: 'success' },
63
+ { label: 'Warn icon only', icon: 'triangle-alert', iconColor: 'warn' },
64
+ { label: 'Danger icon only', icon: 'shield-alert', iconColor: 'danger' },
65
+ { type: 'separator' },
66
+ { label: 'Both danger', icon: 'trash-2', color: 'danger', iconColor: 'danger' },
67
+ { label: 'Danger label, info icon', icon: 'info', color: 'danger', iconColor: 'info' },
68
+ ]
69
+
70
+ const subMenus: DropdownItem[] = [
71
+ { label: 'New File', icon: 'file-plus' },
72
+ { label: 'New Folder', icon: 'folder-plus' },
73
+ { type: 'separator' },
74
+ {
75
+ label: 'Share',
76
+ icon: 'share-2',
77
+ subMenus: [
78
+ { label: 'Email link', icon: 'mail' },
79
+ { label: 'Copy link', icon: 'link' },
80
+ { type: 'separator' },
81
+ {
82
+ label: 'Social',
83
+ icon: 'globe',
84
+ subMenus: [
85
+ { label: 'Twitter', icon: 'twitter' },
86
+ { label: 'Facebook', icon: 'facebook' },
87
+ { label: 'LinkedIn', icon: 'linkedin' },
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ {
93
+ label: 'Move to',
94
+ icon: 'folder-symlink',
95
+ active: true,
96
+ subMenus: [
97
+ { label: 'Documents', icon: 'folder' },
98
+ { label: 'Downloads', icon: 'folder', active: true },
99
+ { label: 'Trash', icon: 'trash-2', color: 'danger', iconColor: 'danger' },
100
+ ],
101
+ },
102
+ { type: 'separator' },
103
+ { label: 'Delete', icon: 'trash-2', color: 'danger' },
104
+ ]
105
+
59
106
  const meta = {
60
107
  title: 'UI/Dropdown',
61
108
  component: Dropdown,
@@ -65,6 +112,7 @@ const meta = {
65
112
  side: { control: 'select', options: sides },
66
113
  align: { control: 'select', options: aligns },
67
114
  sideOffset: { control: 'number' },
115
+ minWidth: { control: 'text' },
68
116
  },
69
117
  args: {
70
118
  menus: basicMenus,
@@ -72,6 +120,7 @@ const meta = {
72
120
  side: undefined,
73
121
  align: undefined,
74
122
  sideOffset: undefined,
123
+ minWidth: undefined,
75
124
  },
76
125
  render: args => ({
77
126
  components: { Dropdown, Button },
@@ -123,6 +172,47 @@ export const WithGroups: Story = {
123
172
  },
124
173
  }
125
174
 
175
+ export const WithIconColor: Story = {
176
+ parameters: noControls,
177
+ args: {
178
+ menus: iconColorMenus,
179
+ trigger: 'click',
180
+ },
181
+ }
182
+
183
+ export const WithSubMenus: Story = {
184
+ parameters: noControls,
185
+ args: {
186
+ menus: subMenus,
187
+ trigger: 'click',
188
+ },
189
+ }
190
+
191
+ export const WithSubMenusHover: Story = {
192
+ parameters: noControls,
193
+ args: {
194
+ menus: subMenus,
195
+ trigger: 'hover',
196
+ },
197
+ }
198
+
199
+ export const WithMinWidth: Story = {
200
+ parameters: noControls,
201
+ args: {
202
+ menus: subMenus,
203
+ trigger: 'click',
204
+ minWidth: 240,
205
+ },
206
+ }
207
+
208
+ export const EmptyMenus: Story = {
209
+ parameters: noControls,
210
+ args: {
211
+ menus: [],
212
+ trigger: 'click',
213
+ },
214
+ }
215
+
126
216
  export const CustomSlots: Story = {
127
217
  parameters: {
128
218
  ...noControls,
@@ -2,50 +2,10 @@
2
2
  import {
3
3
  DropdownMenu,
4
4
  DropdownMenuContent,
5
- DropdownMenuItem,
6
- DropdownMenuLabel,
7
- DropdownMenuSeparator,
8
5
  DropdownMenuTrigger,
9
6
  } from '../../shadcn/dropdown-menu'
10
- import { cva } from 'class-variance-authority'
11
- import type {
12
- DropdownActionItem,
13
- DropdownCustomActionItem,
14
- DropdownProps,
15
- } from './types'
16
-
17
- const actionColorVariants = cva('', {
18
- variants: {
19
- color: {
20
- default: '',
21
- primary: `
22
- text-primary
23
- focus:bg-primary/10 focus:text-primary
24
- `,
25
- success: `
26
- text-success
27
- focus:bg-success/10 focus:text-success
28
- `,
29
- info: `
30
- text-info
31
- focus:bg-info/10 focus:text-info
32
- `,
33
- help: `
34
- text-help
35
- focus:bg-help/10 focus:text-help
36
- `,
37
- warn: `
38
- text-warn
39
- focus:bg-warn/10 focus:text-warn
40
- `,
41
- danger: `
42
- text-danger
43
- focus:bg-danger/10 focus:text-danger
44
- `,
45
- },
46
- },
47
- defaultVariants: { color: 'default' },
48
- })
7
+ import MenuItems from './MenuItems.vue'
8
+ import type { DropdownProps } from './types'
49
9
 
50
10
  defineOptions({ inheritAttrs: false })
51
11
 
@@ -53,14 +13,23 @@ const props = withDefaults(defineProps<DropdownProps>(), {
53
13
  menus: () => [],
54
14
  trigger: 'hover',
55
15
  class: undefined,
16
+ minWidth: undefined,
17
+ })
18
+
19
+ const contentStyle = computed<{ minWidth?: string } | undefined>(() => {
20
+ if (props.minWidth == null) return undefined
21
+ const value = typeof props.minWidth === 'number' ? `${props.minWidth}px` : props.minWidth
22
+ return { minWidth: value }
56
23
  })
57
24
 
58
- defineSlots<{
59
- default?: () => unknown
60
- popup?: (props: { hide: () => void }) => unknown
61
- [key: string]: ((props?: any) => unknown) | undefined
25
+ const slots = defineSlots<{
26
+ default?: () => any
27
+ popup?: (props: { hide: () => void }) => any
28
+ empty?: () => any
29
+ [key: string]: ((props?: any) => any) | undefined
62
30
  }>()
63
31
 
32
+ const T = useTranslations('components.ui.Dropdown')
64
33
  const { isMobile } = useDevice()
65
34
 
66
35
  // Force click trigger on mobile devices for better touch experience
@@ -107,17 +76,11 @@ const handleMenuLeave = () => {
107
76
  }
108
77
  }
109
78
 
110
- const handleItemAction = (
111
- item: DropdownActionItem | DropdownCustomActionItem,
112
- event?: Event,
113
- ) => {
114
- if (item.disabled) {
115
- event?.preventDefault()
116
- return
117
- }
118
- item.command?.()
119
- hide()
120
- }
79
+ provide(dropdownContextKey, {
80
+ hide,
81
+ slots,
82
+ contentStyle,
83
+ })
121
84
 
122
85
  onBeforeUnmount(() => {
123
86
  clearHideTimeout()
@@ -139,6 +102,7 @@ onBeforeUnmount(() => {
139
102
  <DropdownMenuContent
140
103
  v-bind="$attrs"
141
104
  :class="props.class"
105
+ :style="contentStyle"
142
106
  @mouseenter="handleMenuEnter"
143
107
  @mouseleave="handleMenuLeave"
144
108
  >
@@ -151,109 +115,29 @@ onBeforeUnmount(() => {
151
115
  </template>
152
116
 
153
117
  <!-- Default menu dropdown -->
154
- <template v-else>
155
- <template
156
- v-for="(item, index) in menus"
157
- :key="index"
118
+ <MenuItems
119
+ v-else-if="menus.length"
120
+ :menus="menus"
121
+ />
122
+
123
+ <!-- Empty placeholder. Default content is wrapped; #empty slot is not. -->
124
+ <slot
125
+ v-else
126
+ name="empty"
127
+ >
128
+ <div
129
+ class="
130
+ gap-2 px-2 py-4 text-sm text-muted-foreground flex flex-col
131
+ items-center
132
+ "
158
133
  >
159
- <!-- Built-in: separator -->
160
- <DropdownMenuSeparator v-if="item.type === 'separator'" />
161
- <!-- Built-in: group label -->
162
- <DropdownMenuLabel
163
- v-else-if="item.type === 'label'"
164
- class="text-xs font-normal text-muted-foreground"
165
- >
166
- {{ item.label }}
167
- </DropdownMenuLabel>
168
- <!-- Custom label: content via named slot -->
169
- <DropdownMenuLabel
170
- v-else-if="item.type === 'custom-label'"
171
- :class="cn('p-0 font-normal', item.class)"
172
- >
173
- <slot
174
- :name="item.slot"
175
- :item="item"
176
- />
177
- </DropdownMenuLabel>
178
- <!-- Custom action: content via named slot -->
179
- <DropdownMenuItem
180
- v-else-if="item.type === 'custom-action'"
181
- :disabled="item.disabled"
182
- :class="cn(actionColorVariants({ color: item.color }), item.class)"
183
- @click="handleItemAction(item, $event)"
184
- >
185
- <slot
186
- :name="item.slot"
187
- :item="item"
188
- />
189
- <Icon
190
- v-if="item.active"
191
- name="check"
192
- class="size-4 ml-auto"
193
- />
194
- </DropdownMenuItem>
195
- <!-- Built-in: action (default) -->
196
- <DropdownMenuItem
197
- v-else
198
- :disabled="item.disabled"
199
- :asChild="!!item.href"
200
- :class="cn(actionColorVariants({ color: item.color }), item.class)"
201
- @click="!item.href && handleItemAction(item, $event)"
202
- >
203
- <template v-if="item.href">
204
- <WebLink
205
- unstyled
206
- :href="item.href"
207
- :target="item.target"
208
- class="gap-2 flex w-full items-center"
209
- @click="handleItemAction(item, $event)"
210
- >
211
- <Icon
212
- v-if="typeof item.icon === 'string'"
213
- :name="item.icon"
214
- />
215
- <component
216
- :is="item.icon"
217
- v-else-if="item.icon"
218
- class="size-4"
219
- />
220
- <span class="flex-1">
221
- {{ item.label }}
222
- </span>
223
- <Icon
224
- v-if="isUrl(item.href)"
225
- name="external-link"
226
- class="size-3.5 text-muted-foreground"
227
- />
228
- <Icon
229
- v-if="item.active"
230
- name="check"
231
- class="size-4"
232
- />
233
- </WebLink>
234
- </template>
235
- <template v-else>
236
- <Icon
237
- v-if="typeof item.icon === 'string'"
238
- :name="item.icon"
239
- />
240
- <component
241
- :is="item.icon"
242
- v-else-if="item.icon"
243
- class="size-4"
244
- />
245
- <span class="flex-1">
246
- {{ item.label }}
247
- </span>
248
- <Icon
249
- v-if="item.active"
250
- name="check"
251
- class="size-4"
252
- />
253
- </template>
254
- </DropdownMenuItem>
255
- </template>
256
- </template>
134
+ <Icon
135
+ name="inbox"
136
+ class="size-6"
137
+ />
138
+ <span>{{ T('empty') }}</span>
139
+ </div>
140
+ </slot>
257
141
  </DropdownMenuContent>
258
142
  </DropdownMenu>
259
143
  </template>
@@ -1,29 +1,45 @@
1
1
  import type { DropdownMenuContentProps } from 'reka-ui'
2
- import type { Component } from 'vue'
2
+ import type { Component, ComputedRef, InjectionKey, Slots } from 'vue'
3
3
 
4
+ /** Semantic color, matches project-wide color scheme. */
4
5
  export type DropdownItemColor = 'default' | 'primary' | 'success' | 'info' | 'help' | 'warn' | 'danger'
5
6
 
6
7
  export interface DropdownActionItem {
7
- /** Defaults to 'action' when omitted. */
8
+ /** Item kind. Defaults to 'action' when omitted. */
8
9
  type?: 'action'
9
- /** Semantic color, matches project-wide color scheme. Defaults to 'default'. */
10
+ /** Foreground color of the whole item (label + focus background). */
10
11
  color?: DropdownItemColor
12
+ /** Override icon color independently of `color`. */
13
+ iconColor?: DropdownItemColor
14
+ /** Display text shown in the item. */
11
15
  label?: string
16
+ /** Icon name (lucide kebab-case) or a Vue component. */
12
17
  icon?: string | Component
18
+ /** Click handler. Ignored when `subMenus` is set. */
13
19
  command?: () => void
20
+ /** Disabled items are non-interactive and visually muted. */
14
21
  disabled?: boolean
22
+ /** Renders a trailing check icon to indicate selected/active state. */
15
23
  active?: boolean
24
+ /** Extra class merged onto the item element. */
16
25
  class?: ClassValue
26
+ /** Render the item as a link. Ignored when `subMenus` is set. */
17
27
  href?: string
28
+ /** Anchor target. Only meaningful with `href`. */
18
29
  target?: string
30
+ /** Nested sub-menu items. When provided, `command` / `href` are ignored. */
31
+ subMenus?: DropdownItem[]
19
32
  }
20
33
 
21
34
  export interface DropdownSeparatorItem {
35
+ /** Item kind. */
22
36
  type: 'separator'
23
37
  }
24
38
 
25
39
  export interface DropdownLabelItem {
40
+ /** Item kind. */
26
41
  type: 'label'
42
+ /** Group header text. */
27
43
  label: string
28
44
  }
29
45
 
@@ -33,14 +49,21 @@ export interface DropdownLabelItem {
33
49
  * to the slot as `item` for rendering.
34
50
  */
35
51
  export interface DropdownCustomActionItem {
52
+ /** Item kind. */
36
53
  type: 'custom-action'
37
- /** Semantic color, matches project-wide color scheme. Defaults to 'default'. */
54
+ /** Foreground color of the whole item (label + focus background). */
38
55
  color?: DropdownItemColor
56
+ /** Name of the slot that renders this item's content. */
39
57
  slot: string
58
+ /** Click handler. */
40
59
  command?: () => void
60
+ /** Disabled items are non-interactive and visually muted. */
41
61
  disabled?: boolean
62
+ /** Renders a trailing check icon to indicate selected/active state. */
42
63
  active?: boolean
64
+ /** Extra class merged onto the item element. */
43
65
  class?: ClassValue
66
+ /** Arbitrary extra data forwarded to the slot as `item`. */
44
67
  [field: string]: unknown
45
68
  }
46
69
 
@@ -50,9 +73,13 @@ export interface DropdownCustomActionItem {
50
73
  * to the slot as `item` for rendering.
51
74
  */
52
75
  export interface DropdownCustomLabelItem {
76
+ /** Item kind. */
53
77
  type: 'custom-label'
78
+ /** Name of the slot that renders this label's content. */
54
79
  slot: string
80
+ /** Extra class merged onto the label element. */
55
81
  class?: ClassValue
82
+ /** Arbitrary extra data forwarded to the slot as `item`. */
56
83
  [field: string]: unknown
57
84
  }
58
85
 
@@ -73,10 +100,25 @@ export type DropdownItem
73
100
  | DropdownCustomLabelItem
74
101
 
75
102
  export interface DropdownProps extends /* @vue-ignore */ DropdownMenuContentProps {
76
- /** Menu items to display in the dropdown (not required when using popup slot) */
103
+ /** Menu items to display in the dropdown. Not required when using the `popup` slot. */
77
104
  menus?: DropdownItem[]
78
105
  /** Trigger mode for showing the dropdown. Defaults to 'hover'. */
79
106
  trigger?: 'click' | 'hover'
80
107
  /** Extra class for the dropdown content container. */
81
108
  class?: ClassValue
109
+ /** Min-width applied to the root content and all sub-menus. Numbers are treated as px. */
110
+ minWidth?: string | number
82
111
  }
112
+
113
+ /** Context shared from the root Dropdown to nested MenuItems via provide/inject. */
114
+ export interface DropdownContext {
115
+ /** Closes the entire dropdown (root + any open sub-menus). */
116
+ hide: () => void
117
+ /** The root Dropdown's slots, used to render `custom-label` / `custom-action` items. */
118
+ slots: Slots
119
+ /** Inline style applied to root content and all sub-menus (currently min-width). */
120
+ contentStyle: ComputedRef<{ minWidth?: string } | undefined>
121
+ }
122
+
123
+ /** Provide/inject key for the shared DropdownContext. */
124
+ export const dropdownContextKey: InjectionKey<DropdownContext> = Symbol('dropdown-context')
@@ -0,0 +1,87 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import Progress from './index.vue'
3
+
4
+ const meta = {
5
+ title: 'UI/Progress',
6
+ component: Progress,
7
+ argTypes: {
8
+ modelValue: { control: { type: 'number', min: 0, max: 100 }},
9
+ max: { control: 'number' },
10
+ },
11
+ args: {
12
+ modelValue: 50,
13
+ max: 100,
14
+ },
15
+ render: args => ({
16
+ components: { Progress },
17
+ setup: () => ({ args }),
18
+ template: `
19
+ <div class="max-w-sm">
20
+ <Progress v-bind="args" />
21
+ </div>
22
+ `,
23
+ }),
24
+ } satisfies Meta<typeof Progress>
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ const noControls = { controls: { disable: true }} satisfies Story['parameters']
30
+
31
+ export const Default: Story = {}
32
+
33
+ export const CustomMax: Story = {
34
+ parameters: {
35
+ ...noControls,
36
+ docs: {
37
+ source: {
38
+ code: `
39
+ <template>
40
+ <Progress :modelValue="150" :max="200" />
41
+ </template>
42
+ `.trim(),
43
+ },
44
+ },
45
+ },
46
+ render: () => ({
47
+ components: { Progress },
48
+ template: `
49
+ <div class="max-w-sm space-y-2">
50
+ <Progress :modelValue="150" :max="200" />
51
+ <div class="text-sm text-muted-foreground">150 / 200</div>
52
+ </div>
53
+ `,
54
+ }),
55
+ }
56
+
57
+ export const Animated: Story = {
58
+ parameters: {
59
+ ...noControls,
60
+ docs: {
61
+ source: {
62
+ code: `
63
+ <template>
64
+ <Progress :modelValue="value" />
65
+ </template>
66
+ `.trim(),
67
+ },
68
+ },
69
+ },
70
+ render: () => ({
71
+ components: { Progress },
72
+ setup () {
73
+ const value = ref(0)
74
+ const id = setInterval(() => {
75
+ value.value = (value.value + 5) % 105
76
+ }, 300)
77
+ onUnmounted(() => clearInterval(id))
78
+ return { value }
79
+ },
80
+ template: `
81
+ <div class="max-w-sm space-y-2">
82
+ <Progress :modelValue="value" />
83
+ <div class="text-sm text-muted-foreground">{{ value }}%</div>
84
+ </div>
85
+ `,
86
+ }),
87
+ }
@@ -0,0 +1,10 @@
1
+ <script setup lang="ts">
2
+ import { Progress as ShadcnProgress } from '../../shadcn/progress'
3
+ import type { ProgressProps } from './types'
4
+
5
+ defineProps<ProgressProps>()
6
+ </script>
7
+
8
+ <template>
9
+ <ShadcnProgress />
10
+ </template>
@@ -0,0 +1,3 @@
1
+ import type { ProgressRootProps } from 'reka-ui'
2
+
3
+ export interface ProgressProps extends /* @vue-ignore */ ProgressRootProps {}
@@ -12,13 +12,13 @@ const meta = {
12
12
  color: { control: 'select', options: colors },
13
13
  variant: { control: 'select', options: variants },
14
14
  active: { control: 'boolean' },
15
- selectable: { control: 'boolean' },
15
+ clickable: { control: 'boolean' },
16
16
  },
17
17
  args: {
18
18
  color: 'default',
19
19
  variant: 'soft',
20
20
  active: false,
21
- selectable: false,
21
+ clickable: false,
22
22
  },
23
23
  render: args => ({
24
24
  components: { Surface },
@@ -98,6 +98,9 @@ export const VariantColorMatrix: Story = {
98
98
  parameters: {
99
99
  ...noControls,
100
100
  docs: {
101
+ description: {
102
+ story: 'Surfaces in this matrix are clickable — click any cell to toggle its active state.',
103
+ },
101
104
  source: {
102
105
  code: `
103
106
  <template>
@@ -110,7 +113,7 @@ export const VariantColorMatrix: Story = {
110
113
  :key="c"
111
114
  :variant="v"
112
115
  :color="c"
113
- selectable
116
+ clickable
114
117
  :active="selected === keyFor(v, c)"
115
118
  class="p-4"
116
119
  @click="selected = keyFor(v, c)"
@@ -147,7 +150,7 @@ const keyFor = (v: string, c: string) => \`\${v}:\${c}\`
147
150
  :key="c"
148
151
  :variant="v"
149
152
  :color="c"
150
- selectable
153
+ clickable
151
154
  :active="selected === keyFor(v, c)"
152
155
  class="p-4"
153
156
  @click="selected = keyFor(v, c)"
@@ -25,7 +25,7 @@ const surfaceVariants = cva(
25
25
  true: 'ring-[3px]',
26
26
  false: '',
27
27
  },
28
- selectable: {
28
+ clickable: {
29
29
  true: 'cursor-pointer transition-colors',
30
30
  false: '',
31
31
  },
@@ -109,89 +109,89 @@ const surfaceVariants = cva(
109
109
  { active: true, color: 'help', class: 'border-help ring-help/50' },
110
110
  { active: true, color: 'warn', class: 'border-warn ring-warn/50' },
111
111
  { active: true, color: 'danger', class: 'border-danger ring-danger/50' },
112
- // selectable — hover bg, one step up the variant's intensity ladder
113
- { selectable: true, variant: 'solid', color: 'default', class: `
112
+ // clickable — hover bg, one step up the variant's intensity ladder
113
+ { clickable: true, variant: 'solid', color: 'default', class: `
114
114
  hover:bg-accent/80
115
115
  ` },
116
- { selectable: true, variant: 'solid', color: 'primary', class: `
116
+ { clickable: true, variant: 'solid', color: 'primary', class: `
117
117
  hover:bg-primary/90
118
118
  ` },
119
- { selectable: true, variant: 'solid', color: 'success', class: `
119
+ { clickable: true, variant: 'solid', color: 'success', class: `
120
120
  hover:bg-success/90
121
121
  ` },
122
- { selectable: true, variant: 'solid', color: 'info', class: `
122
+ { clickable: true, variant: 'solid', color: 'info', class: `
123
123
  hover:bg-info/90
124
124
  ` },
125
- { selectable: true, variant: 'solid', color: 'help', class: `
125
+ { clickable: true, variant: 'solid', color: 'help', class: `
126
126
  hover:bg-help/90
127
127
  ` },
128
- { selectable: true, variant: 'solid', color: 'warn', class: `
128
+ { clickable: true, variant: 'solid', color: 'warn', class: `
129
129
  hover:bg-warn/90
130
130
  ` },
131
- { selectable: true, variant: 'solid', color: 'danger', class: `
131
+ { clickable: true, variant: 'solid', color: 'danger', class: `
132
132
  hover:bg-danger/90
133
133
  ` },
134
- { selectable: true, variant: 'soft', color: 'default', class: `
134
+ { clickable: true, variant: 'soft', color: 'default', class: `
135
135
  hover:bg-secondary/70
136
136
  ` },
137
- { selectable: true, variant: 'soft', color: 'primary', class: `
137
+ { clickable: true, variant: 'soft', color: 'primary', class: `
138
138
  hover:bg-primary/20
139
139
  ` },
140
- { selectable: true, variant: 'soft', color: 'success', class: `
140
+ { clickable: true, variant: 'soft', color: 'success', class: `
141
141
  hover:bg-success/20
142
142
  ` },
143
- { selectable: true, variant: 'soft', color: 'info', class: `
143
+ { clickable: true, variant: 'soft', color: 'info', class: `
144
144
  hover:bg-info/20
145
145
  ` },
146
- { selectable: true, variant: 'soft', color: 'help', class: `
146
+ { clickable: true, variant: 'soft', color: 'help', class: `
147
147
  hover:bg-help/20
148
148
  ` },
149
- { selectable: true, variant: 'soft', color: 'warn', class: `
149
+ { clickable: true, variant: 'soft', color: 'warn', class: `
150
150
  hover:bg-warn/20
151
151
  ` },
152
- { selectable: true, variant: 'soft', color: 'danger', class: `
152
+ { clickable: true, variant: 'soft', color: 'danger', class: `
153
153
  hover:bg-danger/20
154
154
  ` },
155
- { selectable: true, variant: 'bordered', color: 'default', class: `
155
+ { clickable: true, variant: 'bordered', color: 'default', class: `
156
156
  hover:bg-accent/50
157
157
  ` },
158
- { selectable: true, variant: 'bordered', color: 'primary', class: `
158
+ { clickable: true, variant: 'bordered', color: 'primary', class: `
159
159
  hover:bg-primary/10
160
160
  ` },
161
- { selectable: true, variant: 'bordered', color: 'success', class: `
161
+ { clickable: true, variant: 'bordered', color: 'success', class: `
162
162
  hover:bg-success/10
163
163
  ` },
164
- { selectable: true, variant: 'bordered', color: 'info', class: `
164
+ { clickable: true, variant: 'bordered', color: 'info', class: `
165
165
  hover:bg-info/10
166
166
  ` },
167
- { selectable: true, variant: 'bordered', color: 'help', class: `
167
+ { clickable: true, variant: 'bordered', color: 'help', class: `
168
168
  hover:bg-help/10
169
169
  ` },
170
- { selectable: true, variant: 'bordered', color: 'warn', class: `
170
+ { clickable: true, variant: 'bordered', color: 'warn', class: `
171
171
  hover:bg-warn/10
172
172
  ` },
173
- { selectable: true, variant: 'bordered', color: 'danger', class: `
173
+ { clickable: true, variant: 'bordered', color: 'danger', class: `
174
174
  hover:bg-danger/10
175
175
  ` },
176
- { selectable: true, variant: 'flat', color: 'default', class: `
176
+ { clickable: true, variant: 'flat', color: 'default', class: `
177
177
  hover:bg-secondary/70
178
178
  ` },
179
- { selectable: true, variant: 'flat', color: 'primary', class: `
179
+ { clickable: true, variant: 'flat', color: 'primary', class: `
180
180
  hover:bg-primary/20
181
181
  ` },
182
- { selectable: true, variant: 'flat', color: 'success', class: `
182
+ { clickable: true, variant: 'flat', color: 'success', class: `
183
183
  hover:bg-success/20
184
184
  ` },
185
- { selectable: true, variant: 'flat', color: 'info', class: `
185
+ { clickable: true, variant: 'flat', color: 'info', class: `
186
186
  hover:bg-info/20
187
187
  ` },
188
- { selectable: true, variant: 'flat', color: 'help', class: `
188
+ { clickable: true, variant: 'flat', color: 'help', class: `
189
189
  hover:bg-help/20
190
190
  ` },
191
- { selectable: true, variant: 'flat', color: 'warn', class: `
191
+ { clickable: true, variant: 'flat', color: 'warn', class: `
192
192
  hover:bg-warn/20
193
193
  ` },
194
- { selectable: true, variant: 'flat', color: 'danger', class: `
194
+ { clickable: true, variant: 'flat', color: 'danger', class: `
195
195
  hover:bg-danger/20
196
196
  ` },
197
197
  ],
@@ -199,7 +199,7 @@ const surfaceVariants = cva(
199
199
  variant: 'soft',
200
200
  color: 'default',
201
201
  active: false,
202
- selectable: false,
202
+ clickable: false,
203
203
  },
204
204
  },
205
205
  )
@@ -208,7 +208,7 @@ const props = withDefaults(defineProps<SurfaceProps>(), {
208
208
  color: 'default',
209
209
  variant: 'soft',
210
210
  active: false,
211
- selectable: false,
211
+ clickable: false,
212
212
  class: undefined,
213
213
  })
214
214
 
@@ -218,7 +218,7 @@ const mergedClass = computed(() =>
218
218
  color: props.color,
219
219
  variant: props.variant,
220
220
  active: props.active,
221
- selectable: props.selectable,
221
+ clickable: props.clickable,
222
222
  }),
223
223
  props.class,
224
224
  ),
@@ -4,7 +4,7 @@ export type SurfaceVariant = 'solid' | 'soft' | 'bordered' | 'flat'
4
4
  export interface SurfaceProps {
5
5
  color?: SurfaceColor
6
6
  variant?: SurfaceVariant
7
- selectable?: boolean
7
+ clickable?: boolean
8
8
  active?: boolean
9
9
  class?: ClassValue
10
10
  }
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "تاريخ البداية",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "لا توجد عناصر"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} من {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Startdatum",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Keine Elemente"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} von {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Start date",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "No items"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} of {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Fecha de inicio",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Sin elementos"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} de {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Date de début",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Aucun élément"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} sur {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "प्रारंभ तारीख",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "कोई आइटम नहीं"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} में से {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Tanggal awal",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Tidak ada item"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} dari {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Data di inizio",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Nessun elemento"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} di {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "開始日",
47
47
  "to": "〜"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "アイテムなし"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}〜{last}件 / 全{total}件"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "시작 날짜",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "항목 없음"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} / {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Startdatum",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Geen items"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} van {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Data początkowa",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Brak elementów"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} z {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Data inicial",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Sem itens"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} de {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Дата начала",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Нет элементов"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} из {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "วันที่เริ่มต้น",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "ไม่มีรายการ"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "รายการที่ {first}–{last} จากทั้งหมด {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Başlangıç tarihi",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Öğe yok"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} / {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "Ngày bắt đầu",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "Không có mục"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} của {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "开始日期",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "无项目"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} 共 {total}"
51
54
  },
@@ -46,6 +46,9 @@
46
46
  "startPlaceholder": "開始日期",
47
47
  "to": "–"
48
48
  },
49
+ "Dropdown": {
50
+ "empty": "沒有項目"
51
+ },
49
52
  "Pagination": {
50
53
  "pageReport": "{first}–{last} 共 {total}"
51
54
  },
package/nuxt.config.ts CHANGED
@@ -52,9 +52,15 @@ export default defineNuxtConfig({
52
52
  css: [ join(currentDir, 'app/assets/styles/globals.css') ],
53
53
 
54
54
  components: [
55
- // Layer's own components (absolute paths required)
56
- { path: join(currentDir, 'app/components/ui'), pathPrefix: false, extensions: [ 'vue' ]},
57
- { path: join(currentDir, 'app/components'), pathPrefix: true, ignore: [ 'shadcn/**' ], extensions: [ 'vue' ]},
55
+ // Auto-import only first-level UI components: `Foo.vue` or `Foo/index.vue`.
56
+ // Files nested inside a component folder (e.g. `Dropdown/MenuItems.vue`)
57
+ // are intentionally skipped those should be imported explicitly by their
58
+ // owning component. shadcn/* is excluded by the pattern (no nested scan).
59
+ {
60
+ path: join(currentDir, 'app/components/ui'),
61
+ pathPrefix: true,
62
+ pattern: '{*.vue,*/index.vue}',
63
+ },
58
64
  ],
59
65
 
60
66
  imports: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymarbot/nuxt-layer-shadcn-ui",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Nuxt layer providing shadcn-vue based UI components",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -42,5 +42,5 @@
42
42
  "vue-i18n": "^11",
43
43
  "vue-router": "^4 || ^5"
44
44
  },
45
- "gitHead": "a3c2032791a82180087ac4f922cb99102a392a53"
45
+ "gitHead": "b25d6d9d16ad5ba72906936f2c672d65035df9ea"
46
46
  }