@eifi1/ui-kit 0.10.0 → 0.12.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/README.md +66 -37
- package/dist/chart.d.ts +3 -1
- package/dist/chart.js +2 -0
- package/dist/chart.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +105 -0
- package/dist/components/account-settings-labels.js +81 -0
- package/dist/components/account-settings-labels.js.map +1 -0
- package/dist/components/account-settings.d.ts +36 -52
- package/dist/components/account-settings.js +44 -8
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/alert-banner.d.ts +26 -1
- package/dist/components/alert-banner.js +10 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +26 -4
- package/dist/components/amount-input.js +5 -2
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.d.ts +68 -0
- package/dist/components/authed-image.js +117 -0
- package/dist/components/authed-image.js.map +1 -0
- package/dist/components/breadcrumbs.d.ts +2 -1
- package/dist/components/breadcrumbs.js +5 -2
- package/dist/components/breadcrumbs.js.map +1 -1
- package/dist/components/bulk-action-bar.d.ts +51 -5
- package/dist/components/bulk-action-bar.js +88 -41
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.d.ts +107 -3
- package/dist/components/button-group.js +60 -5
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/calculator.d.ts +17 -4
- package/dist/components/calendar-heatmap.d.ts +162 -0
- package/dist/components/calendar-heatmap.js +336 -0
- package/dist/components/calendar-heatmap.js.map +1 -0
- package/dist/components/chip.d.ts +2 -1
- package/dist/components/chip.js +6 -3
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +66 -2
- package/dist/components/choice-card.js +108 -1
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.js +2 -0
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +2 -2
- package/dist/components/combobox.js +31 -9
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/copy-button.d.ts +52 -70
- package/dist/components/copy-button.js +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +63 -0
- package/dist/components/data-table-cells.js +64 -0
- package/dist/components/data-table-cells.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-filters.js +31 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +13 -0
- package/dist/components/data-table-labels.js +8 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +104 -12
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +3 -1
- package/dist/components/date-picker.js +15 -2
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +23 -3
- package/dist/components/description-list.js +23 -3
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +1 -0
- package/dist/components/entity-combobox.d.ts +1 -1
- package/dist/components/entity-combobox.js +18 -3
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.d.ts +92 -0
- package/dist/components/error-boundary.js +111 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/field.d.ts +69 -0
- package/dist/components/field.js +75 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/file-button.d.ts +51 -208
- package/dist/components/file-dropzone.d.ts +50 -2
- package/dist/components/floating-panel.d.ts +156 -4
- package/dist/components/floating-panel.js +193 -29
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +53 -0
- package/dist/components/form-actions.js +98 -0
- package/dist/components/form-actions.js.map +1 -0
- package/dist/components/image-grid.d.ts +67 -0
- package/dist/components/image-grid.js +112 -0
- package/dist/components/image-grid.js.map +1 -0
- package/dist/components/lightbox.d.ts +104 -0
- package/dist/components/lightbox.js +229 -0
- package/dist/components/lightbox.js.map +1 -0
- package/dist/components/line-items.d.ts +114 -0
- package/dist/components/line-items.js +238 -0
- package/dist/components/line-items.js.map +1 -0
- package/dist/components/list.d.ts +37 -3
- package/dist/components/list.js +39 -14
- package/dist/components/list.js.map +1 -1
- package/dist/components/loading-state.d.ts +24 -0
- package/dist/components/loading-state.js +32 -0
- package/dist/components/loading-state.js.map +1 -0
- package/dist/components/menu-item.d.ts +13 -2
- package/dist/components/menu-item.js +11 -2
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.d.ts +82 -5
- package/dist/components/mini-calendar.js +212 -64
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +20 -1
- package/dist/components/modal.js +26 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.d.ts +21 -1
- package/dist/components/month-picker.js +139 -26
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +1 -1
- package/dist/components/multi-entity-combobox.js +19 -3
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/nav-pills.d.ts +67 -0
- package/dist/components/nav-pills.js +69 -0
- package/dist/components/nav-pills.js.map +1 -0
- package/dist/components/number-field.d.ts +26 -5
- package/dist/components/number-field.js +5 -7
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +27 -5
- package/dist/components/number-input.js +7 -1
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +17 -4
- package/dist/components/passkeys-setting.d.ts +67 -0
- package/dist/components/passkeys-setting.js +214 -0
- package/dist/components/passkeys-setting.js.map +1 -0
- package/dist/components/picker-sheet.js +2 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart-labels.d.ts +20 -0
- package/dist/components/pie-chart-labels.js +12 -0
- package/dist/components/pie-chart-labels.js.map +1 -0
- package/dist/components/pie-chart.d.ts +101 -0
- package/dist/components/pie-chart.js +283 -0
- package/dist/components/pie-chart.js.map +1 -0
- package/dist/components/progress-bar.d.ts +73 -3
- package/dist/components/progress-bar.js +110 -8
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.d.ts +34 -0
- package/dist/components/qr-code.js +57 -0
- package/dist/components/qr-code.js.map +1 -0
- package/dist/components/search-field.d.ts +15 -3
- package/dist/components/search-field.js +5 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/series-chart.d.ts +16 -0
- package/dist/components/series-chart.js +4 -1
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.d.ts +50 -1
- package/dist/components/signed-amount.d.ts +121 -0
- package/dist/components/signed-amount.js +152 -0
- package/dist/components/signed-amount.js.map +1 -0
- package/dist/components/skeleton.d.ts +10 -3
- package/dist/components/skeleton.js +12 -2
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/stat-tile.d.ts +2 -1
- package/dist/components/stat-tile.js +5 -2
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/status-dot.d.ts +6 -2
- package/dist/components/status-dot.js +6 -1
- package/dist/components/status-dot.js.map +1 -1
- package/dist/components/table.d.ts +77 -11
- package/dist/components/table.js +85 -17
- package/dist/components/table.js.map +1 -1
- package/dist/components/text-link.d.ts +176 -0
- package/dist/components/text-link.js +109 -0
- package/dist/components/text-link.js.map +1 -0
- package/dist/components/text.d.ts +8 -1
- package/dist/components/text.js +1 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/time-input.d.ts +50 -1
- package/dist/components/toggle-group.d.ts +28 -0
- package/dist/components/toggle-group.js +58 -9
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.d.ts +36 -3
- package/dist/components/toggle-legend.js +49 -0
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.d.ts +11 -3
- package/dist/components/tooltip.js +2 -0
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/ui.d.ts +52 -729
- package/dist/components/ui.js +296 -27
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +3 -52
- package/dist/components/use-table-state.js +128 -30
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.d.ts +25 -3
- package/dist/components/user-avatar.js +32 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/{data-table-filters-Dh9uF_S-.d.ts → data-table-Drun8Fk9.d.ts} +305 -61
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +5 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +26 -3
- package/dist/feedback/feedback-dialog.js +16 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +133 -0
- package/dist/feedback/feedback-thread.js +237 -0
- package/dist/feedback/feedback-thread.js.map +1 -0
- package/dist/feedback.d.ts +2 -1
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/hooks/use-authed-src.d.ts +71 -0
- package/dist/hooks/use-authed-src.js +63 -0
- package/dist/hooks/use-authed-src.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +50 -2
- package/dist/hooks/use-hotkey.d.ts +59 -0
- package/dist/hooks/use-hotkey.js +105 -0
- package/dist/hooks/use-hotkey.js.map +1 -0
- package/dist/hooks/use-search-param-state.d.ts +83 -0
- package/dist/hooks/use-search-param-state.js +94 -0
- package/dist/hooks/use-search-param-state.js.map +1 -0
- package/dist/i18n/defaults.d.ts +17 -4
- package/dist/i18n/defaults.js +33 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +47 -310
- package/dist/i18n/kit-labels.js +20 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +17 -4
- package/dist/i18n/locales/de-CH.d.ts +17 -4
- package/dist/i18n/locales/de-informal.d.ts +17 -4
- package/dist/i18n/locales/de.d.ts +17 -4
- package/dist/i18n/locales/de.js +165 -4
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +17 -4
- package/dist/i18n/locales/es.js +162 -4
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +17 -4
- package/dist/i18n/locales/fr.js +162 -4
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +17 -4
- package/dist/i18n/locales/hu.js +162 -4
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +17 -4
- package/dist/i18n/locales/it.js +162 -4
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +17 -4
- package/dist/i18n/locales/zh.js +162 -4
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/index.d.ts +45 -21
- package/dist/index.js +43 -1
- package/dist/index.js.map +1 -1
- package/dist/kit-labels-v3biUF1L.d.ts +1645 -0
- package/dist/lib/clipping.d.ts +21 -5
- package/dist/lib/clipping.js +3 -0
- package/dist/lib/clipping.js.map +1 -1
- package/dist/lib/dates.d.ts +17 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/format.d.ts +144 -0
- package/dist/lib/format.js +149 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/qr-encode.d.ts +44 -0
- package/dist/lib/qr-encode.js +338 -0
- package/dist/lib/qr-encode.js.map +1 -0
- package/dist/rhf/fields.d.ts +241 -0
- package/dist/rhf/fields.js +601 -0
- package/dist/rhf/fields.js.map +1 -0
- package/dist/rhf/form.d.ts +50 -1
- package/dist/rhf/line-items.d.ts +31 -0
- package/dist/rhf/line-items.js +59 -0
- package/dist/rhf/line-items.js.map +1 -0
- package/dist/rhf/use-rhf-wizard-step.d.ts +22 -0
- package/dist/rhf/use-rhf-wizard-step.js +38 -0
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -0
- package/dist/rhf.d.ts +63 -1
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +99 -2
- package/dist/shell/app-shell.js +58 -3
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.d.ts +74 -0
- package/dist/shell/auth-layout.js +97 -0
- package/dist/shell/auth-layout.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +81 -0
- package/dist/shell/top-bar-brand.js +47 -0
- package/dist/shell/top-bar-brand.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +84 -3
- package/dist/shell/topbar-action-menu.js +115 -27
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +58 -2
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -1
- package/dist/wizard/wizard-context.d.ts +30 -1
- package/dist/wizard/wizard-context.js +23 -2
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +20 -5
- package/dist/wizard/wizard-step.js +16 -3
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard.d.ts +51 -3
- package/package.json +1 -1
- package/src/chart.ts +4 -0
- package/src/components/account-settings-labels.ts +190 -0
- package/src/components/account-settings.tsx +110 -63
- package/src/components/alert-banner.tsx +36 -7
- package/src/components/amount-input.tsx +13 -1
- package/src/components/authed-image.tsx +190 -0
- package/src/components/breadcrumbs.tsx +8 -2
- package/src/components/bulk-action-bar.tsx +163 -57
- package/src/components/button-group.tsx +129 -4
- package/src/components/calendar-heatmap.tsx +599 -0
- package/src/components/chip.tsx +8 -4
- package/src/components/choice-card.tsx +208 -1
- package/src/components/combobox-core.tsx +5 -0
- package/src/components/combobox.tsx +31 -9
- package/src/components/copy-button.tsx +3 -2
- package/src/components/data-table-cells.tsx +119 -0
- package/src/components/data-table-filters.ts +77 -0
- package/src/components/data-table-labels.ts +23 -0
- package/src/components/data-table.tsx +231 -13
- package/src/components/date-picker.tsx +19 -3
- package/src/components/description-list.tsx +52 -3
- package/src/components/entity-combobox.tsx +18 -3
- package/src/components/error-boundary.tsx +216 -0
- package/src/components/field.tsx +188 -0
- package/src/components/floating-panel.tsx +380 -16
- package/src/components/form-actions.tsx +191 -0
- package/src/components/image-grid.tsx +181 -0
- package/src/components/lightbox.tsx +374 -0
- package/src/components/line-items.tsx +385 -0
- package/src/components/list.tsx +93 -18
- package/src/components/loading-state.tsx +47 -0
- package/src/components/menu-item.tsx +29 -3
- package/src/components/mini-calendar.tsx +317 -57
- package/src/components/modal.tsx +61 -3
- package/src/components/month-picker.tsx +181 -26
- package/src/components/multi-entity-combobox.tsx +19 -3
- package/src/components/nav-pills.tsx +152 -0
- package/src/components/number-field.tsx +15 -10
- package/src/components/number-input.tsx +16 -1
- package/src/components/passkeys-setting.tsx +331 -0
- package/src/components/picker-sheet.tsx +7 -1
- package/src/components/pie-chart-labels.ts +32 -0
- package/src/components/pie-chart.tsx +479 -0
- package/src/components/progress-bar.tsx +241 -13
- package/src/components/qr-code.tsx +83 -0
- package/src/components/search-field.tsx +20 -4
- package/src/components/series-chart.tsx +21 -0
- package/src/components/signed-amount.tsx +286 -0
- package/src/components/skeleton.tsx +21 -3
- package/src/components/stat-tile.tsx +8 -2
- package/src/components/status-dot.tsx +10 -2
- package/src/components/table.tsx +186 -23
- package/src/components/text-link.tsx +248 -0
- package/src/components/text.tsx +9 -1
- package/src/components/toggle-group.tsx +106 -13
- package/src/components/toggle-legend.tsx +87 -2
- package/src/components/tooltip.tsx +17 -3
- package/src/components/ui.tsx +572 -44
- package/src/components/use-table-state.ts +265 -33
- package/src/components/user-avatar.tsx +57 -3
- package/src/data-table.ts +10 -0
- package/src/feedback/feedback-dialog.tsx +42 -3
- package/src/feedback/feedback-thread.tsx +434 -0
- package/src/feedback.ts +1 -0
- package/src/hooks/use-authed-src.ts +161 -0
- package/src/hooks/use-hotkey.ts +172 -0
- package/src/hooks/use-search-param-state.ts +187 -0
- package/src/i18n/defaults.ts +32 -0
- package/src/i18n/kit-labels.tsx +104 -8
- package/src/i18n/locales/de.ts +162 -0
- package/src/i18n/locales/es.ts +159 -0
- package/src/i18n/locales/fr.ts +162 -0
- package/src/i18n/locales/hu.ts +160 -0
- package/src/i18n/locales/it.ts +160 -0
- package/src/i18n/locales/zh.ts +159 -0
- package/src/index.ts +64 -0
- package/src/lib/clipping.ts +22 -4
- package/src/lib/dates.ts +37 -0
- package/src/lib/format.ts +325 -0
- package/src/lib/qr-encode.ts +420 -0
- package/src/rhf/fields.tsx +1054 -0
- package/src/rhf/line-items.tsx +130 -0
- package/src/rhf/use-rhf-wizard-step.ts +113 -0
- package/src/rhf.ts +8 -0
- package/src/shell/app-shell.tsx +147 -2
- package/src/shell/auth-layout.tsx +190 -0
- package/src/shell/top-bar-brand.tsx +76 -0
- package/src/shell/topbar-action-menu.tsx +260 -49
- package/src/shell.ts +2 -0
- package/src/wizard/wizard-context.tsx +50 -1
- package/src/wizard/wizard-step.tsx +43 -6
- package/tokens.css +12 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
import { AuthedFetcher } from '../hooks/use-authed-src.js';
|
|
4
|
+
import { ImageItem, LightboxLabels } from './lightbox.js';
|
|
5
|
+
|
|
6
|
+
interface ImageGridLabels {
|
|
7
|
+
/** The list's accessible name. */
|
|
8
|
+
list: string;
|
|
9
|
+
/** An open button's name when an item's `alt` is empty: "Image 3 of 7". */
|
|
10
|
+
item: (index: number, count: number) => string;
|
|
11
|
+
/** The name of the group of per-image actions, given the image's name. */
|
|
12
|
+
actions: (name: string) => string;
|
|
13
|
+
}
|
|
14
|
+
declare const DEFAULT_IMAGE_GRID_LABELS: ImageGridLabels;
|
|
15
|
+
interface ImageGridProps extends Omit<ComponentPropsWithoutRef<"ul">, "children"> {
|
|
16
|
+
items: readonly ImageItem[];
|
|
17
|
+
/** The app's authenticated GET — thumbnails and the lightbox fetch through it. Only
|
|
18
|
+
* images are fetched for the grid; a file (PDF) tile shows its icon and name. */
|
|
19
|
+
fetcher?: AuthedFetcher | null;
|
|
20
|
+
/**
|
|
21
|
+
* Per-image actions — a delete, an edit. Rendered BESIDE the open button (on top of
|
|
22
|
+
* the thumbnail's corner), never inside it: a button inside a button is invalid HTML
|
|
23
|
+
* and a screen reader flattens it into the outer one's name, where it cannot be
|
|
24
|
+
* reached. Use `IconButton variant="overlay"` for controls that sit on a photo.
|
|
25
|
+
*/
|
|
26
|
+
renderActions?: (item: ImageItem, index: number) => ReactNode;
|
|
27
|
+
/**
|
|
28
|
+
* What a tile's click does. Left out, the grid opens its own {@link Lightbox} on that
|
|
29
|
+
* image. Given, the grid opens nothing — for a caller that keeps the lightbox's
|
|
30
|
+
* index in the URL, or that opens something else.
|
|
31
|
+
*/
|
|
32
|
+
onOpen?: (index: number) => void;
|
|
33
|
+
/**
|
|
34
|
+
* A fixed column count. Left out, the grid is responsive: as many columns of at
|
|
35
|
+
* least {@link minTileSize} px as fit, so the same grid is three across in a card on
|
|
36
|
+
* a phone and eight across on a desktop page — no breakpoints to choose.
|
|
37
|
+
*/
|
|
38
|
+
columns?: number;
|
|
39
|
+
/** The smallest a tile may be in the responsive layout. Default 96 (px). */
|
|
40
|
+
minTileSize?: number;
|
|
41
|
+
/** `square` (default) crops to a square; `video` to 16:9; `auto` keeps each image's
|
|
42
|
+
* own proportions (a floor plan), within a square-ish box. */
|
|
43
|
+
aspect?: "square" | "video" | "auto";
|
|
44
|
+
/** Props for the built-in lightbox (ignored with {@link onOpen}). */
|
|
45
|
+
lightbox?: {
|
|
46
|
+
loop?: boolean;
|
|
47
|
+
zoom?: boolean;
|
|
48
|
+
download?: boolean;
|
|
49
|
+
onDownload?: (item: ImageItem, index: number) => void;
|
|
50
|
+
labels?: Partial<LightboxLabels>;
|
|
51
|
+
};
|
|
52
|
+
labels?: Partial<ImageGridLabels>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect
|
|
56
|
+
* photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each
|
|
57
|
+
* of which drew its own `grid grid-cols-N` of `<img>` that nothing could open, with no
|
|
58
|
+
* loading state and nowhere to put a delete.
|
|
59
|
+
*
|
|
60
|
+
* Each tile is a real `<button>` named by the image's alt, with the actions beside it
|
|
61
|
+
* and the caption below it — outside the button, so the name stays the alt and the
|
|
62
|
+
* caption is read once. Placeholders come from {@link AuthedImage}: a skeleton while
|
|
63
|
+
* loading, a "couldn't load" tile on failure.
|
|
64
|
+
*/
|
|
65
|
+
declare function ImageGrid({ items, fetcher, renderActions, onOpen, columns, minTileSize, aspect, lightbox, labels: labelsProp, className, style, ...rest }: ImageGridProps): react.JSX.Element;
|
|
66
|
+
|
|
67
|
+
export { DEFAULT_IMAGE_GRID_LABELS, ImageGrid, type ImageGridLabels, type ImageGridProps };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { FileText } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
7
|
+
import { AuthedImage } from "./authed-image.js";
|
|
8
|
+
import { Lightbox, imageItemKind } from "./lightbox.js";
|
|
9
|
+
const DEFAULT_IMAGE_GRID_LABELS = {
|
|
10
|
+
list: "Images",
|
|
11
|
+
item: (index, count) => `Image ${index} of ${count}`,
|
|
12
|
+
actions: (name) => `Actions for ${name}`
|
|
13
|
+
};
|
|
14
|
+
const ASPECT_CLASS = {
|
|
15
|
+
square: "aspect-square",
|
|
16
|
+
video: "aspect-video",
|
|
17
|
+
auto: "aspect-[4/3]"
|
|
18
|
+
};
|
|
19
|
+
function ImageGrid({
|
|
20
|
+
items,
|
|
21
|
+
fetcher,
|
|
22
|
+
renderActions,
|
|
23
|
+
onOpen,
|
|
24
|
+
columns,
|
|
25
|
+
minTileSize = 96,
|
|
26
|
+
aspect = "square",
|
|
27
|
+
lightbox,
|
|
28
|
+
labels: labelsProp,
|
|
29
|
+
className,
|
|
30
|
+
style,
|
|
31
|
+
...rest
|
|
32
|
+
}) {
|
|
33
|
+
const labels = useKitLabels("imageGrid", DEFAULT_IMAGE_GRID_LABELS, labelsProp);
|
|
34
|
+
const [openIndex, setOpenIndex] = useState(null);
|
|
35
|
+
const count = items.length;
|
|
36
|
+
const gridStyle = {
|
|
37
|
+
gridTemplateColumns: columns !== void 0 ? `repeat(${columns}, minmax(0, 1fr))` : `repeat(auto-fill, minmax(min(${minTileSize}px, 100%), 1fr))`,
|
|
38
|
+
...style
|
|
39
|
+
};
|
|
40
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
41
|
+
/* @__PURE__ */ jsx(
|
|
42
|
+
"ul",
|
|
43
|
+
{
|
|
44
|
+
"aria-label": labels.list,
|
|
45
|
+
...rest,
|
|
46
|
+
className: cn("grid gap-2", className),
|
|
47
|
+
style: gridStyle,
|
|
48
|
+
children: items.map((item, i) => {
|
|
49
|
+
const name = item.alt || labels.item(i + 1, count);
|
|
50
|
+
const kind = imageItemKind(item);
|
|
51
|
+
const actions = renderActions?.(item, i);
|
|
52
|
+
return /* @__PURE__ */ jsxs("li", { className: "relative min-w-0", children: [
|
|
53
|
+
/* @__PURE__ */ jsx(
|
|
54
|
+
"button",
|
|
55
|
+
{
|
|
56
|
+
type: "button",
|
|
57
|
+
"aria-label": name,
|
|
58
|
+
onClick: () => onOpen ? onOpen(i) : setOpenIndex(i),
|
|
59
|
+
className: cn(
|
|
60
|
+
"block w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-hover)]",
|
|
61
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]",
|
|
62
|
+
ASPECT_CLASS[aspect]
|
|
63
|
+
),
|
|
64
|
+
children: kind === "file" ? /* @__PURE__ */ jsxs("span", { className: "flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]", children: [
|
|
65
|
+
/* @__PURE__ */ jsx(FileText, { "aria-hidden": true, className: "size-6 shrink-0 text-[var(--text-muted)]" }),
|
|
66
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "line-clamp-2 break-all", children: item.fileName ?? item.alt })
|
|
67
|
+
] }) : /* @__PURE__ */ jsx(
|
|
68
|
+
AuthedImage,
|
|
69
|
+
{
|
|
70
|
+
src: item.thumbnailSrc ?? item.src,
|
|
71
|
+
alt: "",
|
|
72
|
+
fetcher,
|
|
73
|
+
loading: "lazy",
|
|
74
|
+
wrapperClassName: "h-full w-full",
|
|
75
|
+
className: cn("h-full w-full", aspect === "auto" ? "object-contain" : "object-cover")
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
),
|
|
80
|
+
actions !== void 0 && actions !== null && actions !== false && /* @__PURE__ */ jsx(
|
|
81
|
+
"div",
|
|
82
|
+
{
|
|
83
|
+
role: "group",
|
|
84
|
+
"aria-label": labels.actions(name),
|
|
85
|
+
className: "absolute end-1 top-1 flex gap-1",
|
|
86
|
+
children: actions
|
|
87
|
+
}
|
|
88
|
+
),
|
|
89
|
+
item.caption !== void 0 && item.caption !== null && item.caption !== false && /* @__PURE__ */ jsx("div", { className: "mt-1 line-clamp-2 text-xs text-[var(--text-secondary)]", children: item.caption })
|
|
90
|
+
] }, item.key ?? item.src);
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
),
|
|
94
|
+
!onOpen && /* @__PURE__ */ jsx(
|
|
95
|
+
Lightbox,
|
|
96
|
+
{
|
|
97
|
+
open: openIndex !== null,
|
|
98
|
+
onClose: () => setOpenIndex(null),
|
|
99
|
+
items,
|
|
100
|
+
index: openIndex ?? 0,
|
|
101
|
+
onIndexChange: setOpenIndex,
|
|
102
|
+
fetcher,
|
|
103
|
+
...lightbox
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
] });
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
DEFAULT_IMAGE_GRID_LABELS,
|
|
110
|
+
ImageGrid
|
|
111
|
+
};
|
|
112
|
+
//# sourceMappingURL=image-grid.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/image-grid.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from \"react\";\nimport { FileText } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport type { AuthedFetcher } from \"../hooks/use-authed-src\";\nimport { AuthedImage } from \"./authed-image\";\nimport { Lightbox, imageItemKind } from \"./lightbox\";\nimport type { ImageItem, LightboxLabels } from \"./lightbox\";\n\nexport interface ImageGridLabels {\n /** The list's accessible name. */\n list: string;\n /** An open button's name when an item's `alt` is empty: \"Image 3 of 7\". */\n item: (index: number, count: number) => string;\n /** The name of the group of per-image actions, given the image's name. */\n actions: (name: string) => string;\n}\n\nexport const DEFAULT_IMAGE_GRID_LABELS: ImageGridLabels = {\n list: \"Images\",\n item: (index, count) => `Image ${index} of ${count}`,\n actions: (name) => `Actions for ${name}`,\n};\n\nexport interface ImageGridProps extends Omit<ComponentPropsWithoutRef<\"ul\">, \"children\"> {\n items: readonly ImageItem[];\n /** The app's authenticated GET — thumbnails and the lightbox fetch through it. Only\n * images are fetched for the grid; a file (PDF) tile shows its icon and name. */\n fetcher?: AuthedFetcher | null;\n /**\n * Per-image actions — a delete, an edit. Rendered BESIDE the open button (on top of\n * the thumbnail's corner), never inside it: a button inside a button is invalid HTML\n * and a screen reader flattens it into the outer one's name, where it cannot be\n * reached. Use `IconButton variant=\"overlay\"` for controls that sit on a photo.\n */\n renderActions?: (item: ImageItem, index: number) => ReactNode;\n /**\n * What a tile's click does. Left out, the grid opens its own {@link Lightbox} on that\n * image. Given, the grid opens nothing — for a caller that keeps the lightbox's\n * index in the URL, or that opens something else.\n */\n onOpen?: (index: number) => void;\n /**\n * A fixed column count. Left out, the grid is responsive: as many columns of at\n * least {@link minTileSize} px as fit, so the same grid is three across in a card on\n * a phone and eight across on a desktop page — no breakpoints to choose.\n */\n columns?: number;\n /** The smallest a tile may be in the responsive layout. Default 96 (px). */\n minTileSize?: number;\n /** `square` (default) crops to a square; `video` to 16:9; `auto` keeps each image's\n * own proportions (a floor plan), within a square-ish box. */\n aspect?: \"square\" | \"video\" | \"auto\";\n /** Props for the built-in lightbox (ignored with {@link onOpen}). */\n lightbox?: {\n loop?: boolean;\n zoom?: boolean;\n download?: boolean;\n onDownload?: (item: ImageItem, index: number) => void;\n labels?: Partial<LightboxLabels>;\n };\n labels?: Partial<ImageGridLabels>;\n}\n\nconst ASPECT_CLASS = {\n square: \"aspect-square\",\n video: \"aspect-video\",\n auto: \"aspect-[4/3]\",\n} as const;\n\n/**\n * A grid of thumbnails that open in a {@link Lightbox} — kastlan's room and defect\n * photos (room-inspector, unit-rooms-tab) and its floor plans (unit-floor-plan), each\n * of which drew its own `grid grid-cols-N` of `<img>` that nothing could open, with no\n * loading state and nowhere to put a delete.\n *\n * Each tile is a real `<button>` named by the image's alt, with the actions beside it\n * and the caption below it — outside the button, so the name stays the alt and the\n * caption is read once. Placeholders come from {@link AuthedImage}: a skeleton while\n * loading, a \"couldn't load\" tile on failure.\n */\nexport function ImageGrid({\n items,\n fetcher,\n renderActions,\n onOpen,\n columns,\n minTileSize = 96,\n aspect = \"square\",\n lightbox,\n labels: labelsProp,\n className,\n style,\n ...rest\n}: ImageGridProps) {\n const labels = useKitLabels(\"imageGrid\", DEFAULT_IMAGE_GRID_LABELS, labelsProp);\n const [openIndex, setOpenIndex] = useState<number | null>(null);\n const count = items.length;\n\n const gridStyle: CSSProperties = {\n gridTemplateColumns:\n columns !== undefined\n ? `repeat(${columns}, minmax(0, 1fr))`\n : `repeat(auto-fill, minmax(min(${minTileSize}px, 100%), 1fr))`,\n ...style,\n };\n\n return (\n <>\n <ul\n aria-label={labels.list}\n {...rest}\n className={cn(\"grid gap-2\", className)}\n style={gridStyle}\n >\n {items.map((item, i) => {\n const name = item.alt || labels.item(i + 1, count);\n const kind = imageItemKind(item);\n const actions = renderActions?.(item, i);\n return (\n <li key={item.key ?? item.src} className=\"relative min-w-0\">\n <button\n type=\"button\"\n aria-label={name}\n onClick={() => (onOpen ? onOpen(i) : setOpenIndex(i))}\n className={cn(\n \"block w-full overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-hover)]\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--brand)]\",\n ASPECT_CLASS[aspect],\n )}\n >\n {kind === \"file\" ? (\n <span className=\"flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-[var(--text-secondary)]\">\n <FileText aria-hidden className=\"size-6 shrink-0 text-[var(--text-muted)]\" />\n <span aria-hidden className=\"line-clamp-2 break-all\">{item.fileName ?? item.alt}</span>\n </span>\n ) : (\n <AuthedImage\n src={item.thumbnailSrc ?? item.src}\n // The button is named already; the picture inside it is decoration\n // to a reader, or the name would be read twice.\n alt=\"\"\n fetcher={fetcher}\n loading=\"lazy\"\n wrapperClassName=\"h-full w-full\"\n className={cn(\"h-full w-full\", aspect === \"auto\" ? \"object-contain\" : \"object-cover\")}\n />\n )}\n </button>\n {actions !== undefined && actions !== null && actions !== false && (\n <div\n role=\"group\"\n aria-label={labels.actions(name)}\n className=\"absolute end-1 top-1 flex gap-1\"\n >\n {actions}\n </div>\n )}\n {item.caption !== undefined && item.caption !== null && item.caption !== false && (\n <div className=\"mt-1 line-clamp-2 text-xs text-[var(--text-secondary)]\">{item.caption}</div>\n )}\n </li>\n );\n })}\n </ul>\n {!onOpen && (\n <Lightbox\n open={openIndex !== null}\n onClose={() => setOpenIndex(null)}\n items={items}\n index={openIndex ?? 0}\n onIndexChange={setOpenIndex}\n fetcher={fetcher}\n {...lightbox}\n />\n )}\n </>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { AuthedFetcher } from '../hooks/use-authed-src.js';
|
|
4
|
+
|
|
5
|
+
/** `image`: shown as a picture. `file`: anything a browser cannot put in an `<img>` —
|
|
6
|
+
* a floor plan uploaded as a PDF — shown as a file card with open and download. */
|
|
7
|
+
type ImageItemKind = "image" | "file";
|
|
8
|
+
interface ImageItem {
|
|
9
|
+
/** React key. Defaults to `src`. */
|
|
10
|
+
key?: string | number;
|
|
11
|
+
/** The full-size file — what the lightbox shows and downloads. An API path when the
|
|
12
|
+
* component is given a `fetcher`. */
|
|
13
|
+
src: string;
|
|
14
|
+
/** A smaller rendition for the grid, when the server has one. Default: `src`. */
|
|
15
|
+
thumbnailSrc?: string;
|
|
16
|
+
/** Required: the picture's text alternative, and the open button's name. */
|
|
17
|
+
alt: string;
|
|
18
|
+
/** Shown under the thumbnail and at the foot of the lightbox. */
|
|
19
|
+
caption?: ReactNode;
|
|
20
|
+
/** The file's MIME type, when known (`"application/pdf"`). Decides {@link kind}. */
|
|
21
|
+
mimeType?: string;
|
|
22
|
+
/** Say it outright. Default: from {@link mimeType}, else from the file extension in
|
|
23
|
+
* `src`, else — for a fetched file — from the type the server sent back. */
|
|
24
|
+
kind?: ImageItemKind;
|
|
25
|
+
/** The name a download is saved under, and a file card's heading. */
|
|
26
|
+
fileName?: string;
|
|
27
|
+
}
|
|
28
|
+
/** What an item is, from what is known about it WITHOUT fetching it — see
|
|
29
|
+
* {@link ImageItem.kind}. `fetchedType` is the blob's type once it is in. */
|
|
30
|
+
declare function imageItemKind(item: ImageItem, fetchedType?: string): ImageItemKind;
|
|
31
|
+
interface LightboxLabels {
|
|
32
|
+
/** The dialog's accessible name. */
|
|
33
|
+
dialog: string;
|
|
34
|
+
close: string;
|
|
35
|
+
previous: string;
|
|
36
|
+
next: string;
|
|
37
|
+
/** The visible counter: "3 / 7". */
|
|
38
|
+
counter: (index: number, count: number) => string;
|
|
39
|
+
/** What a screen reader hears on each page turn: "Image 3 of 7". The alt follows. */
|
|
40
|
+
position: (index: number, count: number) => string;
|
|
41
|
+
download: string;
|
|
42
|
+
/** The zoom toggle (a pressed/not-pressed button — its name does not change). */
|
|
43
|
+
zoom: string;
|
|
44
|
+
/** A file card's note: the file has no picture to show. */
|
|
45
|
+
noPreview: string;
|
|
46
|
+
/** A file card's link to the browser's own viewer (a PDF opens in its viewer). */
|
|
47
|
+
openInNewTab: string;
|
|
48
|
+
}
|
|
49
|
+
declare const DEFAULT_LIGHTBOX_LABELS: LightboxLabels;
|
|
50
|
+
interface LightboxProps {
|
|
51
|
+
open: boolean;
|
|
52
|
+
/** The X, Escape and the Back gesture all call this. */
|
|
53
|
+
onClose: () => void;
|
|
54
|
+
items: readonly ImageItem[];
|
|
55
|
+
/** The item on screen (0-based). Controlled, so the grid — or a URL — can own it. */
|
|
56
|
+
index: number;
|
|
57
|
+
onIndexChange: (index: number) => void;
|
|
58
|
+
/** The app's authenticated GET (see {@link useAuthedSrc}); left out, `src` is
|
|
59
|
+
* loaded by the browser as usual. */
|
|
60
|
+
fetcher?: AuthedFetcher | null;
|
|
61
|
+
/** Previous from the first goes to the last, and next from the last to the first.
|
|
62
|
+
* Default `false`: the buttons at the ends are disabled, which says where the set
|
|
63
|
+
* ends. */
|
|
64
|
+
loop?: boolean;
|
|
65
|
+
/** Double-click (or double-tap) the picture, or the zoom button, to see it at full
|
|
66
|
+
* size and pan by scrolling. Default `true`. Pinch zoom is the browser's own. */
|
|
67
|
+
zoom?: boolean;
|
|
68
|
+
/** A download link in the header. Default `true`. */
|
|
69
|
+
download?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Download through the app instead — kastlan's floor plans have `openDocument(id)`,
|
|
72
|
+
* which knows the stored file name. Replaces the link with a button that calls this.
|
|
73
|
+
*/
|
|
74
|
+
onDownload?: (item: ImageItem, index: number) => void;
|
|
75
|
+
/** Push a history entry so Back closes the viewer. Default `true`; see
|
|
76
|
+
* `FullBleedDialog`'s `backCloses` for when to turn it off. */
|
|
77
|
+
backCloses?: boolean;
|
|
78
|
+
labels?: Partial<LightboxLabels>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A full-screen viewer for a set of images (and files that are not images).
|
|
82
|
+
*
|
|
83
|
+
* Built on {@link FullBleedDialog} rather than beside it, so the viewer has the kit's
|
|
84
|
+
* one implementation of everything an overlay owes the page: the focus trap, Escape,
|
|
85
|
+
* the Back gesture closing it instead of leaving the page (`useOverlayHistory`), the
|
|
86
|
+
* body-scroll lock and the enter/exit motion.
|
|
87
|
+
*
|
|
88
|
+
* Paging: the previous/next buttons, ←/→ (which swap in a right-to-left page — the
|
|
89
|
+
* direction is read at the point the viewer is rendered, since the dialog itself is
|
|
90
|
+
* portalled out of it), Home/End, and a horizontal swipe on a touch screen.
|
|
91
|
+
*
|
|
92
|
+
* Zoom is a TOGGLE, not a gesture: double-click (or the zoom button) shows the picture
|
|
93
|
+
* at its natural size and the body scrolls to pan. Pinch-to-zoom is not implemented —
|
|
94
|
+
* the browser's own page pinch still works — because a pinch handler would have to own
|
|
95
|
+
* every touch on the picture, and with it the swipe.
|
|
96
|
+
*
|
|
97
|
+
* A non-image (a PDF floor plan) is shown as a file card: its name, "no preview", a
|
|
98
|
+
* link that opens it in the browser's own viewer, and the download. An `<iframe>`
|
|
99
|
+
* preview was the alternative; phone browsers render a PDF in one inconsistently or
|
|
100
|
+
* not at all, and a card works everywhere.
|
|
101
|
+
*/
|
|
102
|
+
declare function Lightbox({ open, onClose, items, index: indexProp, onIndexChange, fetcher, loop, zoom: zoomEnabled, download, onDownload, backCloses, labels: labelsProp, }: LightboxProps): react.JSX.Element;
|
|
103
|
+
|
|
104
|
+
export { DEFAULT_LIGHTBOX_LABELS, type ImageItem, type ImageItemKind, Lightbox, type LightboxLabels, type LightboxProps, imageItemKind };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight, Download, ExternalLink, FileText, ZoomIn } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { horizontalStep } from "../lib/direction.js";
|
|
7
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
8
|
+
import { useAuthedSrc } from "../hooks/use-authed-src.js";
|
|
9
|
+
import { FullBleedDialog } from "./full-bleed-dialog.js";
|
|
10
|
+
import { IconButton, buttonClasses } from "./ui.js";
|
|
11
|
+
import { ImageStates, DEFAULT_AUTHED_IMAGE_LABELS } from "./authed-image.js";
|
|
12
|
+
import { useAnchorDir } from "./use-anchor-dir.js";
|
|
13
|
+
const FILE_EXTENSION = /\.(pdf|docx?|xlsx?|pptx?|odt|ods|zip|txt|csv|rtf)(?:[?#]|$)/i;
|
|
14
|
+
function imageItemKind(item, fetchedType) {
|
|
15
|
+
if (item.kind) return item.kind;
|
|
16
|
+
if (item.mimeType) return item.mimeType.startsWith("image/") ? "image" : "file";
|
|
17
|
+
if (FILE_EXTENSION.test(item.src)) return "file";
|
|
18
|
+
if (fetchedType && !fetchedType.startsWith("image/")) return "file";
|
|
19
|
+
return "image";
|
|
20
|
+
}
|
|
21
|
+
const DEFAULT_LIGHTBOX_LABELS = {
|
|
22
|
+
dialog: "Image viewer",
|
|
23
|
+
close: "Close",
|
|
24
|
+
previous: "Previous image",
|
|
25
|
+
next: "Next image",
|
|
26
|
+
counter: (index, count) => `${index} / ${count}`,
|
|
27
|
+
position: (index, count) => `Image ${index} of ${count}`,
|
|
28
|
+
download: "Download",
|
|
29
|
+
zoom: "Zoom",
|
|
30
|
+
noPreview: "No preview available for this file",
|
|
31
|
+
openInNewTab: "Open in new tab"
|
|
32
|
+
};
|
|
33
|
+
const SWIPE_MIN_PX = 50;
|
|
34
|
+
function Lightbox({
|
|
35
|
+
open,
|
|
36
|
+
onClose,
|
|
37
|
+
items,
|
|
38
|
+
index: indexProp,
|
|
39
|
+
onIndexChange,
|
|
40
|
+
fetcher,
|
|
41
|
+
loop = false,
|
|
42
|
+
zoom: zoomEnabled = true,
|
|
43
|
+
download = true,
|
|
44
|
+
onDownload,
|
|
45
|
+
backCloses,
|
|
46
|
+
labels: labelsProp
|
|
47
|
+
}) {
|
|
48
|
+
const labels = useKitLabels("lightbox", DEFAULT_LIGHTBOX_LABELS, labelsProp);
|
|
49
|
+
const imageLabels = useKitLabels("authedImage", DEFAULT_AUTHED_IMAGE_LABELS);
|
|
50
|
+
const count = items.length;
|
|
51
|
+
const index = count === 0 ? 0 : Math.min(Math.max(indexProp, 0), count - 1);
|
|
52
|
+
const item = items[index];
|
|
53
|
+
const anchorRef = useRef(null);
|
|
54
|
+
const dir = useAnchorDir(anchorRef, open);
|
|
55
|
+
const fetched = useAuthedSrc(item?.src, { fetcher, enabled: open });
|
|
56
|
+
const kind = item ? imageItemKind(item, fetched.type) : "image";
|
|
57
|
+
const [zoomedKey, setZoomedKey] = useState(null);
|
|
58
|
+
const itemKey = item ? String(item.key ?? item.src) : null;
|
|
59
|
+
const zoomed = zoomEnabled && kind === "image" && zoomedKey !== null && zoomedKey === itemKey;
|
|
60
|
+
const toggleZoom = () => setZoomedKey(zoomed ? null : itemKey);
|
|
61
|
+
const canPrev = loop ? count > 1 : index > 0;
|
|
62
|
+
const canNext = loop ? count > 1 : index < count - 1;
|
|
63
|
+
const go = (step) => {
|
|
64
|
+
if (count === 0) return;
|
|
65
|
+
let next = index + step;
|
|
66
|
+
if (loop) next = (next + count) % count;
|
|
67
|
+
if (next < 0 || next >= count || next === index) return;
|
|
68
|
+
onIndexChange(next);
|
|
69
|
+
};
|
|
70
|
+
const onKeyDown = (e) => {
|
|
71
|
+
const target = e.target;
|
|
72
|
+
if (target.closest("input, textarea, select, [contenteditable='true']")) return;
|
|
73
|
+
const step = horizontalStep(e.key, e.currentTarget);
|
|
74
|
+
if (step !== 0) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
go(step);
|
|
77
|
+
} else if (e.key === "Home") {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
if (index !== 0) onIndexChange(0);
|
|
80
|
+
} else if (e.key === "End") {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
if (index !== count - 1) onIndexChange(count - 1);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const touchStart = useRef(null);
|
|
86
|
+
const onTouchStart = (e) => {
|
|
87
|
+
const t = e.touches[0];
|
|
88
|
+
touchStart.current = e.touches.length === 1 && t ? { x: t.clientX, y: t.clientY } : null;
|
|
89
|
+
};
|
|
90
|
+
const onTouchEnd = (e) => {
|
|
91
|
+
const start = touchStart.current;
|
|
92
|
+
touchStart.current = null;
|
|
93
|
+
const t = e.changedTouches[0];
|
|
94
|
+
if (!start || !t || zoomed) return;
|
|
95
|
+
const dx = t.clientX - start.x;
|
|
96
|
+
const dy = t.clientY - start.y;
|
|
97
|
+
if (Math.abs(dx) < SWIPE_MIN_PX || Math.abs(dx) < Math.abs(dy)) return;
|
|
98
|
+
const towardStart = dir === "rtl" ? dx > 0 : dx < 0;
|
|
99
|
+
go(towardStart ? 1 : -1);
|
|
100
|
+
};
|
|
101
|
+
const bodyRef = useRef(null);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
bodyRef.current?.scrollTo?.({ top: 0, left: 0 });
|
|
104
|
+
}, [index]);
|
|
105
|
+
const downloadHref = fetched.src;
|
|
106
|
+
const downloadControl = download && item ? onDownload ? /* @__PURE__ */ jsx(IconButton, { size: "sm", label: labels.download, onClick: () => onDownload(item, index), children: /* @__PURE__ */ jsx(Download, {}) }) : downloadHref ? /* @__PURE__ */ jsxs(
|
|
107
|
+
"a",
|
|
108
|
+
{
|
|
109
|
+
href: downloadHref,
|
|
110
|
+
download: item.fileName ?? "",
|
|
111
|
+
className: buttonClasses("ghost", { size: "sm" }),
|
|
112
|
+
children: [
|
|
113
|
+
/* @__PURE__ */ jsx(Download, { "aria-hidden": true, className: "size-4" }),
|
|
114
|
+
/* @__PURE__ */ jsx("span", { className: "max-sm:sr-only", children: labels.download })
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
) : null : null;
|
|
118
|
+
const header = item ? /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [
|
|
119
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": true, className: "shrink-0 text-sm tabular-nums text-[var(--text-secondary)]", children: labels.counter(index + 1, count) }),
|
|
120
|
+
/* @__PURE__ */ jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: `${labels.position(index + 1, count)}${item.alt ? `: ${item.alt}` : ""}` }),
|
|
121
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1" }),
|
|
122
|
+
zoomEnabled && kind === "image" && /* @__PURE__ */ jsx(IconButton, { size: "sm", label: labels.zoom, pressed: zoomed, onClick: toggleZoom, children: /* @__PURE__ */ jsx(ZoomIn, {}) }),
|
|
123
|
+
downloadControl
|
|
124
|
+
] }) : null;
|
|
125
|
+
const slide = !item ? null : kind === "file" ? /* @__PURE__ */ jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 rounded-lg border border-[var(--border)] p-6 text-center", children: [
|
|
126
|
+
/* @__PURE__ */ jsx(FileText, { "aria-hidden": true, className: "size-10 text-[var(--text-muted)]" }),
|
|
127
|
+
/* @__PURE__ */ jsx("p", { className: "break-all font-medium text-[var(--text-primary)]", children: item.fileName ?? item.alt }),
|
|
128
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-[var(--text-muted)]", children: labels.noPreview }),
|
|
129
|
+
fetched.src && /* @__PURE__ */ jsxs(
|
|
130
|
+
"a",
|
|
131
|
+
{
|
|
132
|
+
href: fetched.src,
|
|
133
|
+
target: "_blank",
|
|
134
|
+
rel: "noreferrer noopener",
|
|
135
|
+
className: buttonClasses("secondary", { size: "sm" }),
|
|
136
|
+
children: [
|
|
137
|
+
/* @__PURE__ */ jsx(ExternalLink, { "aria-hidden": true, className: "size-4" }),
|
|
138
|
+
labels.openInNewTab
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
] }) : /* @__PURE__ */ jsx(
|
|
143
|
+
ImageStates,
|
|
144
|
+
{
|
|
145
|
+
fetched,
|
|
146
|
+
alt: item.alt,
|
|
147
|
+
labels: imageLabels,
|
|
148
|
+
wrapperClassName: zoomed ? "max-w-none shrink-0" : "flex h-full w-full items-center justify-center",
|
|
149
|
+
className: cn(
|
|
150
|
+
zoomed ? "max-w-none cursor-zoom-out" : "max-h-full max-w-full object-contain",
|
|
151
|
+
!zoomed && zoomEnabled && "cursor-zoom-in"
|
|
152
|
+
),
|
|
153
|
+
imgProps: {
|
|
154
|
+
draggable: false,
|
|
155
|
+
onDoubleClick: zoomEnabled ? toggleZoom : void 0
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
itemKey ?? void 0
|
|
159
|
+
);
|
|
160
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
161
|
+
/* @__PURE__ */ jsx("span", { ref: anchorRef, hidden: true }),
|
|
162
|
+
/* @__PURE__ */ jsxs(
|
|
163
|
+
FullBleedDialog,
|
|
164
|
+
{
|
|
165
|
+
open: open && !!item,
|
|
166
|
+
onClose,
|
|
167
|
+
closeLabel: labels.close,
|
|
168
|
+
"aria-label": labels.dialog,
|
|
169
|
+
dir,
|
|
170
|
+
header,
|
|
171
|
+
backCloses,
|
|
172
|
+
onKeyDown,
|
|
173
|
+
"data-lightbox": "",
|
|
174
|
+
footer: item?.caption !== void 0 && item.caption !== null && item.caption !== false ? /* @__PURE__ */ jsx("p", { className: "me-auto min-w-0 text-sm text-[var(--text-secondary)]", children: item.caption }) : void 0,
|
|
175
|
+
children: [
|
|
176
|
+
/* @__PURE__ */ jsx(
|
|
177
|
+
"div",
|
|
178
|
+
{
|
|
179
|
+
ref: bodyRef,
|
|
180
|
+
"data-lightbox-stage": "",
|
|
181
|
+
"data-zoomed": zoomed ? "" : void 0,
|
|
182
|
+
onTouchStart,
|
|
183
|
+
onTouchEnd,
|
|
184
|
+
className: cn(
|
|
185
|
+
"relative h-full min-h-48 w-full",
|
|
186
|
+
zoomed ? "overflow-auto" : "flex items-center justify-center overflow-hidden"
|
|
187
|
+
),
|
|
188
|
+
children: slide
|
|
189
|
+
}
|
|
190
|
+
),
|
|
191
|
+
count > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
192
|
+
/* @__PURE__ */ jsx(
|
|
193
|
+
IconButton,
|
|
194
|
+
{
|
|
195
|
+
variant: "overlay",
|
|
196
|
+
size: "lg",
|
|
197
|
+
label: labels.previous,
|
|
198
|
+
disabled: !canPrev,
|
|
199
|
+
onClick: () => go(-1),
|
|
200
|
+
className: "absolute start-3 top-1/2 -translate-y-1/2",
|
|
201
|
+
tooltip: false,
|
|
202
|
+
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "rtl:-scale-x-100" })
|
|
203
|
+
}
|
|
204
|
+
),
|
|
205
|
+
/* @__PURE__ */ jsx(
|
|
206
|
+
IconButton,
|
|
207
|
+
{
|
|
208
|
+
variant: "overlay",
|
|
209
|
+
size: "lg",
|
|
210
|
+
label: labels.next,
|
|
211
|
+
disabled: !canNext,
|
|
212
|
+
onClick: () => go(1),
|
|
213
|
+
className: "absolute end-3 top-1/2 -translate-y-1/2",
|
|
214
|
+
tooltip: false,
|
|
215
|
+
children: /* @__PURE__ */ jsx(ChevronRight, { className: "rtl:-scale-x-100" })
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
] })
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
] });
|
|
223
|
+
}
|
|
224
|
+
export {
|
|
225
|
+
DEFAULT_LIGHTBOX_LABELS,
|
|
226
|
+
Lightbox,
|
|
227
|
+
imageItemKind
|
|
228
|
+
};
|
|
229
|
+
//# sourceMappingURL=lightbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/lightbox.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode, TouchEvent } from \"react\";\nimport { ChevronLeft, ChevronRight, Download, ExternalLink, FileText, ZoomIn } from \"lucide-react\";\n\nimport { cn } from \"../lib/cn\";\nimport { horizontalStep } from \"../lib/direction\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { useAuthedSrc } from \"../hooks/use-authed-src\";\nimport type { AuthedFetcher } from \"../hooks/use-authed-src\";\nimport { FullBleedDialog } from \"./full-bleed-dialog\";\nimport { IconButton, buttonClasses } from \"./ui\";\nimport { ImageStates, DEFAULT_AUTHED_IMAGE_LABELS } from \"./authed-image\";\nimport { useAnchorDir } from \"./use-anchor-dir\";\n\n/* ── The item both the grid and the lightbox show ────────────────────────── */\n\n/** `image`: shown as a picture. `file`: anything a browser cannot put in an `<img>` —\n * a floor plan uploaded as a PDF — shown as a file card with open and download. */\nexport type ImageItemKind = \"image\" | \"file\";\n\nexport interface ImageItem {\n /** React key. Defaults to `src`. */\n key?: string | number;\n /** The full-size file — what the lightbox shows and downloads. An API path when the\n * component is given a `fetcher`. */\n src: string;\n /** A smaller rendition for the grid, when the server has one. Default: `src`. */\n thumbnailSrc?: string;\n /** Required: the picture's text alternative, and the open button's name. */\n alt: string;\n /** Shown under the thumbnail and at the foot of the lightbox. */\n caption?: ReactNode;\n /** The file's MIME type, when known (`\"application/pdf\"`). Decides {@link kind}. */\n mimeType?: string;\n /** Say it outright. Default: from {@link mimeType}, else from the file extension in\n * `src`, else — for a fetched file — from the type the server sent back. */\n kind?: ImageItemKind;\n /** The name a download is saved under, and a file card's heading. */\n fileName?: string;\n}\n\nconst FILE_EXTENSION = /\\.(pdf|docx?|xlsx?|pptx?|odt|ods|zip|txt|csv|rtf)(?:[?#]|$)/i;\n\n/** What an item is, from what is known about it WITHOUT fetching it — see\n * {@link ImageItem.kind}. `fetchedType` is the blob's type once it is in. */\nexport function imageItemKind(item: ImageItem, fetchedType?: string): ImageItemKind {\n if (item.kind) return item.kind;\n if (item.mimeType) return item.mimeType.startsWith(\"image/\") ? \"image\" : \"file\";\n if (FILE_EXTENSION.test(item.src)) return \"file\";\n if (fetchedType && !fetchedType.startsWith(\"image/\")) return \"file\";\n return \"image\";\n}\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\nexport interface LightboxLabels {\n /** The dialog's accessible name. */\n dialog: string;\n close: string;\n previous: string;\n next: string;\n /** The visible counter: \"3 / 7\". */\n counter: (index: number, count: number) => string;\n /** What a screen reader hears on each page turn: \"Image 3 of 7\". The alt follows. */\n position: (index: number, count: number) => string;\n download: string;\n /** The zoom toggle (a pressed/not-pressed button — its name does not change). */\n zoom: string;\n /** A file card's note: the file has no picture to show. */\n noPreview: string;\n /** A file card's link to the browser's own viewer (a PDF opens in its viewer). */\n openInNewTab: string;\n}\n\nexport const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {\n dialog: \"Image viewer\",\n close: \"Close\",\n previous: \"Previous image\",\n next: \"Next image\",\n counter: (index, count) => `${index} / ${count}`,\n position: (index, count) => `Image ${index} of ${count}`,\n download: \"Download\",\n zoom: \"Zoom\",\n noPreview: \"No preview available for this file\",\n openInNewTab: \"Open in new tab\",\n};\n\n/* ── The component ───────────────────────────────────────────────────────── */\n\nexport interface LightboxProps {\n open: boolean;\n /** The X, Escape and the Back gesture all call this. */\n onClose: () => void;\n items: readonly ImageItem[];\n /** The item on screen (0-based). Controlled, so the grid — or a URL — can own it. */\n index: number;\n onIndexChange: (index: number) => void;\n /** The app's authenticated GET (see {@link useAuthedSrc}); left out, `src` is\n * loaded by the browser as usual. */\n fetcher?: AuthedFetcher | null;\n /** Previous from the first goes to the last, and next from the last to the first.\n * Default `false`: the buttons at the ends are disabled, which says where the set\n * ends. */\n loop?: boolean;\n /** Double-click (or double-tap) the picture, or the zoom button, to see it at full\n * size and pan by scrolling. Default `true`. Pinch zoom is the browser's own. */\n zoom?: boolean;\n /** A download link in the header. Default `true`. */\n download?: boolean;\n /**\n * Download through the app instead — kastlan's floor plans have `openDocument(id)`,\n * which knows the stored file name. Replaces the link with a button that calls this.\n */\n onDownload?: (item: ImageItem, index: number) => void;\n /** Push a history entry so Back closes the viewer. Default `true`; see\n * `FullBleedDialog`'s `backCloses` for when to turn it off. */\n backCloses?: boolean;\n labels?: Partial<LightboxLabels>;\n}\n\n/** Swipes shorter than this are taps, and a drag that is mostly vertical is a scroll. */\nconst SWIPE_MIN_PX = 50;\n\n/**\n * A full-screen viewer for a set of images (and files that are not images).\n *\n * Built on {@link FullBleedDialog} rather than beside it, so the viewer has the kit's\n * one implementation of everything an overlay owes the page: the focus trap, Escape,\n * the Back gesture closing it instead of leaving the page (`useOverlayHistory`), the\n * body-scroll lock and the enter/exit motion.\n *\n * Paging: the previous/next buttons, ←/→ (which swap in a right-to-left page — the\n * direction is read at the point the viewer is rendered, since the dialog itself is\n * portalled out of it), Home/End, and a horizontal swipe on a touch screen.\n *\n * Zoom is a TOGGLE, not a gesture: double-click (or the zoom button) shows the picture\n * at its natural size and the body scrolls to pan. Pinch-to-zoom is not implemented —\n * the browser's own page pinch still works — because a pinch handler would have to own\n * every touch on the picture, and with it the swipe.\n *\n * A non-image (a PDF floor plan) is shown as a file card: its name, \"no preview\", a\n * link that opens it in the browser's own viewer, and the download. An `<iframe>`\n * preview was the alternative; phone browsers render a PDF in one inconsistently or\n * not at all, and a card works everywhere.\n */\nexport function Lightbox({\n open,\n onClose,\n items,\n index: indexProp,\n onIndexChange,\n fetcher,\n loop = false,\n zoom: zoomEnabled = true,\n download = true,\n onDownload,\n backCloses,\n labels: labelsProp,\n}: LightboxProps) {\n const labels = useKitLabels(\"lightbox\", DEFAULT_LIGHTBOX_LABELS, labelsProp);\n const imageLabels = useKitLabels(\"authedImage\", DEFAULT_AUTHED_IMAGE_LABELS);\n const count = items.length;\n // Clamped: the set can shrink under an open viewer (the photo on screen was deleted).\n const index = count === 0 ? 0 : Math.min(Math.max(indexProp, 0), count - 1);\n const item = items[index] as ImageItem | undefined;\n\n // The dialog is portalled to <body>, out of the subtree whose `dir` it would have\n // inherited; this marker stays where the viewer was rendered and reads it there.\n const anchorRef = useRef<HTMLSpanElement>(null);\n const dir = useAnchorDir(anchorRef, open);\n\n const fetched = useAuthedSrc(item?.src, { fetcher, enabled: open });\n const kind = item ? imageItemKind(item, fetched.type) : \"image\";\n\n const [zoomedKey, setZoomedKey] = useState<string | null>(null);\n const itemKey = item ? String(item.key ?? item.src) : null;\n // Zoom belongs to the picture it was turned on for: paging on resets it.\n const zoomed = zoomEnabled && kind === \"image\" && zoomedKey !== null && zoomedKey === itemKey;\n const toggleZoom = () => setZoomedKey(zoomed ? null : itemKey);\n\n const canPrev = loop ? count > 1 : index > 0;\n const canNext = loop ? count > 1 : index < count - 1;\n const go = (step: number) => {\n if (count === 0) return;\n let next = index + step;\n if (loop) next = (next + count) % count;\n if (next < 0 || next >= count || next === index) return;\n onIndexChange(next);\n };\n\n const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n // A key meant for a control stays with it: Space/Enter on a button, typing in any\n // field a caller's caption might hold.\n const target = e.target as HTMLElement;\n if (target.closest(\"input, textarea, select, [contenteditable='true']\")) return;\n const step = horizontalStep(e.key, e.currentTarget);\n if (step !== 0) {\n e.preventDefault();\n go(step);\n } else if (e.key === \"Home\") {\n e.preventDefault();\n if (index !== 0) onIndexChange(0);\n } else if (e.key === \"End\") {\n e.preventDefault();\n if (index !== count - 1) onIndexChange(count - 1);\n }\n };\n\n const touchStart = useRef<{ x: number; y: number } | null>(null);\n const onTouchStart = (e: TouchEvent) => {\n const t = e.touches[0];\n touchStart.current = e.touches.length === 1 && t ? { x: t.clientX, y: t.clientY } : null;\n };\n const onTouchEnd = (e: TouchEvent) => {\n const start = touchStart.current;\n touchStart.current = null;\n const t = e.changedTouches[0];\n // Zoomed, a drag is a PAN of the picture, not a page turn.\n if (!start || !t || zoomed) return;\n const dx = t.clientX - start.x;\n const dy = t.clientY - start.y;\n if (Math.abs(dx) < SWIPE_MIN_PX || Math.abs(dx) < Math.abs(dy)) return;\n // Swiping toward the START edge brings in the next picture from the end edge.\n const towardStart = dir === \"rtl\" ? dx > 0 : dx < 0;\n go(towardStart ? 1 : -1);\n };\n\n // Paging the viewer while zoomed would leave the new picture scrolled to where the\n // last one was panned; start each at its top-left.\n const bodyRef = useRef<HTMLDivElement>(null);\n useEffect(() => {\n bodyRef.current?.scrollTo?.({ top: 0, left: 0 });\n }, [index]);\n\n const downloadHref = fetched.src;\n const downloadControl =\n download && item ? (\n onDownload ? (\n <IconButton size=\"sm\" label={labels.download} onClick={() => onDownload(item, index)}>\n <Download />\n </IconButton>\n ) : downloadHref ? (\n <a\n href={downloadHref}\n // `download` names the saved file; a blob: URL has no name of its own.\n download={item.fileName ?? \"\"}\n className={buttonClasses(\"ghost\", { size: \"sm\" })}\n >\n <Download aria-hidden className=\"size-4\" />\n <span className=\"max-sm:sr-only\">{labels.download}</span>\n </a>\n ) : null\n ) : null;\n\n const header = item ? (\n <div className=\"flex min-w-0 items-center gap-2\">\n <span aria-hidden className=\"shrink-0 text-sm tabular-nums text-[var(--text-secondary)]\">\n {labels.counter(index + 1, count)}\n </span>\n {/* The page turn, said — focus does not move when the picture changes, so\n without this a reader pressing → hears nothing at all. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {`${labels.position(index + 1, count)}${item.alt ? `: ${item.alt}` : \"\"}`}\n </span>\n <span className=\"min-w-0 flex-1\" />\n {zoomEnabled && kind === \"image\" && (\n <IconButton size=\"sm\" label={labels.zoom} pressed={zoomed} onClick={toggleZoom}>\n <ZoomIn />\n </IconButton>\n )}\n {downloadControl}\n </div>\n ) : null;\n\n const slide = !item ? null : kind === \"file\" ? (\n <div className=\"flex max-w-sm flex-col items-center gap-3 rounded-lg border border-[var(--border)] p-6 text-center\">\n <FileText aria-hidden className=\"size-10 text-[var(--text-muted)]\" />\n <p className=\"break-all font-medium text-[var(--text-primary)]\">{item.fileName ?? item.alt}</p>\n <p className=\"text-sm text-[var(--text-muted)]\">{labels.noPreview}</p>\n {fetched.src && (\n <a\n href={fetched.src}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n className={buttonClasses(\"secondary\", { size: \"sm\" })}\n >\n <ExternalLink aria-hidden className=\"size-4\" />\n {labels.openInNewTab}\n </a>\n )}\n </div>\n ) : (\n <ImageStates\n // Keyed by item: a new picture is a new decode, never the old one's \"loaded\".\n key={itemKey ?? undefined}\n fetched={fetched}\n alt={item.alt}\n labels={imageLabels}\n wrapperClassName={\n zoomed ? \"max-w-none shrink-0\" : \"flex h-full w-full items-center justify-center\"\n }\n className={cn(\n zoomed ? \"max-w-none cursor-zoom-out\" : \"max-h-full max-w-full object-contain\",\n !zoomed && zoomEnabled && \"cursor-zoom-in\",\n )}\n imgProps={{\n draggable: false,\n onDoubleClick: zoomEnabled ? toggleZoom : undefined,\n }}\n />\n );\n\n return (\n <>\n <span ref={anchorRef} hidden />\n <FullBleedDialog\n open={open && !!item}\n onClose={onClose}\n closeLabel={labels.close}\n aria-label={labels.dialog}\n dir={dir}\n header={header}\n backCloses={backCloses}\n onKeyDown={onKeyDown}\n data-lightbox=\"\"\n footer={\n item?.caption !== undefined && item.caption !== null && item.caption !== false ? (\n <p className=\"me-auto min-w-0 text-sm text-[var(--text-secondary)]\">{item.caption}</p>\n ) : undefined\n }\n >\n <div\n ref={bodyRef}\n data-lightbox-stage=\"\"\n data-zoomed={zoomed ? \"\" : undefined}\n onTouchStart={onTouchStart}\n onTouchEnd={onTouchEnd}\n className={cn(\n \"relative h-full min-h-48 w-full\",\n zoomed ? \"overflow-auto\" : \"flex items-center justify-center overflow-hidden\",\n )}\n >\n {slide}\n </div>\n {count > 1 && (\n <>\n <IconButton\n variant=\"overlay\"\n size=\"lg\"\n label={labels.previous}\n disabled={!canPrev}\n onClick={() => go(-1)}\n className=\"absolute start-3 top-1/2 -translate-y-1/2\"\n tooltip={false}\n >\n <ChevronLeft className=\"rtl:-scale-x-100\" />\n </IconButton>\n <IconButton\n variant=\"overlay\"\n size=\"lg\"\n label={labels.next}\n disabled={!canNext}\n onClick={() => go(1)}\n className=\"absolute end-3 top-1/2 -translate-y-1/2\"\n tooltip={false}\n >\n <ChevronRight className=\"rtl:-scale-x-100\" />\n </IconButton>\n </>\n )}\n </FullBleedDialog>\n </>\n );\n}\n"],"mappings":"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","names":[]}
|