@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,139 @@
|
|
|
1
|
+
import { Button } from "@hoardodile/ui/components/button"
|
|
2
|
+
import { Checkbox } from "@hoardodile/ui/components/checkbox"
|
|
3
|
+
import { Separator } from "@hoardodile/ui/components/separator"
|
|
4
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
5
|
+
import { type ReactNode, useId } from "react"
|
|
6
|
+
|
|
7
|
+
type FilterRailProps = {
|
|
8
|
+
/** Rail title, e.g. "Filters". */
|
|
9
|
+
readonly title: ReactNode
|
|
10
|
+
/** Header link that resets every staged filter (applies immediately). */
|
|
11
|
+
readonly clearAllLabel: ReactNode
|
|
12
|
+
readonly onClearAll: () => void
|
|
13
|
+
/** Footer apply button label, e.g. "Show 24 resources". */
|
|
14
|
+
readonly resultLabel: ReactNode
|
|
15
|
+
readonly onApply: () => void
|
|
16
|
+
/** When false (live-search mode) the footer apply button is omitted. */
|
|
17
|
+
readonly showApply?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* Live-search toggle row above the apply button — checked means every
|
|
20
|
+
* filter change applies immediately instead of staging. Rendered when
|
|
21
|
+
* provided.
|
|
22
|
+
*/
|
|
23
|
+
readonly liveSearch?: boolean
|
|
24
|
+
readonly onLiveSearchChange?: (live: boolean) => void
|
|
25
|
+
readonly liveSearchLabel?: ReactNode
|
|
26
|
+
readonly children: ReactNode
|
|
27
|
+
readonly className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Filter rail: the faceted filter surface on index pages. Lives in the
|
|
32
|
+
* AppShell's right panel column on wide screens and in a route-owned
|
|
33
|
+
* drawer below the panel breakpoint, so it always fills its parent's
|
|
34
|
+
* height and scrolls its own sections.
|
|
35
|
+
*/
|
|
36
|
+
export function FilterRail(props: FilterRailProps) {
|
|
37
|
+
const {
|
|
38
|
+
title,
|
|
39
|
+
clearAllLabel,
|
|
40
|
+
onClearAll,
|
|
41
|
+
resultLabel,
|
|
42
|
+
onApply,
|
|
43
|
+
showApply = true,
|
|
44
|
+
liveSearch,
|
|
45
|
+
onLiveSearchChange,
|
|
46
|
+
liveSearchLabel,
|
|
47
|
+
children,
|
|
48
|
+
className,
|
|
49
|
+
} = props
|
|
50
|
+
const liveToggleId = useId()
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={cn("flex h-full w-full flex-col overflow-hidden", className)}
|
|
54
|
+
>
|
|
55
|
+
<div className="flex items-baseline px-5 pt-5">
|
|
56
|
+
<span className="text-xs font-semibold tracking-label uppercase text-foreground">
|
|
57
|
+
{title}
|
|
58
|
+
</span>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className="ml-auto cursor-pointer text-xs text-muted-foreground hover:text-secondary-foreground"
|
|
62
|
+
data-testid="filter-rail-clear-all"
|
|
63
|
+
onClick={onClearAll}
|
|
64
|
+
>
|
|
65
|
+
{clearAllLabel}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="flex-1 overflow-y-auto px-5 pb-5">{children}</div>
|
|
69
|
+
{liveSearch !== undefined ? (
|
|
70
|
+
<>
|
|
71
|
+
<Separator size="seam" />
|
|
72
|
+
<div className="mb-3 flex items-center justify-between px-5 pt-3">
|
|
73
|
+
<div className="flex items-center gap-2">
|
|
74
|
+
<Checkbox
|
|
75
|
+
id={liveToggleId}
|
|
76
|
+
checked={liveSearch}
|
|
77
|
+
onCheckedChange={(v) => onLiveSearchChange?.(v === true)}
|
|
78
|
+
data-testid="live-search-toggle"
|
|
79
|
+
/>
|
|
80
|
+
<label
|
|
81
|
+
htmlFor={liveToggleId}
|
|
82
|
+
className="cursor-pointer text-xs text-muted-foreground"
|
|
83
|
+
>
|
|
84
|
+
{liveSearchLabel}
|
|
85
|
+
</label>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</>
|
|
89
|
+
) : null}
|
|
90
|
+
{showApply ? (
|
|
91
|
+
<div className="mb-3 px-5">
|
|
92
|
+
<Button
|
|
93
|
+
type="button"
|
|
94
|
+
className="w-full justify-center"
|
|
95
|
+
onClick={onApply}
|
|
96
|
+
data-testid="filter-rail-apply"
|
|
97
|
+
>
|
|
98
|
+
{resultLabel}
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type FilterRailSectionProps = {
|
|
107
|
+
/** Section label (uppercase tracking-label); omitted for the
|
|
108
|
+
label-less first panel section. */
|
|
109
|
+
readonly label?: ReactNode
|
|
110
|
+
/**
|
|
111
|
+
* Pad the label and content (`px-5`) while the seam stays full-bleed —
|
|
112
|
+
* detail panels whose sections take their own padding without touching
|
|
113
|
+
* the edge-to-edge separators.
|
|
114
|
+
*/
|
|
115
|
+
readonly padded?: boolean
|
|
116
|
+
readonly children: ReactNode
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One facet group; sections are separated by 2px structural seams. */
|
|
120
|
+
export function FilterRailSection(props: FilterRailSectionProps) {
|
|
121
|
+
const { label, children, padded = false } = props
|
|
122
|
+
return (
|
|
123
|
+
<section className="mt-5 first:mt-4 first:pt-0 first:*:data-[slot=separator]:hidden">
|
|
124
|
+
<Separator size="seam" className="mb-4" />
|
|
125
|
+
<div className={padded ? "px-5" : undefined}>
|
|
126
|
+
{label !== undefined ? (
|
|
127
|
+
<div className="text-xs font-semibold tracking-label uppercase text-foreground">
|
|
128
|
+
{label}
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
{label !== undefined ? (
|
|
132
|
+
<div className="mt-2 flex flex-col">{children}</div>
|
|
133
|
+
) : (
|
|
134
|
+
children
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</section>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
type FixedActionBarProps = {
|
|
5
|
+
readonly children: ReactNode
|
|
6
|
+
readonly className?: string
|
|
7
|
+
/**
|
|
8
|
+
* 0–100 shows the 2px top progress strip while an upload is staging;
|
|
9
|
+
* omit when idle. The track doubles as the seam.
|
|
10
|
+
*/
|
|
11
|
+
readonly progress?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fixed bottom action bar for long forms. Pinned to the viewport bottom
|
|
16
|
+
* so the submit control is always reachable, starting after the sidebar
|
|
17
|
+
* on md+; the top progress strip separates it from scrolling content (a
|
|
18
|
+
* quiet muted track when idle, a foreground fill while staging). The
|
|
19
|
+
* inner row mirrors PageScaffold's gutters and centers at `max-w-content`,
|
|
20
|
+
* so the buttons right-align with the page column's edge. Forms using it
|
|
21
|
+
* reserve clearance with bottom padding.
|
|
22
|
+
*/
|
|
23
|
+
export function FixedActionBar(props: FixedActionBarProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
"fixed inset-x-0 bottom-0 z-40 bg-background/90 backdrop-blur supports-backdrop-filter:bg-background/60 md:left-sidebar",
|
|
28
|
+
props.className,
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
<div className="mx-auto w-full max-w-content">
|
|
32
|
+
<div className="h-0.5 bg-muted">
|
|
33
|
+
{props.progress !== undefined ? (
|
|
34
|
+
<div
|
|
35
|
+
className="h-full bg-foreground transition-[width] duration-(--duration-2) ease-standard"
|
|
36
|
+
style={{ width: `${props.progress}%` }}
|
|
37
|
+
/>
|
|
38
|
+
) : null}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="mx-auto flex w-full max-w-content items-center justify-end gap-2 px-3 py-4 sm:px-6 lg:px-10">
|
|
42
|
+
{props.children}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closestCenter,
|
|
3
|
+
DndContext,
|
|
4
|
+
type DragEndEvent,
|
|
5
|
+
KeyboardSensor,
|
|
6
|
+
PointerSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
} from "@dnd-kit/core"
|
|
10
|
+
import {
|
|
11
|
+
arrayMove,
|
|
12
|
+
rectSortingStrategy,
|
|
13
|
+
SortableContext,
|
|
14
|
+
sortableKeyboardCoordinates,
|
|
15
|
+
useSortable,
|
|
16
|
+
} from "@dnd-kit/sortable"
|
|
17
|
+
import { CSS } from "@dnd-kit/utilities"
|
|
18
|
+
import { Input } from "@hoardodile/ui/components/input"
|
|
19
|
+
import { Switch } from "@hoardodile/ui/components/switch"
|
|
20
|
+
import { Cross } from "@hoardodile/ui/icons/marks"
|
|
21
|
+
import { SortVertical } from "@hoardodile/ui/icons/registry"
|
|
22
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
23
|
+
import { type ReactNode, useState } from "react"
|
|
24
|
+
import { useTranslation } from "react-i18next"
|
|
25
|
+
|
|
26
|
+
export type FontPickerProps = {
|
|
27
|
+
readonly value: readonly string[]
|
|
28
|
+
readonly onChange: (value: string[]) => void
|
|
29
|
+
/**
|
|
30
|
+
* Controlled "inherit app font" state. When `onInheritChange` is
|
|
31
|
+
* provided the picker renders an inherit switch; the switch only
|
|
32
|
+
* decides whether the chosen stack is used (implementation-level
|
|
33
|
+
* fallback) — the picker itself always stays visible and editable,
|
|
34
|
+
* so the user can keep a stack ready while inheriting.
|
|
35
|
+
*/
|
|
36
|
+
readonly inherit?: boolean
|
|
37
|
+
readonly onInheritChange?: (checked: boolean) => void
|
|
38
|
+
readonly inheritedFonts?: readonly string[]
|
|
39
|
+
readonly inheritedFamily?: string
|
|
40
|
+
/** Preset tag rows (web/system/extra) — the consumer renders them
|
|
41
|
+
(registry knowledge and localized labels are app-side). */
|
|
42
|
+
readonly presetTags?: ReactNode
|
|
43
|
+
/**
|
|
44
|
+
* Resolves an entered name to its canonical stack value (and may load
|
|
45
|
+
* its CSS); returning `undefined` keeps the raw name.
|
|
46
|
+
*/
|
|
47
|
+
readonly resolveFont?: (name: string) => string | undefined
|
|
48
|
+
/** Label and chip family for a stack entry. */
|
|
49
|
+
readonly resolveEntry?: (name: string) => { label: string; family?: string }
|
|
50
|
+
/** `font-family` for the live preview stack. */
|
|
51
|
+
readonly previewFamily?: string
|
|
52
|
+
readonly "data-testid"?: string
|
|
53
|
+
readonly "aria-label"?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Font picker with tag-style layout: optional preset tag rows on top,
|
|
58
|
+
* a custom input below them, the selected fonts as draggable chips, and
|
|
59
|
+
* an always-visible live preview. The font registry itself lives in the
|
|
60
|
+
* consumer — presets, CSS loading and localized display names come in
|
|
61
|
+
* through props.
|
|
62
|
+
*/
|
|
63
|
+
export function FontPicker(props: FontPickerProps) {
|
|
64
|
+
const {
|
|
65
|
+
value,
|
|
66
|
+
onChange,
|
|
67
|
+
inherit = false,
|
|
68
|
+
onInheritChange,
|
|
69
|
+
inheritedFonts,
|
|
70
|
+
inheritedFamily,
|
|
71
|
+
presetTags,
|
|
72
|
+
resolveFont,
|
|
73
|
+
resolveEntry,
|
|
74
|
+
previewFamily,
|
|
75
|
+
"data-testid": testId,
|
|
76
|
+
"aria-label": ariaLabel,
|
|
77
|
+
} = props
|
|
78
|
+
const { t } = useTranslation("ui", { useSuspense: false })
|
|
79
|
+
|
|
80
|
+
const [customInput, setCustomInput] = useState("")
|
|
81
|
+
|
|
82
|
+
function addFont(name: string) {
|
|
83
|
+
const trimmed = name.trim()
|
|
84
|
+
if (trimmed.length === 0) return
|
|
85
|
+
const resolved = resolveFont?.(trimmed) ?? trimmed
|
|
86
|
+
if (value.includes(resolved)) return
|
|
87
|
+
onChange([...value, resolved])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function removeFont(index: number) {
|
|
91
|
+
onChange(value.filter((_, i) => i !== index))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
95
|
+
const { active, over } = event
|
|
96
|
+
if (over === null || active.id === over.id) return
|
|
97
|
+
const oldIndex = value.indexOf(String(active.id))
|
|
98
|
+
const newIndex = value.indexOf(String(over.id))
|
|
99
|
+
onChange(arrayMove([...value], oldIndex, newIndex))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function handleCustomKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
103
|
+
if (e.key === "Enter") {
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
addFont(customInput)
|
|
106
|
+
setCustomInput("")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sensors = useSensors(
|
|
111
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),
|
|
112
|
+
useSensor(KeyboardSensor, {
|
|
113
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex flex-col gap-4" data-testid={testId}>
|
|
119
|
+
{onInheritChange !== undefined && (
|
|
120
|
+
<div className="flex items-center gap-2">
|
|
121
|
+
<Switch
|
|
122
|
+
checked={inherit}
|
|
123
|
+
onCheckedChange={onInheritChange}
|
|
124
|
+
aria-label={t("fontPicker.inherit")}
|
|
125
|
+
/>
|
|
126
|
+
<span className="text-sm">{t("fontPicker.inherit")}</span>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{inherit && (
|
|
131
|
+
<p className="text-xs text-muted-foreground">
|
|
132
|
+
{t("fontPicker.inheritedHint")}
|
|
133
|
+
{inheritedFonts !== undefined && inheritedFonts.length > 0 && (
|
|
134
|
+
<span className="ml-1" style={{ fontFamily: inheritedFamily }}>
|
|
135
|
+
({inheritedFonts.join(" → ")})
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{presetTags !== undefined ? (
|
|
142
|
+
<div className="flex flex-col gap-2">{presetTags}</div>
|
|
143
|
+
) : null}
|
|
144
|
+
|
|
145
|
+
{/* Custom font input */}
|
|
146
|
+
<Input
|
|
147
|
+
value={customInput}
|
|
148
|
+
onChange={(e) => setCustomInput(e.target.value)}
|
|
149
|
+
onKeyDown={handleCustomKeyDown}
|
|
150
|
+
placeholder={t("fontPicker.addCustom")}
|
|
151
|
+
aria-label={ariaLabel ?? t("fontPicker.addCustom")}
|
|
152
|
+
/>
|
|
153
|
+
|
|
154
|
+
{/* Selected font chips with drag sorting */}
|
|
155
|
+
{value.length > 0 && (
|
|
156
|
+
<div className="flex flex-col gap-2">
|
|
157
|
+
<p className="text-xs text-muted-foreground">{t("fontPicker.selected")}</p>
|
|
158
|
+
<DndContext
|
|
159
|
+
sensors={sensors}
|
|
160
|
+
collisionDetection={closestCenter}
|
|
161
|
+
onDragEnd={handleDragEnd}
|
|
162
|
+
>
|
|
163
|
+
<SortableContext
|
|
164
|
+
items={value as string[]}
|
|
165
|
+
strategy={rectSortingStrategy}
|
|
166
|
+
>
|
|
167
|
+
<div className="flex flex-wrap gap-2">
|
|
168
|
+
{value.map((name, index) => (
|
|
169
|
+
<SortableFontChip
|
|
170
|
+
key={name}
|
|
171
|
+
name={name}
|
|
172
|
+
resolveEntry={resolveEntry}
|
|
173
|
+
onRemove={() => removeFont(index)}
|
|
174
|
+
/>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
</SortableContext>
|
|
178
|
+
</DndContext>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* Live preview — the stack rendered in its own family. Always
|
|
183
|
+
visible: with no stack chosen the sample simply renders in
|
|
184
|
+
the system font. */}
|
|
185
|
+
<div className="rounded-xl bg-background px-5 py-4">
|
|
186
|
+
<div
|
|
187
|
+
className="text-lg text-foreground"
|
|
188
|
+
style={{ fontFamily: previewFamily }}
|
|
189
|
+
>
|
|
190
|
+
{t("fontPicker.description")}
|
|
191
|
+
</div>
|
|
192
|
+
{value.length > 0 && (
|
|
193
|
+
<div className="mt-2 text-tiny text-muted-foreground">
|
|
194
|
+
{value
|
|
195
|
+
.map((name) => resolveEntry?.(name)?.label ?? name)
|
|
196
|
+
.join(" · ")}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type SortableFontChipProps = {
|
|
205
|
+
readonly name: string
|
|
206
|
+
readonly resolveEntry?: FontPickerProps["resolveEntry"]
|
|
207
|
+
readonly onRemove: () => void
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function SortableFontChip(props: SortableFontChipProps) {
|
|
211
|
+
const { name, resolveEntry, onRemove } = props
|
|
212
|
+
const {
|
|
213
|
+
attributes,
|
|
214
|
+
listeners,
|
|
215
|
+
setNodeRef,
|
|
216
|
+
transform,
|
|
217
|
+
transition,
|
|
218
|
+
isDragging,
|
|
219
|
+
} = useSortable({ id: name, transition: null })
|
|
220
|
+
|
|
221
|
+
const style: React.CSSProperties = {
|
|
222
|
+
transform: CSS.Translate.toString(transform),
|
|
223
|
+
transition,
|
|
224
|
+
opacity: isDragging ? 0.5 : 1,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entry = resolveEntry?.(name)
|
|
228
|
+
const displayName = entry?.label ?? name
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
ref={setNodeRef}
|
|
233
|
+
style={style}
|
|
234
|
+
className={cn(
|
|
235
|
+
"inline-flex items-center gap-1 rounded-md border bg-card px-2 py-1 text-sm",
|
|
236
|
+
isDragging && "z-10",
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
className="cursor-grab text-muted-foreground active:cursor-grabbing"
|
|
242
|
+
{...attributes}
|
|
243
|
+
{...listeners}
|
|
244
|
+
>
|
|
245
|
+
<SortVertical className="size-3.5" />
|
|
246
|
+
</button>
|
|
247
|
+
<span style={{ fontFamily: entry?.family ?? name }}>{displayName}</span>
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={onRemove}
|
|
251
|
+
className="text-muted-foreground hover:text-destructive"
|
|
252
|
+
>
|
|
253
|
+
<Cross className="size-3.5" />
|
|
254
|
+
</button>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useRender } from "@base-ui/react/use-render"
|
|
2
|
+
import { Label } from "@hoardodile/ui/components/label"
|
|
3
|
+
import {
|
|
4
|
+
FormFieldContext,
|
|
5
|
+
FormItemContext,
|
|
6
|
+
useFormField,
|
|
7
|
+
} from "@hoardodile/ui/hooks/use-form-field"
|
|
8
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import {
|
|
11
|
+
Controller,
|
|
12
|
+
type ControllerProps,
|
|
13
|
+
type FieldPath,
|
|
14
|
+
type FieldValues,
|
|
15
|
+
FormProvider,
|
|
16
|
+
} from "react-hook-form"
|
|
17
|
+
|
|
18
|
+
const Form = FormProvider
|
|
19
|
+
|
|
20
|
+
const FormField = <
|
|
21
|
+
TFieldValues extends FieldValues = FieldValues,
|
|
22
|
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
23
|
+
>({
|
|
24
|
+
...props
|
|
25
|
+
}: ControllerProps<TFieldValues, TName>) => {
|
|
26
|
+
return (
|
|
27
|
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
28
|
+
<Controller {...props} />
|
|
29
|
+
</FormFieldContext.Provider>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
|
34
|
+
const id = React.useId()
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<FormItemContext.Provider value={{ id }}>
|
|
38
|
+
<div
|
|
39
|
+
data-slot="form-item"
|
|
40
|
+
className={cn("grid gap-2", className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
</FormItemContext.Provider>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function FormLabel({
|
|
48
|
+
className,
|
|
49
|
+
...props
|
|
50
|
+
}: React.ComponentProps<typeof Label>) {
|
|
51
|
+
const { error, formItemId } = useFormField()
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Label
|
|
55
|
+
data-slot="form-label"
|
|
56
|
+
data-error={!!error}
|
|
57
|
+
className={cn("data-[error=true]:text-destructive", className)}
|
|
58
|
+
htmlFor={formItemId}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function FormControl({ children }: { children: React.ReactElement }) {
|
|
65
|
+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
|
66
|
+
|
|
67
|
+
return useRender({
|
|
68
|
+
render: children,
|
|
69
|
+
props: {
|
|
70
|
+
"data-slot": "form-control",
|
|
71
|
+
id: formItemId,
|
|
72
|
+
"aria-describedby": error
|
|
73
|
+
? `${formDescriptionId} ${formMessageId}`
|
|
74
|
+
: formDescriptionId,
|
|
75
|
+
"aria-invalid": !!error,
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
81
|
+
const { formDescriptionId } = useFormField()
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<p
|
|
85
|
+
data-slot="form-description"
|
|
86
|
+
id={formDescriptionId}
|
|
87
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function FormMessage({
|
|
94
|
+
className,
|
|
95
|
+
children,
|
|
96
|
+
...props
|
|
97
|
+
}: React.ComponentProps<"p">) {
|
|
98
|
+
const { error, formMessageId } = useFormField()
|
|
99
|
+
const body = error ? String(error.message ?? "") : children
|
|
100
|
+
|
|
101
|
+
if (!body) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<p
|
|
107
|
+
data-slot="form-message"
|
|
108
|
+
id={formMessageId}
|
|
109
|
+
role="alert"
|
|
110
|
+
className={cn("text-sm text-destructive", className)}
|
|
111
|
+
{...props}
|
|
112
|
+
>
|
|
113
|
+
{body}
|
|
114
|
+
</p>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
Form,
|
|
120
|
+
FormControl,
|
|
121
|
+
FormDescription,
|
|
122
|
+
FormField,
|
|
123
|
+
FormItem,
|
|
124
|
+
FormLabel,
|
|
125
|
+
FormMessage,
|
|
126
|
+
useFormField,
|
|
127
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny group label inside picker blocks ("Selected", "Available", "Tags")
|
|
5
|
+
* — the PickerGroupLabel voice: 11px, uppercase, tracked.
|
|
6
|
+
*/
|
|
7
|
+
export function GroupLabel({ children }: { readonly children: ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<span className="text-tiny font-semibold tracking-label uppercase text-muted-foreground">
|
|
10
|
+
{children}
|
|
11
|
+
</span>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Icon,
|
|
3
|
+
type IconSize,
|
|
4
|
+
type IconType,
|
|
5
|
+
} from "@hoardodile/ui/components/icon"
|
|
6
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 32/40px fill icon tile used by list rows and directory rows; the 40px
|
|
10
|
+
* tier pairs with the lg icon.
|
|
11
|
+
*/
|
|
12
|
+
export function IconTile({
|
|
13
|
+
icon,
|
|
14
|
+
size = 32,
|
|
15
|
+
iconSize,
|
|
16
|
+
className,
|
|
17
|
+
}: {
|
|
18
|
+
readonly icon: IconType
|
|
19
|
+
readonly size?: 32 | 40
|
|
20
|
+
/** Icon tier — md (16) by default, lg (20) on the 40px tile. */
|
|
21
|
+
readonly iconSize?: IconSize
|
|
22
|
+
readonly className?: string
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
className={cn(
|
|
27
|
+
"flex shrink-0 items-center justify-center rounded-lg bg-muted text-secondary-foreground",
|
|
28
|
+
size === 40 ? "size-10" : "size-8",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<Icon icon={icon} size={iconSize ?? "md"} />
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Icon, type IconType } from "@hoardodile/ui/components/icon"
|
|
2
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
3
|
+
|
|
4
|
+
export type IconToggleOption<T extends string> = {
|
|
5
|
+
readonly value: T
|
|
6
|
+
readonly icon: IconType
|
|
7
|
+
/** Accessible name (also the tooltip). */
|
|
8
|
+
readonly label: string
|
|
9
|
+
readonly testId?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type IconToggleProps<T extends string> = {
|
|
13
|
+
readonly value: T
|
|
14
|
+
readonly options: readonly IconToggleOption<T>[]
|
|
15
|
+
readonly onChange: (value: T) => void
|
|
16
|
+
readonly className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Icon toggle: a segmented control of square icon buttons (grid/masonry
|
|
21
|
+
* view switchers). Muted track; the active button lifts to the card fill
|
|
22
|
+
* and its icon takes the Bold weight.
|
|
23
|
+
*/
|
|
24
|
+
export function IconToggle<T extends string>(props: IconToggleProps<T>) {
|
|
25
|
+
const { value, options, onChange, className } = props
|
|
26
|
+
return (
|
|
27
|
+
<span
|
|
28
|
+
className={cn(
|
|
29
|
+
"inline-flex shrink-0 items-center gap-0.5 rounded-lg bg-muted p-0.5",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{options.map((option) => {
|
|
34
|
+
const active = option.value === value
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
key={option.value}
|
|
39
|
+
title={option.label}
|
|
40
|
+
aria-label={option.label}
|
|
41
|
+
aria-pressed={active}
|
|
42
|
+
data-testid={option.testId}
|
|
43
|
+
onClick={() => onChange(option.value)}
|
|
44
|
+
className={cn(
|
|
45
|
+
"flex size-7 cursor-pointer items-center justify-center rounded-md",
|
|
46
|
+
active
|
|
47
|
+
? "bg-card text-foreground"
|
|
48
|
+
: "text-muted-foreground hover:text-secondary-foreground",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<Icon icon={option.icon} selected={active} className="size-4" />
|
|
52
|
+
</button>
|
|
53
|
+
)
|
|
54
|
+
})}
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
}
|