@proyecto-viviana/ui 0.3.1 → 0.3.3

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 (74) hide show
  1. package/dist/components.css +1077 -1077
  2. package/dist/index.js +236 -249
  3. package/dist/index.js.map +3 -3
  4. package/dist/index.ssr.js +78 -81
  5. package/dist/index.ssr.js.map +3 -3
  6. package/dist/radio/index.d.ts +12 -27
  7. package/dist/radio/index.d.ts.map +1 -1
  8. package/dist/test-utils/index.d.ts +2 -2
  9. package/dist/test-utils/index.d.ts.map +1 -1
  10. package/package.json +13 -12
  11. package/src/alert/index.tsx +48 -0
  12. package/src/assets/favicon.png +0 -0
  13. package/src/assets/fire.gif +0 -0
  14. package/src/autocomplete/index.tsx +313 -0
  15. package/src/avatar/index.tsx +75 -0
  16. package/src/badge/index.tsx +43 -0
  17. package/src/breadcrumbs/index.tsx +207 -0
  18. package/src/button/Button.tsx +74 -0
  19. package/src/button/index.ts +2 -0
  20. package/src/button/types.ts +24 -0
  21. package/src/calendar/DateField.tsx +200 -0
  22. package/src/calendar/DatePicker.tsx +298 -0
  23. package/src/calendar/RangeCalendar.tsx +236 -0
  24. package/src/calendar/TimeField.tsx +196 -0
  25. package/src/calendar/index.tsx +223 -0
  26. package/src/checkbox/index.tsx +257 -0
  27. package/src/color/index.tsx +687 -0
  28. package/src/combobox/index.tsx +383 -0
  29. package/src/components.css +1077 -0
  30. package/src/custom/calendar-card/index.tsx +66 -0
  31. package/src/custom/chip/index.tsx +46 -0
  32. package/src/custom/conversation/index.tsx +105 -0
  33. package/src/custom/event-card/index.tsx +132 -0
  34. package/src/custom/header/index.tsx +33 -0
  35. package/src/custom/lateral-nav/index.tsx +88 -0
  36. package/src/custom/logo/index.tsx +58 -0
  37. package/src/custom/nav-header/index.tsx +42 -0
  38. package/src/custom/page-layout/index.tsx +29 -0
  39. package/src/custom/profile-card/index.tsx +64 -0
  40. package/src/custom/project-card/index.tsx +59 -0
  41. package/src/custom/timeline-item/index.tsx +105 -0
  42. package/src/dialog/Dialog.tsx +260 -0
  43. package/src/dialog/index.tsx +3 -0
  44. package/src/disclosure/index.tsx +307 -0
  45. package/src/gridlist/index.tsx +403 -0
  46. package/src/icon/icons/GitHubIcon.tsx +20 -0
  47. package/src/icon/index.tsx +48 -0
  48. package/src/index.ts +322 -0
  49. package/src/landmark/index.tsx +231 -0
  50. package/src/link/index.tsx +130 -0
  51. package/src/listbox/index.tsx +231 -0
  52. package/src/menu/index.tsx +297 -0
  53. package/src/meter/index.tsx +163 -0
  54. package/src/numberfield/index.tsx +482 -0
  55. package/src/popover/index.tsx +260 -0
  56. package/src/progress-bar/index.tsx +169 -0
  57. package/src/radio/index.tsx +173 -0
  58. package/src/searchfield/index.tsx +453 -0
  59. package/src/select/index.tsx +349 -0
  60. package/src/separator/index.tsx +141 -0
  61. package/src/slider/index.tsx +382 -0
  62. package/src/styles.css +450 -0
  63. package/src/switch/ToggleSwitch.tsx +112 -0
  64. package/src/switch/index.tsx +90 -0
  65. package/src/table/index.tsx +531 -0
  66. package/src/tabs/index.tsx +273 -0
  67. package/src/tag-group/index.tsx +240 -0
  68. package/src/test-utils/index.ts +40 -0
  69. package/src/textfield/index.tsx +211 -0
  70. package/src/theme.css +101 -0
  71. package/src/toast/index.tsx +324 -0
  72. package/src/toolbar/index.tsx +108 -0
  73. package/src/tooltip/index.tsx +197 -0
  74. package/src/tree/index.tsx +494 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Link component for proyecto-viviana-ui
