@polymarbot/nuxt-layer-shadcn-ui 0.4.2 → 0.5.1

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 (37) hide show
  1. package/app/components/ui/DatePicker/index.vue +2 -1
  2. package/app/components/ui/DateRangePicker/index.stories.ts +16 -16
  3. package/app/components/ui/DateRangePicker/index.vue +8 -11
  4. package/app/components/ui/DateRangePicker/types.ts +4 -4
  5. package/app/components/ui/Dropdown/ItemContent.vue +23 -0
  6. package/app/components/ui/Dropdown/ItemIcon.vue +41 -0
  7. package/app/components/ui/Dropdown/MenuItems.vue +160 -0
  8. package/app/components/ui/Dropdown/SlotRenderer.vue +18 -0
  9. package/app/components/ui/Dropdown/en.json +3 -0
  10. package/app/components/ui/Dropdown/index.stories.ts +91 -1
  11. package/app/components/ui/Dropdown/index.vue +43 -159
  12. package/app/components/ui/Dropdown/types.ts +47 -5
  13. package/app/components/ui/Progress/index.stories.ts +87 -0
  14. package/app/components/ui/Progress/index.vue +10 -0
  15. package/app/components/ui/Progress/types.ts +3 -0
  16. package/app/components/ui/Surface/index.stories.ts +3 -0
  17. package/i18n/messages/ar.json +3 -0
  18. package/i18n/messages/de.json +3 -0
  19. package/i18n/messages/en.json +3 -0
  20. package/i18n/messages/es.json +3 -0
  21. package/i18n/messages/fr.json +3 -0
  22. package/i18n/messages/hi.json +3 -0
  23. package/i18n/messages/id.json +3 -0
  24. package/i18n/messages/it.json +3 -0
  25. package/i18n/messages/ja.json +3 -0
  26. package/i18n/messages/ko.json +3 -0
  27. package/i18n/messages/nl.json +3 -0
  28. package/i18n/messages/pl.json +3 -0
  29. package/i18n/messages/pt.json +3 -0
  30. package/i18n/messages/ru.json +3 -0
  31. package/i18n/messages/th.json +3 -0
  32. package/i18n/messages/tr.json +3 -0
  33. package/i18n/messages/vi.json +3 -0
  34. package/i18n/messages/zh-CN.json +3 -0
  35. package/i18n/messages/zh-TW.json +3 -0
  36. package/nuxt.config.ts +9 -3
  37. package/package.json +2 -2
