@skyhook-io/k8s-ui 1.18.0 → 1.18.2
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 +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -43,7 +43,7 @@ import type { NavigateToResource } from '../../utils/navigation'
|
|
|
43
43
|
import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
|
|
44
44
|
import { neighborhoodFor, seedNodeIds, topologyNodeResourceKind } from '../../utils/topology-neighborhood'
|
|
45
45
|
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
46
|
-
import { gitOpsOwnerFromRelationships, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
46
|
+
import { gitOpsOwnerFromRelationships, managerChipCoversGroup, managerChipPresentation, managerListing, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
47
47
|
import { gitOpsRouteForResource } from '../../utils/gitops-route'
|
|
48
48
|
import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
|
|
49
49
|
import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
|
|
@@ -57,11 +57,13 @@ import {
|
|
|
57
57
|
import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type DiagnoseHealthHint, type RendererOverrides } from '../shared/ResourceRendererDispatch'
|
|
58
58
|
import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
|
|
59
59
|
import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
|
|
60
|
-
import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
|
|
60
|
+
import { gitOpsOwnerKindLabel, HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
|
|
61
61
|
import { AlertBanner, getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
|
|
62
62
|
import { Badge, type BadgeSeverity } from '../ui/Badge'
|
|
63
63
|
import { Tooltip } from '../ui/Tooltip'
|
|
64
64
|
import { midTruncate } from '../../utils/format'
|
|
65
|
+
import { isManagedResource, isComposite, getCrossplanePauseState } from '../resources/resource-utils-crossplane'
|
|
66
|
+
import { CrossplanePauseChip } from '../resources/renderers/CrossplanePauseContext'
|
|
65
67
|
import { hpaStateLabel } from '../resources/resource-utils-hpa'
|
|
66
68
|
import {
|
|
67
69
|
cronToHuman,
|
|
@@ -216,6 +218,8 @@ interface WorkloadViewProps {
|
|
|
216
218
|
/** Timeline events the history lanes are built from: this workload's own
|
|
217
219
|
* history, or events for its namespace from a host that has no scoped query. */
|
|
218
220
|
allEvents?: TimelineEvent[]
|
|
221
|
+
/** Reveals one event, loading older pages if needed. Use a new object for each navigation request. */
|
|
222
|
+
timelineFocusEvent?: { id: string }
|
|
219
223
|
/** Older history exists beyond allEvents. */
|
|
220
224
|
historyTruncated?: boolean
|
|
221
225
|
/** Loads the next older page of history; omit when the host can't page. */
|
|
@@ -469,6 +473,7 @@ export function WorkloadView({
|
|
|
469
473
|
refetch: refetchProp,
|
|
470
474
|
// Timeline
|
|
471
475
|
allEvents,
|
|
476
|
+
timelineFocusEvent,
|
|
472
477
|
historyTruncated = false,
|
|
473
478
|
onLoadOlderHistory,
|
|
474
479
|
loadingOlderHistory = false,
|
|
@@ -537,7 +542,7 @@ export function WorkloadView({
|
|
|
537
542
|
gitOpsOwnerPending = false,
|
|
538
543
|
gitOpsOwnerSource,
|
|
539
544
|
gitOpsOwnerStatus,
|
|
540
|
-
helmOwner,
|
|
545
|
+
helmOwner: resolvedHelmOwner,
|
|
541
546
|
helmOwnerSource,
|
|
542
547
|
onOpenHelmRelease,
|
|
543
548
|
onNavigateGitOpsPath,
|
|
@@ -722,7 +727,24 @@ export function WorkloadView({
|
|
|
722
727
|
// Metadata
|
|
723
728
|
const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
|
|
724
729
|
const relationshipGitOpsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
|
|
725
|
-
const
|
|
730
|
+
const manager = relationships?.ownership?.manager
|
|
731
|
+
const gitopsOwner = manager ? relationshipGitOpsOwner : resolvedGitOpsOwner ?? relationshipGitOpsOwner
|
|
732
|
+
const helmOwner = manager
|
|
733
|
+
? manager.lead?.kind === 'HelmRelease' && !manager.lead.group ? { namespace: manager.lead.namespace, name: manager.lead.name } : null
|
|
734
|
+
: resolvedHelmOwner
|
|
735
|
+
const showGitOpsChip = manager ? !helmOwner && Boolean(managerChipPresentation(manager)) : Boolean(gitopsOwner)
|
|
736
|
+
const listing = useMemo(
|
|
737
|
+
() => managerListing(gitopsOwner, relationships?.managers, gitOpsOwnerVerified, !relationships?.ownership?.managers),
|
|
738
|
+
[gitopsOwner, relationships?.managers, relationships?.ownership?.managers, gitOpsOwnerVerified],
|
|
739
|
+
)
|
|
740
|
+
// When the chip's manager is the only one whose status lists the resource,
|
|
741
|
+
// the chip says it all: Related Resources would repeat it.
|
|
742
|
+
const rendererRelationships = useMemo(
|
|
743
|
+
() => (relationships && (manager ? managerChipCoversGroup(relationships) : listing?.state === 'confirmed') ? { ...relationships, managers: undefined } : relationships),
|
|
744
|
+
[relationships, manager, listing],
|
|
745
|
+
)
|
|
746
|
+
// When they disagree, Related Resources says which manager the label names.
|
|
747
|
+
const trackedBy = !manager && listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
|
|
726
748
|
// When the resource itself is a portal GitOps CR (Application, Kustomization,
|
|
727
749
|
// HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
|
|
728
750
|
// the drawer's renderer is thorough but the tab has the tree + insights +
|
|
@@ -820,6 +842,7 @@ export function WorkloadView({
|
|
|
820
842
|
? getWorkloadDisplayStatus(resource, apiKind, workloadPods)
|
|
821
843
|
: null
|
|
822
844
|
const rolloutActivity = rolloutDisplay?.activity ?? null
|
|
845
|
+
const crossplaneResource = isManagedResource(resource) || isComposite(resource) || getCrossplanePauseState(resource) === 'acknowledged'
|
|
823
846
|
// A 404 with a cached copy: the object was deleted while this view was
|
|
824
847
|
// open. Everything below that copy describes the past.
|
|
825
848
|
const resourceGone = Boolean(resource) && isFetchError(resourceError) && resourceError.status === 404
|
|
@@ -907,7 +930,7 @@ export function WorkloadView({
|
|
|
907
930
|
icon: <Activity className="w-4 h-4" />,
|
|
908
931
|
badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
|
|
909
932
|
},
|
|
910
|
-
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
|
|
933
|
+
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible, phoneCompactHeader: true },
|
|
911
934
|
{ id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
|
|
912
935
|
{
|
|
913
936
|
id: 'reachability',
|
|
@@ -960,6 +983,7 @@ export function WorkloadView({
|
|
|
960
983
|
{status.text}
|
|
961
984
|
</span>
|
|
962
985
|
)}
|
|
986
|
+
{!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
|
|
963
987
|
</div>
|
|
964
988
|
<div className="flex shrink-0 flex-nowrap items-center justify-end gap-1.5">
|
|
965
989
|
{diagnoseAction}
|
|
@@ -1020,10 +1044,10 @@ export function WorkloadView({
|
|
|
1020
1044
|
</Tooltip>
|
|
1021
1045
|
</div>
|
|
1022
1046
|
<p className="text-sm text-theme-text-tertiary">{namespace}</p>
|
|
1023
|
-
{(
|
|
1047
|
+
{(showGitOpsChip || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
|
|
1024
1048
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
1025
|
-
{
|
|
1026
|
-
{helmOwner && <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
|
|
1049
|
+
{showGitOpsChip && <ManagedByChip manager={manager} owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} />}
|
|
1050
|
+
{helmOwner && <HelmManagedByChip manager={manager} owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
|
|
1027
1051
|
{gitOpsResourcePath && onNavigateGitOpsPath && (
|
|
1028
1052
|
<OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
|
|
1029
1053
|
)}
|
|
@@ -1109,7 +1133,8 @@ export function WorkloadView({
|
|
|
1109
1133
|
<ResourceRendererDispatch
|
|
1110
1134
|
resource={selectedResource}
|
|
1111
1135
|
data={resource}
|
|
1112
|
-
relationships={
|
|
1136
|
+
relationships={rendererRelationships}
|
|
1137
|
+
trackedBy={trackedBy}
|
|
1113
1138
|
certificateInfo={certificateInfo}
|
|
1114
1139
|
hpaDiagnosis={hpaDiagnosis}
|
|
1115
1140
|
scalerDiagnostics={scalerDiagnostics}
|
|
@@ -1139,10 +1164,35 @@ export function WorkloadView({
|
|
|
1139
1164
|
}
|
|
1140
1165
|
|
|
1141
1166
|
// ── Expanded (full) mode ─────────────────────────────────────────────────
|
|
1167
|
+
const nameAndCopy = (
|
|
1168
|
+
<>
|
|
1169
|
+
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
|
|
1170
|
+
<Tooltip content="Copy name" delay={150}>
|
|
1171
|
+
<button
|
|
1172
|
+
onClick={() => copyToClipboard(name, 'name')}
|
|
1173
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
1174
|
+
aria-label="Copy name"
|
|
1175
|
+
>
|
|
1176
|
+
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
1177
|
+
</button>
|
|
1178
|
+
</Tooltip>
|
|
1179
|
+
</>
|
|
1180
|
+
)
|
|
1142
1181
|
return (
|
|
1143
1182
|
<OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
|
|
1144
1183
|
<DetailShell
|
|
1145
1184
|
breadcrumb={breadcrumb}
|
|
1185
|
+
compactIdentity={
|
|
1186
|
+
// The namespace stays: Restart and the other actions sit right below.
|
|
1187
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
1188
|
+
{titlePrefix}
|
|
1189
|
+
<div className="flex min-w-0 max-w-full items-center gap-2">{nameAndCopy}</div>
|
|
1190
|
+
{namespace && namespace !== '_' && (
|
|
1191
|
+
<span className="whitespace-nowrap text-sm text-theme-text-secondary">Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
|
|
1192
|
+
)}
|
|
1193
|
+
{namespace && namespace !== '_' && namespaceNote}
|
|
1194
|
+
</div>
|
|
1195
|
+
}
|
|
1146
1196
|
wrapHeader={inlineBadges}
|
|
1147
1197
|
subheader={resourceGone ? undefined : subheader}
|
|
1148
1198
|
nav={
|
|
@@ -1159,20 +1209,6 @@ export function WorkloadView({
|
|
|
1159
1209
|
)
|
|
1160
1210
|
}
|
|
1161
1211
|
identity={(() => {
|
|
1162
|
-
const nameAndCopy = (
|
|
1163
|
-
<>
|
|
1164
|
-
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
|
|
1165
|
-
<Tooltip content="Copy name" delay={150}>
|
|
1166
|
-
<button
|
|
1167
|
-
onClick={() => copyToClipboard(name, 'name')}
|
|
1168
|
-
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
1169
|
-
aria-label="Copy name"
|
|
1170
|
-
>
|
|
1171
|
-
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
1172
|
-
</button>
|
|
1173
|
-
</Tooltip>
|
|
1174
|
-
</>
|
|
1175
|
-
)
|
|
1176
1212
|
const kindAndStatus = (
|
|
1177
1213
|
<>
|
|
1178
1214
|
{!hideKindBadge && (
|
|
@@ -1185,6 +1221,7 @@ export function WorkloadView({
|
|
|
1185
1221
|
{status.text}
|
|
1186
1222
|
</span>
|
|
1187
1223
|
)}
|
|
1224
|
+
{!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
|
|
1188
1225
|
{!resourceGone && statusNote}
|
|
1189
1226
|
</>
|
|
1190
1227
|
)
|
|
@@ -1206,11 +1243,11 @@ export function WorkloadView({
|
|
|
1206
1243
|
</span>
|
|
1207
1244
|
</Tooltip>
|
|
1208
1245
|
),
|
|
1209
|
-
|
|
1210
|
-
<ManagedByChip key="gitops" owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} variant="block" />
|
|
1246
|
+
showGitOpsChip && (
|
|
1247
|
+
<ManagedByChip key="gitops" manager={manager} owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} variant="block" />
|
|
1211
1248
|
),
|
|
1212
1249
|
helmOwner && (
|
|
1213
|
-
<HelmManagedByChip key="helm" owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
|
|
1250
|
+
<HelmManagedByChip key="helm" manager={manager} owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
|
|
1214
1251
|
),
|
|
1215
1252
|
gitOpsResourcePath && onNavigateGitOpsPath && (
|
|
1216
1253
|
<OpenInGitOpsChip key="open-gitops" onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
|
|
@@ -1322,7 +1359,8 @@ export function WorkloadView({
|
|
|
1322
1359
|
<InfoTab
|
|
1323
1360
|
resource={resource}
|
|
1324
1361
|
selectedResource={selectedResource}
|
|
1325
|
-
relationships={
|
|
1362
|
+
relationships={rendererRelationships}
|
|
1363
|
+
trackedBy={trackedBy}
|
|
1326
1364
|
certificateInfo={certificateInfo}
|
|
1327
1365
|
hpaDiagnosis={hpaDiagnosis}
|
|
1328
1366
|
scalerDiagnostics={scalerDiagnostics}
|
|
@@ -1383,6 +1421,7 @@ export function WorkloadView({
|
|
|
1383
1421
|
)}
|
|
1384
1422
|
{effectiveTab === 'timeline' && (
|
|
1385
1423
|
<EventsTab
|
|
1424
|
+
focusEvent={timelineFocusEvent}
|
|
1386
1425
|
events={resourceEvents}
|
|
1387
1426
|
isLoading={eventsLoading}
|
|
1388
1427
|
truncated={historyTruncated}
|
|
@@ -1429,10 +1468,12 @@ export function WorkloadView({
|
|
|
1429
1468
|
</div>
|
|
1430
1469
|
)}
|
|
1431
1470
|
{effectiveTab === 'yaml' && (
|
|
1432
|
-
|
|
1471
|
+
// Phones put the objects in a scrolling row above the YAML, which
|
|
1472
|
+
// gets the full width.
|
|
1473
|
+
<div className="flex h-full min-h-0 phone:flex-col">
|
|
1433
1474
|
{renderRelatedYaml && yamlObjects.length > 1 && (
|
|
1434
|
-
<div className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2">
|
|
1435
|
-
<div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Objects</div>
|
|
1475
|
+
<div className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2 phone:w-full phone:flex-row phone:gap-1 phone:overflow-x-auto phone:overflow-y-hidden phone:border-b phone:border-r-0">
|
|
1476
|
+
<div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary phone:hidden">Objects</div>
|
|
1436
1477
|
{yamlObjects.map((o) => {
|
|
1437
1478
|
const active = o.primary ? yamlObjectId === null : yamlObjectId === o.id
|
|
1438
1479
|
return (
|
|
@@ -1441,7 +1482,7 @@ export function WorkloadView({
|
|
|
1441
1482
|
type="button"
|
|
1442
1483
|
onClick={() => setYamlObjectId(o.primary ? null : o.id)}
|
|
1443
1484
|
className={clsx(
|
|
1444
|
-
'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors',
|
|
1485
|
+
'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors phone:w-auto phone:max-w-[11rem] phone:shrink-0',
|
|
1445
1486
|
active ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
1446
1487
|
)}
|
|
1447
1488
|
>
|
|
@@ -1688,6 +1729,7 @@ function OwnershipHeading({
|
|
|
1688
1729
|
// ============================================================================
|
|
1689
1730
|
|
|
1690
1731
|
function EventsTab({
|
|
1732
|
+
focusEvent,
|
|
1691
1733
|
events,
|
|
1692
1734
|
isLoading,
|
|
1693
1735
|
truncated,
|
|
@@ -1703,6 +1745,7 @@ function EventsTab({
|
|
|
1703
1745
|
topology,
|
|
1704
1746
|
onResourceClick,
|
|
1705
1747
|
}: {
|
|
1748
|
+
focusEvent?: { id: string }
|
|
1706
1749
|
events: TimelineEvent[]
|
|
1707
1750
|
isLoading: boolean
|
|
1708
1751
|
truncated?: boolean
|
|
@@ -1798,6 +1841,22 @@ function EventsTab({
|
|
|
1798
1841
|
onSelectEvent(id)
|
|
1799
1842
|
}, [onSelectEvent])
|
|
1800
1843
|
|
|
1844
|
+
const focusedRequest = useRef<typeof focusEvent>(undefined)
|
|
1845
|
+
const requestedPage = useRef<{ focus: typeof focusEvent; events: TimelineEvent[] } | undefined>(undefined)
|
|
1846
|
+
const focusFound = Boolean(focusEvent && cleanEvents.some((event) => event.id === focusEvent.id))
|
|
1847
|
+
useEffect(() => {
|
|
1848
|
+
if (!focusEvent || focusedRequest.current === focusEvent || isLoading) return
|
|
1849
|
+
if (focusFound) {
|
|
1850
|
+
handleSelect(focusEvent.id)
|
|
1851
|
+
focusedRequest.current = focusEvent
|
|
1852
|
+
} else if (truncated && onLoadOlder && !loadingOlder && !olderError && !error) {
|
|
1853
|
+
// Strict Mode can replay this effect before the loading prop updates.
|
|
1854
|
+
if (requestedPage.current?.focus === focusEvent && requestedPage.current.events === events) return
|
|
1855
|
+
requestedPage.current = { focus: focusEvent, events }
|
|
1856
|
+
onLoadOlder()
|
|
1857
|
+
}
|
|
1858
|
+
}, [focusEvent, focusFound, events, isLoading, truncated, onLoadOlder, loadingOlder, olderError, error, handleSelect])
|
|
1859
|
+
|
|
1801
1860
|
if (isLoading) {
|
|
1802
1861
|
return (
|
|
1803
1862
|
<div className="flex items-center justify-center h-full text-theme-text-tertiary">
|
|
@@ -1827,6 +1886,15 @@ function EventsTab({
|
|
|
1827
1886
|
|
|
1828
1887
|
return (
|
|
1829
1888
|
<div className="h-full flex flex-col overflow-hidden">
|
|
1889
|
+
{focusEvent && !focusFound && (
|
|
1890
|
+
<div className="shrink-0 border-b border-theme-border px-4 py-2 text-xs text-theme-text-secondary" role="status">
|
|
1891
|
+
{olderError || error
|
|
1892
|
+
? "Couldn't find the reported error because some history couldn't be loaded."
|
|
1893
|
+
: truncated && onLoadOlder
|
|
1894
|
+
? 'Finding the reported error in older history…'
|
|
1895
|
+
: 'The reported error is not available in the retained timeline history.'}
|
|
1896
|
+
</div>
|
|
1897
|
+
)}
|
|
1830
1898
|
{error && (
|
|
1831
1899
|
<div className="shrink-0 flex flex-wrap items-center gap-x-2 border-b border-theme-border bg-theme-base px-4 py-1.5 text-xs" role="alert">
|
|
1832
1900
|
<span className="text-[var(--color-error)]" title={error.message}>
|
|
@@ -1932,6 +2000,7 @@ function InfoTab({
|
|
|
1932
2000
|
resource,
|
|
1933
2001
|
selectedResource,
|
|
1934
2002
|
relationships,
|
|
2003
|
+
trackedBy,
|
|
1935
2004
|
certificateInfo,
|
|
1936
2005
|
hpaDiagnosis,
|
|
1937
2006
|
scalerDiagnostics,
|
|
@@ -1968,6 +2037,7 @@ function InfoTab({
|
|
|
1968
2037
|
resource: any
|
|
1969
2038
|
selectedResource: SelectedResource
|
|
1970
2039
|
relationships?: Relationships
|
|
2040
|
+
trackedBy?: string
|
|
1971
2041
|
certificateInfo?: any
|
|
1972
2042
|
hpaDiagnosis?: HPADiagnosis
|
|
1973
2043
|
scalerDiagnostics?: ScalerDiagnosis[]
|
|
@@ -2022,6 +2092,7 @@ function InfoTab({
|
|
|
2022
2092
|
resource={selectedResource}
|
|
2023
2093
|
data={resource}
|
|
2024
2094
|
relationships={relationships}
|
|
2095
|
+
trackedBy={trackedBy}
|
|
2025
2096
|
certificateInfo={certificateInfo}
|
|
2026
2097
|
hpaDiagnosis={hpaDiagnosis}
|
|
2027
2098
|
scalerDiagnostics={scalerDiagnostics}
|
|
@@ -2252,7 +2323,7 @@ function WorkloadOverviewTab({
|
|
|
2252
2323
|
|
|
2253
2324
|
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
2254
2325
|
<div className="space-y-4">
|
|
2255
|
-
<div className="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|
|
2326
|
+
<div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|
|
2256
2327
|
<div className="space-y-4">
|
|
2257
2328
|
{showServingPath && (
|
|
2258
2329
|
<ServingConfigurationCard
|
|
@@ -2345,7 +2416,7 @@ function WorkloadOverviewTab({
|
|
|
2345
2416
|
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
2346
2417
|
<div className="space-y-4">
|
|
2347
2418
|
<CurrentStateCard resource={resource} apiKind={apiKind} readiness={readiness} />
|
|
2348
|
-
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
2419
|
+
<div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-2">
|
|
2349
2420
|
{overviewShape === 'cronjob' ? (
|
|
2350
2421
|
<CronJobRunsCard
|
|
2351
2422
|
resource={resource}
|
|
@@ -2385,7 +2456,7 @@ function WorkloadOverviewTab({
|
|
|
2385
2456
|
/>
|
|
2386
2457
|
) : null}
|
|
2387
2458
|
{extraContent && (
|
|
2388
|
-
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
2459
|
+
<div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-2">
|
|
2389
2460
|
{extraContent}
|
|
2390
2461
|
</div>
|
|
2391
2462
|
)}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { useAnimatedUnmount } from './useAnimatedUnmount'
|
|
2
2
|
export { useDebouncedValue } from './useDebouncedValue'
|
|
3
3
|
export { useRefreshAnimation } from './useRefreshAnimation'
|
|
4
|
+
export { useMediaQuery } from './useMediaQuery'
|
|
5
|
+
export { usePhoneLayout, PHONE_MEDIA_QUERY } from './usePhoneLayout'
|
|
4
6
|
export {
|
|
5
7
|
KeyboardShortcutProvider,
|
|
6
8
|
useRegisterShortcut,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The hook must leave a caller's own width constraints alone unless the menu
|
|
4
|
+
// can't fit, and put them back exactly once it fits again.
|
|
5
|
+
import { act, useRef } from 'react'
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import { afterEach, expect, it } from 'vitest'
|
|
8
|
+
import { useKeepInViewport } from './useKeepInViewport'
|
|
9
|
+
|
|
10
|
+
let root: Root | undefined
|
|
11
|
+
let host: HTMLDivElement | undefined
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
act(() => root?.unmount())
|
|
14
|
+
host?.remove()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function Menu({ tick }: { tick: number }) {
|
|
18
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
19
|
+
useKeepInViewport(ref, true)
|
|
20
|
+
return <div ref={ref} data-tick={tick} style={{ minWidth: 240 }} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setMenuWidth(el: HTMLElement, width: number) {
|
|
24
|
+
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width })
|
|
25
|
+
el.getBoundingClientRect = () => ({ left: 0, right: width, top: 0, bottom: 0, width, height: 0, x: 0, y: 0, toJSON: () => ({}) })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function render(tick: number) {
|
|
29
|
+
act(() => root!.render(<Menu tick={tick} />))
|
|
30
|
+
return host!.querySelector('div')!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('keeps the caller min-width when the menu fits, and restores it after a squeeze', () => {
|
|
34
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
|
|
35
|
+
host = document.createElement('div')
|
|
36
|
+
document.body.appendChild(host)
|
|
37
|
+
root = createRoot(host)
|
|
38
|
+
let el = render(1)
|
|
39
|
+
setMenuWidth(el, 260)
|
|
40
|
+
el = render(2)
|
|
41
|
+
expect(el.style.minWidth).toBe('240px')
|
|
42
|
+
expect(el.style.maxWidth).toBe('')
|
|
43
|
+
|
|
44
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 200 })
|
|
45
|
+
el = render(3)
|
|
46
|
+
expect(el.style.minWidth).toBe('0px')
|
|
47
|
+
expect(el.style.maxWidth).toBe('184px')
|
|
48
|
+
|
|
49
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
|
|
50
|
+
el = render(4)
|
|
51
|
+
expect(el.style.minWidth).toBe('240px')
|
|
52
|
+
expect(el.style.maxWidth).toBe('')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('measures with transitions off and restores the caller transition', () => {
|
|
56
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
|
|
57
|
+
host = document.createElement('div')
|
|
58
|
+
document.body.appendChild(host)
|
|
59
|
+
root = createRoot(host)
|
|
60
|
+
let el = render(1)
|
|
61
|
+
el.style.transition = 'opacity 100ms'
|
|
62
|
+
const seen: string[] = []
|
|
63
|
+
el.getBoundingClientRect = () => {
|
|
64
|
+
seen.push(el.style.transition)
|
|
65
|
+
return { left: 410, right: 650, top: 0, bottom: 0, width: 240, height: 0, x: 410, y: 0, toJSON: () => ({}) }
|
|
66
|
+
}
|
|
67
|
+
el = render(2)
|
|
68
|
+
expect(seen.length).toBeGreaterThan(0)
|
|
69
|
+
expect(seen.every((t) => t === 'none')).toBe(true)
|
|
70
|
+
expect(el.style.transition).toBe('opacity 100ms')
|
|
71
|
+
expect(el.style.translate).toBe('')
|
|
72
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { keepInViewportShift } from './useKeepInViewport'
|
|
3
|
+
|
|
4
|
+
describe('keepInViewportShift', () => {
|
|
5
|
+
it('leaves a menu that fits alone', () => {
|
|
6
|
+
expect(keepInViewportShift(56, 336, 8, 1272)).toBe(0)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('pulls a menu past the right edge back inside', () => {
|
|
10
|
+
// A 280px menu opened at x=56 on a 320px phone.
|
|
11
|
+
expect(keepInViewportShift(56, 336, 8, 312)).toBe(-24)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('stops at a clipping ancestor, not the viewport edge', () => {
|
|
15
|
+
// Content column starts after a 56px rail: the left bound is 64, not 8,
|
|
16
|
+
// so a menu sitting under the rail moves out from behind it.
|
|
17
|
+
expect(keepInViewportShift(40, 240, 64, 312)).toBe(24)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps the left edge when the menu is wider than the bounds', () => {
|
|
21
|
+
expect(keepInViewportShift(20, 420, 8, 312)).toBe(-12)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('pushes a menu past the left edge back in', () => {
|
|
25
|
+
expect(keepInViewportShift(-30, 200, 8, 382)).toBe(38)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useLayoutEffect, useEffect, type RefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Keeps an open menu or popover horizontally inside the visible area: the
|
|
4
|
+
* viewport, narrowed by any ancestor that clips horizontally (a content
|
|
5
|
+
* column beside a nav rail clips what slides under the rail). Anchored menus
|
|
6
|
+
* open at their trigger's edge with a fixed minimum width, so on a narrow
|
|
7
|
+
* screen a trigger near the edge pushes the menu out of view. After each
|
|
8
|
+
* layout, and on resize while open, this shifts the element back with the
|
|
9
|
+
* `translate` property (which moves it whichever side it's anchored to), and
|
|
10
|
+
* narrows it only if it can't fit. Don't use it on an element whose own
|
|
11
|
+
* motion animates `translate`: the inline value would override it.
|
|
12
|
+
*
|
|
13
|
+
* Runs after every render while `active`, so a menu whose content changes
|
|
14
|
+
* width (a filter narrowing a list) is re-measured. */
|
|
15
|
+
export function useKeepInViewport(ref: RefObject<HTMLElement | null>, active: boolean, margin = 8): void {
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
if (active) keepInView(ref.current, margin)
|
|
18
|
+
})
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!active) return
|
|
21
|
+
const onResize = () => keepInView(ref.current, margin)
|
|
22
|
+
window.addEventListener('resize', onResize)
|
|
23
|
+
return () => window.removeEventListener('resize', onResize)
|
|
24
|
+
}, [ref, active, margin])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Width overrides this hook applied, keyed by element, with the inline values
|
|
28
|
+
// they replaced, so a later measurement can put back exactly what the caller set
|
|
29
|
+
// (React won't re-apply an inline style whose value it thinks is unchanged).
|
|
30
|
+
const sizedBy = new WeakMap<HTMLElement, { minWidth: string; maxWidth: string }>()
|
|
31
|
+
|
|
32
|
+
function keepInView(el: HTMLElement | null, margin: number): void {
|
|
33
|
+
if (!el) return
|
|
34
|
+
// Measure and move with transitions off. Under reduced motion every
|
|
35
|
+
// element gets a near-zero transition on all properties, and a transitioned
|
|
36
|
+
// element reports its pre-change box in the same frame: a menu just moved
|
|
37
|
+
// from left 0 to its trigger still measured at 0, and was pushed off its
|
|
38
|
+
// trigger by the margin.
|
|
39
|
+
const transition = el.style.transition
|
|
40
|
+
el.style.transition = 'none'
|
|
41
|
+
place(el, margin)
|
|
42
|
+
el.getBoundingClientRect() // apply the move before transitions return
|
|
43
|
+
el.style.transition = transition
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function place(el: HTMLElement, margin: number): void {
|
|
47
|
+
el.style.translate = ''
|
|
48
|
+
const saved = sizedBy.get(el)
|
|
49
|
+
if (saved) {
|
|
50
|
+
el.style.minWidth = saved.minWidth
|
|
51
|
+
el.style.maxWidth = saved.maxWidth
|
|
52
|
+
sizedBy.delete(el)
|
|
53
|
+
}
|
|
54
|
+
const [boundLeft, boundRight] = visibleBounds(el, margin)
|
|
55
|
+
const room = boundRight - boundLeft
|
|
56
|
+
// Only a menu that can't fit gets resized; otherwise the caller's own
|
|
57
|
+
// minimum and maximum widths stand.
|
|
58
|
+
if (el.offsetWidth > room) {
|
|
59
|
+
sizedBy.set(el, { minWidth: el.style.minWidth, maxWidth: el.style.maxWidth })
|
|
60
|
+
el.style.minWidth = '0'
|
|
61
|
+
el.style.maxWidth = `${room}px`
|
|
62
|
+
}
|
|
63
|
+
const { left, right } = el.getBoundingClientRect()
|
|
64
|
+
const shift = keepInViewportShift(left, right, boundLeft, boundRight)
|
|
65
|
+
if (shift !== 0) el.style.translate = `${shift}px 0`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function visibleBounds(el: HTMLElement, margin: number): [number, number] {
|
|
69
|
+
let left = margin
|
|
70
|
+
let right = document.documentElement.clientWidth - margin
|
|
71
|
+
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
|
|
72
|
+
if (getComputedStyle(p).overflowX === 'visible') continue
|
|
73
|
+
const r = p.getBoundingClientRect()
|
|
74
|
+
left = Math.max(left, r.left + margin)
|
|
75
|
+
right = Math.min(right, r.right - margin)
|
|
76
|
+
}
|
|
77
|
+
return [left, Math.max(left, right)]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Horizontal shift (px) that brings [left, right] inside [boundLeft,
|
|
81
|
+
* boundRight], preferring the left edge when the element is wider. */
|
|
82
|
+
export function keepInViewportShift(left: number, right: number, boundLeft: number, boundRight: number): number {
|
|
83
|
+
let shift = 0
|
|
84
|
+
if (right > boundRight) shift = boundRight - right
|
|
85
|
+
if (left + shift < boundLeft) shift = boundLeft - left
|
|
86
|
+
return shift
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Subscribe to a CSS media query. SSR-safe (returns false before mount) and
|
|
4
|
+
// updates on viewport changes.
|
|
5
|
+
export function useMediaQuery(query: string): boolean {
|
|
6
|
+
const [matches, setMatches] = useState(() => {
|
|
7
|
+
if (typeof window === 'undefined' || !window.matchMedia) return false
|
|
8
|
+
return window.matchMedia(query).matches
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (typeof window === 'undefined' || !window.matchMedia) return
|
|
13
|
+
const mql = window.matchMedia(query)
|
|
14
|
+
const onChange = () => setMatches(mql.matches)
|
|
15
|
+
onChange()
|
|
16
|
+
mql.addEventListener('change', onChange)
|
|
17
|
+
return () => mql.removeEventListener('change', onChange)
|
|
18
|
+
}, [query])
|
|
19
|
+
|
|
20
|
+
return matches
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useMediaQuery } from './useMediaQuery'
|
|
2
|
+
|
|
3
|
+
// A phone: a narrow screen whose primary pointer is touch. A desktop browser
|
|
4
|
+
// narrowed below this width keeps the desktop layout (and its content floor),
|
|
5
|
+
// since a wider window can show the full UI; browser dev-tools device mode
|
|
6
|
+
// emulates touch and gets the phone layout. Keep in sync with the `phone:`
|
|
7
|
+
// variant in theme/tailwind-theme.css.
|
|
8
|
+
export const PHONE_MEDIA_QUERY = '(max-width: 639px) and (pointer: coarse)'
|
|
9
|
+
|
|
10
|
+
export function usePhoneLayout(): boolean {
|
|
11
|
+
return useMediaQuery(PHONE_MEDIA_QUERY)
|
|
12
|
+
}
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* Import after variables.css.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
/* `phone:` styles apply on a narrow touch-first screen only (see
|
|
10
|
+
* PHONE_MEDIA_QUERY in hooks/usePhoneLayout.ts). A narrowed desktop window
|
|
11
|
+
* keeps the desktop layout. */
|
|
12
|
+
@custom-variant phone (@media (max-width: 639px) and (pointer: coarse));
|
|
13
|
+
|
|
9
14
|
@theme inline {
|
|
10
15
|
/* Backgrounds */
|
|
11
16
|
--color-theme-sidebar: var(--bg-sidebar);
|