@hoardodile/ui 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +70 -0
- package/dist/components/alert.d.ts +13 -0
- package/dist/components/alert.js +97 -0
- package/dist/components/alert.js.map +1 -0
- package/dist/components/app-dialog.d.ts +62 -0
- package/dist/components/app-dialog.js +656 -0
- package/dist/components/app-dialog.js.map +1 -0
- package/dist/components/badge.d.ts +11 -0
- package/dist/components/badge.js +70 -0
- package/dist/components/badge.js.map +1 -0
- package/dist/components/breadcrumb.d.ts +12 -0
- package/dist/components/breadcrumb.js +617 -0
- package/dist/components/breadcrumb.js.map +1 -0
- package/dist/components/button.d.ts +17 -0
- package/dist/components/button.js +78 -0
- package/dist/components/button.js.map +1 -0
- package/dist/components/caption-bar.d.ts +44 -0
- package/dist/components/caption-bar.js +827 -0
- package/dist/components/caption-bar.js.map +1 -0
- package/dist/components/chart-card.d.ts +15 -0
- package/dist/components/chart-card.js +27 -0
- package/dist/components/chart-card.js.map +1 -0
- package/dist/components/checkbox.d.ts +6 -0
- package/dist/components/checkbox.js +76 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/chip-button.d.ts +28 -0
- package/dist/components/chip-button.js +87 -0
- package/dist/components/chip-button.js.map +1 -0
- package/dist/components/close-confirm-dialog.d.ts +19 -0
- package/dist/components/close-confirm-dialog.js +743 -0
- package/dist/components/close-confirm-dialog.js.map +1 -0
- package/dist/components/color-picker.d.ts +32 -0
- package/dist/components/color-picker.js +667 -0
- package/dist/components/color-picker.js.map +1 -0
- package/dist/components/combobox.d.ts +32 -0
- package/dist/components/combobox.js +1211 -0
- package/dist/components/combobox.js.map +1 -0
- package/dist/components/confirm-by-typing-dialog.d.ts +48 -0
- package/dist/components/confirm-by-typing-dialog.js +759 -0
- package/dist/components/confirm-by-typing-dialog.js.map +1 -0
- package/dist/components/confirm-dialog.d.ts +34 -0
- package/dist/components/confirm-dialog.js +710 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/count-badge.d.ts +13 -0
- package/dist/components/count-badge.js +39 -0
- package/dist/components/count-badge.js.map +1 -0
- package/dist/components/dialog.d.ts +40 -0
- package/dist/components/dialog.js +571 -0
- package/dist/components/dialog.js.map +1 -0
- package/dist/components/dropdown-menu.d.ts +45 -0
- package/dist/components/dropdown-menu.js +1003 -0
- package/dist/components/dropdown-menu.js.map +1 -0
- package/dist/components/dropdown-select.d.ts +29 -0
- package/dist/components/dropdown-select.js +1036 -0
- package/dist/components/dropdown-select.js.map +1 -0
- package/dist/components/empty.d.ts +15 -0
- package/dist/components/empty.js +119 -0
- package/dist/components/empty.js.map +1 -0
- package/dist/components/filter-rail.d.ts +48 -0
- package/dist/components/filter-rail.js +231 -0
- package/dist/components/filter-rail.js.map +1 -0
- package/dist/components/fixed-action-bar.d.ts +24 -0
- package/dist/components/fixed-action-bar.js +48 -0
- package/dist/components/fixed-action-bar.js.map +1 -0
- package/dist/components/font-picker.d.ts +45 -0
- package/dist/components/font-picker.js +759 -0
- package/dist/components/font-picker.js.map +1 -0
- package/dist/components/form.d.ts +17 -0
- package/dist/components/form.js +150 -0
- package/dist/components/form.js.map +1 -0
- package/dist/components/group-label.d.ts +12 -0
- package/dist/components/group-label.js +10 -0
- package/dist/components/group-label.js.map +1 -0
- package/dist/components/icon-tile.d.ts +16 -0
- package/dist/components/icon-tile.js +67 -0
- package/dist/components/icon-tile.js.map +1 -0
- package/dist/components/icon-toggle.d.ts +24 -0
- package/dist/components/icon-toggle.js +81 -0
- package/dist/components/icon-toggle.js.map +1 -0
- package/dist/components/icon.d.ts +2 -0
- package/dist/components/icon.js +49 -0
- package/dist/components/icon.js.map +1 -0
- package/dist/components/image-crop-panel.d.ts +80 -0
- package/dist/components/image-crop-panel.js +1104 -0
- package/dist/components/image-crop-panel.js.map +1 -0
- package/dist/components/image-cropper.d.ts +63 -0
- package/dist/components/image-cropper.js +166 -0
- package/dist/components/image-cropper.js.map +1 -0
- package/dist/components/input-group.d.ts +22 -0
- package/dist/components/input-group.js +270 -0
- package/dist/components/input-group.js.map +1 -0
- package/dist/components/input.d.ts +16 -0
- package/dist/components/input.js +60 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/label.d.ts +5 -0
- package/dist/components/label.js +40 -0
- package/dist/components/label.js.map +1 -0
- package/dist/components/list-empty-row.d.ts +16 -0
- package/dist/components/list-empty-row.js +40 -0
- package/dist/components/list-empty-row.js.map +1 -0
- package/dist/components/management-empty.d.ts +14 -0
- package/dist/components/management-empty.js +40 -0
- package/dist/components/management-empty.js.map +1 -0
- package/dist/components/management-skeleton.d.ts +11 -0
- package/dist/components/management-skeleton.js +72 -0
- package/dist/components/management-skeleton.js.map +1 -0
- package/dist/components/meta-chip.d.ts +17 -0
- package/dist/components/meta-chip.js +49 -0
- package/dist/components/meta-chip.js.map +1 -0
- package/dist/components/mobile-drawer.d.ts +29 -0
- package/dist/components/mobile-drawer.js +331 -0
- package/dist/components/mobile-drawer.js.map +1 -0
- package/dist/components/page-header.d.ts +20 -0
- package/dist/components/page-header.js +44 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/page-scaffold.d.ts +34 -0
- package/dist/components/page-scaffold.js +157 -0
- package/dist/components/page-scaffold.js.map +1 -0
- package/dist/components/pagination-bar.d.ts +22 -0
- package/dist/components/pagination-bar.js +703 -0
- package/dist/components/pagination-bar.js.map +1 -0
- package/dist/components/pagination.d.ts +22 -0
- package/dist/components/pagination.js +680 -0
- package/dist/components/pagination.js.map +1 -0
- package/dist/components/panel-toolbar.d.ts +37 -0
- package/dist/components/panel-toolbar.js +781 -0
- package/dist/components/panel-toolbar.js.map +1 -0
- package/dist/components/pill-tabs.d.ts +33 -0
- package/dist/components/pill-tabs.js +66 -0
- package/dist/components/pill-tabs.js.map +1 -0
- package/dist/components/plugin-download-consent.d.ts +52 -0
- package/dist/components/plugin-download-consent.js +780 -0
- package/dist/components/plugin-download-consent.js.map +1 -0
- package/dist/components/popover.d.ts +26 -0
- package/dist/components/popover.js +343 -0
- package/dist/components/popover.js.map +1 -0
- package/dist/components/progress.d.ts +10 -0
- package/dist/components/progress.js +103 -0
- package/dist/components/progress.js.map +1 -0
- package/dist/components/query-state-view.d.ts +36 -0
- package/dist/components/query-state-view.js +17 -0
- package/dist/components/query-state-view.js.map +1 -0
- package/dist/components/radio-group.d.ts +8 -0
- package/dist/components/radio-group.js +60 -0
- package/dist/components/radio-group.js.map +1 -0
- package/dist/components/scroll-area.d.ts +7 -0
- package/dist/components/scroll-area.js +80 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/search-field.d.ts +49 -0
- package/dist/components/search-field.js +586 -0
- package/dist/components/search-field.js.map +1 -0
- package/dist/components/secondary-stat.d.ts +13 -0
- package/dist/components/secondary-stat.js +56 -0
- package/dist/components/secondary-stat.js.map +1 -0
- package/dist/components/section-header.d.ts +23 -0
- package/dist/components/section-header.js +76 -0
- package/dist/components/section-header.js.map +1 -0
- package/dist/components/section-label.d.ts +20 -0
- package/dist/components/section-label.js +46 -0
- package/dist/components/section-label.js.map +1 -0
- package/dist/components/section-tabs.d.ts +47 -0
- package/dist/components/section-tabs.js +145 -0
- package/dist/components/section-tabs.js.map +1 -0
- package/dist/components/separator.d.ts +14 -0
- package/dist/components/separator.js +49 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/sheet.d.ts +22 -0
- package/dist/components/sheet.js +469 -0
- package/dist/components/sheet.js.map +1 -0
- package/dist/components/sidebar.d.ts +69 -0
- package/dist/components/sidebar.js +1645 -0
- package/dist/components/sidebar.js.map +1 -0
- package/dist/components/skeleton.d.ts +8 -0
- package/dist/components/skeleton.js +41 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/slider.d.ts +6 -0
- package/dist/components/slider.js +75 -0
- package/dist/components/slider.js.map +1 -0
- package/dist/components/sortable-chip-list.d.ts +26 -0
- package/dist/components/sortable-chip-list.js +59 -0
- package/dist/components/sortable-chip-list.js.map +1 -0
- package/dist/components/spinner.d.ts +5 -0
- package/dist/components/spinner.js +65 -0
- package/dist/components/spinner.js.map +1 -0
- package/dist/components/surface.d.ts +13 -0
- package/dist/components/surface.js +51 -0
- package/dist/components/surface.js.map +1 -0
- package/dist/components/switch.d.ts +8 -0
- package/dist/components/switch.js +53 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/table.d.ts +12 -0
- package/dist/components/table.js +129 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/textarea.d.ts +15 -0
- package/dist/components/textarea.js +58 -0
- package/dist/components/textarea.js.map +1 -0
- package/dist/components/toast.d.ts +19 -0
- package/dist/components/toast.js +805 -0
- package/dist/components/toast.js.map +1 -0
- package/dist/components/toggle.d.ts +12 -0
- package/dist/components/toggle.js +63 -0
- package/dist/components/toggle.js.map +1 -0
- package/dist/components/tooltip.d.ts +9 -0
- package/dist/components/tooltip.js +82 -0
- package/dist/components/tooltip.js.map +1 -0
- package/dist/hooks/use-dialog-footer-actions.d.ts +18 -0
- package/dist/hooks/use-dialog-footer-actions.js +11 -0
- package/dist/hooks/use-dialog-footer-actions.js.map +1 -0
- package/dist/hooks/use-form-field.d.ts +33 -0
- package/dist/hooks/use-form-field.js +36 -0
- package/dist/hooks/use-form-field.js.map +1 -0
- package/dist/hooks/use-mobile.d.ts +34 -0
- package/dist/hooks/use-mobile.js +29 -0
- package/dist/hooks/use-mobile.js.map +1 -0
- package/dist/hooks/use-pinch-pan.d.ts +56 -0
- package/dist/hooks/use-pinch-pan.js +223 -0
- package/dist/hooks/use-pinch-pan.js.map +1 -0
- package/dist/hooks/useMobileBackToClose.d.ts +61 -0
- package/dist/hooks/useMobileBackToClose.js +234 -0
- package/dist/hooks/useMobileBackToClose.js.map +1 -0
- package/dist/icon-BOgL5bpL.d.ts +85 -0
- package/dist/icons/actions.d.ts +4 -0
- package/dist/icons/actions.js +520 -0
- package/dist/icons/actions.js.map +1 -0
- package/dist/icons/icon-style.d.ts +2 -0
- package/dist/icons/icon-style.js +51 -0
- package/dist/icons/icon-style.js.map +1 -0
- package/dist/icons/marks.d.ts +15 -0
- package/dist/icons/marks.js +48 -0
- package/dist/icons/marks.js.map +1 -0
- package/dist/icons/registry.d.ts +177 -0
- package/dist/icons/registry.js +1180 -0
- package/dist/icons/registry.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +653 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/merge-config.d.ts +15 -0
- package/dist/lib/merge-config.js +20 -0
- package/dist/lib/merge-config.js.map +1 -0
- package/dist/lib/pagination.d.ts +19 -0
- package/dist/lib/pagination.js +26 -0
- package/dist/lib/pagination.js.map +1 -0
- package/dist/lib/utils.d.ts +5 -0
- package/dist/lib/utils.js +26 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/styles/theme.css +480 -0
- package/dist/styles/theme.css.map +1 -0
- package/dist/styles/theme.d.ts +2 -0
- package/dist/viewport.d.ts +64 -0
- package/dist/viewport.js +13 -0
- package/dist/viewport.js.map +1 -0
- package/package.json +122 -0
- package/src/components/alert.tsx +76 -0
- package/src/components/app-dialog.tsx +187 -0
- package/src/components/badge.tsx +52 -0
- package/src/components/breadcrumb.tsx +129 -0
- package/src/components/button.tsx +70 -0
- package/src/components/caption-bar.test.tsx +151 -0
- package/src/components/caption-bar.tsx +392 -0
- package/src/components/chart-card.tsx +31 -0
- package/src/components/checkbox.tsx +28 -0
- package/src/components/chip-button.tsx +69 -0
- package/src/components/close-confirm-dialog.tsx +99 -0
- package/src/components/color-picker.tsx +186 -0
- package/src/components/combobox.tsx +335 -0
- package/src/components/confirm-by-typing-dialog.tsx +130 -0
- package/src/components/confirm-dialog.test.tsx +39 -0
- package/src/components/confirm-dialog.tsx +98 -0
- package/src/components/count-badge.tsx +23 -0
- package/src/components/dialog.tsx +315 -0
- package/src/components/dropdown-menu.tsx +311 -0
- package/src/components/dropdown-select.tsx +211 -0
- package/src/components/empty.tsx +104 -0
- package/src/components/filter-rail.tsx +139 -0
- package/src/components/fixed-action-bar.tsx +46 -0
- package/src/components/font-picker.tsx +257 -0
- package/src/components/form.tsx +127 -0
- package/src/components/group-label.tsx +13 -0
- package/src/components/icon-tile.tsx +35 -0
- package/src/components/icon-toggle.tsx +57 -0
- package/src/components/icon.tsx +66 -0
- package/src/components/image-crop-panel.test.tsx +167 -0
- package/src/components/image-crop-panel.tsx +465 -0
- package/src/components/image-cropper.test.tsx +144 -0
- package/src/components/image-cropper.tsx +265 -0
- package/src/components/input-group.tsx +156 -0
- package/src/components/input.tsx +45 -0
- package/src/components/label.tsx +20 -0
- package/src/components/list-empty-row.tsx +27 -0
- package/src/components/management-empty.tsx +25 -0
- package/src/components/management-skeleton.tsx +23 -0
- package/src/components/meta-chip.tsx +40 -0
- package/src/components/mobile-drawer.test.tsx +52 -0
- package/src/components/mobile-drawer.tsx +98 -0
- package/src/components/page-header.tsx +47 -0
- package/src/components/page-scaffold.test.tsx +24 -0
- package/src/components/page-scaffold.tsx +131 -0
- package/src/components/pagination-bar.test.tsx +87 -0
- package/src/components/pagination-bar.tsx +139 -0
- package/src/components/pagination.tsx +139 -0
- package/src/components/panel-toolbar.tsx +99 -0
- package/src/components/pill-tabs.test.tsx +84 -0
- package/src/components/pill-tabs.tsx +77 -0
- package/src/components/plugin-download-consent.test.tsx +90 -0
- package/src/components/plugin-download-consent.tsx +183 -0
- package/src/components/popover.tsx +133 -0
- package/src/components/progress.tsx +88 -0
- package/src/components/query-state-view.tsx +47 -0
- package/src/components/radio-group.tsx +36 -0
- package/src/components/scroll-area.tsx +52 -0
- package/src/components/search-field.test.tsx +86 -0
- package/src/components/search-field.tsx +119 -0
- package/src/components/secondary-stat.tsx +24 -0
- package/src/components/section-header.tsx +54 -0
- package/src/components/section-label.tsx +40 -0
- package/src/components/section-tabs.test.tsx +154 -0
- package/src/components/section-tabs.tsx +190 -0
- package/src/components/separator.tsx +38 -0
- package/src/components/sheet.tsx +170 -0
- package/src/components/sidebar.tsx +725 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/slider.tsx +52 -0
- package/src/components/sortable-chip-list.tsx +56 -0
- package/src/components/spinner.tsx +35 -0
- package/src/components/surface.tsx +37 -0
- package/src/components/switch.tsx +30 -0
- package/src/components/table.tsx +116 -0
- package/src/components/textarea.tsx +41 -0
- package/src/components/toast.tsx +239 -0
- package/src/components/toggle.tsx +45 -0
- package/src/components/tooltip.tsx +64 -0
- package/src/hooks/use-dialog-footer-actions.ts +23 -0
- package/src/hooks/use-form-field.ts +62 -0
- package/src/hooks/use-mobile.ts +54 -0
- package/src/hooks/use-pinch-pan.test.tsx +42 -0
- package/src/hooks/use-pinch-pan.ts +340 -0
- package/src/hooks/useMobileBackToClose.test.tsx +140 -0
- package/src/hooks/useMobileBackToClose.ts +508 -0
- package/src/icons/actions.ts +10 -0
- package/src/icons/icon-style.test.tsx +69 -0
- package/src/icons/icon-style.ts +104 -0
- package/src/icons/marks.tsx +72 -0
- package/src/icons/registry.test.ts +65 -0
- package/src/icons/registry.ts +1163 -0
- package/src/index.ts +22 -0
- package/src/lib/merge-config.test.ts +109 -0
- package/src/lib/merge-config.ts +22 -0
- package/src/lib/pagination.test.ts +55 -0
- package/src/lib/pagination.ts +42 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/scan-contract.test.ts +74 -0
- package/src/styles/theme.css +798 -0
- package/src/test/i18n.tsx +22 -0
- package/src/test/setup.ts +104 -0
- package/src/viewport.ts +69 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Skeleton } from "@hoardodile/ui/components/skeleton"
|
|
2
|
+
import { Surface } from "@hoardodile/ui/components/surface"
|
|
3
|
+
|
|
4
|
+
export type ManagementSkeletonProps = {
|
|
5
|
+
readonly chipCount?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loading placeholder for a chip-based management panel.
|
|
10
|
+
*/
|
|
11
|
+
export function ManagementSkeleton(props: ManagementSkeletonProps) {
|
|
12
|
+
const chipCount = props.chipCount ?? 2
|
|
13
|
+
return (
|
|
14
|
+
<Surface as="section" size="compact" className="space-y-3">
|
|
15
|
+
<Skeleton className="h-4 w-28" />
|
|
16
|
+
<div className="flex flex-wrap gap-1.5">
|
|
17
|
+
{Array.from({ length: chipCount }).map((_, i) => (
|
|
18
|
+
<Skeleton key={i} className="h-8 w-24 rounded-md" />
|
|
19
|
+
))}
|
|
20
|
+
</div>
|
|
21
|
+
</Surface>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
export type MetaChipProps = {
|
|
5
|
+
/** muted: quiet gray chip (default); inverse: foreground on background;
|
|
6
|
+
bordered: outline-only state marker. */
|
|
7
|
+
readonly tone?: "muted" | "inverse" | "bordered"
|
|
8
|
+
readonly className?: string
|
|
9
|
+
readonly children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Meta chip — the tiny rounded-sm text-tiny state marker worn next to
|
|
14
|
+
* names, versions, deltas and list rows across the app.
|
|
15
|
+
*/
|
|
16
|
+
export function MetaChip(props: MetaChipProps) {
|
|
17
|
+
return (
|
|
18
|
+
<span
|
|
19
|
+
className={cn(
|
|
20
|
+
"shrink-0 rounded-sm px-1.5 py-0.5 text-tiny leading-none",
|
|
21
|
+
chipToneClassName(props.tone),
|
|
22
|
+
props.className,
|
|
23
|
+
)}
|
|
24
|
+
>
|
|
25
|
+
{props.children}
|
|
26
|
+
</span>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function chipToneClassName(tone: MetaChipProps["tone"]) {
|
|
31
|
+
if (tone === "inverse") {
|
|
32
|
+
return "bg-foreground text-background"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (tone === "bordered") {
|
|
36
|
+
return "border border-border-strong text-muted-foreground"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return "bg-muted text-secondary-foreground"
|
|
40
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { MobileDrawer } from "./mobile-drawer"
|
|
4
|
+
|
|
5
|
+
function renderDrawer(open: boolean) {
|
|
6
|
+
return render(
|
|
7
|
+
<MobileDrawer open={open} onOpenChange={() => {}} title="Drawer">
|
|
8
|
+
<div data-testid="drawer-content">content</div>
|
|
9
|
+
</MobileDrawer>,
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("MobileDrawer children gating", () => {
|
|
14
|
+
it("does not render children while closed before the first open", () => {
|
|
15
|
+
renderDrawer(false)
|
|
16
|
+
expect(screen.queryByTestId("drawer-content")).not.toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("renders children once open", () => {
|
|
20
|
+
renderDrawer(true)
|
|
21
|
+
expect(screen.getByTestId("drawer-content")).toBeInTheDocument()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("keeps children mounted after closing again", () => {
|
|
25
|
+
const { rerender } = render(
|
|
26
|
+
<MobileDrawer open={false} onOpenChange={() => {}}>
|
|
27
|
+
<div data-testid="drawer-content">content</div>
|
|
28
|
+
</MobileDrawer>,
|
|
29
|
+
)
|
|
30
|
+
expect(screen.queryByTestId("drawer-content")).not.toBeInTheDocument()
|
|
31
|
+
rerender(
|
|
32
|
+
<MobileDrawer open={true} onOpenChange={() => {}}>
|
|
33
|
+
<div data-testid="drawer-content">content</div>
|
|
34
|
+
</MobileDrawer>,
|
|
35
|
+
)
|
|
36
|
+
expect(screen.getByTestId("drawer-content")).toBeInTheDocument()
|
|
37
|
+
rerender(
|
|
38
|
+
<MobileDrawer open={false} onOpenChange={() => {}}>
|
|
39
|
+
<div data-testid="drawer-content">content</div>
|
|
40
|
+
</MobileDrawer>,
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByTestId("drawer-content")).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("sizes the body as a constrained flex column", () => {
|
|
46
|
+
const { container } = renderDrawer(true)
|
|
47
|
+
const body = container.querySelector("aside > div:last-child")
|
|
48
|
+
for (const token of ["flex", "min-h-0", "flex-1", "flex-col"]) {
|
|
49
|
+
expect(body?.classList.contains(token)).toBe(true)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useMobileBackToClose } from "@hoardodile/ui/hooks/useMobileBackToClose"
|
|
2
|
+
import { Cross } from "@hoardodile/ui/icons/marks"
|
|
3
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
4
|
+
import { type ReactNode, useEffect, useState } from "react"
|
|
5
|
+
|
|
6
|
+
export type MobileDrawerProps = {
|
|
7
|
+
readonly open: boolean
|
|
8
|
+
readonly onOpenChange: (open: boolean) => void
|
|
9
|
+
readonly side?: "left" | "right"
|
|
10
|
+
readonly width?: string
|
|
11
|
+
/**
|
|
12
|
+
* Viewport variant at and above which the drawer is hidden. Must be a
|
|
13
|
+
* literal class at the call site (e.g. "min-[1440px]:hidden") so
|
|
14
|
+
* Tailwind can scan it. Defaults to "md:hidden".
|
|
15
|
+
*/
|
|
16
|
+
readonly hideAbove?: string
|
|
17
|
+
readonly title?: ReactNode
|
|
18
|
+
readonly children: ReactNode
|
|
19
|
+
readonly className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mobile-only slide-in drawer with backdrop, close header and
|
|
24
|
+
* {@link useMobileBackToClose} integration.
|
|
25
|
+
*
|
|
26
|
+
* Renders nothing at and above the {@link MobileDrawerProps.hideAbove}
|
|
27
|
+
* breakpoint ("md" by default) — the caller is expected to provide its
|
|
28
|
+
* own desktop layout independently.
|
|
29
|
+
*/
|
|
30
|
+
export function MobileDrawer(props: MobileDrawerProps) {
|
|
31
|
+
const {
|
|
32
|
+
open,
|
|
33
|
+
onOpenChange,
|
|
34
|
+
side = "left",
|
|
35
|
+
width = "w-72",
|
|
36
|
+
hideAbove = "md:hidden",
|
|
37
|
+
title,
|
|
38
|
+
} = props
|
|
39
|
+
const isLeft = side === "left"
|
|
40
|
+
// Mount children only once the drawer has been opened, so closed drawers
|
|
41
|
+
// (which stay in the tree for the slide transition) do not pay for
|
|
42
|
+
// rendering their content. Once mounted they stay mounted so the close
|
|
43
|
+
// animation and any inner state survive.
|
|
44
|
+
const [hasOpened, setHasOpened] = useState(open)
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (open) setHasOpened(true)
|
|
48
|
+
}, [open])
|
|
49
|
+
|
|
50
|
+
useMobileBackToClose(open, onOpenChange)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{open && (
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
className={cn(
|
|
58
|
+
"fixed inset-0 z-30 bg-background/60 backdrop-blur-sm",
|
|
59
|
+
hideAbove,
|
|
60
|
+
)}
|
|
61
|
+
onClick={() => onOpenChange(false)}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
<aside
|
|
65
|
+
className={cn(
|
|
66
|
+
"fixed top-0 z-40 flex h-svh flex-col bg-card transition-transform duration-200",
|
|
67
|
+
hideAbove,
|
|
68
|
+
isLeft ? "left-0 border-r" : "right-0 border-l",
|
|
69
|
+
width,
|
|
70
|
+
isLeft
|
|
71
|
+
? open
|
|
72
|
+
? "translate-x-0 shadow-xl"
|
|
73
|
+
: "-translate-x-full"
|
|
74
|
+
: open
|
|
75
|
+
? "translate-x-0 shadow-xl"
|
|
76
|
+
: "translate-x-full",
|
|
77
|
+
props.className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{title !== undefined && (
|
|
81
|
+
<div className="flex items-center justify-between border-b px-3 py-3">
|
|
82
|
+
<span className="text-sm font-medium">{title}</span>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent"
|
|
86
|
+
onClick={() => onOpenChange(false)}
|
|
87
|
+
>
|
|
88
|
+
<Cross className="size-4" />
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
93
|
+
{hasOpened ? props.children : null}
|
|
94
|
+
</div>
|
|
95
|
+
</aside>
|
|
96
|
+
</>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
type PageHeaderProps = {
|
|
5
|
+
readonly title?: ReactNode
|
|
6
|
+
/** Muted total on the title baseline (e.g. the item count). */
|
|
7
|
+
readonly count?: string
|
|
8
|
+
readonly description?: ReactNode
|
|
9
|
+
readonly actions?: ReactNode
|
|
10
|
+
readonly className?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Page header — 28px bold title with the total count muted on its baseline,
|
|
15
|
+
* optional muted description below, page actions on the right. The literary
|
|
16
|
+
* serif (font-doc) is scoped to the documents section; chrome and index
|
|
17
|
+
* pages speak in the app sans.
|
|
18
|
+
*/
|
|
19
|
+
export function PageHeader(props: PageHeaderProps) {
|
|
20
|
+
const { title, count, description, actions, className } = props
|
|
21
|
+
return (
|
|
22
|
+
<header className={cn("mb-6", className)}>
|
|
23
|
+
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
|
|
24
|
+
<div className="flex min-w-0 items-baseline gap-3">
|
|
25
|
+
{title !== undefined ? (
|
|
26
|
+
<h1 className="text-doc-heading font-bold text-foreground">
|
|
27
|
+
{title}
|
|
28
|
+
</h1>
|
|
29
|
+
) : null}
|
|
30
|
+
{count !== undefined ? (
|
|
31
|
+
<span className="text-ui text-muted-foreground">{count}</span>
|
|
32
|
+
) : null}
|
|
33
|
+
</div>
|
|
34
|
+
{actions !== undefined ? (
|
|
35
|
+
// `ml-auto` keeps a wrapped actions line right-aligned on
|
|
36
|
+
// narrow viewports, matching the same-line justify-between.
|
|
37
|
+
<div className="ml-auto flex shrink-0 flex-wrap items-center gap-4 pb-1">
|
|
38
|
+
{actions}
|
|
39
|
+
</div>
|
|
40
|
+
) : null}
|
|
41
|
+
</div>
|
|
42
|
+
{description !== undefined ? (
|
|
43
|
+
<p className="mt-2 text-ui text-muted-foreground">{description}</p>
|
|
44
|
+
) : null}
|
|
45
|
+
</header>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { render } from "@testing-library/react"
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { RoutePendingFallback } from "./page-scaffold"
|
|
4
|
+
|
|
5
|
+
describe("RoutePendingFallback", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.restoreAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("renders the header skeletons without invalid DOM nesting", () => {
|
|
11
|
+
// React's dev-time validator logs "cannot be a descendant of <p>" /
|
|
12
|
+
// "cannot contain a nested <div>" for a block element inside the
|
|
13
|
+
// header's h1/p wrappers — a hydration hazard.
|
|
14
|
+
const errorSpy = vi
|
|
15
|
+
.spyOn(console, "error")
|
|
16
|
+
.mockImplementation(() => undefined)
|
|
17
|
+
render(<RoutePendingFallback />)
|
|
18
|
+
for (const call of errorSpy.mock.calls) {
|
|
19
|
+
const message = String(call[0])
|
|
20
|
+
expect(message).not.toContain("cannot be a descendant")
|
|
21
|
+
expect(message).not.toContain("cannot contain a nested")
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Skeleton } from "@hoardodile/ui/components/skeleton"
|
|
2
|
+
import { Surface } from "@hoardodile/ui/components/surface"
|
|
3
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
import { PageHeader } from "./page-header"
|
|
6
|
+
|
|
7
|
+
/** Content widths (DESIGN.md — Layout): the three fixed measures
|
|
8
|
+
index/detail pages are held to, centered beyond them. The measure is
|
|
9
|
+
the content width — padding never counts toward it (DESIGN.md),
|
|
10
|
+
so it lives on the outer wrapper, not on the measure element. Desktop
|
|
11
|
+
padding is `px-10 pt-10 pb-16`; mobile
|
|
12
|
+
and tablet keep their own tighter gutters. */
|
|
13
|
+
export type PageScaffoldWidth = "reading" | "medium" | "content"
|
|
14
|
+
|
|
15
|
+
const widthClasses: Record<PageScaffoldWidth, string> = {
|
|
16
|
+
reading: "max-w-reading",
|
|
17
|
+
medium: "max-w-medium",
|
|
18
|
+
content: "max-w-content",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type PageScaffoldProps = {
|
|
22
|
+
readonly children: ReactNode
|
|
23
|
+
/** Center the page at one of the three content measures; omitted when
|
|
24
|
+
the page's own component owns the measure. */
|
|
25
|
+
readonly width?: PageScaffoldWidth
|
|
26
|
+
/** Drop the desktop bottom padding — pages ending in a sticky ActionBar. */
|
|
27
|
+
readonly bottomPad?: boolean
|
|
28
|
+
readonly className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function frameClassName(props: PageScaffoldProps) {
|
|
32
|
+
return cn(
|
|
33
|
+
"pb-4 pt-2 px-3 md:px-4 md:pt-4.5 lg:px-10",
|
|
34
|
+
props.bottomPad !== false && "lg:pb-16",
|
|
35
|
+
props.className,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function PageScaffold(props: PageScaffoldProps) {
|
|
40
|
+
if (props.width === undefined) {
|
|
41
|
+
return (
|
|
42
|
+
<div className={cn("flex w-full flex-col", frameClassName(props))}>
|
|
43
|
+
{props.children}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className={frameClassName(props)}>
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"mx-auto flex w-full flex-col",
|
|
53
|
+
widthClasses[props.width],
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{props.children}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type SurfaceProps = {
|
|
63
|
+
readonly children: ReactNode
|
|
64
|
+
readonly className?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function FlatSurface(props: SurfaceProps) {
|
|
68
|
+
return (
|
|
69
|
+
<Surface as="section" size="compact" className={props.className}>
|
|
70
|
+
{props.children}
|
|
71
|
+
</Surface>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type PillProps = {
|
|
76
|
+
readonly children: ReactNode
|
|
77
|
+
readonly tone?: "primary" | "secondary" | "accent" | "muted"
|
|
78
|
+
readonly className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function InfoPill(props: PillProps) {
|
|
82
|
+
return (
|
|
83
|
+
<span
|
|
84
|
+
className={cn(
|
|
85
|
+
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium",
|
|
86
|
+
pillToneClassName(props.tone),
|
|
87
|
+
props.className,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{props.children}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function RoutePendingFallback() {
|
|
96
|
+
return (
|
|
97
|
+
<PageScaffold>
|
|
98
|
+
<PageHeader
|
|
99
|
+
title={
|
|
100
|
+
// Span elements: the header wraps title in h1 and description
|
|
101
|
+
// in p, and a block div inside either is invalid HTML.
|
|
102
|
+
<Skeleton element="span" className="block h-8 w-64 max-w-full" />
|
|
103
|
+
}
|
|
104
|
+
description={
|
|
105
|
+
<Skeleton element="span" className="block h-4 w-full max-w-lg" />
|
|
106
|
+
}
|
|
107
|
+
/>
|
|
108
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
109
|
+
<Skeleton className="h-32 rounded-lg" />
|
|
110
|
+
<Skeleton className="h-32 rounded-lg" />
|
|
111
|
+
<Skeleton className="h-32 rounded-lg md:col-span-2 xl:col-span-1" />
|
|
112
|
+
</div>
|
|
113
|
+
</PageScaffold>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pillToneClassName(tone: PillProps["tone"]) {
|
|
118
|
+
if (tone === "primary") {
|
|
119
|
+
return "bg-primary/10 text-primary"
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (tone === "secondary") {
|
|
123
|
+
return "bg-secondary text-secondary-foreground"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (tone === "accent") {
|
|
127
|
+
return "bg-accent text-accent-foreground"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return "bg-muted text-muted-foreground"
|
|
131
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fireEvent, screen } from "@testing-library/react"
|
|
2
|
+
import userEvent from "@testing-library/user-event"
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { renderWithI18n } from "../test/i18n"
|
|
5
|
+
import { PaginationBar } from "./pagination-bar"
|
|
6
|
+
|
|
7
|
+
let mobileMock = false
|
|
8
|
+
|
|
9
|
+
vi.mock("@hoardodile/ui/hooks/use-mobile", () => ({
|
|
10
|
+
useBelowMd: () => mobileMock,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe("PaginationBar", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mobileMock = false
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("renders the asymmetric window with first, last and the total label", () => {
|
|
19
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
20
|
+
renderWithI18n(
|
|
21
|
+
<PaginationBar
|
|
22
|
+
page={6}
|
|
23
|
+
pageCount={68}
|
|
24
|
+
totalLabel="68 items"
|
|
25
|
+
onChangePage={() => {}}
|
|
26
|
+
/>,
|
|
27
|
+
)
|
|
28
|
+
for (const page of ["1", "5", "6", "7", "8", "68"]) {
|
|
29
|
+
expect(screen.getByRole("button", { name: page })).toBeInTheDocument()
|
|
30
|
+
}
|
|
31
|
+
expect(screen.getByTestId("pagination-current")).toHaveTextContent("6")
|
|
32
|
+
expect(screen.getAllByText("…")).toHaveLength(2)
|
|
33
|
+
expect(screen.getByText("68 items")).toBeInTheDocument()
|
|
34
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
35
|
+
errorSpy.mockRestore()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("collapses to a page/total chip below the md breakpoint", () => {
|
|
39
|
+
mobileMock = true
|
|
40
|
+
renderWithI18n(
|
|
41
|
+
<PaginationBar
|
|
42
|
+
page={6}
|
|
43
|
+
pageCount={68}
|
|
44
|
+
totalLabel="68 items"
|
|
45
|
+
onChangePage={() => {}}
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByTestId("pagination-current")).toHaveTextContent("6/68")
|
|
49
|
+
expect(screen.queryByRole("button", { name: "5" })).not.toBeInTheDocument()
|
|
50
|
+
expect(screen.queryByText("…")).not.toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("disables the prev chevron on the first page without hover", async () => {
|
|
54
|
+
const onChangePage = vi.fn()
|
|
55
|
+
const user = userEvent.setup()
|
|
56
|
+
renderWithI18n(
|
|
57
|
+
<PaginationBar
|
|
58
|
+
page={1}
|
|
59
|
+
pageCount={3}
|
|
60
|
+
totalLabel="3 items"
|
|
61
|
+
onChangePage={onChangePage}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
const prev = screen.getByRole("button", { name: "Prev" })
|
|
65
|
+
expect(prev).toBeDisabled()
|
|
66
|
+
await user.click(prev)
|
|
67
|
+
expect(onChangePage).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("jumps to a clamped page via the go-to field", async () => {
|
|
71
|
+
const onChangePage = vi.fn()
|
|
72
|
+
const user = userEvent.setup()
|
|
73
|
+
renderWithI18n(
|
|
74
|
+
<PaginationBar
|
|
75
|
+
page={3}
|
|
76
|
+
pageCount={10}
|
|
77
|
+
totalLabel="10 items"
|
|
78
|
+
onChangePage={onChangePage}
|
|
79
|
+
/>,
|
|
80
|
+
)
|
|
81
|
+
const input = screen.getByRole("spinbutton")
|
|
82
|
+
await user.click(input)
|
|
83
|
+
fireEvent.change(input, { target: { value: "12" } })
|
|
84
|
+
fireEvent.submit(input.closest("form")!)
|
|
85
|
+
expect(onChangePage).toHaveBeenCalledWith(10)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Icon } from "@hoardodile/ui/components/icon"
|
|
2
|
+
import { Input } from "@hoardodile/ui/components/input"
|
|
3
|
+
import {
|
|
4
|
+
Pagination,
|
|
5
|
+
PaginationContent,
|
|
6
|
+
PaginationItem,
|
|
7
|
+
} from "@hoardodile/ui/components/pagination"
|
|
8
|
+
import { useBelowMd } from "@hoardodile/ui/hooks/use-mobile"
|
|
9
|
+
import { AltArrowLeft, AltArrowRight } from "@hoardodile/ui/icons/registry"
|
|
10
|
+
import { paginationWindow } from "@hoardodile/ui/lib/pagination"
|
|
11
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
12
|
+
import { useState } from "react"
|
|
13
|
+
import { useTranslation } from "react-i18next"
|
|
14
|
+
|
|
15
|
+
export type PaginationBarProps = {
|
|
16
|
+
readonly page: number
|
|
17
|
+
readonly pageCount: number
|
|
18
|
+
readonly onChangePage: (page: number) => void
|
|
19
|
+
/** Result-count label rendered in the left slot (e.g. "56 messages"). */
|
|
20
|
+
readonly totalLabel: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pageButton =
|
|
24
|
+
"flex h-control min-w-8 cursor-pointer items-center justify-center rounded-lg px-2 text-xs"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The paginator: the result count on the left, balanced by the page-jump
|
|
28
|
+
* field on the right, and the centered row of h-control page chips —
|
|
29
|
+
* chevrons, first and last page always visible with an asymmetric window
|
|
30
|
+
* around the active page (one leading, two trailing) and ellipses for the
|
|
31
|
+
* gap (see {@link paginationWindow}). Below the md breakpoint the window
|
|
32
|
+
* collapses to a compact `page/total` chip, so narrow screens still see
|
|
33
|
+
* how many pages exist. Shared by every paged list (resources,
|
|
34
|
+
* characters, comments, stats, footprints).
|
|
35
|
+
*/
|
|
36
|
+
export function PaginationBar(props: PaginationBarProps) {
|
|
37
|
+
const { page, pageCount, onChangePage, totalLabel } = props
|
|
38
|
+
const { t } = useTranslation("ui", { useSuspense: false })
|
|
39
|
+
const [draft, setDraft] = useState("")
|
|
40
|
+
const isMobile = useBelowMd()
|
|
41
|
+
|
|
42
|
+
function jump(): void {
|
|
43
|
+
const parsed = Number.parseInt(draft, 10)
|
|
44
|
+
if (Number.isNaN(parsed)) return
|
|
45
|
+
const clamped = Math.min(Math.max(1, parsed), pageCount)
|
|
46
|
+
setDraft("")
|
|
47
|
+
if (clamped !== page) onChangePage(clamped)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const pages = isMobile ? [page] : paginationWindow(page, pageCount)
|
|
51
|
+
const chip = (active: boolean) =>
|
|
52
|
+
cn(
|
|
53
|
+
pageButton,
|
|
54
|
+
active
|
|
55
|
+
? "bg-muted font-medium text-foreground"
|
|
56
|
+
: "text-muted-foreground enabled:hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Pagination data-testid="pagination-bar">
|
|
61
|
+
<div className="flex w-full items-center gap-2">
|
|
62
|
+
<span className="w-28 shrink-0 truncate text-xs text-muted-foreground">
|
|
63
|
+
{totalLabel}
|
|
64
|
+
</span>
|
|
65
|
+
<PaginationContent className="flex-1 justify-center">
|
|
66
|
+
<PaginationItem>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className={chip(false)}
|
|
70
|
+
disabled={page <= 1}
|
|
71
|
+
aria-label={t("pagination.prev")}
|
|
72
|
+
onClick={() => onChangePage(page - 1)}
|
|
73
|
+
>
|
|
74
|
+
<Icon icon={AltArrowLeft} />
|
|
75
|
+
</button>
|
|
76
|
+
</PaginationItem>
|
|
77
|
+
{pages.map((entry, index) =>
|
|
78
|
+
entry === "…" ? (
|
|
79
|
+
<PaginationItem key={`ellipsis-${index}`}>
|
|
80
|
+
<span className="px-1 text-xs text-muted-foreground">
|
|
81
|
+
{entry}
|
|
82
|
+
</span>
|
|
83
|
+
</PaginationItem>
|
|
84
|
+
) : (
|
|
85
|
+
<PaginationItem key={entry}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={chip(entry === page)}
|
|
89
|
+
data-testid={
|
|
90
|
+
entry === page ? "pagination-current" : undefined
|
|
91
|
+
}
|
|
92
|
+
onClick={() => onChangePage(entry)}
|
|
93
|
+
>
|
|
94
|
+
{isMobile ? `${page}/${pageCount}` : entry}
|
|
95
|
+
</button>
|
|
96
|
+
</PaginationItem>
|
|
97
|
+
),
|
|
98
|
+
)}
|
|
99
|
+
<PaginationItem>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
className={chip(false)}
|
|
103
|
+
disabled={page >= pageCount}
|
|
104
|
+
aria-label={t("pagination.next")}
|
|
105
|
+
onClick={() => onChangePage(page + 1)}
|
|
106
|
+
>
|
|
107
|
+
<Icon icon={AltArrowRight} />
|
|
108
|
+
</button>
|
|
109
|
+
</PaginationItem>
|
|
110
|
+
</PaginationContent>
|
|
111
|
+
<form
|
|
112
|
+
className="flex w-28 items-center justify-end gap-1.5 text-xs text-muted-foreground"
|
|
113
|
+
onSubmit={(event) => {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
jump()
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<span className="shrink-0 whitespace-nowrap">{t("pagination.goTo")}</span>
|
|
119
|
+
<Input
|
|
120
|
+
type="number"
|
|
121
|
+
min={1}
|
|
122
|
+
max={pageCount}
|
|
123
|
+
value={draft}
|
|
124
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
125
|
+
placeholder={t("pagination.jumpToPage")}
|
|
126
|
+
aria-label={t("pagination.goTo")}
|
|
127
|
+
size="sm"
|
|
128
|
+
// Content-elastic width in `ch` units (the native `size`
|
|
129
|
+
// attribute's modern equivalent — `field-sizing` is not
|
|
130
|
+
// supported everywhere yet): one character per digit,
|
|
131
|
+
// with room for the "Page" placeholder and padding.
|
|
132
|
+
style={{ width: `${Math.max(4, draft.length + 2) + 4}ch` }}
|
|
133
|
+
className="min-w-0 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
134
|
+
/>
|
|
135
|
+
</form>
|
|
136
|
+
</div>
|
|
137
|
+
</Pagination>
|
|
138
|
+
)
|
|
139
|
+
}
|