@@ -79,6 +79,7 @@ const timeConfig = computed(() => {
79
79
  :yearPicker="type === 'year'"
80
80
  :autoApply="autoApply"
81
81
  :inputAttrs="{ clearable: false }"
82
+ :teleport="true"
82
83
  textInput
83
84
  >
84
85
  <template #dp-input="{ value, onInput, onEnter, onTab, onClear, onBlur, onFocus, openMenu }">
@@ -98,7 +99,7 @@ const timeConfig = computed(() => {
98
99
  <template #prefix>
99
100
  <Icon
100
101
  name="calendar-days"
101
- class="cursor-pointer text-muted-foreground"
102
+ class="text-muted-foreground cursor-pointer"
102
103
  @click="openMenu"
103
104
  />
104
105
  </template>
@@ -20,7 +20,7 @@ const meta = {
20
20
  class: { control: 'text' },
21
21
  },
22
22
  args: {
23
- modelValue: { start: null, end: null },
23
+ modelValue: [ null, null ],
24
24
  showTime: false,
25
25
  disabled: false,
26
26
  readonly: false,
@@ -36,7 +36,7 @@ const meta = {
36
36
  render: args => ({
37
37
  components: { DateRangePicker },
38
38
  setup () {
39
- const range = ref<DateRangePickerValue>({ start: null, end: null })
39
+ const range = ref<DateRangePickerValue>([ null, null ])
40
40
  return { args, range }
41
41
  },
42
42
  template: `
@@ -67,7 +67,7 @@ export const WithTime: Story = {
67
67
  render: () => ({
68
68
  components: { DateRangePicker },
69
69
  setup () {
70
- const withTime = ref<DateRangePickerValue>({ start: null, end: null })
70
+ const withTime = ref<DateRangePickerValue>([ null, null ])
71
71
  return { withTime }
72
72
  },
73
73
  template: `
@@ -91,7 +91,7 @@ export const MaxSpanDays: Story = {
91
91
  render: () => ({
92
92
  components: { DateRangePicker },
93
93
  setup () {
94
- const maxSpan = ref<DateRangePickerValue>({ start: null, end: null })
94
+ const maxSpan = ref<DateRangePickerValue>([ null, null ])
95
95
  return { maxSpan }
96
96
  },
97
97
  template: `
@@ -115,10 +115,10 @@ export const Preselected: Story = {
115
115
  render: () => ({
116
116
  components: { DateRangePicker },
117
117
  setup () {
118
- const preselected = ref<DateRangePickerValue>({
119
- start: new Date(2025, 5, 1),
120
- end: new Date(2025, 5, 15),
121
- })
118
+ const preselected = ref<DateRangePickerValue>([
119
+ new Date(2025, 5, 1),
120
+ new Date(2025, 5, 15),
121
+ ])
122
122
  return { preselected }
123
123
  },
124
124
  template: `
@@ -142,10 +142,10 @@ export const Disabled: Story = {
142
142
  render: () => ({
143
143
  components: { DateRangePicker },
144
144
  setup () {
145
- const range = ref<DateRangePickerValue>({
146
- start: new Date(2025, 5, 1),
147
- end: new Date(2025, 5, 15),
148
- })
145
+ const range = ref<DateRangePickerValue>([
146
+ new Date(2025, 5, 1),
147
+ new Date(2025, 5, 15),
148
+ ])
149
149
  return { range }
150
150
  },
151
151
  template: `
@@ -168,10 +168,10 @@ export const Readonly: Story = {
168
168
  render: () => ({
169
169
  components: { DateRangePicker },
170
170
  setup () {
171
- const range = ref<DateRangePickerValue>({
172
- start: new Date(2025, 5, 1),
173
- end: new Date(2025, 5, 15),
174
- })
171
+ const range = ref<DateRangePickerValue>([
172
+ new Date(2025, 5, 1),
173
+ new Date(2025, 5, 15),
174
+ ])
175
175
  return { range }
176
176
  },
177
177
  template: `
@@ -4,7 +4,7 @@ import type { DateRangePickerProps, DateRangePickerValue } from './types'
4
4
  defineOptions({ inheritAttrs: false })
5
5
 
6
6
  const props = withDefaults(defineProps<DateRangePickerProps>(), {
7
- modelValue: () => ({ start: null, end: null }),
7
+ modelValue: () => [ null, null ],
8
8
  showTime: false,
9
9
  disabled: false,
10
10
  readonly: false,
@@ -24,19 +24,16 @@ const emit = defineEmits<{
24
24
 
25
25
  const T = useTranslations('components.ui.DateRangePicker')
26
26
 
27
- const startDate = ref<Date | string | null>(props.modelValue?.start ?? null)
28
- const endDate = ref<Date | string | null>(props.modelValue?.end ?? null)
27
+ const startDate = ref<Date | string | null>(props.modelValue?.[0] ?? null)
28
+ const endDate = ref<Date | string | null>(props.modelValue?.[1] ?? null)
29
29
 
30
30
  watch(() => props.modelValue, val => {
31
- startDate.value = val?.start ?? null
32
- endDate.value = val?.end ?? null
31
+ startDate.value = val?.[0] ?? null
32
+ endDate.value = val?.[1] ?? null
33
33
  })
34
34
 
35
35
  function emitRange () {
36
- emit('update:modelValue', {
37
- start: startDate.value,
38
- end: endDate.value,
39
- })
36
+ emit('update:modelValue', [ startDate.value, endDate.value ])
40
37
  }
41
38
 
42
39
  function handleStartUpdate (value: Date | string | null) {
@@ -104,7 +101,7 @@ const endMaxDate = computed(() => {
104
101
  </script>
105
102
 
106
103
  <template>
107
- <div :class="cn('flex items-center gap-2', props.class)">
104
+ <div :class="cn('gap-2 flex items-center', props.class)">
108
105
  <DatePicker
109
106
  :modelValue="startDate"
110
107
  :showTime="showTime"
@@ -118,7 +115,7 @@ const endMaxDate = computed(() => {
118
115
  v-bind="$attrs"
119
116
  @update:modelValue="handleStartUpdate"
120
117
  />
121
- <span class="shrink-0 text-muted-foreground">
118
+ <span class="text-muted-foreground shrink-0">
122
119
  {{ T('to') }}
123
120
  </span>
124
121
  <DatePicker
@@ -1,9 +1,9 @@
1
1
  import type { DatePickerTimeConfig } from '../DatePicker/types'
2
2
 
3
- export interface DateRangePickerValue {
4
- start: Date | string | null
5
- end: Date | string | null
6
- }
3
+ export type DateRangePickerValue = [
4
+ start: Date | string | null,
5
+ end: Date | string | null,
6
+ ]
7
7
 
8
8
  export interface DateRangePickerProps {
9
9
  modelValue?: DateRangePickerValue
@@ -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 {}
@@ -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>
@@ -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.2",
3
+ "version": "0.5.1",
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": "704765b43899093b99abeeb363fc417bef1135f9"
45
+ "gitHead": "715490d2d8fd6d2511c0c57828b17f47238bcd45"
46
46
  }