@eifi1/ui-kit 0.46.1 → 0.47.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 +5 -5
- package/dist/components/data-table.js +18 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/row-actions.d.ts +25 -0
- package/dist/components/row-actions.js +13 -3
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/tabs.d.ts +24 -16
- package/dist/components/tabs.js +37 -31
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/ui-labels.js +1 -1
- package/dist/components/ui-labels.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-attachment-download.d.ts +19 -0
- package/dist/feedback/feedback-attachment-download.js +59 -0
- package/dist/feedback/feedback-attachment-download.js.map +1 -0
- package/dist/feedback/feedback-comment-thread.d.ts +17 -6
- package/dist/feedback/feedback-comment-thread.js +21 -2
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-entry-file.d.ts +61 -0
- package/dist/feedback/feedback-entry-file.js +76 -0
- package/dist/feedback/feedback-entry-file.js.map +1 -0
- package/dist/feedback/feedback-public-list.d.ts +18 -5
- package/dist/feedback/feedback-public-list.js +8 -0
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-public-types.d.ts +7 -2
- package/dist/feedback/feedback-replies.d.ts +26 -9
- package/dist/feedback/feedback-replies.js +20 -10
- package/dist/feedback/feedback-replies.js.map +1 -1
- package/dist/feedback/feedback-row-detail-labels.js +0 -1
- package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +23 -10
- package/dist/feedback/feedback-row-detail.js +10 -54
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback.d.ts +1 -0
- package/dist/feedback.js +1 -0
- package/dist/feedback.js.map +1 -1
- package/dist/i18n/german.js +1 -6
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +1 -6
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +1 -6
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +1 -6
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +1 -6
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +1 -6
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +1 -6
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +0 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.js +11 -4
- package/dist/index.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +1 -1
- package/dist/notifications/device-zone-report.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +0 -3
- package/dist/notifications/notifications-labels.d.ts +2 -15
- package/dist/notifications/notifications-labels.js +0 -4
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/quiet-hours-setting.d.ts +3 -8
- package/dist/notifications/quiet-hours-setting.js +8 -3
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/theme/theme-store.d.ts +4 -4
- package/dist/theme/theme-store.js +1 -0
- package/dist/theme/theme-store.js.map +1 -1
- package/package.json +2 -2
- package/src/components/data-table.tsx +29 -3
- package/src/components/row-actions.tsx +42 -3
- package/src/components/tabs.tsx +66 -44
- package/src/components/ui-labels.ts +1 -1
- package/src/feedback/feedback-attachment-download.tsx +77 -0
- package/src/feedback/feedback-comment-thread.tsx +42 -7
- package/src/feedback/feedback-entry-file.tsx +150 -0
- package/src/feedback/feedback-public-list.tsx +25 -4
- package/src/feedback/feedback-public-types.ts +7 -2
- package/src/feedback/feedback-replies.tsx +46 -19
- package/src/feedback/feedback-row-detail-labels.ts +0 -1
- package/src/feedback/feedback-row-detail.tsx +35 -86
- package/src/feedback.ts +1 -0
- package/src/i18n/german.ts +1 -6
- package/src/i18n/locales/es.ts +1 -6
- package/src/i18n/locales/fr.ts +1 -6
- package/src/i18n/locales/hu.ts +1 -6
- package/src/i18n/locales/it.ts +1 -6
- package/src/i18n/locales/zh.ts +1 -6
- package/src/i18n/review.ts +0 -1
- package/src/index.ts +10 -3
- package/src/notifications/device-zone-report.ts +1 -1
- package/src/notifications/notification-types.ts +0 -3
- package/src/notifications/notifications-labels.ts +2 -19
- package/src/notifications/quiet-hours-setting.tsx +16 -11
- package/src/theme/theme-store.ts +13 -5
- package/dist/notifications/time-zone-select.d.ts +0 -54
- package/dist/notifications/time-zone-select.js +0 -71
- package/dist/notifications/time-zone-select.js.map +0 -1
- package/src/notifications/time-zone-select.tsx +0 -125
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\nimport { zoneName } from \"./time-zone-select\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /** The zone the 0.36 card showed, an IANA name, or `null`.\n * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.\n * Shown read-only while `zone` is left out; the card no longer offers a picker. */\n timezone?: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while neither `zone` nor `timezone` names one: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else\n * `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n timezone = null,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\n/** A zone for a sentence: \"Europe / Zurich\", the region in the kit's words (`labels.regions`);\n * a region the labels do not know keeps its IANA name, a zone with none (\"UTC\") is itself. */\nfunction zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n const named = Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : region;\n return `${named} / ${zone.slice(slash + 1).replace(/_/g, \" \").replace(/\\//g, \" / \")}`;\n}\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while `zone` names none: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = zone ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -16,10 +16,10 @@ export interface ThemeState {
|
|
|
16
16
|
/**
|
|
17
17
|
* Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight
|
|
18
18
|
* from localStorage, resolve it to a concrete light/dark mode, and toggle the
|
|
19
|
-
* `.dark` class on <html> — BEFORE React and the store mount,
|
|
20
|
-
* already has the right theme (no flash). Returns the resolved
|
|
21
|
-
* can chain {@link applyPersistedPalette}. Encapsulates the
|
|
22
|
-
* envelope + the legacy `mode`→`preference` fallback so apps never re-type the
|
|
19
|
+
* `.dark` class (and `color-scheme`) on <html> — BEFORE React and the store mount,
|
|
20
|
+
* so the first paint already has the right theme (no flash). Returns the resolved
|
|
21
|
+
* mode so the caller can chain {@link applyPersistedPalette}. Encapsulates the
|
|
22
|
+
* zustand-persist envelope + the legacy `mode`→`preference` fallback so apps never re-type the
|
|
23
23
|
* storage shape in their entrypoint.
|
|
24
24
|
*/
|
|
25
25
|
export declare function applyPersistedTheme(storageKey: string): ThemeMode;
|
|
@@ -25,6 +25,7 @@ function applyThemeClass(mode) {
|
|
|
25
25
|
const root = document.documentElement;
|
|
26
26
|
if (mode === "dark") root.classList.add("dark");
|
|
27
27
|
else root.classList.remove("dark");
|
|
28
|
+
root.style.colorScheme = mode;
|
|
28
29
|
}
|
|
29
30
|
function applyPersistedTheme(storageKey) {
|
|
30
31
|
let preference = "system";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/theme/theme-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\n\nexport type ThemeMode = \"light\" | \"dark\";\n/** What the user chose; \"system\" tracks the OS `prefers-color-scheme`. */\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeState {\n /** The user's choice (persisted). */\n preference: ThemePreference;\n /** The RESOLVED light/dark actually in effect (system → OS). Read this for\n * anything that needs the concrete theme (e.g. token selection, toasts). */\n mode: ThemeMode;\n setPreference: (preference: ThemePreference) => void;\n /** Set an explicit light/dark choice (preference follows). */\n setMode: (mode: ThemeMode) => void;\n /** Flip the effective theme to the opposite explicit choice. */\n toggle: () => void;\n}\n\nfunction systemPreference(): ThemeMode {\n try {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n } catch {\n return \"light\";\n }\n}\n\nfunction resolve(preference: ThemePreference): ThemeMode {\n return preference === \"system\" ? systemPreference() : preference;\n}\n\n/** Normalize a persisted theme-state blob to a valid preference. Older blobs\n * persisted the concrete `mode` (light/dark); carry that over as an explicit\n * preference so the choice survives. Shared by the store's `migrate` and the\n * pre-hydration {@link applyPersistedTheme}. */\nfunction preferenceFromState(\n s: { mode?: string; preference?: string } | null | undefined,\n): ThemePreference {\n if (s?.preference === \"light\" || s?.preference === \"dark\" || s?.preference === \"system\") {\n return s.preference;\n }\n if (s?.mode === \"light\" || s?.mode === \"dark\") return s.mode;\n return \"system\";\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../../src/theme/theme-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\n\nexport type ThemeMode = \"light\" | \"dark\";\n/** What the user chose; \"system\" tracks the OS `prefers-color-scheme`. */\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeState {\n /** The user's choice (persisted). */\n preference: ThemePreference;\n /** The RESOLVED light/dark actually in effect (system → OS). Read this for\n * anything that needs the concrete theme (e.g. token selection, toasts). */\n mode: ThemeMode;\n setPreference: (preference: ThemePreference) => void;\n /** Set an explicit light/dark choice (preference follows). */\n setMode: (mode: ThemeMode) => void;\n /** Flip the effective theme to the opposite explicit choice. */\n toggle: () => void;\n}\n\nfunction systemPreference(): ThemeMode {\n try {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n } catch {\n return \"light\";\n }\n}\n\nfunction resolve(preference: ThemePreference): ThemeMode {\n return preference === \"system\" ? systemPreference() : preference;\n}\n\n/** Normalize a persisted theme-state blob to a valid preference. Older blobs\n * persisted the concrete `mode` (light/dark); carry that over as an explicit\n * preference so the choice survives. Shared by the store's `migrate` and the\n * pre-hydration {@link applyPersistedTheme}. */\nfunction preferenceFromState(\n s: { mode?: string; preference?: string } | null | undefined,\n): ThemePreference {\n if (s?.preference === \"light\" || s?.preference === \"dark\" || s?.preference === \"system\") {\n return s.preference;\n }\n if (s?.mode === \"light\" || s?.mode === \"dark\") return s.mode;\n return \"system\";\n}\n\n/**\n * Toggle the `.dark` class on <html> for a resolved light/dark mode, and say the same\n * to the BROWSER through `color-scheme` (0.47, keksdose dev #600): the class switches\n * the kit's tokens, but everything the browser paints itself — a native `<select>`'s\n * open list, a date input's popup, scrollbars, autofill — follows `color-scheme` only,\n * and stayed light on the dark theme. Inline on <html>, so it holds whatever an app's\n * stylesheet says, and it is set by the pre-paint {@link applyPersistedTheme} too.\n */\nfunction applyThemeClass(mode: ThemeMode): void {\n const root = document.documentElement;\n if (mode === \"dark\") root.classList.add(\"dark\");\n else root.classList.remove(\"dark\");\n root.style.colorScheme = mode;\n}\n\n/**\n * Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight\n * from localStorage, resolve it to a concrete light/dark mode, and toggle the\n * `.dark` class (and `color-scheme`) on <html> — BEFORE React and the store mount,\n * so the first paint already has the right theme (no flash). Returns the resolved\n * mode so the caller can chain {@link applyPersistedPalette}. Encapsulates the\n * zustand-persist envelope + the legacy `mode`→`preference` fallback so apps never re-type the\n * storage shape in their entrypoint.\n */\nexport function applyPersistedTheme(storageKey: string): ThemeMode {\n let preference: ThemePreference = \"system\";\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { mode?: string; preference?: string };\n };\n preference = preferenceFromState(blob.state);\n } catch {\n /* malformed storage → system */\n }\n const mode = resolve(preference);\n applyThemeClass(mode);\n return mode;\n}\n\n/**\n * Build a theme store bound to a consumer-supplied localStorage key. Tracks a\n * light/dark/system PREFERENCE (feedback #330) and exposes the resolved `mode`.\n * Returns the `useTheme` hook plus a `useApplyTheme` effect that toggles the\n * `.dark` class on <html> and, while on \"system\", follows OS theme changes live.\n */\nexport function createThemeStore(storageKey: string) {\n const useTheme = create<ThemeState>()(\n logger(\n persist(\n (set, get) => ({\n preference: \"system\" as ThemePreference,\n mode: systemPreference(),\n setPreference: (preference) => set({ preference, mode: resolve(preference) }),\n setMode: (mode) => set({ preference: mode, mode }),\n toggle: () => {\n const next: ThemeMode = get().mode === \"dark\" ? \"light\" : \"dark\";\n set({ preference: next, mode: next });\n },\n }),\n {\n name: storageKey,\n version: 2,\n // Only the preference is persisted; `mode` is derived on load.\n partialize: (s) => ({ preference: s.preference }),\n migrate: (persistedState): { preference: ThemePreference } => ({\n preference: preferenceFromState(\n persistedState as { mode?: string; preference?: string } | null,\n ),\n }),\n onRehydrateStorage: () => (state) => {\n // The persisted preference decides the concrete mode at load.\n if (state) state.mode = resolve(state.preference);\n },\n },\n ),\n \"theme\",\n ),\n );\n // Reset this device's settings (docs/settings-harmonization.md §11.4): back to\n // \"System\" through the setter, which re-resolves `mode`; `useApplyTheme` puts the\n // class back and follows the OS again, and the persisted blob is what the pre-paint\n // `applyPersistedTheme` reads next load.\n registerDeviceSetting({\n key: storageKey,\n kind: \"appearance\",\n reset: () => useTheme.getState().setPreference(\"system\"),\n });\n\n function useApplyTheme(): void {\n const mode = useTheme((s) => s.mode);\n const preference = useTheme((s) => s.preference);\n useEffect(() => {\n applyThemeClass(mode);\n }, [mode]);\n // While tracking the system, follow OS theme changes live.\n useEffect(() => {\n if (preference !== \"system\") return;\n let mq: MediaQueryList;\n try {\n mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n } catch {\n return;\n }\n const onChange = () => useTheme.setState({ mode: mq.matches ? \"dark\" : \"light\" });\n onChange();\n mq.addEventListener?.(\"change\", onChange);\n return () => mq.removeEventListener?.(\"change\", onChange);\n }, [preference]);\n }\n\n return { useTheme, useApplyTheme };\n}\n\nexport type ThemeStore = ReturnType<typeof createThemeStore>;\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -213,7 +213,7 @@
|
|
|
213
213
|
"tsup": "^8.5.0",
|
|
214
214
|
"typescript": "~5.9.3",
|
|
215
215
|
"typescript-eslint": "^8.70.1",
|
|
216
|
-
"vite": "^8.3.
|
|
216
|
+
"vite": "^8.3.4",
|
|
217
217
|
"vitest": "^5.0.2"
|
|
218
218
|
},
|
|
219
219
|
"license": "MIT",
|
|
@@ -1213,6 +1213,17 @@ export function DataTable<T>({
|
|
|
1213
1213
|
const isMdUp = !usePhoneLayout();
|
|
1214
1214
|
// `edgeFade` (0.25) on the desktop scroller; the phone list never scrolls sideways.
|
|
1215
1215
|
const desktopScroller = useRef<HTMLDivElement | null>(null);
|
|
1216
|
+
// 0.47 (keksdose S4): the scroller's visible width, as `--dt-visible-w` on it, for the
|
|
1217
|
+
// expansion row's pinned content (below). Set on the element, not in state: a resize
|
|
1218
|
+
// re-renders nothing. Only a table that can expand a row measures.
|
|
1219
|
+
const measuresVisibleWidth = isMdUp && !!expandedRow;
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
const el = desktopScroller.current;
|
|
1222
|
+
if (!measuresVisibleWidth || !el || typeof ResizeObserver === "undefined") return;
|
|
1223
|
+
const ro = new ResizeObserver(() => el.style.setProperty("--dt-visible-w", `${el.clientWidth}px`));
|
|
1224
|
+
ro.observe(el);
|
|
1225
|
+
return () => ro.disconnect();
|
|
1226
|
+
}, [measuresVisibleWidth]);
|
|
1216
1227
|
const edgeFadeProps = useEdgeFade(desktopScroller, edgeFade && isMdUp);
|
|
1217
1228
|
|
|
1218
1229
|
// ---- Mobile card list (md:hidden) ----
|
|
@@ -1926,10 +1937,25 @@ export function DataTable<T>({
|
|
|
1926
1937
|
content in `w-0 min-w-full` pins the cell's preferred
|
|
1927
1938
|
width to ~0 (the explicit width:0 child), so it can never
|
|
1928
1939
|
perturb the columns; the div then fills to the full row
|
|
1929
|
-
width only at paint time, and long text wraps within it.
|
|
1940
|
+
width only at paint time, and long text wraps within it.
|
|
1941
|
+
Inside it the content is pinned to what the scroller SHOWS
|
|
1942
|
+
(0.47, keksdose S4): on a table wider than its scroller a
|
|
1943
|
+
table-wide panel put an inline editor's Save/Cancel off-screen.
|
|
1944
|
+
`sticky start-0` holds it at the scroller's start edge (the
|
|
1945
|
+
right one in RTL) as the row scrolls sideways, and
|
|
1946
|
+
`--dt-visible-w` (the scroller's clientWidth, measured above)
|
|
1947
|
+
sizes it; before the first measure, and in a table no wider
|
|
1948
|
+
than its scroller, it is the full width as before. */}
|
|
1930
1949
|
<td colSpan={totalColSpan} className="p-0">
|
|
1931
|
-
<div className=
|
|
1932
|
-
|
|
1950
|
+
<div className="w-0 min-w-full">
|
|
1951
|
+
<div
|
|
1952
|
+
className={cn(
|
|
1953
|
+
"sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
|
|
1954
|
+
compact ? "px-2 py-2" : "px-3 py-3",
|
|
1955
|
+
)}
|
|
1956
|
+
>
|
|
1957
|
+
{expansion}
|
|
1958
|
+
</div>
|
|
1933
1959
|
</div>
|
|
1934
1960
|
</td>
|
|
1935
1961
|
</tr>
|
|
@@ -13,6 +13,7 @@ import { useCommitReason, type CommitScope } from "./write-lock";
|
|
|
13
13
|
import type { IconButtonGlyphSize, IconButtonSize } from "./icon-button";
|
|
14
14
|
import type { TooltipSide } from "./tooltip";
|
|
15
15
|
import type { DataTableColumn } from "./data-table";
|
|
16
|
+
import { TableHeaderCell, type TableHeaderCellProps } from "./table";
|
|
16
17
|
import { DEFAULT_ROW_ACTIONS_LABELS } from "./row-actions-labels";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -538,6 +539,43 @@ function RowActionsHeader({
|
|
|
538
539
|
return hidden ? <span className="sr-only">{word}</span> : <>{word}</>;
|
|
539
540
|
}
|
|
540
541
|
|
|
542
|
+
/** The actions cell's classes — `rowActionsColumn`'s `className`: the column hugs its
|
|
543
|
+
* buttons, on one line, at the row's end. A hand-built table's `<TableCell>` takes them
|
|
544
|
+
* around its {@link RowActions} (0.47, keksdose N17). */
|
|
545
|
+
export const ROW_ACTIONS_CELL_CLASS = "w-px whitespace-nowrap text-end";
|
|
546
|
+
/** The actions heading's classes — `rowActionsColumn`'s `headClassName`, which
|
|
547
|
+
* {@link RowActionsHead} draws: hugging, end-aligned over the buttons (0.47, N17). */
|
|
548
|
+
export const ROW_ACTIONS_HEAD_CLASS = "w-px text-end";
|
|
549
|
+
|
|
550
|
+
export interface RowActionsHeadProps extends Omit<TableHeaderCellProps, "children"> {
|
|
551
|
+
/** The heading. Default: the provider's `rowActions.actions` ("Actions"). */
|
|
552
|
+
children?: ReactNode;
|
|
553
|
+
/** Named for a screen reader only — `rowActionsColumn`'s `header: false`. */
|
|
554
|
+
hideLabel?: boolean;
|
|
555
|
+
labels?: LabelOverride<RowActionsLabels>;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A hand-built table's actions heading (0.47, keksdose N17): the `<th>` `rowActionsColumn`
|
|
560
|
+
* gives a DataTable — "Actions" on screen in the provider's words, end-aligned over the
|
|
561
|
+
* buttons, hugging them. Pair it with `<TableCell className={ROW_ACTIONS_CELL_CLASS}>`
|
|
562
|
+
* around each row's {@link RowActions}. Every other `TableHeaderCell` prop passes on
|
|
563
|
+
* (`size`, the app's `className`, merged last).
|
|
564
|
+
*
|
|
565
|
+
* <RowActionsHead size="sm" className={TH} />
|
|
566
|
+
*/
|
|
567
|
+
export function RowActionsHead({ children, hideLabel, labels, className, ...rest }: RowActionsHeadProps) {
|
|
568
|
+
return (
|
|
569
|
+
<TableHeaderCell align="end" {...rest} className={cn(ROW_ACTIONS_HEAD_CLASS, className)}>
|
|
570
|
+
{typeof children === "string" || children == null ? (
|
|
571
|
+
<RowActionsHeader text={children ?? undefined} labels={labels} hidden={hideLabel} />
|
|
572
|
+
) : (
|
|
573
|
+
children
|
|
574
|
+
)}
|
|
575
|
+
</TableHeaderCell>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
541
579
|
export interface RowActionsColumnOptions<T> {
|
|
542
580
|
/** The column's key. Default `"actions"`. */
|
|
543
581
|
key?: string;
|
|
@@ -617,9 +655,10 @@ export function rowActionsColumn<T>({
|
|
|
617
655
|
labels={labels}
|
|
618
656
|
/>
|
|
619
657
|
),
|
|
620
|
-
className:
|
|
621
|
-
// The heading over the buttons, at the row's end (0.46.1)
|
|
622
|
-
|
|
658
|
+
className: ROW_ACTIONS_CELL_CLASS,
|
|
659
|
+
// The heading over the buttons, at the row's end (0.46.1); a hand-built table's
|
|
660
|
+
// `RowActionsHead` (0.47).
|
|
661
|
+
headClassName: ROW_ACTIONS_HEAD_CLASS,
|
|
623
662
|
noRowLink: true,
|
|
624
663
|
...column,
|
|
625
664
|
};
|
package/src/components/tabs.tsx
CHANGED
|
@@ -48,10 +48,16 @@ export interface TabsLabels {
|
|
|
48
48
|
add: string;
|
|
49
49
|
/** Accessible name of a tab's × — "Remove <tab>". */
|
|
50
50
|
remove: (tab: string) => string;
|
|
51
|
-
/** The `overflowCue`
|
|
51
|
+
/** The `overflowCue` › over the strip's end, while tabs lie past it: "More tabs". */
|
|
52
52
|
more: string;
|
|
53
|
-
/** The `overflowCue`
|
|
54
|
-
|
|
53
|
+
/** The `overflowCue` ‹ over the strip's start, while tabs lie before it: "Previous tabs". */
|
|
54
|
+
previous: string;
|
|
55
|
+
/**
|
|
56
|
+
* @deprecated 0.47: not drawn any more — the caret that turned into "back to the
|
|
57
|
+
* first tab" at the strip's end is now a ‹ of its own (`previous`). Accepted and
|
|
58
|
+
* ignored; removed in 0.48.
|
|
59
|
+
*/
|
|
60
|
+
backToFirst?: string;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
// Defined in ./ui-labels, which holds no component code (0.34, KS U8).
|
|
@@ -78,20 +84,22 @@ export interface TabsProps<T extends string>
|
|
|
78
84
|
onChange: (id: T) => void;
|
|
79
85
|
className?: string;
|
|
80
86
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
87
|
+
* Carets over the ends of a SCROLLING strip (0.40, kk run 79 gap 1; 0.47, keksdose
|
|
88
|
+
* live #410 round 4): a › over the strip's end while tabs lie past it, and a ‹ over
|
|
89
|
+
* its start while tabs lie before it — each there only while something is hidden
|
|
90
|
+
* past ITS edge (1 px slack, so a few cut-off pixels count), each a step of three
|
|
91
|
+
* quarters of the strip's width toward its own side. Neither ever changes direction:
|
|
92
|
+
* until 0.46 one caret turned round into "back to the first" at the end, and read as
|
|
93
|
+
* an arrow pointing the wrong way. Start and end follow the reading direction.
|
|
85
94
|
*
|
|
86
|
-
* The edge fade alone could read as the end of the row:
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* `tabs.more` / `tabs.backToFirst`.
|
|
95
|
+
* The edge fade alone could read as the end of the row: the last visible tab can
|
|
96
|
+
* finish exactly where the fade begins, so nothing looks cut off (keksdose live #410).
|
|
97
|
+
* The carets sit OVER the strip's ends (positioned, on the page's surface), not beside
|
|
98
|
+
* it, so the strip's width never changes as they come and go. Out of the tab order —
|
|
99
|
+
* the keyboard walks the strip with the arrow keys, which bring the focused tab into
|
|
100
|
+
* view. At every width. Ignored on a vertical strip (a side nav is a column, not a
|
|
101
|
+
* row). Names: `tabs.more` / `tabs.previous`. keksdose's `scroll-edge-cues.tsx` is
|
|
102
|
+
* the reference this replaces.
|
|
95
103
|
*/
|
|
96
104
|
overflowCue?: boolean;
|
|
97
105
|
/**
|
|
@@ -254,36 +262,43 @@ export function Tabs<T extends string>({
|
|
|
254
262
|
const stripRef = useRef<HTMLDivElement>(null);
|
|
255
263
|
const setStrip = useMemo(() => mergeRefs(stripRef, ref), [ref]);
|
|
256
264
|
const fade = useStripFade(stripRef);
|
|
257
|
-
// See `overflowCue
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
const
|
|
265
|
+
// See `overflowCue`: a ‹ over the start while tabs lie before it, a › over the end
|
|
266
|
+
// while tabs lie past it — `fade.overflow` names the cut ends in reading-direction terms.
|
|
267
|
+
const cue = overflowCue && !vertical;
|
|
268
|
+
const cueStart = cue && (fade.overflow === "start" || fade.overflow === "both");
|
|
269
|
+
const cueEnd = cue && (fade.overflow === "end" || fade.overflow === "both");
|
|
270
|
+
// Three quarters of the visible width toward `side`; an RTL strip runs leftward.
|
|
271
|
+
const onCue = (side: "start" | "end") => {
|
|
261
272
|
const strip = stripRef.current;
|
|
262
273
|
if (!strip) return;
|
|
263
274
|
const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
264
|
-
if (cueAtEnd) {
|
|
265
|
-
strip.scrollTo({ left: 0, behavior });
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
275
|
const rtl = getComputedStyle(strip).direction === "rtl";
|
|
269
|
-
|
|
276
|
+
const toward = (side === "end" ? 1 : -1) * (rtl ? -1 : 1);
|
|
277
|
+
strip.scrollBy({ left: toward * Math.round(strip.clientWidth * 0.75), behavior });
|
|
270
278
|
};
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
279
|
+
// OVER the strip's end, on the page's surface, so the caret reads as a control and not
|
|
280
|
+
// as one more faded tab — and so it can come and go without the strip changing width
|
|
281
|
+
// under the thumb. The position sits on a wrapper of ours: IconButton puts its
|
|
282
|
+
// tooltip's span around the button, and a class on the button would land inside it.
|
|
283
|
+
const cueAt = (side: "start" | "end") => (
|
|
284
|
+
<div
|
|
285
|
+
data-tabs-overflow-cue={side}
|
|
286
|
+
className={cn("absolute top-1/2 z-10 flex -translate-y-1/2", side === "start" ? "start-0" : "end-0")}
|
|
287
|
+
>
|
|
274
288
|
<IconButton
|
|
275
289
|
size="sm"
|
|
276
|
-
variant="
|
|
277
|
-
|
|
290
|
+
variant="secondary"
|
|
291
|
+
className="bg-surface shadow-sm"
|
|
278
292
|
tabIndex={-1}
|
|
279
293
|
type="button"
|
|
280
|
-
|
|
281
|
-
|
|
294
|
+
label={side === "start" ? text.previous : text.more}
|
|
295
|
+
labelVisible={false}
|
|
296
|
+
onClick={() => onCue(side)}
|
|
282
297
|
>
|
|
283
|
-
{
|
|
298
|
+
{side === "start" ? <ChevronLeft className="rtl:-scale-x-100" /> : <ChevronRight className="rtl:-scale-x-100" />}
|
|
284
299
|
</IconButton>
|
|
285
300
|
</div>
|
|
286
|
-
)
|
|
301
|
+
);
|
|
287
302
|
// The OPEN tab is scrolled into the strip whenever it changes — on mount too, for a
|
|
288
303
|
// deep link to the seventh report. A selection behind the cut edge was the worst
|
|
289
304
|
// case of the overflow above: the one tab the panel below belongs to, sliced to
|
|
@@ -407,7 +422,7 @@ export function Tabs<T extends string>({
|
|
|
407
422
|
// — it is the box a caller's margin or width is meant for.
|
|
408
423
|
className={cn(
|
|
409
424
|
vertical ? TABLIST_VERTICAL_CLASSES : TABLIST_CLASSES,
|
|
410
|
-
|
|
425
|
+
cue ? "min-w-0 flex-1" : onAdd ? "min-w-0" : className,
|
|
411
426
|
)}
|
|
412
427
|
>
|
|
413
428
|
{tabs.map((tab) => {
|
|
@@ -575,16 +590,24 @@ export function Tabs<T extends string>({
|
|
|
575
590
|
{text.remove(nameOf(activeTab))}
|
|
576
591
|
</button>
|
|
577
592
|
) : null;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
593
|
+
// The positioned box the carets sit in, over the strip's two ends (see `overflowCue`).
|
|
594
|
+
// There whether or not the strip overflows right now, so a resize never re-parents it.
|
|
595
|
+
const stripBox = cue ? (
|
|
596
|
+
<div className={cn("relative flex min-w-0", !onAdd && "flex-1")}>
|
|
597
|
+
{tablist}
|
|
598
|
+
{cueStart && cueAt("start")}
|
|
599
|
+
{cueEnd && cueAt("end")}
|
|
600
|
+
</div>
|
|
601
|
+
) : (
|
|
602
|
+
tablist
|
|
603
|
+
);
|
|
604
|
+
if (!onAdd && cue) {
|
|
605
|
+
// `className` goes on the row around the strip — it is the box a caller's margin or
|
|
606
|
+
// width is meant for, as with an add button — and stays there at every scroll position.
|
|
583
607
|
return (
|
|
584
608
|
<div className={cn("flex items-end", className)}>
|
|
585
|
-
{
|
|
609
|
+
{stripBox}
|
|
586
610
|
{removeButton}
|
|
587
|
-
{cueButton}
|
|
588
611
|
</div>
|
|
589
612
|
);
|
|
590
613
|
}
|
|
@@ -614,9 +637,8 @@ export function Tabs<T extends string>({
|
|
|
614
637
|
}
|
|
615
638
|
return (
|
|
616
639
|
<div className={cn("flex items-end", className)}>
|
|
617
|
-
{
|
|
640
|
+
{stripBox}
|
|
618
641
|
{removeButton}
|
|
619
|
-
{cueButton}
|
|
620
642
|
{/* Outside the tablist: an action, not a tab — and a tablist may own only
|
|
621
643
|
tabs. The rule under the strip carries on beneath it. */}
|
|
622
644
|
<div
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { MouseEvent } from "react";
|
|
3
|
+
import { Download, FileText } from "lucide-react";
|
|
4
|
+
import { Button } from "../components/button";
|
|
5
|
+
import { toast } from "../components/toast";
|
|
6
|
+
import type { AuthedFetcher } from "../hooks/use-authed-src";
|
|
7
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
8
|
+
import { filenameFromDisposition, saveBlob } from "../lib/download";
|
|
9
|
+
import { attachmentName } from "./feedback-record";
|
|
10
|
+
import { DEFAULT_FEEDBACK_DETAIL_LABELS } from "./feedback-row-detail-labels";
|
|
11
|
+
|
|
12
|
+
// Internal (not in the `./feedback` entry): the row detail's download button and its
|
|
13
|
+
// plain fetch, shared since 0.47 with ./feedback-entry-file's inline entry files.
|
|
14
|
+
|
|
15
|
+
/** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */
|
|
16
|
+
async function toBlob(answer: Blob | Response): Promise<Blob> {
|
|
17
|
+
if (typeof Response !== "undefined" && answer instanceof Response) {
|
|
18
|
+
if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
|
|
19
|
+
return answer.blob();
|
|
20
|
+
}
|
|
21
|
+
return answer as Blob;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
|
|
25
|
+
export const plainFeedbackFetch: AuthedFetcher = (url, { signal }) =>
|
|
26
|
+
fetch(url, { signal, credentials: "same-origin" });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
|
|
30
|
+
* an authenticated route that serves them as `attachment`, never inline (§3.5), so a
|
|
31
|
+
* plain link would be a 401 the moment it was opened. Fetched through the app's
|
|
32
|
+
* fetcher and saved with {@link saveBlob}; a failure says so in a toast.
|
|
33
|
+
*
|
|
34
|
+
* Saved under the name the server gives it when the fetcher answers with a `Response`
|
|
35
|
+
* (server-kit's `content_disposition` puts the uploader's own name in `filename*`), and
|
|
36
|
+
* under the URL's last segment — the name on the button — when it answers with a bare
|
|
37
|
+
* `Blob`, which has no headers to ask. Words: `feedbackDetail.download` /
|
|
38
|
+
* `downloadFailed`.
|
|
39
|
+
*/
|
|
40
|
+
export function FeedbackAttachmentDownload({ url, fetcher }: { url: string; fetcher: AuthedFetcher }) {
|
|
41
|
+
const labels = useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS);
|
|
42
|
+
const [busy, setBusy] = useState(false);
|
|
43
|
+
const name = attachmentName(url);
|
|
44
|
+
const download = async (event: MouseEvent) => {
|
|
45
|
+
// The row around the detail may toggle on a click.
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
setBusy(true);
|
|
48
|
+
try {
|
|
49
|
+
const answer = await fetcher(url, { signal: new AbortController().signal });
|
|
50
|
+
const blob = await toBlob(answer);
|
|
51
|
+
const saveAs =
|
|
52
|
+
typeof Response !== "undefined" && answer instanceof Response
|
|
53
|
+
? filenameFromDisposition(answer.headers.get("Content-Disposition"), name)
|
|
54
|
+
: name;
|
|
55
|
+
saveBlob(blob, saveAs);
|
|
56
|
+
} catch {
|
|
57
|
+
toast.error(labels.downloadFailed);
|
|
58
|
+
} finally {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return (
|
|
63
|
+
<Button
|
|
64
|
+
type="button"
|
|
65
|
+
variant="secondary"
|
|
66
|
+
size="sm"
|
|
67
|
+
pending={busy}
|
|
68
|
+
onClick={(event) => void download(event)}
|
|
69
|
+
aria-label={labels.download(name)}
|
|
70
|
+
className="max-w-full"
|
|
71
|
+
>
|
|
72
|
+
<FileText className="size-3.5 shrink-0" aria-hidden />
|
|
73
|
+
<span className="min-w-0 max-w-[14rem] truncate-until-large">{name}</span>
|
|
74
|
+
<Download className="size-3.5 shrink-0" aria-hidden />
|
|
75
|
+
</Button>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -4,11 +4,13 @@ import { EyeOff } from "lucide-react";
|
|
|
4
4
|
import { Button } from "../components/button";
|
|
5
5
|
import { useConfirm } from "../components/confirm-dialog";
|
|
6
6
|
import type { CommitScope } from "../components/write-lock";
|
|
7
|
+
import type { AuthedFetcher } from "../hooks/use-authed-src";
|
|
7
8
|
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
8
9
|
import { FeedbackStatusBadge } from "./feedback-inbox";
|
|
9
10
|
import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
|
|
10
11
|
import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
|
|
11
12
|
import { FEEDBACK_COMMENT_MAX, attachmentName } from "./feedback-record";
|
|
13
|
+
import { FeedbackEntryFile, useFeedbackEntryUpload, type FeedbackEntryUploadOptions } from "./feedback-entry-file";
|
|
12
14
|
import { useFeedbackRepliesLabels } from "./feedback-replies";
|
|
13
15
|
import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
|
|
14
16
|
import { FeedbackReworkEntryChip } from "./feedback-rework-entry-chip";
|
|
@@ -34,8 +36,13 @@ import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels
|
|
|
34
36
|
* sees their text with that line; the admin sees it struck through, with **Unhide**.
|
|
35
37
|
* An admin has **Hide** on every visible comment; the writer **Delete** on their own,
|
|
36
38
|
* after a confirm (it is a hard delete). Nobody edits a comment.
|
|
37
|
-
* - **The composer** (1–4 000 characters
|
|
38
|
-
*
|
|
39
|
+
* - **The composer** (1–4 000 characters) only with `onComment`: left out for a reader
|
|
40
|
+
* who may not write (a demo, a CRASH report — the app knows). Since 0.47 (§12) with
|
|
41
|
+
* `onUpload` it takes one file as the rework note does — pasted into the box,
|
|
42
|
+
* captured or picked — uploaded first, its URL `onComment`'s second argument.
|
|
43
|
+
* - **Files** (0.47, §12): a comment's or an entry's file under its text, a picture
|
|
44
|
+
* INLINE — a thumbnail through `fetcher` that opens full size — any other file a
|
|
45
|
+
* download ({@link FeedbackEntryFile}). A veiled comment shows none.
|
|
39
46
|
*
|
|
40
47
|
* Delete asks through `useConfirm`, so a thread with `onDelete` and a comment of the
|
|
41
48
|
* viewer's needs the app's `ConfirmProvider` (each app mounts one already).
|
|
@@ -75,7 +82,7 @@ export { DEFAULT_FEEDBACK_COMMENTS_LABELS };
|
|
|
75
82
|
|
|
76
83
|
/* ── The thread ───────────────────────────────────────────────────────────── */
|
|
77
84
|
|
|
78
|
-
export interface FeedbackCommentThreadProps {
|
|
85
|
+
export interface FeedbackCommentThreadProps extends FeedbackEntryUploadOptions {
|
|
79
86
|
/** The report's replies (§9.7), oldest first. Left out or `[]`: none.
|
|
80
87
|
*
|
|
81
88
|
* Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's
|
|
@@ -92,8 +99,12 @@ export interface FeedbackCommentThreadProps {
|
|
|
92
99
|
/** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
|
|
93
100
|
* resolves, and keeps the draft when it rejects. A failure is the app's to toast —
|
|
94
101
|
* the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
|
|
95
|
-
* composer.
|
|
96
|
-
|
|
102
|
+
* composer. 0.47 (§12): `attachmentUrl` is the one file uploaded with it (with
|
|
103
|
+
* `onUpload`), `undefined` for none — `attachment_url` in the body (server-kit 0.18). */
|
|
104
|
+
onComment?: (body: string, attachmentUrl?: string) => void | Promise<unknown>;
|
|
105
|
+
/** 0.47: the app's authenticated GET (`AuthedImage`'s) for the files in the thread.
|
|
106
|
+
* Left out, the browser's own fetch with same-origin cookies. */
|
|
107
|
+
fetcher?: AuthedFetcher | null;
|
|
97
108
|
/** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection
|
|
98
109
|
* is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left
|
|
99
110
|
* out, no Hide / Unhide. */
|
|
@@ -141,9 +152,18 @@ export function FeedbackCommentThread({
|
|
|
141
152
|
loading = false,
|
|
142
153
|
commit,
|
|
143
154
|
now,
|
|
155
|
+
fetcher,
|
|
156
|
+
onUpload,
|
|
157
|
+
onCaptureScreenshot,
|
|
158
|
+
attachment,
|
|
144
159
|
labels: labelsProp,
|
|
145
160
|
className,
|
|
146
161
|
}: FeedbackCommentThreadProps) {
|
|
162
|
+
const { composerAttachment, upload, uploading } = useFeedbackEntryUpload({
|
|
163
|
+
onUpload,
|
|
164
|
+
onCaptureScreenshot,
|
|
165
|
+
attachment,
|
|
166
|
+
});
|
|
147
167
|
const labels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);
|
|
148
168
|
const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
|
|
149
169
|
const replyLabels = useFeedbackRepliesLabels();
|
|
@@ -189,6 +209,10 @@ export function FeedbackCommentThread({
|
|
|
189
209
|
author: reply.author_name ?? replyLabels.admin,
|
|
190
210
|
createdAt: reply.created_at,
|
|
191
211
|
body: reply.body,
|
|
212
|
+
// 0.47 (§12): an admin reply's file, where the view carries it.
|
|
213
|
+
attachments: reply.attachment_url
|
|
214
|
+
? [{ name: attachmentName(reply.attachment_url), url: reply.attachment_url }]
|
|
215
|
+
: undefined,
|
|
192
216
|
staff: true,
|
|
193
217
|
badge: reply.status_to ? (
|
|
194
218
|
<span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1" data-feedback-reply-status={reply.status_to}>
|
|
@@ -244,6 +268,11 @@ export function FeedbackCommentThread({
|
|
|
244
268
|
author: veiled ? undefined : comment.mine ? undefined : (comment.author_name ?? anonymous),
|
|
245
269
|
createdAt: comment.created_at,
|
|
246
270
|
body: veiled ? "" : (comment.body ?? ""),
|
|
271
|
+
// 0.47 (§12): its one file; none when veiled (the server sends none either).
|
|
272
|
+
attachments:
|
|
273
|
+
!veiled && comment.attachment_url
|
|
274
|
+
? [{ name: attachmentName(comment.attachment_url), url: comment.attachment_url }]
|
|
275
|
+
: undefined,
|
|
247
276
|
staff: !veiled && comment.staff,
|
|
248
277
|
struck: comment.hidden && isAdmin && !veiled,
|
|
249
278
|
footer: actions.length > 0 ? actions : undefined,
|
|
@@ -258,13 +287,19 @@ export function FeedbackCommentThread({
|
|
|
258
287
|
messages={entries.map((entry) => entry.message)}
|
|
259
288
|
loading={loading}
|
|
260
289
|
now={now}
|
|
290
|
+
renderAttachment={(file) => (file.url ? <FeedbackEntryFile url={file.url} fetcher={fetcher} /> : file.name)}
|
|
261
291
|
labels={{ thread: labels.comments, empty: labels.empty }}
|
|
262
292
|
/>
|
|
263
293
|
{onComment && (
|
|
264
294
|
<ChatComposer
|
|
265
295
|
className="mt-3"
|
|
266
|
-
onSend={(body) =>
|
|
267
|
-
|
|
296
|
+
onSend={async (body, file) => {
|
|
297
|
+
const url = await upload(file);
|
|
298
|
+
// The second argument only with a file, so a text comment calls as before 0.47.
|
|
299
|
+
return url === undefined ? onComment(body) : onComment(body, url);
|
|
300
|
+
}}
|
|
301
|
+
pending={pending || uploading}
|
|
302
|
+
attachment={composerAttachment}
|
|
268
303
|
commit={commit}
|
|
269
304
|
maxLength={FEEDBACK_COMMENT_MAX}
|
|
270
305
|
showCount
|