3
+ *
4
+ * Styled link component built on top of solidaria-components.
5
+ */
6
+
7
+ import { type JSX, splitProps } from 'solid-js';
8
+ import {
9
+ Link as HeadlessLink,
10
+ type LinkProps as HeadlessLinkProps,
11
+ type LinkRenderProps,
12
+ } from '@proyecto-viviana/solidaria-components';
13
+
14
+ // ============================================
15
+ // TYPES
16
+ // ============================================
17
+
18
+ export type LinkVariant = 'primary' | 'secondary' | 'subtle';
19
+
20
+ export interface LinkProps extends Omit<HeadlessLinkProps, 'class' | 'style' | 'children'> {
21
+ /** The visual style of the link. @default 'primary' */
22
+ variant?: LinkVariant;
23
+ /** Whether the link is on its own vs inside a longer string of text. */
24
+ isStandalone?: boolean;
25
+ /** Whether the link should be displayed with a quiet style (no underline by default). */
26
+ isQuiet?: boolean;
27
+ /** Additional CSS class name. */
28
+ class?: string;
29
+ /** The content of the link. */
30
+ children?: JSX.Element;
31
+ }
32
+
33
+ // ============================================
34
+ // STYLES
35
+ // ============================================
36
+
37
+ const variantStyles = {
38
+ primary: 'text-accent hover:text-accent-300',
39
+ secondary: 'text-primary-300 hover:text-primary-200',
40
+ subtle: 'text-primary-400 hover:text-primary-300',
41
+ };
42
+
43
+ // ============================================
44
+ // LINK COMPONENT
45
+ // ============================================
46
+
47
+ /**
48
+ * Links allow users to navigate to a different location.
49
+ * They can be presented inline inside a paragraph or as standalone text.
50
+ *
51
+ * Built on solidaria-components Link for full accessibility support.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * <Link href="/about">About Us</Link>
56
+ *
57
+ * // Secondary variant
58
+ * <Link href="/help" variant="secondary">Help</Link>
59
+ *
60
+ * // Standalone (bold, no underline until hover)
61
+ * <Link href="/home" isStandalone isQuiet>Home</Link>
62
+ * ```
63
+ */
64
+ export function Link(props: LinkProps): JSX.Element {
65
+ const [local, headlessProps] = splitProps(props, [
66
+ 'variant',
67
+ 'isStandalone',
68
+ 'isQuiet',
69
+ 'class',
70
+ ]);
71
+
72
+ const variant = local.variant ?? 'primary';
73
+ const customClass = local.class ?? '';
74
+
75
+ // Generate class based on render props
76
+ const getClassName = (renderProps: LinkRenderProps): string => {
77
+ const base = 'transition-colors duration-200 cursor-pointer rounded-sm outline-none';
78
+
79
+ // Variant colors
80
+ const variantClass = variantStyles[variant];
81
+
82
+ // Underline behavior
83
+ let underlineClass: string;
84
+ if (local.isStandalone && local.isQuiet) {
85
+ // Quiet standalone: no underline by default, underline on hover/focus
86
+ underlineClass = renderProps.isHovered || renderProps.isFocusVisible
87
+ ? 'underline'
88
+ : 'no-underline';
89
+ } else {
90
+ // Inline links always have underline for accessibility
91
+ underlineClass = 'underline';
92
+ }
93
+
94
+ // Font weight for standalone
95
+ const weightClass = local.isStandalone ? 'font-medium' : '';
96
+
97
+ // Focus ring
98
+ const focusClass = renderProps.isFocusVisible
99
+ ? 'ring-2 ring-accent-300 ring-offset-2 ring-offset-bg-400'
100
+ : '';
101
+
102
+ // Disabled state
103
+ const disabledClass = renderProps.isDisabled
104
+ ? 'opacity-50 cursor-not-allowed'
105
+ : '';
106
+
107
+ // Pressed state
108
+ const pressedClass = renderProps.isPressed ? 'opacity-80' : '';
109
+
110
+ return [
111
+ base,
112
+ variantClass,
113
+ underlineClass,
114
+ weightClass,
115
+ focusClass,
116
+ disabledClass,
117
+ pressedClass,
118
+ customClass,
119
+ ].filter(Boolean).join(' ');
120
+ };
121
+
122
+ return (
123
+ <HeadlessLink
124
+ {...headlessProps}
125
+ class={getClassName}
126
+ >
127
+ {props.children}
128
+ </HeadlessLink>
129
+ );
130
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * ListBox component for proyecto-viviana-ui
3
+ *
4
+ * Styled listbox component built on top of solidaria-components.
5
+ * Inspired by Spectrum 2's ListBox component patterns.
6
+ */
7
+
8
+ import { type JSX, splitProps, createContext, useContext, Show } from 'solid-js'
9
+ import {
10
+ ListBox as HeadlessListBox,
11
+ ListBoxOption as HeadlessListBoxOption,
12
+ type ListBoxProps as HeadlessListBoxProps,
13
+ type ListBoxOptionProps as HeadlessListBoxOptionProps,
14
+ type ListBoxRenderProps,
15
+ type ListBoxOptionRenderProps,
16
+ } from '@proyecto-viviana/solidaria-components'
17
+ import type { Key } from '@proyecto-viviana/solid-stately'
18
+
19
+ // ============================================
20
+ // SIZE CONTEXT
21
+ // ============================================
22
+
23
+ export type ListBoxSize = 'sm' | 'md' | 'lg'
24
+
25
+ const ListBoxSizeContext = createContext<ListBoxSize>('md')
26
+
27
+ // ============================================
28
+ // TYPES
29
+ // ============================================
30
+
31
+ export interface ListBoxProps<T> extends Omit<HeadlessListBoxProps<T>, 'class' | 'style'> {
32
+ /** The size of the listbox. */
33
+ size?: ListBoxSize
34
+ /** Additional CSS class name. */
35
+ class?: string
36
+ /** Label for the listbox. */
37
+ label?: string
38
+ /** Description for the listbox. */
39
+ description?: string
40
+ }
41
+
42
+ export interface ListBoxOptionProps<T> extends Omit<HeadlessListBoxOptionProps<T>, 'class' | 'style'> {
43
+ /** Additional CSS class name. */
44
+ class?: string
45
+ /** Optional description text. */
46
+ description?: string
47
+ /**
48
+ * Optional icon to display before the label.
49
+ * Use a function returning JSX for SSR compatibility: `icon={() => <MyIcon />}`
50
+ */
51
+ icon?: () => JSX.Element
52
+ }
53
+
54
+ // ============================================
55
+ // STYLES
56
+ // ============================================
57
+
58
+ const sizeStyles = {
59
+ sm: {
60
+ list: 'py-1',
61
+ option: 'text-sm py-1.5 px-3 gap-2',
62
+ icon: 'h-4 w-4',
63
+ label: 'text-sm',
64
+ description: 'text-xs',
65
+ },
66
+ md: {
67
+ list: 'py-1.5',
68
+ option: 'text-base py-2 px-4 gap-3',
69
+ icon: 'h-5 w-5',
70
+ label: 'text-base',
71
+ description: 'text-sm',
72
+ },
73
+ lg: {
74
+ list: 'py-2',
75
+ option: 'text-lg py-2.5 px-5 gap-3',
76
+ icon: 'h-6 w-6',
77
+ label: 'text-lg',
78
+ description: 'text-base',
79
+ },
80
+ }
81
+
82
+ // ============================================
83
+ // LISTBOX COMPONENT
84
+ // ============================================
85
+
86
+ /**
87
+ * A listbox displays a list of options and allows a user to select one or more of them.
88
+ *
89
+ * Built on solidaria-components ListBox for full accessibility support.
90
+ */
91
+ export function ListBox<T>(props: ListBoxProps<T>): JSX.Element {
92
+ const [local, headlessProps] = splitProps(props, [
93
+ 'size',
94
+ 'class',
95
+ 'label',
96
+ 'description',
97
+ 'renderEmptyState',
98
+ ])
99
+
100
+ const size = local.size ?? 'md'
101
+ const styles = sizeStyles[size]
102
+ const customClass = local.class ?? ''
103
+
104
+ const getClassName = (renderProps: ListBoxRenderProps): string => {
105
+ const base = 'rounded-lg border-2 border-primary-600 bg-bg-400 overflow-auto focus:outline-none'
106
+ const sizeClass = styles.list
107
+
108
+ let stateClass: string
109
+ if (renderProps.isDisabled) {
110
+ stateClass = 'opacity-50'
111
+ } else {
112
+ stateClass = ''
113
+ }
114
+
115
+ const focusClass = renderProps.isFocusVisible
116
+ ? 'ring-2 ring-accent-300 ring-offset-2 ring-offset-bg-400'
117
+ : ''
118
+
119
+ return [base, sizeClass, stateClass, focusClass, customClass].filter(Boolean).join(' ')
120
+ }
121
+
122
+ const defaultEmptyState = () => (
123
+ <li class="py-4 px-4 text-center text-primary-500">
124
+ No items
125
+ </li>
126
+ )
127
+
128
+ return (
129
+ <ListBoxSizeContext.Provider value={size}>
130
+ <div class="flex flex-col gap-1.5">
131
+ <Show when={local.label}>
132
+ <label class={`text-primary-200 font-medium ${styles.label}`}>
133
+ {local.label}
134
+ </label>
135
+ </Show>
136
+ <HeadlessListBox
137
+ {...headlessProps}
138
+ class={getClassName}
139
+ renderEmptyState={local.renderEmptyState ?? defaultEmptyState}
140
+ children={props.children}
141
+ />
142
+ <Show when={local.description}>
143
+ <span class="text-primary-400 text-sm">{local.description}</span>
144
+ </Show>
145
+ </div>
146
+ </ListBoxSizeContext.Provider>
147
+ )
148
+ }
149
+
150
+ // ============================================
151
+ // LISTBOX OPTION COMPONENT
152
+ // ============================================
153
+
154
+ /**
155
+ * An option in a listbox.
156
+ * SSR-compatible - renders icon, check, content, and description directly without render props.
157
+ */
158
+ export function ListBoxOption<T>(props: ListBoxOptionProps<T>): JSX.Element {
159
+ const [local, headlessProps] = splitProps(props, ['class', 'description', 'icon'])
160
+ const size = useContext(ListBoxSizeContext)
161
+ const sizeStyle = sizeStyles[size]
162
+ const customClass = local.class ?? ''
163
+
164
+ const getClassName = (renderProps: ListBoxOptionRenderProps): string => {
165
+ const base = 'flex items-center cursor-pointer transition-colors duration-150 outline-none'
166
+ const sizeClass = sizeStyle.option
167
+
168
+ let colorClass: string
169
+ if (renderProps.isDisabled) {
170
+ colorClass = 'text-primary-500 cursor-not-allowed'
171
+ } else if (renderProps.isSelected) {
172
+ if (renderProps.isFocused || renderProps.isHovered) {
173
+ colorClass = 'bg-accent/30 text-accent'
174
+ } else {
175
+ colorClass = 'bg-accent/20 text-accent'
176
+ }
177
+ } else if (renderProps.isFocused || renderProps.isHovered) {
178
+ colorClass = 'bg-bg-300 text-primary-100'
179
+ } else {
180
+ colorClass = 'text-primary-200'
181
+ }
182
+
183
+ const focusClass = renderProps.isFocusVisible
184
+ ? 'ring-2 ring-inset ring-accent-300'
185
+ : ''
186
+
187
+ return [base, sizeClass, colorClass, focusClass, customClass].filter(Boolean).join(' ')
188
+ }
189
+
190
+ return (
191
+ <HeadlessListBoxOption
192
+ {...headlessProps}
193
+ class={getClassName}
194
+ >
195
+ {local.icon && <span class={`shrink-0 ${sizeStyle.icon}`}>{local.icon()}</span>}
196
+ <CheckIcon class={`shrink-0 ${sizeStyle.icon} text-accent hidden data-selected:block`} />
197
+ <div class="flex flex-col flex-1 min-w-0">
198
+ <span class="truncate">{props.children as JSX.Element}</span>
199
+ {local.description && (
200
+ <span class={`text-primary-400 truncate ${sizeStyle.description}`}>
201
+ {local.description}
202
+ </span>
203
+ )}
204
+ </div>
205
+ </HeadlessListBoxOption>
206
+ )
207
+ }
208
+
209
+ // ============================================
210
+ // ICONS
211
+ // ============================================
212
+
213
+ function CheckIcon(props: { class?: string }): JSX.Element {
214
+ return (
215
+ <svg
216
+ class={props.class}
217
+ fill="none"
218
+ viewBox="0 0 24 24"
219
+ stroke="currentColor"
220
+ stroke-width="2"
221
+ >
222
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
223
+ </svg>
224
+ )
225
+ }
226
+
227
+ // Attach sub-components for convenience
228
+ ListBox.Option = ListBoxOption
229
+
230
+ // Re-export Key type for convenience
231
+ export type { Key }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Menu component for proyecto-viviana-ui
3
+ *
4
+ * Styled menu component built on top of solidaria-components.
5
+ * Inspired by Spectrum 2's Menu component patterns.
6
+ */
7
+
8
+ import { type JSX, splitProps, createContext, useContext } from 'solid-js'
9
+ import {
10
+ Menu as HeadlessMenu,
11
+ MenuItem as HeadlessMenuItem,
12
+ MenuTrigger as HeadlessMenuTrigger,
13
+ MenuButton as HeadlessMenuButton,
14
+ type MenuProps as HeadlessMenuProps,
15
+ type MenuItemProps as HeadlessMenuItemProps,
16
+ type MenuTriggerProps as HeadlessMenuTriggerProps,
17
+ type MenuButtonProps as HeadlessMenuButtonProps,
18
+ type MenuRenderProps,
19
+ type MenuItemRenderProps,
20
+ type MenuTriggerRenderProps,
21
+ } from '@proyecto-viviana/solidaria-components'
22
+ import type { Key } from '@proyecto-viviana/solid-stately'
23
+
24
+ // ============================================
25
+ // SIZE CONTEXT
26
+ // ============================================
27
+
28
+ export type MenuSize = 'sm' | 'md' | 'lg'
29
+
30
+ const MenuSizeContext = createContext<MenuSize>('md')
31
+
32
+ // ============================================
33
+ // TYPES
34
+ // ============================================
35
+
36
+ export interface MenuTriggerProps extends Omit<HeadlessMenuTriggerProps, 'class' | 'style'> {
37
+ /** The size of the menu. */
38
+ size?: MenuSize
39
+ /** Additional CSS class name. */
40
+ class?: string
41
+ }
42
+
43
+ export interface MenuButtonProps extends Omit<HeadlessMenuButtonProps, 'class' | 'style'> {
44
+ /** Additional CSS class name. */
45
+ class?: string
46
+ /** Visual variant of the button. */
47
+ variant?: 'primary' | 'secondary' | 'quiet'
48
+ }
49
+
50
+ export interface MenuProps<T> extends Omit<HeadlessMenuProps<T>, 'class' | 'style'> {
51
+ /** Additional CSS class name. */
52
+ class?: string
53
+ }
54
+
55
+ export interface MenuItemProps<T> extends Omit<HeadlessMenuItemProps<T>, 'class' | 'style'> {
56
+ /** Additional CSS class name. */
57
+ class?: string
58
+ /**
59
+ * Optional icon to display before the label.
60
+ * Use a function returning JSX for SSR compatibility: `icon={() => <MyIcon />}`
61
+ */
62
+ icon?: () => JSX.Element
63
+ /** Optional keyboard shortcut to display. */
64
+ shortcut?: string
65
+ /** Whether this is a destructive action. */
66
+ isDestructive?: boolean
67
+ }
68
+
69
+ // ============================================
70
+ // STYLES
71
+ // ============================================
72
+
73
+ const sizeStyles = {
74
+ sm: {
75
+ button: 'h-8 text-sm px-3 gap-2',
76
+ menu: 'py-1',
77
+ item: 'text-sm py-1.5 px-3 gap-2',
78
+ icon: 'h-4 w-4',
79
+ },
80
+ md: {
81
+ button: 'h-10 text-base px-4 gap-2',
82
+ menu: 'py-1.5',
83
+ item: 'text-base py-2 px-4 gap-3',
84
+ icon: 'h-5 w-5',
85
+ },
86
+ lg: {
87
+ button: 'h-12 text-lg px-5 gap-3',
88
+ menu: 'py-2',
89
+ item: 'text-lg py-2.5 px-5 gap-3',
90
+ icon: 'h-6 w-6',
91
+ },
92
+ }
93
+
94
+ const buttonVariants = {
95
+ primary: 'bg-accent text-bg-500 border-accent hover:bg-accent-300 hover:border-accent-300',
96
+ secondary: 'bg-bg-400 text-primary-200 border-primary-600 hover:bg-bg-300 hover:border-accent-300',
97
+ quiet: 'bg-transparent text-primary-200 border-transparent hover:bg-bg-300',
98
+ }
99
+
100
+ // ============================================
101
+ // MENU TRIGGER COMPONENT
102
+ // ============================================
103
+
104
+ /**
105
+ * A menu trigger wraps a button and menu, handling the open/close state.
106
+ */
107
+ export function MenuTrigger(props: MenuTriggerProps): JSX.Element {
108
+ const [local, headlessProps] = splitProps(props, ['size', 'class'])
109
+ const size = local.size ?? 'md'
110
+
111
+ return (
112
+ <MenuSizeContext.Provider value={size}>
113
+ <div class={`relative inline-block ${local.class ?? ''}`}>
114
+ <HeadlessMenuTrigger {...headlessProps}>
115
+ {props.children}
116
+ </HeadlessMenuTrigger>
117
+ </div>
118
+ </MenuSizeContext.Provider>
119
+ )
120
+ }
121
+
122
+ // ============================================
123
+ // MENU BUTTON COMPONENT
124
+ // ============================================
125
+
126
+ /**
127
+ * A button that opens a menu.
128
+ * SSR-compatible - renders children and chevron icon directly without render props.
129
+ */
130
+ export function MenuButton(props: MenuButtonProps): JSX.Element {
131
+ const [local, headlessProps] = splitProps(props, ['class', 'variant'])
132
+ const size = useContext(MenuSizeContext)
133
+ const sizeStyle = sizeStyles[size]
134
+ const variant = local.variant ?? 'secondary'
135
+ const customClass = local.class ?? ''
136
+
137
+ const getClassName = (renderProps: MenuTriggerRenderProps): string => {
138
+ const base = 'inline-flex items-center justify-center rounded-lg border-2 font-medium transition-all duration-200'
139
+ const sizeClass = sizeStyle.button
140
+ const variantClass = buttonVariants[variant]
141
+
142
+ let stateClass: string
143
+ if (renderProps.isDisabled) {
144
+ stateClass = 'opacity-50 cursor-not-allowed'
145
+ } else if (renderProps.isPressed) {
146
+ stateClass = 'scale-95'
147
+ } else {
148
+ stateClass = 'cursor-pointer'
149
+ }
150
+
151
+ const focusClass = renderProps.isFocusVisible
152
+ ? 'ring-2 ring-accent-300 ring-offset-2 ring-offset-bg-400'
153
+ : ''
154
+
155
+ return [base, sizeClass, variantClass, stateClass, focusClass, customClass].filter(Boolean).join(' ')
156
+ }
157
+
158
+ return (
159
+ <HeadlessMenuButton
160
+ {...headlessProps}
161
+ class={getClassName}
162
+ >
163
+ {props.children as JSX.Element}
164
+ {/* Chevron rotates via CSS based on data-open attribute */}
165
+ <ChevronIcon class={`${sizeStyle.icon} transition-transform duration-200 data-open:rotate-180`} />
166
+ </HeadlessMenuButton>
167
+ )
168
+ }
169
+
170
+ // ============================================
171
+ // MENU COMPONENT
172
+ // ============================================
173
+
174
+ /**
175
+ * A menu displays a list of actions or options for the user to choose from.
176
+ */
177
+ export function Menu<T>(props: MenuProps<T>): JSX.Element {
178
+ const [local, headlessProps] = splitProps(props, ['class'])
179
+ const size = useContext(MenuSizeContext)
180
+ const styles = () => sizeStyles[size]
181
+ const customClass = local.class ?? ''
182
+
183
+ const getClassName = (_renderProps: MenuRenderProps): string => {
184
+ const base = 'absolute z-50 mt-1 min-w-[12rem] rounded-lg border-2 border-primary-600 bg-bg-400 shadow-lg overflow-hidden'
185
+ const sizeClass = styles().menu
186
+ return [base, sizeClass, customClass].filter(Boolean).join(' ')
187
+ }
188
+
189
+ return (
190
+ <HeadlessMenu
191
+ {...headlessProps}
192
+ class={getClassName}
193
+ children={props.children}
194
+ />
195
+ )
196
+ }
197
+
198
+ // ============================================
199
+ // MENU ITEM COMPONENT
200
+ // ============================================
201
+
202
+ /**
203
+ * An item in a menu.
204
+ * SSR-compatible - renders icon, content, and shortcut directly without render props.
205
+ */
206
+ export function MenuItem<T>(props: MenuItemProps<T>): JSX.Element {
207
+ const [local, headlessProps] = splitProps(props, ['class', 'icon', 'shortcut', 'isDestructive'])
208
+ const size = useContext(MenuSizeContext)
209
+ const sizeStyle = sizeStyles[size]
210
+ const customClass = local.class ?? ''
211
+
212
+ const getClassName = (renderProps: MenuItemRenderProps): string => {
213
+ const base = 'flex items-center cursor-pointer transition-colors duration-150 outline-none'
214
+ const sizeClass = sizeStyle.item
215
+
216
+ let colorClass: string
217
+ if (renderProps.isDisabled) {
218
+ colorClass = 'text-primary-500 cursor-not-allowed'
219
+ } else if (local.isDestructive) {
220
+ if (renderProps.isFocused || renderProps.isHovered) {
221
+ colorClass = 'bg-danger-400/20 text-danger-400'
222
+ } else {
223
+ colorClass = 'text-danger-400'
224
+ }
225
+ } else if (renderProps.isFocused || renderProps.isHovered) {
226
+ colorClass = 'bg-bg-300 text-primary-100'
227
+ } else {
228
+ colorClass = 'text-primary-200'
229
+ }
230
+
231
+ const pressedClass = renderProps.isPressed ? 'bg-bg-200' : ''
232
+
233
+ const focusClass = renderProps.isFocusVisible
234
+ ? 'ring-2 ring-inset ring-accent-300'
235
+ : ''
236
+
237
+ return [base, sizeClass, colorClass, pressedClass, focusClass, customClass].filter(Boolean).join(' ')
238
+ }
239
+
240
+ return (
241
+ <HeadlessMenuItem
242
+ {...headlessProps}
243
+ class={getClassName}
244
+ >
245
+ {local.icon && <span class={`shrink-0 ${sizeStyle.icon}`}>{local.icon()}</span>}
246
+ <span class="flex-1">{props.children as JSX.Element}</span>
247
+ {local.shortcut && <span class="text-primary-500 text-sm ml-auto">{local.shortcut}</span>}
248
+ </HeadlessMenuItem>
249
+ )
250
+ }
251
+
252
+ // ============================================
253
+ // MENU SEPARATOR COMPONENT
254
+ // ============================================
255
+
256
+ export interface MenuSeparatorProps {
257
+ /** Additional CSS class name. */
258
+ class?: string
259
+ }
260
+
261
+ /**
262
+ * A visual separator between menu items.
263
+ */
264
+ export function MenuSeparator(props: MenuSeparatorProps): JSX.Element {
265
+ return (
266
+ <li
267
+ role="separator"
268
+ class={`my-1 border-t border-primary-600 ${props.class ?? ''}`}
269
+ />
270
+ )
271
+ }
272
+
273
+ // ============================================
274
+ // ICONS
275
+ // ============================================
276
+
277
+ function ChevronIcon(props: { class?: string }): JSX.Element {
278
+ return (
279
+ <svg
280
+ class={props.class}
281
+ fill="none"
282
+ viewBox="0 0 24 24"
283
+ stroke="currentColor"
284
+ stroke-width="2"
285
+ >
286
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
287
+ </svg>
288
+ )
289
+ }
290
+
291
+ // Attach sub-components for convenience
292
+ Menu.Item = MenuItem
293
+ Menu.Separator = MenuSeparator
294
+ MenuTrigger.Button = MenuButton
295
+
296
+ // Re-export Key type for convenience
297
+ export type { Key }