@proyecto-viviana/ui 0.1.7 → 0.2.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.
- package/README.md +192 -0
- package/dist/autocomplete/index.d.ts +89 -0
- package/dist/autocomplete/index.d.ts.map +1 -0
- package/dist/breadcrumbs/index.d.ts +38 -0
- package/dist/breadcrumbs/index.d.ts.map +1 -0
- package/dist/button/Button.d.ts.map +1 -1
- package/dist/calendar/DateField.d.ts +47 -0
- package/dist/calendar/DateField.d.ts.map +1 -0
- package/dist/calendar/DatePicker.d.ts +48 -0
- package/dist/calendar/DatePicker.d.ts.map +1 -0
- package/dist/calendar/RangeCalendar.d.ts +42 -0
- package/dist/calendar/RangeCalendar.d.ts.map +1 -0
- package/dist/calendar/TimeField.d.ts +44 -0
- package/dist/calendar/TimeField.d.ts.map +1 -0
- package/dist/calendar/index.d.ts +50 -0
- package/dist/calendar/index.d.ts.map +1 -0
- package/dist/checkbox/index.d.ts.map +1 -1
- package/dist/color/index.d.ts +228 -0
- package/dist/color/index.d.ts.map +1 -0
- package/dist/combobox/index.d.ts +81 -0
- package/dist/combobox/index.d.ts.map +1 -0
- package/dist/components.css +116 -14
- package/dist/custom/chip/index.d.ts +7 -2
- package/dist/custom/chip/index.d.ts.map +1 -1
- package/dist/custom/event-card/index.d.ts +5 -1
- package/dist/custom/event-card/index.d.ts.map +1 -1
- package/dist/custom/header/index.d.ts +16 -0
- package/dist/custom/header/index.d.ts.map +1 -0
- package/dist/custom/logo/index.d.ts +2 -0
- package/dist/custom/logo/index.d.ts.map +1 -1
- package/dist/custom/page-layout/index.d.ts +2 -0
- package/dist/custom/page-layout/index.d.ts.map +1 -1
- package/dist/custom/profile-card/index.d.ts +5 -1
- package/dist/custom/profile-card/index.d.ts.map +1 -1
- package/dist/custom/timeline-item/index.d.ts +12 -2
- package/dist/custom/timeline-item/index.d.ts.map +1 -1
- package/dist/dialog/Dialog.d.ts +67 -0
- package/dist/dialog/Dialog.d.ts.map +1 -0
- package/dist/dialog/index.d.ts +2 -17
- package/dist/dialog/index.d.ts.map +1 -1
- package/dist/disclosure/index.d.ts +84 -0
- package/dist/disclosure/index.d.ts.map +1 -0
- package/dist/gridlist/index.d.ts +92 -0
- package/dist/gridlist/index.d.ts.map +1 -0
- package/dist/index.d.ts +58 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6984 -783
- package/dist/index.js.map +1 -1
- package/dist/index.ssr.js +5905 -571
- package/dist/index.ssr.js.map +1 -1
- package/dist/landmark/index.d.ts +83 -0
- package/dist/landmark/index.d.ts.map +1 -0
- package/dist/link/index.d.ts.map +1 -1
- package/dist/listbox/index.d.ts +47 -0
- package/dist/listbox/index.d.ts.map +1 -0
- package/dist/menu/index.d.ts +74 -0
- package/dist/menu/index.d.ts.map +1 -0
- package/dist/meter/index.d.ts +49 -0
- package/dist/meter/index.d.ts.map +1 -0
- package/dist/numberfield/index.d.ts +50 -0
- package/dist/numberfield/index.d.ts.map +1 -0
- package/dist/popover/index.d.ts +85 -0
- package/dist/popover/index.d.ts.map +1 -0
- package/dist/radio/index.d.ts +7 -4
- package/dist/radio/index.d.ts.map +1 -1
- package/dist/searchfield/index.d.ts +44 -0
- package/dist/searchfield/index.d.ts.map +1 -0
- package/dist/select/index.d.ts +72 -0
- package/dist/select/index.d.ts.map +1 -0
- package/dist/slider/index.d.ts +53 -0
- package/dist/slider/index.d.ts.map +1 -0
- package/dist/switch/ToggleSwitch.d.ts.map +1 -1
- package/dist/table/index.d.ts +140 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/tabs/index.d.ts +56 -0
- package/dist/tabs/index.d.ts.map +1 -0
- package/dist/tag-group/index.d.ts +80 -0
- package/dist/tag-group/index.d.ts.map +1 -0
- package/dist/toast/index.d.ts +101 -0
- package/dist/toast/index.d.ts.map +1 -0
- package/dist/toolbar/index.d.ts +42 -0
- package/dist/toolbar/index.d.ts.map +1 -0
- package/dist/tooltip/index.d.ts +66 -5
- package/dist/tooltip/index.d.ts.map +1 -1
- package/dist/tree/index.d.ts +99 -0
- package/dist/tree/index.d.ts.map +1 -0
- package/package.json +66 -58
- package/src/autocomplete/index.tsx +313 -0
- package/src/breadcrumbs/index.tsx +207 -0
- package/src/button/Button.tsx +74 -75
- package/src/calendar/DateField.tsx +200 -0
- package/src/calendar/DatePicker.tsx +298 -0
- package/src/calendar/RangeCalendar.tsx +236 -0
- package/src/calendar/TimeField.tsx +196 -0
- package/src/calendar/index.tsx +223 -0
- package/src/checkbox/index.tsx +3 -4
- package/src/color/index.tsx +687 -0
- package/src/combobox/index.tsx +383 -0
- package/src/components.css +116 -14
- package/src/custom/chip/index.tsx +17 -3
- package/src/custom/event-card/index.tsx +8 -2
- package/src/custom/header/index.tsx +33 -0
- package/src/custom/logo/index.tsx +7 -3
- package/src/custom/page-layout/index.tsx +12 -3
- package/src/custom/profile-card/index.tsx +8 -2
- package/src/custom/timeline-item/index.tsx +28 -4
- package/src/dialog/Dialog.tsx +260 -0
- package/src/dialog/index.tsx +3 -69
- package/src/disclosure/index.tsx +307 -0
- package/src/gridlist/index.tsx +403 -0
- package/src/index.ts +219 -4
- package/src/landmark/index.tsx +231 -0
- package/src/link/index.tsx +1 -2
- package/src/listbox/index.tsx +231 -0
- package/src/menu/index.tsx +297 -0
- package/src/meter/index.tsx +163 -0
- package/src/numberfield/index.tsx +482 -0
- package/src/popover/index.tsx +260 -0
- package/src/radio/index.tsx +36 -82
- package/src/searchfield/index.tsx +453 -0
- package/src/select/index.tsx +349 -0
- package/src/slider/index.tsx +382 -0
- package/src/switch/ToggleSwitch.tsx +1 -2
- package/src/table/index.tsx +531 -0
- package/src/tabs/index.tsx +273 -0
- package/src/tag-group/index.tsx +240 -0
- package/src/toast/index.tsx +324 -0
- package/src/toolbar/index.tsx +108 -0
- package/src/tooltip/index.tsx +171 -5
- package/src/tree/index.tsx +494 -0
|
@@ -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 }
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meter component for proyecto-viviana-ui
|
|
3
|
+
*
|
|
4
|
+
* Styled meter component built on top of the solidaria hook directly.
|
|
5
|
+
* Meters represent a quantity within a known range (unlike progress bars which show progress toward a goal).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type JSX, splitProps, Show, createMemo } from 'solid-js';
|
|
9
|
+
import { createMeter } from '@proyecto-viviana/solidaria';
|
|
10
|
+
|
|
11
|
+
// ============================================
|
|
12
|
+
// TYPES
|
|
13
|
+
// ============================================
|
|
14
|
+
|
|
15
|
+
export type MeterSize = 'sm' | 'md' | 'lg';
|
|
16
|
+
export type MeterVariant = 'primary' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
|
|
17
|
+
|
|
18
|
+
export interface MeterProps {
|
|
19
|
+
/** The current value (controlled). @default 0 */
|
|
20
|
+
value?: number;
|
|
21
|
+
/** The smallest value allowed. @default 0 */
|
|
22
|
+
minValue?: number;
|
|
23
|
+
/** The largest value allowed. @default 100 */
|
|
24
|
+
maxValue?: number;
|
|
25
|
+
/** The content to display as the value's label (e.g. "75 GB"). */
|
|
26
|
+
valueLabel?: string;
|
|
27
|
+
/** The size of the meter. @default 'md' */
|
|
28
|
+
size?: MeterSize;
|
|
29
|
+
/** The visual style variant. @default 'primary' */
|
|
30
|
+
variant?: MeterVariant;
|
|
31
|
+
/** The label to display above the meter. */
|
|
32
|
+
label?: string;
|
|
33
|
+
/** Whether to show the value text. @default true */
|
|
34
|
+
showValueLabel?: boolean;
|
|
35
|
+
/** Additional CSS class name. */
|
|
36
|
+
class?: string;
|
|
37
|
+
/** An accessibility label. */
|
|
38
|
+
'aria-label'?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================
|
|
42
|
+
// STYLES
|
|
43
|
+
// ============================================
|
|
44
|
+
|
|
45
|
+
const sizeStyles = {
|
|
46
|
+
sm: {
|
|
47
|
+
track: 'h-1',
|
|
48
|
+
text: 'text-xs',
|
|
49
|
+
},
|
|
50
|
+
md: {
|
|
51
|
+
track: 'h-2',
|
|
52
|
+
text: 'text-sm',
|
|
53
|
+
},
|
|
54
|
+
lg: {
|
|
55
|
+
track: 'h-3',
|
|
56
|
+
text: 'text-base',
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const variantStyles = {
|
|
61
|
+
primary: 'bg-primary-500',
|
|
62
|
+
accent: 'bg-accent',
|
|
63
|
+
success: 'bg-green-500',
|
|
64
|
+
warning: 'bg-yellow-500',
|
|
65
|
+
danger: 'bg-red-500',
|
|
66
|
+
info: 'bg-blue-500',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ============================================
|
|
70
|
+
// UTILITIES
|
|
71
|
+
// ============================================
|
|
72
|
+
|
|
73
|
+
function clamp(value: number, min: number, max: number): number {
|
|
74
|
+
return Math.min(Math.max(value, min), max);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================
|
|
78
|
+
// METER COMPONENT
|
|
79
|
+
// ============================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Meters represent a quantity within a known range, or a fractional value.
|
|
83
|
+
* Unlike progress bars, meters represent a current value rather than progress toward a goal.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```tsx
|
|
87
|
+
* // Storage usage meter
|
|
88
|
+
* <Meter value={75} label="Storage space" valueLabel="75 GB of 100 GB" />
|
|
89
|
+
*
|
|
90
|
+
* // Battery level
|
|
91
|
+
* <Meter value={25} variant="warning" label="Battery" />
|
|
92
|
+
*
|
|
93
|
+
* // CPU usage with dynamic color
|
|
94
|
+
* <Meter value={cpuUsage} variant={cpuUsage > 80 ? 'danger' : 'success'} label="CPU" />
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function Meter(props: MeterProps): JSX.Element {
|
|
98
|
+
const [local, ariaProps] = splitProps(props, [
|
|
99
|
+
'size',
|
|
100
|
+
'variant',
|
|
101
|
+
'label',
|
|
102
|
+
'showValueLabel',
|
|
103
|
+
'class',
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const size = () => local.size ?? 'md';
|
|
107
|
+
const variant = () => local.variant ?? 'primary';
|
|
108
|
+
const showValueLabel = () => local.showValueLabel ?? true;
|
|
109
|
+
|
|
110
|
+
// Create meter aria props
|
|
111
|
+
const meterAria = createMeter({
|
|
112
|
+
get value() { return ariaProps.value; },
|
|
113
|
+
get minValue() { return ariaProps.minValue; },
|
|
114
|
+
get maxValue() { return ariaProps.maxValue; },
|
|
115
|
+
get valueLabel() { return ariaProps.valueLabel; },
|
|
116
|
+
get label() { return local.label; },
|
|
117
|
+
get 'aria-label'() { return ariaProps['aria-label']; },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Calculate percentage
|
|
121
|
+
const percentage = createMemo(() => {
|
|
122
|
+
const value = ariaProps.value ?? 0;
|
|
123
|
+
const minValue = ariaProps.minValue ?? 0;
|
|
124
|
+
const maxValue = ariaProps.maxValue ?? 100;
|
|
125
|
+
const clampedValue = clamp(value, minValue, maxValue);
|
|
126
|
+
return ((clampedValue - minValue) / (maxValue - minValue)) * 100;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Get value text from aria props
|
|
130
|
+
const valueText = () => meterAria.meterProps['aria-valuetext'] as string | undefined;
|
|
131
|
+
|
|
132
|
+
const sizeConfig = () => sizeStyles[size()];
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
{...meterAria.meterProps}
|
|
137
|
+
class={`w-full ${local.class ?? ''}`}
|
|
138
|
+
>
|
|
139
|
+
{/* Label and value row */}
|
|
140
|
+
<Show when={local.label || showValueLabel()}>
|
|
141
|
+
<div class={`flex justify-between items-center mb-1 ${sizeConfig().text}`}>
|
|
142
|
+
<Show when={local.label}>
|
|
143
|
+
<span class="text-primary-200 font-medium">{local.label}</span>
|
|
144
|
+
</Show>
|
|
145
|
+
<Show when={showValueLabel()}>
|
|
146
|
+
<span class="text-primary-300">{valueText()}</span>
|
|
147
|
+
</Show>
|
|
148
|
+
</div>
|
|
149
|
+
</Show>
|
|
150
|
+
|
|
151
|
+
{/* Track */}
|
|
152
|
+
<div class={`w-full ${sizeConfig().track} bg-bg-300 rounded-full overflow-hidden`}>
|
|
153
|
+
{/* Fill */}
|
|
154
|
+
<div
|
|
155
|
+
class={`h-full rounded-full transition-all duration-300 ${variantStyles[variant()]}`}
|
|
156
|
+
style={{
|
|
157
|
+
width: `${percentage()}%`,
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|