Skip to content

Commit 4037d8c

Browse files
fix(spa): banner contrast, Layout Reset, empty-inline collapse (#589, #590, #591)
Three SPA polish bugs / refactors reported against v1.3.0, all addressed in one patch release. #589 — experience-toggle strip link contrast collapses in dark mode ------------------------------------------------------------------- The strip's link used `hover:text-gray-900`, which in light mode is near-black on the muted `bg-gray-50` strip — readable. In dark mode, `bg-gray-50` remaps to #0b1020 (near-black) but `hover:text-gray-900` had no `.dark` remap, so on :hover the text resolved to its raw Tailwind value (#111827) — essentially the same near-black as the strip background. Result: text faded INTO the bg on hover, the opposite of normal hover affordance. Fixes: - `frontend/apps/web/src/index.css` adds `.dark` remaps for `hover:text-gray-{700,800,900}` (→ #f9fafb) and `hover:text-gray- {100,200}` (→ #ffffff). Future components using those JSX-side hover variants get the right :hover contrast automatically. - `django_admin_react/templates/django_admin_react/_experience_toggle_strip.html` on the legacy Django admin side switches the link from `color: inherit` (no hover state) to an explicit two-token model: `var(--link-fg, #1f2937)` at rest, `var(--link-hover-color, #000)` on :hover — these are Django admin's own theme variables (defined in `admin/css/base.css` from 4.2+), so Django's bundled light and dark themes both light the link correctly. A small inline `<style>` keeps the strip with zero external CSS dependencies. #590 — Reset button on Layout modal + shared <ResetButton> primitive -------------------------------------------------------------------- The Customize / Layout modal (post-#586) had no way to return to the ModelAdmin's default order + visibility. Manual drag every row back or open devtools and clear localStorage. Filter row's "Clear all" solved the same UX gap for filters but with a different inline implementation — two impls of the same primitive. - New `frontend/packages/ui/src/ResetButton.tsx` — generic return-to-defaults button. Takes `isDirty`, `onReset` (sync OR async — pending state for promise-returning callbacks), `label`, `disabledHint`, optional `icon` ReactNode, optional `trailing` ReactNode. Stays icon-agnostic (the caller passes the icon as a prop) so @dar/ui doesn't pick up a lucide-react dep — matches the rest of the package's "primitives without an icon library" contract. - `frontend/apps/web/src/pages/ListPage.tsx` "Clear all" button refactored to use <ResetButton label="Clear all" icon={<X.../>}>. Disabled (not hidden) when no filters apply, with a discoverable tooltip "No filters applied" — the affordance stays visible so users learn it exists before they need it. - `frontend/apps/web/src/ColumnLayoutModal.tsx` adds a Reset button in the modal header alongside the description. `isDirty` checks whether the user has a non-empty saved order OR any hidden col; resetting clears both back to empty so the ModelAdmin's registered defaults take over. #591 — empty inlines collapse-or-hide on the detail page -------------------------------------------------------- A typical detail page registers multiple inlines (comments, history, attachments, audit). On a freshly-created object none have records yet — the page becomes a tall stack of empty "No X yet" panels with no information. Today an empty + non-addable inline was already hidden entirely (Option A). What was missing was Option B for empty + addable inlines: instead of rendering the full "No X yet" panel, render just the title bar with a caret toggle. The body (the "No X yet — click Edit to add the first one" hint) only shows when the operator expands the section. - New `CollapsedEmptyInline` helper in `frontend/apps/web/src/pages/DetailPage.tsx` mirrors the `FieldsetSection` collapse pattern (Card + ChevronDown toggle). - `InlineSection` early-return upgraded: - Empty + !can_add → return null (unchanged Option A). - Empty + can_add → <CollapsedEmptyInline> (new Option B). - Non-empty → render normally (unchanged). - Default-collapsed every page load — per-user persistence of which empty inlines are expanded isn't worth the storage for a hint copy. The "click Edit" affordance below makes the next step obvious without enumerating the inline's empty schema. Bundle impact ------------- - Main bundle: 263.79 → 265.02 kB (+1.2 kB raw / +0.3 kB gz) for the <ResetButton> primitive + the two consumer call sites. - CSS: 30.66 → 30.83 kB (+170 B raw) for the dark hover remaps. - ColumnLayoutModal lazy chunk: +0.4 kB (the Reset button + its RotateCcw icon import). No API contract change, no schema change, no settings change. Patch bump (1.3.0 → 1.3.1). Closes #589, Closes #590, Closes #591. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1439abf commit 4037d8c

8 files changed

Lines changed: 242 additions & 34 deletions

File tree

django_admin_react/templates/django_admin_react/_experience_toggle_strip.html

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,37 @@
55
rather than a new admin CSS file so the strip ships without any
66
staticfiles requirement on the consumer's side.
77

8+
Theme awareness (#589): the link uses CSS custom properties that fall
9+
back to a legible-everywhere palette but get overridden by Django
10+
admin's own theme variables (`--body-fg`, `--link-fg`,
11+
`--link-hover-color`) where present. This keeps contrast WCAG-AA on
12+
both Django's default light theme and the dark-mode toggle Django
13+
added in 4.2 — hover *brightens* the link, never dims it.
14+
815
When `visible` is false (either prefix unset, request missing, or
916
current path outside the legacy mount), this template renders
1017
nothing — the surrounding {% block %} stays clean.
1118
{% endcomment %}
1219
{% if visible %}
1320
<div role="region" aria-label="Experience toggle"
14-
style="padding:.25rem .75rem; border-bottom:1px solid #e5e7eb;
15-
background:#f9fafb; font-size:.75rem; color:#4b5563;">
21+
style="padding:.25rem .75rem; border-bottom:1px solid var(--border-color, #e5e7eb);
22+
background:var(--darkened-bg, #f9fafb); font-size:.75rem;
23+
color:var(--body-fg, #4b5563);">
1624
<a href="{{ target }}" target="_self" rel="noopener"
17-
style="color:inherit; text-decoration:none;">
25+
class="dar-experience-toggle-link"
26+
style="color:var(--link-fg, #1f2937); text-decoration:underline;
27+
text-underline-offset:2px;">
1828
Looking for the new admin experience?
1929
<strong style="font-weight:600;">Open this page in {{ react_root }}</strong>
2030
</a>
2131
</div>
32+
{# Inline :hover via <style> so we keep zero external CSS dependencies
33+
while still meeting the "hover brightens, never dims" contract (#589). #}
34+
<style>
35+
.dar-experience-toggle-link:hover,
36+
.dar-experience-toggle-link:focus-visible {
37+
color: var(--link-hover-color, #000000);
38+
text-decoration-thickness: 2px;
39+
}
40+
</style>
2241
{% endif %}

frontend/apps/web/src/ColumnLayoutModal.tsx

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
// rendered without a handle, with a small lock icon and a title=
1818
// tooltip, so the operator can see WHY it's not draggable.
1919

20-
import { GripVertical, Lock } from 'lucide-react';
20+
import { GripVertical, Lock, RotateCcw } from 'lucide-react';
2121
import {
2222
DndContext,
2323
KeyboardSensor,
@@ -36,7 +36,7 @@ import {
3636
} from '@dnd-kit/sortable';
3737
import { CSS } from '@dnd-kit/utilities';
3838

39-
import { Checkbox, Modal } from '@dar/ui';
39+
import { Checkbox, Modal, ResetButton } from '@dar/ui';
4040

4141
export interface ColumnLayoutDescriptor {
4242
name: string;
@@ -58,6 +58,12 @@ export interface ColumnLayoutModalProps {
5858
onToggle: (name: string, visibleCount: number) => void;
5959
/** Persist the new ordering of the non-pk columns. */
6060
onReorder: (nextNonPkOrder: string[]) => void;
61+
/** Is the column layout currently customised vs the registered
62+
* `ModelAdmin` default? Drives the Reset button's enabled state. */
63+
isCustomised: boolean;
64+
/** Discard any per-user reorder + hide preferences and fall back to
65+
* the registered `ModelAdmin` default (#590). */
66+
onReset: () => void;
6167
}
6268

6369
// Default export to make `React.lazy(() => import('./ColumnLayoutModal'))`
@@ -71,6 +77,8 @@ export default function ColumnLayoutModal({
7177
visibleColumnCount,
7278
onToggle,
7379
onReorder,
80+
isCustomised,
81+
onReset,
7482
}: ColumnLayoutModalProps) {
7583
const sensors = useSensors(
7684
useSensor(PointerSensor, {
@@ -98,10 +106,24 @@ export default function ColumnLayoutModal({
98106

99107
return (
100108
<Modal title="Layout" onClose={onClose}>
101-
<p className="mb-2 text-xs text-gray-500">
102-
Toggle to show or hide. Drag the handle to reorder (keyboard:
103-
Tab to a handle, then Space + arrow keys + Space to drop).
104-
</p>
109+
<div className="mb-2 flex items-center justify-between gap-2">
110+
<p className="text-xs text-gray-500">
111+
Toggle to show or hide. Drag the handle to reorder (keyboard:
112+
Tab to a handle, then Space + arrow keys + Space to drop).
113+
</p>
114+
{/* Reset (#590): discards the per-user layout and falls back
115+
to the registered ModelAdmin default. Disabled when the
116+
layout is already at default — the tooltip explains why
117+
so the affordance stays discoverable. */}
118+
<ResetButton
119+
isDirty={isCustomised}
120+
onReset={onReset}
121+
label="Reset"
122+
disabledHint="Layout is already at default"
123+
icon={<RotateCcw className="h-4 w-4" aria-hidden />}
124+
title="Reset to the default layout"
125+
/>
126+
</div>
105127
<ul className="space-y-1">
106128
{pkRow ? (
107129
<LockedRow

frontend/apps/web/src/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ body,
8383
.dark .hover\:bg-gray-100:hover {
8484
background-color: #1f2937 !important;
8585
}
86+
/* Hover text-color remaps (#589). Without these, a JSX-side
87+
`hover:text-gray-900` (designed against the light surface) resolves
88+
to #111827 on :hover under .dark — which is nearly identical to the
89+
.dark surface itself, so the text fades INTO the background on
90+
hover. Remap the few hover text utilities the app uses so :hover
91+
always *increases* contrast against the dark surface, never reduces. */
92+
.dark .hover\:text-gray-900:hover,
93+
.dark .hover\:text-gray-800:hover,
94+
.dark .hover\:text-gray-700:hover {
95+
color: #f9fafb !important;
96+
}
97+
.dark .hover\:text-gray-200:hover,
98+
.dark .hover\:text-gray-100:hover {
99+
color: #ffffff !important;
100+
}
86101
/* Form controls have no explicit bg utility (the @dar/ui Input + the
87102
filter selects render on the browser-default white), so the bg-white
88103
remap above misses them — they'd stay white in dark mode. Style them

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -905,17 +905,48 @@ function DeleteButton({ label, loadPreview, onConfirm }: DeleteButtonProps) {
905905
);
906906
}
907907

908+
// CollapsedEmptyInline (#591) — a slim card showing only the inline's
909+
// title + a caret toggle. The body (the "No X yet" copy + a hint to
910+
// enter edit mode) is hidden by default; clicking the title expands
911+
// it. Default-collapsed every page load — per-user persistence isn't
912+
// worth the storage for a detail-page hint.
913+
function CollapsedEmptyInline({ label }: { label: string }) {
914+
const [open, setOpen] = useState(false);
915+
return (
916+
<Card>
917+
<button
918+
type="button"
919+
onClick={() => setOpen((o) => !o)}
920+
aria-expanded={open}
921+
className="flex w-full items-center justify-between gap-2 text-left text-base font-semibold text-gray-900"
922+
>
923+
<span>{label}</span>
924+
<ChevronDown
925+
className={`h-4 w-4 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
926+
aria-hidden
927+
/>
928+
</button>
929+
{open ? (
930+
<p className="mt-3 text-sm text-gray-500">
931+
No {label.toLowerCase()} yet. Click <span className="font-medium">Edit</span> to
932+
add the first one.
933+
</p>
934+
) : null}
935+
</Card>
936+
);
937+
}
938+
908939
function InlineSection({ inline }: { inline: InlineDescriptor }) {
940+
// Empty inline (#591):
941+
// - Not addable → hide the whole section (Option A). Empty + read-only
942+
// has zero information value and just lengthens the page.
943+
// - Addable → render as a single-line collapsed card with a caret
944+
// (Option B). The operator can see the inline EXISTS (so they
945+
// know to click Edit to add a first child) but the "No X yet"
946+
// placeholder no longer eats vertical space on every load.
909947
if (inline.rows.length === 0) {
910-
// An empty inline the user can't add to is pure noise on the detail
911-
// page (#411) — hide the whole section. An empty but *addable* inline
912-
// stays, so the first child can still be added.
913948
if (!inline.can_add) return null;
914-
return (
915-
<Card title={inline.label}>
916-
<p className="py-4 text-sm text-gray-500">No {inline.label.toLowerCase()} yet.</p>
917-
</Card>
918-
);
949+
return <CollapsedEmptyInline label={inline.label} />;
919950
}
920951

921952
// Per-row link to the child's own change page (#384 — Django's

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
Pagination,
3030
Popover,
3131
RecordCardList,
32+
ResetButton,
3233
Table,
3334
useMediaQuery,
3435
} from '@dar/ui';
@@ -579,23 +580,23 @@ export function ListPage() {
579580
) : null
580581
}
581582
trailing={
582-
// Filter-row trailing slot (#554): "Clear all" (only when at
583-
// least one filter is selected — nothing to clear, no button)
584-
// and "Customize" (always visible — it's the column-customizer
585-
// affordance, independent of filter state) as the last two
586-
// buttons on the row, in that order.
583+
// Filter-row trailing slot (#554): "Clear all" + "Customize"
584+
// as the last two buttons on the row, in that order.
585+
// "Clear all" is the shared <ResetButton> primitive from
586+
// @dar/ui (#590) — same component the Layout modal uses for
587+
// its "Reset" affordance, with the label + icon overridden
588+
// for filter-row muscle memory. Renders disabled (not
589+
// hidden) when no filters are applied so the affordance
590+
// stays discoverable.
587591
<>
588-
{activeFilterCount > 0 && (
589-
<button
590-
type="button"
591-
onClick={() => patchParams((next) => filters.forEach((f) => next.delete(f.name)))}
592-
title="Clear all filters"
593-
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
594-
>
595-
<X className="h-4 w-4" aria-hidden />
596-
Clear all
597-
</button>
598-
)}
592+
<ResetButton
593+
isDirty={activeFilterCount > 0}
594+
onReset={() => patchParams((next) => filters.forEach((f) => next.delete(f.name)))}
595+
label="Clear all"
596+
disabledHint="No filters applied"
597+
icon={<X className="h-4 w-4" aria-hidden />}
598+
title="Clear all filters"
599+
/>
599600
<button
600601
type="button"
601602
onClick={() => setColsOpen(true)}
@@ -792,6 +793,23 @@ export function ListPage() {
792793
visibleColumnCount={visibleColumnCount}
793794
onToggle={toggleColumn}
794795
onReorder={setColOrder}
796+
// The layout is "customised" when either preference has
797+
// any state — a non-empty saved order or any hidden col.
798+
// Both persist via localStorage (`dar:colorder:v1:*` and
799+
// the columnsKey set); resetting drops both back to the
800+
// empty / default state so the ModelAdmin's registered
801+
// order + visibility take over again on the next render
802+
// (#590).
803+
isCustomised={colOrder.length > 0 || hiddenCols.size > 0}
804+
onReset={() => {
805+
// `usePersistedSet`'s setter takes a functional updater,
806+
// not the raw next-value (unlike `useState`); returning a
807+
// fresh empty Set clears every saved-hidden entry. The
808+
// `colOrder` setter from `usePersistedState` is the
809+
// plain useState variant, so it accepts the raw next.
810+
setColOrder([]);
811+
setHiddenCols(() => new Set<string>());
812+
}}
795813
/>
796814
</Suspense>
797815
)}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// ResetButton (#590) — shared "return to defaults" affordance for the
2+
// list-page filter row ("Clear all") and the Column-Layout modal
3+
// ("Reset"). Both call sites want the same UX primitive: a ghost-styled
4+
// button with optional leading icon, a pending state, and a disabled
5+
// tooltip when there is nothing to reset.
6+
//
7+
// Lives in @dar/ui so future "reset" surfaces (saved searches, sidebar
8+
// customizations, etc.) inherit the same visual + a11y contract by
9+
// construction. The label and icon are configurable per call site so
10+
// the filter row can keep saying "Clear all" with the `X` icon while
11+
// the modal says "Reset" with the curved-arrow icon.
12+
//
13+
// Icon-agnostic: callers pass the icon node themselves so @dar/ui
14+
// stays free of an icon-library dep (matches the rest of the package).
15+
16+
import { cloneElement, isValidElement, useState, type ReactNode } from 'react';
17+
18+
export interface ResetButtonProps {
19+
/** Is the underlying state actually customised? Disables the button
20+
* with a tooltip when false so the affordance stays discoverable. */
21+
isDirty: boolean;
22+
/** Run the reset. Promise-returning calls show a pending state; a
23+
* synchronous return runs through the same code path and the
24+
* finally branch flushes instantly. */
25+
onReset: () => Promise<void> | void;
26+
/** Visible label. Default "Reset". */
27+
label?: string;
28+
/** Tooltip shown when `isDirty` is false. */
29+
disabledHint?: string;
30+
/** Leading icon. If a React element that takes `className`, the
31+
* button passes `animate-spin` while a promise-returning onReset
32+
* is in flight (the lucide convention). Pass `null` to opt out. */
33+
icon?: ReactNode;
34+
/** Extra classes — kept narrow so the visual contract stays uniform. */
35+
className?: string;
36+
/** `title` for the trigger button when enabled. */
37+
title?: string;
38+
/** Optional content rendered after the label (e.g. a count chip). */
39+
trailing?: ReactNode;
40+
}
41+
42+
export function ResetButton({
43+
isDirty,
44+
onReset,
45+
label = 'Reset',
46+
disabledHint = 'Already at default',
47+
icon = null,
48+
className,
49+
title,
50+
trailing,
51+
}: ResetButtonProps) {
52+
const [pending, setPending] = useState(false);
53+
const disabled = !isDirty || pending;
54+
55+
async function run(): Promise<void> {
56+
if (disabled) return;
57+
setPending(true);
58+
try {
59+
await onReset();
60+
} finally {
61+
setPending(false);
62+
}
63+
}
64+
65+
const tooltip = disabled && !pending ? disabledHint : title;
66+
67+
// If the caller's icon is a valid element that accepts a `className`,
68+
// augment it with `animate-spin` during a pending promise — the
69+
// lucide-react / heroicons convention. Bail out for non-elements
70+
// (strings, fragments, null).
71+
const renderedIcon =
72+
pending && isValidElement<{ className?: string }>(icon)
73+
? cloneElement(icon, {
74+
className: `${icon.props.className ?? ''} animate-spin`.trim(),
75+
})
76+
: icon;
77+
78+
return (
79+
<button
80+
type="button"
81+
onClick={() => {
82+
void run();
83+
}}
84+
disabled={disabled}
85+
title={tooltip}
86+
className={[
87+
'inline-flex shrink-0 items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm',
88+
disabled
89+
? 'cursor-not-allowed text-gray-400'
90+
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
91+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
92+
className ?? '',
93+
].join(' ')}
94+
>
95+
{renderedIcon}
96+
<span>{pending ? `${label}…` : label}</span>
97+
{trailing}
98+
</button>
99+
);
100+
}

frontend/packages/ui/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ export type { BreadcrumbItem, BreadcrumbProps } from './Breadcrumb';
4545

4646
export { Popover } from './Popover';
4747
export type { PopoverProps } from './Popover';
48+
49+
export { ResetButton } from './ResetButton';
50+
export type { ResetButtonProps } from './ResetButton';

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.3.0"
3+
version = "1.3.1"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"

0 commit comments

Comments
 (0)