@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,314 @@
|
|
|
1
|
+
import { type JSX, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, splitProps } from 'solid-js'
|
|
2
|
+
import { Pin } from 'lucide-solid'
|
|
3
|
+
import { cn } from '../../utilities/classNames'
|
|
4
|
+
import { DropdownMenuContent, DropdownMenuItem } from './DropdownMenu'
|
|
5
|
+
import { DropdownMenu as KobalteDropdownMenu } from '@kobalte/core/dropdown-menu'
|
|
6
|
+
|
|
7
|
+
export type ViewScope = 'user' | 'tenant'
|
|
8
|
+
|
|
9
|
+
export interface ViewSwitcherItem {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
count?: number
|
|
13
|
+
scope?: ViewScope
|
|
14
|
+
pinned?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ViewSwitcherProps {
|
|
18
|
+
views: ViewSwitcherItem[]
|
|
19
|
+
activeId: string
|
|
20
|
+
onChange: (id: string) => void
|
|
21
|
+
onAdd?: () => void
|
|
22
|
+
addIcon?: JSX.Element
|
|
23
|
+
maxVisible?: number
|
|
24
|
+
moreLabel?: string
|
|
25
|
+
variant?: 'standalone' | 'embedded'
|
|
26
|
+
/** Accessible label for the view switcher group. Default: "Views". */
|
|
27
|
+
ariaLabel?: string
|
|
28
|
+
class?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DIVIDER_WIDTH = 1
|
|
32
|
+
const ESTIMATED_TAB_WIDTH = 80
|
|
33
|
+
const OVERFLOW_TRIGGER_WIDTH = 100
|
|
34
|
+
const ADD_BUTTON_WIDTH = 40
|
|
35
|
+
|
|
36
|
+
/** View switcher with overflow dropdown (e.g. table views). Single use case: switch between views with optional count and overflow. */
|
|
37
|
+
export function ViewSwitcher(props: ViewSwitcherProps) {
|
|
38
|
+
const [local] = splitProps(props, [
|
|
39
|
+
'views', 'activeId', 'onChange', 'onAdd', 'addIcon',
|
|
40
|
+
'maxVisible', 'moreLabel', 'variant', 'ariaLabel', 'class',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
const [dynamicMax, setDynamicMax] = createSignal<number | null>(null)
|
|
44
|
+
let containerRef: HTMLDivElement | undefined
|
|
45
|
+
// Component lifetime: persists for the duration the component is mounted.
|
|
46
|
+
const widthCache = new Map<string, number>()
|
|
47
|
+
let lastContainerWidth = 0
|
|
48
|
+
|
|
49
|
+
const isEmbedded = () => local.variant === 'embedded'
|
|
50
|
+
const dividerWidth = () => (isEmbedded() ? DIVIDER_WIDTH : 0)
|
|
51
|
+
|
|
52
|
+
const maxVisible = () => {
|
|
53
|
+
if (local.maxVisible != null) return Math.max(local.maxVisible, 1)
|
|
54
|
+
const d = dynamicMax()
|
|
55
|
+
return d != null ? Math.max(d, 1) : local.views.length
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pinned-first order for measurement (no active swap to avoid dynamicMax feedback loop).
|
|
59
|
+
const measureViews = createMemo(() => {
|
|
60
|
+
const pinned = local.views.filter((v) => v.pinned)
|
|
61
|
+
const unpinned = local.views.filter((v) => !v.pinned)
|
|
62
|
+
return [...pinned, ...unpinned]
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Sort pinned views first, then ensure the active view is visible.
|
|
66
|
+
// Measurement uses measureViews (pinned-first, no swap) to avoid dynamicMax feedback loop.
|
|
67
|
+
const renderViews = createMemo(() => {
|
|
68
|
+
const sorted = measureViews()
|
|
69
|
+
const max = maxVisible()
|
|
70
|
+
if (max <= 0) return sorted
|
|
71
|
+
const activeIdx = sorted.findIndex((v) => v.id === local.activeId)
|
|
72
|
+
if (activeIdx < 0 || activeIdx < max) return sorted
|
|
73
|
+
const result = [...sorted]
|
|
74
|
+
;[result[max - 1], result[activeIdx]] = [result[activeIdx], result[max - 1]]
|
|
75
|
+
return result
|
|
76
|
+
})
|
|
77
|
+
const visibleAndOverflow = createMemo(() => {
|
|
78
|
+
const all = renderViews()
|
|
79
|
+
const max = Math.min(maxVisible(), all.length)
|
|
80
|
+
return { visible: all.slice(0, max), overflow: all.slice(max) }
|
|
81
|
+
})
|
|
82
|
+
const visibleViews = createMemo(() => visibleAndOverflow().visible)
|
|
83
|
+
const overflowViews = createMemo(() => visibleAndOverflow().overflow)
|
|
84
|
+
|
|
85
|
+
/** Count how many views fit in the available width using cached widths. */
|
|
86
|
+
const fitCount = (views: ViewSwitcherItem[], available: number, reserved: number) => {
|
|
87
|
+
const divW = dividerWidth()
|
|
88
|
+
let total = 0
|
|
89
|
+
let count = 0
|
|
90
|
+
for (const view of views) {
|
|
91
|
+
const w = widthCache.get(view.id) ?? ESTIMATED_TAB_WIDTH
|
|
92
|
+
const nextDividers = count * divW // dividers between tabs (count tabs → count dividers)
|
|
93
|
+
if (total + w + nextDividers + reserved > available && count > 0) break
|
|
94
|
+
total += w
|
|
95
|
+
count++
|
|
96
|
+
}
|
|
97
|
+
return count
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const measure = (force = false) => {
|
|
101
|
+
if (!containerRef) return
|
|
102
|
+
// Use Math.ceil for consistency with tab width measurements
|
|
103
|
+
const containerWidth = Math.ceil(containerRef.getBoundingClientRect().width)
|
|
104
|
+
if (containerWidth <= 0) return
|
|
105
|
+
if (!force && containerWidth === lastContainerWidth) return
|
|
106
|
+
lastContainerWidth = containerWidth
|
|
107
|
+
|
|
108
|
+
// Width cache is keyed by view id, so render order (including active swap) doesn't matter.
|
|
109
|
+
const tabElements = containerRef.querySelectorAll<HTMLElement>('[data-view-tab]')
|
|
110
|
+
for (const el of tabElements) {
|
|
111
|
+
const id = el.dataset.viewId
|
|
112
|
+
if (id) {
|
|
113
|
+
// Ceil to avoid subpixel flicker at certain zoom levels
|
|
114
|
+
const width = Math.ceil(el.getBoundingClientRect().width) || el.offsetWidth
|
|
115
|
+
widthCache.set(id, width)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const views = measureViews()
|
|
120
|
+
const addReserve = local.onAdd ? ADD_BUTTON_WIDTH : 0
|
|
121
|
+
|
|
122
|
+
// First pass: assume no overflow trigger needed
|
|
123
|
+
let count = fitCount(views, containerWidth, addReserve)
|
|
124
|
+
|
|
125
|
+
// If that creates overflow, re-fit accounting for the overflow trigger
|
|
126
|
+
if (count < views.length) {
|
|
127
|
+
count = fitCount(views, containerWidth, addReserve + OVERFLOW_TRIGGER_WIDTH)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const next = Math.max(count, 1)
|
|
131
|
+
setDynamicMax((prev) => (prev !== next ? next : prev))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let raf = 0
|
|
135
|
+
const scheduleMeasure = (force = false) => {
|
|
136
|
+
cancelAnimationFrame(raf)
|
|
137
|
+
raf = requestAnimationFrame(() => measure(force))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMount(() => {
|
|
141
|
+
if (local.maxVisible != null) return
|
|
142
|
+
if (!containerRef) return
|
|
143
|
+
|
|
144
|
+
const ro = new ResizeObserver(() => scheduleMeasure(false))
|
|
145
|
+
ro.observe(containerRef)
|
|
146
|
+
|
|
147
|
+
// First paint: force measure
|
|
148
|
+
scheduleMeasure(true)
|
|
149
|
+
|
|
150
|
+
// Fonts can shift widths without resizing the container
|
|
151
|
+
let cancelled = false
|
|
152
|
+
const fontsReady = typeof document !== 'undefined' ? document.fonts?.ready : undefined
|
|
153
|
+
fontsReady?.then(() => { if (!cancelled) scheduleMeasure(true) })
|
|
154
|
+
|
|
155
|
+
onCleanup(() => {
|
|
156
|
+
cancelled = true
|
|
157
|
+
cancelAnimationFrame(raf)
|
|
158
|
+
ro.disconnect()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
createEffect(() => {
|
|
163
|
+
if (local.maxVisible != null) return
|
|
164
|
+
// Cheap hash-ish signature without allocating huge strings
|
|
165
|
+
let acc = local.views.length
|
|
166
|
+
for (const v of local.views) {
|
|
167
|
+
acc = (acc * 31 + v.id.length + v.label.length + (v.count ?? 0) + (v.pinned ? 1 : 0)) | 0
|
|
168
|
+
}
|
|
169
|
+
void acc
|
|
170
|
+
scheduleMeasure(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// Prune stale cache entries when views change
|
|
174
|
+
createEffect(() => {
|
|
175
|
+
const ids = new Set(local.views.map((v) => v.id))
|
|
176
|
+
for (const key of widthCache.keys()) {
|
|
177
|
+
if (!ids.has(key)) widthCache.delete(key)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Automatic activation: arrow keys move focus and select (view switcher pattern).
|
|
182
|
+
const onTabKeyDown: JSX.EventHandlerUnion<HTMLButtonElement, KeyboardEvent> = (e) => {
|
|
183
|
+
const key = e.key
|
|
184
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)) return
|
|
185
|
+
e.preventDefault()
|
|
186
|
+
const tabs = containerRef?.querySelectorAll<HTMLButtonElement>('[data-view-tab]')
|
|
187
|
+
if (!tabs || tabs.length === 0) return
|
|
188
|
+
const idx = Array.prototype.indexOf.call(tabs, e.currentTarget)
|
|
189
|
+
let next = idx
|
|
190
|
+
if (key === 'ArrowLeft') next = Math.max(0, idx - 1)
|
|
191
|
+
if (key === 'ArrowRight') next = Math.min(tabs.length - 1, idx + 1)
|
|
192
|
+
if (key === 'Home') next = 0
|
|
193
|
+
if (key === 'End') next = tabs.length - 1
|
|
194
|
+
tabs[next]?.focus()
|
|
195
|
+
const nextId = tabs[next]?.dataset.viewId
|
|
196
|
+
if (nextId) local.onChange(nextId)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const containerClass = () =>
|
|
200
|
+
isEmbedded()
|
|
201
|
+
? 'flex min-w-0 w-full items-stretch gap-0 overflow-hidden rounded-t-2xl'
|
|
202
|
+
: 'inline-flex w-full max-w-full items-center gap-1.5 overflow-hidden rounded-xl border border-surface-border bg-surface-dim px-2 py-1.5'
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div
|
|
206
|
+
ref={(el) => (containerRef = el)}
|
|
207
|
+
role="group"
|
|
208
|
+
aria-label={local.ariaLabel ?? 'Views'}
|
|
209
|
+
class={cn(containerClass(), local.class)}
|
|
210
|
+
>
|
|
211
|
+
{(() => {
|
|
212
|
+
const vis = visibleViews()
|
|
213
|
+
const ov = overflowViews()
|
|
214
|
+
return (
|
|
215
|
+
<>
|
|
216
|
+
<For each={vis}>
|
|
217
|
+
{(view, i) => {
|
|
218
|
+
const isActive = () => view.id === local.activeId
|
|
219
|
+
const isFirst = () => i() === 0
|
|
220
|
+
const isLastVisible = () => i() === vis.length - 1
|
|
221
|
+
const hideDivider = () => {
|
|
222
|
+
const next = vis[i() + 1]
|
|
223
|
+
return isActive() || next?.id === local.activeId
|
|
224
|
+
}
|
|
225
|
+
return (
|
|
226
|
+
<>
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
data-view-tab
|
|
230
|
+
data-view-id={view.id}
|
|
231
|
+
class={cn(
|
|
232
|
+
'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium transition-colors',
|
|
233
|
+
isEmbedded() &&
|
|
234
|
+
'max-w-[200px] shrink-0 rounded-t-2xl rounded-b-none border border-transparent border-b-0',
|
|
235
|
+
isEmbedded() && isFirst() && 'rounded-tl-2xl',
|
|
236
|
+
isEmbedded() && isLastVisible() && 'rounded-tr-2xl',
|
|
237
|
+
isEmbedded() &&
|
|
238
|
+
(isActive()
|
|
239
|
+
? 'relative z-10 -mx-px -mt-px bg-surface-raised text-ink-900 border border-surface-border border-b-transparent'
|
|
240
|
+
: 'bg-transparent text-ink-500 hover:text-ink-700'),
|
|
241
|
+
!isEmbedded() &&
|
|
242
|
+
(isActive()
|
|
243
|
+
? 'rounded-lg border border-surface-border bg-surface-raised text-ink-900 shadow-sm'
|
|
244
|
+
: 'rounded-lg border border-transparent text-ink-500 hover:text-ink-700 hover:bg-surface-overlay'),
|
|
245
|
+
)}
|
|
246
|
+
aria-current={isActive() ? 'page' : undefined}
|
|
247
|
+
tabIndex={isActive() ? 0 : -1}
|
|
248
|
+
onKeyDown={onTabKeyDown}
|
|
249
|
+
onClick={() => local.onChange(view.id)}
|
|
250
|
+
>
|
|
251
|
+
<Show when={view.pinned}>
|
|
252
|
+
<Pin size={12} class="shrink-0 text-ink-400" aria-hidden="true" />
|
|
253
|
+
</Show>
|
|
254
|
+
<span class="min-w-0 truncate">{view.label}</span>
|
|
255
|
+
<Show when={typeof view.count === 'number'}>
|
|
256
|
+
<span class="shrink-0 rounded-full bg-ink-200/80 px-2 py-0.5 text-xs font-semibold text-ink-600">
|
|
257
|
+
{view.count}
|
|
258
|
+
</span>
|
|
259
|
+
</Show>
|
|
260
|
+
</button>
|
|
261
|
+
<Show when={isEmbedded() && !isLastVisible() && !hideDivider()}>
|
|
262
|
+
<span class="w-px bg-surface-border" role="presentation" aria-hidden="true" />
|
|
263
|
+
</Show>
|
|
264
|
+
</>
|
|
265
|
+
)
|
|
266
|
+
}}
|
|
267
|
+
</For>
|
|
268
|
+
<Show when={ov.length > 0}>
|
|
269
|
+
<KobalteDropdownMenu>
|
|
270
|
+
<KobalteDropdownMenu.Trigger as="button" type="button" class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-ink-600 hover:bg-surface-raised hover:text-ink-900">
|
|
271
|
+
{local.moreLabel ?? 'More'}
|
|
272
|
+
<span class="rounded-full bg-ink-200 px-2 py-0.5 text-xs font-semibold text-ink-600">
|
|
273
|
+
{ov.length}
|
|
274
|
+
</span>
|
|
275
|
+
</KobalteDropdownMenu.Trigger>
|
|
276
|
+
<DropdownMenuContent>
|
|
277
|
+
<For each={ov}>
|
|
278
|
+
{(view) => (
|
|
279
|
+
<DropdownMenuItem onSelect={() => local.onChange(view.id)}>
|
|
280
|
+
<div class="flex w-full items-center justify-between gap-3">
|
|
281
|
+
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
282
|
+
<Show when={view.pinned}>
|
|
283
|
+
<Pin size={12} class="shrink-0 text-ink-400" aria-hidden="true" />
|
|
284
|
+
</Show>
|
|
285
|
+
<span class="truncate">{view.label}</span>
|
|
286
|
+
</div>
|
|
287
|
+
<Show when={typeof view.count === 'number'}>
|
|
288
|
+
<span class="shrink-0 rounded-full bg-ink-200 px-2 py-0.5 text-xs font-semibold text-ink-600">
|
|
289
|
+
{view.count}
|
|
290
|
+
</span>
|
|
291
|
+
</Show>
|
|
292
|
+
</div>
|
|
293
|
+
</DropdownMenuItem>
|
|
294
|
+
)}
|
|
295
|
+
</For>
|
|
296
|
+
</DropdownMenuContent>
|
|
297
|
+
</KobalteDropdownMenu>
|
|
298
|
+
</Show>
|
|
299
|
+
</>
|
|
300
|
+
)
|
|
301
|
+
})()}
|
|
302
|
+
<Show when={local.onAdd}>
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
class="ml-2 inline-flex h-7 w-7 shrink-0 items-center justify-center self-center rounded-lg border border-transparent text-ink-500 transition-colors hover:bg-surface-raised hover:text-ink-900"
|
|
306
|
+
aria-label="Add view"
|
|
307
|
+
onClick={local.onAdd}
|
|
308
|
+
>
|
|
309
|
+
{local.addIcon}
|
|
310
|
+
</button>
|
|
311
|
+
</Show>
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Navigation: Breadcrumbs, Dropdown Menu, NavigationMenu, Pagination, Tabs, ViewSwitcher */
|
|
2
|
+
export { Breadcrumbs, type BreadcrumbsProps, type BreadcrumbItem } from './Breadcrumbs'
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuSeparator,
|
|
8
|
+
type DropdownMenuContentProps,
|
|
9
|
+
type DropdownMenuItemProps,
|
|
10
|
+
type DropdownMenuSeparatorProps,
|
|
11
|
+
} from './DropdownMenu'
|
|
12
|
+
|
|
13
|
+
export { Pagination, type PaginationProps } from './Pagination'
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
Tabs,
|
|
17
|
+
KobalteTabs,
|
|
18
|
+
TabsContent,
|
|
19
|
+
TabsList,
|
|
20
|
+
TabsTrigger,
|
|
21
|
+
type TabsProps,
|
|
22
|
+
type TabItem,
|
|
23
|
+
type TabsContentProps,
|
|
24
|
+
type TabsListProps,
|
|
25
|
+
type TabsTriggerProps,
|
|
26
|
+
} from './Tabs'
|
|
27
|
+
|
|
28
|
+
export { ViewSwitcher, type ViewSwitcherProps, type ViewSwitcherItem, type ViewScope } from './ViewSwitcher'
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
MenuBar,
|
|
32
|
+
MenuBarMenu,
|
|
33
|
+
MenuBarTrigger,
|
|
34
|
+
MenuBarContent,
|
|
35
|
+
MenuBarItem,
|
|
36
|
+
MenuBarLink,
|
|
37
|
+
MenuBarLabel,
|
|
38
|
+
MenuBarDivider,
|
|
39
|
+
MenuBarNavLink,
|
|
40
|
+
type MenuBarProps,
|
|
41
|
+
type MenuBarTriggerProps,
|
|
42
|
+
type MenuBarContentProps,
|
|
43
|
+
type MenuBarItemProps,
|
|
44
|
+
type MenuBarLinkProps,
|
|
45
|
+
} from './NavigationMenu'
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
MegaMenuBar,
|
|
49
|
+
MegaMenuMenu,
|
|
50
|
+
MegaMenuTrigger,
|
|
51
|
+
MegaMenuContent,
|
|
52
|
+
MegaMenuPanel,
|
|
53
|
+
MegaMenuColumn,
|
|
54
|
+
MegaMenuSection,
|
|
55
|
+
MegaMenuItem,
|
|
56
|
+
MegaMenuFeatured,
|
|
57
|
+
MegaMenuDivider,
|
|
58
|
+
MegaMenuFooter,
|
|
59
|
+
MegaMenuFooterLink,
|
|
60
|
+
type MegaMenuBarProps,
|
|
61
|
+
type MegaMenuTriggerProps,
|
|
62
|
+
type MegaMenuContentProps,
|
|
63
|
+
type MegaMenuPanelProps,
|
|
64
|
+
type MegaMenuItemProps,
|
|
65
|
+
type MegaMenuFeaturedProps,
|
|
66
|
+
} from './MegaMenu'
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Show, splitProps, onMount, createSignal, createEffect } from 'solid-js'
|
|
2
|
+
import { AlertDialog as KobalteAlertDialog, type AlertDialogRootProps as KobalteAlertDialogRootProps } from '@kobalte/core/alert-dialog'
|
|
3
|
+
import { cn } from '../../utilities/classNames'
|
|
4
|
+
import { Button } from '../actions'
|
|
5
|
+
|
|
6
|
+
// Both constants are evaluated at module load and baked into alertDialogStyles
|
|
7
|
+
// as literal values. They are intentionally not runtime-configurable.
|
|
8
|
+
const DEFAULT_DURATION_MS = 200
|
|
9
|
+
const EXIT_DURATION_MS = Math.round(DEFAULT_DURATION_MS * 0.8)
|
|
10
|
+
|
|
11
|
+
const alertDialogStyles = `
|
|
12
|
+
@keyframes torchui-alert-overlay-fade-in {
|
|
13
|
+
from { opacity: 0; }
|
|
14
|
+
to { opacity: 1; }
|
|
15
|
+
}
|
|
16
|
+
@keyframes torchui-alert-overlay-fade-out {
|
|
17
|
+
from { opacity: 1; }
|
|
18
|
+
to { opacity: 0; }
|
|
19
|
+
}
|
|
20
|
+
@keyframes torchui-alert-panel-scale-in {
|
|
21
|
+
from { opacity: 0; transform: scale(0.96); }
|
|
22
|
+
to { opacity: 1; transform: scale(1); }
|
|
23
|
+
}
|
|
24
|
+
@keyframes torchui-alert-panel-scale-out {
|
|
25
|
+
from { opacity: 1; transform: scale(1); }
|
|
26
|
+
to { opacity: 0; transform: scale(0.96); }
|
|
27
|
+
}
|
|
28
|
+
.torchui-alert-dialog-overlay {
|
|
29
|
+
animation: torchui-alert-overlay-fade-out ${EXIT_DURATION_MS}ms ease-in forwards;
|
|
30
|
+
}
|
|
31
|
+
.torchui-alert-dialog-overlay[data-expanded] {
|
|
32
|
+
animation: torchui-alert-overlay-fade-in ${DEFAULT_DURATION_MS}ms ease-out forwards;
|
|
33
|
+
}
|
|
34
|
+
.torchui-alert-dialog-content {
|
|
35
|
+
animation: torchui-alert-panel-scale-out ${EXIT_DURATION_MS}ms ease-in forwards;
|
|
36
|
+
}
|
|
37
|
+
.torchui-alert-dialog-content[data-expanded] {
|
|
38
|
+
animation: torchui-alert-panel-scale-in ${DEFAULT_DURATION_MS}ms ease-out forwards;
|
|
39
|
+
}
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
// ID-based DOM guard: prevents duplicate style tags even across multiple module loads
|
|
43
|
+
// (e.g., microfrontends, test setups, different bundles)
|
|
44
|
+
const STYLE_ID = 'torchui-alert-dialog-styles'
|
|
45
|
+
function ensureAlertStyles() {
|
|
46
|
+
if (typeof document === 'undefined') return
|
|
47
|
+
if (document.getElementById(STYLE_ID)) return
|
|
48
|
+
|
|
49
|
+
const style = document.createElement('style')
|
|
50
|
+
style.id = STYLE_ID
|
|
51
|
+
style.textContent = alertDialogStyles
|
|
52
|
+
document.head.appendChild(style)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AlertDialogProps extends Omit<KobalteAlertDialogRootProps, 'open' | 'onOpenChange'> {
|
|
56
|
+
/** Whether the dialog is open */
|
|
57
|
+
open: boolean
|
|
58
|
+
/** Called when open state changes */
|
|
59
|
+
onOpenChange?: (open: boolean) => void
|
|
60
|
+
/** Dialog title */
|
|
61
|
+
title: string
|
|
62
|
+
/** Dialog description */
|
|
63
|
+
description?: string
|
|
64
|
+
/** Label for the confirm/action button */
|
|
65
|
+
confirmLabel?: string
|
|
66
|
+
/** Label for the cancel button */
|
|
67
|
+
cancelLabel?: string
|
|
68
|
+
/** Called when user confirms. If it returns a Promise, the dialog stays open until resolved. */
|
|
69
|
+
onConfirm?: () => void | Promise<void>
|
|
70
|
+
/** Called when user cancels */
|
|
71
|
+
onCancel?: () => void
|
|
72
|
+
/** When true, confirm button uses destructive (red) styling */
|
|
73
|
+
destructive?: boolean
|
|
74
|
+
/** Additional class for the content panel */
|
|
75
|
+
class?: string
|
|
76
|
+
/** Additional class for the overlay */
|
|
77
|
+
overlayClass?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AlertDialog(props: AlertDialogProps) {
|
|
81
|
+
const [local] = splitProps(props, [
|
|
82
|
+
'open', 'onOpenChange', 'onCancel', 'title', 'description',
|
|
83
|
+
'confirmLabel', 'cancelLabel', 'onConfirm',
|
|
84
|
+
'destructive', 'class', 'overlayClass',
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
onMount(ensureAlertStyles)
|
|
88
|
+
|
|
89
|
+
const [pending, setPending] = createSignal(false)
|
|
90
|
+
let closingFromConfirm = false
|
|
91
|
+
|
|
92
|
+
if (import.meta.env?.DEV) {
|
|
93
|
+
createEffect(() => {
|
|
94
|
+
if (local.open && !local.onOpenChange) {
|
|
95
|
+
console.warn('[AlertDialog] open is true but onOpenChange is not provided. The dialog cannot be closed by user interaction.')
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const handleOpenChange = (isOpen: boolean) => {
|
|
101
|
+
if (!isOpen) {
|
|
102
|
+
if (!closingFromConfirm) local.onCancel?.()
|
|
103
|
+
closingFromConfirm = false
|
|
104
|
+
}
|
|
105
|
+
local.onOpenChange?.(isOpen)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handleConfirm = async () => {
|
|
109
|
+
if (pending()) return
|
|
110
|
+
setPending(true)
|
|
111
|
+
try {
|
|
112
|
+
await local.onConfirm?.()
|
|
113
|
+
closingFromConfirm = true
|
|
114
|
+
handleOpenChange(false) // ✅ actually closes through Root handler
|
|
115
|
+
} catch {
|
|
116
|
+
// Keep dialog open on failure; caller can surface error via toast/state.
|
|
117
|
+
} finally {
|
|
118
|
+
setPending(false)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<KobalteAlertDialog
|
|
124
|
+
open={local.open}
|
|
125
|
+
onOpenChange={handleOpenChange}
|
|
126
|
+
>
|
|
127
|
+
<KobalteAlertDialog.Portal>
|
|
128
|
+
<KobalteAlertDialog.Overlay
|
|
129
|
+
class={cn(
|
|
130
|
+
'torchui-alert-dialog-overlay fixed inset-0 z-[100]',
|
|
131
|
+
'bg-black/30 dark:bg-black/50 backdrop-blur-md dark:backdrop-blur-sm',
|
|
132
|
+
local.overlayClass,
|
|
133
|
+
)}
|
|
134
|
+
/>
|
|
135
|
+
<div class="fixed left-1/2 top-1/2 z-[101] w-full max-w-md -translate-x-1/2 -translate-y-1/2 p-4">
|
|
136
|
+
{/* ARIA alert-dialog: user must choose Cancel or Confirm; outside click should not dismiss. */}
|
|
137
|
+
<KobalteAlertDialog.Content
|
|
138
|
+
class="torchui-alert-dialog-content block w-full"
|
|
139
|
+
onInteractOutside={(e) => e.preventDefault()}
|
|
140
|
+
>
|
|
141
|
+
<div
|
|
142
|
+
class={cn(
|
|
143
|
+
'torchui-alert-dialog-content-panel rounded-2xl border border-surface-border bg-surface-raised p-6 shadow-xl dark:shadow-[0_20px_50px_-12px_rgba(0,0,0,.5)]',
|
|
144
|
+
local.class,
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
<KobalteAlertDialog.Title class="text-lg font-semibold text-ink-900">
|
|
148
|
+
{local.title}
|
|
149
|
+
</KobalteAlertDialog.Title>
|
|
150
|
+
<Show when={local.description}>
|
|
151
|
+
<KobalteAlertDialog.Description class="mt-2 text-sm text-ink-500">
|
|
152
|
+
{local.description}
|
|
153
|
+
</KobalteAlertDialog.Description>
|
|
154
|
+
</Show>
|
|
155
|
+
<div class="mt-6 flex justify-end gap-3">
|
|
156
|
+
<KobalteAlertDialog.CloseButton as={Button} variant="outlined" size="sm">
|
|
157
|
+
{local.cancelLabel ?? 'Cancel'}
|
|
158
|
+
</KobalteAlertDialog.CloseButton>
|
|
159
|
+
<Button
|
|
160
|
+
variant={local.destructive ? 'danger' : 'primary'}
|
|
161
|
+
size="sm"
|
|
162
|
+
disabled={pending()}
|
|
163
|
+
onClick={handleConfirm}
|
|
164
|
+
>
|
|
165
|
+
{local.confirmLabel ?? 'Confirm'}
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</KobalteAlertDialog.Content>
|
|
170
|
+
</div>
|
|
171
|
+
</KobalteAlertDialog.Portal>
|
|
172
|
+
</KobalteAlertDialog>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type JSX, splitProps } from 'solid-js'
|
|
2
|
+
import { ContextMenu as KobalteContextMenu, type ContextMenuContentProps as KobalteContextMenuContentProps, type ContextMenuItemProps as KobalteContextMenuItemProps, type ContextMenuSeparatorProps as KobalteContextMenuSeparatorProps } from '@kobalte/core/context-menu'
|
|
3
|
+
import { cn } from '../../utilities/classNames'
|
|
4
|
+
|
|
5
|
+
/** Pass-through to Kobalte's ContextMenu.Root. See Kobalte docs for available props (e.g. onOpenChange). */
|
|
6
|
+
export const ContextMenuRoot = KobalteContextMenu
|
|
7
|
+
/** Pass-through to Kobalte's ContextMenu.Trigger. Renders as the element that responds to right-click. */
|
|
8
|
+
export const ContextMenuTrigger = KobalteContextMenu.Trigger
|
|
9
|
+
|
|
10
|
+
export interface ContextMenuContentProps extends KobalteContextMenuContentProps {
|
|
11
|
+
class?: string
|
|
12
|
+
children?: JSX.Element
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ContextMenuContent(props: ContextMenuContentProps) {
|
|
16
|
+
const [local, others] = splitProps(props, ['class', 'children'])
|
|
17
|
+
return (
|
|
18
|
+
<KobalteContextMenu.Portal>
|
|
19
|
+
<KobalteContextMenu.Content
|
|
20
|
+
class={cn(
|
|
21
|
+
'z-50 min-w-[160px] rounded-lg border border-surface-border bg-surface-raised p-1 shadow-lg',
|
|
22
|
+
local.class
|
|
23
|
+
)}
|
|
24
|
+
{...others}
|
|
25
|
+
>
|
|
26
|
+
{local.children}
|
|
27
|
+
</KobalteContextMenu.Content>
|
|
28
|
+
</KobalteContextMenu.Portal>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ContextMenuItemProps extends KobalteContextMenuItemProps {
|
|
33
|
+
class?: string
|
|
34
|
+
children: JSX.Element
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ContextMenuItem(props: ContextMenuItemProps) {
|
|
38
|
+
const [local, others] = splitProps(props, ['class', 'children'])
|
|
39
|
+
return (
|
|
40
|
+
<KobalteContextMenu.Item
|
|
41
|
+
class={cn(
|
|
42
|
+
'flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 text-sm text-ink-700 outline-none',
|
|
43
|
+
'data-[highlighted]:bg-surface-overlay data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
|
|
44
|
+
local.class,
|
|
45
|
+
)}
|
|
46
|
+
{...others}
|
|
47
|
+
>
|
|
48
|
+
{local.children}
|
|
49
|
+
</KobalteContextMenu.Item>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ContextMenuSeparatorProps extends KobalteContextMenuSeparatorProps {
|
|
54
|
+
class?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
|
|
58
|
+
const [local, others] = splitProps(props, ['class'])
|
|
59
|
+
return (
|
|
60
|
+
<KobalteContextMenu.Separator
|
|
61
|
+
class={cn('my-1 h-px bg-surface-border', local.class)}
|
|
62
|
+
{...others}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
}
|