@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.
Files changed (118) hide show
  1. package/README.md +166 -0
  2. package/package.json +67 -0
  3. package/src/components/actions/Button.tsx +612 -0
  4. package/src/components/actions/ButtonGroup.tsx +728 -0
  5. package/src/components/actions/Copy.tsx +98 -0
  6. package/src/components/actions/DarkModeToggle.tsx +80 -0
  7. package/src/components/actions/Link.tsx +37 -0
  8. package/src/components/actions/index.ts +19 -0
  9. package/src/components/actions/useCopyToClipboard.ts +90 -0
  10. package/src/components/charts/Chart.tsx +331 -0
  11. package/src/components/charts/Sparkline.tsx +156 -0
  12. package/src/components/charts/index.ts +13 -0
  13. package/src/components/data-display/Avatar.tsx +208 -0
  14. package/src/components/data-display/AvatarGroup.tsx +228 -0
  15. package/src/components/data-display/Badge.tsx +70 -0
  16. package/src/components/data-display/Carousel.tsx +214 -0
  17. package/src/components/data-display/ColorSwatch.tsx +56 -0
  18. package/src/components/data-display/DataTable.tsx +886 -0
  19. package/src/components/data-display/EmptyState.tsx +61 -0
  20. package/src/components/data-display/Image.tsx +277 -0
  21. package/src/components/data-display/Kbd.tsx +114 -0
  22. package/src/components/data-display/Persona.tsx +78 -0
  23. package/src/components/data-display/StatCard.tsx +338 -0
  24. package/src/components/data-display/Table.tsx +147 -0
  25. package/src/components/data-display/Tag.tsx +91 -0
  26. package/src/components/data-display/Timeline.tsx +200 -0
  27. package/src/components/data-display/TreeView.tsx +172 -0
  28. package/src/components/data-display/Video.tsx +95 -0
  29. package/src/components/data-display/avatar-utils.ts +32 -0
  30. package/src/components/data-display/index.ts +81 -0
  31. package/src/components/feedback/Loading.tsx +159 -0
  32. package/src/components/feedback/Progress.tsx +321 -0
  33. package/src/components/feedback/Skeleton.tsx +62 -0
  34. package/src/components/feedback/SkeletonBlocks.tsx +222 -0
  35. package/src/components/feedback/Toast.tsx +648 -0
  36. package/src/components/feedback/index.ts +44 -0
  37. package/src/components/feedback/password/PasswordStrengthIndicator.tsx +232 -0
  38. package/src/components/feedback/password/password-strength.ts +115 -0
  39. package/src/components/feedback/password/password-validation-data.ts +66 -0
  40. package/src/components/feedback/password/password-validation.ts +93 -0
  41. package/src/components/forms/Autocomplete.tsx +268 -0
  42. package/src/components/forms/Checkbox.tsx +155 -0
  43. package/src/components/forms/CodeInput.tsx +237 -0
  44. package/src/components/forms/ColorPicker/ColorPicker.tsx +469 -0
  45. package/src/components/forms/ColorPicker/color-utils.ts +75 -0
  46. package/src/components/forms/ColorPicker/index.ts +2 -0
  47. package/src/components/forms/DatePicker.tsx +516 -0
  48. package/src/components/forms/DateRangePicker.tsx +464 -0
  49. package/src/components/forms/FieldPicker.tsx +64 -0
  50. package/src/components/forms/FileUpload.tsx +614 -0
  51. package/src/components/forms/FilterBuilder/FilterGroupBlock.ts +6 -0
  52. package/src/components/forms/FilterBuilder.tsx +16 -0
  53. package/src/components/forms/FilterRuleRow.tsx +68 -0
  54. package/src/components/forms/Input.tsx +200 -0
  55. package/src/components/forms/MultiSelect.tsx +361 -0
  56. package/src/components/forms/NumberField.tsx +145 -0
  57. package/src/components/forms/RadioGroup.tsx +135 -0
  58. package/src/components/forms/RelativeDateDefaultInput.tsx +62 -0
  59. package/src/components/forms/ReorderableList.tsx +163 -0
  60. package/src/components/forms/Select.tsx +268 -0
  61. package/src/components/forms/Slider.tsx +260 -0
  62. package/src/components/forms/Switch.tsx +135 -0
  63. package/src/components/forms/TextArea.tsx +202 -0
  64. package/src/components/forms/ViewCustomizer.tsx +44 -0
  65. package/src/components/forms/index.ts +43 -0
  66. package/src/components/layout/Accordion.tsx +110 -0
  67. package/src/components/layout/Alert.tsx +156 -0
  68. package/src/components/layout/BlockQuote.tsx +70 -0
  69. package/src/components/layout/Card.tsx +166 -0
  70. package/src/components/layout/CodeBlock/CodeBlock.tsx +477 -0
  71. package/src/components/layout/CodeBlock/code-block-tokens.css +104 -0
  72. package/src/components/layout/CodeBlock/prism.ts +81 -0
  73. package/src/components/layout/Collapsible.tsx +84 -0
  74. package/src/components/layout/Container.tsx +55 -0
  75. package/src/components/layout/Divider.tsx +64 -0
  76. package/src/components/layout/Form.tsx +39 -0
  77. package/src/components/layout/FormActions.tsx +50 -0
  78. package/src/components/layout/Grid.tsx +53 -0
  79. package/src/components/layout/PageHeading.tsx +46 -0
  80. package/src/components/layout/PromptWithAction.tsx +49 -0
  81. package/src/components/layout/Section.tsx +60 -0
  82. package/src/components/layout/TablePanel.tsx +24 -0
  83. package/src/components/layout/TableView/TableView.tsx +1018 -0
  84. package/src/components/layout/TableView/index.ts +3 -0
  85. package/src/components/layout/TableView/types.ts +51 -0
  86. package/src/components/layout/WizardStep.tsx +40 -0
  87. package/src/components/layout/WizardStepper.tsx +173 -0
  88. package/src/components/layout/index.ts +96 -0
  89. package/src/components/navigation/Breadcrumbs.tsx +66 -0
  90. package/src/components/navigation/DropdownMenu.tsx +86 -0
  91. package/src/components/navigation/MegaMenu.tsx +480 -0
  92. package/src/components/navigation/NavigationMenu.tsx +305 -0
  93. package/src/components/navigation/Pagination.tsx +298 -0
  94. package/src/components/navigation/Sidebar.tsx +280 -0
  95. package/src/components/navigation/Tabs.tsx +122 -0
  96. package/src/components/navigation/ViewSwitcher.tsx +314 -0
  97. package/src/components/navigation/index.ts +66 -0
  98. package/src/components/overlays/AlertDialog.tsx +174 -0
  99. package/src/components/overlays/ContextMenu.tsx +65 -0
  100. package/src/components/overlays/Dialog.tsx +279 -0
  101. package/src/components/overlays/Drawer.tsx +370 -0
  102. package/src/components/overlays/HoverCard.tsx +107 -0
  103. package/src/components/overlays/Popover.tsx +73 -0
  104. package/src/components/overlays/Tooltip.tsx +31 -0
  105. package/src/components/overlays/index.ts +71 -0
  106. package/src/components/typography/Code.tsx +72 -0
  107. package/src/components/typography/Icon.tsx +36 -0
  108. package/src/components/typography/index.ts +10 -0
  109. package/src/env.d.ts +9 -0
  110. package/src/index.ts +13 -0
  111. package/src/styles/theme.css +226 -0
  112. package/src/types/avatar-types.ts +11 -0
  113. package/src/types/filter-types.ts +35 -0
  114. package/src/utilities/classNames.ts +6 -0
  115. package/src/utilities/componentSize.ts +46 -0
  116. package/src/utilities/i18n.tsx +60 -0
  117. package/src/utilities/mergeRefs.ts +12 -0
  118. 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
+ }