@torch-ui/solid 0.1.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.
- package/README.md +166 -0
- package/package.json +67 -0
- package/src/components/actions/Button.tsx +612 -0
- package/src/components/actions/ButtonGroup.tsx +728 -0
- package/src/components/actions/Copy.tsx +98 -0
- package/src/components/actions/DarkModeToggle.tsx +80 -0
- package/src/components/actions/Link.tsx +37 -0
- package/src/components/actions/index.ts +19 -0
- package/src/components/actions/useCopyToClipboard.ts +90 -0
- package/src/components/charts/Chart.tsx +331 -0
- package/src/components/charts/Sparkline.tsx +156 -0
- package/src/components/charts/index.ts +13 -0
- package/src/components/data-display/Avatar.tsx +208 -0
- package/src/components/data-display/AvatarGroup.tsx +228 -0
- package/src/components/data-display/Badge.tsx +70 -0
- package/src/components/data-display/Carousel.tsx +214 -0
- package/src/components/data-display/ColorSwatch.tsx +56 -0
- package/src/components/data-display/DataTable.tsx +886 -0
- package/src/components/data-display/EmptyState.tsx +61 -0
- package/src/components/data-display/Image.tsx +277 -0
- package/src/components/data-display/Kbd.tsx +114 -0
- package/src/components/data-display/Persona.tsx +78 -0
- package/src/components/data-display/StatCard.tsx +338 -0
- package/src/components/data-display/Table.tsx +147 -0
- package/src/components/data-display/Tag.tsx +91 -0
- package/src/components/data-display/Timeline.tsx +200 -0
- package/src/components/data-display/TreeView.tsx +172 -0
- package/src/components/data-display/Video.tsx +95 -0
- package/src/components/data-display/avatar-utils.ts +32 -0
- package/src/components/data-display/index.ts +81 -0
- package/src/components/feedback/Loading.tsx +159 -0
- package/src/components/feedback/Progress.tsx +321 -0
- package/src/components/feedback/Skeleton.tsx +62 -0
- package/src/components/feedback/SkeletonBlocks.tsx +222 -0
- package/src/components/feedback/Toast.tsx +648 -0
- package/src/components/feedback/index.ts +44 -0
- package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
- package/src/components/feedback/password/password-strength.ts +115 -0
- package/src/components/feedback/password/password-validation-data.ts +66 -0
- package/src/components/feedback/password/password-validation.ts +93 -0
- package/src/components/forms/Autocomplete.tsx +268 -0
- package/src/components/forms/Checkbox.tsx +155 -0
- package/src/components/forms/CodeInput.tsx +237 -0
- package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
- package/src/components/forms/ColorPicker/color-utils.ts +75 -0
- package/src/components/forms/ColorPicker/index.ts +2 -0
- package/src/components/forms/DatePicker.tsx +516 -0
- package/src/components/forms/DateRangePicker.tsx +464 -0
- package/src/components/forms/FieldPicker.tsx +64 -0
- package/src/components/forms/FileUpload.tsx +614 -0
- package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
- package/src/components/forms/FilterBuilder.tsx +16 -0
- package/src/components/forms/FilterRuleRow.tsx +68 -0
- package/src/components/forms/Input.tsx +200 -0
- package/src/components/forms/MultiSelect.tsx +361 -0
- package/src/components/forms/NumberField.tsx +145 -0
- package/src/components/forms/RadioGroup.tsx +135 -0
- package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
- package/src/components/forms/ReorderableList.tsx +163 -0
- package/src/components/forms/Select.tsx +268 -0
- package/src/components/forms/Slider.tsx +260 -0
- package/src/components/forms/Switch.tsx +135 -0
- package/src/components/forms/TextArea.tsx +202 -0
- package/src/components/forms/ViewCustomizer.tsx +44 -0
- package/src/components/forms/index.ts +43 -0
- package/src/components/layout/Accordion.tsx +110 -0
- package/src/components/layout/Alert.tsx +156 -0
- package/src/components/layout/BlockQuote.tsx +70 -0
- package/src/components/layout/Card.tsx +166 -0
- package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
- package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
- package/src/components/layout/CodeBlock/prism.ts +81 -0
- package/src/components/layout/Collapsible.tsx +84 -0
- package/src/components/layout/Container.tsx +55 -0
- package/src/components/layout/Divider.tsx +64 -0
- package/src/components/layout/Form.tsx +39 -0
- package/src/components/layout/FormActions.tsx +50 -0
- package/src/components/layout/Grid.tsx +53 -0
- package/src/components/layout/PageHeading.tsx +46 -0
- package/src/components/layout/PromptWithAction.tsx +49 -0
- package/src/components/layout/Section.tsx +60 -0
- package/src/components/layout/TablePanel.tsx +24 -0
- package/src/components/layout/TableView/TableView.tsx +1018 -0
- package/src/components/layout/TableView/index.ts +3 -0
- package/src/components/layout/TableView/types.ts +51 -0
- package/src/components/layout/WizardStep.tsx +40 -0
- package/src/components/layout/WizardStepper.tsx +173 -0
- package/src/components/layout/index.ts +96 -0
- package/src/components/navigation/Breadcrumbs.tsx +66 -0
- package/src/components/navigation/DropdownMenu.tsx +86 -0
- package/src/components/navigation/MegaMenu.tsx +480 -0
- package/src/components/navigation/NavigationMenu.tsx +305 -0
- package/src/components/navigation/Pagination.tsx +298 -0
- package/src/components/navigation/Sidebar.tsx +280 -0
- package/src/components/navigation/Tabs.tsx +122 -0
- package/src/components/navigation/ViewSwitcher.tsx +314 -0
- package/src/components/navigation/index.ts +66 -0
- package/src/components/overlays/AlertDialog.tsx +174 -0
- package/src/components/overlays/ContextMenu.tsx +65 -0
- package/src/components/overlays/Dialog.tsx +279 -0
- package/src/components/overlays/Drawer.tsx +370 -0
- package/src/components/overlays/HoverCard.tsx +107 -0
- package/src/components/overlays/Popover.tsx +73 -0
- package/src/components/overlays/Tooltip.tsx +31 -0
- package/src/components/overlays/index.ts +71 -0
- package/src/components/typography/Code.tsx +72 -0
- package/src/components/typography/Icon.tsx +36 -0
- package/src/components/typography/index.ts +10 -0
- package/src/env.d.ts +9 -0
- package/src/index.ts +13 -0
- package/src/styles/theme.css +226 -0
- package/src/types/avatar-types.ts +11 -0
- package/src/types/filter-types.ts +35 -0
- package/src/utilities/classNames.ts +6 -0
- package/src/utilities/componentSize.ts +46 -0
- package/src/utilities/i18n.tsx +60 -0
- package/src/utilities/mergeRefs.ts +12 -0
- package/src/utilities/relativeDateDefault.ts +14 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { type JSX, splitProps, Show, onMount } from 'solid-js'
|
|
2
|
+
import { Menubar as KobalteMenuBar } from "@kobalte/core/menubar";
|
|
3
|
+
import { ChevronDown } from 'lucide-solid'
|
|
4
|
+
import { cn } from '../../utilities/classNames'
|
|
5
|
+
|
|
6
|
+
function injectMenuBarStyles() {
|
|
7
|
+
const id = 'torchui-menu-bar-styles'
|
|
8
|
+
if (typeof document === 'undefined') return
|
|
9
|
+
let el = document.getElementById(id) as HTMLStyleElement | null
|
|
10
|
+
if (!el) {
|
|
11
|
+
el = document.createElement('style')
|
|
12
|
+
el.id = id
|
|
13
|
+
document.head.appendChild(el)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
el.textContent = `
|
|
17
|
+
@keyframes torchui-menu-in { from { opacity: 0; transform: scale(0.96) } to { opacity: 1; transform: scale(1) } }
|
|
18
|
+
@keyframes torchui-menu-out { from { opacity: 1; transform: scale(1) } to { opacity: 0; transform: scale(0.96) } }
|
|
19
|
+
.torchui-menubar-content {
|
|
20
|
+
transform-origin: var(--kb-menu-content-transform-origin);
|
|
21
|
+
animation: torchui-menu-out 150ms ease-in forwards;
|
|
22
|
+
}
|
|
23
|
+
.torchui-menubar-content[data-expanded] {
|
|
24
|
+
animation: torchui-menu-in 150ms ease-out;
|
|
25
|
+
}
|
|
26
|
+
`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MenuBarTriggerProps {
|
|
30
|
+
class?: string
|
|
31
|
+
children?: JSX.Element
|
|
32
|
+
/** Hide the default chevron icon */
|
|
33
|
+
noChevron?: boolean
|
|
34
|
+
/** Visual style variant */
|
|
35
|
+
variant?: 'default' | 'underline' | 'ghost'
|
|
36
|
+
/** Optional icon element */
|
|
37
|
+
icon?: JSX.Element
|
|
38
|
+
/** Icon placement relative to label */
|
|
39
|
+
iconPosition?: 'start' | 'end' | 'top' | 'bottom'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function MenuBarTrigger(props: MenuBarTriggerProps) {
|
|
43
|
+
const [local, others] = splitProps(props, ['class', 'children', 'noChevron', 'variant', 'icon', 'iconPosition'])
|
|
44
|
+
const v = () => local.variant ?? 'default'
|
|
45
|
+
const ip = () => local.iconPosition ?? 'start'
|
|
46
|
+
const isStacked = () => ip() === 'top' || ip() === 'bottom'
|
|
47
|
+
return (
|
|
48
|
+
<KobalteMenuBar.Trigger
|
|
49
|
+
class={cn(
|
|
50
|
+
'group relative inline-flex text-sm font-medium text-ink-700 transition-colors',
|
|
51
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
|
|
52
|
+
isStacked() ? 'flex-col items-center justify-center gap-1 px-3 py-2' : 'flex-row items-center gap-1.5',
|
|
53
|
+
v() === 'default' && [
|
|
54
|
+
!isStacked() && 'h-9',
|
|
55
|
+
'rounded-md px-3 py-2',
|
|
56
|
+
'hover:bg-surface-overlay hover:text-ink-900',
|
|
57
|
+
'data-[expanded]:bg-surface-overlay data-[expanded]:text-ink-900',
|
|
58
|
+
],
|
|
59
|
+
v() === 'underline' && [
|
|
60
|
+
!isStacked() && 'h-full',
|
|
61
|
+
'rounded-none px-4',
|
|
62
|
+
'hover:text-primary-600 data-[expanded]:text-primary-600',
|
|
63
|
+
],
|
|
64
|
+
v() === 'ghost' && [
|
|
65
|
+
!isStacked() && 'h-9',
|
|
66
|
+
'rounded-md px-3 py-2',
|
|
67
|
+
'hover:text-primary-600 data-[expanded]:text-primary-600',
|
|
68
|
+
],
|
|
69
|
+
local.class,
|
|
70
|
+
)}
|
|
71
|
+
{...others}
|
|
72
|
+
>
|
|
73
|
+
<Show when={local.icon && (ip() === 'start' || ip() === 'top')}>
|
|
74
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center">{local.icon}</span>
|
|
75
|
+
</Show>
|
|
76
|
+
<span class={cn(isStacked() && 'text-xs leading-none')}>{local.children}</span>
|
|
77
|
+
<Show when={local.icon && (ip() === 'end' || ip() === 'bottom')}>
|
|
78
|
+
<span class="flex h-4 w-4 shrink-0 items-center justify-center">{local.icon}</span>
|
|
79
|
+
</Show>
|
|
80
|
+
<Show when={!local.noChevron && !isStacked()}>
|
|
81
|
+
<ChevronDown
|
|
82
|
+
class="relative h-3.5 w-3.5 shrink-0 text-ink-400 transition-transform duration-200 group-data-[expanded]:rotate-180"
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
/>
|
|
85
|
+
</Show>
|
|
86
|
+
<Show when={v() === 'underline'}>
|
|
87
|
+
<span
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
class="absolute inset-x-0 bottom-0 h-[2px] origin-center scale-x-0 bg-primary-500 transition-transform duration-200 group-hover:scale-x-100 group-data-[expanded]:scale-x-100"
|
|
90
|
+
/>
|
|
91
|
+
</Show>
|
|
92
|
+
</KobalteMenuBar.Trigger>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MenuBarContentProps {
|
|
97
|
+
class?: string
|
|
98
|
+
children?: JSX.Element
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function MenuBarContent(props: MenuBarContentProps) {
|
|
102
|
+
const [local, others] = splitProps(props, ['class', 'children'])
|
|
103
|
+
return (
|
|
104
|
+
<KobalteMenuBar.Portal>
|
|
105
|
+
<KobalteMenuBar.Content
|
|
106
|
+
class={cn(
|
|
107
|
+
'torchui-menubar-content',
|
|
108
|
+
'z-[9999] mt-2 rounded-xl border border-surface-border bg-surface-raised shadow-lg p-2 outline-none',
|
|
109
|
+
local.class,
|
|
110
|
+
)}
|
|
111
|
+
{...others}
|
|
112
|
+
>
|
|
113
|
+
{local.children}
|
|
114
|
+
</KobalteMenuBar.Content>
|
|
115
|
+
</KobalteMenuBar.Portal>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface MenuBarItemProps {
|
|
120
|
+
class?: string
|
|
121
|
+
children?: JSX.Element
|
|
122
|
+
/** Leading icon */
|
|
123
|
+
icon?: JSX.Element
|
|
124
|
+
/** Short description text below the label */
|
|
125
|
+
description?: string
|
|
126
|
+
/** Icon placement relative to text */
|
|
127
|
+
iconPosition?: 'start' | 'end' | 'top' | 'bottom'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function MenuBarItem(props: MenuBarItemProps) {
|
|
131
|
+
const [local, others] = splitProps(props, ['class', 'children', 'icon', 'description', 'iconPosition'])
|
|
132
|
+
const ip = () => local.iconPosition ?? 'start'
|
|
133
|
+
return (
|
|
134
|
+
<KobalteMenuBar.Item
|
|
135
|
+
class={cn(
|
|
136
|
+
'relative flex cursor-default select-none rounded-lg px-3 py-2 text-sm outline-none transition-colors',
|
|
137
|
+
'text-ink-700 hover:bg-surface-overlay hover:text-ink-900',
|
|
138
|
+
'focus:bg-surface-overlay focus:text-ink-900',
|
|
139
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
140
|
+
(ip() === 'start' || ip() === 'end') ? 'items-start gap-2.5' : 'flex-col items-center text-center gap-1.5',
|
|
141
|
+
ip() === 'end' && 'flex-row-reverse',
|
|
142
|
+
ip() === 'bottom' && 'flex-col-reverse',
|
|
143
|
+
local.class,
|
|
144
|
+
)}
|
|
145
|
+
{...others}
|
|
146
|
+
>
|
|
147
|
+
<Show when={local.icon}>
|
|
148
|
+
<span class={cn(
|
|
149
|
+
'flex shrink-0 items-center justify-center text-ink-500',
|
|
150
|
+
(ip() === 'start' || ip() === 'end') ? 'mt-0.5 h-5 w-5' : 'h-5 w-5',
|
|
151
|
+
)}>
|
|
152
|
+
{local.icon}
|
|
153
|
+
</span>
|
|
154
|
+
</Show>
|
|
155
|
+
<div class={cn('min-w-0', (ip() === 'start' || ip() === 'end') && 'flex-1')}>
|
|
156
|
+
<div class="font-medium text-ink-900">{local.children}</div>
|
|
157
|
+
<Show when={local.description}>
|
|
158
|
+
<div class="mt-0.5 text-xs text-ink-500 leading-relaxed">{local.description}</div>
|
|
159
|
+
</Show>
|
|
160
|
+
</div>
|
|
161
|
+
</KobalteMenuBar.Item>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function MenuBarLabel(props: { class?: string; children: JSX.Element }) {
|
|
166
|
+
return (
|
|
167
|
+
<div class={cn('px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-ink-400', props.class)}>
|
|
168
|
+
{props.children}
|
|
169
|
+
</div>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function MenuBarDivider(props: { class?: string }) {
|
|
174
|
+
return <div role="separator" aria-orientation="horizontal" class={cn('-mx-2 my-1 h-px bg-surface-border', props.class)} />
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface MenuBarLinkProps {
|
|
178
|
+
href: string
|
|
179
|
+
class?: string
|
|
180
|
+
children?: JSX.Element
|
|
181
|
+
icon?: JSX.Element
|
|
182
|
+
description?: string
|
|
183
|
+
active?: boolean
|
|
184
|
+
/** Visual style variant */
|
|
185
|
+
variant?: 'default' | 'underline' | 'ghost'
|
|
186
|
+
/** Icon placement relative to text */
|
|
187
|
+
iconPosition?: 'start' | 'end' | 'top' | 'bottom'
|
|
188
|
+
/**
|
|
189
|
+
* Disabled state. Sets aria-disabled + tabIndex=-1 and prevents navigation.
|
|
190
|
+
* Note: plain anchors don't participate in Menubar roving focus — if full
|
|
191
|
+
* keyboard nav is needed, render via KobalteMenuBar.Item with asChild.
|
|
192
|
+
*/
|
|
193
|
+
disabled?: boolean
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function MenuBarLink(props: MenuBarLinkProps) {
|
|
197
|
+
const v = () => props.variant ?? 'default'
|
|
198
|
+
const ip = () => props.iconPosition ?? 'start'
|
|
199
|
+
return (
|
|
200
|
+
<a
|
|
201
|
+
href={props.disabled ? undefined : props.href}
|
|
202
|
+
aria-disabled={props.disabled || undefined}
|
|
203
|
+
tabIndex={props.disabled ? -1 : undefined}
|
|
204
|
+
onClick={props.disabled ? (e: Event) => e.preventDefault() : undefined}
|
|
205
|
+
class={cn(
|
|
206
|
+
'relative flex select-none text-sm outline-none transition-colors',
|
|
207
|
+
(ip() === 'start' || ip() === 'end') ? 'items-start gap-2.5' : 'flex-col items-center text-center gap-1.5',
|
|
208
|
+
ip() === 'end' && 'flex-row-reverse',
|
|
209
|
+
ip() === 'bottom' && 'flex-col-reverse',
|
|
210
|
+
v() === 'default' && [
|
|
211
|
+
'rounded-lg px-3 py-2',
|
|
212
|
+
props.active
|
|
213
|
+
? 'bg-primary-50 text-primary-700 dark:bg-primary-500/10 dark:text-primary-400'
|
|
214
|
+
: 'text-ink-700 hover:bg-surface-overlay hover:text-ink-900',
|
|
215
|
+
],
|
|
216
|
+
v() === 'underline' && [
|
|
217
|
+
'rounded-lg px-3 py-2',
|
|
218
|
+
props.active
|
|
219
|
+
? 'text-primary-700 underline underline-offset-2 dark:text-primary-400'
|
|
220
|
+
: 'text-ink-700 hover:text-ink-900 hover:underline hover:underline-offset-2',
|
|
221
|
+
],
|
|
222
|
+
v() === 'ghost' && [
|
|
223
|
+
'rounded-lg px-3 py-2',
|
|
224
|
+
props.active
|
|
225
|
+
? 'text-primary-700 dark:text-primary-400'
|
|
226
|
+
: 'text-ink-500 hover:text-ink-900',
|
|
227
|
+
],
|
|
228
|
+
props.class,
|
|
229
|
+
)}
|
|
230
|
+
>
|
|
231
|
+
<Show when={props.icon}>
|
|
232
|
+
<span class={cn(
|
|
233
|
+
'flex shrink-0 items-center justify-center',
|
|
234
|
+
(ip() === 'start' || ip() === 'end') ? 'mt-0.5 h-5 w-5' : 'h-5 w-5',
|
|
235
|
+
props.active ? 'text-primary-500' : 'text-ink-500',
|
|
236
|
+
)}>
|
|
237
|
+
{props.icon}
|
|
238
|
+
</span>
|
|
239
|
+
</Show>
|
|
240
|
+
<div class={cn('min-w-0', (ip() === 'start' || ip() === 'end') && 'flex-1')}>
|
|
241
|
+
<div class={cn('font-medium', props.active ? 'text-primary-700 dark:text-primary-400' : 'text-ink-900')}>
|
|
242
|
+
{props.children}
|
|
243
|
+
</div>
|
|
244
|
+
<Show when={props.description}>
|
|
245
|
+
<div class="mt-0.5 text-xs text-ink-500 leading-relaxed">{props.description}</div>
|
|
246
|
+
</Show>
|
|
247
|
+
</div>
|
|
248
|
+
</a>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface MenuBarProps {
|
|
253
|
+
class?: string
|
|
254
|
+
children?: JSX.Element
|
|
255
|
+
/** Horizontal alignment of the nav items */
|
|
256
|
+
justify?: 'start' | 'center' | 'end'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function MenuBar(props: MenuBarProps) {
|
|
260
|
+
const [local, others] = splitProps(props, ['class', 'children', 'justify'])
|
|
261
|
+
onMount(injectMenuBarStyles)
|
|
262
|
+
return (
|
|
263
|
+
<KobalteMenuBar
|
|
264
|
+
class={cn(
|
|
265
|
+
'flex h-full items-center gap-1',
|
|
266
|
+
local.justify === 'center' && 'justify-center',
|
|
267
|
+
local.justify === 'end' && 'justify-end',
|
|
268
|
+
local.class,
|
|
269
|
+
)}
|
|
270
|
+
{...others}
|
|
271
|
+
>
|
|
272
|
+
{local.children}
|
|
273
|
+
</KobalteMenuBar>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function MenuBarMenu(props: { children?: JSX.Element }) {
|
|
278
|
+
return <KobalteMenuBar.Menu {...props} />
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
export function MenuBarNavLink(props: MenuBarLinkProps) {
|
|
283
|
+
const v = () => props.variant ?? 'default'
|
|
284
|
+
return (
|
|
285
|
+
<a
|
|
286
|
+
href={props.href}
|
|
287
|
+
class={cn(
|
|
288
|
+
'group relative inline-flex h-full items-center text-sm font-medium text-ink-700 transition-colors',
|
|
289
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
|
|
290
|
+
v() === 'default' && 'h-9 rounded-md px-3 py-2 hover:bg-surface-overlay hover:text-ink-900',
|
|
291
|
+
v() === 'underline' && 'rounded-none px-4 hover:text-primary-600',
|
|
292
|
+
v() === 'ghost' && 'h-9 rounded-md px-3 py-2 hover:text-primary-600',
|
|
293
|
+
props.class,
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
{props.children}
|
|
297
|
+
<Show when={v() === 'underline'}>
|
|
298
|
+
<span
|
|
299
|
+
aria-hidden="true"
|
|
300
|
+
class="absolute inset-x-0 bottom-0 h-[2px] origin-center scale-x-0 bg-primary-500 transition-transform duration-200 group-hover:scale-x-100"
|
|
301
|
+
/>
|
|
302
|
+
</Show>
|
|
303
|
+
</a>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { type JSX, Show, For, splitProps, createEffect, createSignal, createUniqueId, onMount, onCleanup } from 'solid-js'
|
|
2
|
+
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-solid'
|
|
3
|
+
import { cn } from '../../utilities/classNames'
|
|
4
|
+
import { Button } from '../actions'
|
|
5
|
+
import { Select } from '../forms'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50]
|
|
8
|
+
|
|
9
|
+
export interface PaginationProps extends JSX.HTMLAttributes<HTMLElement> {
|
|
10
|
+
/** Current 1-based page. */
|
|
11
|
+
page: number
|
|
12
|
+
/** Total number of pages. */
|
|
13
|
+
totalPages: number
|
|
14
|
+
/** Called when page changes. */
|
|
15
|
+
onPageChange: (page: number) => void
|
|
16
|
+
/** Max page-number buttons to show (excluding prev/next). Default: 5. Set 0 to hide page numbers. */
|
|
17
|
+
maxPages?: number
|
|
18
|
+
/** Show first/last page buttons (double chevrons). Default: false. */
|
|
19
|
+
showFirstLast?: boolean
|
|
20
|
+
/** Total item count. When set, renders "Showing X–Y of Z" info text. */
|
|
21
|
+
totalItems?: number
|
|
22
|
+
/** Current page size. When set alongside onPageSizeChange, renders per-page selector. */
|
|
23
|
+
pageSize?: number
|
|
24
|
+
/** Called when page size changes. Required alongside pageSize for the per-page selector to render. */
|
|
25
|
+
onPageSizeChange?: (size: number) => void
|
|
26
|
+
/** Options for per-page selector. Default: [10, 25, 50]. */
|
|
27
|
+
pageSizeOptions?: number[]
|
|
28
|
+
/** Optional id for the per-page select element (for label association). */
|
|
29
|
+
selectId?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Inclusive integer range. */
|
|
33
|
+
function range(start: number, end: number): number[] {
|
|
34
|
+
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Unified pagination: page-number buttons with prev/next, optional "Showing X–Y of Z" info,
|
|
39
|
+
* and optional per-page size selector. Replaces both standalone Pagination and TablePaginationFooter.
|
|
40
|
+
*/
|
|
41
|
+
export function Pagination(props: PaginationProps) {
|
|
42
|
+
const [local, others] = splitProps(props, [
|
|
43
|
+
'page', 'totalPages', 'onPageChange', 'maxPages', 'showFirstLast',
|
|
44
|
+
'totalItems', 'pageSize', 'onPageSizeChange', 'pageSizeOptions',
|
|
45
|
+
'selectId', 'class',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
const total = () => Math.max(1, local.totalPages)
|
|
49
|
+
/** Clamped page — always valid even if consumer state is stale. */
|
|
50
|
+
const page = () => Math.max(1, Math.min(local.page, total()))
|
|
51
|
+
const userMaxPages = () => local.maxPages ?? 5
|
|
52
|
+
|
|
53
|
+
const hasInfo = () => local.totalItems != null
|
|
54
|
+
const hasPageSize = () => local.pageSize != null && local.onPageSizeChange != null
|
|
55
|
+
const showNav = () => total() > 1
|
|
56
|
+
|
|
57
|
+
// --- Responsive: measure fixed elements and available space ---
|
|
58
|
+
let navEl!: HTMLElement
|
|
59
|
+
let fixedEl!: HTMLDivElement
|
|
60
|
+
const [navWidth, setNavWidth] = createSignal(0)
|
|
61
|
+
const [fixedWidth, setFixedWidth] = createSignal(0)
|
|
62
|
+
// Fallback 40px breaks the circular dep: no buttons → no measurement → fallback → buttons render → measured.
|
|
63
|
+
const [btnWidth, setBtnWidth] = createSignal(40)
|
|
64
|
+
|
|
65
|
+
let ro: ResizeObserver | undefined
|
|
66
|
+
|
|
67
|
+
onMount(() => {
|
|
68
|
+
// Ensure refs are set before measuring
|
|
69
|
+
if (!navEl || !fixedEl) {
|
|
70
|
+
console.warn('Pagination refs not set in onMount')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
// Synchronous initial read — eliminates flash since first reactive computation has real values.
|
|
74
|
+
setNavWidth(navEl.clientWidth)
|
|
75
|
+
setFixedWidth(fixedEl.scrollWidth)
|
|
76
|
+
|
|
77
|
+
ro = new ResizeObserver((entries) => {
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.target === navEl) setNavWidth(entry.contentRect.width)
|
|
80
|
+
if (entry.target === fixedEl) setFixedWidth(entry.contentRect.width)
|
|
81
|
+
}
|
|
82
|
+
const btn = navEl.querySelector('button[data-page-btns]') as HTMLElement | null
|
|
83
|
+
if (btn) setBtnWidth(btn.offsetWidth + 4) // + gap-1
|
|
84
|
+
})
|
|
85
|
+
ro.observe(navEl)
|
|
86
|
+
ro.observe(fixedEl)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
onCleanup(() => ro?.disconnect())
|
|
90
|
+
|
|
91
|
+
// Count icon buttons: prev/next always, first/last conditional
|
|
92
|
+
const iconBtnCount = () => 2 + (local.showFirstLast ? 2 : 0)
|
|
93
|
+
// Each icon button ≈ 36px + gap-1 (4px)
|
|
94
|
+
const iconBtnsWidth = () => iconBtnCount() * 40
|
|
95
|
+
|
|
96
|
+
/** Compute how many page buttons fit in the remaining space after fixed elements. */
|
|
97
|
+
const effectiveMaxPages = (): number => {
|
|
98
|
+
const m = userMaxPages()
|
|
99
|
+
if (m === 0) return 0
|
|
100
|
+
const nw = navWidth()
|
|
101
|
+
if (nw === 0) return m // pre-measurement fallback; observer corrects within 1 frame
|
|
102
|
+
// Available = nav - info/perpage - gap - icon buttons - rounding buffer
|
|
103
|
+
const gap = fixedWidth() > 0 ? 16 : 0 // gap-4 between fixedEl and btnGroup
|
|
104
|
+
const available = nw - fixedWidth() - gap - iconBtnsWidth() - 16
|
|
105
|
+
// Deduct 1 slot to reserve space for up to 2 ellipsis (~22px each < 1 button width)
|
|
106
|
+
const fits = Math.floor(available / btnWidth()) - 1
|
|
107
|
+
if (fits < 3) return 0
|
|
108
|
+
return Math.min(m, fits)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const showPageNumbers = () => effectiveMaxPages() > 0
|
|
112
|
+
|
|
113
|
+
/** Sync consumer state when clamp kicks in (e.g. page-size change shrinks totalPages below current page). */
|
|
114
|
+
createEffect(() => {
|
|
115
|
+
const clamped = Math.max(1, Math.min(local.page, total()))
|
|
116
|
+
if (clamped !== local.page) local.onPageChange(clamped)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// --- Info text ---
|
|
120
|
+
const pageSizeVal = () => local.pageSize ?? 10
|
|
121
|
+
const start = () => (page() - 1) * pageSizeVal()
|
|
122
|
+
const end = () => Math.min(start() + pageSizeVal(), local.totalItems ?? 0)
|
|
123
|
+
|
|
124
|
+
// --- Per-page selector ---
|
|
125
|
+
const pageSizeOptions = () => local.pageSizeOptions ?? DEFAULT_PAGE_SIZE_OPTIONS
|
|
126
|
+
const pageSizeSelectOptions = () =>
|
|
127
|
+
pageSizeOptions().map((n) => ({ value: String(n), label: String(n) }))
|
|
128
|
+
const uniqueId = createUniqueId()
|
|
129
|
+
const selectElId = () => local.selectId ?? `pagination-page-size-${uniqueId}`
|
|
130
|
+
|
|
131
|
+
// --- Page number buttons ---
|
|
132
|
+
/** Build page buttons. maxPages = exact max number of numbered buttons (including first/last). */
|
|
133
|
+
const pageRange = (): (number | '...')[] => {
|
|
134
|
+
const t = total()
|
|
135
|
+
const c = page()
|
|
136
|
+
const m = effectiveMaxPages()
|
|
137
|
+
if (!showPageNumbers() || t <= 1) return []
|
|
138
|
+
if (t <= m) return range(1, t)
|
|
139
|
+
|
|
140
|
+
const result: (number | '...')[] = []
|
|
141
|
+
// Always show first page
|
|
142
|
+
result.push(1)
|
|
143
|
+
// Window budget: m minus first and last
|
|
144
|
+
const windowSize = Math.max(0, m - 2)
|
|
145
|
+
if (windowSize === 0) {
|
|
146
|
+
// Only first and last
|
|
147
|
+
if (t > 2) result.push('...')
|
|
148
|
+
result.push(t)
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
// Center window around current page
|
|
152
|
+
const half = Math.floor(windowSize / 2)
|
|
153
|
+
let wStart = Math.max(2, c - half)
|
|
154
|
+
let wEnd = Math.min(t - 1, wStart + windowSize - 1)
|
|
155
|
+
wStart = Math.max(2, wEnd - windowSize + 1)
|
|
156
|
+
|
|
157
|
+
if (wStart > 2) result.push('...')
|
|
158
|
+
for (let i = wStart; i <= wEnd; i++) result.push(i)
|
|
159
|
+
if (wEnd < t - 1) result.push('...')
|
|
160
|
+
result.push(t)
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const canPrev = () => page() > 1
|
|
165
|
+
const canNext = () => page() < total()
|
|
166
|
+
|
|
167
|
+
function handlePageSizeChange(v: string) {
|
|
168
|
+
local.onPageSizeChange?.(Number(v))
|
|
169
|
+
local.onPageChange(1)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<nav
|
|
174
|
+
ref={(el) => (navEl = el)}
|
|
175
|
+
role="navigation"
|
|
176
|
+
aria-label="Pagination"
|
|
177
|
+
{...others}
|
|
178
|
+
class={cn(
|
|
179
|
+
'flex w-full items-center gap-4',
|
|
180
|
+
(hasInfo() || hasPageSize()) ? '' : 'justify-center',
|
|
181
|
+
local.class,
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
{/* Info + per-page selector. Measured by ResizeObserver for available-width calc. */}
|
|
185
|
+
<div ref={(el) => (fixedEl = el)} class="flex shrink-0 items-center gap-4">
|
|
186
|
+
<Show when={hasInfo()}>
|
|
187
|
+
<p class="shrink-0 text-sm text-ink-600">
|
|
188
|
+
Showing{' '}
|
|
189
|
+
{(local.totalItems ?? 0) === 0 ? (
|
|
190
|
+
<>
|
|
191
|
+
<span class="font-medium text-ink-900">0</span> of{' '}
|
|
192
|
+
<span class="font-medium text-ink-900">0</span>
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<>
|
|
196
|
+
<span class="font-medium text-ink-900">{start() + 1}</span>–
|
|
197
|
+
<span class="font-medium text-ink-900">{end()}</span> of{' '}
|
|
198
|
+
<span class="font-medium text-ink-900">{local.totalItems}</span>
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
</p>
|
|
202
|
+
</Show>
|
|
203
|
+
<Show when={hasPageSize()}>
|
|
204
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
205
|
+
<label for={selectElId()} class="text-sm text-ink-500">
|
|
206
|
+
Per page
|
|
207
|
+
</label>
|
|
208
|
+
<Select
|
|
209
|
+
id={selectElId()}
|
|
210
|
+
value={String(pageSizeVal())}
|
|
211
|
+
onValueChange={handlePageSizeChange}
|
|
212
|
+
options={pageSizeSelectOptions()}
|
|
213
|
+
compact
|
|
214
|
+
class="w-20 rounded-lg"
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</Show>
|
|
218
|
+
</div>
|
|
219
|
+
{/* All nav buttons in one gap-1 cluster: «first ‹prev [pages] next› last» */}
|
|
220
|
+
<Show when={showNav()}>
|
|
221
|
+
<div class={cn('flex items-center gap-1', (hasInfo() || hasPageSize()) && 'ml-auto')}>
|
|
222
|
+
<Show when={local.showFirstLast}>
|
|
223
|
+
<Button
|
|
224
|
+
type="button"
|
|
225
|
+
variant="outlined"
|
|
226
|
+
size="sm"
|
|
227
|
+
iconOnly
|
|
228
|
+
icon={<ChevronsLeft class="h-4 w-4" />}
|
|
229
|
+
aria-label="First page"
|
|
230
|
+
disabled={!canPrev()}
|
|
231
|
+
onClick={() => local.onPageChange(1)}
|
|
232
|
+
class="rounded-lg"
|
|
233
|
+
/>
|
|
234
|
+
</Show>
|
|
235
|
+
<Button
|
|
236
|
+
type="button"
|
|
237
|
+
variant="outlined"
|
|
238
|
+
size="sm"
|
|
239
|
+
iconOnly
|
|
240
|
+
icon={<ChevronLeft class="h-4 w-4" />}
|
|
241
|
+
aria-label="Previous page"
|
|
242
|
+
disabled={!canPrev()}
|
|
243
|
+
onClick={() => local.onPageChange(page() - 1)}
|
|
244
|
+
class="rounded-lg"
|
|
245
|
+
/>
|
|
246
|
+
<Show when={showPageNumbers()}>
|
|
247
|
+
<For each={pageRange()}>
|
|
248
|
+
{(p) =>
|
|
249
|
+
typeof p === 'number' ? (
|
|
250
|
+
<Button
|
|
251
|
+
type="button"
|
|
252
|
+
variant={p === page() ? 'primary' : 'outlined'}
|
|
253
|
+
size="sm"
|
|
254
|
+
aria-label={p === page() ? `Page ${p}` : `Go to page ${p}`}
|
|
255
|
+
aria-current={p === page() ? 'page' : undefined}
|
|
256
|
+
onClick={() => local.onPageChange(p)}
|
|
257
|
+
class="min-w-[2.25rem] rounded-lg"
|
|
258
|
+
data-page-btns
|
|
259
|
+
>
|
|
260
|
+
{p}
|
|
261
|
+
</Button>
|
|
262
|
+
) : (
|
|
263
|
+
<span class="px-1 text-ink-400" aria-hidden="true">
|
|
264
|
+
…
|
|
265
|
+
</span>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
</For>
|
|
269
|
+
</Show>
|
|
270
|
+
<Button
|
|
271
|
+
type="button"
|
|
272
|
+
variant="outlined"
|
|
273
|
+
size="sm"
|
|
274
|
+
iconOnly
|
|
275
|
+
icon={<ChevronRight class="h-4 w-4" />}
|
|
276
|
+
aria-label="Next page"
|
|
277
|
+
disabled={!canNext()}
|
|
278
|
+
onClick={() => local.onPageChange(page() + 1)}
|
|
279
|
+
class="rounded-lg"
|
|
280
|
+
/>
|
|
281
|
+
<Show when={local.showFirstLast}>
|
|
282
|
+
<Button
|
|
283
|
+
type="button"
|
|
284
|
+
variant="outlined"
|
|
285
|
+
size="sm"
|
|
286
|
+
iconOnly
|
|
287
|
+
icon={<ChevronsRight class="h-4 w-4" />}
|
|
288
|
+
aria-label="Last page"
|
|
289
|
+
disabled={!canNext()}
|
|
290
|
+
onClick={() => local.onPageChange(total())}
|
|
291
|
+
class="rounded-lg"
|
|
292
|
+
/>
|
|
293
|
+
</Show>
|
|
294
|
+
</div>
|
|
295
|
+
</Show>
|
|
296
|
+
</nav>
|
|
297
|
+
)
|
|
298
|
+
}
|